PyPtt 2.1.6__tar.gz → 2.2.0__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 (92) hide show
  1. {pyptt-2.1.6 → pyptt-2.2.0}/PKG-INFO +1 -1
  2. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/PTT.py +16 -2
  3. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_del_post.py +107 -2
  5. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_post.py +57 -2
  6. pyptt-2.2.0/PyPtt/ssl_config.py +24 -0
  7. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt.egg-info/PKG-INFO +1 -1
  8. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt.egg-info/SOURCES.txt +2 -0
  9. pyptt-2.2.0/tests/test_del_post_unit.py +78 -0
  10. pyptt-2.2.0/tests/test_post_anonymous.py +96 -0
  11. pyptt-2.1.6/PyPtt/ssl_config.py +0 -24
  12. {pyptt-2.1.6 → pyptt-2.2.0}/LICENSE +0 -0
  13. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_bucket.py +0 -0
  14. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_call_status.py +0 -0
  15. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_change_pw.py +0 -0
  16. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_comment.py +0 -0
  17. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_board_info.py +0 -0
  18. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_board_list.py +0 -0
  19. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_bottom_post_list.py +0 -0
  20. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_favourite_board.py +0 -0
  21. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_newest_index.py +0 -0
  22. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_post.py +0 -0
  23. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_post_index.py +0 -0
  24. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_post_list.py +0 -0
  25. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_time.py +0 -0
  26. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_user.py +0 -0
  27. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_get_waterball.py +0 -0
  28. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_give_money.py +0 -0
  29. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_has_new_mail.py +0 -0
  30. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_loginout.py +0 -0
  31. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_mail.py +0 -0
  32. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_mark_post.py +0 -0
  33. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_reply_post.py +0 -0
  34. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_search_user.py +0 -0
  35. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_set_board_title.py +0 -0
  36. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/_api_util.py +0 -0
  37. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/api_server.py +0 -0
  38. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/check_value.py +0 -0
  39. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/command.py +0 -0
  40. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/config.py +0 -0
  41. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/connect_core.py +0 -0
  42. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/data_type.py +0 -0
  43. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/exceptions.py +0 -0
  44. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/i18n.py +0 -0
  45. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/lang_en_US.py +0 -0
  46. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/lang_zh_TW.py +0 -0
  47. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/lib_util.py +0 -0
  48. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/log.py +0 -0
  49. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/screens.py +0 -0
  50. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt/service.py +0 -0
  51. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt.egg-info/dependency_links.txt +0 -0
  52. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt.egg-info/requires.txt +0 -0
  53. {pyptt-2.1.6 → pyptt-2.2.0}/PyPtt.egg-info/top_level.txt +0 -0
  54. {pyptt-2.1.6 → pyptt-2.2.0}/README.md +0 -0
  55. {pyptt-2.1.6 → pyptt-2.2.0}/pyproject.toml +0 -0
  56. {pyptt-2.1.6 → pyptt-2.2.0}/setup.cfg +0 -0
  57. {pyptt-2.1.6 → pyptt-2.2.0}/setup.py +0 -0
  58. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_bucket.py +0 -0
  59. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_change_pw.py +0 -0
  60. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_comment.py +0 -0
  61. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_del_post.py +0 -0
  62. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_exceptions.py +0 -0
  63. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_fast_post.py +0 -0
  64. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_board_info.py +0 -0
  65. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_board_list.py +0 -0
  66. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_bottom_post_list.py +0 -0
  67. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_content_formula.py +0 -0
  68. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_favourite_boards.py +0 -0
  69. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_mail.py +0 -0
  70. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_newest_index.py +0 -0
  71. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_post.py +0 -0
  72. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_post_list.py +0 -0
  73. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_post_list_parser.py +0 -0
  74. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_post_parser.py +0 -0
  75. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_time.py +0 -0
  76. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_user.py +0 -0
  77. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_get_waterball.py +0 -0
  78. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_give_money.py +0 -0
  79. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_i18n.py +0 -0
  80. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_init.py +0 -0
  81. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_logger.py +0 -0
  82. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_login_logout.py +0 -0
  83. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_mail_send_and_del.py +0 -0
  84. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_mark_post.py +0 -0
  85. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_parse_query_post.py +0 -0
  86. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_post.py +0 -0
  87. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_pyte_parity.py +0 -0
  88. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_reply_post.py +0 -0
  89. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_search_user.py +0 -0
  90. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_service.py +0 -0
  91. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_set_board_title.py +0 -0
  92. {pyptt-2.1.6 → pyptt-2.2.0}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.1.6
