PyPtt 2.0.7__tar.gz → 2.0.9__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 (80) hide show
  1. {pyptt-2.0.7 → pyptt-2.0.9}/PKG-INFO +1 -1
  2. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/PTT.py +6 -0
  3. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_bucket.py +1 -1
  5. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_call_status.py +11 -8
  6. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_comment.py +1 -1
  7. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_board_info.py +5 -0
  8. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_user.py +18 -6
  9. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_give_money.py +1 -2
  10. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_mail.py +2 -2
  11. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_mark_post.py +1 -0
  12. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_reply_post.py +2 -0
  13. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_util.py +7 -2
  14. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/config.py +3 -0
  15. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/connect_core.py +36 -20
  16. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/data_type.py +8 -0
  17. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/screens.py +3 -3
  18. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/service.py +32 -3
  19. pyptt-2.0.9/PyPtt/ssl_config.py +24 -0
  20. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt.egg-info/PKG-INFO +1 -1
  21. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_user.py +17 -2
  22. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_service.py +47 -0
  23. pyptt-2.0.7/PyPtt/ssl_config.py +0 -24
  24. {pyptt-2.0.7 → pyptt-2.0.9}/LICENSE +0 -0
  25. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_change_pw.py +0 -0
  26. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_del_post.py +0 -0
  27. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_board_list.py +0 -0
  28. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_bottom_post_list.py +0 -0
  29. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_favourite_board.py +0 -0
  30. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_newest_index.py +0 -0
  31. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_post.py +0 -0
  32. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_post_index.py +0 -0
  33. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_post_list.py +0 -0
  34. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_time.py +0 -0
  35. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_get_waterball.py +0 -0
  36. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_has_new_mail.py +0 -0
  37. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_loginout.py +0 -0
  38. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_post.py +0 -0
  39. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_search_user.py +0 -0
  40. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/_api_set_board_title.py +0 -0
  41. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/api_server.py +0 -0
  42. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/check_value.py +0 -0
  43. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/command.py +0 -0
  44. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/exceptions.py +0 -0
  45. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/i18n.py +0 -0
  46. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/lang_en_US.py +0 -0
  47. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/lang_zh_TW.py +0 -0
  48. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/lib_util.py +0 -0
  49. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt/log.py +0 -0
  50. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt.egg-info/SOURCES.txt +0 -0
  51. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt.egg-info/dependency_links.txt +0 -0
  52. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt.egg-info/requires.txt +0 -0
  53. {pyptt-2.0.7 → pyptt-2.0.9}/PyPtt.egg-info/top_level.txt +0 -0
  54. {pyptt-2.0.7 → pyptt-2.0.9}/README.md +0 -0
  55. {pyptt-2.0.7 → pyptt-2.0.9}/pyproject.toml +0 -0
  56. {pyptt-2.0.7 → pyptt-2.0.9}/setup.cfg +0 -0
  57. {pyptt-2.0.7 → pyptt-2.0.9}/setup.py +0 -0
  58. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_change_pw.py +0 -0
  59. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_comment.py +0 -0
  60. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_del_post.py +0 -0
  61. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_exceptions.py +0 -0
  62. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_board_info.py +0 -0
  63. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_board_list.py +0 -0
  64. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_bottom_post_list.py +0 -0
  65. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_favourite_boards.py +0 -0
  66. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_mail.py +0 -0
  67. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_newest_index.py +0 -0
  68. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_post.py +0 -0
  69. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_post_list.py +0 -0
  70. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_post_parser.py +0 -0
  71. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_time.py +0 -0
  72. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_get_waterball.py +0 -0
  73. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_give_money.py +0 -0
  74. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_i18n.py +0 -0
  75. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_init.py +0 -0
  76. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_logger.py +0 -0
  77. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_parse_query_post.py +0 -0
  78. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_post.py +0 -0
  79. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_reply_post.py +0 -0
  80. {pyptt-2.0.7 → pyptt-2.0.9}/tests/test_search_user.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.0.7
3
+ Version: 2.0.9
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)
@@ -53,6 +53,7 @@ class API:
53
53
  logger_callback (Callable): PyPtt 顯示訊息的 callback。預設為 None。
