agents-pod 0.2.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 +40 -0
- package/README.md +79 -8
- 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 +75 -2
- package/src/doctor.ts +551 -0
- package/src/paths.ts +5 -0
- package/src/sources.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ 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.0] — 2026-11-04
|
|
8
|
+
|
|
9
|
+
A machine whose IM surface has never been exercised can now say so. Discovery
|
|
10
|
+
was the one thing ADRs 0024/0025 left without a home: credentials and sockets
|
|
11
|
+
moved into operator commands, so core could not check any of it, and every
|
|
12
|
+
failure mode — a drain that runs and yields nothing, a missing deliverer, no
|
|
13
|
+
channels, no cron line — was exit-`0`-silent by design (issue #19).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`agents-pod doctor [--probe] [--json]`** — a read-only Health Report over
|
|
18
|
+
the store (ADR 0027): store and registry, the last pass, each Source (command,
|
|
19
|
+
cursor, drain age and yield, last failure), each Listener (Source or inbox,
|
|
20
|
+
template, project, bound Pod), the deliverer with pending and dead Outbox
|
|
21
|
+
entries, and the notification config including a `passwordEnv` that is not
|
|
22
|
+
set. `✓` nothing to do, `✗` blocks a channel, `?` worth knowing; exit `0`
|
|
23
|
+
clean, `1` otherwise, `2` for usage errors.
|
|
24
|
+
- **The first run, printed** — with no Source, no deliverer and no channel,
|
|
25
|
+
`doctor` prints the ordered recipe instead of a report, shrinking to the
|
|
26
|
+
missing steps once anything exists. A bare `agents-pod` now names `doctor`
|
|
27
|
+
instead of answering `Unknown command`.
|
|
28
|
+
- **`<cmd> --check`** — the operator command's own self-check (exit `0`
|
|
29
|
+
healthy, `3` none here, anything else unhealthy), run by `doctor --probe`
|
|
30
|
+
under the ten-second budget and quoted verbatim. Core still holds no
|
|
31
|
+
credentials; the side that owns them does the checking.
|
|
32
|
+
- **`examples/feishu/`** — a worked Source and deliverer pair for Feishu with
|
|
33
|
+
`--check` implemented, a `.env.example`, and the platform facts measured
|
|
34
|
+
while wiring it to a real tenant: the four scopes, `230027`/`99991672`, the
|
|
35
|
+
group that must be created by a human, and the endpoints that are not there.
|
|
36
|
+
- **Two facts that end the silence**: `sources.json` now records
|
|
37
|
+
`lastDrainItems`, `lastError` and `lastFailureAt` (a zero-yield drain is
|
|
38
|
+
visible; a failure is reportable without tailing `cron.log`), and every pass
|
|
39
|
+
writes `last-pass.json`, so "cron is not installed" and "cron runs and finds
|
|
40
|
+
nothing" read differently.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- A failed drain's record keeps the failure fact; the cursor, drain horizon and
|
|
45
|
+
yield still do not move (ADR 0024, amended in ADR 0027).
|
|
46
|
+
|
|
7
47
|
## [0.2.0] — 2026-11-04
|
|
8
48
|
|
|
9
49
|
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
|
|
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.
|
|
@@ -262,7 +263,53 @@ registry-lock CAS on every state transition, so two passes — or a human
|
|
|
262
263
|
Notifications (P1): two events only — park (with the failure verdict)
|
|
263
264
|
and done — through the copied channels (webhook/email/system) in
|
|
264
265
|
`~/.agents/pods/config.json`; once per episode; individual outcomes
|
|
265
|
-
never notify (ADR 0021).
|
|
266
|
+
never notify (ADR 0021). A store with no channels at all stays silent by
|
|
267
|
+
design, so `agents-pod doctor` reports it as a `?` line rather than letting
|
|
268
|
+
silence look like health.
|
|
269
|
+
|
|
270
|
+
## First run
|
|
271
|
+
|
|
272
|
+
`agents-pod doctor` answers one question, read-only: what does this machine
|
|
273
|
+
still lack for a conversation to reach a Pod, and a reply to leave one? With
|
|
274
|
+
nothing configured it prints the ordered first run instead of a report — write
|
|
275
|
+
a Source command, `source add`, `listener add`, `deliver add`, the cron line,
|
|
276
|
+
`doctor` again — and exits `1` until every line is `✓`
|
|
277
|
+
([ADR 0027](docs/adr/0027-discovery-doctor-and-the-self-check.md)).
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
store ✓ 1 pods, 0 active
|
|
281
|
+
pass ✓ last pass 41s ago
|
|
282
|
+
intake ✗ source feishu: the last drain (2s ago) failed — exit 1
|
|
283
|
+
run: agents-pod doctor --probe
|
|
284
|
+
listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
|
|
285
|
+
outbound ✓ deliverer feishu-reply: ~/.agents/pods/adapters/feishu_deliver.py
|
|
286
|
+
notify ? no notification channels: parks and dones stay silent
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`✓` nothing to do, `✗` the channel cannot work, `?` worth knowing — a quiet
|
|
290
|
+
chat, no channels, a pass that has not run. Exit `0` when nothing is `✗`, `1`
|
|
291
|
+
otherwise, `2` for store or usage errors; `--json` prints the same report as
|
|
292
|
+
data. Two small records make the silent cases visible: `sources.json` keeps
|
|
293
|
+
the last drain's yield and its last failure, and every pass marks
|
|
294
|
+
`last-pass.json`, so "cron is not installed" and "cron runs and finds nothing"
|
|
295
|
+
stop looking identical.
|
|
296
|
+
|
|
297
|
+
**Credentials belong to the operator command, so only it can check them.** The
|
|
298
|
+
convention is `<cmd> --check`: exit `0` healthy, `3` no self-check here,
|
|
299
|
+
anything else unhealthy. `doctor --probe` runs it once for each Source and
|
|
300
|
+
once for the deliverer and quotes its lines verbatim — which is how a missing
|
|
301
|
+
platform scope, with its console URL, reaches the person who can grant it:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
checks ✗ source feishu: self-check failed (exit 1)
|
|
305
|
+
read failed in 联调: code=230027 msg=Lack of necessary permissions, ext=need scope: im:message.group_msg
|
|
306
|
+
https://open.feishu.cn/app/cli_…/auth?q=im:message.group_msg&op_from=openapi&token_type=tenant
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Plain `doctor` never executes operator code. `examples/feishu/` is a worked
|
|
310
|
+
pair that implements `--check` and records the platform facts measured while
|
|
311
|
+
wiring it up: the four scopes, the error codes, and the two traps that cost an
|
|
312
|
+
afternoon.
|
|
266
313
|
|
|
267
314
|
## IM intake and outbound
|
|
268
315
|
|
|
@@ -320,8 +367,11 @@ inbox namespace and the item's platform id, shared by every Listener on that
|
|
|
320
367
|
Source. Claims are written to `listeners.json` before the Source's cursor is
|
|
321
368
|
atomically written to `sources.json`, so a crash between those writes replays
|
|
322
369
|
into id deduplication. A committed drain also advances the record's
|
|
323
|
-
`lastDrainAt
|
|
324
|
-
|
|
370
|
+
`lastDrainAt` and records its yield as `lastDrainItems` — zero is a fact, not a
|
|
371
|
+
gap, and it is what makes a pull that is pointed at nothing visible to
|
|
372
|
+
`agents-pod doctor` (ADR 0027). Claims from the current and previous
|
|
373
|
+
successful drain generations are retained and older timestamped claims are
|
|
374
|
+
pruned. Claims with
|
|
325
375
|
no generation timestamp are retained. For a Source bound to a standing Pod,
|
|
326
376
|
the claimed batch must also be appended to `TASKS.md` before cursor and
|
|
327
377
|
`lastDrainAt` commit; a live wake-up holds that append, so the next pass drains
|
|
@@ -329,9 +379,18 @@ from the unchanged cursor again.
|
|
|
329
379
|
|
|
330
380
|
A non-zero exit, termination by a signal, spawn failure, timeout, or total
|
|
331
381
|
stdout larger than **1 MiB** abandons the whole drain: no items are claimed,
|
|
332
|
-
neither cursor nor `lastDrainAt` moves, no claims are
|
|
333
|
-
`cron.log` line records the failure.
|
|
334
|
-
|
|
382
|
+
neither cursor nor `lastDrainAt` nor `lastDrainItems` moves, no claims are
|
|
383
|
+
pruned, and exactly one `cron.log` line records the failure. The record also
|
|
384
|
+
keeps `lastError` and `lastFailureAt`, cleared by the next success, so the
|
|
385
|
+
report can name a Source that fails every pass without tailing the log
|
|
386
|
+
(ADR 0027). Reconcile still exits `0`; the next pass retries the whole drain
|
|
387
|
+
from the same cursor (ADR 0024).
|
|
388
|
+
|
|
389
|
+
A Source command may also answer `<cmd> --check` (exit `0` healthy, `3` no
|
|
390
|
+
self-check here, anything else unhealthy) with its own diagnosis on
|
|
391
|
+
stdout/stderr; `agents-pod doctor --probe` runs it and quotes it (ADR 0027).
|
|
392
|
+
The drain invocation never passes an argument — the check is a second calling
|
|
393
|
+
convention, opted into by the command that implements it.
|
|
335
394
|
|
|
336
395
|
### Writing the deliverer
|
|
337
396
|
|
|
@@ -397,6 +456,11 @@ no deliverer configured, the Wrapper flush is a no-op; each reconcile pass
|
|
|
397
456
|
writes one `cron.log` line per Pod with waiting entries, and the same one-hour
|
|
398
457
|
aging still applies (ADR 0025).
|
|
399
458
|
|
|
459
|
+
A deliverer may answer `<cmd> --check` on the same convention as a Source
|
|
460
|
+
command (exit `0` healthy, `3` no self-check here, anything else unhealthy);
|
|
461
|
+
`agents-pod doctor --probe` runs it, and `agents-pod doctor` reports any
|
|
462
|
+
entries waiting in Pod outboxes while no deliverer is configured (ADR 0027).
|
|
463
|
+
|
|
400
464
|
### Role Instructions for replying agents
|
|
401
465
|
|
|
402
466
|
Save the following as the replying Agent's instructions file and pass it to
|
|
@@ -430,7 +494,9 @@ rename, delete, or rewrite pending or dead entries yourself.
|
|
|
430
494
|
├── agents.json # named agents: adapter type, model, role instructions, 0600
|
|
431
495
|
├── listeners.json # listeners, bounds, claimed item ids, 0600
|
|
432
496
|
├── sources.json # named drain commands, committed cursors, lastDrainAt
|
|
497
|
+
│ # + lastDrainItems and the last failure (ADR 0027)
|
|
433
498
|
├── deliverer.json # the single outbound delivery command
|
|
499
|
+
├── last-pass.json # when a pass last finished, so "never ran" is a fact
|
|
434
500
|
├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
|
|
435
501
|
└── registry.lock # short-lived registry write lock, 0600
|
|
436
502
|
|
|
@@ -529,3 +595,8 @@ is refused at the surface and at launch. The extension lives in
|
|
|
529
595
|
`src/extension/` (one module per plane, plus `handoff.ts` for the document
|
|
530
596
|
rendering), with `index.mjs` at the package root as Pi's entry so the footer
|
|
531
597
|
label stays `agents-pod`.
|
|
598
|
+
|
|
599
|
+
`examples/` ships operator commands that core never imports — today the Feishu
|
|
600
|
+
Source/deliverer pair — as copyable starting points. `src/doctor.ts` is the
|
|
601
|
+
read-only Health Report; it is tested through the CLI like everything else,
|
|
602
|
+
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 ""
|
|
@@ -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())
|