3
+ Version: 2.2.0
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)
@@ -436,7 +436,8 @@ class API:
436
436
  return _api_get_newest_index.get_newest_index(
437
437
  self, index_type, board, search_type, search_condition, search_list)
438
438
 
439
- def post(self, board: str, title_index: int, title: str, content: str, sign_file: [str | int] = 0) -> None:
439
+ def post(self, board: str, title_index: int, title: str, content: str, sign_file: [str | int] = 0,
440
+ anonymous: bool = False, display_id: Optional[str] = None) -> None:
440
441
  """
441
442
  發文。
442
443
 
@@ -446,6 +447,12 @@ class API:
446
447
  title (str): 文章標題。
447
448
  content (str): 文章內容。
448
449
  sign_file (str | int): 編號或隨機簽名檔 (x),預設為 0 (不選)。
450
+ anonymous (bool): 是否匿名發文,預設為 False。僅在該看板本身具有匿名 (BRD_ANONYMOUS)
451
+ 屬性時才會生效,一般看板無法透過此參數強制匿名;PyPtt 只能在該提示出現時回覆它。
452
+ display_id (str): 匿名發文時欲顯示的自訂名稱;預設為 None,代表使用 pttbbs 預設值
453
+ (預設匿名板為 "Anonymous.",否則為原 ID)。pttbbs 會在暱名 (含預設的 "Anonymous")
454
+ 後方加上一個 "."(例如 display_id='Fox' 會顯示為 "Fox."),此為 pttbbs 固定行為,
455
+ 無法調整。僅能在 anonymous=True 時使用。
449
456
 
450
457
  Returns:
451
458
  None
@@ -455,6 +462,7 @@ class API:
455
462
  RequireLogin: 需要登入。
456
463
  NoSuchBoard: 看板不存在。
457
464
  NoPermission: 沒有發佈權限。
465
+ ParameterError: display_id 於 anonymous=False 時提供。
458
466
 
459
467
  範例::
460
468
 
@@ -464,13 +472,19 @@ class API:
464
472
  try:
465
473
  # .. login ..
466
474
  ptt_bot.post(board='Test', title_index=1, title='PyPtt 程式貼文測試', content='測試內容', sign_file=0)
475
+ # 匿名發文 (需看板本身具有匿名屬性)
476
+ ptt_bot.post(board='AnonBoard', title_index=1, title='匿名貼文測試', content='測試內容',
477
+ sign_file=0, anonymous=True)
478
+ # 匿名發文並自訂顯示名稱
479
+ ptt_bot.post(board='AnonBoard', title_index=1, title='匿名貼文測試', content='測試內容',
480
+ sign_file=0, anonymous=True, display_id='Fox')
467
481
  # .. do something ..
468
482
  finally:
469
483
  ptt_bot.logout()
470
484
 
471
485
  """
472
486
 
473
- _api_post.post(self, board, title, content, title_index, sign_file)
487
+ _api_post.post(self, board, title, content, title_index, sign_file, anonymous, display_id)
474
488
 
475
489
  def comment(self, board: str, comment_type: data_type.CommentType, content: str, aid: Optional[str] = None,
476
490
  index: int = 0) -> None:
@@ -1,4 +1,4 @@
1
- __version__ = '2.1.6'
1
+ __version__ = '2.2.0'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -17,6 +17,45 @@ from . import screens
17
17
  # character), not in Python characters.
18
18
  _BAD_POST_REASON_MAX_BYTES = 50
19
19
 
