@fastagent-sh/fastagent 0.19.0 → 0.21.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 +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* Engine-neutral, and living here rather than under `engines/` because of it: reading `channels/*.ts`
|
|
6
6
|
* is the Channel contract plus a directory, with no engine in sight.
|
|
7
7
|
*/
|
|
8
|
-
import { readdir } from "node:fs/promises";
|
|
9
8
|
import { isAbsolute, join } from "node:path";
|
|
10
9
|
import { assertRouteKey, routeKeysConflict } from "./serve.js";
|
|
11
|
-
import {
|
|
10
|
+
import { loadModuleDir, moduleInventory } from "../loader.js";
|
|
12
11
|
import { assertInsideAgentDir } from "../paths.js";
|
|
13
12
|
function longConnectionModule(value) {
|
|
14
13
|
return value !== null && typeof value === "object" && typeof value.connect === "function";
|
|
@@ -18,28 +17,29 @@ function validateLongConnectionModule(value, label) {
|
|
|
18
17
|
throw new Error(`${label}: long-connection channel name must be a non-empty string`);
|
|
19
18
|
}
|
|
20
19
|
}
|
|
20
|
+
/** Declared channels from basenames that share one ingress: the serving surface's mounted route list
|
|
21
|
+
* (a long-connection channel mounts no HTTP route, so every route IS a webhook channel), and fixtures. */
|
|
22
|
+
export function declaredChannels(names, ingress = "webhook") {
|
|
23
|
+
return names.map((name) => ({ name, ingress }));
|
|
24
|
+
}
|
|
21
25
|
/**
|
|
22
26
|
* Import channel files without mounting route factories or opening connections. Deployment needs only
|
|
23
|
-
* the authored structural fact: function exports are
|
|
27
|
+
* the authored structural fact: function exports are webhook channels; `{ connect() }` exports are
|
|
24
28
|
* long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
|
|
25
29
|
*/
|
|
26
30
|
export async function inspectChannels(dir) {
|
|
27
31
|
await assertInsideAgentDir(dir, "channels");
|
|
28
32
|
const { modules, failures } = await loadModuleDir(join(dir, "channels"));
|
|
29
33
|
const channels = [];
|
|
30
|
-
const routeChannels = [];
|
|
31
|
-
const longConnectionChannels = [];
|
|
32
34
|
for (const { name, label, file, mod } of modules) {
|
|
33
35
|
try {
|
|
34
36
|
if (typeof mod.default === "function") {
|
|
35
|
-
channels.push(name);
|
|
36
|
-
routeChannels.push(name);
|
|
37
|
+
channels.push({ name, ingress: "webhook" });
|
|
37
38
|
continue;
|
|
38
39
|
}
|
|
39
40
|
if (longConnectionModule(mod.default)) {
|
|
40
41
|
validateLongConnectionModule(mod.default, label);
|
|
41
|
-
channels.push(name);
|
|
42
|
-
longConnectionChannels.push(name);
|
|
42
|
+
channels.push({ name, ingress: "long-connection" });
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
45
|
throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
|
|
@@ -48,7 +48,7 @@ export async function inspectChannels(dir) {
|
|
|
48
48
|
failures.push({ label, file, message: error.message });
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
return { channels,
|
|
51
|
+
return { channels, failures };
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
|
|
@@ -56,19 +56,8 @@ export async function inspectChannels(dir) {
|
|
|
56
56
|
*/
|
|
57
57
|
export async function discoverChannelFiles(dir) {
|
|
58
58
|
await assertInsideAgentDir(dir, "channels");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
names = await readdir(join(dir, "channels"));
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
if (error.code === "ENOENT")
|
|
65
|
-
return [];
|
|
66
|
-
throw error;
|
|
67
|
-
}
|
|
68
|
-
return names
|
|
69
|
-
.filter(isModuleFile)
|
|
70
|
-
.map((name) => name.replace(/\.(ts|js|mjs)$/, ""))
|
|
71
|
-
.sort();
|
|
59
|
+
const entries = await moduleInventory(join(dir, "channels"));
|
|
60
|
+
return entries.map((entry) => entry.name);
|
|
72
61
|
}
|
|
73
62
|
function validateRoutes(value, label) {
|
|
74
63
|
if (value === null || typeof value !== "object" || value instanceof Map) {
|
|
@@ -96,7 +85,6 @@ export async function loadChannels(dir, ctx) {
|
|
|
96
85
|
const routes = {};
|
|
97
86
|
const longConnections = [];
|
|
98
87
|
const routeChannels = [];
|
|
99
|
-
const longConnectionChannels = [];
|
|
100
88
|
const collisions = [];
|
|
101
89
|
for (const { name, label, file, mod } of modules) {
|
|
102
90
|
try {
|
|
@@ -107,7 +95,6 @@ export async function loadChannels(dir, ctx) {
|
|
|
107
95
|
name: channel.name,
|
|
108
96
|
connect: (signal) => channel.connect(ctx, signal),
|
|
109
97
|
});
|
|
110
|
-
longConnectionChannels.push(name);
|
|
111
98
|
continue;
|
|
112
99
|
}
|
|
113
100
|
if (typeof mod.default !== "function") {
|
|
@@ -135,5 +122,5 @@ export async function loadChannels(dir, ctx) {
|
|
|
135
122
|
failures.push({ label, file, message: error.message });
|
|
136
123
|
}
|
|
137
124
|
}
|
|
138
|
-
return { routes, longConnections, routeChannels,
|
|
125
|
+
return { routes, longConnections, routeChannels, collisions, failures };
|
|
139
126
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
|
|
3
|
+
* ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
|
|
4
|
+
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
|
+
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
|
+
*/
|
|
1
7
|
import { type ContextBuffer } from "../kit/context-buffer.ts";
|
|
2
8
|
import type { NormalizedFeishuMessage } from "./model.ts";
|
|
3
9
|
export interface FeishuBufferedResource {
|
|
@@ -4,9 +4,7 @@
|
|
|
4
4
|
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
5
|
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
6
|
*/
|
|
7
|
-
import { log } from "../../log.js";
|
|
8
7
|
import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
|
|
9
|
-
import { loadStateFile, saveStateFile } from "../kit/state.js";
|
|
10
8
|
import { truncateCodePointPrefix } from "../kit/text.js";
|
|
11
9
|
function bufferLine(entry) {
|
|
12
10
|
const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
|
|
@@ -80,43 +78,7 @@ function isEntry(value) {
|
|
|
80
78
|
resources(entry.files) &&
|
|
81
79
|
resources(entry.images));
|
|
82
80
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Buckets from the pre-participant-model keying (`<chat>:root:<root_id>`) can never be produced again —
|
|
85
|
-
* a place is `<chat>` or `<chat>:thread:<thread_id>` — so nothing could ever fold or clear them, and
|
|
86
|
-
* they would hold chat content on disk forever. Dropped here, before the buffer loads, so the shared
|
|
87
|
-
* kernel never learns about a key shape one channel retired.
|
|
88
|
-
*
|
|
89
|
-
* TWO one-time losses, both accepted and both logged by count. (1) The retired shape covered every
|
|
90
|
-
* thread bucket and every main-chat quoted-reply bucket, so buffered discussion in threads does not
|
|
91
|
-
* survive the upgrade — it becomes unreachable BECAUSE of the re-keying, not before it. (2)
|
|
92
|
-
* `turns.json` persists each in-flight turn's `bufferKey` verbatim and this runs before turn recovery,
|
|
93
|
-
* so a turn spanning the upgrade finds its bucket already gone. Sparing referenced keys would couple
|
|
94
|
-
* the buffer to the turn store to protect a single upgrade, and would not help (1) at all.
|
|
95
|
-
*
|
|
96
|
-
* PERMANENT, unlike the `owned-threads.json` cleanup it otherwise resembles. That one leaves an inert
|
|
97
|
-
* orphan file, so deleting it a release later is free; this one is what stops user chat content
|
|
98
|
-
* lingering, and a deployment that skips from before the model to well after it would never run an
|
|
99
|
-
* expired version of this code. The standing cost is one key scan at load, and nothing when no retired
|
|
100
|
-
* key is present.
|
|
101
|
-
*/
|
|
102
|
-
function dropRetiredBuckets(path, label) {
|
|
103
|
-
const raw = loadStateFile(path);
|
|
104
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
105
|
-
return;
|
|
106
|
-
const live = Object.entries(raw).filter(([placeKey]) => !placeKey.includes(":root:"));
|
|
107
|
-
const dropped = Object.keys(raw).length - live.length;
|
|
108
|
-
if (dropped === 0)
|
|
109
|
-
return;
|
|
110
|
-
log.info(`${label} dropped ${dropped} context bucket(s) with a retired key shape`);
|
|
111
|
-
try {
|
|
112
|
-
saveStateFile(path, Object.fromEntries(live));
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
log.warn(`${label} could not rewrite ${path} after dropping retired buckets: ${String(error)}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
81
|
export function createFeishuContextBuffer(path, label) {
|
|
119
|
-
dropRetiredBuckets(path, label);
|
|
120
82
|
return createGenericContextBuffer({
|
|
121
83
|
path,
|
|
122
84
|
label,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
|
|
2
|
-
export declare function timingSafeEqualStr(a: string, b: string): boolean;
|
|
3
1
|
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
|
|
4
2
|
* input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
|
|
5
3
|
* guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
|
|
@@ -14,13 +14,8 @@
|
|
|
14
14
|
* Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
|
|
15
15
|
* token check leaks a timing signal.
|
|
16
16
|
*/
|
|
17
|
-
import { createDecipheriv, createHash
|
|
18
|
-
|
|
19
|
-
export function timingSafeEqualStr(a, b) {
|
|
20
|
-
const ab = Buffer.from(a);
|
|
21
|
-
const bb = Buffer.from(b);
|
|
22
|
-
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
23
|
-
}
|
|
17
|
+
import { createDecipheriv, createHash } from "node:crypto";
|
|
18
|
+
import { secretEquals } from "../secret.js";
|
|
24
19
|
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
|
|
25
20
|
* input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
|
|
26
21
|
* guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
|
|
@@ -39,5 +34,5 @@ export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
|
|
|
39
34
|
}
|
|
40
35
|
/** Whether a request's signature headers verify against the raw body (constant-time). */
|
|
41
36
|
export function verifySignature(encryptKey, headers, rawBody) {
|
|
42
|
-
return
|
|
37
|
+
return secretEquals(headers.signature, eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody));
|
|
43
38
|
}
|
|
@@ -14,13 +14,11 @@
|
|
|
14
14
|
* 5. Every failure is a {@link FeishuApiError} naming the call; self-description is a property of the
|
|
15
15
|
* error type, not per-call-site string assembly.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* or grows past ~a dozen methods, adopt @larksuiteoapi/node-sdk instead of growing it — the methods
|
|
20
|
-
* here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
|
|
17
|
+
* Typed methods cover messages, cards, resources and app configuration. Proactive tools share this
|
|
18
|
+
* transport through shared-api.ts. The official SDK handles WebSocket ingress in ws-ingress.ts.
|
|
21
19
|
*/
|
|
22
20
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
23
|
-
import {
|
|
21
|
+
import { attachmentPath } from "../kit/attachment-path.js";
|
|
24
22
|
import { utf8Prefix } from "../kit/text.js";
|
|
25
23
|
/** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
|
|
26
24
|
const API_TIMEOUT_MS = 30_000;
|
|
@@ -283,13 +281,10 @@ export function createFeishuApi(opts) {
|
|
|
283
281
|
},
|
|
284
282
|
async fetchFile(messageId, fileKey, name, chatId, filesDir) {
|
|
285
283
|
const { bytes } = await api.downloadResource(messageId, fileKey, "file");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const dest = join(dir, safe);
|
|
291
|
-
await writeFile(dest, bytes);
|
|
292
|
-
return { path: dest, name: safe, size: bytes.byteLength };
|
|
284
|
+
const dest = attachmentPath(filesDir, chatId, name);
|
|
285
|
+
await mkdir(dest.dir, { recursive: true });
|
|
286
|
+
await writeFile(dest.path, bytes);
|
|
287
|
+
return { path: dest.path, name: dest.name, size: bytes.byteLength };
|
|
293
288
|
},
|
|
294
289
|
async getAppConfig(appId) {
|
|
295
290
|
// v6 app detail — the one read surface that returns the event-security material (under data.app).
|
|
@@ -12,15 +12,18 @@ import { isAbsolute, join } from "node:path";
|
|
|
12
12
|
import { log } from "../../log.js";
|
|
13
13
|
import { readBodyCapped } from "../body.js";
|
|
14
14
|
import { text } from "../respond.js";
|
|
15
|
+
import { secretEquals } from "../secret.js";
|
|
15
16
|
import { createSeenRing } from "../kit/seen.js";
|
|
16
17
|
import { createTaskTracker } from "../kit/tasks.js";
|
|
17
18
|
import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
|
|
19
|
+
import { signatureIsFresh } from "../kit/signature.js";
|
|
18
20
|
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
19
|
-
import {
|
|
21
|
+
import { createTurnRunner } from "../kit/turn-runner.js";
|
|
20
22
|
import { createTurnStore } from "../kit/turn-store.js";
|
|
23
|
+
import { discussionBlock } from "../kit/context-buffer.js";
|
|
21
24
|
import { FEISHU_CLOUD } from "./cloud.js";
|
|
22
25
|
import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
|
|
23
|
-
import { decryptEvent,
|
|
26
|
+
import { decryptEvent, verifySignature } from "./crypto.js";
|
|
24
27
|
import { invokeFeishuTurn } from "./invoke-turn.js";
|
|
25
28
|
import { createFeishuApi } from "./feishu-api.js";
|
|
26
29
|
import { normalizeFeishuMessage } from "./normalize.js";
|
|
@@ -29,14 +32,20 @@ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE
|
|
|
29
32
|
import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
|
|
30
33
|
import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
|
|
31
34
|
import { connectFeishuWs } from "./ws-ingress.js";
|
|
35
|
+
import { registerFeishuApi } from "./shared-api.js";
|
|
32
36
|
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
33
37
|
export { defaultFeishuRoute, feishuEnvelope };
|
|
34
|
-
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
35
|
-
* rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
|
|
36
|
-
* at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
|
|
37
|
-
const MAX_TURN_ATTEMPTS = 3;
|
|
38
38
|
/** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
39
39
|
const MAX_EVENT_BYTES = 1 << 20;
|
|
40
|
+
/**
|
|
41
|
+
* Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
|
|
42
|
+
* 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
|
|
43
|
+
* is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
|
|
44
|
+
* to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
|
|
45
|
+
* turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
|
|
46
|
+
* enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
|
|
40
49
|
/** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
|
|
41
50
|
* accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
|
|
42
51
|
* is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
|
|
@@ -72,16 +81,6 @@ export function feishuChannel(opts) {
|
|
|
72
81
|
export function feishuWebSocketChannel(opts) {
|
|
73
82
|
return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
|
|
74
83
|
}
|
|
75
|
-
/** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
|
|
76
|
-
* workspace still passing one would otherwise start fine and silently get different placement AND a
|
|
77
|
-
* different memory boundary — the one breaking change most likely to be hit, and invisible. */
|
|
78
|
-
function rejectRemovedSessionOptions(opts, factoryName) {
|
|
79
|
-
const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
|
|
80
|
-
if (removed.length > 0) {
|
|
81
|
-
throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
|
|
82
|
-
"and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
84
|
function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
86
85
|
const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
|
|
87
86
|
const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
|
|
@@ -220,11 +219,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
220
219
|
});
|
|
221
220
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
222
221
|
// Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
|
|
223
|
-
const sideTasks = createTaskTracker();
|
|
224
|
-
const toStored = (r) => {
|
|
225
|
-
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
226
|
-
return { ...intent, attempts: 0 };
|
|
227
|
-
};
|
|
222
|
+
const sideTasks = createTaskTracker(label);
|
|
228
223
|
const targetOf = (r) => ({
|
|
229
224
|
chatId: r.chatId,
|
|
230
225
|
replyTo: r.replyTo,
|
|
@@ -235,18 +230,28 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
235
230
|
replyTo: r.queueReplyTo,
|
|
236
231
|
replyInThread: r.replyInThread,
|
|
237
232
|
});
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
233
|
+
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
|
|
234
|
+
// not just an operator log line. Take over its queue preview in place if present (else send fresh) —
|
|
235
|
+
// leaving it pinned at "Queued" while sending a separate failure would double-post.
|
|
236
|
+
const notifyDropped = (r) => {
|
|
237
|
+
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
238
|
+
void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
239
|
+
};
|
|
240
|
+
const runner = createTurnRunner({
|
|
244
241
|
label,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
242
|
+
store,
|
|
243
|
+
buffer,
|
|
244
|
+
seen,
|
|
245
|
+
toStored: ({ preview: _live, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
246
|
+
fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, preview: undefined }),
|
|
247
|
+
bufferKey: (rec) => rec.bufferKey,
|
|
248
|
+
where: (rec) => `chat=${rec.chatId}`,
|
|
249
|
+
// Queue feedback: mount that turn's preview early with a queue status. It reply-quotes the exact
|
|
250
|
+
// source message (including p2p), then the runner mutates the SAME card/text into Thinking →
|
|
251
|
+
// final answer. Immediate by default; with an explicit delay it mounts only if the turn is still
|
|
252
|
+
// waiting when the timer fires and is cancelled unsent otherwise. Best-effort and post-ACK: a
|
|
253
|
+
// failed mount is a log line, never a failed event delivery; the turn later mounts its normal
|
|
254
|
+
// preview.
|
|
250
255
|
onQueuedBehind: (rec) => {
|
|
251
256
|
let fired = false;
|
|
252
257
|
let settle = () => { };
|
|
@@ -264,8 +269,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
264
269
|
const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
|
|
265
270
|
if (timer === undefined)
|
|
266
271
|
mount();
|
|
267
|
-
|
|
268
|
-
|
|
272
|
+
return {
|
|
273
|
+
done,
|
|
274
|
+
// A no-op once mounting started — the send is in flight and `done` settles with it.
|
|
269
275
|
cancel: () => {
|
|
270
276
|
if (!fired) {
|
|
271
277
|
if (timer !== undefined)
|
|
@@ -273,109 +279,47 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
273
279
|
settle();
|
|
274
280
|
}
|
|
275
281
|
},
|
|
276
|
-
|
|
277
|
-
});
|
|
282
|
+
};
|
|
278
283
|
},
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
notice?.cancel();
|
|
285
|
-
await notice?.done;
|
|
286
|
-
notices.delete(rec.id);
|
|
287
|
-
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
288
|
-
const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
|
|
289
|
-
if (decision === "exceeded") {
|
|
290
|
-
notifyDropped(rec);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
if (decision === "defer") {
|
|
294
|
-
// Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
|
|
295
|
-
// recall an existing queue preview (the client exposes a confusing tombstone): settle it in
|
|
296
|
-
// place to an honest delayed status. The eventual replay mounts a fresh preview.
|
|
297
|
-
if (rec.preview !== undefined) {
|
|
298
|
-
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)}`));
|
|
299
|
-
}
|
|
300
|
-
return;
|
|
284
|
+
// Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in
|
|
285
|
+
// place to an honest delayed status. The eventual replay mounts a fresh preview.
|
|
286
|
+
onDeferred: (rec) => {
|
|
287
|
+
if (rec.preview !== undefined) {
|
|
288
|
+
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)}`));
|
|
301
289
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// message arriving while the turn runs remains buffered for the next answered turn.
|
|
306
|
-
// ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
|
|
307
|
-
// this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
|
|
308
|
-
// would instead couple otherwise-independent root sessions and require failure rollback.
|
|
309
|
-
const { text: recent, consumed } = buffer.peek(rec.bufferKey);
|
|
290
|
+
},
|
|
291
|
+
notifyDropped,
|
|
292
|
+
execute: (rec, discussion, onCompleted) => {
|
|
310
293
|
// PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
|
|
294
|
+
// ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
|
|
295
|
+
// the thread's snapshot before either commits it. That fan-out loses nothing; claiming by buffer
|
|
296
|
+
// key would instead couple otherwise-independent root sessions and require failure rollback.
|
|
311
297
|
const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
|
|
312
298
|
const roomBlock = room?.text
|
|
313
299
|
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
314
300
|
: "";
|
|
315
|
-
const
|
|
316
|
-
const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
|
|
301
|
+
const prompt = `${roomBlock}${discussionBlock(discussion.text)}${rec.baseText}`;
|
|
317
302
|
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
318
303
|
// attachments win the slots.
|
|
319
|
-
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
304
|
+
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...discussion.consumed], {
|
|
320
305
|
images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
|
|
321
306
|
files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
|
|
322
307
|
});
|
|
323
308
|
// Recorded at ingress (see submit) — never re-derived from the session key, which may be a
|
|
324
309
|
// routed OPAQUE id that only looks like a place key.
|
|
325
310
|
const parentSession = rec.parentSession;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
|
|
335
|
-
// Drop intent first: a crash between these writes may re-fold answered context later,
|
|
336
|
-
// but can never replay this turn after its context was removed.
|
|
337
|
-
store.remove(rec.id);
|
|
338
|
-
buffer.commit(rec.bufferKey, consumed);
|
|
339
|
-
}), api, targetOf(rec), formatError, rec.preview, label);
|
|
340
|
-
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
341
|
-
}
|
|
342
|
-
catch (error) {
|
|
343
|
-
log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
344
|
-
}
|
|
345
|
-
finally {
|
|
346
|
-
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
347
|
-
// never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
|
|
348
|
-
// INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
|
|
349
|
-
// replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
|
|
350
|
-
store.remove(rec.id);
|
|
351
|
-
}
|
|
311
|
+
return streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
312
|
+
api,
|
|
313
|
+
chatId: rec.chatId,
|
|
314
|
+
filesDir: join(stateHome, "files"),
|
|
315
|
+
label,
|
|
316
|
+
appId,
|
|
317
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
318
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, onCompleted), api, targetOf(rec), formatError, rec.preview, label);
|
|
352
319
|
},
|
|
353
320
|
});
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
|
|
357
|
-
const submit = (rec, persist) => {
|
|
358
|
-
if (persist) {
|
|
359
|
-
store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
|
|
360
|
-
seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
|
|
361
|
-
}
|
|
362
|
-
queue.accept(rec);
|
|
363
|
-
};
|
|
364
|
-
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
|
|
365
|
-
// not just an operator log line. Take over its queue preview in place if present (else send fresh) —
|
|
366
|
-
// leaving it pinned at "Queued" while sending a separate failure would double-post.
|
|
367
|
-
const notifyDropped = (r) => {
|
|
368
|
-
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
369
|
-
void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
370
|
-
};
|
|
371
|
-
// Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
|
|
372
|
-
// the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
|
|
373
|
-
const recovered = store.recover();
|
|
374
|
-
if (recovered.length > 0)
|
|
375
|
-
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
376
|
-
let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
|
|
377
|
-
for (const { attempts: _a, ...intent } of recovered)
|
|
378
|
-
submit({ ...intent, preview: undefined }, false);
|
|
321
|
+
registerFeishuApi(stateRoot, kind, api);
|
|
322
|
+
let seqCounter = runner.recover().reduce((max, r) => Math.max(max, r.seq), 0);
|
|
379
323
|
// Who the agent has heard in a thread decides whether a bare message addresses it (participant
|
|
380
324
|
// model §3), and it comes from what this channel observed — see thread-participants.ts.
|
|
381
325
|
let warnedUnidentified = false;
|
|
@@ -523,7 +467,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
523
467
|
const baseText = r.text ?? cloudEnvelope(event, kind);
|
|
524
468
|
if (baseText.trim() === "" && images.length === 0 && files.length === 0)
|
|
525
469
|
return;
|
|
526
|
-
submit({
|
|
470
|
+
runner.submit({
|
|
527
471
|
id: m.message_id,
|
|
528
472
|
seq: ++seqCounter, // arrival order; the turn store replays by it
|
|
529
473
|
session,
|
|
@@ -576,13 +520,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
576
520
|
threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
|
|
577
521
|
}
|
|
578
522
|
};
|
|
579
|
-
return { acceptEvent, turnsIdle: () => Promise.all([
|
|
523
|
+
return { acceptEvent, turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined) };
|
|
580
524
|
};
|
|
581
525
|
}
|
|
582
526
|
function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
583
527
|
const { verificationToken, encryptKey } = opts;
|
|
584
528
|
const { kind, envPrefix } = profile;
|
|
585
529
|
const label = `[${kind}]`;
|
|
530
|
+
if (!encryptKey) {
|
|
531
|
+
// Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
|
|
532
|
+
// carry no signature, so the freshness window below never runs and a captured body replays for
|
|
533
|
+
// as long as the verification token lives.
|
|
534
|
+
log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
|
|
535
|
+
}
|
|
586
536
|
const handler = async (req) => {
|
|
587
537
|
if (req.method !== "POST")
|
|
588
538
|
return text("POST only\n", 405);
|
|
@@ -609,9 +559,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
609
559
|
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
610
560
|
signature: req.headers.get("x-lark-signature") ?? "",
|
|
611
561
|
};
|
|
612
|
-
if (sig.signature
|
|
613
|
-
|
|
614
|
-
|
|
562
|
+
if (sig.signature) {
|
|
563
|
+
// Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
|
|
564
|
+
// it, so without a window a captured body + its three x-lark-* headers replays forever. The
|
|
565
|
+
// `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
|
|
566
|
+
// turn (a re-sent message, a re-fired tool).
|
|
567
|
+
if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
|
|
568
|
+
log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
|
|
569
|
+
return text("stale signature\n", 401);
|
|
570
|
+
}
|
|
571
|
+
if (!verifySignature(encryptKey, sig, body.text)) {
|
|
572
|
+
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
573
|
+
return text("invalid signature\n", 401);
|
|
574
|
+
}
|
|
615
575
|
}
|
|
616
576
|
try {
|
|
617
577
|
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
@@ -639,7 +599,7 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
639
599
|
(typeof envelope.header?.token === "string"
|
|
640
600
|
? envelope.header.token
|
|
641
601
|
: undefined);
|
|
642
|
-
if (!
|
|
602
|
+
if (!secretEquals(token, verificationToken)) {
|
|
643
603
|
log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
|
|
644
604
|
return text("invalid token\n", 401);
|
|
645
605
|
}
|
|
@@ -659,7 +619,6 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
659
619
|
return { [`POST /${kind}`]: handler };
|
|
660
620
|
}
|
|
661
621
|
export function buildFeishuChannel(profile, opts, factoryName) {
|
|
662
|
-
rejectRemovedSessionOptions(opts, factoryName);
|
|
663
622
|
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
664
623
|
return (ctx) => {
|
|
665
624
|
if (!opts.verificationToken) {
|
|
@@ -669,7 +628,6 @@ export function buildFeishuChannel(profile, opts, factoryName) {
|
|
|
669
628
|
};
|
|
670
629
|
}
|
|
671
630
|
export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
|
|
672
|
-
rejectRemovedSessionOptions(opts, factoryName);
|
|
673
631
|
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
674
632
|
return {
|
|
675
633
|
name: `${profile.kind} websocket`,
|
|
@@ -35,9 +35,10 @@ export declare function senderId(sender: FeishuSender | undefined): string | und
|
|
|
35
35
|
*
|
|
36
36
|
* Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
|
|
37
37
|
* every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
|
|
38
|
-
* would answer into the same memory. The length bound is the FILENAME the id becomes (
|
|
39
|
-
*
|
|
40
|
-
* a 36-char thread id — encodes to well
|
|
38
|
+
* would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
|
|
39
|
+
* `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
|
|
40
|
+
* three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
|
|
41
|
+
* under 100 bytes against the filesystem's 255.
|
|
41
42
|
*/
|
|
42
43
|
export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
|
|
43
44
|
/** The canonical Feishu-branded prompt envelope. */
|
|
@@ -30,9 +30,10 @@ export function senderId(sender) {
|
|
|
30
30
|
*
|
|
31
31
|
* Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
|
|
32
32
|
* every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
|
|
33
|
-
* would answer into the same memory. The length bound is the FILENAME the id becomes (
|
|
34
|
-
*
|
|
35
|
-
* a 36-char thread id — encodes to well
|
|
33
|
+
* would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
|
|
34
|
+
* `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
|
|
35
|
+
* three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
|
|
36
|
+
* under 100 bytes against the filesystem's 255.
|
|
36
37
|
*/
|
|
37
38
|
export function placeKey(kind, message) {
|
|
38
39
|
const chat = `${kind}:${message.chat_id}`;
|
|
@@ -54,7 +55,7 @@ export function cloudEnvelope(event, tag) {
|
|
|
54
55
|
from ? `from ${from}` : undefined,
|
|
55
56
|
// The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
|
|
56
57
|
// enters the session transcript, and session inheritance locates a thread's branch point by
|
|
57
|
-
// searching the parent transcript for exactly these ids (scope.branchHints —
|
|
58
|
+
// searching the parent transcript for exactly these ids (scope.branchHints — session-inheritance.ts).
|
|
58
59
|
// Remove it and every thread quietly inherits from the room's present instead of the branch
|
|
59
60
|
// point. It also lets the model name what it is answering in a busy chat.
|
|
60
61
|
`msg ${message.message_id}`,
|