agents-pod 0.2.0 → 0.3.1

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
+ ## [0.3.1] — 2026-11-04
8
+
9
+ Two measurements decided this patch: a standing pod with an empty inbox is no
10
+ longer woken, and `spawn_pod` finally says what it is for. The first is the gate
11
+ ADR 0026 deferred until "the ticking hurts" — it hurt, one whole agent run per
12
+ minute on a router with nothing to do (issue #20). The second is a discovery
13
+ failure measured headlessly: neither a router request nor "每天早上 9 点汇总 CI"
14
+ ever reached the tool, because its description promised a pod that ends and a
15
+ guideline made turn 1 a survey (issue #21).
16
+
17
+ ### Changed
18
+
19
+ - **`spawn_pod`'s model-facing text now names standing work** (issue #21).
20
+ Measured headless runs never called the tool — 18 turns and 31 tool calls for
21
+ a router request, 7 turns and 10 for "每天 9 点汇总 CI", zero spawns — because
22
+ the description promised a pod that ends "until its goal is done", said
23
+ nothing about intake, and a guideline made turn 1 a `/pod list` survey. The
24
+ description now says a pod stands while its goal is open, is fed from a
25
+ source or inbox, and routes by writing inbox items; the guidelines map
26
+ 常驻/路由/收消息/每天 onto `spawn_pod` as the first tool call. Both measured
27
+ prompts now reach `spawn_pod` in the first call, 2 turns
28
+ (`scripts/first-turn-check.sh`, which runs against a throwaway store).
29
+
30
+ ### Fixed
31
+
32
+ - **A standing pod bound by a listener sleeps until its `## Inbox` has
33
+ something** (issue #20). `wakeDuePods` woke every idle, goal-open pod on
34
+ every tick, so a pod that exists to serve an inbox burned one whole agent run
35
+ per minute with nothing to do — the shape that got the first router pod
36
+ cancelled. The intake phase now records the batch it owes
37
+ (`policy.intakePending`) and the wake phase admits an intake-driven pod on
38
+ that debt alone; a clean exit pays it, a `failed`/`lost` wake-up leaves it for
39
+ the retry, and a capacity-blocked launch keeps it for the next pass. An
40
+ unbound pod keeps ADR 0011's rule unchanged (ADR 0026 amendment).
41
+
42
+ ## [0.3.0] — 2026-11-04
43
+
44
+ A machine whose IM surface has never been exercised can now say so. Discovery
45
+ was the one thing ADRs 0024/0025 left without a home: credentials and sockets
46
+ moved into operator commands, so core could not check any of it, and every
47
+ failure mode — a drain that runs and yields nothing, a missing deliverer, no
48
+ channels, no cron line — was exit-`0`-silent by design (issue #19).
49
+
50
+ ### Added
51
+
52
+ - **`agents-pod doctor [--probe] [--json]`** — a read-only Health Report over
53
+ the store (ADR 0027): store and registry, the last pass, each Source (command,
54
+ cursor, drain age and yield, last failure), each Listener (Source or inbox,
55
+ template, project, bound Pod), the deliverer with pending and dead Outbox
56
+ entries, and the notification config including a `passwordEnv` that is not
57
+ set. `✓` nothing to do, `✗` blocks a channel, `?` worth knowing; exit `0`
58
+ clean, `1` otherwise, `2` for usage errors.
59
+ - **The first run, printed** — with no Source, no deliverer and no channel,
60
+ `doctor` prints the ordered recipe instead of a report, shrinking to the
61
+ missing steps once anything exists. A bare `agents-pod` now names `doctor`
62
+ instead of answering `Unknown command`.
63
+ - **`<cmd> --check`** — the operator command's own self-check (exit `0`
64
+ healthy, `3` none here, anything else unhealthy), run by `doctor --probe`
65
+ under the ten-second budget and quoted verbatim. Core still holds no
66
+ credentials; the side that owns them does the checking.
67
+ - **`examples/feishu/`** — a worked Source and deliverer pair for Feishu with
68
+ `--check` implemented, a `.env.example`, and the platform facts measured
69
+ while wiring it to a real tenant: the four scopes, `230027`/`99991672`, the
70
+ group that must be created by a human, and the endpoints that are not there.
71
+ - **Two facts that end the silence**: `sources.json` now records
72
+ `lastDrainItems`, `lastError` and `lastFailureAt` (a zero-yield drain is
73
+ visible; a failure is reportable without tailing `cron.log`), and every pass
74
+ writes `last-pass.json`, so "cron is not installed" and "cron runs and finds
75
+ nothing" read differently.
76
+
77
+ ### Changed
78
+
79
+ - A failed drain's record keeps the failure fact; the cursor, drain horizon and
80
+ yield still do not move (ADR 0024, amended in ADR 0027).
81
+
7
82
  ## [0.2.0] — 2026-11-04
8
83
 
9
84
  Work that arrives in a chat can now be picked up there, and answered there,
package/README.md CHANGED
@@ -137,6 +137,7 @@ package until both plugins demonstrably need one.
137
137
  ## CLI
138
138
 
139
139
  ```text
140
+ agents-pod doctor [--probe] [--json] # what is missing, and the command that fixes it
140
141
  agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
141
142
  [--after <podId>] # wait for the upstream pod to reach done
142
143
  agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
@@ -168,8 +169,8 @@ upstream pod's `done`; a cancelled upstream never releases it. When the
168
169
  project's cap is full, the new pod likewise waits idle with its document store
169
170
  ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
170
171
  Exit codes follow the house rule: `0` success
171
- or nothing to do, `1` at least one pod failed, `2` configuration or storage
172
- error.
172
+ or nothing to do, `1` at least one pod failed (or `doctor` found something
173
+ that blocks a channel), `2` configuration or storage error.
173
174
 
174
175
  Every goal prompt carries the wake-up protocol: read the pod documents
175
176
  first, record before exiting.
@@ -222,6 +223,10 @@ policy, per record:
222
223
  pod's default agent — which agent, which model, after how much backoff,
223
224
  is pod-record policy, written by reconcile from outcomes
224
225
  ([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
226
+ - idle + **bound by a listener** + nothing owed → no-op. An intake-driven pod
227
+ is woken on the batch it owes (`policy.intakePending`), never on the tick, so
228
+ a standing router with an empty inbox costs nothing
229
+ ([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
225
230
  - idle + parked (consecutive-failure stop) → no-op until a human `wake`
226
231
  clears it;
227
232
  - live but progressless (no document change for 60 min) → killed via the
@@ -251,7 +256,9 @@ section — below the agent-owned `## Waiting`, and appended only while no
251
256
  wake-up is live, then one batch, one wake on the ordinary wake path; the
252
257
  agent serves entries and flips their checkboxes. A block with a Delivery
253
258
  Address carries ` — to: <Delivery Address>` on its checkbox line, never in
254
- the copied body (ADRs 0014, 0026).
259
+ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
260
+ but **asleep**: the tick alone does not wake a bound pod, which is what makes
261
+ "serve this inbox" a shape that can stand for days (ADR 0026 amendment).
255
262
 
256
263
  Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
257
264
  after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
@@ -262,7 +269,53 @@ registry-lock CAS on every state transition, so two passes — or a human
262
269
  Notifications (P1): two events only — park (with the failure verdict)
263
270
  and done — through the copied channels (webhook/email/system) in
264
271
  `~/.agents/pods/config.json`; once per episode; individual outcomes
265
- never notify (ADR 0021).
272
+ never notify (ADR 0021). A store with no channels at all stays silent by
273
+ design, so `agents-pod doctor` reports it as a `?` line rather than letting
274
+ silence look like health.
275
+
276
+ ## First run
277
+
278
+ `agents-pod doctor` answers one question, read-only: what does this machine
279
+ still lack for a conversation to reach a Pod, and a reply to leave one? With
280
+ nothing configured it prints the ordered first run instead of a report — write
281
+ a Source command, `source add`, `listener add`, `deliver add`, the cron line,
282
+ `doctor` again — and exits `1` until every line is `✓`
283
+ ([ADR 0027](docs/adr/0027-discovery-doctor-and-the-self-check.md)).
284
+
285
+ ```text
286
+ store ✓ 1 pods, 0 active
287
+ pass ✓ last pass 41s ago
288
+ intake ✗ source feishu: the last drain (2s ago) failed — exit 1
289
+ run: agents-pod doctor --probe
290
+ listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
291
+ outbound ✓ deliverer feishu-reply: ~/.agents/pods/adapters/feishu_deliver.py
292
+ notify ? no notification channels: parks and dones stay silent
293
+ ```
294
+
295
+ `✓` nothing to do, `✗` the channel cannot work, `?` worth knowing — a quiet
296
+ chat, no channels, a pass that has not run. Exit `0` when nothing is `✗`, `1`
297
+ otherwise, `2` for store or usage errors; `--json` prints the same report as
298
+ data. Two small records make the silent cases visible: `sources.json` keeps
299
+ the last drain's yield and its last failure, and every pass marks
300
+ `last-pass.json`, so "cron is not installed" and "cron runs and finds nothing"
301
+ stop looking identical.
302
+
303
+ **Credentials belong to the operator command, so only it can check them.** The
304
+ convention is `<cmd> --check`: exit `0` healthy, `3` no self-check here,
305
+ anything else unhealthy. `doctor --probe` runs it once for each Source and
306
+ once for the deliverer and quotes its lines verbatim — which is how a missing
307
+ platform scope, with its console URL, reaches the person who can grant it:
308
+
309
+ ```text
310
+ checks ✗ source feishu: self-check failed (exit 1)
311
+ read failed in 联调: code=230027 msg=Lack of necessary permissions, ext=need scope: im:message.group_msg
312
+ https://open.feishu.cn/app/cli_…/auth?q=im:message.group_msg&op_from=openapi&token_type=tenant
313
+ ```
314
+
315
+ Plain `doctor` never executes operator code. `examples/feishu/` is a worked
316
+ pair that implements `--check` and records the platform facts measured while
317
+ wiring it up: the four scopes, the error codes, and the two traps that cost an
318
+ afternoon.
266
319
 
267
320
  ## IM intake and outbound
268
321
 
@@ -320,8 +373,11 @@ inbox namespace and the item's platform id, shared by every Listener on that
320
373
  Source. Claims are written to `listeners.json` before the Source's cursor is
321
374
  atomically written to `sources.json`, so a crash between those writes replays
322
375
  into id deduplication. A committed drain also advances the record's
323
- `lastDrainAt`; claims from the current and previous successful drain
324
- generations are retained and older timestamped claims are pruned. Claims with
376
+ `lastDrainAt` and records its yield as `lastDrainItems` — zero is a fact, not a
377
+ gap, and it is what makes a pull that is pointed at nothing visible to
378
+ `agents-pod doctor` (ADR 0027). Claims from the current and previous
379
+ successful drain generations are retained and older timestamped claims are
380
+ pruned. Claims with
325
381
  no generation timestamp are retained. For a Source bound to a standing Pod,
326
382
  the claimed batch must also be appended to `TASKS.md` before cursor and
327
383
  `lastDrainAt` commit; a live wake-up holds that append, so the next pass drains
@@ -329,9 +385,18 @@ from the unchanged cursor again.
329
385
 
330
386
  A non-zero exit, termination by a signal, spawn failure, timeout, or total
331
387
  stdout larger than **1 MiB** abandons the whole drain: no items are claimed,
332
- neither cursor nor `lastDrainAt` moves, no claims are pruned, and exactly one
333
- `cron.log` line records the failure. Reconcile still exits `0`; the next pass
334
- retries the whole drain from the same cursor (ADR 0024).
388
+ neither cursor nor `lastDrainAt` nor `lastDrainItems` moves, no claims are
389
+ pruned, and exactly one `cron.log` line records the failure. The record also
390
+ keeps `lastError` and `lastFailureAt`, cleared by the next success, so the
391
+ report can name a Source that fails every pass without tailing the log
392
+ (ADR 0027). Reconcile still exits `0`; the next pass retries the whole drain
393
+ from the same cursor (ADR 0024).
394
+
395
+ A Source command may also answer `<cmd> --check` (exit `0` healthy, `3` no
396
+ self-check here, anything else unhealthy) with its own diagnosis on
397
+ stdout/stderr; `agents-pod doctor --probe` runs it and quotes it (ADR 0027).
398
+ The drain invocation never passes an argument — the check is a second calling
399
+ convention, opted into by the command that implements it.
335
400
 
336
401
  ### Writing the deliverer
337
402
 
@@ -397,6 +462,11 @@ no deliverer configured, the Wrapper flush is a no-op; each reconcile pass
397
462
  writes one `cron.log` line per Pod with waiting entries, and the same one-hour
398
463
  aging still applies (ADR 0025).
399
464
 
465
+ A deliverer may answer `<cmd> --check` on the same convention as a Source
466
+ command (exit `0` healthy, `3` no self-check here, anything else unhealthy);
467
+ `agents-pod doctor --probe` runs it, and `agents-pod doctor` reports any
468
+ entries waiting in Pod outboxes while no deliverer is configured (ADR 0027).
469
+
400
470
  ### Role Instructions for replying agents
401
471
 
402
472
  Save the following as the replying Agent's instructions file and pass it to
@@ -430,7 +500,9 @@ rename, delete, or rewrite pending or dead entries yourself.
430
500
  ├── agents.json # named agents: adapter type, model, role instructions, 0600
431
501
  ├── listeners.json # listeners, bounds, claimed item ids, 0600
432
502
  ├── sources.json # named drain commands, committed cursors, lastDrainAt
503
+ │ # + lastDrainItems and the last failure (ADR 0027)
433
504
  ├── deliverer.json # the single outbound delivery command
505
+ ├── last-pass.json # when a pass last finished, so "never ran" is a fact
434
506
  ├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
435
507
  └── registry.lock # short-lived registry write lock, 0600
436
508
 
@@ -529,3 +601,8 @@ is refused at the surface and at launch. The extension lives in
529
601
  `src/extension/` (one module per plane, plus `handoff.ts` for the document
530
602
  rendering), with `index.mjs` at the package root as Pi's entry so the footer
531
603
  label stays `agents-pod`.
604
+
605
+ `examples/` ships operator commands that core never imports — today the Feishu
606
+ Source/deliverer pair — as copyable starting points. `src/doctor.ts` is the
607
+ read-only Health Report; it is tested through the CLI like everything else,
608
+ with fake operator commands that answer `--check` on each exit code.
@@ -0,0 +1,23 @@
1
+ # A copy of this file is your own: `cp .env.example .env` (0600), then fill in.
2
+ # Nothing here is ever committed; core never reads these — the commands do
3
+ # (ADRs 0024, 0025), and `agents-pod doctor --probe` asks the commands.
4
+
5
+ # Self-built app credentials: open platform → app → 凭证与基础信息.
6
+ FEISHU_APP_ID=
7
+ FEISHU_APP_SECRET=
8
+
9
+ # Comma-separated chat_id allowlist; empty = every group chat the app is in.
10
+ # Read a chat_id from state.json's known_chats after one successful drain.
11
+ FEISHU_CHATS=
12
+
13
+ # 1 = only messages that @-mention the bot (a mixed-use group); 0 = everything.
14
+ FEISHU_REQUIRE_MENTION=0
15
+
16
+ # First-run window when there is no cursor yet, and the re-read overlap behind
17
+ # an existing one, in seconds.
18
+ FEISHU_LOOKBACK_SECONDS=3600
19
+ FEISHU_OVERLAP_SECONDS=60
20
+
21
+ # Per-pass item cap; the cursor stops at the last item printed, so a bigger
22
+ # burst simply arrives over several passes.
23
+ FEISHU_MAX_ITEMS=40
@@ -0,0 +1,92 @@
1
+ # A Feishu command pair for agents-pod
2
+
3
+ Two operator commands that hold every platform concern — app credentials, token
4
+ rotation, chat and thread paging, message flattening, reply addressing — so
5
+ core stays credential- and socket-free (ADRs [0024](../../docs/adr/0024-sources-pull-drain-and-cursor.md),
6
+ [0025](../../docs/adr/0025-outbox-operator-delivery-and-entry-files.md)).
7
+
8
+ | file | role | registered as |
9
+ |---|---|---|
10
+ | `feishu_drain.py` | Source: chats + threads → JSONL items + a cursor line | `agents-pod source add feishu --cmd <path>` |
11
+ | `feishu_deliver.py` | Deliverer: one Outbox entry → one Feishu reply | `agents-pod deliver add feishu-reply --cmd <path>` |
12
+ | `feishu_client.py` | shared client: token cache, paging, message flattening | — |
13
+ | `.env.example` | the switchboard; copy to `.env` (0600) and fill | — |
14
+
15
+ Copy all four next to each other anywhere outside this repository — the pair is
16
+ meant to be *yours*, not core's. `chmod +x feishu_drain.py feishu_deliver.py`.
17
+ `state.json` and `.token.json` appear on first use; both are rebuilt if lost.
18
+
19
+ ```bash
20
+ cp examples/feishu/*.py ~/.agents/pods/adapters/ # or wherever
21
+ cp examples/feishu/.env.example ~/.agents/pods/adapters/.env && chmod 600 …
22
+ agents-pod source add feishu --cmd ~/.agents/pods/adapters/feishu_drain.py --timeout 60
23
+ agents-pod deliver add feishu-reply --cmd ~/.agents/pods/adapters/feishu_deliver.py --timeout 20
24
+ agents-pod doctor --probe # the pair answers for itself; fix what it names
25
+ ```
26
+
27
+ ## The one thing this cannot do for you: the app
28
+
29
+ Feishu's console is a human surface. In order:
30
+
31
+ 1. **Create a self-built app** (自建应用) and take `App ID` / `App Secret` from
32
+ 凭证与基础信息 into `.env`.
33
+ 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
+
43
+ 3. **Add the bot to a group chat** — 群设置 → 群机器人 → 添加机器人. Create that
44
+ group yourself, in the Feishu app: see the trap table below.
45
+ 4. **Say one thing in the group**, then run the smoke test below.
46
+
47
+ ## Smoke test
48
+
49
+ ```bash
50
+ cd ~/.agents/pods/adapters
51
+ env AGENTS_POD_SOURCE_ID=feishu AGENTS_POD_CURSOR= ./feishu_drain.py # JSONL items, then {"cursor":"…"}
52
+ printf '%s' '{"to":"chat=oc_xxx;affinity=om_xxx","text":"通道自检"}' | ./feishu_deliver.py
53
+ ```
54
+
55
+ `exit 0` on both is the wiring. A drain that prints nothing **and** exits `0`
56
+ is not proof of health — it is the shape of "the pull is pointed at nothing",
57
+ which is why `sources.json` records `lastDrainItems` and why
58
+ `agents-pod doctor` prints a `?` line for a zero-yield drain (ADR 0027).
59
+
60
+ Both commands answer `--check` (exit `0` healthy, `1` not) with their own
61
+ diagnosis, including the console URL for a missing scope; `agents-pod doctor
62
+ --probe` runs those and quotes them.
63
+
64
+ ## Measured traps
65
+
66
+ Facts observed while wiring this pair to a real tenant, in the order they bite:
67
+
68
+ | trap | what actually happens |
69
+ |---|---|
70
+ | **Send works while read is denied** | The bot can post into a group and cannot read a word of it, so a half-granted app looks alive. `im:message.group_msg` is the one that blocks intake (`230027`), and the error message carries its own console link. |
71
+ | **An app-created chat is invisible to humans** | `POST /im/v1/chats` succeeds (so does the local test above), but the group is created *by the app*: you do not see it in Feishu, and you cannot join it without another write scope. Create the group in the client and add the bot — that is the only shape a human can also type into. |
72
+ | **A new chat can take a moment to appear** | `GET /im/v1/chats` may return `items: []` for a chat that exists; a retry a few seconds later lists it. Treat one empty page as inconclusive before concluding the bot is in no group. |
73
+ | **p2p is not enumerable** | The chat list excludes p2p conversations, so a pull drain cannot see a direct message (ADR 0024). Group chats are the supported intake shape. |
74
+ | **`/im/v1/chats/:chat_id/link` is 404** | Not every endpoint in older notes exists on the tenant's version; do not build an invite flow on it. |
75
+ | **A version must be published** | Granting scopes without 版本管理与发布 changes nothing at runtime, and the refusal looks identical to "scope not granted". |
76
+ | **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
+ | **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
+
79
+ ## Switchboard (`.env`, or the environment — the environment wins)
80
+
81
+ - `FEISHU_CHATS` — comma-separated `chat_id` allowlist; empty = every chat.
82
+ - `FEISHU_REQUIRE_MENTION` — `1` reads only messages that @-mention the bot.
83
+ - `FEISHU_LOOKBACK_SECONDS` (3600) / `FEISHU_OVERLAP_SECONDS` (60) /
84
+ `FEISHU_MAX_ITEMS` (40) — first-run window, re-read overlap behind the
85
+ cursor, per-pass cap. The cap never loses messages: the cursor stops at the
86
+ last item printed.
87
+
88
+ ## Two independent commands, one platform
89
+
90
+ The pair shares a client file and nothing else. A different platform is a
91
+ second pair of commands and needs nothing from core; a second Feishu app needs
92
+ nothing from this directory but another `.env`.
@@ -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 ""