PyPtt 2.1.2__tar.gz → 2.1.4__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 (90) hide show
  1. {pyptt-2.1.2 → pyptt-2.1.4}/PKG-INFO +1 -1
  2. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/PTT.py +4 -2
  3. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_del_post.py +41 -12
  5. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/connect_core.py +12 -4
  6. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/screens.py +3 -1
  7. pyptt-2.1.4/PyPtt/ssl_config.py +24 -0
  8. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/PKG-INFO +1 -1
  9. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_bucket.py +2 -1
  10. pyptt-2.1.4/tests/test_del_post.py +172 -0
  11. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_newest_index.py +12 -1
  12. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_waterball.py +7 -0
  13. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_give_money.py +5 -0
  14. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_mark_post.py +3 -2
  15. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_service.py +7 -1
  16. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_set_board_title.py +2 -1
  17. pyptt-2.1.2/PyPtt/ssl_config.py +0 -24
  18. pyptt-2.1.2/tests/test_del_post.py +0 -88
  19. {pyptt-2.1.2 → pyptt-2.1.4}/LICENSE +0 -0
  20. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_bucket.py +0 -0
  21. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_call_status.py +0 -0
  22. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_change_pw.py +0 -0
  23. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_comment.py +0 -0
  24. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_board_info.py +0 -0
  25. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_board_list.py +0 -0
  26. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_bottom_post_list.py +0 -0
  27. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_favourite_board.py +0 -0
  28. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_newest_index.py +0 -0
  29. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post.py +0 -0
  30. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post_index.py +0 -0
  31. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post_list.py +0 -0
  32. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_time.py +0 -0
  33. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_user.py +0 -0
  34. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_waterball.py +0 -0
  35. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_give_money.py +0 -0
  36. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_has_new_mail.py +0 -0
  37. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_loginout.py +0 -0
  38. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_mail.py +0 -0
  39. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_mark_post.py +0 -0
  40. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_post.py +0 -0
  41. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_reply_post.py +0 -0
  42. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_search_user.py +0 -0
  43. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_set_board_title.py +0 -0
  44. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_util.py +0 -0
  45. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/api_server.py +0 -0
  46. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/check_value.py +0 -0
  47. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/command.py +0 -0
  48. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/config.py +0 -0
  49. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/data_type.py +0 -0
  50. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/exceptions.py +0 -0
  51. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/i18n.py +0 -0
  52. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lang_en_US.py +0 -0
  53. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lang_zh_TW.py +0 -0
  54. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lib_util.py +0 -0
  55. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/log.py +0 -0
  56. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/service.py +0 -0
  57. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/SOURCES.txt +0 -0
  58. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/dependency_links.txt +0 -0
  59. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/requires.txt +0 -0
  60. {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/top_level.txt +0 -0
  61. {pyptt-2.1.2 → pyptt-2.1.4}/README.md +0 -0
  62. {pyptt-2.1.2 → pyptt-2.1.4}/pyproject.toml +0 -0
  63. {pyptt-2.1.2 → pyptt-2.1.4}/setup.cfg +0 -0
  64. {pyptt-2.1.2 → pyptt-2.1.4}/setup.py +0 -0
  65. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_change_pw.py +0 -0
  66. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_comment.py +0 -0
  67. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_exceptions.py +0 -0
  68. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_fast_post.py +0 -0
  69. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_board_info.py +0 -0
  70. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_board_list.py +0 -0
  71. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_bottom_post_list.py +0 -0
  72. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_content_formula.py +0 -0
  73. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_favourite_boards.py +0 -0
  74. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_mail.py +0 -0
  75. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post.py +0 -0
  76. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_list.py +0 -0
  77. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_list_parser.py +0 -0
  78. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_parser.py +0 -0
  79. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_time.py +0 -0
  80. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_user.py +0 -0
  81. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_i18n.py +0 -0
  82. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_init.py +0 -0
  83. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_logger.py +0 -0
  84. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_mail_send_and_del.py +0 -0
  85. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_parse_query_post.py +0 -0
  86. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_post.py +0 -0
  87. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_pyte_parity.py +0 -0
  88. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_reply_post.py +0 -0
  89. {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_search_user.py +0 -0
  90. {pyptt-2.1.2 → pyptt-2.1.4}/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.2
3
+ Version: 2.1.4
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)
@@ -1071,7 +1071,7 @@ class API:
1071
1071
 
1072
1072
  return _api_get_bottom_post_list.get_bottom_post_list(self, board)
1073
1073
 
1074
- def del_post(self, board: str, aid: Optional[str] = None, index: int = 0) -> None:
1074
+ def del_post(self, board: str, aid: Optional[str] = None, index: int = 0, reason: Optional[str] = None) -> None:
1075
1075
  """
1076
1076
  刪除文章。
1077
1077
 
@@ -1079,6 +1079,7 @@ class API:
1079
1079
  board (str): 看板名稱。
1080
1080
  aid (str): 文章編號。
1081
1081
  index (int): 文章編號。
1082
+ reason (str): 板主刪除他人文章時,加註於刪除後標題的理由(僅板主刪除他板友文章時有效)。
1082
1083
 
1083
1084
  Returns:
1084
1085
  None
@@ -1089,6 +1090,7 @@ class API:
1089
1090
  NoSuchBoard: 看板不存在。
1090
1091
  NoSuchPost: 文章不存在。
1091
1092
  NoPermission: 沒有權限。
1093
+ ParameterError: 對自己的文章傳入 reason(加註理由僅適用於版主刪除他人文章)。
1092
1094
 
1093
1095
  範例::
1094
1096
 
@@ -1103,7 +1105,7 @@ class API:
1103
1105
  ptt_bot.logout()
1104
1106
  """
1105
1107
 
1106
- _api_del_post.del_post(self, board, aid, index)
1108
+ _api_del_post.del_post(self, board, aid, index, reason)
1107
1109
 
1108
1110
  def get_post_list(self, board: str, limit: int = 20, offset: int = 0) -> list[dict]:
1109
1111
  """
@@ -1,4 +1,4 @@
1
- __version__ = '2.1.2'
1
+ __version__ = '2.1.4'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -14,7 +14,7 @@ from . import log
14
14
  from . import screens
15
15
 
16
16
 
17
- def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0) -> None:
17
+ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, reason: Optional[str] = None) -> None:
18
18
  _api_util.one_thread(api)
19
19
 
20
20
  if not api.is_registered_user:
@@ -27,6 +27,8 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
27
27
  if post_aid is not None:
28
28
  check_value.check_type(post_aid, str, 'PostAID')
29
29
  check_value.check_type(post_index, int, 'PostIndex')
30
+ if reason is not None:
31
+ check_value.check_type(reason, str, 'reason')
30
32
 
31
33
  if len(board) == 0:
32
34
  raise exceptions.ParameterError(f'board error parameter: {board}')
@@ -64,6 +66,13 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
64
66
  log.logger.info(i18n.delete_post, '...', i18n.success)
65
67
  return
66
68
 
69
+ # PTT only offers the "R加註理由" annotation when a moderator deletes
70
+ # another user's post. On your own post the reason can never be applied,
71
+ # so reject it rather than silently dropping it.
72
+ if reason and api.ptt_id.lower() == post_info[data_type.PostField.author].lower():
73
+ raise exceptions.ParameterError(
74
+ 'reason is only valid when a moderator deletes another user\'s post')
75
+
67
76
  if check_author:
68
77
  if api.ptt_id.lower() != post_info[data_type.PostField.author].lower():
69
78
  log.logger.info(i18n.delete_post, '...', i18n.fail)
@@ -90,20 +99,40 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
90
99
  def confirm_delete_handler(screen):
91
100
  api.confirm = True
92
101
 
102
+ # A board moderator deleting another user's post gets an extra
103
+ # "R加註理由" option that lets us annotate the title shown after deletion.
104
+ use_reason = bool(reason) and not check_author
105
+
106
+ def confirm_delete_response(screen):
107
+ if use_reason and '加註理由' in screen:
108
+ return 'r' + command.enter
109
+ return 'y' + command.enter
110
+
93
111
  target_list = [
94
112
  connect_core.TargetUnit('請按任意鍵繼續', response=' '),
95
- connect_core.TargetUnit('請確定刪除(Y/N)?[N]', response='y' + command.enter, handler=confirm_delete_handler,
96
- max_match=1),
97
- connect_core.TargetUnit(screens.Target.InBoard, break_detect=True),
113
+ connect_core.TargetUnit('請確定刪除(Y/N', response=confirm_delete_response,
114
+ handler=confirm_delete_handler, max_match=1),
98
115
  ]
99
116
 
100
- index = api.connect_core.send(
101
- cmd,
102
- target_list)
103
-
104
- if index == 1:
105
- if not api.confirm:
106
- log.logger.info(i18n.delete_post, '...', i18n.fail)
107
- raise exceptions.NoPermission(i18n.no_permission)
117
+ if use_reason:
118
+ target_list.append(
119
+ connect_core.TargetUnit(
120
+ '請輸入刪除後要顯示的標題',
121
+ response=command.ctrl_e + ' ' + reason + command.enter,
122
+ max_match=1))
123
+ target_list.append(
124
+ connect_core.TargetUnit(
125
+ '請再次確定是否要用上述理由刪除(Y/N',
126
+ response='y' + command.enter,
127
+ max_match=1))
128
+
129
+ target_list.append(
130
+ connect_core.TargetUnit(screens.Target.InBoard, break_detect=True))
131
+
132
+ api.connect_core.send(cmd, target_list)
133
+
134
+ if not api.confirm:
135
+ log.logger.info(i18n.delete_post, '...', i18n.fail)
136
+ raise exceptions.NoPermission(i18n.no_permission)
108
137
 
109
138
  log.logger.info(i18n.delete_post, '...', i18n.success)
@@ -196,8 +196,12 @@ class API(object):
196
196
  websocket_host = 'wss://ws.ptt2.cc/bbs/'
197
197
  websocket_origin = 'https://term.ptt2.cc'
198
198
  elif self.config.host == data_type.HOST.LOCALHOST:
199
- websocket_host = 'wss://localhost'
200
- websocket_origin = 'https://term.ptt.cc'
199
+ # ponytail: local pttbbs docker image (bbsdocker/imageptt) serves plain ws
200
+ # (no TLS) on 48763; config.port defaults to 23 (telnet default) so treat
201
+ # that as "not set" and fall back to imageptt's default port
202
+ port = self.config.port if self.config.port != 23 else 48763
203
+ websocket_host = f'ws://localhost:{port}/bbs'
204
+ websocket_origin = 'http://localhost'
201
205
  else:
202
206
  websocket_host = f'wss://{self.config.host}'
203
207
  websocket_origin = 'https://term.ptt.cc'
@@ -209,11 +213,15 @@ class API(object):
209
213
  try:
210
214
  log.logger.debug('USER_AGENT',
211
215
  websockets.http11.USER_AGENT if use_http11 else websockets.http.USER_AGENT)
216
+ # ponytail: ws:// (local docker target) can't take an ssl context —
217
+ # websockets errors out if you pass one on a non-wss URI
218
+ connect_kwargs = {'origin': websocket_origin}
219
+ if websocket_host.startswith('wss://'):
220
+ connect_kwargs['ssl'] = self._ssl_context
212
221
  self._core = loop.run_until_complete(
213
222
  websockets.connect(
214
223
  websocket_host,
215
- origin=websocket_origin,
216
- ssl=self._ssl_context))
224
+ **connect_kwargs))
217
225
  # Respond to PTT's IAC DO NAWS (ff fd 1f) and announce terminal size.
218
226
  # PTT honours any height >= 24; larger values reduce get_content iterations.
219
227
  # PTT's hard cap is 100 rows regardless of what we send.
@@ -199,7 +199,9 @@ class Target:
199
199
  content_end_list = [
200
200
  '--\n※ 發信站: 批踢踢實業坊',
201
201
  '--\n※ 發信站: 批踢踢兔(ptt2.cc)',
202
- '--\n※ 發信站: 新批踢踢(ptt2.twbbs.org.tw)'
202
+ '--\n※ 發信站: 新批踢踢(ptt2.twbbs.org.tw)',
203
+ # bbsdocker/imageptt (HOST.LOCALHOST test container) site name.
204
+ '--\n※ 發信站: 批踢踢 docker(pttdocker.test)'
203
205
  ]
204
206
 
205
207
  OnlineUser = [
@@ -0,0 +1,24 @@
1
+ key = """-----BEGIN EC PARAMETERS-----
2
+ BggqhkjOPQMBBw==
3
+ -----END EC PARAMETERS-----
4
+ -----BEGIN EC PRIVATE KEY-----
5
+ MHcCAQEEIPSUMd0VM3Lz9oHqzRjDOoA7mMSwkff1CvCmOhswqdeToAoGCCqGSM49
6
+ AwEHoUQDQgAEFL8NgpDyAYc1AfvrY4QeOOF5bEaj3fQyrfF+SQhRzgqnKFPxCVVs
7
+ qPE3yCC7L/2VweGjoPMg1YPOsSTsiliqlg==
8
+ -----END EC PRIVATE KEY-----
9
+ """
10
+
11
+ cert = """-----BEGIN CERTIFICATE-----
12
+ MIIB6jCCAY8CFFnMpZwQZ949yt925TXsbCqq9/+SMAoGCCqGSM49BAMCMHcxCzAJ
13
+ BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
+ CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
+ EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTMxNTE4MDhaFw0zNjA3MTAxNTE4
16
+ MDhaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
+ eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
+ bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
+ AwEHA0IABBS/DYKQ8gGHNQH762OEHjjheWxGo930Mq3xfkkIUc4KpyhT8QlVbKjx
20
+ N8gguy/9lcHho6DzINWDzrEk7IpYqpYwCgYIKoZIzj0EAwIDSQAwRgIhAJaKKQ+t
21
+ oEPH2j3xs3Rg3F3IR/8EdlzdD+mFPoXtjl9dAiEAhvHMefijy34Ya268zOGArdNz
22
+ 5msfSDH5QT27scqencw=
23
+ -----END CERTIFICATE-----
24
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.1.2
3
+ Version: 2.1.4
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)
@@ -17,6 +17,7 @@ import pytest
17
17
 