20
+ # --- del_post's `reason` field (annotates the title shown after a
21
+ # moderator deletes another user's post) ---
22
+ #
23
+ # pttbbs pre-fills this prompt with "(已被<moderator id>刪除) <author id>"
24
+ # (pttbbs mbbsd/bbs.c, del_post()'s SAFE_ARTICLE_DELETE branch) in a
25
+ # `char reason[PROPER_TITLE_LEN]` buffer; PROPER_TITLE_LEN is 42
26
+ # (pttbbs include/common.h), so the whole title -- prefix *and* PyPtt's
27
+ # reason together -- is capped at 41 Big5 bytes, not python characters.
28
+ # PyPtt answers the prompt with Ctrl-E (jump to the end of the prefilled
29
+ # text) plus a leading space plus `reason`, so the byte budget actually
30
+ # left for `reason` depends on how long the moderator's and the post
31
+ # author's PTT IDs are -- there is no single safe constant independent of
32
+ # who is deleting what, unlike bad_post_reason above.
33
+ #
34
+ # Verified live against a local pttbbs (bbsdocker/imageptt rebuilt with
35
+ # SAFE_ARTICLE_DELETE enabled -- the stock image ships with that feature
36
+ # #define'd out and never offers this prompt at all):
37
+ # - exactly at the budget: reason is accepted in full, title not
38
+ # truncated.
39
+ # - 1 byte over budget, ASCII-only overflow: pttbbs silently truncates
40
+ # to fit (title is just missing the last character).
41
+ # - 1 byte over budget, where the overflow splits a double-byte Big5
42
+ # glyph in half: pttbbs's line editor corrupts and del_post() hangs
43
+ # until connect_core's screen_timeout, returning neither success nor
44
+ # a clean failure -- this is the "刪文失敗" a caller with a too-long
45
+ # reason actually experiences.
46
+ # 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
48
+ _REASON_PREFIX_FIXED_BYTES = 13 # Big5 bytes of "(已被刪除) <>", the literal part of "(已被%s刪除) <%s>"
49
+ # A rough, moderator/author-id-independent ceiling: the most bytes `reason`
50
+ # could ever fit under, i.e. the budget if both PTT ids were the shortest
51
+ # possible (1 byte each). Real ids can only be longer than that, which only
52
+ # shrinks the true budget further -- so a reason within this ceiling is not
53
+ # guaranteed to fit any *particular* id pair, but a reason over it can never
54
+ # fit *any* id pair. That asymmetry is what makes it safe to use as an
55
+ # early, id-independent guard: it will never reject a reason that might be
56
+ # legal once we know the real ids (checked later by _check_reason_budget).
57
+ _REASON_MAX_POSSIBLE_BYTES = _REASON_TITLE_BUFFER_BYTES - _REASON_PREFIX_FIXED_BYTES - 1 - 1 - 1
58
+
20
59
 
21
60
  def _check_no_control_chars(name: str, value: str) -> None:
