@fastagent-sh/fastagent 0.12.0 → 0.13.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 +42 -36
- 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/crypto.d.ts +13 -0
- package/dist/channels/feishu/crypto.js +41 -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 +36 -0
- package/dist/channels/feishu/feishu.js +359 -0
- package/dist/channels/feishu/invoke-turn.d.ts +59 -0
- package/dist/channels/feishu/invoke-turn.js +106 -0
- package/dist/channels/feishu/parse.d.ts +125 -0
- package/dist/channels/feishu/parse.js +175 -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 +34 -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 +32 -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-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +68 -23
- package/dist/deploy/container.js +10 -6
- 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/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/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/chat.js +3 -3
- package/dist/engines/pi/create.d.ts +0 -1
- package/dist/engines/pi/create.js +8 -7
- 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/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +73 -7
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +19 -8
- /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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Feishu bot-channel engine: verify webhook → answer url_verification → dedup → route → 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 { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
|
|
20
|
+
import { invokeFeishuTurn } from "./invoke-turn.js";
|
|
21
|
+
import { createFeishuApi } from "./feishu-api.js";
|
|
22
|
+
import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, parseContent, placeKey, } from "./parse.js";
|
|
23
|
+
import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
|
|
24
|
+
import { createSeenRing } from "./seen.js";
|
|
25
|
+
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
26
|
+
export { defaultFeishuRoute, feishuEnvelope };
|
|
27
|
+
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
28
|
+
* rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
|
|
29
|
+
* at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
|
|
30
|
+
const MAX_TURN_ATTEMPTS = 3;
|
|
31
|
+
/** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
32
|
+
const MAX_EVENT_BYTES = 1 << 20;
|
|
33
|
+
/** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
|
|
34
|
+
* accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
|
|
35
|
+
* is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
|
|
36
|
+
const QUEUE_NOTICE_DELAY_MS = 0;
|
|
37
|
+
const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
|
|
38
|
+
const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
|
|
39
|
+
/** State files are an IO boundary: valid JSON of the WRONG SHAPE must degrade like a corrupt file. */
|
|
40
|
+
function isStoredFeishuTurn(t) {
|
|
41
|
+
const r = t;
|
|
42
|
+
const refs = (v) => Array.isArray(v) &&
|
|
43
|
+
v.every((x) => typeof x.msg === "string" && typeof x.key === "string");
|
|
44
|
+
return (typeof r?.id === "string" &&
|
|
45
|
+
typeof r.seq === "number" &&
|
|
46
|
+
typeof r.session === "string" &&
|
|
47
|
+
typeof r.baseText === "string" &&
|
|
48
|
+
typeof r.chatId === "string" &&
|
|
49
|
+
(r.replyTo === undefined || typeof r.replyTo === "string") &&
|
|
50
|
+
(r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
|
|
51
|
+
(r.replyInThread === undefined || typeof r.replyInThread === "boolean") &&
|
|
52
|
+
(r.parentId === undefined || typeof r.parentId === "string") &&
|
|
53
|
+
refs(r.images) &&
|
|
54
|
+
refs(r.files) &&
|
|
55
|
+
typeof r.attempts === "number");
|
|
56
|
+
}
|
|
57
|
+
/** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
|
|
58
|
+
export function feishuChannel(opts) {
|
|
59
|
+
return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
|
|
60
|
+
}
|
|
61
|
+
/** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
|
|
62
|
+
export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
|
|
63
|
+
const { kind, envPrefix } = profile;
|
|
64
|
+
const label = `[${kind}]`;
|
|
65
|
+
// All three are mandatory: without the app credentials no reply can be sent; without the verification
|
|
66
|
+
// token a plaintext-mode endpoint would accept forged events. Fail at construction (startup), not
|
|
67
|
+
// silently at the first event.
|
|
68
|
+
if (!appId || !appSecret) {
|
|
69
|
+
throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
|
|
70
|
+
}
|
|
71
|
+
if (!verificationToken) {
|
|
72
|
+
throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks; an unset one accepts forged events)`);
|
|
73
|
+
}
|
|
74
|
+
return ({ agent, stateRoot }) => {
|
|
75
|
+
const formatError = onError ?? defaultErrorMessage;
|
|
76
|
+
const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
|
|
77
|
+
// One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
|
|
78
|
+
// Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works.
|
|
79
|
+
let botOpenId;
|
|
80
|
+
void api.botInfo().then((me) => {
|
|
81
|
+
botOpenId = me.openId;
|
|
82
|
+
if (!botOpenId)
|
|
83
|
+
log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
|
|
84
|
+
}, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
|
|
85
|
+
const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
|
|
86
|
+
// The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
|
|
87
|
+
// (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
|
|
88
|
+
// the operator's ONE state knob (FASTAGENT_STATE_DIR) can never be silently bypassed by glue.
|
|
89
|
+
if (!isAbsolute(stateRoot)) {
|
|
90
|
+
throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
|
|
91
|
+
}
|
|
92
|
+
const stateHome = join(stateRoot, "channels", kind);
|
|
93
|
+
ensureStateHome(stateHome); // create + self-ignore — downloaded files may carry chat content
|
|
94
|
+
const store = createTurnStore(join(stateHome, "turns.json"), {
|
|
95
|
+
label,
|
|
96
|
+
isRecord: isStoredFeishuTurn,
|
|
97
|
+
order: (a, b) => a.seq - b.seq,
|
|
98
|
+
});
|
|
99
|
+
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
100
|
+
const toStored = (r) => {
|
|
101
|
+
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
102
|
+
return { ...intent, attempts: 0 };
|
|
103
|
+
};
|
|
104
|
+
const targetOf = (r) => ({
|
|
105
|
+
chatId: r.chatId,
|
|
106
|
+
replyTo: r.replyTo,
|
|
107
|
+
replyInThread: r.replyInThread,
|
|
108
|
+
});
|
|
109
|
+
const queueTargetOf = (r) => ({
|
|
110
|
+
chatId: r.chatId,
|
|
111
|
+
replyTo: r.queueReplyTo,
|
|
112
|
+
replyInThread: r.replyInThread,
|
|
113
|
+
});
|
|
114
|
+
// In-memory: the pending queue-preview mount per turn. Immediate by default; with an explicit delay,
|
|
115
|
+
// it mounts only if the turn is still waiting when the timer fires and is cancelled unsent otherwise.
|
|
116
|
+
// `done` settles either way, awaited at dequeue so the runner reliably receives the mounted preview
|
|
117
|
+
// instead of racing it and double-posting.
|
|
118
|
+
const notices = new Map();
|
|
119
|
+
const queue = createTurnQueue({
|
|
120
|
+
label,
|
|
121
|
+
// Queue feedback: when this session already has a turn running/queued, a silent wait reads as
|
|
122
|
+
// "the bot ignored me" once the current turn runs long — mount that turn's preview early with a
|
|
123
|
+
// queue status. It reply-quotes the exact source message (including p2p), then the runner mutates
|
|
124
|
+
// the SAME card/text into Thinking → final answer. Best-effort and post-ACK: a failed mount is a
|
|
125
|
+
// log line, never a failed event delivery; the turn later mounts its normal preview.
|
|
126
|
+
onQueuedBehind: (rec) => {
|
|
127
|
+
let fired = false;
|
|
128
|
+
let settle = () => { };
|
|
129
|
+
const done = new Promise((resolve) => {
|
|
130
|
+
settle = resolve;
|
|
131
|
+
});
|
|
132
|
+
const mount = () => {
|
|
133
|
+
fired = true;
|
|
134
|
+
mountFeishuPreview(api, queueTargetOf(rec), QUEUED_PLACEHOLDER, label)
|
|
135
|
+
.then((preview) => {
|
|
136
|
+
rec.preview = preview;
|
|
137
|
+
}, (e) => log.warn(`${label} queue preview failed (the turn still runs): ${String(e)}`))
|
|
138
|
+
.finally(settle);
|
|
139
|
+
};
|
|
140
|
+
const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
|
|
141
|
+
if (timer === undefined)
|
|
142
|
+
mount();
|
|
143
|
+
notices.set(rec.id, {
|
|
144
|
+
// Cancel is a no-op once mounting started — the send is in flight and `done` settles with it.
|
|
145
|
+
cancel: () => {
|
|
146
|
+
if (!fired) {
|
|
147
|
+
if (timer !== undefined)
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
settle();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
done,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
run: async (rec) => {
|
|
156
|
+
// Runs at DEQUEUE time (serialized). The turn's queue wait is over: cancel a not-yet-mounted
|
|
157
|
+
// preview (fast turnover skips the Queued frame), then settle so rec.preview is final — in the
|
|
158
|
+
// common path this await is instant. BEFORE the ceiling check so drop/defer can take it over too.
|
|
159
|
+
const notice = notices.get(rec.id);
|
|
160
|
+
notice?.cancel();
|
|
161
|
+
await notice?.done;
|
|
162
|
+
notices.delete(rec.id);
|
|
163
|
+
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
164
|
+
const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
|
|
165
|
+
if (decision === "exceeded") {
|
|
166
|
+
notifyDropped(rec);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (decision === "defer") {
|
|
170
|
+
// Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
|
|
171
|
+
// recall an existing queue preview (the client exposes a confusing tombstone): settle it in
|
|
172
|
+
// place to an honest delayed status. The eventual replay mounts a fresh preview.
|
|
173
|
+
if (rec.preview !== undefined) {
|
|
174
|
+
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)}`));
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const startedAt = Date.now();
|
|
179
|
+
log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
|
|
180
|
+
try {
|
|
181
|
+
await streamFeishuReply(invokeFeishuTurn(agent, rec.session, rec.baseText, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { images: rec.images, files: rec.files, parentId: rec.parentId },
|
|
182
|
+
// On completed, drop the intent — the turn provably lives in the session from here on.
|
|
183
|
+
() => store.remove(rec.id)), api, targetOf(rec), formatError, rec.preview, label);
|
|
184
|
+
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
191
|
+
// never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
|
|
192
|
+
// INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
|
|
193
|
+
// replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
|
|
194
|
+
store.remove(rec.id);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
// Accept a turn: persist its intent (pre-ACK; a failed write throws → webhook 500 → redeliver),
|
|
199
|
+
// record its id in the dedup ring (post-decision insurance, best-effort), enqueue it. Recovery
|
|
200
|
+
// re-enqueues a crash-surviving turn WITHOUT re-persisting.
|
|
201
|
+
const submit = (rec, persist) => {
|
|
202
|
+
if (persist) {
|
|
203
|
+
store.add(toStored(rec));
|
|
204
|
+
seen.add(rec.id);
|
|
205
|
+
}
|
|
206
|
+
queue.accept(rec);
|
|
207
|
+
};
|
|
208
|
+
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
|
|
209
|
+
// not just an operator log line. Take over its queue preview in place if present (else send fresh) —
|
|
210
|
+
// leaving it pinned at "Queued" while sending a separate failure would double-post.
|
|
211
|
+
const notifyDropped = (r) => {
|
|
212
|
+
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
213
|
+
void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
214
|
+
};
|
|
215
|
+
// Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
|
|
216
|
+
// the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
|
|
217
|
+
const recovered = store.recover();
|
|
218
|
+
if (recovered.length > 0)
|
|
219
|
+
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
220
|
+
let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
|
|
221
|
+
for (const { attempts: _a, ...intent } of recovered)
|
|
222
|
+
submit({ ...intent, preview: undefined }, false);
|
|
223
|
+
const handler = async (req) => {
|
|
224
|
+
if (req.method !== "POST")
|
|
225
|
+
return text("POST only\n", 405);
|
|
226
|
+
const body = await readBodyCapped(req, MAX_EVENT_BYTES);
|
|
227
|
+
if ("tooLarge" in body)
|
|
228
|
+
return text("payload too large\n", 413);
|
|
229
|
+
let outer;
|
|
230
|
+
try {
|
|
231
|
+
outer = JSON.parse(body.text);
|
|
232
|
+
if (typeof outer !== "object" || outer === null)
|
|
233
|
+
throw new Error("not an object");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return text("invalid json\n", 400);
|
|
237
|
+
}
|
|
238
|
+
// ── Verification. Two modes, decided by the CONSOLE's Encrypt Key setting, mirrored here. ──────
|
|
239
|
+
let envelope;
|
|
240
|
+
if (typeof outer.encrypt === "string") {
|
|
241
|
+
if (!encryptKey) {
|
|
242
|
+
log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
|
|
243
|
+
return text("encrypt key not configured\n", 400);
|
|
244
|
+
}
|
|
245
|
+
const sig = {
|
|
246
|
+
timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
|
|
247
|
+
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
248
|
+
signature: req.headers.get("x-lark-signature") ?? "",
|
|
249
|
+
};
|
|
250
|
+
// Ordinary encrypted events MUST verify the signature over the raw body before decryption.
|
|
251
|
+
// Feishu's documented exception is Request URL verification: its encrypted challenge carries
|
|
252
|
+
// no event-signature headers, so it is decrypted first and admitted ONLY when its type is
|
|
253
|
+
// url_verification; the common constant-time Token check below then authenticates it.
|
|
254
|
+
if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
|
|
255
|
+
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
256
|
+
return text("invalid signature\n", 401);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
if (!sig.signature) {
|
|
263
|
+
log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
|
|
264
|
+
return text("invalid encrypted payload\n", 401);
|
|
265
|
+
}
|
|
266
|
+
return text("invalid encrypted payload\n", 400);
|
|
267
|
+
}
|
|
268
|
+
if (!sig.signature && envelope.type !== "url_verification") {
|
|
269
|
+
log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
|
|
270
|
+
return text("invalid signature\n", 401);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
if (encryptKey) {
|
|
275
|
+
// With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
|
|
276
|
+
// mismatch — surfaced in the log): accepting it would let a sender skip the signature.
|
|
277
|
+
log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
|
|
278
|
+
return text("plaintext events not accepted\n", 401);
|
|
279
|
+
}
|
|
280
|
+
envelope = outer;
|
|
281
|
+
}
|
|
282
|
+
// The Verification Token authenticates plaintext mode and the platform-documented unsigned,
|
|
283
|
+
// encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
|
|
284
|
+
// in header.token, while url_verification carries it at the top level. Fail closed when absent.
|
|
285
|
+
const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
|
|
286
|
+
(typeof envelope.header?.token === "string"
|
|
287
|
+
? envelope.header.token
|
|
288
|
+
: undefined);
|
|
289
|
+
if (!token || !timingSafeEqualStr(token, verificationToken)) {
|
|
290
|
+
// Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
|
|
291
|
+
// the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
|
|
292
|
+
log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
|
|
293
|
+
return text("invalid token\n", 401);
|
|
294
|
+
}
|
|
295
|
+
// ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
|
|
296
|
+
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
|
|
297
|
+
// The console fires this when the operator saves the Request URL; without this line a PASSING
|
|
298
|
+
// handshake is invisible and "did the challenge even arrive?" becomes guesswork.
|
|
299
|
+
log.info(`${label} answered the console's url_verification challenge`);
|
|
300
|
+
return Response.json({ challenge: envelope.challenge });
|
|
301
|
+
}
|
|
302
|
+
// ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
|
|
303
|
+
// (a non-2xx would just make the platform retry an event this channel will never act on). ──────
|
|
304
|
+
const header = envelope.header;
|
|
305
|
+
if (header?.event_type !== "im.message.receive_v1") {
|
|
306
|
+
log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
|
|
307
|
+
return new Response(null, { status: 200 });
|
|
308
|
+
}
|
|
309
|
+
const event = (envelope.event ?? {});
|
|
310
|
+
const m = event.message;
|
|
311
|
+
if (!m?.message_id || !m.chat_id)
|
|
312
|
+
return new Response(null, { status: 200 });
|
|
313
|
+
if (seen.has(m.message_id)) {
|
|
314
|
+
log.debug(`${label} duplicate push for message ${m.message_id} — already accepted, skipping`);
|
|
315
|
+
return new Response(null, { status: 200 });
|
|
316
|
+
}
|
|
317
|
+
const r = decide(event);
|
|
318
|
+
if (!r) {
|
|
319
|
+
log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
|
|
320
|
+
return new Response(null, { status: 200 });
|
|
321
|
+
}
|
|
322
|
+
{
|
|
323
|
+
const session = r.session ?? placeKey(m);
|
|
324
|
+
const chatId = r.chatId ?? m.chat_id;
|
|
325
|
+
// Reply to the summoning message in groups (threads the answer under the asker; stays inside a
|
|
326
|
+
// topic); a 1:1 p2p chat needs no reply-quote. Only when the RESOLVED target is the message's own
|
|
327
|
+
// chat: a route that redirects elsewhere must not quote a same-id message in the wrong place.
|
|
328
|
+
const sameTarget = chatId === m.chat_id;
|
|
329
|
+
const replyTo = m.chat_type !== "p2p" && sameTarget ? m.message_id : undefined;
|
|
330
|
+
// Queue feedback always identifies the exact ask, including p2p. Ordinary p2p answers remain
|
|
331
|
+
// unquoted unless the turn actually waited long enough for its queue preview to mount.
|
|
332
|
+
const queueReplyTo = sameTarget ? m.message_id : undefined;
|
|
333
|
+
const content = parseContent(m);
|
|
334
|
+
const baseText = r.text ?? cloudEnvelope(event, kind);
|
|
335
|
+
if (baseText.trim() !== "" || content.imageKeys.length > 0 || content.fileRefs.length > 0) {
|
|
336
|
+
submit({
|
|
337
|
+
id: m.message_id,
|
|
338
|
+
seq: ++seqCounter,
|
|
339
|
+
session,
|
|
340
|
+
baseText,
|
|
341
|
+
chatId,
|
|
342
|
+
replyTo,
|
|
343
|
+
queueReplyTo,
|
|
344
|
+
replyInThread: replyTo !== undefined && m.thread_id !== undefined ? true : undefined,
|
|
345
|
+
parentId: m.parent_id,
|
|
346
|
+
images: content.imageKeys.map((key) => ({ msg: m.message_id, key })),
|
|
347
|
+
files: content.fileRefs.map((f) => ({ msg: m.message_id, key: f.key, name: f.name })),
|
|
348
|
+
}, true);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// ACK immediately (the platform expects a fast 200; the turn may outlast it by minutes) —
|
|
352
|
+
// lifecycle goes to stderr; after the 200 those lines are the operator's only signal.
|
|
353
|
+
return new Response(null, { status: 200 });
|
|
354
|
+
};
|
|
355
|
+
// Test/observability seam: await the fire-and-forget turns this handler enqueues (see turn-queue).
|
|
356
|
+
handler.turnsIdle = () => queue.idle();
|
|
357
|
+
return { [`POST /${kind}`]: handler };
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
* Everything here is PRIMARY input — the summoning message's own attachments and the message the user
|
|
10
|
+
* explicitly replied to — so any load failure THROWS and the caller sees a `failed` event: the agent
|
|
11
|
+
* never runs on inputs the user pointed at but we failed to load. (The telegram channel's second,
|
|
12
|
+
* degrade-per-attachment tier is its context BUFFER — background material; Feishu grows that tier only
|
|
13
|
+
* with the buffer itself, which needs the sensitive all-group-messages scope.)
|
|
14
|
+
*/
|
|
15
|
+
import { type Agent, type AgentEvent } from "../../agent.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 attachment inputs: the summoning message's own resources, plus the message it replied to
|
|
32
|
+
* (resolved here — content and resources both). */
|
|
33
|
+
export interface FeishuTurnAttachments {
|
|
34
|
+
images: FeishuAttachmentInput[];
|
|
35
|
+
files: FeishuAttachmentInput[];
|
|
36
|
+
/** The replied-to message's id, when the summon is a reply. */
|
|
37
|
+
parentId?: string;
|
|
38
|
+
}
|
|
39
|
+
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
40
|
+
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
41
|
+
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
42
|
+
* always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
|
|
43
|
+
export interface BusyRetry {
|
|
44
|
+
delayMs: number;
|
|
45
|
+
maxWaitMs: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run one turn: resolve its inputs, then stream agent.invoke. An input failure surfaces as a `failed`
|
|
49
|
+
* event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed` event — the
|
|
50
|
+
* durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
|
|
51
|
+
* moment the turn provably lives in the session.
|
|
52
|
+
*
|
|
53
|
+
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
|
|
54
|
+
* lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
|
|
55
|
+
* the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
|
|
56
|
+
* FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
|
|
57
|
+
* engine emits it in, so nothing that started is ever re-run.
|
|
58
|
+
*/
|
|
59
|
+
export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
* Everything here is PRIMARY input — the summoning message's own attachments and the message the user
|
|
10
|
+
* explicitly replied to — so any load failure THROWS and the caller sees a `failed` event: the agent
|
|
11
|
+
* never runs on inputs the user pointed at but we failed to load. (The telegram channel's second,
|
|
12
|
+
* degrade-per-attachment tier is its context BUFFER — background material; Feishu grows that tier only
|
|
13
|
+
* with the buffer itself, which needs the sensitive all-group-messages scope.)
|
|
14
|
+
*/
|
|
15
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
16
|
+
import { log } from "../../log.js";
|
|
17
|
+
import { parseContent } from "./parse.js";
|
|
18
|
+
import { codePointPrefix } from "./text.js";
|
|
19
|
+
/** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
|
|
20
|
+
* card's markdown element is the natural fit for LLM output — steer away from HTML/plain. */
|
|
21
|
+
const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
|
|
24
|
+
* (vision) and file (disk). Throws on any failure — primary inputs, no silent drops.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveTurnInputs(t, attachments) {
|
|
27
|
+
const images = [...attachments.images];
|
|
28
|
+
const files = [...attachments.files];
|
|
29
|
+
let referentBlock = "";
|
|
30
|
+
if (attachments.parentId !== undefined) {
|
|
31
|
+
const parent = await t.api.getMessage(attachments.parentId);
|
|
32
|
+
if (!parent)
|
|
33
|
+
throw new Error(`replied-to message ${attachments.parentId} is not readable`);
|
|
34
|
+
const parsed = parseContent({
|
|
35
|
+
message_type: parent.msg_type ?? "unknown",
|
|
36
|
+
content: parent.body?.content ?? "",
|
|
37
|
+
mentions: parent.mentions,
|
|
38
|
+
});
|
|
39
|
+
// The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
|
|
40
|
+
for (const key of parsed.imageKeys)
|
|
41
|
+
images.push({ msg: attachments.parentId, key });
|
|
42
|
+
for (const ref of parsed.fileRefs)
|
|
43
|
+
files.push({ msg: attachments.parentId, key: ref.key, name: ref.name });
|
|
44
|
+
// getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
|
|
45
|
+
// sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
|
|
46
|
+
const senderId = parent.sender?.id;
|
|
47
|
+
const from = senderId ? `user ${senderId}` : undefined;
|
|
48
|
+
referentBlock = `\n\n[replied-to message (msg ${attachments.parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
|
|
49
|
+
}
|
|
50
|
+
const imageRefs = [];
|
|
51
|
+
for (const ref of images)
|
|
52
|
+
imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
|
|
53
|
+
const downloaded = [];
|
|
54
|
+
for (const ref of files)
|
|
55
|
+
downloaded.push(await t.api.fetchFile(ref.msg, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir));
|
|
56
|
+
const manifest = downloaded.length
|
|
57
|
+
? `\n\n[attached files — read them with your tools:\n${downloaded.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
|
|
58
|
+
: "";
|
|
59
|
+
return { images: imageRefs.length ? imageRefs : undefined, promptSuffix: `${referentBlock}${manifest}` };
|
|
60
|
+
}
|
|
61
|
+
// Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
|
|
62
|
+
// free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
|
|
63
|
+
// tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
|
|
64
|
+
const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
65
|
+
/**
|
|
66
|
+
* Run one turn: resolve its inputs, then stream agent.invoke. An input failure surfaces as a `failed`
|
|
67
|
+
* event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed` event — the
|
|
68
|
+
* durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
|
|
69
|
+
* moment the turn provably lives in the session.
|
|
70
|
+
*
|
|
71
|
+
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
|
|
72
|
+
* lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
|
|
73
|
+
* the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
|
|
74
|
+
* FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
|
|
75
|
+
* engine emits it in, so nothing that started is ever re-run.
|
|
76
|
+
*/
|
|
77
|
+
export async function* invokeFeishuTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
|
|
78
|
+
let resolved;
|
|
79
|
+
try {
|
|
80
|
+
resolved = await resolveTurnInputs(transport, attachments);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
|
|
87
|
+
const deadline = Date.now() + busyRetry.maxWaitMs;
|
|
88
|
+
for (;;) {
|
|
89
|
+
let retryBusy = false;
|
|
90
|
+
let first = true;
|
|
91
|
+
for await (const e of agent.invoke({ session }, prompt)) {
|
|
92
|
+
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
93
|
+
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
first = false;
|
|
97
|
+
if (e.type === "completed")
|
|
98
|
+
onCompleted?.(); // the turn is durably in the session — commit point
|
|
99
|
+
yield e;
|
|
100
|
+
}
|
|
101
|
+
if (!retryBusy)
|
|
102
|
+
return;
|
|
103
|
+
log.info(`${transport.label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
|
|
104
|
+
await new Promise((r) => setTimeout(r, busyRetry.delayMs));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Feishu protocol parsing — PURE: event field extraction, message-content decoding, prompt
|
|
3
|
+
* envelope, and summon/route policy. Lark compatibility events reuse these wire shapes. Feishu
|
|
4
|
+
* messages carry their payload as a JSON-ENCODED STRING in `content`, shaped by `message_type`
|
|
5
|
+
* (text / post / image / file / audio / media / …). {@link parseContent} is the single decoder: it
|
|
6
|
+
* yields the readable text (mention placeholders restored), the vision image keys, and the downloadable
|
|
7
|
+
* file refs — the envelope, the turn record, and the parent-message resolution all read through it.
|
|
8
|
+
*/
|
|
9
|
+
import type { FeishuCloudKind } from "./cloud.ts";
|
|
10
|
+
/** One entry of a message's `mentions` array: the platform's parsed @-mention (the `key` is the
|
|
11
|
+
* placeholder in the text content, e.g. `@_user_1`). */
|
|
12
|
+
export interface FeishuMention {
|
|
13
|
+
key: string;
|
|
14
|
+
id?: {
|
|
15
|
+
open_id?: string;
|
|
16
|
+
user_id?: string;
|
|
17
|
+
union_id?: string;
|
|
18
|
+
};
|
|
19
|
+
name?: string;
|
|
20
|
+
[k: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
/** A received message (the `im.message.receive_v1` event's `message`, common subset; `[k]` keeps the
|
|
23
|
+
* rest reachable without a types dependency). */
|
|
24
|
+
export interface FeishuMessage {
|
|
25
|
+
message_id: string;
|
|
26
|
+
/** The replied-to message, when this message is a reply. Content is NOT included — the channel
|
|
27
|
+
* fetches it via the API (the IO half, invoke-turn.ts). */
|
|
28
|
+
parent_id?: string;
|
|
29
|
+
root_id?: string;
|
|
30
|
+
chat_id: string;
|
|
31
|
+
/** Present in topic groups — reply into the same topic to stay threaded. */
|
|
32
|
+
thread_id?: string;
|
|
33
|
+
chat_type: string;
|
|
34
|
+
message_type: string;
|
|
35
|
+
/** JSON-encoded string, shaped by message_type — decode with {@link parseContent}. */
|
|
36
|
+
content: string;
|
|
37
|
+
mentions?: FeishuMention[];
|
|
38
|
+
[k: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
export interface FeishuSender {
|
|
41
|
+
sender_id?: {
|
|
42
|
+
open_id?: string;
|
|
43
|
+
user_id?: string;
|
|
44
|
+
union_id?: string;
|
|
45
|
+
};
|
|
46
|
+
/** "user" for humans; apps/bots have other values — the default route ignores those (anti-loop). */
|
|
47
|
+
sender_type?: string;
|
|
48
|
+
[k: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
/** The `im.message.receive_v1` event body (`event` of the v2.0 envelope). */
|
|
51
|
+
export interface FeishuMessageEvent {
|
|
52
|
+
sender?: FeishuSender;
|
|
53
|
+
message?: FeishuMessage;
|
|
54
|
+
[k: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
/** What `route` returns: act with these (every field optional — omitted ones default from the message),
|
|
57
|
+
* or null to ignore. */
|
|
58
|
+
export interface FeishuRoute {
|
|
59
|
+
/** Conversation identity (default: `chat` or `chat:thread`). */
|
|
60
|
+
session?: string;
|
|
61
|
+
/** Reply target chat (default: the message's chat). */
|
|
62
|
+
chatId?: string;
|
|
63
|
+
/** Base prompt (default: {@link feishuEnvelope}); the channel still appends attachments. */
|
|
64
|
+
text?: string;
|
|
65
|
+
}
|
|
66
|
+
/** A downloadable attachment reference: the resource key inside its carrying message (the resource API
|
|
67
|
+
* addresses a file by message_id + key). */
|
|
68
|
+
export interface FeishuAttachmentRef {
|
|
69
|
+
key: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
}
|
|
72
|
+
/** A message's decoded payload — see the module header. */
|
|
73
|
+
export interface ParsedFeishuContent {
|
|
74
|
+
/** Readable text: the text/post body with mention placeholders restored to `@Name`; marker lines
|
|
75
|
+
* (`[image]`, `[file: …]`) for non-text payloads, so a media-only message is never blank. */
|
|
76
|
+
text: string;
|
|
77
|
+
/** Vision images: `image_key`s from an image message or a post's inline images. */
|
|
78
|
+
imageKeys: string[];
|
|
79
|
+
/** Files to download to disk (file / audio / media). */
|
|
80
|
+
fileRefs: FeishuAttachmentRef[];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Decode a message's `content` by its `message_type` — the single decoder (module header). Unknown or
|
|
84
|
+
* malformed content degrades to a visible marker (`[sticker message]`), never a throw: the payload is
|
|
85
|
+
* external input, and a message the agent cannot read should still say WHAT it couldn't read.
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseContent(m: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
|
|
88
|
+
/** A stable sender label for attribution. The receive event carries only ids (a display name needs a
|
|
89
|
+
* contacts-API scope), so the label is the open_id — stable across turns, which is what a shared
|
|
90
|
+
* multi-user session needs to tell participants apart. */
|
|
91
|
+
export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
|
|
92
|
+
/** The place a message lives (chat, or chat:topic in a topic group) — the default session key. */
|
|
93
|
+
export declare function placeKey(m: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
|
|
94
|
+
/**
|
|
95
|
+
* The default base prompt: a context envelope (chat/thread/sender + a group note + a reply marker),
|
|
96
|
+
* then the message's decoded body. The sender is named on every message and a group chat is flagged —
|
|
97
|
+
* in a shared multi-user session that is how the model tells participants apart and knows it is not a
|
|
98
|
+
* 1:1. A reply carries only `[in reply to msg …]` here: the referent's CONTENT is not in the event, so
|
|
99
|
+
* the channel fetches and appends it in the IO half (invoke-turn.ts), keeping this layer pure. Exported
|
|
100
|
+
* so a custom Feishu `route` can reuse it, e.g. `text: `${feishuEnvelope(event)}\n\n[extra]``. The
|
|
101
|
+
* internal compatibility seam binds the same shape to `[lark: …]`; each kind's send tool reads the
|
|
102
|
+
* chat id from its own branded line.
|
|
103
|
+
*/
|
|
104
|
+
export declare function feishuEnvelope(event: FeishuMessageEvent): string;
|
|
105
|
+
/** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
|
|
106
|
+
export declare function cloudEnvelope(event: FeishuMessageEvent, tag: FeishuCloudKind): string;
|
|
107
|
+
/**
|
|
108
|
+
* Whether the message @mentions the bot — read from the `mentions` array the platform already parsed
|
|
109
|
+
* (never a regex over the text: a pasted `@bot` in a code block is not a mention entry), matched on the
|
|
110
|
+
* bot's open_id (stable identity; names are mutable). No id → fail closed (false): answering "is this
|
|
111
|
+
* mention me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group.
|
|
112
|
+
*/
|
|
113
|
+
export declare function mentionsBot(m: Pick<FeishuMessage, "mentions">, botOpenId: string | undefined): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
|
|
116
|
+
* answer humans only (a non-`user` sender is another bot/app — two bots answering each other loop
|
|
117
|
+
* forever), p2p chats always, a group only on an @mention of THIS bot (matched by open_id, which
|
|
118
|
+
* feishuChannel resolves via bot/v3/info). NOTE the platform side of the same coin: with the default
|
|
119
|
+
* `im:message.group_at_msg` scope, un-mentioned group messages are never even delivered — receiving
|
|
120
|
+
* everything needs the sensitive `im:message.group_msg` scope. Returns `{}` (act; the channel fills
|
|
121
|
+
* session/target/prompt from the message) or `null` (ignore).
|
|
122
|
+
*/
|
|
123
|
+
export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
|
|
124
|
+
botOpenId?: string;
|
|
125
|
+
}): FeishuRoute | null;
|