python-seatalk-bot 1.0.2__tar.gz → 1.1.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.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-seatalk-bot
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: Python wrapper for the SeaTalk API, inspired by python-telegram-bot.
5
5
  Requires-Dist: aiohttp
6
+ Requires-Dist: websockets>=15.0.1
6
7
  Requires-Python: >=3.9
7
8
  Project-URL: Repository, https://github.com/heyzec/python-seatalk-bot
8
9
  Description-Content-Type: text/markdown
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "python-seatalk-bot"
3
- version = "1.0.2"
3
+ version = "1.1.0"
4
4
  description = "Python wrapper for the SeaTalk API, inspired by python-telegram-bot."
5
5
  requires-python = ">=3.9"
6
6
  dependencies = [
7
7
  "aiohttp",
8
+ "websockets>=15.0.1",
8
9
  ]
9
10
  readme = "README.md"
10
11
 
@@ -208,8 +208,9 @@ class Update:
208
208
  date = (
209
209
  data.event.message.message_sent_time.unwrap()
210
210
  ) # suddenly this API exposes this extra field
211
+ print("the data is ", data)
211
212
  group_id = data.event.group_id.unwrap()
212
- thread_id = data.event.thread_id.unwrap()
213
+ thread_id = data.event.message.thread_id.unwrap()
213
214
  chat_id = Chat.join_group_and_thread_id(group_id, thread_id)
214
215
  chat = Chat(chat_id)
215
216
  employee_code = data.event.message.sender.employee_code.unwrap()
@@ -287,8 +288,8 @@ class Bot:
287
288
  )
288
289
 
289
290
  def __init__(self, app_id: str, app_secret: str):
290
- self.app_id = app_id
291
- self.app_secret = app_secret
291
+ self.app_id: str = app_id
292
+ self.app_secret: str = app_secret
292
293
 
293
294
  async def create_session_if_necessary(self):
294
295
  if self.session is None: # pyright: ignore[reportUnnecessaryComparison]
@@ -315,8 +316,16 @@ class Bot:
315
316
  ) as r:
316
317
  return (await r.json())["app_access_token"]
317
318
 
318
- async def send_message(self, chat_id: str, text: str):
319
+ async def send(self, url: str, data: dict[str, Any]):
319
320
  await self.create_session_if_necessary()
321
+
322
+ r = await self.session.post(url, json=data)
323
+ if (await r.json())["code"] == 100:
324
+ await self.refresh_session()
325
+ r = await self.session.post(url, json=data)
326
+ return await r.json()
327
+
328
+ async def send_message(self, chat_id: str, text: str):
320
329
  data: dict[str, Any] = {
321
330
  "message": {
322
331
  "tag": "text",
@@ -326,7 +335,6 @@ class Bot:
326
335
  },
327
336
  },
328
337
  }
329
- print(data)
330
338
  if Chat.is_employee_code(chat_id):
331
339
  print("Sending message to bot user with employee code:", chat_id)
332
340
  data["employee_code"] = chat_id
@@ -338,15 +346,16 @@ class Bot:
338
346
  data["message"]["thread_id"] = thread_id
339
347
  url = SEND_MESSAGE_TO_GROUP_CHAT_URL
340
348
 
341
- r = await self.session.post(url, json=data)
342
- if (await r.json())["code"] == 100:
343
- await self.refresh_session()
344
- r = await self.session.post(url, json=data)
345
- print(await r.json()) # debug
349
+ res = await self.send(url, data)
350
+ if res["code"] != 0:
351
+ print("Failed to send message:", res)
352
+ return None
353
+ chat = Chat(chat_id)
354
+ return Message(
355
+ message_id=res["message_id"], chat=chat, date=0, from_user=User("")
356
+ ) # dummy message
346
357
 
347
358
  async def send_image(self, chat_id: str, image: str):
