@fastagent-sh/fastagent 0.12.1 → 0.14.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.
Files changed (151) hide show
  1. package/README.md +6 -4
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  9. package/dist/channels/feishu/context-buffer.js +133 -0
  10. package/dist/channels/feishu/crypto.d.ts +15 -0
  11. package/dist/channels/feishu/crypto.js +43 -0
  12. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  13. package/dist/channels/feishu/feishu-api.js +325 -0
  14. package/dist/channels/feishu/feishu.d.ts +46 -0
  15. package/dist/channels/feishu/feishu.js +472 -0
  16. package/dist/channels/feishu/invoke-turn.d.ts +65 -0
  17. package/dist/channels/feishu/invoke-turn.js +157 -0
  18. package/dist/channels/feishu/model.d.ts +97 -0
  19. package/dist/channels/feishu/model.js +9 -0
  20. package/dist/channels/feishu/normalize.d.ts +22 -0
  21. package/dist/channels/feishu/normalize.js +132 -0
  22. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  23. package/dist/channels/feishu/owned-threads.js +47 -0
  24. package/dist/channels/feishu/parse.d.ts +43 -0
  25. package/dist/channels/feishu/parse.js +65 -0
  26. package/dist/channels/feishu/preview.d.ts +36 -0
  27. package/dist/channels/feishu/preview.js +387 -0
  28. package/dist/channels/feishu/register-app.d.ts +70 -0
  29. package/dist/channels/feishu/register-app.js +141 -0
  30. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  31. package/dist/channels/feishu/register-webhook.js +106 -0
  32. package/dist/channels/feishu/scaffold/channel.ts +41 -0
  33. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  34. package/dist/channels/feishu/seen.d.ts +5 -0
  35. package/dist/channels/feishu/seen.js +47 -0
  36. package/dist/channels/feishu/text.d.ts +13 -0
  37. package/dist/channels/feishu/text.js +63 -0
  38. package/dist/channels/lark/lark.d.ts +15 -0
  39. package/dist/channels/lark/lark.js +10 -0
  40. package/dist/channels/lark/onboard.d.ts +39 -0
  41. package/dist/channels/lark/onboard.js +58 -0
  42. package/dist/channels/lark/scaffold/channel.ts +39 -0
  43. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  44. package/dist/channels/registration.d.ts +15 -0
  45. package/dist/channels/registration.js +1 -0
  46. package/dist/channels/{telegram/state.js → state.js} +6 -4
  47. package/dist/channels/telegram/context-buffer.js +1 -1
  48. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  49. package/dist/channels/telegram/register-webhook.js +17 -26
  50. package/dist/channels/telegram/telegram.js +2 -2
  51. package/dist/channels/telegram/turn-store.d.ts +8 -21
  52. package/dist/channels/telegram/turn-store.js +11 -130
  53. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  54. package/dist/channels/turn-store.d.ts +42 -0
  55. package/dist/channels/turn-store.js +139 -0
  56. package/dist/channels/wait-health.d.ts +6 -0
  57. package/dist/channels/wait-health.js +27 -0
  58. package/dist/cli/commands/add.d.ts +9 -0
  59. package/dist/cli/commands/add.js +142 -0
  60. package/dist/cli/commands/chat.d.ts +3 -0
  61. package/dist/cli/commands/chat.js +16 -0
  62. package/dist/cli/commands/deploy.d.ts +13 -0
  63. package/dist/cli/commands/deploy.js +338 -0
  64. package/dist/cli/commands/dev.d.ts +11 -0
  65. package/dist/cli/commands/dev.js +76 -0
  66. package/dist/cli/commands/fire.d.ts +7 -0
  67. package/dist/cli/commands/fire.js +45 -0
  68. package/dist/cli/commands/info.d.ts +7 -0
  69. package/dist/cli/commands/info.js +108 -0
  70. package/dist/cli/commands/init.d.ts +8 -0
  71. package/dist/cli/commands/init.js +81 -0
  72. package/dist/cli/commands/invoke.d.ts +7 -0
  73. package/dist/cli/commands/invoke.js +29 -0
  74. package/dist/cli/commands/login.d.ts +6 -0
  75. package/dist/cli/commands/login.js +63 -0
  76. package/dist/cli/commands/models.d.ts +1 -0
  77. package/dist/cli/commands/models.js +15 -0
  78. package/dist/cli/commands/schedule.d.ts +12 -0
  79. package/dist/cli/commands/schedule.js +89 -0
  80. package/dist/cli/commands/start.d.ts +10 -0
  81. package/dist/cli/commands/start.js +90 -0
  82. package/dist/cli/commands/tool.d.ts +1 -0
  83. package/dist/cli/commands/tool.js +37 -0
  84. package/dist/cli/fail.d.ts +19 -0
  85. package/dist/cli/fail.js +32 -0
  86. package/dist/cli/kernel.d.ts +89 -0
  87. package/dist/cli/kernel.js +190 -0
  88. package/dist/cli/program.d.ts +11 -0
  89. package/dist/cli/program.js +421 -0
  90. package/dist/cli/serve.d.ts +28 -0
  91. package/dist/cli/serve.js +90 -0
  92. package/dist/cli/shared.d.ts +24 -0
  93. package/dist/cli/shared.js +116 -0
  94. package/dist/cli-add-feishu.d.ts +8 -0
  95. package/dist/cli-add-feishu.js +223 -0
  96. package/dist/cli.js +8 -1284
  97. package/dist/deploy/container.js +10 -6
  98. package/dist/deploy/docker/plan.d.ts +45 -0
  99. package/dist/deploy/docker/plan.js +139 -0
  100. package/dist/deploy/docker/run.d.ts +40 -0
  101. package/dist/deploy/docker/run.js +126 -0
  102. package/dist/deploy/fly/plan.d.ts +1 -1
  103. package/dist/deploy/fly/plan.js +15 -4
  104. package/dist/deploy/fly/run.d.ts +7 -4
  105. package/dist/deploy/fly/run.js +26 -5
  106. package/dist/deploy/preflight.js +4 -3
  107. package/dist/deploy/railway/plan.d.ts +1 -1
  108. package/dist/deploy/railway/plan.js +17 -5
  109. package/dist/deploy/railway/run.d.ts +6 -3
  110. package/dist/deploy/railway/run.js +26 -4
  111. package/dist/deploy/registration-gate.d.ts +20 -0
  112. package/dist/deploy/registration-gate.js +20 -0
  113. package/dist/deploy/runner.d.ts +4 -1
  114. package/dist/deploy/runner.js +1 -0
  115. package/dist/deploy/secrets.d.ts +10 -9
  116. package/dist/deploy/secrets.js +15 -14
  117. package/dist/dev-supervisor.js +2 -1
  118. package/dist/engines/pi/auth.js +160 -46
  119. package/dist/engines/pi/chat.js +80 -7
  120. package/dist/engines/pi/config.d.ts +12 -3
  121. package/dist/engines/pi/config.js +16 -1
  122. package/dist/engines/pi/create.d.ts +14 -6
  123. package/dist/engines/pi/create.js +52 -16
  124. package/dist/engines/pi/harness.d.ts +16 -1
  125. package/dist/engines/pi/harness.js +77 -1
  126. package/dist/engines/pi/invoke.d.ts +1 -1
  127. package/dist/engines/pi/invoke.js +37 -2
  128. package/dist/engines/pi/login.js +1 -1
  129. package/dist/engines/pi/search-tools.d.ts +10 -0
  130. package/dist/engines/pi/search-tools.js +138 -0
  131. package/dist/engines/pi/tool-context.d.ts +28 -0
  132. package/dist/engines/pi/tool-context.js +8 -0
  133. package/dist/engines/pi/tool.d.ts +32 -1
  134. package/dist/engines/pi/tool.js +42 -1
  135. package/dist/engines/pi/workspace.d.ts +4 -1
  136. package/dist/engines/pi/workspace.js +3 -1
  137. package/dist/feishu.d.ts +2 -0
  138. package/dist/feishu.js +2 -0
  139. package/dist/lark.d.ts +3 -0
  140. package/dist/lark.js +3 -0
  141. package/dist/open-url.d.ts +2 -0
  142. package/dist/open-url.js +6 -0
  143. package/dist/pi.d.ts +2 -1
  144. package/dist/scaffold/add-channel.d.ts +9 -5
  145. package/dist/scaffold/add-channel.js +75 -7
  146. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  147. package/dist/tunnel.d.ts +9 -6
  148. package/dist/tunnel.js +48 -31
  149. package/package.json +18 -4
  150. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  151. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Canonical Feishu bot-channel engine: verify webhook → answer url_verification → dedup → route → persist → run
