agents-pod 0.3.2 → 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 CHANGED
@@ -4,6 +4,81 @@ 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
+
7
82
  ## [0.3.2] — 2026-11-04
8
83
 
9
84
  0.3.1 taught `spawn_pod` to name standing work and got one word wrong: 每天. Two
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
- cp examples/feishu/*.py ~/.agents/pods/adapters/ # or wherever
21
- cp examples/feishu/.env.example ~/.agents/pods/adapters/.env && chmod 600 …
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
- | scope | why | measured failure without it |
37
- |---|---|---|
38
- | `im:message.group_msg` | read group history | `GET /im/v1/messages` → `code=230027 lack of necessary permissions` |
39
- | `im:message:readonly` | read messages generally | same class of refusal |
40
- | `im:message:send_as_bot` | post as the bot | send refused |
41
- | `im:chat:readonly` | enumerate the chats the bot is in | chat list comes back empty |
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 not force and cached.get("token") and cached.get("expires_at", 0) > time.time() + 60:
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
- collected, seen = [], set()
271
+ pass_state = new_pass(require_mention=require_mention, open_id=open_id)
272
+ collected = pass_state["collected"]
145
273
 
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):
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 require_mention and not mentions_bot(msg, open_id):
280
+ if kind != "item":
151
281
  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
- })
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": created_at(msg) or now}
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
- absorb(chat_id, msg)
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
- absorb(chat_id or msg.get("chat_id") or "", msg)
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
- # 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)
328
+ next_cursor = advance_cursor(cursor, emitted, collected, pass_state["consumed_at"])
213
329
 
214
- for item in emitted:
215
- item.pop("at", None)
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
 
@@ -0,0 +1,157 @@
1
+ """Offline tests for the drain's intake rules (issue #27, ADR 0024).
2
+
3
+ Nothing here touches the network, the environment or a store: whether one
4
+ message becomes an Inbox item is a pure decision, so the tests feed hand-written
5
+ message dicts and assert on the JSONL a pass would print.
6
+
7
+ Run from this directory: python3 -m unittest test_feishu_drain -v
8
+ """
9
+
10
+ import io
11
+ import json
12
+ import unittest
13
+ from contextlib import redirect_stderr
14
+
15
+ import feishu_drain as drain
16
+
17
+ CHAT_ID = "oc_chat"
18
+ CHAT_NAME = "测试群"
19
+ BASE_AT = 1_700_000_000
20
+
21
+
22
+ def text_message(msg_id, text, at=BASE_AT, sender_type="user", mentions=None):
23
+ message = {
24
+ "message_id": msg_id,
25
+ "msg_type": "text",
26
+ "create_time": str(at * 1000),
27
+ "sender": {
28
+ "id": "ou_human",
29
+ "id_type": "open_id",
30
+ "sender_type": sender_type,
31
+ "tenant_key": "tenant",
32
+ },
33
+ "body": {"content": json.dumps({"text": text}, ensure_ascii=False)},
34
+ }
35
+ if mentions is not None:
36
+ message["mentions"] = mentions
37
+ return message
38
+
39
+
40
+ def system_message(msg_id, at=BASE_AT + 1):
41
+ """The measured shape: a template body and an all-empty sender."""
42
+ return {
43
+ "message_id": msg_id,
44
+ "msg_type": "system",
45
+ "create_time": str(at * 1000),
46
+ "sender": {"id": "", "id_type": "", "sender_type": "", "tenant_key": ""},
47
+ "body": {
48
+ "content": json.dumps(
49
+ {
50
+ "template": "{from_user} invited {to_chatters} to the chat.",
51
+ "from_user": ["徐福洋"],
52
+ "to_chatters": ["邹文", "大肥鱼"],
53
+ },
54
+ ensure_ascii=False,
55
+ )
56
+ },
57
+ }
58
+
59
+
60
+ def run_pass(messages, *, cursor=None, require_mention=False, open_id=None, max_items=40):
61
+ """One pass over a message list, as main() would assemble it."""
62
+ state = drain.new_pass(require_mention=require_mention, open_id=open_id)
63
+ kinds = [drain.absorb(state, msg, CHAT_ID, CHAT_NAME) for msg in messages]
64
+ collected = state["collected"]
65
+ collected.sort(key=lambda item: item["at"])
66
+ emitted = collected[:max_items]
67
+ lines = drain.output_lines(
68
+ emitted,
69
+ drain.advance_cursor(cursor, emitted, collected, state["consumed_at"]),
70
+ )
71
+ return kinds, [json.loads(line) for line in lines]
72
+
73
+
74
+ class SystemEventTest(unittest.TestCase):
75
+ def test_system_only_pass_prints_cursor_and_no_item(self):
76
+ kinds, lines = run_pass([system_message("om_sys", at=BASE_AT)])
77
+
78
+ self.assertEqual(kinds, ["system"])
79
+ self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
80
+
81
+ def test_system_then_human_yields_only_the_human_and_cursor_past_both(self):
82
+ kinds, lines = run_pass(
83
+ [
84
+ system_message("om_sys", at=BASE_AT),
85
+ text_message("om_human", "大家好", at=BASE_AT + 5),
86
+ ]
87
+ )
88
+
89
+ self.assertEqual(kinds, ["system", "item"])
90
+ self.assertEqual(len(lines), 2)
91
+ self.assertEqual(lines[0]["id"], "om_human")
92
+ self.assertEqual(lines[0]["body"], "大家好")
93
+ self.assertNotIn("system", lines[0]["body"])
94
+ self.assertEqual(lines[1], {"cursor": str(BASE_AT + 5)})
95
+
96
+ def test_system_event_is_logged_once_on_stderr(self):
97
+ stderr = io.StringIO()
98
+ with redirect_stderr(stderr):
99
+ drain.note_system_event(system_message("om_sys"), CHAT_ID)
100
+
101
+ self.assertEqual(
102
+ stderr.getvalue().strip(),
103
+ "[feishu-drain] skip system event om_sys in %s" % CHAT_ID,
104
+ )
105
+
106
+
107
+ class ExistingFiltersTest(unittest.TestCase):
108
+ def test_require_mention_drops_unmentioned_human(self):
109
+ mentioned = [
110
+ {
111
+ "key": "@bot",
112
+ "id": {"union_id": "on_bot", "user_id": "u_bot", "open_id": "ou_bot"},
113
+ "name": "pod",
114
+ }
115
+ ]
116
+ kinds, lines = run_pass(
117
+ [
118
+ text_message("om_plain", "没人叫我", at=BASE_AT),
119
+ text_message("om_at", "@pod 在吗", at=BASE_AT + 1, mentions=mentioned),
120
+ ],
121
+ require_mention=True,
122
+ open_id="ou_bot",
123
+ )
124
+
125
+ self.assertEqual(kinds, ["skip", "item"])
126
+ self.assertEqual([line["id"] for line in lines[:-1]], ["om_at"])
127
+ self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
128
+
129
+ def test_app_sender_is_still_dropped(self):
130
+ kinds, lines = run_pass(
131
+ [text_message("om_bot", "机器人发言", at=BASE_AT, sender_type="app")]
132
+ )
133
+
134
+ self.assertEqual(kinds, ["skip"])
135
+ self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
136
+
137
+
138
+ class CursorTest(unittest.TestCase):
139
+ def test_nothing_read_prints_nothing(self):
140
+ _, lines = run_pass([], cursor=BASE_AT)
141
+
142
+ self.assertEqual(lines, [])
143
+
144
+ def test_cursor_does_not_jump_a_queued_item(self):
145
+ messages = [
146
+ text_message("om_1", "一", at=BASE_AT),
147
+ text_message("om_2", "二", at=BASE_AT + 1),
148
+ text_message("om_3", "三", at=BASE_AT + 2),
149
+ ]
150
+ _, lines = run_pass(messages, max_items=2)
151
+
152
+ self.assertEqual([line["id"] for line in lines[:-1]], ["om_1", "om_2"])
153
+ self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
154
+
155
+
156
+ if __name__ == "__main__":
157
+ unittest.main()