54
54
  port (int): PyPtt 連線的 port。預設為 **23**。
55
55
  host (:ref:`host`): PyPtt 連線的 PTT 伺服器。預設為 **PTT1**。
56
+ verify_ssl (bool): 是否驗證 PTT server 端的 TLS 憑證。預設為 **True**。若你的網路環境透過 SSL 攔截 proxy(如企業防火牆),可設為 **False** 以停用驗證。
56
57
  check_update (bool): 是否檢查 PyPtt 的更新。預設為 **False**。
57
58
 
58
59
  Returns:
@@ -145,8 +146,13 @@ class API:
145
146
  raise exceptions.ParameterError('[PyPtt] TELNET is not available on PTT1 and PTT2')
146
147
  self.config.connect_mode = connect_mode
147
148
 
149
+ verify_ssl = kwargs.get('verify_ssl', True)
150
+ check_value.check_type(verify_ssl, bool, 'verify_ssl')
151
+ self.config.verify_ssl = verify_ssl
152
+
148
153
  self.connect_core = connect_core.API(self)
149
154
  self._exist_board_list = set()
155
+ self._exist_user_list = set()
150
156
  self._moderators = dict()
151
157
  self._thread_id = threading.get_ident()
152
158
  self._goto_board_list = set()
@@ -1,4 +1,4 @@
1
- __version__ = '2.0.7'
1
+ __version__ = '2.0.9'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -22,7 +22,7 @@ def bucket(api, board: str, bucket_days: int, reason: str, ptt_id: str) -> None:
22
22
  check_value.check_type(reason, str, 'reason')
23
23
  check_value.check_type(ptt_id, str, 'ptt_id')
24
24
 
25
- api.get_user(ptt_id)
25
+ _api_util.check_user_exist(api, ptt_id)
26
26
 
27
27
  _api_util.check_board(api, board, check_moderator=True)
28
28
 
@@ -6,8 +6,7 @@ from . import log
6
6
  from . import screens
7
7
 
8
8
 
9
- def get_call_status(api) -> None:
10
- # log.py = DefaultLogger('api', api.config.log_level)
9
+ def get_call_status(api) -> data_type.CallStatus:
11
10
 
12
11
  cmd_list = []
13
12
  cmd_list.append(command.go_main_menu)
@@ -34,15 +33,15 @@ def get_call_status(api) -> None:
34
33
  raise exceptions.UnknownError('UnknownError')
35
34
 
36
35
  if index == 0:
37
- return data_type.call_status.ON
36
+ return data_type.CallStatus.ON
38
37
  if index == 1:
39
- return data_type.call_status.UNPLUG
38
+ return data_type.CallStatus.UNPLUG
40
39
  if index == 2:
41
- return data_type.call_status.WATERPROOF
40
+ return data_type.CallStatus.WATERPROOF
42
41
  if index == 3:
43
- return data_type.call_status.FRIEND
42
+ return data_type.CallStatus.FRIEND
44
43
  if index == 4:
45
- return data_type.call_status.OFF
44
+ return data_type.CallStatus.OFF
46
45
 
47
46
  ori_screen = api.connect_core.get_screen_queue()[-1]
48
47
  raise exceptions.UnknownError(ori_screen)
@@ -63,10 +62,14 @@ def set_call_status(api, call_status) -> None:
63
62
  target_list = [
64
63
  connect_core.TargetUnit(screens.Target.InUserList, break_detect=True)]
65
64
 
66
- while current_call_status != call_status:
65
+ for _ in range(6):
66
+ if current_call_status == call_status:
67
+ break
67
68
  api.connect_core.send(
68
69
  cmd,
69
70
  target_list,
70
71
  screen_timeout=api.config.screen_long_timeout)
71
72
 
72
73
  current_call_status = api._get_call_status()
