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/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
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))
|