agents-pod 0.1.0 → 0.3.0
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.
- package/CHANGELOG.md +68 -0
- package/README.md +283 -25
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +472 -47
- package/src/doctor.ts +551 -0
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +15 -0
- package/src/sources.ts +312 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Shared Feishu client for the agents-pod operator adapters (ADR 0024 / 0025).
|
|
2
|
+
|
|
3
|
+
No third-party dependencies: urllib only, so a cron tick can run an adapter
|
|
4
|
+
without a virtualenv. Credentials come from this directory's `.env` (0600) and
|
|
5
|
+
never appear on a command line or in stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
BASE = "https://open.feishu.cn/open-apis"
|
|
16
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
ENV_FILE = os.path.join(HERE, ".env")
|
|
18
|
+
TOKEN_FILE = os.path.join(HERE, ".token.json")
|
|
19
|
+
STATE_FILE = os.path.join(HERE, "state.json")
|
|
20
|
+
BOT_FILE = os.path.join(HERE, ".bot.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FeishuError(RuntimeError):
|
|
24
|
+
def __init__(self, path, code, msg, ext=None):
|
|
25
|
+
super().__init__("%s: code=%s msg=%s" % (path, code, msg))
|
|
26
|
+
self.path = path
|
|
27
|
+
self.code = code
|
|
28
|
+
self.msg = msg
|
|
29
|
+
# Feishu puts the actionable part - the scope name, often a console
|
|
30
|
+
# link - in `ext` rather than `msg`, so keep it (ADR 0027's --check).
|
|
31
|
+
self.ext = ext or ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_env():
|
|
35
|
+
"""Reads `.env` without clobbering the environment operators set explicitly."""
|
|
36
|
+
values = {}
|
|
37
|
+
try:
|
|
38
|
+
with open(ENV_FILE, "r", encoding="utf-8") as fh:
|
|
39
|
+
for line in fh:
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
42
|
+
continue
|
|
43
|
+
key, _, val = line.partition("=")
|
|
44
|
+
values[key.strip()] = val.strip().strip('"').strip("'")
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
pass
|
|
47
|
+
for key, val in values.items():
|
|
48
|
+
os.environ.setdefault(key, val)
|
|
49
|
+
return values
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _request(method, path, token=None, body=None, params=None, timeout=10):
|
|
53
|
+
url = BASE + path
|
|
54
|
+
if params:
|
|
55
|
+
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
|
|
56
|
+
data = None
|
|
57
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
58
|
+
if token:
|
|
59
|
+
headers["Authorization"] = "Bearer " + token
|
|
60
|
+
if body is not None:
|
|
61
|
+
data = json.dumps(body).encode("utf-8")
|
|
62
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
63
|
+
raw = None
|
|
64
|
+
for attempt in range(3):
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
67
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
68
|
+
break
|
|
69
|
+
except urllib.error.HTTPError as err:
|
|
70
|
+
raw = err.read().decode("utf-8", "replace")
|
|
71
|
+
if err.code == 429 and attempt < 2:
|
|
72
|
+
time.sleep(1 + attempt)
|
|
73
|
+
continue
|
|
74
|
+
break
|
|
75
|
+
except urllib.error.URLError as err:
|
|
76
|
+
if attempt < 2:
|
|
77
|
+
time.sleep(1 + attempt)
|
|
78
|
+
continue
|
|
79
|
+
raise FeishuError(path, "network", str(err))
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(raw or "{}")
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise FeishuError(path, "not-json", (raw or "")[:200])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def api(method, path, token=None, body=None, params=None, timeout=10):
|
|
87
|
+
answer = _request(method, path, token, body, params, timeout)
|
|
88
|
+
code = answer.get("code")
|
|
89
|
+
if code not in (0, None):
|
|
90
|
+
raise FeishuError(path, code, answer.get("msg"), answer.get("ext"))
|
|
91
|
+
return answer.get("data", answer)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_json(path):
|
|
95
|
+
try:
|
|
96
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
97
|
+
return json.load(fh)
|
|
98
|
+
except (FileNotFoundError, ValueError):
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_json(path, value, mode=0o600):
|
|
103
|
+
tmp = path + ".tmp"
|
|
104
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
105
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
106
|
+
json.dump(value, fh, ensure_ascii=False)
|
|
107
|
+
os.replace(tmp, path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tenant_token(force=False):
|
|
111
|
+
"""`tenant_access_token` lives 2 h; caching keeps a 1-minute tick cheap."""
|
|
112
|
+
cached = _read_json(TOKEN_FILE)
|
|
113
|
+
if not force and cached.get("token") and cached.get("expires_at", 0) > time.time() + 60:
|
|
114
|
+
return cached["token"]
|
|
115
|
+
app_id = os.environ.get("FEISHU_APP_ID", "").strip()
|
|
116
|
+
app_secret = os.environ.get("FEISHU_APP_SECRET", "").strip()
|
|
117
|
+
if not app_id or not app_secret:
|
|
118
|
+
raise FeishuError("/auth/v3/tenant_access_token/internal", "no-credentials",
|
|
119
|
+
"FEISHU_APP_ID / FEISHU_APP_SECRET missing (see %s)" % ENV_FILE)
|
|
120
|
+
answer = _request("POST", "/auth/v3/tenant_access_token/internal",
|
|
121
|
+
body={"app_id": app_id, "app_secret": app_secret})
|
|
122
|
+
if answer.get("code") != 0 or not answer.get("tenant_access_token"):
|
|
123
|
+
raise FeishuError("/auth/v3/tenant_access_token/internal",
|
|
124
|
+
answer.get("code"), answer.get("msg"))
|
|
125
|
+
token = answer["tenant_access_token"]
|
|
126
|
+
expires_at = time.time() + int(answer.get("expire", 7200))
|
|
127
|
+
_write_json(TOKEN_FILE, {"token": token, "expires_at": expires_at})
|
|
128
|
+
return token
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def bot_open_id(token):
|
|
132
|
+
"""The bot's own `open_id`, used to spot an @-mention of us."""
|
|
133
|
+
cached = _read_json(BOT_FILE)
|
|
134
|
+
if cached.get("open_id"):
|
|
135
|
+
return cached["open_id"]
|
|
136
|
+
data = api("GET", "/bot/v3/info", token=token)
|
|
137
|
+
bot = data.get("bot") or {}
|
|
138
|
+
open_id = bot.get("open_id")
|
|
139
|
+
if open_id:
|
|
140
|
+
_write_json(BOT_FILE, {"open_id": open_id, "app_name": bot.get("app_name")})
|
|
141
|
+
return open_id
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_state():
|
|
145
|
+
state = _read_json(STATE_FILE)
|
|
146
|
+
state.setdefault("known_chats", [])
|
|
147
|
+
state.setdefault("threads", {})
|
|
148
|
+
return state
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save_state(state):
|
|
152
|
+
_write_json(STATE_FILE, state)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def flatten_message(msg):
|
|
156
|
+
"""Plain text for a Feishu message, whatever its `msg_type`."""
|
|
157
|
+
msg_type = msg.get("msg_type") or ""
|
|
158
|
+
try:
|
|
159
|
+
content = json.loads((msg.get("body") or {}).get("content") or "{}")
|
|
160
|
+
except ValueError:
|
|
161
|
+
content = {}
|
|
162
|
+
if isinstance(content, str):
|
|
163
|
+
try:
|
|
164
|
+
content = json.loads(content)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return content
|
|
167
|
+
|
|
168
|
+
if msg_type == "text":
|
|
169
|
+
return str(content.get("text", "")).strip()
|
|
170
|
+
if msg_type == "post":
|
|
171
|
+
return _flatten_post(content).strip()
|
|
172
|
+
if msg_type in ("image",):
|
|
173
|
+
return "[图片]"
|
|
174
|
+
if msg_type in ("file", "folder"):
|
|
175
|
+
return "[文件] %s" % (content.get("file_name") or "")
|
|
176
|
+
if msg_type in ("audio",):
|
|
177
|
+
return "[语音]"
|
|
178
|
+
if msg_type in ("media",):
|
|
179
|
+
return "[视频]"
|
|
180
|
+
if msg_type == "sticker":
|
|
181
|
+
return "[表情]"
|
|
182
|
+
if msg_type == "merge_forward":
|
|
183
|
+
return "[合并转发消息]"
|
|
184
|
+
if msg_type == "interactive":
|
|
185
|
+
return "[卡片]"
|
|
186
|
+
if msg_type == "share_chat":
|
|
187
|
+
return "[群名片]"
|
|
188
|
+
return "[%s]" % (msg_type or "unknown")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _flatten_post(node):
|
|
192
|
+
"""A post may nest a locale layer: {zh_cn: {title, content}}."""
|
|
193
|
+
if isinstance(node, dict):
|
|
194
|
+
if "content" in node or "title" in node:
|
|
195
|
+
lines = []
|
|
196
|
+
title = (node.get("title") or "").strip()
|
|
197
|
+
if title:
|
|
198
|
+
lines.append(title)
|
|
199
|
+
for paragraph in node.get("content") or []:
|
|
200
|
+
parts = []
|
|
201
|
+
for element in paragraph if isinstance(paragraph, list) else []:
|
|
202
|
+
if not isinstance(element, dict):
|
|
203
|
+
continue
|
|
204
|
+
if element.get("tag") in ("text", "a", "md"):
|
|
205
|
+
parts.append(str(element.get("text") or ""))
|
|
206
|
+
elif element.get("tag") == "at":
|
|
207
|
+
parts.append("@" + str(element.get("user_name") or "某人"))
|
|
208
|
+
elif element.get("tag") == "img":
|
|
209
|
+
parts.append("[图片]")
|
|
210
|
+
if parts:
|
|
211
|
+
lines.append("".join(parts))
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
for value in node.values():
|
|
214
|
+
text = _flatten_post(value)
|
|
215
|
+
if text:
|
|
216
|
+
return text
|
|
217
|
+
return ""
|
|
218
|
+
if isinstance(node, list):
|
|
219
|
+
return "\n".join(t for t in (_flatten_post(v) for v in node) if t)
|
|
220
|
+
return ""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Feishu reply deliverer — the single agents-pod delivery command (ADR 0025).
|
|
3
|
+
|
|
4
|
+
The wrapper invokes this once per outbox entry, at wake-up exit. The entry
|
|
5
|
+
arrives verbatim on stdin:
|
|
6
|
+
|
|
7
|
+
{"to": "chat=<chat_id>;affinity=<root_id>[;thread=<thread_id>]", "text": "…"}
|
|
8
|
+
|
|
9
|
+
`to` is the Delivery Address rendered by `{{reply_to}}` (ADR 0026). A reply goes
|
|
10
|
+
to the affinity message — inside its thread when `thread` is present — so the
|
|
11
|
+
human's answer inherits `root_id` and the next turn routes back to the same
|
|
12
|
+
Conversation. `AGENTS_POD_OUTBOX_UUID` is Feishu's one-hour reply idempotency
|
|
13
|
+
key: the same value is sent on every retry of the same entry.
|
|
14
|
+
|
|
15
|
+
Exit 0 acknowledges delivery (core deletes the entry); anything else leaves the
|
|
16
|
+
file for the next pass. `--check` (ADR 0027) prints a self-check instead, and
|
|
17
|
+
exits 0 healthy or 1 not.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import feishu_client as fc
|
|
25
|
+
|
|
26
|
+
# Feishu text messages cap at 150 KB; a chat reply has no business being long.
|
|
27
|
+
TEXT_CAP = 30000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_address(raw):
|
|
31
|
+
"""`chat=…;affinity=…[;thread=…]` → dict, tolerating spaces and order."""
|
|
32
|
+
fields = {}
|
|
33
|
+
for part in (raw or "").split(";"):
|
|
34
|
+
key, _, value = part.partition("=")
|
|
35
|
+
key = key.strip().lower()
|
|
36
|
+
if key:
|
|
37
|
+
fields[key] = value.strip()
|
|
38
|
+
return fields
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reply(token, fields, text, uuid):
|
|
42
|
+
chat_id = fields.get("chat", "")
|
|
43
|
+
affinity = fields.get("affinity", "")
|
|
44
|
+
thread_id = fields.get("thread", "")
|
|
45
|
+
content = json.dumps({"text": text}, ensure_ascii=False)
|
|
46
|
+
body = {"msg_type": "text", "content": content}
|
|
47
|
+
if uuid:
|
|
48
|
+
body["uuid"] = uuid
|
|
49
|
+
|
|
50
|
+
if affinity:
|
|
51
|
+
try:
|
|
52
|
+
payload = dict(body)
|
|
53
|
+
if thread_id:
|
|
54
|
+
payload["reply_in_thread"] = True
|
|
55
|
+
fc.api("POST", "/im/v1/messages/%s/reply" % affinity, token=token, body=payload)
|
|
56
|
+
return "reply:%s%s" % (affinity, ":thread" if thread_id else "")
|
|
57
|
+
except fc.FeishuError as err:
|
|
58
|
+
sys.stderr.write("[feishu-deliver] reply to %s failed (%s); trying the chat\n"
|
|
59
|
+
% (affinity, err))
|
|
60
|
+
if not chat_id:
|
|
61
|
+
raise
|
|
62
|
+
if not chat_id:
|
|
63
|
+
raise fc.FeishuError("/im/v1/messages", "no-address", "neither affinity nor chat")
|
|
64
|
+
fc.api("POST", "/im/v1/messages", token=token,
|
|
65
|
+
params={"receive_id_type": "chat_id"},
|
|
66
|
+
body={"receive_id": chat_id, **body})
|
|
67
|
+
return "chat:%s" % chat_id
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def self_check():
|
|
71
|
+
"""`--check`: the operator command's own report (ADR 0027). Exit 0 healthy, 1 not."""
|
|
72
|
+
fc.load_env()
|
|
73
|
+
try:
|
|
74
|
+
fc.tenant_token()
|
|
75
|
+
except fc.FeishuError as err:
|
|
76
|
+
print("no tenant token: %s" % err)
|
|
77
|
+
print(" credentials live in .env beside this file: cp .env.example .env, then fill")
|
|
78
|
+
return 1
|
|
79
|
+
print("token ok (tenant_access_token)")
|
|
80
|
+
|
|
81
|
+
address = "chat=oc_x;affinity=om_x;thread=t_x"
|
|
82
|
+
parsed = parse_address(address)
|
|
83
|
+
if parsed != {"chat": "oc_x", "affinity": "om_x", "thread": "t_x"}:
|
|
84
|
+
print("Delivery Address parser broken: %r -> %r" % (address, parsed))
|
|
85
|
+
return 1
|
|
86
|
+
print("Delivery Address parser ok: chat=…;affinity=…[;thread=…]")
|
|
87
|
+
|
|
88
|
+
# No scope-check endpoint exists: `im:message:send_as_bot` is proven by a
|
|
89
|
+
# real send, and a probe must not post into someone's chat.
|
|
90
|
+
print("send scope is only proven by a real send; the first reply verifies it")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
if "--check" in sys.argv[1:]:
|
|
96
|
+
return self_check()
|
|
97
|
+
fc.load_env()
|
|
98
|
+
raw = sys.stdin.read()
|
|
99
|
+
try:
|
|
100
|
+
entry = json.loads(raw)
|
|
101
|
+
except ValueError:
|
|
102
|
+
sys.stderr.write("[feishu-deliver] entry is not JSON\n")
|
|
103
|
+
return 1
|
|
104
|
+
if not isinstance(entry, dict):
|
|
105
|
+
sys.stderr.write("[feishu-deliver] entry is not a JSON object\n")
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
text = entry.get("text")
|
|
109
|
+
address = entry.get("to")
|
|
110
|
+
if not isinstance(text, str) or not text.strip():
|
|
111
|
+
sys.stderr.write("[feishu-deliver] entry has no text\n")
|
|
112
|
+
return 1
|
|
113
|
+
if not isinstance(address, str) or not address.strip():
|
|
114
|
+
sys.stderr.write("[feishu-deliver] entry has no Delivery Address\n")
|
|
115
|
+
return 1
|
|
116
|
+
if len(text) > TEXT_CAP:
|
|
117
|
+
text = text[:TEXT_CAP] + "\n…(truncated)"
|
|
118
|
+
|
|
119
|
+
fields = parse_address(address)
|
|
120
|
+
if not fields.get("chat") and not fields.get("affinity"):
|
|
121
|
+
sys.stderr.write("[feishu-deliver] unusable Delivery Address %r\n" % address)
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
uuid = (os.environ.get("AGENTS_POD_OUTBOX_UUID") or "").strip()
|
|
125
|
+
try:
|
|
126
|
+
token = fc.tenant_token()
|
|
127
|
+
where = reply(token, fields, text, uuid)
|
|
128
|
+
except fc.FeishuError as err:
|
|
129
|
+
sys.stderr.write("[feishu-deliver] %s\n" % err)
|
|
130
|
+
return 1
|
|
131
|
+
sys.stdout.write("[feishu-deliver] delivered to %s\n" % where)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(main())
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Feishu pull drain — an agents-pod Source command (ADR 0024).
|
|
3
|
+
|
|
4
|
+
One invocation = one reconcile pass. Reads every group chat the bot is in
|
|
5
|
+
(plus the thread containers those chats reveal) from the committed cursor,
|
|
6
|
+
prints one JSONL item per human message, then the new cursor.
|
|
7
|
+
|
|
8
|
+
Environment (all optional, read from `~/.agents/pods/adapters/.env` too):
|
|
9
|
+
FEISHU_APP_ID / FEISHU_APP_SECRET the bot app credentials
|
|
10
|
+
FEISHU_CHATS comma-separated chat_id allowlist (empty: all)
|
|
11
|
+
FEISHU_REQUIRE_MENTION 1 = only messages that @-mention the bot
|
|
12
|
+
FEISHU_LOOKBACK_SECONDS read this far back when there is no cursor (3600)
|
|
13
|
+
FEISHU_OVERLAP_SECONDS re-read overlap behind the cursor (60)
|
|
14
|
+
FEISHU_MAX_ITEMS at most this many items per drain (40)
|
|
15
|
+
--check print a self-check instead of draining (ADR 0027):
|
|
16
|
+
exit 0 healthy, 1 not; `agents-pod doctor --probe`
|
|
17
|
+
runs it and quotes these lines
|
|
18
|
+
|
|
19
|
+
The cursor never advances past an item this drain did not print, so a burst
|
|
20
|
+
bigger than FEISHU_MAX_ITEMS simply arrives over several passes. p2p chats are
|
|
21
|
+
not enumerable (ADR 0024), so only group conversations are read.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
import feishu_client as fc
|
|
31
|
+
|
|
32
|
+
PAGE_SIZE = 50
|
|
33
|
+
THREAD_PAGE_CEILING = 6
|
|
34
|
+
THREAD_KEEP_SECONDS = 7 * 24 * 3600
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def env_int(name, default):
|
|
38
|
+
try:
|
|
39
|
+
return int(str(os.environ.get(name, "")).strip() or default)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def env_flag(name):
|
|
45
|
+
return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn(message):
|
|
49
|
+
sys.stderr.write("[feishu-drain] %s\n" % message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_chats(token):
|
|
53
|
+
"""Every chat the bot can see; the chat list itself excludes p2p (ADR 0024)."""
|
|
54
|
+
chats, page_token = [], ""
|
|
55
|
+
while True:
|
|
56
|
+
data = fc.api("GET", "/im/v1/chats", token=token,
|
|
57
|
+
params={"page_size": 100, "page_token": page_token})
|
|
58
|
+
chats.extend(data.get("items") or [])
|
|
59
|
+
page_token = data.get("page_token") or ""
|
|
60
|
+
if not data.get("has_more") or not page_token:
|
|
61
|
+
return chats
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_messages(token, container_type, container_id, since=None):
|
|
65
|
+
"""Pages one container in ascending create-time order."""
|
|
66
|
+
page_token, pages = "", 0
|
|
67
|
+
while True:
|
|
68
|
+
params = {
|
|
69
|
+
"container_id_type": container_type,
|
|
70
|
+
"container_id": container_id,
|
|
71
|
+
"sort_type": "ByCreateTimeAsc",
|
|
72
|
+
"page_size": PAGE_SIZE,
|
|
73
|
+
"page_token": page_token,
|
|
74
|
+
"start_time": since if since else None,
|
|
75
|
+
}
|
|
76
|
+
data = fc.api("GET", "/im/v1/messages", token=token, params=params)
|
|
77
|
+
for msg in data.get("items") or []:
|
|
78
|
+
yield msg
|
|
79
|
+
page_token = data.get("page_token") or ""
|
|
80
|
+
pages += 1
|
|
81
|
+
if not data.get("has_more") or not page_token:
|
|
82
|
+
return
|
|
83
|
+
if container_type == "thread" and pages >= THREAD_PAGE_CEILING:
|
|
84
|
+
warn("thread %s: stopped after %d pages (ceiling)" % (container_id, pages))
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def created_at(msg):
|
|
89
|
+
try:
|
|
90
|
+
return int(msg.get("create_time") or 0) // 1000
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_bot(msg):
|
|
96
|
+
return ((msg.get("sender") or {}).get("sender_type") or "") == "app"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mentions_bot(msg, open_id):
|
|
100
|
+
if not open_id:
|
|
101
|
+
return True
|
|
102
|
+
for mention in msg.get("mentions") or []:
|
|
103
|
+
if (mention.get("id") or {}).get("open_id") == open_id:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main():
|
|
109
|
+
if "--check" in sys.argv[1:]:
|
|
110
|
+
return self_check()
|
|
111
|
+
fc.load_env()
|
|
112
|
+
try:
|
|
113
|
+
token = fc.tenant_token()
|
|
114
|
+
except fc.FeishuError as err:
|
|
115
|
+
warn("no tenant token: %s" % err)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
cursor_raw = (os.environ.get("AGENTS_POD_CURSOR") or "").strip()
|
|
119
|
+
now = int(time.time())
|
|
120
|
+
overlap = env_int("FEISHU_OVERLAP_SECONDS", 60)
|
|
121
|
+
lookback = env_int("FEISHU_LOOKBACK_SECONDS", 3600)
|
|
122
|
+
max_items = max(1, env_int("FEISHU_MAX_ITEMS", 40))
|
|
123
|
+
allowlist = {c.strip() for c in (os.environ.get("FEISHU_CHATS") or "").split(",") if c.strip()}
|
|
124
|
+
require_mention = env_flag("FEISHU_REQUIRE_MENTION")
|
|
125
|
+
|
|
126
|
+
cursor = int(cursor_raw) if cursor_raw.isdigit() else None
|
|
127
|
+
floor = (cursor if cursor is not None else now - lookback) - overlap
|
|
128
|
+
since = max(0, floor)
|
|
129
|
+
|
|
130
|
+
state = fc.load_state()
|
|
131
|
+
known_chats = set(state["known_chats"])
|
|
132
|
+
threads = state["threads"]
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
open_id = fc.bot_open_id(token) if require_mention else None
|
|
136
|
+
chats = list_chats(token)
|
|
137
|
+
except fc.FeishuError as err:
|
|
138
|
+
warn("chat enumeration failed: %s" % err)
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
chats = [c for c in chats if not allowlist or c.get("chat_id") in allowlist]
|
|
142
|
+
chat_names = {c.get("chat_id"): (c.get("name") or c.get("chat_id")) for c in chats}
|
|
143
|
+
|
|
144
|
+
collected, seen = [], set()
|
|
145
|
+
|
|
146
|
+
def absorb(chat_id, msg):
|
|
147
|
+
msg_id = msg.get("message_id")
|
|
148
|
+
if not msg_id or msg_id in seen or is_bot(msg):
|
|
149
|
+
return
|
|
150
|
+
if require_mention and not mentions_bot(msg, open_id):
|
|
151
|
+
return
|
|
152
|
+
body = fc.flatten_message(msg)
|
|
153
|
+
if not body:
|
|
154
|
+
return
|
|
155
|
+
seen.add(msg_id)
|
|
156
|
+
thread_id = msg.get("thread_id") or ""
|
|
157
|
+
collected.append({
|
|
158
|
+
"id": msg_id,
|
|
159
|
+
"title": "%s: %s" % (chat_names.get(chat_id, chat_id or "chat"),
|
|
160
|
+
body.splitlines()[0][:60]),
|
|
161
|
+
"body": body,
|
|
162
|
+
"chat_id": chat_id,
|
|
163
|
+
"root_id": msg.get("root_id") or msg.get("parent_id") or msg_id,
|
|
164
|
+
"thread_id": thread_id,
|
|
165
|
+
"at": created_at(msg),
|
|
166
|
+
})
|
|
167
|
+
# A thread container is the only place its replies appear (ADR 0024).
|
|
168
|
+
if thread_id and thread_id not in threads:
|
|
169
|
+
threads[thread_id] = {"chat_id": chat_id, "seen_at": created_at(msg) or now}
|
|
170
|
+
|
|
171
|
+
for chat in chats:
|
|
172
|
+
chat_id = chat.get("chat_id")
|
|
173
|
+
if not chat_id:
|
|
174
|
+
continue
|
|
175
|
+
# A chat the bot just joined has no cursor of its own; read a lookback.
|
|
176
|
+
chat_since = since if chat_id in known_chats else max(0, now - lookback)
|
|
177
|
+
try:
|
|
178
|
+
for msg in iter_messages(token, "chat", chat_id, chat_since):
|
|
179
|
+
absorb(chat_id, msg)
|
|
180
|
+
except fc.FeishuError as err:
|
|
181
|
+
warn("chat %s: %s" % (chat_id, err))
|
|
182
|
+
return 1
|
|
183
|
+
known_chats.add(chat_id)
|
|
184
|
+
chat_names.setdefault(chat_id, chat.get("name") or chat_id)
|
|
185
|
+
|
|
186
|
+
for thread_id in list(threads.keys()):
|
|
187
|
+
info = threads[thread_id] or {}
|
|
188
|
+
chat_id = info.get("chat_id") or ""
|
|
189
|
+
if allowlist and chat_id not in allowlist:
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
for msg in iter_messages(token, "thread", thread_id):
|
|
193
|
+
if created_at(msg) < since:
|
|
194
|
+
continue
|
|
195
|
+
absorb(chat_id or msg.get("chat_id") or "", msg)
|
|
196
|
+
except fc.FeishuError as err:
|
|
197
|
+
warn("thread %s: %s" % (thread_id, err))
|
|
198
|
+
continue
|
|
199
|
+
if now - (info.get("seen_at") or now) > THREAD_KEEP_SECONDS:
|
|
200
|
+
del threads[thread_id]
|
|
201
|
+
|
|
202
|
+
state["known_chats"] = sorted(known_chats)
|
|
203
|
+
state["threads"] = threads
|
|
204
|
+
try:
|
|
205
|
+
fc.save_state(state)
|
|
206
|
+
except OSError as err:
|
|
207
|
+
warn("state write failed: %s" % err)
|
|
208
|
+
|
|
209
|
+
collected.sort(key=lambda item: item["at"])
|
|
210
|
+
emitted = collected[:max_items]
|
|
211
|
+
# The cursor stops at the last item printed: the rest must not be skipped.
|
|
212
|
+
next_cursor = max(cursor or 0, emitted[-1]["at"] if emitted else 0)
|
|
213
|
+
|
|
214
|
+
for item in emitted:
|
|
215
|
+
item.pop("at", None)
|
|
216
|
+
sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
217
|
+
|
|
218
|
+
if len(collected) > len(emitted):
|
|
219
|
+
warn("%d more messages wait for the next pass" % (len(collected) - len(emitted)))
|
|
220
|
+
if emitted and next_cursor:
|
|
221
|
+
sys.stdout.write(json.dumps({"cursor": str(next_cursor)}) + "\n")
|
|
222
|
+
sys.stdout.flush()
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def scope_hint(err):
|
|
227
|
+
"""A missing scope names its console link inside the error - or we build one."""
|
|
228
|
+
hint = ["grant it in the app console, then publish a new version (权限管理 → 版本管理与发布)"]
|
|
229
|
+
text = " ".join([err.msg or "", getattr(err, "ext", "") or ""])
|
|
230
|
+
url = None
|
|
231
|
+
for word in re.split(r"[:\s]+", text):
|
|
232
|
+
if word.startswith("https://open.feishu.cn/"):
|
|
233
|
+
url = word
|
|
234
|
+
break
|
|
235
|
+
scopes = re.findall(r"need scope: ([a-z:._]+)", text)
|
|
236
|
+
app_id = (os.environ.get("FEISHU_APP_ID") or "").strip()
|
|
237
|
+
if url is None and scopes and app_id:
|
|
238
|
+
url = ("https://open.feishu.cn/app/%s/auth?q=%s&op_from=openapi&token_type=tenant"
|
|
239
|
+
% (app_id, ",".join(scopes)))
|
|
240
|
+
if url:
|
|
241
|
+
hint.insert(0, url)
|
|
242
|
+
return hint
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def self_check():
|
|
246
|
+
"""`--check`: the operator command's own report (ADR 0027). Exit 0 healthy, 1 not."""
|
|
247
|
+
fc.load_env()
|
|
248
|
+
failures = 0
|
|
249
|
+
try:
|
|
250
|
+
token = fc.tenant_token()
|
|
251
|
+
except fc.FeishuError as err:
|
|
252
|
+
print("no tenant token: %s" % err)
|
|
253
|
+
print(" credentials live in .env beside this file: cp .env.example .env, then fill")
|
|
254
|
+
print(" FEISHU_APP_ID / FEISHU_APP_SECRET from https://open.feishu.cn/app")
|
|
255
|
+
return 1
|
|
256
|
+
print("token ok (tenant_access_token, cached in .token.json for its 2 h)")
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
bot = fc.api("GET", "/bot/v3/info", token=token).get("bot") or {}
|
|
260
|
+
print("bot %s (%s)" % (bot.get("app_name") or "?", bot.get("open_id") or "?"))
|
|
261
|
+
except fc.FeishuError as err:
|
|
262
|
+
print("bot info failed: %s" % err)
|
|
263
|
+
failures += 1
|
|
264
|
+
|
|
265
|
+
allowlist = {c.strip() for c in (os.environ.get("FEISHU_CHATS") or "").split(",") if c.strip()}
|
|
266
|
+
try:
|
|
267
|
+
visible = list_chats(token)
|
|
268
|
+
except fc.FeishuError as err:
|
|
269
|
+
print("chat list failed: %s" % err)
|
|
270
|
+
for line in scope_hint(err):
|
|
271
|
+
print(" " + line)
|
|
272
|
+
return 1
|
|
273
|
+
chats = [c for c in visible if not allowlist or c.get("chat_id") in allowlist]
|
|
274
|
+
if not chats:
|
|
275
|
+
print("no readable chat: this app is in no group chat%s"
|
|
276
|
+
% (" (or FEISHU_CHATS excludes every one)" if allowlist else ""))
|
|
277
|
+
print(" add it: 群设置 → 群机器人 → 添加机器人 %s" % (bot.get("app_name") or ""))
|
|
278
|
+
print(" a p2p chat cannot be enumerated by a pull drain (ADR 0024)")
|
|
279
|
+
return 1
|
|
280
|
+
print("%d chat(s) visible%s" % (len(chats), ", %d allowed by FEISHU_CHATS" % len(allowlist)
|
|
281
|
+
if allowlist else ""))
|
|
282
|
+
|
|
283
|
+
chat = chats[0]
|
|
284
|
+
name = chat.get("name") or chat.get("chat_id")
|
|
285
|
+
try:
|
|
286
|
+
fc.api("GET", "/im/v1/messages", token=token, params={
|
|
287
|
+
"container_id_type": "chat", "container_id": chat.get("chat_id"),
|
|
288
|
+
"sort_type": "ByCreateTimeAsc", "page_size": 1})
|
|
289
|
+
print("read ok in %s" % name)
|
|
290
|
+
except fc.FeishuError as err:
|
|
291
|
+
print("read failed in %s: %s" % (name, err))
|
|
292
|
+
for line in scope_hint(err):
|
|
293
|
+
print(" " + line)
|
|
294
|
+
failures += 1
|
|
295
|
+
state = fc.load_state()
|
|
296
|
+
print("state: %d known chat(s), %d known thread(s)"
|
|
297
|
+
% (len(state["known_chats"]), len(state["threads"])))
|
|
298
|
+
if env_flag("FEISHU_REQUIRE_MENTION") and not fc.bot_open_id(token):
|
|
299
|
+
print("FEISHU_REQUIRE_MENTION is on but the bot's open_id is unknown")
|
|
300
|
+
failures += 1
|
|
301
|
+
return 0 if failures == 0 else 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
+
"examples",
|
|
16
17
|
"bin",
|
|
17
18
|
"src",
|
|
18
19
|
"index.mjs",
|