PyPtt 2.3.2__tar.gz → 2.3.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyptt-2.3.2 → pyptt-2.3.5}/PKG-INFO +1 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/__init__.py +1 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_change_pw.py +6 -5
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_del_post.py +21 -5
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_give_money.py +1 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_loginout.py +3 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_mail.py +14 -3
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_post.py +16 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_reply_post.py +13 -1
- pyptt-2.3.5/PyPtt/_api_set_board_title.py +75 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/connect_core.py +15 -16
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_del_post_unit.py +11 -11
- pyptt-2.3.2/PyPtt/_api_set_board_title.py +0 -48
- {pyptt-2.3.2 → pyptt-2.3.5}/LICENSE +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/PTT.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_set_signature_file.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_api_util.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_uao/__init__.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/api_server.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/check_value.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/command.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/config.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/data_type.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/exceptions.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/i18n.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/lib_util.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/log.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/screens.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/service.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt.egg-info/SOURCES.txt +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/README.md +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/pyproject.toml +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/setup.cfg +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/setup.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_backup_effective.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_backup_param.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_bucket.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_change_pw.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_comment.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_connect_core_timeout.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_del_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_exceptions.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_fast_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_board_info.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_board_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_mail.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_post_list.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_time.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_user.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_get_waterball.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_give_money.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_half_dead_connection.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_i18n.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_incremental_parity.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_init.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_logger.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_login_logout.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_mark_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_reply_post.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_search_user.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_service.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_set_board_title.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_set_signature_file.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_uao_codec.py +0 -0
- {pyptt-2.3.2 → pyptt-2.3.5}/tests/test_vt100_parser.py +0 -0
|
@@ -13,8 +13,6 @@ def change_pw(api, new_password: str) -> None:
|
|
|
13
13
|
|
|
14
14
|
log.logger.info(i18n.change_pw)
|
|
15
15
|
|
|
16
|
-
new_password = new_password[:8]
|
|
17
|
-
|
|
18
16
|
cmd_list = []
|
|
19
17
|
cmd_list.append(command.go_main_menu)
|
|
20
18
|
cmd_list.append('U')
|
|
@@ -29,9 +27,12 @@ def change_pw(api, new_password: str) -> None:
|
|
|
29
27
|
connect_core.TargetUnit('設定聯絡信箱後才能修改密碼', exceptions_=exceptions.SetContactMailFirst()),
|
|
30
28
|
connect_core.TargetUnit('您輸入的密碼不正確', exceptions_=exceptions.WrongPassword()),
|
|
31
29
|
connect_core.TargetUnit('請您確定(Y/N)?', response='Y' + command.enter),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
# 只比對「新密碼」/「原密碼」, 不含前後綴與冒號: 正式站目前是「請設定新密碼:」
|
|
31
|
+
# 「請檢查新密碼:」「請輸入原密碼:」(全形冒號), pttbbs #112 之後上游改成兩步同名的
|
|
32
|
+
# 「新密碼:」(半形), 兩種寫法都涵蓋。新密碼要吃設定與確認兩次, 故 max_match=2。
|
|
33
|
+
# 「新密碼」必須排在「原密碼」前面: 新版輸入完原密碼後該行仍留在畫面上, 兩者會同時出現。
|
|
34
|
+
connect_core.TargetUnit('新密碼', response=new_password + command.enter, max_match=2, secret=True),
|
|
35
|
+
connect_core.TargetUnit('原密碼', response=api._ptt_pw + command.enter, max_match=1, secret=True),
|
|
35
36
|
connect_core.TargetUnit('設定個人資料與密碼', break_detect=True)
|
|
36
37
|
]
|
|
37
38
|
|
|
@@ -15,7 +15,12 @@ from . import screens
|
|
|
15
15
|
|
|
16
16
|
# pttbbs input fields cap length in bytes (Big5: 2 bytes per Chinese
|
|
17
17
|
# character), not in Python characters.
|
|
18
|
-
|
|
18
|
+
# mbbsd/assess.c:71 getdata(..., reason, 50, DOECHO) -> len=50 -> vtuikit.c's
|
|
19
|
+
# getdata() only accepts len-1 bytes, and vkey_purge() (vtuikit.c:1409-1415)
|
|
20
|
+
# clears the whole input queue if the last accepted byte lands at iend ==
|
|
21
|
+
# len-2 with a Big5 lead byte (>0x80) still queued right after it -- so the
|
|
22
|
+
# safe cap is len-2, not len-1.
|
|
23
|
+
_BAD_POST_REASON_MAX_BYTES = 48
|
|
19
24
|
|
|
20
25
|
# --- del_post's `reason` field (annotates the title shown after a
|
|
21
26
|
# moderator deletes another user's post) ---
|
|
@@ -24,7 +29,8 @@ _BAD_POST_REASON_MAX_BYTES = 50
|
|
|
24
29
|
# (pttbbs mbbsd/bbs.c, del_post()'s SAFE_ARTICLE_DELETE branch) in a
|
|
25
30
|
# `char reason[PROPER_TITLE_LEN]` buffer; PROPER_TITLE_LEN is 42
|
|
26
31
|
# (pttbbs include/common.h), so the whole title -- prefix *and* PyPtt's
|
|
27
|
-
# reason together -- is capped at
|
|
32
|
+
# reason together -- is capped at 40 Big5 bytes, not python characters
|
|
33
|
+
# (len-2, see the vkey_purge() mechanism below).
|
|
28
34
|
# PyPtt answers the prompt with Ctrl-E (jump to the end of the prefilled
|
|
29
35
|
# text) plus a leading space plus `reason`, so the byte budget actually
|
|
30
36
|
# left for `reason` depends on how long the moderator's and the post
|
|
@@ -34,8 +40,11 @@ _BAD_POST_REASON_MAX_BYTES = 50
|
|
|
34
40
|
# Verified live against a local pttbbs (bbsdocker/imageptt rebuilt with
|
|
35
41
|
# SAFE_ARTICLE_DELETE enabled -- the stock image ships with that feature
|
|
36
42
|
# #define'd out and never offers this prompt at all):
|
|
37
|
-
# -
|
|
38
|
-
# truncated.
|
|
43
|
+
# - within the safe budget: reason is accepted in full, title not
|
|
44
|
+
# truncated. (One prior run that landed exactly on the old,
|
|
45
|
+
# off-by-one budget only happened not to trip the bug below because
|
|
46
|
+
# the overflow byte's low bit was < 0x80 -- that was luck, not a
|
|
47
|
+
# safe boundary; see the mechanism below for why.)
|
|
39
48
|
# - 1 byte over budget, ASCII-only overflow: pttbbs silently truncates
|
|
40
49
|
# to fit (title is just missing the last character).
|
|
41
50
|
# - 1 byte over budget, where the overflow splits a double-byte Big5
|
|
@@ -44,7 +53,14 @@ _BAD_POST_REASON_MAX_BYTES = 50
|
|
|
44
53
|
# a clean failure -- this is the "刪文失敗" a caller with a too-long
|
|
45
54
|
# reason actually experiences.
|
|
46
55
|
# PyPtt must reject before sending in both cases.
|
|
47
|
-
|
|
56
|
+
#
|
|
57
|
+
# mbbsd/bbs.c:3311,3387 getdata(..., reason, PROPER_TITLE_LEN, ...);
|
|
58
|
+
# include/common.h:193 PROPER_TITLE_LEN 42 -> len=42 -> vtuikit.c's
|
|
59
|
+
# getdata() only accepts len-1 bytes, and vkey_purge() (vtuikit.c:1409-1415)
|
|
60
|
+
# clears the whole input queue if the last accepted byte lands at
|
|
61
|
+
# iend == len-2 with a Big5 lead byte still queued right after it -- so the
|
|
62
|
+
# safe cap is len-2, not len-1.
|
|
63
|
+
_REASON_TITLE_BUFFER_BYTES = 40 # PROPER_TITLE_LEN(42) - 2, see vkey_purge() note above
|
|
48
64
|
_REASON_PREFIX_FIXED_BYTES = 13 # Big5 bytes of "(已被刪除) <>", the literal part of "(已被%s刪除) <%s>"
|
|
49
65
|
# A rough, moderator/author-id-independent ceiling: the most bytes `reason`
|
|
50
66
|
# could ever fit under, i.e. the budget if both PTT ids were the shortest
|
|
@@ -78,7 +78,7 @@ def give_money(api, ptt_id: str, money: int, red_bag_title: str, red_bag_content
|
|
|
78
78
|
connect_core.TargetUnit('按任意鍵繼續', break_detect=True),
|
|
79
79
|
edit_red_bag_target,
|
|
80
80
|
connect_core.TargetUnit('要修改紅包袋嗎', response=command.enter),
|
|
81
|
-
connect_core.TargetUnit('完成交易前要重新確認您的身份', response=api._ptt_pw + command.enter),
|
|
81
|
+
connect_core.TargetUnit('完成交易前要重新確認您的身份', response=api._ptt_pw + command.enter, secret=True),
|
|
82
82
|
connect_core.TargetUnit('他是你的小主人,是否匿名?', response='n' + command.enter),
|
|
83
83
|
connect_core.TargetUnit('要給他多少Ptt幣呢?', response=command.tab + str(money) + command.enter),
|
|
84
84
|
connect_core.TargetUnit('這位幸運兒的id', response=ptt_id + command.enter),
|
|
@@ -68,7 +68,9 @@ def login(api, ptt_id: str, ptt_pw: str, kick_other_session: bool) -> None:
|
|
|
68
68
|
api.process_picks = int(pattern.search(screen).group(0))
|
|
69
69
|
|
|
70
70
|
ptt_id = ptt_id[:12].strip()
|
|
71
|
-
|
|
71
|
+
# 不截斷密碼: 舊版 PTT 由 server 端忽略第 8 字之後的部分, 新版 (pttbbs #112, bcrypt)
|
|
72
|
+
# 則支援 72 字元, 兩邊都必須送出完整密碼才會正確。
|
|
73
|
+
ptt_pw = ptt_pw.strip()
|
|
72
74
|
|
|
73
75
|
api.ptt_id = ptt_id
|
|
74
76
|
api._ptt_pw = ptt_pw
|
|
@@ -33,6 +33,17 @@ def mail(api,
|
|
|
33
33
|
|
|
34
34
|
check_value.check_type(ptt_id, str, 'ptt_id')
|
|
35
35
|
check_value.check_type(title, str, 'title')
|
|
36
|
+
# mbbsd/mail.c:774-775 char tmp_title[STRLEN-20]; include/pttstruct.h:313
|
|
37
|
+
# STRLEN 80 -> len=60 -> vtuikit.c's getdata() only accepts len-1 bytes,
|
|
38
|
+
# and vkey_purge() clears the whole input queue if the overflow byte
|
|
39
|
+
# lands right after a Big5 lead byte -- so the safe cap is len-2 = 58.
|
|
40
|
+
try:
|
|
41
|
+
encoded_title = title.encode('big5uao')
|
|
42
|
+
except UnicodeEncodeError as e:
|
|
43
|
+
raise exceptions.ParameterError(
|
|
44
|
+
f'title contains a character that cannot be encoded in Big5: {e}') from e
|
|
45
|
+
if len(encoded_title) > 58:
|
|
46
|
+
raise exceptions.ParameterError('title must not exceed 58 bytes')
|
|
36
47
|
check_value.check_type(content, str, 'content')
|
|
37
48
|
# 沒擋型別的話,backup='False' 這種字串是 truthy,會靜靜地存底稿。
|
|
38
49
|
check_value.check_type(backup, bool, 'backup')
|
|
@@ -117,7 +128,6 @@ def mail(api,
|
|
|
117
128
|
# ※ 發信站: 批踢踢實業坊(ptt.cc)
|
|
118
129
|
# ◆ From: 220.142.14.95
|
|
119
130
|
content_start = '───────────────────────────────────────'
|
|
120
|
-
content_end = '--\n※ 發信站: 批踢踢實業坊(ptt.cc)'
|
|
121
131
|
content_ip_old = '◆ From: '
|
|
122
132
|
|
|
123
133
|
mail_author_pattern = re.compile(r'作者 (.+)')
|
|
@@ -234,7 +244,7 @@ def get_mail(api, index: int, search_type: Optional[data_type.SearchType] = None
|
|
|
234
244
|
|
|
235
245
|
# 紅包偵測
|
|
236
246
|
red_envelope = False
|
|
237
|
-
if
|
|
247
|
+
if not any(EC in origin_mail for EC in screens.Target.content_end_list) and 'Ptt幣的大紅包喔' in origin_mail:
|
|
238
248
|
mail_content = mail_content.strip()
|
|
239
249
|
red_envelope = True
|
|
240
250
|
|
|
@@ -248,7 +258,8 @@ def get_mail(api, index: int, search_type: Optional[data_type.SearchType] = None
|
|
|
248
258
|
# 非紅包開始解析 ip 與 地區
|
|
249
259
|
|
|
250
260
|
ip_line_list = origin_mail.split('\n')
|
|
251
|
-
ip_line = [x for x in ip_line_list
|
|
261
|
+
ip_line = [x for x in ip_line_list
|
|
262
|
+
if any(x.startswith(EC[3:]) for EC in screens.Target.content_end_list)]
|
|
252
263
|
|
|
253
264
|
if len(ip_line) == 0:
|
|
254
265
|
# 沒 ip 就沒地區
|
|
@@ -149,6 +149,18 @@ def post(api, board: str, title: str, content: str, title_index: int, sign_file:
|
|
|
149
149
|
check_value.check_type(content, str, 'content')
|
|
150
150
|
check_value.check_type(anonymous, bool, 'anonymous')
|
|
151
151
|
|
|
152
|
+
# mbbsd/bbs.c:1360-1365 caps the title field at DISP_TTLEN=46 cells on an
|
|
153
|
+
# 80-col screen; mbbsd/vtuikit.c:1409-1415's vkey_purge() clears the whole
|
|
154
|
+
# input queue one byte earlier than that, eating PyPtt's batched Enter and
|
|
155
|
+
# follow-up commands -- so the safe limit is 46-2=44 Big5 bytes, not 45.
|
|
156
|
+
try:
|
|
157
|
+
encoded_title = title.encode('big5uao')
|
|
158
|
+
except UnicodeEncodeError as e:
|
|
159
|
+
raise exceptions.ParameterError(
|
|
160
|
+
f'title contains a character that cannot be encoded in Big5: {e}') from e
|
|
161
|
+
if len(encoded_title) > 44:
|
|
162
|
+
raise exceptions.ParameterError('title must not exceed 44 bytes')
|
|
163
|
+
|
|
152
164
|
if str(sign_file).lower() not in sign_file_list:
|
|
153
165
|
raise exceptions.ParameterError(f'wrong parameter sign_file: {sign_file}')
|
|
154
166
|
|
|
@@ -205,9 +217,12 @@ def post(api, board: str, title: str, content: str, title_index: int, sign_file:
|
|
|
205
217
|
connect_core.TargetUnit('確定[y/N]', response='y' + command.enter, max_match=1),
|
|
206
218
|
connect_core.TargetUnit('x=隨機', response=str(sign_file) + command.enter),
|
|
207
219
|
]
|
|
208
|
-
api.connect_core.send(
|
|
220
|
+
index = api.connect_core.send(
|
|
209
221
|
cmd,
|
|
210
222
|
target_list,
|
|
211
223
|
screen_timeout=api.config.screen_post_timeout)
|
|
224
|
+
if index < 0:
|
|
225
|
+
ori_screen = api.connect_core.get_screen_queue()[-1]
|
|
226
|
+
raise exceptions.UnknownError(ori_screen)
|
|
212
227
|
|
|
213
228
|
log.logger.info(i18n.post, '...', i18n.success)
|
|
@@ -16,6 +16,9 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
|
|
|
16
16
|
if not api._is_login:
|
|
17
17
|
raise exceptions.RequireLogin(i18n.require_login)
|
|
18
18
|
|
|
19
|
+
if not api.is_registered_user:
|
|
20
|
+
raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
|
|
21
|
+
|
|
19
22
|
if not isinstance(reply_to, data_type.ReplyTo):
|
|
20
23
|
raise TypeError('ReplyTo must be data_type.ReplyTo')
|
|
21
24
|
|
|
@@ -79,6 +82,12 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
|
|
|
79
82
|
connect_core.TargetUnit('任意鍵繼續', break_detect=True),
|
|
80
83
|
connect_core.TargetUnit('◆ 很抱歉, 此文章已結案並標記, 不得回應', log_level=log.INFO,
|
|
81
84
|
exceptions_=exceptions.CantResponse()),
|
|
85
|
+
# mbbsd/bbs.c:1736 "此篇文章已結案, 是否真的要回應?(y/N)" -- closed but not
|
|
86
|
+
# marked, distinct from the CantResponse case above; answering 'y'
|
|
87
|
+
# lets the reply continue normally.
|
|
88
|
+
connect_core.TargetUnit('是否真的要回應', log_level=log.INFO, response='y' + command.enter),
|
|
89
|
+
# mbbsd/bbs.c:1742-1745 BRD_NOREPLY board: "...本板不開放回覆文章...".
|
|
90
|
+
connect_core.TargetUnit('不開放回覆文章', log_level=log.INFO, exceptions_=exceptions.CantResponse()),
|
|
82
91
|
connect_core.TargetUnit('(E)繼續編輯 (W)強制寫入', log_level=log.INFO, response='W' + command.enter),
|
|
83
92
|
# max_match=1 是防禦性上限,理由同 _api_mail.py。三種 reply_to(BOARD / MAIL /
|
|
84
93
|
# BOARD_MAIL)都對真 PTT 實測過,這個 target 一次都沒命中,目前執行不到。
|
|
@@ -93,9 +102,12 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
|
|
|
93
102
|
response=('Y' if backup else 'N') + command.enter),
|
|
94
103
|
]
|
|
95
104
|
|
|
96
|
-
api.connect_core.send(
|
|
105
|
+
index = api.connect_core.send(
|
|
97
106
|
cmd,
|
|
98
107
|
target_list,
|
|
99
108
|
screen_timeout=api.config.screen_long_timeout)
|
|
109
|
+
if index < 0:
|
|
110
|
+
ori_screen = api.connect_core.get_screen_queue()[-1]
|
|
111
|
+
raise exceptions.UnknownError(ori_screen)
|
|
100
112
|
|
|
101
113
|
log.logger.info(reply_msg, '...', i18n.success)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from . import _api_util
|
|
2
|
+
from . import check_value
|
|
3
|
+
from . import command
|
|
4
|
+
from . import connect_core
|
|
5
|
+
from . import exceptions
|
|
6
|
+
from . import i18n
|
|
7
|
+
from . import lib_util
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_board_title(api, board: str, new_title: str) -> None:
|
|
11
|
+
# 第一支板主專用 api
|
|
12
|
+
_api_util.one_thread(api)
|
|
13
|
+
|
|
14
|
+
_api_util.goto_board(api, board)
|
|
15
|
+
|
|
16
|
+
if not api._is_login:
|
|
17
|
+
raise exceptions.RequireLogin(i18n.require_login)
|
|
18
|
+
|
|
19
|
+
if not api.is_registered_user:
|
|
20
|
+
raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
|
|
21
|
+
|
|
22
|
+
check_value.check_type(board, str, 'board')
|
|
23
|
+
check_value.check_type(new_title, str, 'new_title')
|
|
24
|
+
|
|
25
|
+
# mbbsd/board.c:641 vgetstr(genbuf, BTLEN-16, ...) with BTLEN=48 gives
|
|
26
|
+
# len=32; vtuikit.c:1409-1415's vkey_purge() clears the input queue one
|
|
27
|
+
# byte early, so the safe limit is 32-2=30 Big5 bytes, not 31.
|
|
28
|
+
try:
|
|
29
|
+
encoded_new_title = new_title.encode('big5uao')
|
|
30
|
+
except UnicodeEncodeError as e:
|
|
31
|
+
raise exceptions.ParameterError(
|
|
32
|
+
f'new_title contains a character that cannot be encoded in Big5: {e}') from e
|
|
33
|
+
if len(encoded_new_title) > 30:
|
|
34
|
+
raise exceptions.ParameterError('new_title must not exceed 30 bytes')
|
|
35
|
+
|
|
36
|
+
_api_util.check_board(
|
|
37
|
+
api,
|
|
38
|
+
board,
|
|
39
|
+
check_moderator=True)
|
|
40
|
+
|
|
41
|
+
# check_board() above calls get_board_info(get_post_kind=False) (to look
|
|
42
|
+
# up the moderator list) whenever the board isn't already cached, and
|
|
43
|
+
# that helper reads the board's "《board》看板設定" info screen but never
|
|
44
|
+
# dismisses its trailing "[按任意鍵繼續]" prompt. Left alone, the 'I'
|
|
45
|
+
# below lands on that prompt instead of the board's article-list screen:
|
|
46
|
+
# it gets consumed as the "any key" dismissal, so the following ctrl_p
|
|
47
|
+
# is read as the article-list's own "[Ctrl-P]發表文章" shortcut and the
|
|
48
|
+
# whole command ends up composing a new post instead of opening board
|
|
49
|
+
# settings. Verified live against a moderated board: without this
|
|
50
|
+
# re-navigation the send always lands in the article editor; with it,
|
|
51
|
+
# the confirmation targets below match every time (8/8 in local testing).
|
|
52
|
+
_api_util.goto_board(api, board)
|
|
53
|
+
|
|
54
|
+
cmd_list = []
|
|
55
|
+
cmd_list.append('I')
|
|
56
|
+
cmd_list.append(command.ctrl_p)
|
|
57
|
+
cmd_list.append('b')
|
|
58
|
+
cmd_list.append(command.enter)
|
|
59
|
+
cmd_list.append(command.backspace * 31)
|
|
60
|
+
cmd_list.append(new_title)
|
|
61
|
+
cmd_list.append(command.enter * 2)
|
|
62
|
+
cmd = ''.join(cmd_list)
|
|
63
|
+
|
|
64
|
+
target_list = [
|
|
65
|
+
connect_core.TargetUnit('◆ 已儲存新設定', break_detect=True),
|
|
66
|
+
connect_core.TargetUnit('◆ 未改變任何設定', break_detect=True),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
index = api.connect_core.send(
|
|
70
|
+
cmd,
|
|
71
|
+
target_list,
|
|
72
|
+
screen_timeout=api.config.screen_long_timeout)
|
|
73
|
+
if index < 0:
|
|
74
|
+
ori_screen = api.connect_core.get_screen_queue()[-1]
|
|
75
|
+
raise exceptions.UnknownError(ori_screen)
|
|
@@ -22,16 +22,16 @@ from . import log
|
|
|
22
22
|
from . import screens
|
|
23
23
|
from . import ssl_config
|
|
24
24
|
|
|
25
|
+
# ponytail: pass the UA per-connect instead of mutating websockets' module global.
|
|
26
|
+
# The global is baked into connect()'s default arg the moment websockets lazily
|
|
27
|
+
# imports asyncio.client, so mutating it only works if you get there first —
|
|
28
|
+
# order-dependent action at a distance. (websockets.http also went away in 15.)
|
|
25
29
|
try:
|
|
26
|
-
|
|
30
|
+
from websockets.http11 import USER_AGENT as _WS_USER_AGENT
|
|
31
|
+
except ImportError: # websockets < 13
|
|
32
|
+
from websockets.http import USER_AGENT as _WS_USER_AGENT
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
use_http11 = False
|
|
30
|
-
except AttributeError:
|
|
31
|
-
import websockets.http11
|
|
32
|
-
|
|
33
|
-
websockets.http11.USER_AGENT += f' PyPtt/{PyPtt.__version__}'
|
|
34
|
-
use_http11 = True
|
|
34
|
+
USER_AGENT = f'{_WS_USER_AGENT} PyPtt/{PyPtt.__version__}'
|
|
35
35
|
|
|
36
36
|
def ssl_init(verify_ssl: bool = True) -> ssl.SSLContext:
|
|
37
37
|
# Verify the PTT server certificate against the system CA bundle.
|
|
@@ -216,11 +216,11 @@ class API(object):
|
|
|
216
216
|
|
|
217
217
|
for _ in range(2):
|
|
218
218
|
try:
|
|
219
|
-
log.logger.debug('USER_AGENT',
|
|
220
|
-
websockets.http11.USER_AGENT if use_http11 else websockets.http.USER_AGENT)
|
|
219
|
+
log.logger.debug('USER_AGENT', USER_AGENT)
|
|
221
220
|
# ponytail: ws:// (local docker target) can't take an ssl context —
|
|
222
221
|
# websockets errors out if you pass one on a non-wss URI
|
|
223
|
-
connect_kwargs = {'origin': websocket_origin
|
|
222
|
+
connect_kwargs = {'origin': websocket_origin,
|
|
223
|
+
'user_agent_header': USER_AGENT}
|
|
224
224
|
if websocket_host.startswith('wss://'):
|
|
225
225
|
connect_kwargs['ssl'] = self._ssl_context
|
|
226
226
|
self._core = loop.run_until_complete(
|
|
@@ -525,11 +525,10 @@ class API(object):
|
|
|
525
525
|
|
|
526
526
|
def close(self):
|
|
527
527
|
if self.config.connect_mode == data_type.ConnectMode.WEBSOCKETS:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if self._core and is_open:
|
|
528
|
+
# close_code is None until a close frame lands — true on both the legacy
|
|
529
|
+
# WebSocketClientProtocol and the websockets>=14 ClientConnection (which
|
|
530
|
+
# dropped .closed/.open entirely).
|
|
531
|
+
if self._core and self._core.close_code is None:
|
|
533
532
|
loop = self._get_event_loop()
|
|
534
533
|
try:
|
|
535
534
|
loop.run_until_complete(asyncio.wait_for(self._core.close(), timeout=2.0))
|
|
@@ -10,11 +10,11 @@ from PyPtt import _api_del_post
|
|
|
10
10
|
|
|
11
11
|
class TestCheckBadPostReason:
|
|
12
12
|
def test_within_limit_ok(self):
|
|
13
|
-
_api_del_post._check_bad_post_reason('測' *
|
|
13
|
+
_api_del_post._check_bad_post_reason('測' * 24) # 48 bytes, exactly at the limit
|
|
14
14
|
|
|
15
15
|
def test_over_limit_raises(self):
|
|
16
16
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
17
|
-
_api_del_post._check_bad_post_reason('測' *
|
|
17
|
+
_api_del_post._check_bad_post_reason('測' * 24 + 'x') # 49 bytes, 1 over the limit
|
|
18
18
|
|
|
19
19
|
def test_control_char_raises(self):
|
|
20
20
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
@@ -24,18 +24,18 @@ class TestCheckBadPostReason:
|
|
|
24
24
|
class TestCheckReasonStatic:
|
|
25
25
|
"""`_check_reason` is the early, post_info-independent guard: control
|
|
26
26
|
chars, Big5-encodability, and a rough ceiling (_REASON_MAX_POSSIBLE_BYTES
|
|
27
|
-
==
|
|
27
|
+
== 40 - 13 - 1 - 1 - 1 == 24 bytes) that assumes the shortest possible
|
|
28
28
|
(1-byte) moderator/author PTT ids -- the most bytes a reason could ever
|
|
29
29
|
fit under any real id pair. It therefore never rejects a reason that
|
|
30
30
|
might be legal once the real ids are known; that exact, smaller,
|
|
31
31
|
id-dependent budget is _check_reason_budget's job (see below)."""
|
|
32
32
|
|
|
33
33
|
def test_within_rough_ceiling_ok(self):
|
|
34
|
-
_api_del_post._check_reason('測' * 12
|
|
34
|
+
_api_del_post._check_reason('測' * 12) # 24 bytes, exactly the rough ceiling
|
|
35
35
|
|
|
36
36
|
def test_over_rough_ceiling_raises(self):
|
|
37
37
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
38
|
-
_api_del_post._check_reason('測' * 12 + '
|
|
38
|
+
_api_del_post._check_reason('測' * 12 + 'x') # 25 bytes, 1 over the ceiling
|
|
39
39
|
|
|
40
40
|
def test_control_char_raises(self):
|
|
41
41
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
@@ -49,21 +49,21 @@ class TestCheckReasonStatic:
|
|
|
49
49
|
class TestCheckReasonBudget:
|
|
50
50
|
"""`_check_reason_budget` is the exact, post_info-dependent guard: reason
|
|
51
51
|
gets appended, after a leading space, to pttbbs's prefilled
|
|
52
|
-
"(已被<moderator_id>刪除) <author_id>" title inside a
|
|
52
|
+
"(已被<moderator_id>刪除) <author_id>" title inside a 40-Big5-byte buffer
|
|
53
53
|
(_REASON_TITLE_BUFFER_BYTES). Budget for a 9-char/9-char id pair (e.g.
|
|
54
54
|
pypttbot1 deleting pypttbot2's post, as used in the local imageptt
|
|
55
|
-
integration tests) is
|
|
55
|
+
integration tests) is 40 - 13 - 9 - 9 - 1 == 8 bytes -- confirmed live
|
|
56
56
|
against a local pttbbs rebuilt with SAFE_ARTICLE_DELETE enabled."""
|
|
57
57
|
|
|
58
58
|
MOD_ID = 'pypttbot1'
|
|
59
59
|
AUTHOR_ID = 'pypttbot2'
|
|
60
60
|
|
|
61
61
|
def test_within_limit_ok(self):
|
|
62
|
-
_api_del_post._check_reason_budget('測測測測
|
|
62
|
+
_api_del_post._check_reason_budget('測測測測', self.MOD_ID, self.AUTHOR_ID) # 8 bytes, exactly at the limit
|
|
63
63
|
|
|
64
64
|
def test_over_limit_by_one_byte_raises(self):
|
|
65
65
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
66
|
-
_api_del_post._check_reason_budget('
|
|
66
|
+
_api_del_post._check_reason_budget('測測測測x', self.MOD_ID, self.AUTHOR_ID) # 9 bytes, 1 over the limit
|
|
67
67
|
|
|
68
68
|
def test_over_limit_ascii_raises(self):
|
|
69
69
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
@@ -71,8 +71,8 @@ class TestCheckReasonBudget:
|
|
|
71
71
|
|
|
72
72
|
def test_budget_shrinks_with_longer_ids(self):
|
|
73
73
|
"""A 12-char/12-char id pair (PTT's real max, IDLEN==12) leaves only
|
|
74
|
-
|
|
74
|
+
2 bytes of budget -- a reason that fits the 9/9 pair above no
|
|
75
75
|
longer fits."""
|
|
76
76
|
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
77
77
|
_api_del_post._check_reason_budget('測測測測x', 'a' * 12, 'b' * 12)
|
|
78
|
-
_api_del_post._check_reason_budget('x', 'a' * 12, 'b' * 12) # 1 byte still fits the
|
|
78
|
+
_api_del_post._check_reason_budget('x', 'a' * 12, 'b' * 12) # 1 byte still fits the 2-byte budget
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
from . import _api_util
|
|
2
|
-
from . import check_value
|
|
3
|
-
from . import command
|
|
4
|
-
from . import connect_core
|
|
5
|
-
from . import exceptions
|
|
6
|
-
from . import i18n
|
|
7
|
-
from . import lib_util
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def set_board_title(api, board: str, new_title: str) -> None:
|
|
11
|
-
# 第一支板主專用 api
|
|
12
|
-
_api_util.one_thread(api)
|
|
13
|
-
|
|
14
|
-
_api_util.goto_board(api, board)
|
|
15
|
-
|
|
16
|
-
if not api._is_login:
|
|
17
|
-
raise exceptions.RequireLogin(i18n.require_login)
|
|
18
|
-
|
|
19
|
-
if not api.is_registered_user:
|
|
20
|
-
raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
|
|
21
|
-
|
|
22
|
-
check_value.check_type(board, str, 'board')
|
|
23
|
-
check_value.check_type(new_title, str, 'new_title')
|
|
24
|
-
|
|
25
|
-
_api_util.check_board(
|
|
26
|
-
api,
|
|
27
|
-
board,
|
|
28
|
-
check_moderator=True)
|
|
29
|
-
|
|
30
|
-
cmd_list = []
|
|
31
|
-
cmd_list.append('I')
|
|
32
|
-
cmd_list.append(command.ctrl_p)
|
|
33
|
-
cmd_list.append('b')
|
|
34
|
-
cmd_list.append(command.enter)
|
|
35
|
-
cmd_list.append(command.backspace * 31)
|
|
36
|
-
cmd_list.append(new_title)
|
|
37
|
-
cmd_list.append(command.enter * 2)
|
|
38
|
-
cmd = ''.join(cmd_list)
|
|
39
|
-
|
|
40
|
-
target_list = [
|
|
41
|
-
connect_core.TargetUnit('◆ 已儲存新設定', break_detect=True),
|
|
42
|
-
connect_core.TargetUnit('◆ 未改變任何設定', break_detect=True),
|
|
43
|
-
]
|
|
44
|
-
|
|
45
|
-
api.connect_core.send(
|
|
46
|
-
cmd,
|
|
47
|
-
target_list,
|
|
48
|
-
screen_timeout=api.config.screen_long_timeout)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|