18
18
  import PyPtt
19
19
  from tests import config
20
+ from tests import util
20
21
 
21
22
 
22
23
  def test_bucket_nonexistent_user_raises(ptt_bots):
@@ -38,7 +39,7 @@ def test_bucket_on_moderated_board(ptt_bots):
38
39
  if not config.MOD_BOARD:
39
40
  pytest.skip('MOD_BOARD env var not set')
40
41
  for ptt_bot in ptt_bots:
41
- if ptt_bot.host != PyPtt.HOST.PTT1:
42
+ if not util.is_primary_host(ptt_bot, ptt_bots):
42
43
  continue
43
44
  try:
44
45
  ptt_bot.bucket(
@@ -0,0 +1,172 @@
1
+ import json
2
+ import time
3
+
4
+ import pytest
5
+
6
+ import PyPtt
7
+ from tests import config
8
+ from tests import util
9
+
10
+
11
+ def test_del_own_post(ptt_bots):
12
+ """Tests that a user can delete their own post."""
13
+ for ptt_bot in ptt_bots:
14
+ # 1. Post an article to delete
15
+ post_title = f"PyPtt Delete Test Post {int(time.time())}"
16
+ post_content = "This is a test post for deletion."
17
+ ptt_bot.post(
18
+ board='Test',
19
+ title_index=1,
20
+ title=post_title,
21
+ content=post_content
22
+ )
23
+ time.sleep(1) # Allow time for post to be indexed
24
+
25
+ newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
26
+
27
+ # 2. Verify we found our post
28
+ for i in range(5):
29
+ if newest_index - i <= 0:
30
+ continue
31
+ post_data = ptt_bot.get_post('Test', index=newest_index - i)
32
+
33
+ if post_data['author'].startswith(ptt_bot.ptt_id):
34
+ break
35
+
36
+ assert post_data['author'].startswith(ptt_bot.ptt_id)
37
+ assert post_data['title'] == f"[測試] {post_title}"
38
+
39
+ # 3. Delete the post
40
+
41
+ for i in range(5): # Retry up to 3 times in case of transient issues
42
+ if newest_index - i <= 0:
43
+ continue
44
+ try:
45
+ ptt_bot.del_post(board='Test', index=newest_index - i)
46
+ except PyPtt.exceptions.NoPermission:
47
+ pass
48
+
49
+ for i in range(5):
50
+ if newest_index - i <= 0:
51
+ continue
52
+ try:
53
+ deleted_post_data = ptt_bot.get_post('Test', index=newest_index - i)
54
+ except PyPtt.exceptions.ParameterError:
55
+ # On a small local board, deleting every remaining post can
56
+ # shrink the board's live index range out from under this
57
+ # stale `newest_index`; an out-of-range index unambiguously
58
+ # means the post is gone, which is what this loop checks for.
59
+ continue
60
+
61
+ print(json.dumps(deleted_post_data, ensure_ascii=False, indent=2))
62
+
63
+ if deleted_post_data[PyPtt.PostField.post_status] in [
64
+ PyPtt.PostStatus.DELETED_BY_AUTHOR,
65
+ PyPtt.PostStatus.DELETED_BY_MODERATOR,
66
+ PyPtt.PostStatus.DELETED_BY_UNKNOWN
67
+ ]:
68
+ continue
69
+
70
+ if deleted_post_data[PyPtt.PostField.author].split(' ')[0] != post_data[PyPtt.PostField.author].split(' ')[
71
+ 0]:
72
+ continue
73
+
74
+ # author is myself but not deleted
75
+ assert False
76
+
77
+
78
+
79
+ def test_del_other_post_permission_error(ptt_bots):
80
+ """Tests that deleting another user's post raises NoPermission."""
81
+ for ptt_bot in ptt_bots:
82
+ # Find a post on a public board not authored by the bot
83
+ board = 'SYSOP'
84
+ newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
85
+
86
+ target_index = -1
87
+ for i in range(10): # Check the last 10 posts
88
+ index_to_check = newest_index - i
89
+ if index_to_check <= 0:
90
+ continue
91
+ try:
92
+ post = ptt_bot.get_post(board, index=index_to_check, query=True)
93
+ if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
94
+ ptt_bot.ptt_id):
95
+ target_index = index_to_check
96
+ break
97
+ except PyPtt.NoSuchPost:
98
+ continue
99
+
100
+ if target_index == -1:
101
+ pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
102
+
103
+ # 5. Try to delete it and expect a permission error
104
+ with pytest.raises(PyPtt.NoPermission):
105
+ ptt_bot.del_post(board=board, index=target_index)
106
+
107
+
108
+ def test_del_own_post_with_reason_raises(ptt_bots):
109
+ """Passing a reason for your own post is rejected: the annotation only
110
+ applies when a moderator deletes another user's post."""
111
+ for ptt_bot in ptt_bots:
112
+ post_title = f"PyPtt Reason Reject Test {int(time.time())}"
113
+ ptt_bot.post(board='Test', title_index=1, title=post_title,
114
+ content="This is a test post for deletion.")
115
+ time.sleep(1)
116
+
117
+ newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
118
+ index = -1
119
+ for i in range(5):
120
+ if newest_index - i <= 0:
121
+ continue
122
+ post_data = ptt_bot.get_post('Test', index=newest_index - i)
123
+ if post_data['author'].startswith(ptt_bot.ptt_id) and \
124
+ post_data['title'] == f"[測試] {post_title}":
125
+ index = newest_index - i
126
+ break
127
+ assert index != -1
128
+
129
+ # reason on your own post must raise, and must NOT delete it
130
+ with pytest.raises(PyPtt.exceptions.ParameterError):
131
+ ptt_bot.del_post(board='Test', index=index, reason='測試理由')
132
+
133
+ # clean up: a plain delete still works
134
+ ptt_bot.del_post(board='Test', index=index)
135
+
136
+
137
+ def test_del_other_post_with_reason_as_moderator(ptt_bots):
138
+ """Tests that a board moderator can delete another user's post and
139
+ annotate a reason on the resulting title."""
140
+ if not config.MOD_BOARD:
141
+ pytest.skip('MOD_BOARD env var not set')
142
+ for ptt_bot in ptt_bots:
143
+ if not util.is_primary_host(ptt_bot, ptt_bots):
144
+ continue # MOD_BOARD is moderated by the primary bot
145
+
146
+ if ptt_bot.host == PyPtt.HOST.LOCALHOST:
147
+ pytest.skip('local imageptt hard-deletes and reindexes moderator '
148
+ 'posts, leaving no DELETED_BY_MODERATOR tombstone to read back')
149
+
150
+ board = config.MOD_BOARD
151
+ newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
152
+
153
+ # Find a recent existing post on MOD_BOARD not authored by the bot.
154
+ target_index = -1
155
+ for i in range(10):
156
+ index_to_check = newest_index - i
157
+ try:
158
+ post = ptt_bot.get_post(board, index=index_to_check, query=True)
159
+ if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
160
+ ptt_bot.ptt_id):
161
+ target_index = index_to_check
162
+ break
163
+ except PyPtt.NoSuchPost:
164
+ continue
165
+
166
+ if target_index == -1:
167
+ pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
168
+
169
+ ptt_bot.del_post(board=board, index=target_index, reason='PyPtt 自動測試刪文理由')
170
+
171
+ deleted_post = ptt_bot.get_post(board, index=target_index, query=True)
172
+ assert deleted_post[PyPtt.PostField.post_status] == PyPtt.PostStatus.DELETED_BY_MODERATOR
@@ -20,6 +20,15 @@ PTT2_BOARD_CASES = [
20
20
  ('PttSuggest', PyPtt.SearchType.KEYWORD, '[問題]'),
21
21
  ]
