dispatchbox 0.1.0__py3-none-any.whl

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.
dispatchbox/feishu.py ADDED
@@ -0,0 +1,304 @@
1
+ """Feishu (飞书) adapter for a self-built app, standard library only unless an Encrypt Key is used.
2
+
3
+ Formats follow open.feishu.cn docs: OAuth client_credentials token, im/v1 messages and replies,
4
+ card JSON 2.0 with callback buttons, card.action.trigger callbacks, im.message.receive_v1 events,
5
+ url_verification challenges, and AES-256-CBC payload encryption when an Encrypt Key is configured.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import logging
15
+ import re
16
+ import threading
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from typing import Any
22
+
23
+ from .chat import (ChatAdapter, ChatError, Response, answer_text, decided_line, decision_text, http_json,
24
+ in_background, json_response, status_line)
25
+ from .net import ssl_context
26
+
27
+ log = logging.getLogger("dispatchbox.feishu")
28
+
29
+ _AT_PLACEHOLDER = re.compile(r"@_user_\d+")
30
+ TOKEN_URL = "https://accounts.feishu.cn/oauth/v3/token"
31
+ API_BASE = "https://open.feishu.cn/open-apis"
32
+
33
+
34
+ def decrypt(encrypt_key: str, blob: str) -> bytes:
35
+ """AES-256-CBC, key = sha256(encrypt_key), first 16 bytes of the base64-decoded blob are the IV, PKCS7."""
36
+ try:
37
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
38
+ except ImportError as e: # checked at startup too, this is a second line of defence
39
+ raise ChatError("Feishu Encrypt Key is set but the 'cryptography' package is not installed") from e
40
+ raw = base64.b64decode(blob)
41
+ key = hashlib.sha256(encrypt_key.encode()).digest()
42
+ dec = Cipher(algorithms.AES(key), modes.CBC(raw[:16])).decryptor()
43
+ padded = dec.update(raw[16:]) + dec.finalize()
44
+ pad = padded[-1]
45
+ if not 1 <= pad <= 16 or padded[-pad:] != bytes([pad]) * pad:
46
+ raise ChatError("bad padding in Feishu payload")
47
+ return padded[:-pad]
48
+
49
+
50
+ def encryption_available() -> bool:
51
+ try:
52
+ import cryptography.hazmat.primitives.ciphers # noqa: F401
53
+ return True
54
+ except ImportError:
55
+ return False
56
+
57
+
58
+ class FeishuAdapter(ChatAdapter):
59
+ name = "feishu"
60
+
61
+ def __init__(self, hub, app_id: str, app_secret: str, verification_token: str, encrypt_key: str = "",
62
+ api_base: str = API_BASE, token_url: str = TOKEN_URL):
63
+ super().__init__(hub)
64
+ if encrypt_key and not encryption_available():
65
+ raise ChatError("DISPATCHBOX_FEISHU_ENCRYPT_KEY is set, which needs the 'cryptography' package. "
66
+ "Install it with `python3 -m pip install cryptography`, or clear the Encrypt Key "
67
+ "in the Feishu console and unset DISPATCHBOX_FEISHU_ENCRYPT_KEY.")
68
+ self.app_id = app_id
69
+ self.app_secret = app_secret
70
+ self.verification_token = verification_token
71
+ self.encrypt_key = encrypt_key
72
+ self.api_base = api_base.rstrip("/")
73
+ self.token_url = token_url
74
+ self._token = ""
75
+ self._token_expiry = 0.0
76
+ self._token_lock = threading.Lock()
77
+
78
+ # ------------------------------------------------------------------ Feishu Open API
79
+
80
+ def token(self) -> str:
81
+ with self._token_lock:
82
+ if self._token and time.time() < self._token_expiry - 300:
83
+ return self._token
84
+ body = urllib.parse.urlencode({"grant_type": "client_credentials", "client_id": self.app_id,
85
+ "client_secret": self.app_secret}).encode()
86
+ req = urllib.request.Request(self.token_url, data=body, method="POST",
87
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
88
+ try:
89
+ with urllib.request.urlopen(req, timeout=10, context=ssl_context()) as resp:
90
+ data = json.loads(resp.read())
91
+ except urllib.error.HTTPError as e:
92
+ data = json.loads(e.read() or b"{}")
93
+ # The current endpoint returns access_token/expires_in; the historical one tenant_access_token/expire.
94
+ tok = data.get("access_token") or data.get("tenant_access_token")
95
+ if data.get("code", 0) != 0 or not tok:
96
+ raise ChatError(f"feishu token request failed: {data.get('error_description') or data.get('msg') or data}")
97
+ self._token = tok
98
+ self._token_expiry = time.time() + int(data.get("expires_in") or data.get("expire") or 7200)
99
+ return tok
100
+
101
+ def api(self, method: str, path: str, body: dict[str, Any]) -> dict[str, Any]:
102
+ resp = http_json(method, self.api_base + path, body, headers={"Authorization": f"Bearer {self.token()}"})
103
+ if resp.get("code", -1) != 0:
104
+ raise ChatError(f"feishu {path} failed: code {resp.get('code')} {resp.get('msg')}")
105
+ return resp.get("data") or {}
106
+
107
+ def send(self, receive_id: str, id_type: str, msg_type: str, content: dict[str, Any]) -> str:
108
+ data = self.api("POST", f"/im/v1/messages?receive_id_type={urllib.parse.quote(id_type)}",
109
+ {"receive_id": receive_id, "msg_type": msg_type,
110
+ "content": json.dumps(content, ensure_ascii=False)})
111
+ return data.get("message_id", "")
112
+
113
+ def reply(self, message_id: str, msg_type: str, content: dict[str, Any]) -> str:
114
+ data = self.api("POST", f"/im/v1/messages/{urllib.parse.quote(message_id)}/reply",
115
+ {"msg_type": msg_type, "content": json.dumps(content, ensure_ascii=False)})
116
+ return data.get("message_id", "")
117
+
118
+ # ------------------------------------------------------------------ incoming requests
119
+
120
+ def _open(self, headers: dict[str, str], body: bytes) -> dict[str, Any] | None:
121
+ """Decrypt and authenticate a request. Returns the inner JSON, or None if it is not genuine."""
122
+ try:
123
+ data = json.loads(body or b"{}")
124
+ except json.JSONDecodeError:
125
+ return None
126
+ if "encrypt" in data:
127
+ if not self.encrypt_key:
128
+ log.error("feishu sent an encrypted payload but DISPATCHBOX_FEISHU_ENCRYPT_KEY is not set")
129
+ return None
130
+ try:
131
+ inner = json.loads(decrypt(self.encrypt_key, data["encrypt"]))
132
+ except (ChatError, ValueError) as e:
133
+ log.error("cannot decrypt feishu payload: %s", e)
134
+ return None
135
+ if inner.get("type") != "url_verification":
136
+ ts = headers.get("x-lark-request-timestamp", "")
137
+ nonce = headers.get("x-lark-request-nonce", "")
138
+ expected = hashlib.sha256((ts + nonce + self.encrypt_key).encode() + body).hexdigest()
139
+ if not hmac.compare_digest(headers.get("x-lark-signature", ""), expected):
140
+ log.error("feishu signature mismatch")
141
+ return None
142
+ data = inner
143
+ elif self.encrypt_key:
144
+ log.error("feishu sent a plain payload but an Encrypt Key is configured")
145
+ return None
146
+ token = data.get("token") if data.get("type") == "url_verification" else (data.get("header") or {}).get("token")
147
+ if not hmac.compare_digest(str(token or ""), self.verification_token):
148
+ log.error("feishu verification token mismatch")
149
+ return None
150
+ return data
151
+
152
+ def handle(self, headers: dict[str, str], body: bytes) -> Response:
153
+ data = self._open(headers, body)
154
+ if data is None:
155
+ return json_response({"error": "not a genuine feishu request"}, 401)
156
+ if data.get("type") == "url_verification":
157
+ return json_response({"challenge": data.get("challenge", "")})
158
+ event_type = (data.get("header") or {}).get("event_type")
159
+ if event_type == "card.action.trigger":
160
+ return json_response(self._card_action(data.get("event") or {}))
161
+ if event_type == "im.message.receive_v1":
162
+ self._message(data.get("event") or {})
163
+ return json_response({})
164
+
165
+ def _message(self, ev: dict[str, Any]) -> None:
166
+ sender = ev.get("sender") or {}
167
+ msg = ev.get("message") or {}
168
+ if sender.get("sender_type") != "user" or msg.get("message_type") != "text":
169
+ return
170
+ user = (sender.get("sender_id") or {}).get("open_id", "")
171
+ message_id = msg.get("message_id", "")
172
+ if not self.user_allowed(user):
173
+ log.warning("feishu user %s is not in chat.feishu.allowed_users", user)
174
+ in_background(self._reply_safe, message_id,
175
+ f"You are not allowed to start dispatchbox runs. Ask the owner to add {user} to "
176
+ "chat.feishu.allowed_users.")
177
+ return
178
+ try:
179
+ text = json.loads(msg.get("content") or "{}").get("text", "")
180
+ except json.JSONDecodeError:
181
+ text = ""
182
+ text = _AT_PLACEHOLDER.sub("", text).strip()
183
+ chat = msg.get("chat_id", "")
184
+ result = self.hub.ingest({
185
+ "source": "feishu", "type": "message.dm" if msg.get("chat_type") == "p2p" else "message.mention",
186
+ "subject": chat, "summary": text[:200],
187
+ "payload": {"chat": chat, "message_id": message_id, "thread": msg.get("root_id") or message_id,
188
+ "user": user, "text": text},
189
+ # Feishu's docs say to dedupe messages on message_id rather than event_id.
190
+ "dedupe_key": f"feishu:{message_id}",
191
+ "dedupe_window": 86400,
192
+ })
193
+ if self.settings.get("ack", True):
194
+ from .slack import ack_text # same wording on both platforms
195
+ msg_text = ack_text(result)
196
+ if msg_text:
197
+ in_background(self._reply_safe, message_id, msg_text)
198
+
199
+ def _card_action(self, ev: dict[str, Any]) -> dict[str, Any]:
200
+ value = (ev.get("action") or {}).get("value")
201
+ if isinstance(value, str):
202
+ try:
203
+ value = json.loads(value)
204
+ except json.JSONDecodeError:
205
+ value = {}
206
+ try:
207
+ run_id, choice = int(value["run"]), str(value["choice"])
208
+ except (KeyError, TypeError, ValueError):
209
+ return {"toast": {"type": "error", "content": "Not a dispatchbox button"}}
210
+ user = (ev.get("operator") or {}).get("open_id", "")
211
+ ok, message, run = self.decide_from_chat(run_id, choice, user)
212
+ resp: dict[str, Any] = {"toast": {"type": "success" if ok else "error", "content": message[:100]}}
213
+ if run:
214
+ link = self.hub.run_link(run_id)
215
+ card = decision_card(run, link) if run["status"] == "awaiting_decision" else decided_card(run, link)
216
+ resp["card"] = {"type": "raw", "data": card}
217
+ return resp
218
+
219
+ # ------------------------------------------------------------------ hub hooks
220
+
221
+ def notify(self, run: dict[str, Any], link: str) -> None:
222
+ if self.from_chat(run):
223
+ return # the originating conversation hears about it in run_finished
224
+ target = self.settings.get("notify_to")
225
+ if not target:
226
+ log.warning("feishu: chat.feishu.notify_to is not set, skipping notification for run %s", run["id"])
227
+ return
228
+ id_type = self.settings.get("notify_to_type", "chat_id")
229
+ if run["status"] == "awaiting_decision":
230
+ self.send(target, id_type, "interactive", decision_card(run, link))
231
+ else:
232
+ self.send(target, id_type, "interactive", result_card(run, link))
233
+
234
+ def run_finished(self, run: dict[str, Any], link: str) -> None:
235
+ origin = self.origin(run)
236
+ if not origin or not self.settings.get("reply_on_finish", True):
237
+ return
238
+ if run["status"] == "awaiting_decision":
239
+ self.reply(origin["message_id"], "interactive", decision_card(run, link))
240
+ elif run["status"] in {"succeeded", "failed", "lost"}:
241
+ self.reply(origin["message_id"], "interactive", result_card(run, link))
242
+
243
+ def _reply_safe(self, message_id: str, text: str) -> None:
244
+ try:
245
+ self.reply(message_id, "text", {"text": text})
246
+ except ChatError as e:
247
+ log.error("feishu reply failed: %s", e)
248
+
249
+
250
+ # ---------------------------------------------------------------------- cards (JSON 2.0)
251
+
252
+ _TEMPLATE = {"awaiting_decision": "orange", "succeeded": "green", "failed": "red", "lost": "red", "decided": "grey"}
253
+
254
+
255
+ def _card(title: str, template: str, elements: list[dict[str, Any]]) -> dict[str, Any]:
256
+ return {
257
+ "schema": "2.0",
258
+ "config": {"update_multi": True},
259
+ "header": {"title": {"tag": "plain_text", "content": title[:100]}, "template": template},
260
+ "body": {"elements": elements},
261
+ }
262
+
263
+
264
+ def _link_md(link: str) -> list[dict[str, Any]]:
265
+ if link.startswith(("http://", "https://")):
266
+ return [{"tag": "markdown", "content": f"[Open in dispatchbox]({link})"}]
267
+ return []
268
+
269
+
270
+ def decision_card(run: dict[str, Any], link: str) -> dict[str, Any]:
271
+ question, context, options = decision_text(run)
272
+ elements: list[dict[str, Any]] = [
273
+ {"tag": "markdown", "content": f"**{question}**"},
274
+ {"tag": "markdown", "content": status_line(run)},
275
+ ]
276
+ if context:
277
+ elements.append({"tag": "markdown", "content": context[:2000]})
278
+ for i, opt in enumerate(options):
279
+ elements.append({
280
+ "tag": "button", "text": {"tag": "plain_text", "content": opt[:60]},
281
+ "type": "primary" if i == 0 else "default",
282
+ "behaviors": [{"type": "callback", "value": {"run": run["id"], "choice": opt}}],
283
+ })
284
+ if not options:
285
+ elements.append({"tag": "markdown", "content": "No options were given. Answer on the dashboard."})
286
+ return _card(f"Needs your decision · run #{run['id']}", "orange", elements + _link_md(link))
287
+
288
+
289
+ def decided_card(run: dict[str, Any], link: str) -> dict[str, Any]:
290
+ question, _, _ = decision_text(run)
291
+ elements = [
292
+ {"tag": "markdown", "content": f"~~{question}~~"},
293
+ {"tag": "markdown", "content": f"**{decided_line(run)}**"},
294
+ {"tag": "markdown", "content": status_line(run)},
295
+ ]
296
+ return _card(f"Decided · run #{run['id']}", "grey", elements + _link_md(link))
297
+
298
+
299
+ def result_card(run: dict[str, Any], link: str) -> dict[str, Any]:
300
+ elements = [{"tag": "markdown", "content": status_line(run)}]
301
+ answer = answer_text(run, 2000)
302
+ if answer:
303
+ elements.append({"tag": "markdown", "content": answer})
304
+ return _card(f"Run #{run['id']} {run['status']}", _TEMPLATE.get(run["status"], "blue"), elements + _link_md(link))