22
61
  """Terminal command injection guard: none of PyPtt's del_post text
@@ -40,6 +79,55 @@ def _check_bad_post_reason(bad_post_reason: str) -> None:
40
79
  f'bad_post_reason must not exceed {_BAD_POST_REASON_MAX_BYTES} bytes')
41
80
 
42
81
 
82
+ def _check_reason(reason: str) -> None:
83
+ """Early, post_info-independent guard for `reason`: control chars,
84
+ Big5-encodability, and a rough length ceiling that can never fit any
85
+ moderator/author id pair (_REASON_MAX_POSSIBLE_BYTES). This alone does
86
+ NOT guarantee `reason` fits the *actual* ids involved -- that exact,
87
+ id-dependent budget is enforced later by _check_reason_budget, once
88
+ post_info is available. Keeping this one id-independent lets del_post()
89
+ reject an obviously-broken reason (control chars, non-Big5, absurdly
90
+ long) the same way regardless of whether the target post still exists."""
91
+ _check_no_control_chars('reason', reason)
92
+ try:
93
+ encoded = reason.encode('big5uao')
94
+ except UnicodeEncodeError as e:
95
+ raise exceptions.ParameterError(
96
+ f'reason contains a character that cannot be encoded in Big5: {e}') from e
97
+ if len(encoded) > _REASON_MAX_POSSIBLE_BYTES:
98
+ raise exceptions.ParameterError(
99
+ f'reason must not exceed {_REASON_MAX_POSSIBLE_BYTES} bytes '
100
+ f'(pttbbs caps the deleted-post title at {_REASON_TITLE_BUFFER_BYTES} bytes total; '
101
+ f'{_REASON_MAX_POSSIBLE_BYTES} bytes is the most any reason could ever fit, for the '
102
+ f'shortest possible moderator/author PTT ids -- the actual limit for this delete may '
103
+ f'be smaller)')
104
+
105
+
106
+ def _check_reason_budget(reason: str, moderator_id: str, author_id: str) -> None:
107
+ """Exact, post_info-dependent guard for `reason`: pttbbs pre-fills the
108
+ delete-title prompt with "(已被<moderator_id>刪除) <author_id>" inside the
109
+ fixed-size buffer described above, so the byte budget actually left for
110
+ `reason` depends on the two real PTT IDs involved in this delete --
111
+ unlike _check_bad_post_reason, this can't be a plain module-level
112
+ constant. Call this once post_info is available (see del_post());
113
+ _check_reason above already covers the id-independent checks (control
114
+ chars, Big5-encodability) so this only needs to re-derive the byte
115
+ count for the length comparison."""
116
+ try:
117
+ encoded = reason.encode('big5uao')
118
+ except UnicodeEncodeError as e:
119
+ raise exceptions.ParameterError(
120
+ f'reason contains a character that cannot be encoded in Big5: {e}') from e
121
+ prefix_bytes = _REASON_PREFIX_FIXED_BYTES + len(moderator_id) + len(author_id)
122
+ # -1 for the leading space PyPtt sends before reason (see del_post()).
123
+ max_reason_bytes = max(0, _REASON_TITLE_BUFFER_BYTES - prefix_bytes - 1)
124
+ if len(encoded) > max_reason_bytes:
125
+ raise exceptions.ParameterError(
126
+ f'reason must not exceed {max_reason_bytes} bytes for this moderator/author pair '
127
+ f'(pttbbs caps the deleted-post title at {_REASON_TITLE_BUFFER_BYTES} bytes total, '
128
+ f'and the prefix "(已被{moderator_id}刪除) <{author_id}>" already uses {prefix_bytes} of them)')
129
+
130
+
43
131
  def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, reason: Optional[str] = None,
44
132
  bad_post_type: Optional[data_type.BadPostType] = None,
45
133
  bad_post_reason: Optional[str] = None) -> None:
@@ -74,9 +162,17 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
74
162
  # --- Pure parameter validation (doesn't need post_info) happens here,
75
163
  # before the "already deleted" early-return below -- an invalid
76
164
  # combination of parameters must always fail the same way, regardless of
77
- # whether the post happens to already be gone. ---
165
+ # whether the post happens to already be gone. `reason` is checked in
166
+ # two stages for this reason: _check_reason here covers everything that
167
+ # doesn't depend on state (control chars, Big5-encodability, and a
168
+ # rough ceiling no id pair could ever exceed), so those failures are
169
+ # always reported the same way; the *exact* budget depends on the
170
+ # moderator's and the post author's real PTT IDs, so that part
171
+ # (_check_reason_budget) has to wait until post_info is available below
172
+ # -- alongside the other post_info-dependent reason check ("reason is
173
+ # only valid when...") a few lines down. ---
78
174
  if reason is not None:
79
- _check_no_control_chars('reason', reason)
175
+ _check_reason(reason)
80
176
 
81
177
  if bad_post_type == data_type.BadPostType.OTHER:
82
178
  if not bad_post_reason:
@@ -132,6 +228,15 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
132
228
  log.logger.info(i18n.delete_post, '...', i18n.fail)
133
229
  raise exceptions.NoPermission(i18n.no_permission)
134
230
 
231
+ # Reaching here with reason still set means: not self-authored (checked
232
+ # above) and permitted (the check_author gate above didn't raise) --
233
+ # i.e. exactly the "moderator deleting someone else's post" case where
234
+ # PTT will actually offer the R加註理由 prompt. Validate reason's exact
235
+ # byte budget now, using the real moderator/author id pair, before any
236
+ # of the interactive delete flow below is sent.
237
+ if reason:
238
+ _check_reason_budget(reason, api.ptt_id, post_info[data_type.PostField.author])
239
+
135
240
  _api_util.goto_board(api, board)
136
241
 
137
242
  cmd_list = []
@@ -92,7 +92,49 @@ sign_file_list = [str(x) for x in range(0, 10)]
92
92
  sign_file_list.append('x')
93
93
 
94
94
 
95
- def post(api, board: str, title: str, content: str, title_index: int, sign_file: [str | int]) -> None:
95
+ # --- post()'s `anonymous` / `display_id` params (issue #84) ---
96
+ #
97
+ # pttbbs only shows the "請輸入你想用的ID" / "確定[y/N]?" prompt pair when the
98
+ # *board itself* carries BRD_ANONYMOUS server-side (mbbsd/edit.c write_header,
99
+ # `if (currbrdattr & BRD_ANONYMOUS)`). On a normal board the two TargetUnits
100
+ # below simply never match anything on screen -- there is no way for PyPtt to
101
+ # force anonymity on a board that isn't flagged anonymous. See
102
+ # scripts/mkboard.py / scripts/bootstrap_local_pttbbs.py's AnonTest board for
103
+ # how to provision one locally.
104
+ #
105
+ # Response mapping, matching pttbbs' own rules for that prompt:
106
+ # anonymous=False -> 'r' (real userid; safe default, and a
107
+ # no-op on non-anonymous boards)
108
+ # anonymous=True, no display_id -> '' (Enter; "Anonymous." -- pttbbs
109
+ # appends the same trailing '.' as the
110
+ # display_id case below -- on a
111
+ # default-anonymous board, real userid
112
+ # otherwise -- pttbbs' own behavior)
113
+ # anonymous=True, display_id -> that string; pttbbs appends a trailing
114
+ # '.' to it (e.g. display_id='Fox' shows
115
+ # up as author 'Fox.') -- fixed pttbbs
116
+ # behavior, not adjustable by PyPtt.
117
+ def _anonymous_response(anonymous: bool, display_id: [str | None]) -> str:
118
+ if display_id is not None:
119
+ check_value.check_type(display_id, str, 'display_id')
120
+ if not anonymous:
121
+ raise exceptions.ParameterError('display_id requires anonymous=True')
122
+ # pttbbs (mbbsd/edit.c) treats a leading '-' as "this is a deleted
123
+ # name" and re-shows the same prompt instead of accepting it, which
124
+ # would make PyPtt's TargetUnit answer it the same way forever.
125
+ if display_id.startswith('-'):
126
+ raise exceptions.ParameterError("display_id must not start with '-'")
127
+ for c in display_id:
128
+ if ord(c) < 0x20 or ord(c) == 0x7f:
129
+ raise exceptions.ParameterError(
130
+ f'display_id must not contain control characters (found {c!r})')
131
+ return display_id
132
+
133
+ return '' if anonymous else 'r'
134
+
135
+
136
+ def post(api, board: str, title: str, content: str, title_index: int, sign_file: [str | int],
137
+ anonymous: bool = False, display_id: [str | None] = None) -> None:
96
138
  _api_util.one_thread(api)
97
139
 
98
140
  if not api._is_login:
@@ -105,10 +147,13 @@ def post(api, board: str, title: str, content: str, title_index: int, sign_file:
105
147
  check_value.check_type(title_index, int, 'title_index')
106
148
  check_value.check_type(title, str, 'title')
107
149
  check_value.check_type(content, str, 'content')
150
+ check_value.check_type(anonymous, bool, 'anonymous')
108
151
 
109
152
  if str(sign_file).lower() not in sign_file_list:
110
153
  raise exceptions.ParameterError(f'wrong parameter sign_file: {sign_file}')
111
154
 
155
+ anonymous_response = _anonymous_response(anonymous, display_id)
156
+
112
157
  _api_util.check_board(api, board)
113
158
  _api_util.goto_board(api, board)
114
159
 
@@ -147,7 +192,17 @@ def post(api, board: str, title: str, content: str, title_index: int, sign_file:
147
192
 
148
193
  target_list = [
149
194
  connect_core.TargetUnit('任意鍵繼續', break_detect=True),
150
- connect_core.TargetUnit('確定要儲存檔案嗎', response='s' + command.enter),
195
+ # max_match=1: pttbbs' "檔案處理" screen keeps every answered prompt's
196
+ # echoed text on screen as the flow proceeds through the next ones
197
+ # (confirmed live -- "確定要儲存檔案嗎? s" stays visible through the
198
+ # anonymous-post prompts below). Without max_match=1 this target
199
+ # re-matches on every later screen in the same flow and re-sends
200
+ # 's' + Enter into whatever field currently has focus (e.g. the
201
+ # anonymous-ID or confirm prompt), corrupting the answer.
202
+ connect_core.TargetUnit('確定要儲存檔案嗎', response='s' + command.enter, max_match=1),
203
+ # Only shown on a BRD_ANONYMOUS board -- no-op on a normal board.
204
+ connect_core.TargetUnit('請輸入你想用的ID', response=anonymous_response + command.enter, max_match=1),
205
+ connect_core.TargetUnit('確定[y/N]', response='y' + command.enter, max_match=1),
151
206
  connect_core.TargetUnit('x=隨機', response=str(sign_file) + command.enter),
152
207
  ]
153
208
  api.connect_core.send(
@@ -0,0 +1,24 @@
1
+ key = """-----BEGIN EC PARAMETERS-----
2
+ BggqhkjOPQMBBw==
3
+ -----END EC PARAMETERS-----
4
+ -----BEGIN EC PRIVATE KEY-----
5
+ MHcCAQEEIK/vT3CLlx1CdJ6OlvrB70J+1+QzpKnb0DGYe9u1cWaboAoGCCqGSM49
6
+ AwEHoUQDQgAEhzeNEtPu4xVRU7aGhl59oKVJZsZue2B/eEQkmme2gsMzzS6vGJKj
7
+ 6mzkEmDNZv/DLFxccYesWd0comFxe/xOyA==
8
+ -----END EC PRIVATE KEY-----
9
+ """
10
+
11
+ cert = """-----BEGIN CERTIFICATE-----
12
+ MIIB5zCCAY8CFGe33bG5Ilg7iZcbrVQyTUOOD9uiMAoGCCqGSM49BAMCMHcxCzAJ
13
+ BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
+ CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
+ EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTkxMjI2NDVaFw0zNjA3MTYxMjI2
16
+ NDVaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
+ eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
+ bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
+ AwEHA0IABIc3jRLT7uMVUVO2hoZefaClSWbGbntgf3hEJJpntoLDM80urxiSo+ps
20
+ 5BJgzWb/wyxcXHGHrFndHKJhcXv8TsgwCgYIKoZIzj0EAwIDRgAwQwIfcZoiJkfz
21
+ rUDQbdsoh6/7UsBnxSB/01+u6KIfZgepvwIgPT5JXHcIMPgo/dt5ph75qYq78GAN
22
+ zVh3EBftVZSO8po=
23
+ -----END CERTIFICATE-----
24
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.1.6
3
+ Version: 2.2.0
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)
@@ -54,6 +54,7 @@ tests/test_bucket.py
54
54
  tests/test_change_pw.py
