@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.
- package/README.md +6 -4
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +15 -0
- package/dist/channels/feishu/crypto.js +43 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +46 -0
- package/dist/channels/feishu/feishu.js +472 -0
- package/dist/channels/feishu/invoke-turn.d.ts +65 -0
- package/dist/channels/feishu/invoke-turn.js +157 -0
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +43 -0
- package/dist/channels/feishu/parse.js +65 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +41 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +39 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +8 -1284
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +80 -7
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -6
- package/dist/engines/pi/create.js +52 -16
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +75 -7
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +18 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /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>;
|