PyPtt 2.1.4__tar.gz → 2.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyptt-2.1.4 → pyptt-2.1.5}/PKG-INFO +2 -1
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/PTT.py +17 -3
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/__init__.py +1 -1
- pyptt-2.1.5/PyPtt/_api_del_post.py +258 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_bottom_post_list.py +1 -1
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_user.py +12 -4
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/connect_core.py +4 -1
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/data_type.py +14 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/exceptions.py +8 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/lang_en_US.py +3 -1
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/lang_zh_TW.py +2 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/log.py +27 -2
- pyptt-2.1.5/PyPtt/ssl_config.py +24 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt.egg-info/PKG-INFO +2 -1
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt.egg-info/SOURCES.txt +1 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt.egg-info/requires.txt +1 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/pyproject.toml +1 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_del_post.py +134 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_exceptions.py +1 -0
- pyptt-2.1.5/tests/test_get_mail.py +68 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_newest_index.py +18 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_post.py +111 -0
- pyptt-2.1.5/tests/test_get_post_list.py +101 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_i18n.py +20 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_logger.py +19 -1
- pyptt-2.1.5/tests/test_login_logout.py +65 -0
- pyptt-2.1.4/PyPtt/_api_del_post.py +0 -138
- pyptt-2.1.4/PyPtt/ssl_config.py +0 -24
- pyptt-2.1.4/tests/test_get_mail.py +0 -40
- pyptt-2.1.4/tests/test_get_post_list.py +0 -71
- {pyptt-2.1.4 → pyptt-2.1.5}/LICENSE +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/_api_util.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/api_server.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/check_value.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/command.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/config.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/i18n.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/lib_util.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/screens.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt/service.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/README.md +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/setup.cfg +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/setup.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_bucket.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_change_pw.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_comment.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_fast_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_board_info.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_board_list.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_time.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_user.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_get_waterball.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_give_money.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_init.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_mark_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_reply_post.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_search_user.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_service.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.5}/tests/test_set_board_title.py +0 -0
- {pyptt-2.1.4 → pyptt-2.1.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.1.
|
|
3
|
+
Version: 2.1.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)
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: progressbar2
|
|
29
29
|
Requires-Dist: websockets>=12
|
|
30
|
+
Requires-Dist: certifi
|
|
30
31
|
Requires-Dist: uao
|
|
31
32
|
Requires-Dist: requests>=2.32
|
|
32
33
|
Requires-Dist: AutoStrEnum
|
|
@@ -1071,7 +1071,9 @@ 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, reason: Optional[str] = None
|
|
1074
|
+
def del_post(self, board: str, aid: Optional[str] = None, index: int = 0, reason: Optional[str] = None,
|
|
1075
|
+
bad_post_type: Optional[data_type.BadPostType] = None,
|
|
1076
|
+
bad_post_reason: Optional[str] = None) -> None:
|
|
1075
1077
|
"""
|
|
1076
1078
|
刪除文章。
|
|
1077
1079
|
|
|
@@ -1080,6 +1082,10 @@ class API:
|
|
|
1080
1082
|
aid (str): 文章編號。
|
|
1081
1083
|
index (int): 文章編號。
|
|
1082
1084
|
reason (str): 板主刪除他人文章時,加註於刪除後標題的理由(僅板主刪除他板友文章時有效)。
|
|
1085
|
+
bad_post_type (data_type.BadPostType): 板主刪除他人文章時,對作者記的惡退(劣文)分類,
|
|
1086
|
+
僅板主刪除他板友文章時有效。
|
|
1087
|
+
bad_post_reason (str): 惡退理由,僅 ``bad_post_type`` 為 ``BadPostType.OTHER`` 時可傳入且必須傳入,
|
|
1088
|
+
長度上限 50 字。
|
|
1083
1089
|
|
|
1084
1090
|
Returns:
|
|
1085
1091
|
None
|
|
@@ -1090,7 +1096,12 @@ class API:
|
|
|
1090
1096
|
NoSuchBoard: 看板不存在。
|
|
1091
1097
|
NoSuchPost: 文章不存在。
|
|
1092
1098
|
NoPermission: 沒有權限。
|
|
1093
|
-
ParameterError: 對自己的文章傳入 reason
|
|
1099
|
+
ParameterError: 對自己的文章傳入 reason 或 bad_post_type(僅適用於版主刪除他人文章),
|
|
1100
|
+
或 bad_post_type 為 BadPostType.OTHER 卻未傳入 bad_post_reason(或超過 50 bytes,
|
|
1101
|
+
或含有控制字元)。
|
|
1102
|
+
BadPostNotRecorded: 傳入 bad_post_type 時,文章已成功刪除,但 PTT 端沒有完成惡退
|
|
1103
|
+
(劣文)記錄流程(例如文章太舊而跳過惡退選單、或逾時)。代表刪除本身已經成功,
|
|
1104
|
+
只是這支惡退沒有記上。
|
|
1094
1105
|
|
|
1095
1106
|
範例::
|
|
1096
1107
|
|
|
@@ -1100,12 +1111,15 @@ class API:
|
|
|
1100
1111
|
try:
|
|
1101
1112
|
# .. login ..
|
|
1102
1113
|
ptt_bot.del_post(board='Python', aid='1TJH_XY0')
|
|
1114
|
+
# 板主刪除他人文章,並記一支「廣告」惡退
|
|
1115
|
+
ptt_bot.del_post(board='Python', aid='1TJH_XY0',
|
|
1116
|
+
bad_post_type=PyPtt.BadPostType.AD)
|
|
1103
1117
|
# .. do something ..
|
|
1104
1118
|
finally:
|
|
1105
1119
|
ptt_bot.logout()
|
|
1106
1120
|
"""
|
|
1107
1121
|
|
|
1108
|
-
_api_del_post.del_post(self, board, aid, index, reason)
|
|
1122
|
+
_api_del_post.del_post(self, board, aid, index, reason, bad_post_type, bad_post_reason)
|
|
1109
1123
|
|
|
1110
1124
|
def get_post_list(self, board: str, limit: int = 20, offset: int = 0) -> list[dict]:
|
|
1111
1125
|
"""
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from . import _api_util
|
|
6
|
+
from . import check_value
|
|
7
|
+
from . import command
|
|
8
|
+
from . import connect_core
|
|
9
|
+
from . import data_type
|
|
10
|
+
from . import exceptions
|
|
11
|
+
from . import i18n
|
|
12
|
+
from . import lib_util
|
|
13
|
+
from . import log
|
|
14
|
+
from . import screens
|
|
15
|
+
|
|
16
|
+
# pttbbs input fields cap length in bytes (Big5: 2 bytes per Chinese
|
|
17
|
+
# character), not in Python characters.
|
|
18
|
+
_BAD_POST_REASON_MAX_BYTES = 50
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_no_control_chars(name: str, value: str) -> None:
|
|
22
|
+
"""Terminal command injection guard: none of PyPtt's del_post text
|
|
23
|
+
parameters may contain control characters (they get typed directly
|
|
24
|
+
into the remote terminal via connect_core.send())."""
|
|
25
|
+
for c in value:
|
|
26
|
+
if ord(c) < 0x20 or ord(c) == 0x7f:
|
|
27
|
+
raise exceptions.ParameterError(
|
|
28
|
+
f'{name} must not contain control characters (found {c!r})')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_bad_post_reason(bad_post_reason: str) -> None:
|
|
32
|
+
_check_no_control_chars('bad_post_reason', bad_post_reason)
|
|
33
|
+
try:
|
|
34
|
+
encoded = bad_post_reason.encode('big5uao')
|
|
35
|
+
except UnicodeEncodeError as e:
|
|
36
|
+
raise exceptions.ParameterError(
|
|
37
|
+
f'bad_post_reason contains a character that cannot be encoded in Big5: {e}') from e
|
|
38
|
+
if len(encoded) > _BAD_POST_REASON_MAX_BYTES:
|
|
39
|
+
raise exceptions.ParameterError(
|
|
40
|
+
f'bad_post_reason must not exceed {_BAD_POST_REASON_MAX_BYTES} bytes')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, reason: Optional[str] = None,
|
|
44
|
+
bad_post_type: Optional[data_type.BadPostType] = None,
|
|
45
|
+
bad_post_reason: Optional[str] = None) -> None:
|
|
46
|
+
_api_util.one_thread(api)
|
|
47
|
+
|
|
48
|
+
if not api.is_registered_user:
|
|
49
|
+
raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
|
|
50
|
+
|
|
51
|
+
if not api._is_login:
|
|
52
|
+
raise exceptions.RequireLogin(i18n.require_login)
|
|
53
|
+
|
|
54
|
+
check_value.check_type(board, str, 'board')
|
|
55
|
+
if post_aid is not None:
|
|
56
|
+
check_value.check_type(post_aid, str, 'PostAID')
|
|
57
|
+
check_value.check_type(post_index, int, 'PostIndex')
|
|
58
|
+
if reason is not None:
|
|
59
|
+
check_value.check_type(reason, str, 'reason')
|
|
60
|
+
if bad_post_type is not None:
|
|
61
|
+
check_value.check_type(bad_post_type, data_type.BadPostType, 'bad_post_type')
|
|
62
|
+
if bad_post_reason is not None:
|
|
63
|
+
check_value.check_type(bad_post_reason, str, 'bad_post_reason')
|
|
64
|
+
|
|
65
|
+
if len(board) == 0:
|
|
66
|
+
raise exceptions.ParameterError(f'board error parameter: {board}')
|
|
67
|
+
|
|
68
|
+
if post_index != 0 and isinstance(post_aid, str):
|
|
69
|
+
raise exceptions.ParameterError('wrong parameter index and aid can\'t both input')
|
|
70
|
+
|
|
71
|
+
if post_index == 0 and post_aid is None:
|
|
72
|
+
raise exceptions.ParameterError('wrong parameter index or aid must input')
|
|
73
|
+
|
|
74
|
+
# --- Pure parameter validation (doesn't need post_info) happens here,
|
|
75
|
+
# before the "already deleted" early-return below -- an invalid
|
|
76
|
+
# combination of parameters must always fail the same way, regardless of
|
|
77
|
+
# whether the post happens to already be gone. ---
|
|
78
|
+
if reason is not None:
|
|
79
|
+
_check_no_control_chars('reason', reason)
|
|
80
|
+
|
|
81
|
+
if bad_post_type == data_type.BadPostType.OTHER:
|
|
82
|
+
if not bad_post_reason:
|
|
83
|
+
raise exceptions.ParameterError(
|
|
84
|
+
'bad_post_reason is required when bad_post_type is BadPostType.OTHER')
|
|
85
|
+
_check_bad_post_reason(bad_post_reason)
|
|
86
|
+
elif bad_post_reason is not None:
|
|
87
|
+
raise exceptions.ParameterError(
|
|
88
|
+
'bad_post_reason is only valid when bad_post_type is BadPostType.OTHER')
|
|
89
|
+
|
|
90
|
+
if post_index != 0:
|
|
91
|
+
newest_index = api.get_newest_index(
|
|
92
|
+
data_type.NewIndex.BOARD,
|
|
93
|
+
board=board)
|
|
94
|
+
check_value.check_index(
|
|
95
|
+
'PostIndex',
|
|
96
|
+
post_index,
|
|
97
|
+
newest_index)
|
|
98
|
+
|
|
99
|
+
log.logger.info(i18n.delete_post)
|
|
100
|
+
|
|
101
|
+
board_info = _api_util.check_board(api, board)
|
|
102
|
+
|
|
103
|
+
check_author = True
|
|
104
|
+
for moderator in board_info[data_type.BoardField.moderators]:
|
|
105
|
+
if api.ptt_id.lower() == moderator.lower():
|
|
106
|
+
check_author = False
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
log.logger.info(i18n.delete_post)
|
|
110
|
+
|
|
111
|
+
post_info = api.get_post(board, aid=post_aid, index=post_index, query=True)
|
|
112
|
+
if post_info[data_type.PostField.post_status] != data_type.PostStatus.EXISTS:
|
|
113
|
+
# delete success
|
|
114
|
+
log.logger.info(i18n.delete_post, '...', i18n.success)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# PTT only offers the "R加註理由" annotation when a moderator deletes
|
|
118
|
+
# another user's post. On your own post the reason can never be applied,
|
|
119
|
+
# so reject it rather than silently dropping it.
|
|
120
|
+
if reason and api.ptt_id.lower() == post_info[data_type.PostField.author].lower():
|
|
121
|
+
raise exceptions.ParameterError(
|
|
122
|
+
'reason is only valid when a moderator deletes another user\'s post')
|
|
123
|
+
|
|
124
|
+
# 惡退(劣文)跟 R 加註理由一樣,只有「板主刪除他人文章」時才有意義。
|
|
125
|
+
if bad_post_type is not None and (
|
|
126
|
+
check_author or api.ptt_id.lower() == post_info[data_type.PostField.author].lower()):
|
|
127
|
+
raise exceptions.ParameterError(
|
|
128
|
+
'bad_post_type is only valid when a moderator deletes another user\'s post')
|
|
129
|
+
|
|
130
|
+
if check_author:
|
|
131
|
+
if api.ptt_id.lower() != post_info[data_type.PostField.author].lower():
|
|
132
|
+
log.logger.info(i18n.delete_post, '...', i18n.fail)
|
|
133
|
+
raise exceptions.NoPermission(i18n.no_permission)
|
|
134
|
+
|
|
135
|
+
_api_util.goto_board(api, board)
|
|
136
|
+
|
|
137
|
+
cmd_list = []
|
|
138
|
+
|
|
139
|
+
if post_aid is not None:
|
|
140
|
+
cmd_list.append(lib_util.check_aid(post_aid))
|
|
141
|
+
elif post_index != 0:
|
|
142
|
+
cmd_list.append(str(post_index))
|
|
143
|
+
else:
|
|
144
|
+
raise exceptions.ParameterError('post_aid and post_index cannot be None at the same time')
|
|
145
|
+
|
|
146
|
+
cmd_list.append(command.enter)
|
|
147
|
+
cmd_list.append('d')
|
|
148
|
+
|
|
149
|
+
cmd = ''.join(cmd_list)
|
|
150
|
+
|
|
151
|
+
api.confirm = False
|
|
152
|
+
api.bad_post_done = False
|
|
153
|
+
|
|
154
|
+
def confirm_delete_handler(screen):
|
|
155
|
+
api.confirm = True
|
|
156
|
+
|
|
157
|
+
# A board moderator deleting another user's post gets an extra
|
|
158
|
+
# "R加註理由" option that lets us annotate the title shown after deletion.
|
|
159
|
+
use_reason = bool(reason) and not check_author
|
|
160
|
+
|
|
161
|
+
def confirm_delete_response(screen):
|
|
162
|
+
if use_reason and '加註理由' in screen:
|
|
163
|
+
return 'r' + command.enter
|
|
164
|
+
return 'y' + command.enter
|
|
165
|
+
|
|
166
|
+
target_list = [
|
|
167
|
+
connect_core.TargetUnit('請按任意鍵繼續', response=' '),
|
|
168
|
+
connect_core.TargetUnit('請確定刪除(Y/N', response=confirm_delete_response,
|
|
169
|
+
handler=confirm_delete_handler, max_match=1),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if use_reason:
|
|
173
|
+
target_list.append(
|
|
174
|
+
connect_core.TargetUnit(
|
|
175
|
+
'請輸入刪除後要顯示的標題',
|
|
176
|
+
response=command.ctrl_e + ' ' + reason + command.enter,
|
|
177
|
+
max_match=1))
|
|
178
|
+
target_list.append(
|
|
179
|
+
connect_core.TargetUnit(
|
|
180
|
+
'請再次確定是否要用上述理由刪除(Y/N',
|
|
181
|
+
response='y' + command.enter,
|
|
182
|
+
max_match=1))
|
|
183
|
+
|
|
184
|
+
# A moderator deleting another user's post always gets asked
|
|
185
|
+
# "惡退文章?(y/N)" (record a bad-post strike against the author). Answering
|
|
186
|
+
# 'n' when bad_post_type wasn't requested is what fixes send() timing out
|
|
187
|
+
# for 3 seconds and returning -1 on every moderator delete.
|
|
188
|
+
#
|
|
189
|
+
# menu_shown / reason_prompted track whether PTT actually walked us
|
|
190
|
+
# through the bad-post flow: PTT silently skips straight past the "0.取消
|
|
191
|
+
# 退文" menu (e.g. for a post too old to badpost) without any error, so
|
|
192
|
+
# the only way to know whether a bad-post record was actually made is to
|
|
193
|
+
# have observed each step's screen ourselves, rather than assuming the
|
|
194
|
+
# scripted response was consumed.
|
|
195
|
+
menu_shown = False
|
|
196
|
+
reason_prompted = False
|
|
197
|
+
|
|
198
|
+
if not check_author:
|
|
199
|
+
def bad_post_menu_handler(screen):
|
|
200
|
+
nonlocal menu_shown
|
|
201
|
+
menu_shown = True
|
|
202
|
+
|
|
203
|
+
target_list.append(
|
|
204
|
+
connect_core.TargetUnit(
|
|
205
|
+
'惡退文章?(y/N',
|
|
206
|
+
response=('y' if bad_post_type else 'n') + command.enter,
|
|
207
|
+
max_match=1))
|
|
208
|
+
|
|
209
|
+
if bad_post_type is not None:
|
|
210
|
+
target_list.append(
|
|
211
|
+
connect_core.TargetUnit(
|
|
212
|
+
'0.取消退文',
|
|
213
|
+
response=str(int(bad_post_type)) + command.enter,
|
|
214
|
+
handler=bad_post_menu_handler,
|
|
215
|
+
max_match=1))
|
|
216
|
+
|
|
217
|
+
if bad_post_type == data_type.BadPostType.OTHER:
|
|
218
|
+
def reason_prompt_handler(screen):
|
|
219
|
+
nonlocal reason_prompted
|
|
220
|
+
reason_prompted = True
|
|
221
|
+
|
|
222
|
+
target_list.append(
|
|
223
|
+
connect_core.TargetUnit(
|
|
224
|
+
'請輸入原因',
|
|
225
|
+
response=bad_post_reason + command.enter,
|
|
226
|
+
handler=reason_prompt_handler,
|
|
227
|
+
max_match=1))
|
|
228
|
+
|
|
229
|
+
target_list.append(
|
|
230
|
+
connect_core.TargetUnit(screens.Target.InBoard, break_detect=True))
|
|
231
|
+
|
|
232
|
+
result = api.connect_core.send(cmd, target_list)
|
|
233
|
+
|
|
234
|
+
if not api.confirm:
|
|
235
|
+
log.logger.info(i18n.delete_post, '...', i18n.fail)
|
|
236
|
+
raise exceptions.NoPermission(i18n.no_permission)
|
|
237
|
+
|
|
238
|
+
# api.confirm being True means PTT already accepted the delete
|
|
239
|
+
# confirmation ('y' to "請確定刪除(Y/N)") -- the post itself is gone at
|
|
240
|
+
# this point in PTT's flow regardless of what happens afterwards (the
|
|
241
|
+
# bad-post menu, or simply returning to the board screen). A timeout
|
|
242
|
+
# (-1) after that point means send() never reached screens.Target.InBoard,
|
|
243
|
+
# so we can't blindly log success as before -- but we *can* say the
|
|
244
|
+
# delete succeeded and, if a bad-post record was requested, that it
|
|
245
|
+
# wasn't confirmed.
|
|
246
|
+
timed_out = result == -1
|
|
247
|
+
|
|
248
|
+
log.logger.info(i18n.delete_post, '...', i18n.success)
|
|
249
|
+
|
|
250
|
+
if bad_post_type is not None:
|
|
251
|
+
reason_ok = bad_post_type != data_type.BadPostType.OTHER or reason_prompted
|
|
252
|
+
api.bad_post_done = bool(menu_shown and reason_ok and not timed_out)
|
|
253
|
+
if not api.bad_post_done:
|
|
254
|
+
raise exceptions.BadPostNotRecorded(i18n.bad_post_not_recorded)
|
|
255
|
+
elif timed_out:
|
|
256
|
+
raise exceptions.UnknownError(
|
|
257
|
+
'delete succeeded, but PTT never returned to the board screen '
|
|
258
|
+
'afterwards (timeout)')
|
|
@@ -121,11 +121,19 @@ def get_user(api, ptt_id: str) -> Dict:
|
|
|
121
121
|
legal_post = int(re.findall(r'\d+', buff_1)[0])
|
|
122
122
|
|
|
123
123
|
# PTT2 沒有退文
|
|
124
|
-
if api.config.host == data_type.HOST.
|
|
125
|
-
int_list = re.findall(r'\d+', buff_1)
|
|
126
|
-
illegal_post = int(int_list[1]) if len(int_list) > 1 else 0
|
|
127
|
-
else:
|
|
124
|
+
if api.config.host == data_type.HOST.PTT2:
|
|
128
125
|
illegal_post = None
|
|
126
|
+
else:
|
|
127
|
+
int_list = re.findall(r'\d+', buff_1)
|
|
128
|
+
if len(int_list) > 1:
|
|
129
|
+
illegal_post = int(int_list[1])
|
|
130
|
+
elif api.config.host == data_type.HOST.PTT1:
|
|
131
|
+
# Historical PTT1 behaviour: default to 0 rather than "unavailable".
|
|
132
|
+
illegal_post = 0
|
|
133
|
+
else:
|
|
134
|
+
# Non-PTT1 hosts (e.g. LOCALHOST) that fail to parse a second
|
|
135
|
+
# number: report "unavailable" rather than a fabricated 0.
|
|
136
|
+
illegal_post = None
|
|
129
137
|
|
|
130
138
|
activity, mail = parse_user_info_from_line(activity_line)
|
|
131
139
|
last_login_date, last_login_ip = parse_user_info_from_line(last_login_line)
|
|
@@ -9,6 +9,7 @@ import traceback
|
|
|
9
9
|
import warnings
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
import certifi
|
|
12
13
|
import websockets
|
|
13
14
|
import websockets.exceptions
|
|
14
15
|
|
|
@@ -50,7 +51,9 @@ def ssl_init(verify_ssl: bool = True) -> ssl.SSLContext:
|
|
|
50
51
|
cert_file.close()
|
|
51
52
|
key_file.close()
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
# certifi 的 CA bundle 隨 requests 一起裝,避免 macOS 官方 python 沒根憑證時
|
|
55
|
+
# 出現 CERTIFICATE_VERIFY_FAILED,且不必靠使用者設 SSL_CERT_FILE。
|
|
56
|
+
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
54
57
|
ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
|
|
55
58
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
56
59
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
+
from enum import IntEnum
|
|
2
3
|
from enum import auto
|
|
3
4
|
|
|
4
5
|
from AutoStrEnum import AutoStrEnum
|
|
@@ -162,6 +163,19 @@ class MarkType(AutoStrEnum):
|
|
|
162
163
|
UNCONFIRMED = auto()
|
|
163
164
|
|
|
164
165
|
|
|
166
|
+
class BadPostType(IntEnum):
|
|
167
|
+
# 版主刪除他人文章時的惡退(劣文)分類,對應 PTT 選單的數字選項。
|
|
168
|
+
|
|
169
|
+
# 廣告
|
|
170
|
+
AD = 1
|
|
171
|
+
# 不當用辭
|
|
172
|
+
BAD_LANGUAGE = 2
|
|
173
|
+
# 人身攻擊
|
|
174
|
+
PERSONAL_ATTACK = 3
|
|
175
|
+
# 其他
|
|
176
|
+
OTHER = 4
|
|
177
|
+
|
|
178
|
+
|
|
165
179
|
class FavouriteBoardField(AutoStrEnum):
|
|
166
180
|
board = auto()
|
|
167
181
|
type = auto()
|
|
@@ -3,9 +3,11 @@ string_data = {
|
|
|
3
3
|
"PTT2": "PTT2",
|
|
4
4
|
"active": "Active",
|
|
5
5
|
"author": "Author",
|
|
6
|
+
"bad_post": "bad post",
|
|
7
|
+
"bad_post_not_recorded": "the post was deleted, but the bad-post record was not made",
|
|
6
8
|
"board": "Board",
|
|
7
9
|
"can_not_use_search_post_code_f": "This status can not use the search PostField code function",
|
|
8
|
-
"
|
|
10
|
+
"catch_bottom_post": "Catch bottom post",
|
|
9
11
|
"change_pw": "Change password",
|
|
10
12
|
"comment": "Comment",
|
|
11
13
|
"comment_content": "Comment content",
|
|
@@ -63,6 +63,16 @@ class Logger:
|
|
|
63
63
|
|
|
64
64
|
def __init__(self, name: str, level: int = logging.NOTSET, logger_callback: Optional[callable] = None):
|
|
65
65
|
|
|
66
|
+
# Remembered independently of self.logger's own level: NOTSET (SILENT)
|
|
67
|
+
# is used here purely as a "fully silent" sentinel, but stdlib
|
|
68
|
+
# logging.Logger.isEnabledFor() treats NOTSET as "defer to the
|
|
69
|
+
# nearest ancestor with an explicit level" (falling back to the root
|
|
70
|
+
# logger's default of WARNING). That makes isEnabledFor(WARNING)
|
|
71
|
+
# return True even under SILENT, which previously let warning()
|
|
72
|
+
# leak through. Comparing against this stored level directly avoids
|
|
73
|
+
# relying on isEnabledFor()/root-logger defaults at all.
|
|
74
|
+
self._level = level
|
|
75
|
+
|
|
66
76
|
self.logger = logging.getLogger(name)
|
|
67
77
|
self.logger.setLevel(level)
|
|
68
78
|
|
|
@@ -76,9 +86,14 @@ class Logger:
|
|
|
76
86
|
if logger_callback and callable(logger_callback):
|
|
77
87
|
self.logger_callback = logger_callback
|
|
78
88
|
|
|
89
|
+
def _enabled_for(self, level: int) -> bool:
|
|
90
|
+
if self._level == logging.NOTSET:
|
|
91
|
+
return False
|
|
92
|
+
return level >= self._level
|
|
93
|
+
|
|
79
94
|
def info(self, *args):
|
|
80
95
|
|
|
81
|
-
if not self.
|
|
96
|
+
if not self._enabled_for(logging.INFO):
|
|
82
97
|
return
|
|
83
98
|
|
|
84
99
|
msg = _combine_msg(*args)
|
|
@@ -88,7 +103,7 @@ class Logger:
|
|
|
88
103
|
|
|
89
104
|
def debug(self, *args):
|
|
90
105
|
|
|
91
|
-
if not self.
|
|
106
|
+
if not self._enabled_for(logging.DEBUG):
|
|
92
107
|
return
|
|
93
108
|
|
|
94
109
|
msg = _combine_msg(*args)
|
|
@@ -96,6 +111,16 @@ class Logger:
|
|
|
96
111
|
if self.logger_callback:
|
|
97
112
|
self.logger_callback(msg)
|
|
98
113
|
|
|
114
|
+
def warning(self, *args):
|
|
115
|
+
|
|
116
|
+
if not self._enabled_for(logging.WARNING):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
msg = _combine_msg(*args)
|
|
120
|
+
self.logger.warning(msg)
|
|
121
|
+
if self.logger_callback:
|
|
122
|
+
self.logger_callback(msg)
|
|
123
|
+
|
|
99
124
|
|
|
100
125
|
logger: Optional[Logger] = None
|
|
101
126
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
key = """-----BEGIN EC PARAMETERS-----
|
|
2
|
+
BggqhkjOPQMBBw==
|
|
3
|
+
-----END EC PARAMETERS-----
|
|
4
|
+
-----BEGIN EC PRIVATE KEY-----
|
|
5
|
+
MHcCAQEEIF0fyzVzlWVfbwf0IkWdOi1DD6g4dl2Wf2MSaPKS2pRtoAoGCCqGSM49
|
|
6
|
+
AwEHoUQDQgAEvsTkmHxvh9EMh+1wFd+Z8bN0bLOWisGv3uNP0qeV0jy88cmPTWRl
|
|
7
|
+
OzU8emmKTJZpuoOEGqi/Ow4vo+WTxMPynQ==
|
|
8
|
+
-----END EC PRIVATE KEY-----
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
cert = """-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIB6TCCAY8CFGi0fucoS9ysCrt9pKAu9BgMAULWMAoGCCqGSM49BAMCMHcxCzAJ
|
|
13
|
+
BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
|
|
14
|
+
CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
|
|
15
|
+
EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTgwMTIwNTlaFw0zNjA3MTUwMTIw
|
|
16
|
+
NTlaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
|
|
17
|
+
eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
|
|
18
|
+
bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
|
|
19
|
+
AwEHA0IABL7E5Jh8b4fRDIftcBXfmfGzdGyzlorBr97jT9KnldI8vPHJj01kZTs1
|
|
20
|
+
PHppikyWabqDhBqovzsOL6Plk8TD8p0wCgYIKoZIzj0EAwIDSAAwRQIgYTfmCpJN
|
|
21
|
+
abcPTvASZb/a7vanhO1vpafJ6Hlff6pAxh0CIQCl/Vvrx6DV21rUC3rkHBOFeqev
|
|
22
|
+
I9Izuv9PxfLpnxoFcw==
|
|
23
|
+
-----END CERTIFICATE-----
|
|
24
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyPtt
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.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)
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: progressbar2
|
|
29
29
|
Requires-Dist: websockets>=12
|
|
30
|
+
Requires-Dist: certifi
|
|
30
31
|
Requires-Dist: uao
|
|
31
32
|
Requires-Dist: requests>=2.32
|
|
32
33
|
Requires-Dist: AutoStrEnum
|