55
55
  tests/test_comment.py
56
56
  tests/test_del_post.py
57
+ tests/test_del_post_unit.py
57
58
  tests/test_exceptions.py
58
59
  tests/test_fast_post.py
59
60
  tests/test_get_board_info.py
@@ -79,6 +80,7 @@ tests/test_mail_send_and_del.py
79
80
  tests/test_mark_post.py
80
81
  tests/test_parse_query_post.py
81
82
  tests/test_post.py
83
+ tests/test_post_anonymous.py
82
84
  tests/test_pyte_parity.py
83
85
  tests/test_reply_post.py
84
86
  tests/test_search_user.py
@@ -0,0 +1,78 @@
1
+ """
2
+ No-network unit tests for the pure validation helpers in _api_del_post.py.
3
+ These call the module functions directly and don't need a live PTT session.
4
+ """
5
+ import pytest
6
+
7
+ import PyPtt
8
+ from PyPtt import _api_del_post
9
+
10
+
11
+ class TestCheckBadPostReason:
12
+ def test_within_limit_ok(self):
13
+ _api_del_post._check_bad_post_reason('測' * 25) # 50 bytes, exactly at the limit
14
+
15
+ def test_over_limit_raises(self):
16
+ with pytest.raises(PyPtt.exceptions.ParameterError):
17
+ _api_del_post._check_bad_post_reason('測' * 25 + 'x') # 51 bytes
18
+
19
+ def test_control_char_raises(self):
20
+ with pytest.raises(PyPtt.exceptions.ParameterError):
21
+ _api_del_post._check_bad_post_reason('bad\x07reason')
22
+
23
+
24
+ class TestCheckReasonStatic:
25
+ """`_check_reason` is the early, post_info-independent guard: control
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
28
+ (1-byte) moderator/author PTT ids -- the most bytes a reason could ever
29
+ fit under any real id pair. It therefore never rejects a reason that
30
+ might be legal once the real ids are known; that exact, smaller,
31
+ id-dependent budget is _check_reason_budget's job (see below)."""
32
+
33
+ def test_within_rough_ceiling_ok(self):
34
+ _api_del_post._check_reason('測' * 12 + 'x') # 25 bytes, exactly the rough ceiling
35
+
36
+ def test_over_rough_ceiling_raises(self):
37
+ with pytest.raises(PyPtt.exceptions.ParameterError):
38
+ _api_del_post._check_reason('測' * 12 + 'xx') # 26 bytes
39
+
40
+ def test_control_char_raises(self):
41
+ with pytest.raises(PyPtt.exceptions.ParameterError):
42
+ _api_del_post._check_reason('bad\x07reason')
43
+
44
+ def test_non_big5_raises(self):
45
+ with pytest.raises(PyPtt.exceptions.ParameterError):
46
+ _api_del_post._check_reason('emoji not in big5uao \U0001F600')
47
+
48
+
49
+ class TestCheckReasonBudget:
50
+ """`_check_reason_budget` is the exact, post_info-dependent guard: reason
51
+ gets appended, after a leading space, to pttbbs's prefilled
52
+ "(已被<moderator_id>刪除) <author_id>" title inside a 41-Big5-byte buffer
53
+ (_REASON_TITLE_BUFFER_BYTES). Budget for a 9-char/9-char id pair (e.g.
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
56
+ against a local pttbbs rebuilt with SAFE_ARTICLE_DELETE enabled."""
57
+
58
+ MOD_ID = 'pypttbot1'
59
+ AUTHOR_ID = 'pypttbot2'
60
+
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
63
+
64
+ def test_over_limit_by_one_byte_raises(self):
65
+ with pytest.raises(PyPtt.exceptions.ParameterError):
66
+ _api_del_post._check_reason_budget('測測測測測', self.MOD_ID, self.AUTHOR_ID) # 10 bytes
67
+
68
+ def test_over_limit_ascii_raises(self):
69
+ with pytest.raises(PyPtt.exceptions.ParameterError):
70
+ _api_del_post._check_reason_budget('x' * 10, self.MOD_ID, self.AUTHOR_ID)
71
+
72
+ def test_budget_shrinks_with_longer_ids(self):
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
75
+ longer fits."""
76
+ with pytest.raises(PyPtt.exceptions.ParameterError):
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
@@ -0,0 +1,96 @@
1
+ """Tests PyPtt issue #84 (匿名發文): post()'s anonymous/display_id params
2
+ against a board that actually carries pttbbs' BRD_ANONYMOUS/BRD_DEFAULTANONYMOUS
3
+ flags. Only pttbbs boards flagged this way ever show the anonymous prompt --
4
+ PyPtt cannot force anonymity on a normal board (see PyPtt/_api_post.py). Only
5
+ scripts/bootstrap_local_pttbbs.py's local container provisions such a board
6
+ (AnonTest), so this file is LOCALHOST-only.
7
+ """
8
+ import os
9
+ import time
10
+ import uuid
11
+
12
+ import pytest
13
+
14
+ import PyPtt
15
+ from PyPtt import PostField, NewIndex
16
+
17
+ if os.environ.get('PTT_HOST') != 'LOCALHOST':
18
+ pytest.skip(
19
+ 'test_post_anonymous.py needs the AnonTest board, which only '
20
+ 'scripts/bootstrap_local_pttbbs.py provisions. Set PTT_HOST=LOCALHOST to run.',
21
+ allow_module_level=True)
22
+
23
+ ANON_BOARD = 'AnonTest'
24
+
25
+
26
+ def _find_own_post(ptt_bot, title_marker):
27
+ """Scan back from the newest post on ANON_BOARD for the one whose title
28
+ contains title_marker, and return its parsed post dict."""
29
+ newest_index = ptt_bot.get_newest_index(index_type=NewIndex.BOARD, board=ANON_BOARD)
30
+ for i in range(10):
31
+ index = newest_index - i
32
+ if index <= 0:
33
+ break
34
+ post = ptt_bot.get_post(board=ANON_BOARD, index=index)
35
+ if title_marker in post[PostField.title]:
36
+ return post
37
+ raise AssertionError(f'could not find a post titled with marker {title_marker!r}')
38
+
39
+
40
+ def test_post_anonymous(ptt_bots):
41
+ """anonymous=True, display_id=None -> pttbbs masks the author as
42
+ 'Anonymous.' (AnonTest is bootstrapped BRD_DEFAULTANONYMOUS, so an empty
43
+ response to the anon prompt means 'Anonymous', not the real id; pttbbs
44
+ appends a trailing '.' the same way it does for a custom display_id)."""
45
+ ptt_bot = ptt_bots[0]
46
+ marker = uuid.uuid4().hex[:12]
47
+ title = f'PyPtt anon test {marker}'
48
+
49
+ ptt_bot.post(board=ANON_BOARD, title_index=1, title=title,
50
+ content='PyPtt anonymous post test content.\n', sign_file=0,
51
+ anonymous=True)
52
+
53
+ time.sleep(1)
54
+ post = _find_own_post(ptt_bot, marker)
55
+
56
+ author = post[PostField.author]
57
+ assert author.split(' ')[0] == 'Anonymous.', f'expected masked author "Anonymous.", got {author!r}'
58
+ assert ptt_bot.ptt_id not in author, f'real ptt_id leaked into author field: {author!r}'
59
+
60
+
61
+ def test_post_anonymous_with_display_id(ptt_bots):
62
+ """anonymous=True, display_id='PyPttFox' -> pttbbs masks the author as
63
+ the custom name plus a trailing '.' (pttbbs behavior, not adjustable)."""
64
+ ptt_bot = ptt_bots[0]
65
+ marker = uuid.uuid4().hex[:12]
66
+ title = f'PyPtt anon custom id test {marker}'
67
+ display_id = 'PyPttFox'
68
+
69
+ ptt_bot.post(board=ANON_BOARD, title_index=1, title=title,
70
+ content='PyPtt anonymous post (custom display id) test content.\n', sign_file=0,
71
+ anonymous=True, display_id=display_id)
72
+
73
+ time.sleep(1)
74
+ post = _find_own_post(ptt_bot, marker)
75
+
76
+ author = post[PostField.author]
77
+ assert author.split(' ')[0] == f'{display_id}.', f'expected masked author "{display_id}.", got {author!r}'
78
+ assert ptt_bot.ptt_id not in author, f'real ptt_id leaked into author field: {author!r}'
79
+
80
+
81
+ def test_post_not_anonymous_shows_real_id(ptt_bots):
82
+ """anonymous=False (default) -> even on an anonymous-capable board, the
83
+ real ptt_id is used (safe default, backward compatible with pre-#84
84
+ callers of post())."""
85
+ ptt_bot = ptt_bots[0]
86
+ marker = uuid.uuid4().hex[:12]
87
+ title = f'PyPtt non-anon test {marker}'
88
+
89
+ ptt_bot.post(board=ANON_BOARD, title_index=1, title=title,
90
+ content='PyPtt non-anonymous post test content.\n', sign_file=0)
91
+
92
+ time.sleep(1)
93
+ post = _find_own_post(ptt_bot, marker)
94
+
95
+ author = post[PostField.author]
96
+ assert author.split(' ')[0] == ptt_bot.ptt_id, f'expected real author {ptt_bot.ptt_id!r}, got {author!r}'
@@ -1,24 +0,0 @@
1
- key = """-----BEGIN EC PARAMETERS-----
2
- BggqhkjOPQMBBw==
3
- -----END EC PARAMETERS-----
4
- -----BEGIN EC PRIVATE KEY-----
5
- MHcCAQEEIFGaW2sWvlg2R4UP5e4CNg4hpmJ2+AH1p+ZDaE2L4ZfzoAoGCCqGSM49
6
- AwEHoUQDQgAEMNlx7QJJjC6RbB9mPHUMmGK0uwUaTJ53DMzaFb4/DD1dhYpOvKcu
7
- qZaNpQbvNCaBN5Zz+uJm78zyJXxXl6uTgw==
8
- -----END EC PRIVATE KEY-----
9
- """
10
-
11
- cert = """-----BEGIN CERTIFICATE-----
12
- MIIB6TCCAY8CFAKFPO04kcFNRJPUCM1H8KteAaEwMAoGCCqGSM49BAMCMHcxCzAJ
13
- BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
- CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
- EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTgwMjMyMjdaFw0zNjA3MTUwMjMy
16
- MjdaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
- eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
- bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
- AwEHA0IABDDZce0CSYwukWwfZjx1DJhitLsFGkyedwzM2hW+Pww9XYWKTrynLqmW
20
- jaUG7zQmgTeWc/riZu/M8iV8V5erk4MwCgYIKoZIzj0EAwIDSAAwRQIgMOY/7UHn
21
- waVAljYZrhuUwCQxcibRzy006NsenvmTLLwCIQDuQAAsbdtAfswclkrDHBtoCeCl
22
- VphR8OhIzsjJE7SKjA==
23
- -----END CERTIFICATE-----
24
- """
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