74
+ else:
75
+ raise exceptions.UnknownError('could not set call status')
@@ -67,7 +67,7 @@ def _comment(api,
67
67
 
68
68
  cmd_list = []
69
69
 
70
- if index == 0 or index == 1:
70
+ if index in (0, 1, 2):
71
71
  push_option_line = api.connect_core.get_screen_queue()[-1]
72
72
  push_option_line = push_option_line.split('\n')[-1]
73
73
 
@@ -69,22 +69,27 @@ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -
69
69
  ori_screen = api.connect_core.get_screen_queue()[-1]
70
70
  # print(ori_screen)
71
71
 
72
+ boardname = None
72
73
  p = re.compile(r'《(.+)》看板設定')
73
74
  r = p.search(ori_screen)
74
75
  if r is not None:
75
76
  boardname = r.group(0)[1:-5].strip()
76
77
 
78
+ if boardname is None:
79
+ raise exceptions.NoSuchBoard(api.config, board)
77
80
  logger.debug('看板名稱', boardname, board)
78
81
 
79
82
  if boardname.lower() != board.lower():
80
83
  raise exceptions.NoSuchBoard(api.config, board)
81
84
 
85
+ chinese_des = ''
82
86
  p = re.compile(r'中文敘述: (.+)')
83
87
  r = p.search(ori_screen)
84
88
  if r is not None:
85
89
  chinese_des = r.group(0)[5:].strip()
86
90
  logger.debug('中文敘述', chinese_des)
87
91
 
92
+ moderators = []
88
93
  p = re.compile(r'板主名單: (.+)')
89
94
  r = p.search(ori_screen)
90
95
  if r is not None:
@@ -30,12 +30,21 @@ def get_user(api, ptt_id: str) -> Dict:
30
30
  if len(ptt_id) < 2:
31
31
  raise exceptions.ParameterError(f'wrong parameter user_id: {ptt_id}')
32
32
 
33
- cmd_list = []
34
- cmd_list.append(command.go_main_menu)
35
- cmd_list.append('T')
36
- cmd_list.append(command.enter)
33
+ # Step 1: Navigate to Talk menu.
34
+ # AnyKey auto-dismiss is safe here because no query commands are pending.
35
+ cmd = command.go_main_menu + 'T' + command.enter
37
36
 
38
- cmd = ''.join(cmd_list)
37
+ target_list = [
38
+ connect_core.TargetUnit(screens.Target.InTalk, break_detect=True),
39
+ connect_core.TargetUnit(screens.Target.AnyKey, response=' '),
40
+ ]
41
+
42
+ nav_index = api.connect_core.send(cmd, target_list)
43
+
44
+ # Step 2: Query user.
45
+ # Use AnyKey as break target — it's at the very bottom of the user info
46
+ # screen, so matching it confirms the screen is fully transmitted.
47
+ cmd = 'Q' + command.enter + ptt_id + command.enter
39
48
 
40
49
  # Use InTalk as a non-break target that auto-responds with the
41
50
  # query command, and max_match=1 to prevent re-matching on
@@ -113,7 +122,8 @@ def get_user(api, ptt_id: str) -> Dict:
113
122
 
114
123
  # PTT2 沒有退文
115
124
  if api.config.host == data_type.HOST.PTT1:
116
- illegal_post = int(re.findall(r'\d+', buff_1)[1])
125
+ int_list = re.findall(r'\d+', buff_1)
126
+ illegal_post = int(int_list[1]) if len(int_list) > 1 else 0
117
127
  else:
118
128
  illegal_post = None
119
129
 
@@ -152,5 +162,7 @@ def get_user(api, ptt_id: str) -> Dict:
152
162
  UserField.chess: chess,
153
163
  UserField.signature_file: signature_file,
154
164
  }
165
+ api._exist_user_list.add(ptt_id.lower())
166
+
155
167
  user = json.dumps(user, cls=AutoJsonEncoder)
156
168
  return json.loads(user)