22
22
 
23
+ # The PTT1/PTT2 cases above search real, host-specific historical posts and
24
+ # boards that don't exist on a local imageptt container. `Python` is seeded
25
+ # with at least one post by scripts/bootstrap_local_pttbbs.py and — unlike
26
+ # `Test` — no other test in the suite deletes from it, so its post count
27
+ # only ever grows across a full-suite run.
28
+ LOCALHOST_BOARD_CASES = [
29
+ ('Python', None, None),
30
+ ]
31
+
23
32
  def test_get_mail_index(ptt_bots):
24
33
  """Tests getting the newest mail index."""
25
34
  for ptt_bot in ptt_bots:
@@ -30,7 +39,9 @@ def test_get_mail_index(ptt_bots):
30
39
  def test_get_board_index(ptt_bots):
31
40
  """Tests getting the newest board index on PTT2 with various search parameters."""
32
41
  for ptt_bot in ptt_bots:
33
- if ptt_bot.config.host == PyPtt.HOST.PTT1:
42
+ if ptt_bot.config.host == PyPtt.HOST.LOCALHOST:
43
+ test_params = LOCALHOST_BOARD_CASES
44
+ elif ptt_bot.config.host == PyPtt.HOST.PTT1:
34
45
  test_params = PTT1_BOARD_CASES
