PyPtt 2.3.3__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.
Files changed (104) hide show
  1. {pyptt-2.3.3 → pyptt-2.3.5}/PKG-INFO +1 -1
  2. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/__init__.py +1 -1
  3. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_del_post.py +21 -5
  4. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_mail.py +14 -3
  5. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_post.py +16 -1
  6. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_reply_post.py +13 -1
  7. pyptt-2.3.5/PyPtt/_api_set_board_title.py +75 -0
  8. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/connect_core.py +15 -16
  9. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt.egg-info/PKG-INFO +1 -1
  10. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_del_post_unit.py +11 -11
  11. pyptt-2.3.3/PyPtt/_api_set_board_title.py +0 -48
  12. {pyptt-2.3.3 → pyptt-2.3.5}/LICENSE +0 -0
  13. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/PTT.py +0 -0
  14. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_bucket.py +0 -0
  15. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_call_status.py +0 -0
  16. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_change_pw.py +0 -0
  17. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_comment.py +0 -0
  18. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_board_info.py +0 -0
  19. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_board_list.py +0 -0
  20. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_bottom_post_list.py +0 -0
  21. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_favourite_board.py +0 -0
  22. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_newest_index.py +0 -0
  23. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_post.py +0 -0
  24. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_post_index.py +0 -0
  25. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_post_list.py +0 -0
  26. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_time.py +0 -0
  27. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_user.py +0 -0
  28. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_get_waterball.py +0 -0
  29. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_give_money.py +0 -0
  30. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_has_new_mail.py +0 -0
  31. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_loginout.py +0 -0
  32. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_mark_post.py +0 -0
  33. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_search_user.py +0 -0
  34. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_set_signature_file.py +0 -0
  35. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_api_util.py +0 -0
  36. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_uao/LICENSE +0 -0
  37. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_uao/__init__.py +0 -0
  38. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_uao/b2u.py +0 -0
  39. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/_uao/u2b.py +0 -0
  40. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/api_server.py +0 -0
  41. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/check_value.py +0 -0
  42. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/command.py +0 -0
  43. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/config.py +0 -0
  44. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/data_type.py +0 -0
  45. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/exceptions.py +0 -0
  46. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/i18n.py +0 -0
  47. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/lang_en_US.py +0 -0
  48. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/lang_zh_TW.py +0 -0
  49. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/lib_util.py +0 -0
  50. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/log.py +0 -0
  51. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/screens.py +0 -0
  52. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/service.py +0 -0
  53. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt/ssl_config.py +0 -0
  54. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt.egg-info/SOURCES.txt +0 -0
  55. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt.egg-info/dependency_links.txt +0 -0
  56. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt.egg-info/requires.txt +0 -0
  57. {pyptt-2.3.3 → pyptt-2.3.5}/PyPtt.egg-info/top_level.txt +0 -0
  58. {pyptt-2.3.3 → pyptt-2.3.5}/README.md +0 -0
  59. {pyptt-2.3.3 → pyptt-2.3.5}/pyproject.toml +0 -0
  60. {pyptt-2.3.3 → pyptt-2.3.5}/setup.cfg +0 -0
  61. {pyptt-2.3.3 → pyptt-2.3.5}/setup.py +0 -0
  62. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_backup_effective.py +0 -0
  63. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_backup_param.py +0 -0
  64. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_bucket.py +0 -0
  65. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_change_pw.py +0 -0
  66. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_comment.py +0 -0
  67. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_connect_core_timeout.py +0 -0
  68. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_del_post.py +0 -0
  69. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_exceptions.py +0 -0
  70. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_fast_post.py +0 -0
  71. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_board_info.py +0 -0
  72. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_board_list.py +0 -0
  73. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_bottom_post_list.py +0 -0
  74. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_content_formula.py +0 -0
  75. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_favourite_boards.py +0 -0
  76. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_mail.py +0 -0
  77. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_newest_index.py +0 -0
  78. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_post.py +0 -0
  79. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_post_list.py +0 -0
  80. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_post_list_parser.py +0 -0
  81. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_post_parser.py +0 -0
  82. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_time.py +0 -0
  83. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_user.py +0 -0
  84. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_get_waterball.py +0 -0
  85. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_give_money.py +0 -0
  86. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_half_dead_connection.py +0 -0
  87. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_i18n.py +0 -0
  88. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_incremental_parity.py +0 -0
  89. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_init.py +0 -0
  90. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_logger.py +0 -0
  91. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_login_logout.py +0 -0
  92. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_mail_send_and_del.py +0 -0
  93. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_mark_post.py +0 -0
  94. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_parse_query_post.py +0 -0
  95. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_post.py +0 -0
  96. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_post_anonymous.py +0 -0
  97. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_pyte_parity.py +0 -0
  98. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_reply_post.py +0 -0
  99. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_search_user.py +0 -0
  100. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_service.py +0 -0
  101. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_set_board_title.py +0 -0
  102. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_set_signature_file.py +0 -0
  103. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_uao_codec.py +0 -0
  104. {pyptt-2.3.3 → pyptt-2.3.5}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.3
3
+ Version: 2.3.5
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -1,4 +1,4 @@
1
- __version__ = '2.3.3'
1
+ __version__ = '2.3.5'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -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
- _BAD_POST_REASON_MAX_BYTES = 50
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 41 Big5 bytes, not python characters.
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
- # - exactly at the budget: reason is accepted in full, title not
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
- _REASON_TITLE_BUFFER_BYTES = 41 # PROPER_TITLE_LEN(42) - 1 byte reserved for the NUL terminator
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
@@ -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 content_end not in origin_mail and 'Ptt幣的大紅包喔' in origin_mail:
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 if x.startswith(content_end[3:])]
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
- import websockets.http
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
- websockets.http.USER_AGENT += f' PyPtt/{PyPtt.__version__}'
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
- try:
529
- is_open = not self._core.closed
530
- except AttributeError:
531
- is_open = getattr(self._core, 'open', False)
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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.3
3
+ Version: 2.3.5
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -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('測' * 25) # 50 bytes, exactly at the limit
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('測' * 25 + 'x') # 51 bytes
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
- == 41 - 13 - 1 - 1 - 1 == 25 bytes) that assumes the shortest possible
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 + 'x') # 25 bytes, exactly the rough ceiling
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 + 'xx') # 26 bytes
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 41-Big5-byte buffer
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 41 - 13 - 9 - 9 - 1 == 9 bytes -- confirmed live
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('測測測測x', self.MOD_ID, self.AUTHOR_ID) # 9 bytes, exactly at the limit
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('測測測測測', self.MOD_ID, self.AUTHOR_ID) # 10 bytes
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
- 3 bytes of budget -- a reason that fits the 9/9 pair above no
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 3-byte budget
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