@@ -33,8 +33,7 @@ def give_money(api, ptt_id: str, money: int, red_bag_title: str, red_bag_content
33
33
  log.logger.info(
34
34
  i18n.replace(i18n.give_money_to, ptt_id, money))
35
35
 
36
- # Check data_type.user
37
- api.get_user(ptt_id)
36
+ _api_util.check_user_exist(api, ptt_id)
38
37
 
39
38
  cmd_list = []
40
39
  cmd_list.append(command.go_main_menu)
@@ -35,7 +35,7 @@ def mail(api,
35
35
  check_value.check_type(title, str, 'title')
36
36
  check_value.check_type(content, str, 'content')
37
37
 
38
- api.get_user(ptt_id)
38
+ _api_util.check_user_exist(api, ptt_id)
39
39
 
40
40
  check_sign_file = False
41
41
  for i in range(0, 10):
@@ -277,7 +277,7 @@ def del_mail(api, index) -> None:
277
277
  raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
278
278
 
279
279
  current_index = api.get_newest_index(data_type.NewIndex.MAIL)
280
- check_value.check_index(index, current_index)
280
+ check_value.check_index('index', index, current_index)
281
281
 
282
282
  cmd_list = []
283
283
  # 進入主選單
@@ -75,6 +75,7 @@ def mark_post(api, mark_type: int, board: str, post_aid: str, post_index: int, s
75
75
  raise exceptions.HostNotSupport(lib_util.get_current_func_name())
76
76
 
77
77
  _api_util.check_board(
78
+ api,
78
79
  board,
79
80
  check_moderator=True)
80
81
 
@@ -68,6 +68,8 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
68
68
  elif reply_to == data_type.ReplyTo.BOARD_MAIL:
69
69
  reply_msg = i18n.reply_board_mail
70
70
  reply_target_unit = connect_core.TargetUnit('▲ 回應至', log_level=log.INFO, response='B' + command.enter)
71
+ else:
72
+ raise exceptions.ParameterError(f'unsupported reply_to: {reply_to}')
71
73
  log.logger.info(reply_msg)
72
74
 
73
75
  cmd = ''.join(cmd_list)
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import functools
4
3
  import re
5
4
  import threading
6
5
  from dataclasses import dataclass
@@ -62,6 +61,8 @@ def get_content(api, post_mode: bool = True):
62
61
  first_page = True
63
62
  origin_post = []
64
63
  stop_dict = dict()
64
+ new_content_part = ''
65
+ get_line_b = 0
65
66
 
66
67
  while True:
67
68
  index = api.connect_core.send(cmd, target_list)
@@ -445,7 +446,11 @@ def one_thread(api):
445
446
  raise exceptions.MultiThreadOperated()
446
447
 
447
448
 
448
- @functools.lru_cache(maxsize=64)
449
+ def check_user_exist(api, ptt_id: str) -> None:
450
+ if ptt_id.lower() not in api._exist_user_list:
451
+ api.get_user(ptt_id)
452
+
453
+
449
454
  def check_board(api, board: str, check_moderator: bool = False) -> Dict:
450
455
  if board.lower() not in api._exist_board_list:
451
456
  board_info = _api_get_board_info.get_board_info(api, board, get_post_kind=False, call_by_others=False)
@@ -36,6 +36,9 @@ class Config:
36
36
  # 預設使用 23
37
37
  port = 23
38
38
 
39
+ # 預設驗證 server 端 TLS 憑證;設為 False 可在 SSL 攔截 proxy 環境下使用
40
+ verify_ssl = True
41
+
39
42
  logger_callback = None
40
43
 
41
44
 
@@ -32,12 +32,15 @@ except AttributeError:
32
32
  websockets.http11.USER_AGENT += f' PyPtt/{PyPtt.__version__}'
33
33
  use_http11 = True
34
34
 
35
- ssl_context = None
36
-
37
-
38
- def ssl_init():
39
- global ssl_context
40
-
35
+ def ssl_init(verify_ssl: bool = True) -> ssl.SSLContext:
36
+ # Verify the PTT server certificate against the system CA bundle.
37
+ # The client cert loaded below is *not* for identity (PTT does not
38
+ # validate it); it exists purely to make the TLS handshake succeed in
39
+ # environments where omitting a Certificate message breaks negotiation.
40
+ # See PyPtt/ssl_config.py for details.
41
+ #
42
+ # Pass verify_ssl=False only when connecting through an SSL-inspecting
43
+ # proxy whose CA cert is not in the system trust store.
41
44
  cert_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pem')
42
45
  cert_file.write(ssl_config.cert.encode('utf-8'))
43
46
 
@@ -47,17 +50,18 @@ def ssl_init():
47
50
  cert_file.close()
48
51
  key_file.close()
49
52
 
50
- ssl_context = ssl.create_default_context()
51
- ssl_context.check_hostname = False
52
- ssl_context.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
53
- ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
54
- ssl_context.verify_mode = ssl.CERT_NONE
53
+ ctx = ssl.create_default_context()
54
+ ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
55
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
56
+
57
+ if not verify_ssl:
58
+ ctx.check_hostname = False
59
+ ctx.verify_mode = ssl.CERT_NONE
55
60
 
56
61
  os.unlink(cert_file.name)
57
62
  os.unlink(key_file.name)
58
63
 
59
-
60
- ssl_init()
64
+ return ctx
61
65
 
62
66
 
63
67
  class TargetUnit:
@@ -153,6 +157,7 @@ class API(object):
153
157
  self._UseTooManyResources = TargetUnit(screens.Target.use_too_many_resources,
154
158
  exceptions_=exceptions.UseTooManyResources())
155
159
  self._loop = None
160
+ self._ssl_context = ssl_init(self.config.verify_ssl)
156
161
 
157
162
  def _get_event_loop(self):
158
163
  if self._loop and not self._loop.is_closed():
@@ -208,7 +213,7 @@ class API(object):
208
213
  websockets.connect(
209
214
  websocket_host,
210
215
  origin=websocket_origin,
211
- ssl=ssl_context))
216
+ ssl=self._ssl_context))
212
217
  connect_success = True