35
46
  else:
36
47
  test_params = PTT2_BOARD_CASES
@@ -7,9 +7,16 @@ try:
7
7
  except KeyError:
8
8
  import config
9
9
 
10
+ import os
11
+
12
+ # ponytail: PTT_HOST=LOCALHOST points at a local imageptt docker container
13
+ # instead of the real PTT1 host; unset keeps the status quo.
14
+ _use_localhost = os.environ.get('PTT_HOST') == 'LOCALHOST'
15
+
10
16
 
11
17
  def test_get_waterball():
12
18
  ptt1_bot = PyPtt.API(
19
+ host=PyPtt.HOST.LOCALHOST if _use_localhost else PyPtt.HOST.PTT1,
13
20
  )
14
21
  ptt1_bot.login(ptt_id=config.PTT1_ID, ptt_pw=config.PTT1_PW, kick_other_session=True)
15
22
 
@@ -12,6 +12,11 @@ def test_give_money(ptt_bots):
12
12
  # PTT2 test host does not support giving money.
13
13
  if ptt_bot.host == PyPtt.HOST.PTT2:
14
14
  continue
15
+ # Can't give money to yourself — under LOCALHOST both bots share the
16
+ # same .host value, so the PTT2 skip above doesn't disambiguate them,
17
+ # and TEST_USER may legitimately be the PTT2 account's own ptt_id.
18
+ if ptt_bot.ptt_id.lower() == recipient_id.lower():
19
+ continue
15
20
 
