agents-pod 0.3.1 → 0.3.4
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 +98 -0
- package/README.md +2 -0
- package/examples/feishu/README.md +81 -11
- package/examples/feishu/feishu_client.py +27 -7
- package/examples/feishu/feishu_drain.py +144 -31
- package/examples/feishu/test_feishu_drain.py +157 -0
- package/package.json +1 -1
- package/src/adapters.ts +303 -0
- package/src/cli.ts +186 -4
- package/src/doctor.ts +75 -1
- package/src/extension/management.ts +3 -2
- package/src/paths.ts +5 -0
- package/src/relocate.ts +395 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,104 @@ All notable changes to agents-pod. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.3.4] — 2026-11-04
|
|
10
|
+
|
|
11
|
+
Two failures shared one shape: the store knew a fact and could not say it. A
|
|
12
|
+
renamed repository left every record pointing at a path that no longer existed
|
|
13
|
+
while `doctor` printed `✓` for the row that mattered, and an adapter copy a
|
|
14
|
+
release behind answered `--check` perfectly because a self-check measures
|
|
15
|
+
behaviour, never provenance. Both are now facts with a command behind them.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`agents-pod project move <old> <new>` relocates records after a rename**
|
|
20
|
+
(issue #25, ADR 0028). A Pod's identity is its id; `project` is a location,
|
|
21
|
+
and the workspace lives inside the tree (`<project>/.agents/pods/<id>`), so a
|
|
22
|
+
`mv` already carried the Document Store along. Dry run by default; `--apply`
|
|
23
|
+
rewrites `pods.json`, template Listeners' `project` and the `projects.json`
|
|
24
|
+
capacity key inside the registry lock, computing every body before writing
|
|
25
|
+
any, with rollback. Three refusals: the target already holds records, a
|
|
26
|
+
Pod's workspace is missing there (pass `--move-artifacts`), or the old path
|
|
27
|
+
still exists. Measured on the store that lost three hours: the dry run named
|
|
28
|
+
two Pods and one Listener, `--apply` wrote 3 records, and the second run said
|
|
29
|
+
`nothing to do`.
|
|
30
|
+
- **`agents-pod adapter sync <name>` copies an adapter from the installed
|
|
31
|
+
package and records its provenance** (issue #26, ADR 0029). Dry run by
|
|
32
|
+
default; `--force` is the only way to replace a local edit, and `.env`,
|
|
33
|
+
`.token.json`, `.bot.json` and `state.json` are never candidates. The new
|
|
34
|
+
`adapters.json` records `{name, dir, packageVersion, files: sha256}`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **A chat `system` event is no longer an Inbox Item** (issue #27). An invite,
|
|
39
|
+
rename or pin reached the Inbox as the placeholder `[system]` — `is_bot()`
|
|
40
|
+
filtered `sender_type == "app"` and a system event's sender is empty — so a
|
|
41
|
+
router Pod spent a whole wake-up judging one. Events now go to stderr, one
|
|
42
|
+
line each; the cursor still crosses them, so a pass that saw only a rename
|
|
43
|
+
prints `{"cursor":…}` and no item. The intake rule moved into pure
|
|
44
|
+
`is_item`/`item_from`/`absorb` functions, tested offline by
|
|
45
|
+
`examples/feishu/test_feishu_drain.py` (7 cases) and from `npm test`.
|
|
46
|
+
- **`doctor` checks every path a record holds** (issues #24, #25). A pod-mode
|
|
47
|
+
Listener whose bound Pod's project is gone was `✓` while every wake-up was
|
|
48
|
+
doomed; the `next:` advice was `listener rm`, which would have thrown away a
|
|
49
|
+
surviving Document Store. Both the bound Pod (`listeners` section) and an
|
|
50
|
+
unbound non-terminal Pod (`store` section) now report the dead path and name
|
|
51
|
+
`agents-pod project move <old> <new>`. Terminal Pods are exempt: they block
|
|
52
|
+
no conversation.
|
|
53
|
+
|
|
54
|
+
### Notes
|
|
55
|
+
|
|
56
|
+
- `doctor` gains an `adapters` section only when `adapters.json` has entries, so
|
|
57
|
+
an existing store's report is byte-identical until a copy is registered.
|
|
58
|
+
- `CONTEXT.md`: the `Goal` entry said the Goal carries "its working directory";
|
|
59
|
+
the Pod Record carries it. New term: **Relocate**, with *resume* and *fork*
|
|
60
|
+
named as the different operations they are.
|
|
61
|
+
|
|
62
|
+
### Fixed (0.3.3, carried — the release was tagged but never published)
|
|
63
|
+
|
|
64
|
+
- **The Feishu client caches per app, so swapping the app is not a silent
|
|
65
|
+
two-hour lie.** `.token.json` and `.bot.json` were keyed by time only: after
|
|
66
|
+
changing `FEISHU_APP_*` in `.env`, the old app's `tenant_access_token` (2 h)
|
|
67
|
+
and the old bot's `open_id` would keep answering, so the new app looked like
|
|
68
|
+
it had missing scopes and `FEISHU_REQUIRE_MENTION` never matched. Both caches
|
|
69
|
+
now record their `app_id` and a mismatch is a miss.
|
|
70
|
+
|
|
71
|
+
### Documentation (0.3.3)
|
|
72
|
+
|
|
73
|
+
- **`examples/feishu/README.md` now maps every scope to the endpoint it admits**
|
|
74
|
+
(`im:chat:readonly` → `/im/v1/chats`, `im:message:readonly` + the required
|
|
75
|
+
addition `im:message.group_msg` → `/im/v1/messages`, `im:message:send_as_bot`
|
|
76
|
+
→ send and reply), with the console names, the "any one of these" variants,
|
|
77
|
+
the three-scope alternative (`im:message` replaces two), and what is not a
|
|
78
|
+
scope but still required: 机器人能力, a published version, the bot in the
|
|
79
|
+
group. A new **Swapping the app** section lists the three artifacts that
|
|
80
|
+
survive a credential change.
|
|
81
|
+
|
|
82
|
+
## [0.3.2] — 2026-11-04
|
|
83
|
+
|
|
84
|
+
0.3.1 taught `spawn_pod` to name standing work and got one word wrong: 每天. Two
|
|
85
|
+
surfaces answer "recurring" requests — this tool and agents-cron's scheduler
|
|
86
|
+
skill — and a skill body outweighs a guideline snippet, so the pod frame
|
|
87
|
+
absorbed the schedule's vocabulary and pods were asked to read the clock. The
|
|
88
|
+
fix is one sentence, stated on the side that decides: a TIME rule is a
|
|
89
|
+
schedule, a MESSAGE or a ROLE that must stay alive is a pod, and a pod never
|
|
90
|
+
carries a clock (issue #23, ADR 0005).
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **A time rule is a schedule, not a pod** (issue #23). 0.3.1 listed 每天 among
|
|
95
|
+
`spawn_pod`'s triggers, so the scheduler skill's vocabulary was absorbed into
|
|
96
|
+
the pod frame: "每天早上 9 点汇总 CI" and "每 30 分钟检查 CI" both spawned a
|
|
97
|
+
pod whose goal carried a clock (one even instructed the pod to "register a
|
|
98
|
+
09:00 cron trigger" — a pod has no clock). The guidelines now split on the
|
|
99
|
+
signal — a TIME rule (每天/每小时/到点/明天提醒我) is `schedule_task` /
|
|
100
|
+
agents-cron; a MESSAGE or a ROLE that must stay alive (常驻/监听/收消息/路由/
|
|
101
|
+
服务某个 inbox/直到我说停) is `spawn_pod` — the description says NO CLOCK, and
|
|
102
|
+
the check now asserts both directions: a standing prompt must spawn first, a
|
|
103
|
+
scheduled one must not spawn at all.
|
|
104
|
+
|
|
7
105
|
## [0.3.1] — 2026-11-04
|
|
8
106
|
|
|
9
107
|
Two measurements decided this patch: a standing pod with an empty inbox is no
|
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ agents-pod doctor [--probe] [--json] # what is missing, and
|
|
|
141
141
|
agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
|
|
142
142
|
[--after <podId>] # wait for the upstream pod to reach done
|
|
143
143
|
agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
|
|
144
|
+
agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
|
|
144
145
|
agents-pod list [--json]
|
|
145
146
|
agents-pod status <podId>
|
|
146
147
|
agents-pod logs <podId> [--follow]
|
|
@@ -161,6 +162,7 @@ agents-pod source rm <name>
|
|
|
161
162
|
agents-pod deliver add <name> --cmd <path> [--timeout <s>]
|
|
162
163
|
agents-pod deliver list [--json]
|
|
163
164
|
agents-pod deliver rm <name>
|
|
165
|
+
agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force] # copy the package's adapter, record its provenance (ADR 0029)
|
|
164
166
|
```
|
|
165
167
|
|
|
166
168
|
`spawn` claims a pod id, writes the pod record, wakes the first agent, and
|
|
@@ -17,13 +17,20 @@ meant to be *yours*, not core's. `chmod +x feishu_drain.py feishu_deliver.py`.
|
|
|
17
17
|
`state.json` and `.token.json` appear on first use; both are rebuilt if lost.
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
|
|
21
|
-
cp
|
|
20
|
+
agents-pod adapter sync feishu --apply # into <store>/adapters/
|
|
21
|
+
cp ~/.agents/pods/adapters/.env.example ~/.agents/pods/adapters/.env && chmod 600 …
|
|
22
22
|
agents-pod source add feishu --cmd ~/.agents/pods/adapters/feishu_drain.py --timeout 60
|
|
23
23
|
agents-pod deliver add feishu-reply --cmd ~/.agents/pods/adapters/feishu_deliver.py --timeout 20
|
|
24
24
|
agents-pod doctor --probe # the pair answers for itself; fix what it names
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`adapter sync` copies from the **installed package** and records the hashes it
|
|
28
|
+
copied in `adapters.json`, which is what lets `doctor` tell you a copy fell a
|
|
29
|
+
release behind (ADR 0029) — a stale copy answers `--check` perfectly, so
|
|
30
|
+
behaviour alone cannot report it. A file you edited yourself is reported as a
|
|
31
|
+
local edit and kept; `--force` is the only way to replace it. Plain `cp` still
|
|
32
|
+
works; it just leaves nothing for `doctor` to compare against.
|
|
33
|
+
|
|
27
34
|
## The one thing this cannot do for you: the app
|
|
28
35
|
|
|
29
36
|
Feishu's console is a human surface. In order:
|
|
@@ -31,19 +38,57 @@ Feishu's console is a human surface. In order:
|
|
|
31
38
|
1. **Create a self-built app** (自建应用) and take `App ID` / `App Secret` from
|
|
32
39
|
凭证与基础信息 into `.env`.
|
|
33
40
|
2. **Grant four tenant permissions**, then **publish a version** (权限变更 only
|
|
34
|
-
takes effect with a new version; sensitive ones need an admin's approval)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
takes effect with a new version; sensitive ones need an admin's approval).
|
|
42
|
+
|
|
43
|
+
Every endpoint this pair calls, and the scope that admits it — the console
|
|
44
|
+
names are the ones the permission picker shows, and the failure column is
|
|
45
|
+
what the tick actually reports:
|
|
46
|
+
|
|
47
|
+
| scope | console name | admits | measured failure without it |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `im:chat:readonly` | 获取群组信息 | `GET /im/v1/chats` — the chats the bot is in | chat list comes back empty |
|
|
50
|
+
| `im:message:readonly` | 获取单聊、群组消息 | the base read grant for `GET /im/v1/messages` | `code=230027` on read |
|
|
51
|
+
| `im:message.group_msg` | 获取群组中所有消息 | group history — the base grant alone is **not** enough for a group | `code=230027` on read, with the base grant already on |
|
|
52
|
+
| `im:message:send_as_bot` | 以应用的身份发消息 | `POST /im/v1/messages` and `POST /im/v1/messages/:id/reply` | send refused |
|
|
53
|
+
|
|
54
|
+
Three of those four are "any one of a set" in the API docs, which matters
|
|
55
|
+
when ticking boxes in the console:
|
|
56
|
+
|
|
57
|
+
- read base: `im:message` · `im:message:readonly` · `im:message.history:readonly`
|
|
58
|
+
- group history: `im:message.group_msg` — **required in addition**, app identity only
|
|
59
|
+
- send: `im:message` · `im:message:send_as_bot` · `im:message:send`
|
|
60
|
+
- chat list: `im:chat` · `im:chat:readonly` · `im:chat:read` · `im:chat.group_info:readonly`
|
|
61
|
+
|
|
62
|
+
So the least-privilege set is those four; `im:message` replaces two of them
|
|
63
|
+
(read base + send) and the set becomes three, at the price of also being
|
|
64
|
+
allowed to send direct messages. `GET /bot/v3/info` (the bot's own id, for
|
|
65
|
+
@-mention detection) needs no scope — only 机器人能力.
|
|
66
|
+
|
|
67
|
+
Not scopes, but the same console and the same publish step: **机器人能力**
|
|
68
|
+
must be on (应用能力 → 机器人), the version must be published, and the bot
|
|
69
|
+
added to the group (`im:chat:readonly` cannot invent a membership).
|
|
43
70
|
3. **Add the bot to a group chat** — 群设置 → 群机器人 → 添加机器人. Create that
|
|
44
71
|
group yourself, in the Feishu app: see the trap table below.
|
|
45
72
|
4. **Say one thing in the group**, then run the smoke test below.
|
|
46
73
|
|
|
74
|
+
## Swapping the app
|
|
75
|
+
|
|
76
|
+
A new app is a new identity. Three things on this machine still belong to the
|
|
77
|
+
old one, and only the first is in `.env`:
|
|
78
|
+
|
|
79
|
+
| what | why it matters | what to do |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `.env` | the credentials | write the new `FEISHU_APP_ID` / `FEISHU_APP_SECRET`, then `chmod 600` |
|
|
82
|
+
| `.token.json` | the old app's `tenant_access_token`, valid 2 h — keyed by time, so it would keep answering as the old app | delete it, or let this version notice: the cache now records its `app_id` and a mismatch is a miss |
|
|
83
|
+
| `.bot.json` | the old app's bot `open_id`, used to spot an @-mention — a stale id makes `FEISHU_REQUIRE_MENTION=1` never match | same: cached per `app_id`, so a swap is a miss |
|
|
84
|
+
|
|
85
|
+
`state.json`'s `known_chats` survives a swap (chat ids are tenant-level, not
|
|
86
|
+
app-level), but a group the **new** bot was never added to will never yield an
|
|
87
|
+
item — re-add it and say one thing there.
|
|
88
|
+
|
|
89
|
+
After the swap: `agents-pod doctor --probe`; `token ok` + `N chat(s) visible` +
|
|
90
|
+
`read ok in <group>` is the whole handshake.
|
|
91
|
+
|
|
47
92
|
## Smoke test
|
|
48
93
|
|
|
49
94
|
```bash
|
|
@@ -76,6 +121,31 @@ Facts observed while wiring this pair to a real tenant, in the order they bite:
|
|
|
76
121
|
| **Chat anonymity hides history** | A group with 保密模式 or hidden history answers intake with `230050`/`231203` and no item ever arrives — a configuration fact, not a bug. |
|
|
77
122
|
| **Tokens and dedup windows are hours long** | `tenant_access_token` lives 2 h (`.token.json` caches it), so a 1-minute pass is cheap; the reply `uuid` window is 1 h, which is exactly what core's dead-lettering assumes (ADR 0025). |
|
|
78
123
|
|
|
124
|
+
## Events go to the log, messages go to the Inbox
|
|
125
|
+
|
|
126
|
+
A chat `system` event — an invite, a rename, a pin, a member join, the bot
|
|
127
|
+
being added — is something the conversation *did*, not something a human said.
|
|
128
|
+
It has no author to answer, so the drain does not turn it into an item. It
|
|
129
|
+
prints one line on stderr instead:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
[feishu-drain] skip system event om_x100…443 in 徐福洋的小组
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The rule is the same for any adapter that forwards platform events: **events →
|
|
136
|
+
the operator's log, messages → the Inbox.** A system event never spends a
|
|
137
|
+
pod wake-up.
|
|
138
|
+
|
|
139
|
+
Skipped events are *consumed*, not deferred: the cursor crosses them exactly as
|
|
140
|
+
it crosses a delivered message. So a pass whose only traffic was a rename
|
|
141
|
+
prints no item and still prints `{"cursor":"…"}` — that is a quiet chat, not
|
|
142
|
+
the "pointed at nothing" shape (zero items **and** no cursor) that `doctor`
|
|
143
|
+
reports (ADR 0027).
|
|
144
|
+
|
|
145
|
+
`msg_type: "system"` is excluded by content, not by identity: the existing
|
|
146
|
+
`sender_type == "app"` filter still drops the bot's own messages, and a human
|
|
147
|
+
message is still subject to `FEISHU_REQUIRE_MENTION`.
|
|
148
|
+
|
|
79
149
|
## Switchboard (`.env`, or the environment — the environment wins)
|
|
80
150
|
|
|
81
151
|
- `FEISHU_CHATS` — comma-separated `chat_id` allowlist; empty = every chat.
|
|
@@ -107,12 +107,26 @@ def _write_json(path, value, mode=0o600):
|
|
|
107
107
|
os.replace(tmp, path)
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def _app_id():
|
|
111
|
+
return os.environ.get("FEISHU_APP_ID", "").strip()
|
|
112
|
+
|
|
113
|
+
|
|
110
114
|
def tenant_token(force=False):
|
|
111
|
-
"""`tenant_access_token` lives 2 h; caching keeps a 1-minute tick cheap.
|
|
115
|
+
"""`tenant_access_token` lives 2 h; caching keeps a 1-minute tick cheap.
|
|
116
|
+
|
|
117
|
+
The cache records the app it belongs to: swapping `FEISHU_APP_*` in `.env`
|
|
118
|
+
must not keep using the previous app's token until it expires, or the new
|
|
119
|
+
app's first failures read as missing scopes on the wrong identity.
|
|
120
|
+
"""
|
|
121
|
+
app_id = _app_id()
|
|
112
122
|
cached = _read_json(TOKEN_FILE)
|
|
113
|
-
if
|
|
123
|
+
if (
|
|
124
|
+
not force
|
|
125
|
+
and cached.get("token")
|
|
126
|
+
and cached.get("app_id") == app_id
|
|
127
|
+
and cached.get("expires_at", 0) > time.time() + 60
|
|
128
|
+
):
|
|
114
129
|
return cached["token"]
|
|
115
|
-
app_id = os.environ.get("FEISHU_APP_ID", "").strip()
|
|
116
130
|
app_secret = os.environ.get("FEISHU_APP_SECRET", "").strip()
|
|
117
131
|
if not app_id or not app_secret:
|
|
118
132
|
raise FeishuError("/auth/v3/tenant_access_token/internal", "no-credentials",
|
|
@@ -124,20 +138,26 @@ def tenant_token(force=False):
|
|
|
124
138
|
answer.get("code"), answer.get("msg"))
|
|
125
139
|
token = answer["tenant_access_token"]
|
|
126
140
|
expires_at = time.time() + int(answer.get("expire", 7200))
|
|
127
|
-
_write_json(TOKEN_FILE, {"token": token, "expires_at": expires_at})
|
|
141
|
+
_write_json(TOKEN_FILE, {"token": token, "expires_at": expires_at, "app_id": app_id})
|
|
128
142
|
return token
|
|
129
143
|
|
|
130
144
|
|
|
131
145
|
def bot_open_id(token):
|
|
132
|
-
"""The bot's own `open_id`, used to spot an @-mention of us.
|
|
146
|
+
"""The bot's own `open_id`, used to spot an @-mention of us.
|
|
147
|
+
|
|
148
|
+
Cached per app: a swapped app is a different bot, and a stale id would make
|
|
149
|
+
an @-mention check answer for an identity that is no longer in the chat.
|
|
150
|
+
"""
|
|
151
|
+
app_id = _app_id()
|
|
133
152
|
cached = _read_json(BOT_FILE)
|
|
134
|
-
if cached.get("open_id"):
|
|
153
|
+
if cached.get("open_id") and cached.get("app_id") == app_id:
|
|
135
154
|
return cached["open_id"]
|
|
136
155
|
data = api("GET", "/bot/v3/info", token=token)
|
|
137
156
|
bot = data.get("bot") or {}
|
|
138
157
|
open_id = bot.get("open_id")
|
|
139
158
|
if open_id:
|
|
140
|
-
_write_json(BOT_FILE, {"open_id": open_id, "app_name": bot.get("app_name")
|
|
159
|
+
_write_json(BOT_FILE, {"open_id": open_id, "app_name": bot.get("app_name"),
|
|
160
|
+
"app_id": app_id})
|
|
141
161
|
return open_id
|
|
142
162
|
|
|
143
163
|
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
One invocation = one reconcile pass. Reads every group chat the bot is in
|
|
5
5
|
(plus the thread containers those chats reveal) from the committed cursor,
|
|
6
|
-
prints one JSONL item per human message, then the new cursor.
|
|
6
|
+
prints one JSONL item per human message, then the new cursor. Chat `system`
|
|
7
|
+
events (invites, renames, pins) are logged to stderr instead: an event is
|
|
8
|
+
something the conversation did, not something a human said (ADR 0024).
|
|
7
9
|
|
|
8
10
|
Environment (all optional, read from `~/.agents/pods/adapters/.env` too):
|
|
9
11
|
FEISHU_APP_ID / FEISHU_APP_SECRET the bot app credentials
|
|
@@ -32,6 +34,7 @@ import feishu_client as fc
|
|
|
32
34
|
PAGE_SIZE = 50
|
|
33
35
|
THREAD_PAGE_CEILING = 6
|
|
34
36
|
THREAD_KEEP_SECONDS = 7 * 24 * 3600
|
|
37
|
+
SYSTEM_MSG_TYPE = "system"
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def env_int(name, default):
|
|
@@ -105,6 +108,130 @@ def mentions_bot(msg, open_id):
|
|
|
105
108
|
return False
|
|
106
109
|
|
|
107
110
|
|
|
111
|
+
def is_system(msg):
|
|
112
|
+
"""A conversation event — invite, rename, pin — rather than a message."""
|
|
113
|
+
return (msg.get("msg_type") or "") == SYSTEM_MSG_TYPE
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_item(msg, *, require_mention, open_id):
|
|
117
|
+
"""Whether one message is Inbox material (ADR 0024).
|
|
118
|
+
|
|
119
|
+
The `system` exclusion is about what a conversation contains, not about
|
|
120
|
+
identity: an event has no author to answer, so it cannot become an item.
|
|
121
|
+
`sender_type == "app"` stays a separate filter — a bot message is still a
|
|
122
|
+
message, just not one the pod needs to answer.
|
|
123
|
+
"""
|
|
124
|
+
if not msg.get("message_id"):
|
|
125
|
+
return False
|
|
126
|
+
if is_bot(msg):
|
|
127
|
+
return False
|
|
128
|
+
if is_system(msg):
|
|
129
|
+
return False
|
|
130
|
+
if require_mention and not mentions_bot(msg, open_id):
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def item_from(msg, chat_id, chat_name):
|
|
136
|
+
"""The Inbox record for one message, or None when it flattens to nothing.
|
|
137
|
+
|
|
138
|
+
Pure, so the item's shape is testable without a tenant (ADR 0024: `id`,
|
|
139
|
+
`body` and the optional routing fields).
|
|
140
|
+
"""
|
|
141
|
+
body = fc.flatten_message(msg)
|
|
142
|
+
if not body:
|
|
143
|
+
return None
|
|
144
|
+
msg_id = msg.get("message_id")
|
|
145
|
+
return {
|
|
146
|
+
"id": msg_id,
|
|
147
|
+
"title": "%s: %s" % (chat_name, body.splitlines()[0][:60]),
|
|
148
|
+
"body": body,
|
|
149
|
+
"chat_id": chat_id,
|
|
150
|
+
"root_id": msg.get("root_id") or msg.get("parent_id") or msg_id,
|
|
151
|
+
"thread_id": msg.get("thread_id") or "",
|
|
152
|
+
"at": created_at(msg),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def new_pass(*, require_mention, open_id):
|
|
157
|
+
"""The per-pass intake state: what this pass saw, kept, and consumed."""
|
|
158
|
+
return {
|
|
159
|
+
"seen": set(),
|
|
160
|
+
"collected": [],
|
|
161
|
+
"consumed_at": 0,
|
|
162
|
+
"require_mention": require_mention,
|
|
163
|
+
"open_id": open_id,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def absorb(pass_state, msg, chat_id, chat_name):
|
|
168
|
+
"""Fold one message into a pass; returns `item`, `system`, `skip` or `seen`.
|
|
169
|
+
|
|
170
|
+
Pure — no network, no environment, no store. A `system` event is consumed
|
|
171
|
+
like any other message (so the cursor crosses it) but yields no item: it
|
|
172
|
+
belongs in the operator's log, not the Inbox.
|
|
173
|
+
"""
|
|
174
|
+
msg_id = msg.get("message_id")
|
|
175
|
+
if not msg_id or msg_id in pass_state["seen"]:
|
|
176
|
+
return "seen"
|
|
177
|
+
if is_system(msg):
|
|
178
|
+
pass_state["seen"].add(msg_id)
|
|
179
|
+
pass_state["consumed_at"] = max(pass_state["consumed_at"], created_at(msg))
|
|
180
|
+
return "system"
|
|
181
|
+
if not is_item(msg, require_mention=pass_state["require_mention"],
|
|
182
|
+
open_id=pass_state["open_id"]):
|
|
183
|
+
pass_state["consumed_at"] = max(pass_state["consumed_at"], created_at(msg))
|
|
184
|
+
return "skip"
|
|
185
|
+
item = item_from(msg, chat_id, chat_name)
|
|
186
|
+
if item is None:
|
|
187
|
+
pass_state["consumed_at"] = max(pass_state["consumed_at"], created_at(msg))
|
|
188
|
+
return "skip"
|
|
189
|
+
pass_state["seen"].add(msg_id)
|
|
190
|
+
pass_state["collected"].append(item)
|
|
191
|
+
pass_state["consumed_at"] = max(pass_state["consumed_at"], item["at"])
|
|
192
|
+
return "item"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def advance_cursor(cursor, emitted, collected, consumed_at):
|
|
196
|
+
"""Where the next pass starts (ADR 0024).
|
|
197
|
+
|
|
198
|
+
The cursor never jumps a message this pass left for later, so while items
|
|
199
|
+
remain queued it stops at the last one printed. With nothing queued it also
|
|
200
|
+
crosses skipped messages, because they are consumed rather than deferred:
|
|
201
|
+
a pass of nothing but system events still advances, and zero items with a
|
|
202
|
+
cursor stays distinguishable from a drain pointed at nothing (ADR 0027).
|
|
203
|
+
`None` means "no cursor line": the pass read and consumed nothing at all.
|
|
204
|
+
"""
|
|
205
|
+
if not emitted and not consumed_at:
|
|
206
|
+
return None # nothing read and nothing skipped: the "pointed at nothing" shape
|
|
207
|
+
next_cursor = max(cursor or 0, emitted[-1]["at"] if emitted else 0)
|
|
208
|
+
if len(collected) <= len(emitted):
|
|
209
|
+
next_cursor = max(next_cursor, consumed_at)
|
|
210
|
+
return next_cursor
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def output_lines(items, cursor):
|
|
214
|
+
"""The drain's stdout: one item per line, then the cursor line (ADR 0024).
|
|
215
|
+
|
|
216
|
+
`at` is internal ordering only. A cursor-only output is the whole answer of
|
|
217
|
+
a pass that consumed events but had nothing to deliver — deliberately not
|
|
218
|
+
the same shape as empty stdout.
|
|
219
|
+
"""
|
|
220
|
+
lines = []
|
|
221
|
+
for item in items:
|
|
222
|
+
record = {key: value for key, value in item.items() if key != "at"}
|
|
223
|
+
lines.append(json.dumps(record, ensure_ascii=False))
|
|
224
|
+
if cursor:
|
|
225
|
+
lines.append(json.dumps({"cursor": str(cursor)}))
|
|
226
|
+
return lines
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def note_system_event(msg, chat_id):
|
|
230
|
+
"""A skipped event is the operator's line, never an Inbox item."""
|
|
231
|
+
warn("skip system event %s in %s"
|
|
232
|
+
% (msg.get("message_id") or "?", chat_id or "?"))
|
|
233
|
+
|
|
234
|
+
|
|
108
235
|
def main():
|
|
109
236
|
if "--check" in sys.argv[1:]:
|
|
110
237
|
return self_check()
|
|
@@ -141,32 +268,22 @@ def main():
|
|
|
141
268
|
chats = [c for c in chats if not allowlist or c.get("chat_id") in allowlist]
|
|
142
269
|
chat_names = {c.get("chat_id"): (c.get("name") or c.get("chat_id")) for c in chats}
|
|
143
270
|
|
|
144
|
-
|
|
271
|
+
pass_state = new_pass(require_mention=require_mention, open_id=open_id)
|
|
272
|
+
collected = pass_state["collected"]
|
|
145
273
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
274
|
+
def handle(chat_id, msg):
|
|
275
|
+
kind = absorb(pass_state, msg, chat_id,
|
|
276
|
+
chat_names.get(chat_id, chat_id or "chat"))
|
|
277
|
+
if kind == "system":
|
|
278
|
+
note_system_event(msg, chat_id)
|
|
149
279
|
return
|
|
150
|
-
if
|
|
280
|
+
if kind != "item":
|
|
151
281
|
return
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
})
|
|
282
|
+
item = collected[-1]
|
|
283
|
+
thread_id = item["thread_id"]
|
|
167
284
|
# A thread container is the only place its replies appear (ADR 0024).
|
|
168
285
|
if thread_id and thread_id not in threads:
|
|
169
|
-
threads[thread_id] = {"chat_id": chat_id, "seen_at":
|
|
286
|
+
threads[thread_id] = {"chat_id": chat_id, "seen_at": item["at"] or now}
|
|
170
287
|
|
|
171
288
|
for chat in chats:
|
|
172
289
|
chat_id = chat.get("chat_id")
|
|
@@ -176,7 +293,7 @@ def main():
|
|
|
176
293
|
chat_since = since if chat_id in known_chats else max(0, now - lookback)
|
|
177
294
|
try:
|
|
178
295
|
for msg in iter_messages(token, "chat", chat_id, chat_since):
|
|
179
|
-
|
|
296
|
+
handle(chat_id, msg)
|
|
180
297
|
except fc.FeishuError as err:
|
|
181
298
|
warn("chat %s: %s" % (chat_id, err))
|
|
182
299
|
return 1
|
|
@@ -192,7 +309,7 @@ def main():
|
|
|
192
309
|
for msg in iter_messages(token, "thread", thread_id):
|
|
193
310
|
if created_at(msg) < since:
|
|
194
311
|
continue
|
|
195
|
-
|
|
312
|
+
handle(chat_id or msg.get("chat_id") or "", msg)
|
|
196
313
|
except fc.FeishuError as err:
|
|
197
314
|
warn("thread %s: %s" % (thread_id, err))
|
|
198
315
|
continue
|
|
@@ -208,17 +325,13 @@ def main():
|
|
|
208
325
|
|
|
209
326
|
collected.sort(key=lambda item: item["at"])
|
|
210
327
|
emitted = collected[:max_items]
|
|
211
|
-
|
|
212
|
-
next_cursor = max(cursor or 0, emitted[-1]["at"] if emitted else 0)
|
|
328
|
+
next_cursor = advance_cursor(cursor, emitted, collected, pass_state["consumed_at"])
|
|
213
329
|
|
|
214
|
-
for
|
|
215
|
-
|
|
216
|
-
sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
330
|
+
for line in output_lines(emitted, next_cursor):
|
|
331
|
+
sys.stdout.write(line + "\n")
|
|
217
332
|
|
|
218
333
|
if len(collected) > len(emitted):
|
|
219
334
|
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
335
|
sys.stdout.flush()
|
|
223
336
|
return 0
|
|
224
337
|
|