213
218
  break
214
219
  except Exception as e:
@@ -249,6 +254,8 @@ class API(object):
249
254
  self._RDQ.add(screen)
250
255
  if target == self._UseTooManyResources:
251
256
  use_too_many_res = True
257
+ find_target = True
258
+ log.logger.info(i18n.use_too_many_resources)
252
259
  break
253
260
  target.raise_exception()
254
261
 
@@ -327,19 +334,22 @@ class API(object):
327
334
  raise exceptions.UseTooManyResources()
328
335
  raise exceptions.ConnectionClosed()
329
336
 
337
+ if isinstance(data_chunk, str):
338
+ data_chunk = data_chunk.encode('utf-8')
330
339
  receive_data_buffer += data_chunk
331
340
 
332
341
  screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
333
342
  self._decode_screen(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
334
343
 
335
- if self.current_encoding == 'big5uao' and not find_target:
336
- self.current_encoding = 'utf-8'
344
+ if not find_target:
345
+ original_encoding = self.current_encoding
346
+ self.current_encoding = 'big5uao' if original_encoding == 'utf-8' else 'utf-8'
337
347
  screen_, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
338
348
  self._decode_screen(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
339
349
  if find_target:
340
350
  screen = screen_
341
351
  else:
342
- self.current_encoding = 'big5uao'
352
+ self.current_encoding = original_encoding
343
353
 
344
354
  if find_target:
345
355
  break
@@ -348,13 +358,15 @@ class API(object):
348
358
  vt100_p = screens.VT100Parser(receive_data_buffer, self.current_encoding)
349
359
  screens.show(self.config, vt100_p.screen)
350
360
  self._RDQ.add(vt100_p.screen)
361
+ if use_too_many_res:
362
+ raise exceptions.UseTooManyResources()
351
363
  return -1
352
364
 
353
365
  if target_index != -1:
354
366
  return target_index
355
367
 
356
368
  if use_too_many_res:
357
- continue
369
+ raise exceptions.UseTooManyResources()
358
370
 
359
371
  if not find_target:
360
372
  return -1
@@ -366,7 +378,7 @@ class API(object):
366
378
  raise exceptions.ParameterError('Item of TargetList must be TargetUnit')
367
379
 
368
380
  if self._UseTooManyResources not in target_list:
369
- target_list.append(self._UseTooManyResources)
381
+ target_list = target_list + [self._UseTooManyResources]
370
382
 
371
383
  if self.config.connect_mode == data_type.ConnectMode.TELNET:
372
384
  # Original Telnet logic remains, as it doesn't use asyncio
@@ -428,7 +440,11 @@ class API(object):
428
440
 
429
441
  def close(self):
430
442
  if self.config.connect_mode == data_type.ConnectMode.WEBSOCKETS:
431
- if self._core and self._core.open:
443
+ try:
444
+ is_open = not self._core.closed
445
+ except AttributeError:
446
+ is_open = getattr(self._core, 'open', False)
447
+ if self._core and is_open:
432
448
  loop = self._get_event_loop()
433
449
  try:
434
450
  loop.run_until_complete(asyncio.wait_for(self._core.close(), timeout=2.0))
@@ -98,6 +98,14 @@ class PostField(AutoStrEnum):
98
98
  is_unconfirmed = auto()
99
99
 
100
100
 
101
+ class CallStatus(AutoStrEnum):
102
+ ON = auto()
103
+ UNPLUG = auto()
104
+ WATERPROOF = auto()
105
+ FRIEND = auto()
106
+ OFF = auto()
107
+
108
+
101
109
  class WaterballType(AutoStrEnum):
102
110
  SEND = auto()
103
111
  CATCH = auto()
@@ -151,7 +151,7 @@ class Target:
151
151
  ]
152
152
 
153
153
  use_too_many_resources = [
154
- '程式耗用過多計算資源'
154
+ '程式耗用過多'
155
155
  ]
156
156
 
157
157
  Animation = [
@@ -217,11 +217,11 @@ class VT100Parser:
217
217
 
218
218
  def _move(self, x, y):
219
219
  self._cursor_x = x
220
- self._cursor_y = y
220
+ self._cursor_y = min(y, len(self.screen) - 1)
221
221
 
222
222
  def _newline(self):
223
223
  self._cursor_x = 0
224
- self._cursor_y += 1
224
+ self._cursor_y = min(self._cursor_y + 1, len(self.screen) - 1)
225
225
 
226
226
  def _k(self):
227
227
  if self._cursor_x == 0:
@@ -13,7 +13,7 @@ from . import log
13
13
 
14
14
  class Service:
15
15
 
16
- def __init__(self, pyptt_init_config: Optional[dict] = None):
16
+ def __init__(self, pyptt_init_config: Optional[dict] = None, call_interval: float = 0):
17
17
 
18
18
  """
19
19
 
@@ -24,6 +24,7 @@ class Service:
24
24
 
25
25
  Args:
26
26
  pyptt_init_config (dict): PyPtt 初始化設定,請參考 :ref:`初始化設定 <api-init>`。
27
+ call_interval (float): API 呼叫之間的最小時間間隔(秒),預設為 0(不限制)。
27
28
 
28
29
  Returns:
29
30
  None
@@ -48,7 +49,7 @@ class Service:
48
49
  # 'language': PyPtt.Language.ENGLISH,
49
50
  }
50
51
 
51
- service = Service(pyptt_init_config)
52
+ service = Service(pyptt_init_config, call_interval=0.5)
52
53
 
53
54
  try:
54
55
  service.call('login', {'ptt_id': 'YOUR_PTT_ID', 'ptt_pw': 'YOUR_PTT_PW'})
@@ -69,6 +70,10 @@ class Service:
69
70
  if pyptt_init_config is None:
70
71
  pyptt_init_config = {}
71
72
 
73
+ check_value.check_type(call_interval, (int, float), 'call_interval')
74
+ if call_interval < 0:
75
+ raise exceptions.ParameterError('call_interval must be >= 0')
76
+
72
77
  log_level = pyptt_init_config.get('log_level', log.INFO)
73
78
  self.logger = log.init(log_level, 'service')
74
79
 
@@ -77,6 +82,9 @@ class Service:
77
82
  self._api = None
78
83
  self._api_init_config = pyptt_init_config
79
84
 
85
+ self._call_interval = call_interval
86
+ self._last_call_time = 0.0
87
+
80
88
  self._call_queue = Queue()
81
89
  self._call_result = {}
82
90
  self._call_result_cv = Condition()
@@ -106,6 +114,11 @@ class Service:
106
114
  except queue.Empty:
107
115
  continue
108
116
 
117
+ if self._call_interval > 0:
118
+ elapsed = time.monotonic() - self._last_call_time
119
+ if elapsed < self._call_interval:
120
+ time.sleep(self._call_interval - elapsed)
121
+
109
122
  func = getattr(self._api, call['api'])
110
123
 
111
124
  api_result = None
@@ -115,12 +128,14 @@ class Service:
115
128
  except Exception as e:
116
129
  api_exception = e
117
130
 
131
+ self._last_call_time = time.monotonic()
132
+
118
133
  with self._call_result_cv:
119
134
  self._call_result[call['id']] = {
120
135
  'result': api_result,
121
136
  'exception': api_exception
122
137
  }
123
- self._call_result_cv.notify()
138
+ self._call_result_cv.notify_all()
124
139
 
125
140
  def _get_call_id(self):
126
141
 
@@ -160,6 +175,20 @@ class Service:
160
175
 
161
176
  return call_result['result']
162
177
 
178
+ def set_call_interval(self, call_interval: float):
179
+ """設定 API 呼叫之間的最小時間間隔。
180
+
181
+ Args:
182
+ call_interval (float): 最小時間間隔(秒),必須 >= 0。
183
+
184
+ Returns:
185
+ None
186
+ """
187
+ check_value.check_type(call_interval, (int, float), 'call_interval')
188
+ if call_interval < 0:
189
+ raise exceptions.ParameterError('call_interval must be >= 0')
190
+ self._call_interval = call_interval
191
+
163
192
  def close(self):
164
193
  self.logger.info('close')
165
194
  self._close = True
@@ -0,0 +1,24 @@
1
+ key = """-----BEGIN EC PARAMETERS-----
2
+ BggqhkjOPQMBBw==
3
+ -----END EC PARAMETERS-----
4
+ -----BEGIN EC PRIVATE KEY-----
5
+ MHcCAQEEIJ4+93aR3f/McywQGoGTaA4Yp0UkTsG/wnpTMHoRM6GyoAoGCCqGSM49
6
+ AwEHoUQDQgAEIBR8dqNw+UPQDb4TauXJAOiBKt6NtroEnTnugQOWNIoK68pZZFxJ
7
+ IlC1pAUKU5aPpihf5ciQx0vtMsKvwL9Q2Q==
8
+ -----END EC PRIVATE KEY-----
9
+ """
10
+
11
+ cert = """-----BEGIN CERTIFICATE-----
12
+ MIIB5zCCAY8CFH565JyYP4U6zJXXDS3k4g54XJ/iMAoGCCqGSM49BAMCMHcxCzAJ
13
+ BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
+ CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
+ EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA1MDkxNDIzMjVaFw0zNjA1MDYxNDIz
16
+ MjVaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
+ eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
+ bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
+ AwEHA0IABCAUfHajcPlD0A2+E2rlyQDogSrejba6BJ057oEDljSKCuvKWWRcSSJQ
20
+ taQFClOWj6YoX+XIkMdL7TLCr8C/UNkwCgYIKoZIzj0EAwIDRgAwQwIfbizXt/No
21
+ 1GN3IfYB6qRwEZMJ8/AV31mWZve37j1g2AIgPQuPxE3QfgaxMsAmy6s0Xvs1dCRk
22
+ 65bTnGMhK3zjpJE=
23
+ -----END CERTIFICATE-----
24
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.0.7
3
+ Version: 2.0.9
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,12 +17,27 @@ def test_get_user(ptt_bots):
17
17
  """Tests getting user information from PTT."""
18
18
  test_users = [
19
19
  'CodingMan',
20
+ 'codingman',
21
+ 'CODINGMAN',
22
+ 'cOdInGmAn',
20
23
  ]
21
24
 
22
- for _ in range(100):
25
+ for _ in range(3):
23
26
  for test_user in test_users:
24
27
  user_info = ptt_bots[0].get_user(test_user)
25
28
  _check_user_info(user_info, test_user)
26
29
 
27
- time.sleep(1)
28
30
 
31
+ def test_get_user_no_such_user(ptt_bots):
32
+ """Tests that querying a non-existent user raises NoSuchUser."""
33
+ import PyPtt
34
+ import pytest
35
+
36
+ no_such_users = [
37
+ 'zzznotexist999',
38
+ 'qqzzxxyy123',
39
+ ]
40
+
41
+ for user in no_such_users:
42
+ with pytest.raises(PyPtt.exceptions.NoSuchUser):
43
+ ptt_bots[0].get_user(user)
@@ -5,6 +5,53 @@ import time
5
5
  from PyPtt import Service, PostField
6
6
  from tests import config # Assuming config.py has PTT1_ID and PTT1_PW
7
7
 
8
+ class TestCallInterval:
9
+ """Tests for call_interval feature (no network required)."""
10
+
11
+ def test_default_call_interval(self):
12
+ service = Service()
13
+ assert service._call_interval == 0
14
+ service.close()
15
+
16
+ def test_init_with_call_interval(self):
17
+ service = Service(call_interval=1.5)
18
+ assert service._call_interval == 1.5
19
+ service.close()
20
+
21
+ def test_init_with_int_call_interval(self):
22
+ service = Service(call_interval=2)
23
+ assert service._call_interval == 2
24
+ service.close()
25
+
26
+ def test_set_call_interval(self):
27
+ service = Service()
28
+ service.set_call_interval(0.5)
29
+ assert service._call_interval == 0.5
30
+ service.set_call_interval(0)
31
+ assert service._call_interval == 0
32
+ service.close()
33
+
34
+ def test_negative_call_interval_init(self):
35
+ with pytest.raises(PyPtt.exceptions.ParameterError):
36
+ Service(call_interval=-1)
37
+
38
+ def test_negative_call_interval_setter(self):
39
+ service = Service()
40
+ with pytest.raises(PyPtt.exceptions.ParameterError):
41
+ service.set_call_interval(-1)
42
+ service.close()
43
+
44
+ def test_invalid_type_call_interval_init(self):
45
+ with pytest.raises(TypeError):
46
+ Service(call_interval='fast')
47
+
48
+ def test_invalid_type_call_interval_setter(self):
49
+ service = Service()
50
+ with pytest.raises(TypeError):
51
+ service.set_call_interval('fast')
52
+ service.close()
53
+
54
+
8
55
  @pytest.fixture(scope="module", autouse=True)
9
56
  def service_instance():
10
57
  import time
@@ -1,24 +0,0 @@
1
- key = """-----BEGIN EC PARAMETERS-----
2
- BggqhkjOPQMBBw==
3
- -----END EC PARAMETERS-----
4
- -----BEGIN EC PRIVATE KEY-----
5
- MHcCAQEEIFvkfPbkl4Ke99iXWTXG1LJnhFeMFREJWW9d4I76PZmGoAoGCCqGSM49
6
- AwEHoUQDQgAErWNfoKcmEmi+0tNm2CCne7MhF0+4iKZYJvtrn5kxRG73XWhoR79c
7
- 7tfex7gLuFn/VXiP+rgDOe4MLT1LGrtD4A==
8
- -----END EC PRIVATE KEY-----
9
- """
10
-
11
- cert = """-----BEGIN CERTIFICATE-----
12
- MIIB6TCCAY8CFFzZNObjJZqIs69GFDYbSXJqNpBIMAoGCCqGSM49BAMCMHcxCzAJ
13
- BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
- CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
- EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA0MDMxNDM1MzRaFw0zNjAzMzExNDM1
16
- MzRaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
- eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
- bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
- AwEHA0IABK1jX6CnJhJovtLTZtggp3uzIRdPuIimWCb7a5+ZMURu911oaEe/XO7X
20
- 3se4C7hZ/1V4j/q4AznuDC09Sxq7Q+AwCgYIKoZIzj0EAwIDSAAwRQIhAOAM8IPM
21
- 5D4BoaWYf3SaSVzrm8v6FtSIhWrSYJatYjwoAiAW3NggrC+qYY3wYRDq8UmaiHLv
22
- pkJCEsbjl+remcwPbg==
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