16
21
  try:
17
22
  # This call should not raise an exception on success
@@ -13,6 +13,7 @@ import pytest
13
13
  import PyPtt
14
14
  from PyPtt import PostField, NewIndex
15
15
  from tests import config
16
+ from tests import util
16
17
 
17
18
 
18
19
  def _post_and_get_aid(ptt_bot, board: str) -> str:
@@ -39,8 +40,8 @@ def test_mark_post_on_moderated_board(ptt_bots):
39
40
  if not config.MOD_BOARD:
40
41
  pytest.skip('MOD_BOARD env var not set')
41
42
  for ptt_bot in ptt_bots:
42
- if ptt_bot.host != PyPtt.HOST.PTT1:
43
- continue # MOD_BOARD is a PTT1 board
43
+ if not util.is_primary_host(ptt_bot, ptt_bots):
44
+ continue # MOD_BOARD is moderated by the primary bot
44
45
  aid = _post_and_get_aid(ptt_bot, board=config.MOD_BOARD)
45
46
  try:
46
47
  ptt_bot.mark_post(
@@ -1,3 +1,5 @@
1
+ import os
2
+
1
3
  import pytest
2
4
  import PyPtt
3
5
  import threading
@@ -5,6 +7,10 @@ import time
5
7
  from PyPtt import Service, PostField
6
8
  from tests import config # Assuming config.py has PTT1_ID and PTT1_PW
7
9
 
10
+ # ponytail: PTT_HOST=LOCALHOST points at a local imageptt docker container
11
+ # instead of the real PTT1 host; unset keeps the status quo.
12
+ _use_localhost = os.environ.get('PTT_HOST') == 'LOCALHOST'
13
+
8
14
  class TestCallInterval:
9
15
  """Tests for call_interval feature (no network required)."""
10
16
 
@@ -56,7 +62,7 @@ class TestCallInterval:
56
62
  def service_instance():
57
63
  import time
58
64
 
59
- pyptt_init_config = {}
65
+ pyptt_init_config = {'host': PyPtt.HOST.LOCALHOST} if _use_localhost else {}
60
66
  service = Service(pyptt_init_config)
61
67
 
62
68
  max_retries = 3
@@ -16,6 +16,7 @@ import pytest
16
16
 
17
17
  import PyPtt
18
18
  from tests import config
19
+ from tests import util
19
20
 
20
21
 
21
22
  def test_a_set_board_title_without_moderator_perm_raises(ptt_bots):
@@ -36,7 +37,7 @@ def test_b_set_board_title_on_moderated_board(ptt_bots):
36
37
  if not config.MOD_BOARD:
37
38
  pytest.skip('MOD_BOARD env var not set')
38
39
  for ptt_bot in ptt_bots:
39
- if ptt_bot.host != PyPtt.HOST.PTT1:
40
+ if not util.is_primary_host(ptt_bot, ptt_bots):
40
41
  continue
41
42
  info = ptt_bot.get_board_info(board=config.MOD_BOARD)
42
43
  original = info[PyPtt.BoardField.mandarin_des]
@@ -1,24 +0,0 @@
1
- key = """-----BEGIN EC PARAMETERS-----
2
- BggqhkjOPQMBBw==
3
- -----END EC PARAMETERS-----
4
- -----BEGIN EC PRIVATE KEY-----
5
- MHcCAQEEIFxFRO1jNtGmuhLNAdh+bbaPqEl8jXy2OnIDJ7xcYpKQoAoGCCqGSM49
6
- AwEHoUQDQgAEWh3ZP/+oUkac+bXn6m1DpFOQ8ZxJO6R1AurOgK8Q+Dwr0mS/0+nO
7
- wwE63McobMsFUiGM3t3YdzfKnlTAECNlbw==
8
- -----END EC PRIVATE KEY-----
9
- """
10
-
11
- cert = """-----BEGIN CERTIFICATE-----
12
- MIIB6TCCAY8CFB0+cdUrN+Ovk+BQN8KWG1vDyrW5MAoGCCqGSM49BAMCMHcxCzAJ
13
- BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
- CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
- EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA1MjUxNDI4MzdaFw0zNjA1MjIxNDI4
16
- MzdaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
- eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
- bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
- AwEHA0IABFod2T//qFJGnPm15+ptQ6RTkPGcSTukdQLqzoCvEPg8K9Jkv9PpzsMB
20
- OtzHKGzLBVIhjN7d2Hc3yp5UwBAjZW8wCgYIKoZIzj0EAwIDSAAwRQIhAMQMHvf4
21
- CIY36vOEAMrop9Z0RNEGTPF+EQjj95BZNsayAiBxUKgbC3Sdw34xKfUSdO/K3fP7
22
- ou/8qOK+MUBXLV73zw==
23
- -----END CERTIFICATE-----
24
- """
@@ -1,88 +0,0 @@
1
- import json
2
- import time
3
-
4
- import pytest
5
-
6
- import PyPtt
7
-
8
-
9
- def test_del_own_post(ptt_bots):
10
- """Tests that a user can delete their own post."""
11
- for ptt_bot in ptt_bots:
12
- # 1. Post an article to delete
13
- post_title = f"PyPtt Delete Test Post {int(time.time())}"
14
- post_content = "This is a test post for deletion."
15
- ptt_bot.post(
16
- board='Test',
17
- title_index=1,
18
- title=post_title,
19
- content=post_content
20
- )
21
- time.sleep(1) # Allow time for post to be indexed
22
-
23
- newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
24
-
25
- # 2. Verify we found our post
26
- for i in range(5):
27
- post_data = ptt_bot.get_post('Test', index=newest_index - i)
28
-
29
- if post_data['author'].startswith(ptt_bot.ptt_id):
30
- break
31
-
32
- assert post_data['author'].startswith(ptt_bot.ptt_id)
33
- assert post_data['title'] == f"[測試] {post_title}"
34
-
35
- # 3. Delete the post
36
-
37
- for i in range(5): # Retry up to 3 times in case of transient issues
38
- try:
39
- ptt_bot.del_post(board='Test', index=newest_index - i)
40
- except PyPtt.exceptions.NoPermission:
41
- pass
42
-
43
- for i in range(5):
44
- deleted_post_data = ptt_bot.get_post('Test', index=newest_index - i)
45
-
46
- print(json.dumps(deleted_post_data, ensure_ascii=False, indent=2))
47
-
48
- if deleted_post_data[PyPtt.PostField.post_status] in [
49
- PyPtt.PostStatus.DELETED_BY_AUTHOR,
50
- PyPtt.PostStatus.DELETED_BY_MODERATOR,
51
- PyPtt.PostStatus.DELETED_BY_UNKNOWN
52
- ]:
53
- continue
54
-
55
- if deleted_post_data[PyPtt.PostField.author].split(' ')[0] != post_data[PyPtt.PostField.author].split(' ')[
56
- 0]:
57
- continue
58
-
59
- # author is myself but not deleted
60
- assert False
61
-
62
-
63
-
64
- def test_del_other_post_permission_error(ptt_bots):
65
- """Tests that deleting another user's post raises NoPermission."""
66
- for ptt_bot in ptt_bots:
67
- # Find a post on a public board not authored by the bot
68
- board = 'SYSOP'
69
- newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
70
-
71
- target_index = -1
72
- for i in range(10): # Check the last 10 posts
73
- index_to_check = newest_index - i
74
- try:
75
- post = ptt_bot.get_post(board, index=index_to_check, query=True)
76
- if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
77
- ptt_bot.ptt_id):
78
- target_index = index_to_check
79
- break
80
- except PyPtt.NoSuchPost:
81
- continue
82
-
83
- if target_index == -1:
84
- pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
85
-
86
- # 5. Try to delete it and expect a permission error
87
- with pytest.raises(PyPtt.NoPermission):
88
- ptt_bot.del_post(board=board, index=target_index)
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