3
+ * the turn → stream a live card → ACK 200. Feishu (open.feishu.cn) is the reference cloud. Lark
4
+ * international binds this engine through an explicit compatibility profile because its control plane
5
+ * trails Feishu; protocol reuse does not make Lark the design center.
6
+ *
7
+ * The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
8
+ * workspace may mount both without sharing state. Webhook mode only; WebSocket long connection needs
9
+ * the official SDK and a non-HTTP ingress seam. See docs/feishu.md.
10
+ */
11
+ import { isAbsolute, join } from "node:path";
12
+ import { log } from "../../log.js";
13
+ import { readBodyCapped } from "../body.js";
14
+ import { text } from "../respond.js";
15
+ import { ensureStateHome } from "../state.js";
16
+ import { createTurnQueue } from "../turn-queue.js";
17
+ import { createTurnStore } from "../turn-store.js";
18
+ import { FEISHU_CLOUD } from "./cloud.js";
19
+ import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
20
+ import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
21
+ import { invokeFeishuTurn } from "./invoke-turn.js";
22
+ import { createFeishuApi } from "./feishu-api.js";
23
+ import { normalizeFeishuMessage } from "./normalize.js";
24
+ import { createOwnedFeishuThreads } from "./owned-threads.js";
25
+ import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
26
+ import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
27
+ import { createSeenRing } from "./seen.js";
28
+ // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
29
+ export { defaultFeishuRoute, feishuEnvelope };
30
+ /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
31
+ * rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
32
+ * at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
33
+ const MAX_TURN_ATTEMPTS = 3;
34
+ /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
35
+ const MAX_EVENT_BYTES = 1 << 20;
36
+ /** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
37
+ * accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
38
+ * is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
39
+ const QUEUE_NOTICE_DELAY_MS = 0;
40
+ const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
41
+ const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
42
+ /** State files are an IO boundary: valid JSON of the WRONG SHAPE must degrade like a corrupt file. */
43
+ function isStoredFeishuTurn(t) {
44
+ const r = t;
45
+ const refs = (v) => Array.isArray(v) &&
46
+ v.every((x) => typeof x.msg === "string" && typeof x.key === "string");
47
+ return (typeof r?.id === "string" &&
48
+ typeof r.seq === "number" &&
49
+ typeof r.session === "string" &&
50
+ typeof r.baseText === "string" &&
51
+ (r.bufferKey === undefined || typeof r.bufferKey === "string") &&
52
+ typeof r.chatId === "string" &&
53
+ (r.replyTo === undefined || typeof r.replyTo === "string") &&
54
+ (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
55
+ (r.replyInThread === undefined || typeof r.replyInThread === "boolean") &&
56
+ (r.parentId === undefined || typeof r.parentId === "string") &&
57
+ refs(r.images) &&
58
+ refs(r.files) &&
59
+ typeof r.attempts === "number");
60
+ }
61
+ /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
62
+ export function feishuChannel(opts) {
63
+ return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
64
+ }
65
+ /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
66
+ export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
67
+ const { kind, envPrefix } = profile;
68
+ const label = `[${kind}]`;
69
+ // All three are mandatory: without the app credentials no reply can be sent; without the verification
70
+ // token a plaintext-mode endpoint would accept forged events. Fail at construction (startup), not
71
+ // silently at the first event.
72
+ if (!appId || !appSecret) {
73
+ throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
74
+ }
75
+ if (!verificationToken) {
76
+ throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks; an unset one accepts forged events)`);
77
+ }
78
+ if (directMessageSession !== "continuous" && directMessageSession !== "threaded") {
79
+ throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
80
+ }
81
+ if (groupMessageSession !== "continuous" && groupMessageSession !== "threaded") {
82
+ throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
83
+ }
84
+ return ({ agent, stateRoot }) => {
85
+ const formatError = onError ?? defaultErrorMessage;
86
+ const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
87
+ // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
88
+ // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works.
89
+ let botOpenId;
90
+ void api.botInfo().then((me) => {
91
+ botOpenId = me.openId;
92
+ if (!botOpenId)
93
+ log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
94
+ }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
95
+ const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
96
+ // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
97
+ // (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
98
+ // the operator's ONE state knob (FASTAGENT_STATE_DIR) can never be silently bypassed by glue.
99
+ if (!isAbsolute(stateRoot)) {
100
+ throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
101
+ }
102
+ const stateHome = join(stateRoot, "channels", kind);
103
+ ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
104
+ const ownedThreads = createOwnedFeishuThreads(join(stateHome, "owned-threads.json"), label);
105
+ const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
106
+ const store = createTurnStore(join(stateHome, "turns.json"), {
107
+ label,
108
+ isRecord: isStoredFeishuTurn,
109
+ order: (a, b) => a.seq - b.seq,
110
+ });
111
+ const seen = createSeenRing(join(stateHome, "seen.json"), label);
112
+ const toStored = (r) => {
113
+ const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
114
+ return { ...intent, attempts: 0 };
115
+ };
116
+ const targetOf = (r) => ({
117
+ chatId: r.chatId,
118
+ replyTo: r.replyTo,
119
+ replyInThread: r.replyInThread,
120
+ });
121
+ const queueTargetOf = (r) => ({
122
+ chatId: r.chatId,
123
+ replyTo: r.queueReplyTo,
124
+ replyInThread: r.replyInThread,
125
+ });
126
+ // In-memory: the pending queue-preview mount per turn. Immediate by default; with an explicit delay,
127
+ // it mounts only if the turn is still waiting when the timer fires and is cancelled unsent otherwise.
128
+ // `done` settles either way, awaited at dequeue so the runner reliably receives the mounted preview
129
+ // instead of racing it and double-posting.
130
+ const notices = new Map();
131
+ const queue = createTurnQueue({
132
+ label,
133
+ // Queue feedback: when this session already has a turn running/queued, a silent wait reads as
134
+ // "the bot ignored me" once the current turn runs long — mount that turn's preview early with a
135
+ // queue status. It reply-quotes the exact source message (including p2p), then the runner mutates
136
+ // the SAME card/text into Thinking → final answer. Best-effort and post-ACK: a failed mount is a
137
+ // log line, never a failed event delivery; the turn later mounts its normal preview.
138
+ onQueuedBehind: (rec) => {
139
+ let fired = false;
140
+ let settle = () => { };
141
+ const done = new Promise((resolve) => {
142
+ settle = resolve;
143
+ });
144
+ const mount = () => {
145
+ fired = true;
146
+ mountFeishuPreview(api, queueTargetOf(rec), QUEUED_PLACEHOLDER, label)
147
+ .then((preview) => {
148
+ rec.preview = preview;
149
+ }, (e) => log.warn(`${label} queue preview failed (the turn still runs): ${String(e)}`))
150
+ .finally(settle);
151
+ };
152
+ const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
153
+ if (timer === undefined)
154
+ mount();
155
+ notices.set(rec.id, {
156
+ // Cancel is a no-op once mounting started — the send is in flight and `done` settles with it.
157
+ cancel: () => {
158
+ if (!fired) {
159
+ if (timer !== undefined)
160
+ clearTimeout(timer);
161
+ settle();
162
+ }
163
+ },
164
+ done,
165
+ });
166
+ },
167
+ run: async (rec) => {
168
+ // Runs at DEQUEUE time (serialized). The turn's queue wait is over: cancel a not-yet-mounted
169
+ // preview (fast turnover skips the Queued frame), then settle so rec.preview is final — in the
170
+ // common path this await is instant. BEFORE the ceiling check so drop/defer can take it over too.
171
+ const notice = notices.get(rec.id);
172
+ notice?.cancel();
173
+ await notice?.done;
174
+ notices.delete(rec.id);
175
+ // Count this execution against the durable record (poison-turn ceiling) before running it again.
176
+ const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
177
+ if (decision === "exceeded") {
178
+ notifyDropped(rec);
179
+ return;
180
+ }
181
+ if (decision === "defer") {
182
+ // Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
183
+ // recall an existing queue preview (the client exposes a confusing tombstone): settle it in
184
+ // place to an honest delayed status. The eventual replay mounts a fresh preview.
185
+ if (rec.preview !== undefined) {
186
+ void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
187
+ }
188
+ return;
189
+ }
190
+ const startedAt = Date.now();
191
+ log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
192
+ // Snapshot background discussion at dequeue. Commit only this snapshot on `completed`, so a
193
+ // message arriving while the turn runs remains buffered for the next answered turn.
194
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
195
+ // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
196
+ // would instead couple otherwise-independent root sessions and require failure rollback.
197
+ const { text: recent, consumed } = buffer.peek(rec.bufferKey);
198
+ const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
199
+ const buffered = collectFeishuBufferedAttachments(consumed, {
200
+ images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
201
+ files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
202
+ });
203
+ try {
204
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
205
+ // Drop intent first: a crash between these writes may re-fold answered context later,
206
+ // but can never replay this turn after its context was removed.
207
+ store.remove(rec.id);
208
+ buffer.commit(rec.bufferKey, consumed);
209
+ }), api, targetOf(rec), formatError, rec.preview, label);
210
+ log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
211
+ }
212
+ catch (error) {
213
+ log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
214
+ }
215
+ finally {
216
+ // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
217
+ // never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
218
+ // INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
219
+ // replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
220
+ store.remove(rec.id);
221
+ }
222
+ },
223
+ });
224
+ // Accept a turn: persist its intent before the ACK, then record the platform delivery id and enqueue
225
+ // it. The ordering is deliberate: recording first could turn a failed intent write into silent loss
226
+ // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
227
+ const submit = (rec, persist) => {
228
+ if (persist) {
229
+ store.add(toStored(rec)); // failed write → webhook 500 → platform redelivery
230
+ seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
231
+ }
232
+ queue.accept(rec);
233
+ };
234
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
235
+ // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
236
+ // leaving it pinned at "Queued" while sending a separate failure would double-post.
237
+ const notifyDropped = (r) => {
238
+ const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
239
+ void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
240
+ };
241
+ // Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
242
+ // the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
243
+ const recovered = store.recover();
244
+ if (recovered.length > 0)
245
+ log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
246
+ let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
247
+ for (const { attempts: _a, ...intent } of recovered) {
248
+ // A pre-buffer-version record has no trustworthy place identity. Give it an empty private bucket
249
+ // rather than risk consuming new main-chat context that arrived after this restart.
250
+ const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
251
+ submit({ ...intent, bufferKey, preview: undefined }, false);
252
+ }
253
+ const handler = async (req) => {
254
+ if (req.method !== "POST")
255
+ return text("POST only\n", 405);
256
+ const body = await readBodyCapped(req, MAX_EVENT_BYTES);
257
+ if ("tooLarge" in body)
258
+ return text("payload too large\n", 413);
259
+ let outer;
260
+ try {
261
+ outer = JSON.parse(body.text);
262
+ if (typeof outer !== "object" || outer === null)
263
+ throw new Error("not an object");
264
+ }
265
+ catch {
266
+ return text("invalid json\n", 400);
267
+ }
268
+ // ── Verification. Two modes, decided by the CONSOLE's Encrypt Key setting, mirrored here. ──────
269
+ let envelope;
270
+ if (typeof outer.encrypt === "string") {
271
+ if (!encryptKey) {
272
+ log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
273
+ return text("encrypt key not configured\n", 400);
274
+ }
275
+ const sig = {
276
+ timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
277
+ nonce: req.headers.get("x-lark-request-nonce") ?? "",
278
+ signature: req.headers.get("x-lark-signature") ?? "",
279
+ };
280
+ // Ordinary encrypted events MUST verify the signature over the raw body before decryption.
281
+ // Feishu's documented exception is Request URL verification: its encrypted challenge carries
282
+ // no event-signature headers, so it is decrypted first and admitted ONLY when its type is
283
+ // url_verification; the common constant-time Token check below then authenticates it.
284
+ if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
285
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
286
+ return text("invalid signature\n", 401);
287
+ }
288
+ try {
289
+ envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
290
+ }
291
+ catch {
292
+ if (!sig.signature) {
293
+ log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
294
+ return text("invalid encrypted payload\n", 401);
295
+ }
296
+ return text("invalid encrypted payload\n", 400);
297
+ }
298
+ if (!sig.signature && envelope.type !== "url_verification") {
299
+ log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
300
+ return text("invalid signature\n", 401);
301
+ }
302
+ }
303
+ else {
304
+ if (encryptKey) {
305
+ // With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
306
+ // mismatch — surfaced in the log): accepting it would let a sender skip the signature.
307
+ log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
308
+ return text("plaintext events not accepted\n", 401);
309
+ }
310
+ envelope = outer;
311
+ }
312
+ // The Verification Token authenticates plaintext mode and the platform-documented unsigned,
313
+ // encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
314
+ // in header.token, while url_verification carries it at the top level. Fail closed when absent.
315
+ const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
316
+ (typeof envelope.header?.token === "string"
317
+ ? envelope.header.token
318
+ : undefined);
319
+ if (!token || !timingSafeEqualStr(token, verificationToken)) {
320
+ // Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
321
+ // the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
322
+ log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
323
+ return text("invalid token\n", 401);
324
+ }
325
+ // ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
326
+ if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
327
+ // The console fires this when the operator saves the Request URL; without this line a PASSING
328
+ // handshake is invisible and "did the challenge even arrive?" becomes guesswork.
329
+ log.info(`${label} answered the console's url_verification challenge`);
330
+ return Response.json({ challenge: envelope.challenge });
331
+ }
332
+ // ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
333
+ // (a non-2xx would just make the platform retry an event this channel will never act on). ──────
334
+ const header = envelope.header;
335
+ if (header?.event_type !== "im.message.receive_v1") {
336
+ log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
337
+ return new Response(null, { status: 200 });
338
+ }
339
+ const event = (envelope.event ?? {});
340
+ const m = event.message;
341
+ if (!m?.message_id || !m.chat_id)
342
+ return new Response(null, { status: 200 });
343
+ if (seen.has(m.message_id)) {
344
+ log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
345
+ return new Response(null, { status: 200 });
346
+ }
347
+ let r = decide(event);
348
+ const normalized = normalizeFeishuMessage(event);
349
+ if (!normalized)
350
+ return new Response(null, { status: 200 });
351
+ const bufferKey = feishuBufferPlaceKey(normalized.conversation);
352
+ const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
353
+ const managedThread = groupMessageSession === "threaded" &&
354
+ isHumanGroup &&
355
+ m.thread_id !== undefined &&
356
+ m.root_id !== undefined &&
357
+ ownedThreads.has(m.chat_id, m.root_id);
358
+ // In an Agent-created thread, a bare user continuation still summons. Any explicit mention changes
359
+ // that intent: only defaultFeishuRoute's structural @THIS-bot match summons; @other-only discussion
360
+ // is buffered like unsummoned group context. A custom route remains fully authoritative.
361
+ if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
362
+ r = {};
363
+ if (!r) {
364
+ if (route === undefined && isHumanGroup) {
365
+ const bodyText = feishuBufferText(normalized.content.text);
366
+ if (bodyText) {
367
+ const resources = normalized.content.resources;
368
+ const images = resources
369
+ .filter((resource) => resource.kind === "image")
370
+ .map((resource) => ({ messageId: resource.messageId, key: resource.key }));
371
+ const files = resources
372
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
373
+ .map((resource) => ({
374
+ messageId: resource.messageId,
375
+ key: resource.key,
376
+ name: resource.name,
377
+ }));
378
+ // Pre-ACK persistence: a write failure rejects the webhook so the platform can redeliver.
379
+ buffer.push(bufferKey, {
380
+ sender: senderLabel(event.sender) ?? "someone",
381
+ body: bodyText,
382
+ messageId: m.message_id,
383
+ replyTo: m.parent_id,
384
+ files: files.length ? files : undefined,
385
+ images: images.length ? images : undefined,
386
+ });
387
+ seen.add(m.message_id); // post-persist: a redelivery cannot duplicate buffered context
388
+ log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
389
+ }
390
+ else {
391
+ log.debug(`${label} not summoned — ignoring empty message ${m.message_id} (chat ${m.chat_id})`);
392
+ }
393
+ }
394
+ else {
395
+ log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
396
+ }
397
+ return new Response(null, { status: 200 });
398
+ }
399
+ {
400
+ const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
401
+ const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
402
+ const threadedConversation = threadedP2p || threadedGroup;
403
+ // A top-level threaded message has no thread_id yet. Its tenant-unique message_id is therefore
404
+ // the only identity available both before and after the first reply creates the thread.
405
+ // Continuations carry that same value as root_id (field-verified on Feishu p2p; shared protocol
406
+ // shape for groups/Lark). Prefix with the channel kind to isolate Feishu/Lark while keeping pi's
407
+ // provider-facing session/cache key under 64 characters.
408
+ if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
409
+ log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id — session continuity cannot be guaranteed`);
410
+ }
411
+ const defaultSession = threadedConversation
412
+ ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
413
+ : placeKey(m);
414
+ const session = r.session ?? defaultSession;
415
+ const chatId = r.chatId ?? m.chat_id;
416
+ // Groups always quote the summon. Threaded groups and p2p add reply_in_thread: on a top-level
417
+ // message that creates the thread, and on a continuation it keeps the answer inside it. Only
418
+ // quote when the resolved target is the source chat — a custom redirect cannot reuse a message
419
+ // id there. A continuous group still keeps replies inside an already-existing platform topic.
420
+ const sameTarget = chatId === m.chat_id;
421
+ const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
422
+ const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
423
+ // Queue feedback always identifies the exact ask, including continuous modes. In threaded mode
424
+ // it inherits replyInThread, so an ask queued inside a root cannot leak a status card to main chat.
425
+ const queueReplyTo = sameTarget ? m.message_id : undefined;
426
+ const resources = normalized.content.resources;
427
+ const images = resources
428
+ .filter((resource) => resource.kind === "image")
429
+ .map((resource) => ({ msg: resource.messageId, key: resource.key }));
430
+ const files = resources
431
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
432
+ .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
433
+ const baseText = r.text ?? cloudEnvelope(event, kind);
434
+ if (baseText.trim() !== "" || images.length > 0 || files.length > 0) {
435
+ // Persist ownership before ACK. The platform thread does not exist until the first reply lands,
436
+ // but a failed reply has no continuation to misroute; pre-ACK ownership closes the opposite,
437
+ // worse window (thread created, process dies, then its unmentioned continuation is forgotten).
438
+ if (route === undefined &&
439
+ threadedGroup &&
440
+ m.thread_id === undefined &&
441
+ sameTarget &&
442
+ replyInThread === true) {
443
+ ownedThreads.add(m.chat_id, m.message_id);
444
+ }
445
+ submit({
446
+ id: m.message_id,
447
+ seq: ++seqCounter,
448
+ session,
449
+ baseText,
450
+ bufferKey,
451
+ chatId,
452
+ replyTo,
453
+ queueReplyTo,
454
+ replyInThread,
455
+ // Inside a threaded session the root conversation history already contains the previous
456
+ // turns. Reloading parent_id would duplicate that input (and its attachments). A top-level
457
+ // quoted reply has no thread_id, starts a new root, and still hydrates its referent.
458
+ parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
459
+ images,
460
+ files,
461
+ }, true);
462
+ }
463
+ }
464
+ // ACK immediately (the platform expects a fast 200; the turn may outlast it by minutes) —
465
+ // lifecycle goes to stderr; after the 200 those lines are the operator's only signal.
466
+ return new Response(null, { status: 200 });
467
+ };
468
+ // Test/observability seam: await the fire-and-forget turns this handler enqueues (see turn-queue).
469
+ handler.turnsIdle = () => queue.idle();
470
+ return { [`POST /${kind}`]: handler };
471
+ };
472
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Run one turn (the IO half of canonical Feishu→Agent translation): assemble its inputs — resolve the reply
3
+ * referent (a summon that replies to an earlier message names it only by `parent_id`; the content is
4
+ * NOT in the event, so it is fetched here) and the attachments (vision images inline, files to disk) —
5
+ * and stream `agent.invoke` with the assembled prompt. Split from parse.ts (which is pure) because this
6
+ * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
+ * per-turn lifecycle.
8
+ *
9
+ * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
+ * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
+ * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
+ * one expired background file must not block the current ask or hide its still-readable siblings.
13
+ */
14
+ import { type Agent, type AgentEvent } from "../../agent.ts";
15
+ import type { FeishuBufferedRef } from "./context-buffer.ts";
16
+ import type { FeishuApi } from "./feishu-api.ts";
17
+ /** Everything the transport needs to fetch a turn's attachments. */
18
+ export interface FeishuTurnTransport {
19
+ api: FeishuApi;
20
+ chatId: string;
21
+ filesDir: string;
22
+ label: string;
23
+ }
24
+ /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
25
+ * bytes by message_id + key, so the pair travels together through the turn record). */
26
+ export interface FeishuAttachmentInput {
27
+ msg: string;
28
+ key: string;
29
+ name?: string;
30
+ }
31
+ /** A turn's primary resources plus background resources folded from the context buffer. */
32
+ export interface FeishuTurnAttachments {
33
+ primary: {
34
+ images: FeishuAttachmentInput[];
35
+ files: FeishuAttachmentInput[];
36
+ /** The replied-to message's id, when the summon is a reply. */
37
+ parentId?: string;
38
+ };
39
+ buffered: {
40
+ files: FeishuBufferedRef[];
41
+ images: FeishuBufferedRef[];
42
+ skipped: number;
43
+ };
44
+ }
45
+ /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
46
+ * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
47
+ * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
48
+ * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
49
+ export interface BusyRetry {
50
+ delayMs: number;
51
+ maxWaitMs: number;
52
+ }
53
+ /**
54
+ * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
55
+ * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
56
+ * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
57
+ * moment the turn provably lives in the session.
58
+ *
59
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
60
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
61
+ * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
62
+ * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
63
+ * engine emits it in, so nothing that started is ever re-run.
64
+ */
65
+ export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;