348
- await self.create_session_if_necessary()
349
- # b = base64.b64encode(image.read()).decode()
350
359
  data: dict[str, Any] = {
351
360
  "message": {
352
361
  "tag": "image",
@@ -364,14 +373,9 @@ class Bot:
364
373
  data["message"]["thread_id"] = thread_id
365
374
  url = SEND_MESSAGE_TO_GROUP_CHAT_URL
366
375
 
367
- r = await self.session.post(url, json=data)
368
- if (await r.json())["code"] == 100:
369
- await self.refresh_session()
370
- r = await self.session.post(url, json=data)
371
- print(await r.json()) # debug
376
+ await self.send(url, data)
372
377
 
373
378
  async def send_card(self, chat_id: str, card: Card):
374
- await self.create_session_if_necessary()
375
379
  data: dict[str, Any] = {"message": card.encode()}
376
380
  if Chat.is_employee_code(chat_id):
377
381
  data["employee_code"] = chat_id
@@ -381,28 +385,21 @@ class Bot:
381
385
  data["group_id"] = group_id
382
386
  data["message"]["thread_id"] = thread_id
383
387
  url = SEND_MESSAGE_TO_GROUP_CHAT_URL
384
- r = await self.session.post(url, json=data)
385
- if (await r.json())["code"] == 100:
386
- await self.refresh_session()
387
- r = await self.session.post(url, json=data)
388
- print(await r.json()) # debug
388
+
389
+ await self.send(url, data)
389
390
 
390
391
  async def get_user_by_emails(self, emails: list[str]) -> list[User]:
391
- await self.create_session_if_necessary()
392
392
  data = {"emails": emails}
393
- r = await self.session.post(GET_EMPLOYEE_CODE_WITH_EMAIL, json=data)
394
- print(await r.json()) # debug
395
- employees: list[Any] = (await r.json())["employees"]
396
- print(employees) # debug
393
+ url = GET_EMPLOYEE_CODE_WITH_EMAIL
394
+ result = await self.send(url, data)
395
+ employees: list[Any] = (result)["employees"]
397
396
 
398
397
  if employees[0]["code"] == 3000:
399
398
  return []
400
399
 
401
400
  users: list[User] = []
402
401
  for employee in employees:
403
- seatalk_id = (
404
- 0,
405
- ) # employee['seatalk_id'] this api doesn't provide seatalk_id...
402
+ seatalk_id = "Unknown" # employee['seatalk_id'] this api doesn't provide seatalk_id...
406
403
  employee_code = employee["employee_code"]
407
404
  email = employee["email"]
408
405
  user = User(seatalk_id, employee_code, email)
@@ -5,19 +5,16 @@ from typing import Any, Callable, override
5
5
  import asyncio
6
6
  from collections.abc import Coroutine
7
7
  import abc
8
- import hashlib
9
- import json
10
8
  import re
11
9
 
12
- import aiohttp
13
- import aiohttp.web
10
+ from seatalk.ext.updater import Updater, WebhookUpdater, WebsocketUpdater
11
+
14
12
 
15
13
  from .context import CallbackContext
16
14
  from .jobs import JobQueue
17
15
  from ..core import (
18
16
  Bot,
19
17
  Update,
20
- EVENT_VERIFICATION,
21
18
  )
22
19
 
23
20
  # Re-export symbols
@@ -113,8 +110,6 @@ class Persistence:
113
110
 
114
111
  class Application:
115
112
  handlers: dict[int, list[BaseHandler]] = {}
116
- persistence: Persistence | None
117
- signing_secret: bytes | None = None
118
113
 
119
114
  def __init__(
120
115
  self,
@@ -123,14 +118,15 @@ class Application:
123
118
  app_name: str,
124
119
  persistence: Persistence | None = None,
125
120
  ):
121
+ self.app_id: str = app_id
122
+ self.app_secret: str = app_secret
126
123
  self.bot: Bot = Bot(app_id, app_secret)
127
124
  self.persistence: Persistence | None = persistence
128
125
  self.app_name: str = app_name
129
126
 
130
127
  self.job_queue: JobQueue = JobQueue(self.bot)
131
128
 
132
- self.server: aiohttp.web.Application = aiohttp.web.Application()
133
- self._setup()
129
+ self.token: str | None = None
134
130
 
135
131
  def add_handler(self, handler: BaseHandler, group: int = 0):
136
132
  handler.app_name = self.app_name # inject app name
@@ -140,69 +136,48 @@ class Application:
140
136
  handler.app_name = self.app_name # inject app name
141
137
  self.handlers[group].remove(handler)
142
138
 
143
- def _setup(self):
144
- async def handle(request: aiohttp.web.Request) -> aiohttp.web.Response:
145
- assert self.signing_secret is not None
146
-
147
- def is_valid_signature(
148
- signing_secret: bytes, body: bytes, signature: str
149
- ) -> bool:
150
- # ref: https://open.seatalk.io/docs/server-apis-event-callback
151
- return hashlib.sha256(body + signing_secret).hexdigest() == signature
152
-
153
- body: bytes = await request.read()
154
- signature: str = request.headers.get("signature", "")
155
- # 1. validate the signature
156
- if not is_valid_signature(self.signing_secret, body, signature):
157
- return aiohttp.web.Response()
158
- # 2. handle events
159
- data: dict[str, Any] = json.loads(body)
160
-
161
- if data["event_type"] == EVENT_VERIFICATION:
162
- data = {"seatalk_challenge": data["event"]["seatalk_challenge"]}
163
- return aiohttp.web.json_response(data)
164
-
165
- update = Update.from_data(data)
166
- if not update:
167
- print("Failed to parse update from data:", data)
168
- return aiohttp.web.Response()
169
-
170
- callback = None
171
- context = CallbackContext(self.bot)
172
- if self.persistence:
173
- context.bot_data = self.persistence.bot_data
174
- assert update.effective_chat is not None
175
- assert update.effective_user is not None
176
- context.chat_data = self.persistence.chat_data.setdefault(
177
- update.effective_chat.chat_id, {}
178
- )
179
- context.user_data = self.persistence.user_data.setdefault(
180
- update.effective_user.user_id, {}
181
- )
182
-
183
- for group, handlers in sorted(self.handlers.items()):
184
- for handler in handlers:
185
- if handler.check_update(update):
186
- print("Handler", handler, "matched for update", update)
187
- callback = handler.callback
188
- break
189
- else:
190
- print("No handler matched for update", update)
191
-
192
- if callback:
193
- # create task instead of awaiting, we need to respond in time, otherwise seatalk will retry
194
- _ = asyncio.create_task(callback(update, context))
195
-
196
- return aiohttp.web.Response()
197
-
198
- _ = self.server.add_routes([aiohttp.web.post("/", handle)])
139
+ def handle_update(self, update: Update):
140
+ callback = None
141
+ context = CallbackContext(self.bot)
142
+ if self.persistence:
143
+ context.bot_data = self.persistence.bot_data
144
+ assert update.effective_chat is not None
145
+ assert update.effective_user is not None
146
+ context.chat_data = self.persistence.chat_data.setdefault(
147
+ update.effective_chat.chat_id, {}
148
+ )
149
+ context.user_data = self.persistence.user_data.setdefault(
150
+ update.effective_user.user_id, {}
151
+ )
152
+
153
+ for group, handlers in sorted(self.handlers.items()):
154
+ for handler in handlers:
155
+ if handler.check_update(update):
156
+ print("Handler", handler, "matched for update", update)
157
+ callback = handler.callback
158
+ break
159
+ else:
160
+ print("No handler matched for update", update)
161
+
162
+ if callback:
163
+ # create task instead of awaiting, we need to respond in time, otherwise seatalk will retry
164
+ _ = asyncio.create_task(callback(update, context))
165
+
166
+ async def run(self, updater: Updater):
167
+ await updater.setup()
168
+ _ = self.job_queue.start() # don't await
169
+ async for update in updater.get_updates():
170
+ self.handle_update(update)
199
171
 
200
172
  def run_webhook(
201
173
  self, signing_secret: bytes, listen: str = "127.0.0.1", port: int = 80
202
174
  ):
203
- loop = asyncio.new_event_loop()
204
- asyncio.set_event_loop(loop)
205
- loop.create_task(self.job_queue.start())
206
-
207
- self.signing_secret = signing_secret
208
- aiohttp.web.run_app(self.server, host=listen, port=port, loop=loop)
175
+ updater = WebhookUpdater(signing_secret, listen, port)
176
+ asyncio.run(self.run(updater))
177
+
178
+ def run_websocket(self):
179
+ updater = WebsocketUpdater(app_id=self.app_id, app_secret=self.app_secret)
180
+ # loop = asyncio.new_event_loop()
181
+ # asyncio.set_event_loop(loop)
182
+ # loop.run_until_complete(self.run(updater))
183
+ asyncio.run(self.run(updater))
@@ -0,0 +1,254 @@
1
+ # pyright: reportAny=false
2
+ from __future__ import annotations
3
+
4
+ import abc
5
+ import asyncio
6
+ from dataclasses import MISSING, dataclass, field, fields, is_dataclass
7
+ import hashlib
8
+ import json
9
+ from typing import Any, AsyncGenerator, Self, get_type_hints
10
+ import uuid
11
+
12
+ import aiohttp
13
+ import aiohttp.web
14
+ import websockets
15
+ from websockets.asyncio.client import ClientConnection
16
+
17
+ from seatalk.core import EVENT_VERIFICATION, Update
18
+
19
+
20
+ class Updater:
21
+ @abc.abstractmethod
22
+ async def setup(self): ...
23
+
24
+ @abc.abstractmethod
25
+ async def get_updates(self) -> AsyncGenerator[Update, None]:
26
+ raise NotImplementedError("get_updates must be implemented by subclasses")
27
+ yield # pyright: ignore
28
+
29
+
30
+ class WebhookUpdater(Updater):
31
+ def __init__(
32
+ self,
33
+ signing_secret: bytes,
34
+ listen: str = "127.0.0.1",
35
+ port: int = 80,
36
+ ):
37
+ self.signing_secret = signing_secret
38
+ self.listen = listen
39
+ self.port = port
40
+
41
+ self.queue: asyncio.Queue[Update] = asyncio.Queue()
42
+
43
+ async def setup(self):
44
+ server = aiohttp.web.Application()
45
+
46
+ async def handle(request: aiohttp.web.Request) -> aiohttp.web.Response:
47
+ assert self.signing_secret is not None
48
+
49
+ def is_valid_signature(
50
+ signing_secret: bytes, body: bytes, signature: str
51
+ ) -> bool:
52
+ # ref: https://open.seatalk.io/docs/server-apis-event-callback
53
+ return hashlib.sha256(body + signing_secret).hexdigest() == signature
54
+
55
+ body: bytes = await request.read()
56
+ signature: str = request.headers.get("signature", "")
57
+ # 1. validate the signature
58
+ if not is_valid_signature(self.signing_secret, body, signature):
59
+ return aiohttp.web.Response()
60
+ # 2. handle events
61
+ data: dict[str, Any] = json.loads(body)
62
+
63
+ if data["event_type"] == EVENT_VERIFICATION:
64
+ data = {"seatalk_challenge": data["event"]["seatalk_challenge"]}
65
+ return aiohttp.web.json_response(data)
66
+
67
+ update = Update.from_data(data)
68
+ if not update:
69
+ print("Failed to parse update from data:", data)
70
+ return aiohttp.web.Response()
71
+
72
+ self.queue.put_nowait(update)
73
+
74
+ return aiohttp.web.Response()
75
+
76
+ server.add_routes([aiohttp.web.post("/", handle)])
77
+
78
+ # aiohttp.web.run_app(server, host=listen, port=port, loop=loop)
79
+ runner = aiohttp.web.AppRunner(server)
80
+ await runner.setup()
81
+ site = aiohttp.web.TCPSite(
82
+ runner,
83
+ host=self.listen,
84
+ port=self.port,
85
+ )
86
+ await site.start()
87
+
88
+ async def get_updates(self) -> AsyncGenerator[Update, None]:
89
+ while True:
90
+ update = await self.queue.get()
91
+ yield update
92
+
93
+
94
+ CODE_OK = 0
95
+ COMMAND_REGISTER = "register"
96
+ DEFAULT_WEB_SOCKET_URL = "wss://ws-openapi.haiserve.com/ws/bot"
97
+
98
+
99
+ class Serializable:
100
+ @classmethod
101
+ def from_dict(cls, data: Any) -> Self:
102
+ assert is_dataclass(cls), f"{cls.__name__} must be a dataclass"
103
+
104
+ data = data if isinstance(data, dict) else {}
105
+ hints = get_type_hints(cls)
106
+
107
+ kwargs: dict[str, Any] = {}
108
+ for field in fields(cls):
109
+ value = data.get(field.name)
110
+
111
+ # resolved version of what field.type is trying to represent
112
+ annotation = hints[field.name]
113
+
114
+ if isinstance(value, dict) and issubclass(annotation, Serializable):
115
+ value = annotation.from_dict(value)
116
+ elif value is None:
117
+ assert field.default is not MISSING
118
+ value = field.default
119
+
120
+ if annotation is not Any:
121
+ assert isinstance(value, annotation)
122
+ kwargs[field.name] = value
123
+
124
+ return cls(**kwargs)
125
+
126
+ def to_dict(self) -> dict[str, Any]:
127
+ if not is_dataclass(self):
128
+ raise TypeError(f"{type(self).__name__} must be a dataclass")
129
+
130
+ result: dict[str, Any] = {}
131
+ for field in fields(self):
132
+ value = getattr(self, field.name)
133
+ if isinstance(value, Serializable):
134
+ value = value.to_dict()
135
+ if value:
136
+ result[field.name] = value
137
+ return result
138
+
139
+
140
+ @dataclass
141
+ class Header(Serializable):
142
+ app_id: str = ""
143
+ app_secret: str = ""
144
+ token: str = ""
145
+ sid: str = ""
146
+ callback_id: str = ""
147
+ rid: str = ""
148
+
149
+
150
+ @dataclass
151
+ class Envelope(Serializable):
152
+ cmd: str
153
+ header: Header = field(default_factory=Header)
154
+ data: Any = None
155
+ code: int = 0
156
+ message: str = ""
157
+
158
+
159
+ class WebsocketUpdater(Updater):
160
+ def __init__(self, app_id: str, app_secret: str):
161
+ self.app_id: str = app_id
162
+ self.app_secret: str = app_secret
163
+ self._ws: ClientConnection | None = None
164
+
165
+ @property
166
+ def ws(self) -> ClientConnection:
167
+ if self._ws is None:
168
+ raise ValueError("WebSocket is not connected")
169
+ return self._ws
170
+
171
+ async def ws_send(self, envelope: Envelope):
172
+ if not envelope.header.rid:
173
+ envelope.header.rid = uuid.uuid4().hex
174
+ payload = json.dumps(
175
+ envelope.to_dict(), ensure_ascii=False, separators=(",", ":")
176
+ )
177
+ assert self.ws is not None
178
+ await self.ws.send(payload)
179
+
180
+ async def ws_connect(self):
181
+ self._ws = await websockets.connect(
182
+ DEFAULT_WEB_SOCKET_URL,
183
+ # additional_headers=self.request_headers,
184
+ open_timeout=15.0,
185
+ )
186
+ print("[ws] connected")
187
+ await self.ws_send(
188
+ Envelope(
189
+ cmd=COMMAND_REGISTER,
190
+ header=Header(app_id=self.app_id, app_secret=self.app_secret),
191
+ )
192
+ )
193
+
194
+ assert self.ws is not None
195
+ message = await self.ws.recv()
196
+ try:
197
+ env = Envelope.from_dict(json.loads(message))
198
+ except Exception as exc:
199
+ raise ValueError("invalid json in register phase: %s" % exc)
200
+ if env.cmd != COMMAND_REGISTER:
201
+ raise ValueError(
202
+ 'expected cmd "%s" first, got "%s" (%s)'
203
+ % (COMMAND_REGISTER, env.cmd, env.message)
204
+ )
205
+ if env.code != CODE_OK:
206
+ raise ValueError(
207
+ "register rejected: code=%d msg=%s" % (env.code, env.message)
208
+ )
209
+ if not env.header.token:
210
+ raise ValueError("register ok but empty token")
211
+ self.token = env.header.token
212
+ # self.ping_interval = (
213
+ # float(data.get("heartbeat_interval") or 0) or self.ping_interval
214
+ # )
215
+ # print(
216
+ # f"[ws] registered token={self._token} heartbeat_interval={self.ping_interval}"
217
+ # )
218
+
219
+ async def ping_loop(self):
220
+ assert self.token is not None
221
+ while True:
222
+ await self.ws_send(Envelope(cmd="ping", header=Header(token=self.token)))
223
+ # await asyncio.sleep(self.ping_interval)
224
+ await asyncio.sleep(10)
225
+
226
+ async def setup(self):
227
+ await self.ws_connect()
228
+ ping_task = asyncio.create_task(self.ping_loop())
229
+
230
+ async def get_updates(self) -> AsyncGenerator[Update, None]:
231
+ await self.setup()
232
+ print("[ws] waiting for updates...")
233
+ assert self.token is not None
234
+ async for message in self.ws:
235
+ print("[ws] received message:", message)
236
+ try:
237
+ env = Envelope.from_dict(json.loads(message))
238
+ except Exception as exc:
239
+ print(f"[ws] invalid frame: {exc}")
240
+ continue
241
+ if env.cmd == "event" and env.header.callback_id:
242
+ await self.ws_send(
243
+ Envelope(
244
+ cmd="ack",
245
+ header=Header(
246
+ token=self.token, callback_id=env.header.callback_id
247
+ ),
248
+ )
249
+ )
250
+ update = Update.from_data(env.data)
251
+ if not update:
252
+ print(f"[ws] failed to parse update from data: {env.data}")
253
+ continue
254
+ yield update