@fastagent-sh/fastagent 0.14.0 → 0.16.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 +16 -6
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +35 -0
- package/dist/channels/context-buffer.js +103 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +15 -13
- package/dist/channels/feishu/context-buffer.js +62 -69
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +24 -3
- package/dist/channels/feishu/feishu.d.ts +27 -27
- package/dist/channels/feishu/feishu.js +318 -207
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +44 -79
- package/dist/channels/feishu/model.d.ts +2 -2
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +22 -8
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +27 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +37 -0
- package/dist/channels/feishu/setup-mode.js +28 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +16 -7
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +36 -3
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +115 -0
- package/dist/channels/preview-kit.js +184 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/parse.d.ts +38 -0
- package/dist/channels/slack/parse.js +124 -0
- package/dist/channels/slack/preview.d.ts +23 -0
- package/dist/channels/slack/preview.js +403 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +57 -0
- package/dist/channels/slack/slack-api.js +399 -0
- package/dist/channels/slack/slack.d.ts +51 -0
- package/dist/channels/slack/slack.js +496 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +22 -15
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +7 -0
- package/dist/channels/tasks.js +25 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +24 -10
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
- package/dist/channels/{feishu/text.js → text.js} +15 -1
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +8 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +30 -0
- package/dist/cli/add-feishu.js +366 -0
- package/dist/cli/add-slack.d.ts +11 -0
- package/dist/cli/add-slack.js +199 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +191 -60
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +563 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +12 -7
- package/dist/cli/commands/deploy.d.ts +3 -1
- package/dist/cli/commands/deploy.js +384 -82
- package/dist/cli/commands/dev.js +34 -26
- package/dist/cli/commands/fire.js +17 -18
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +11 -9
- package/dist/cli/commands/login.js +48 -45
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +102 -39
- package/dist/cli/commands/tool.js +23 -18
- package/dist/cli/fail.d.ts +17 -2
- package/dist/cli/fail.js +25 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +135 -56
- package/dist/cli/serve.d.ts +60 -20
- package/dist/cli/serve.js +252 -47
- package/dist/cli/shared.d.ts +40 -7
- package/dist/cli/shared.js +180 -38
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +3 -3
- package/dist/deploy/docker/plan.js +20 -23
- package/dist/deploy/fly/plan.d.ts +5 -1
- package/dist/deploy/fly/plan.js +39 -26
- package/dist/deploy/fly/run.d.ts +17 -5
- package/dist/deploy/fly/run.js +52 -7
- package/dist/deploy/preflight.d.ts +20 -8
- package/dist/deploy/preflight.js +267 -71
- package/dist/deploy/railway/plan.d.ts +10 -1
- package/dist/deploy/railway/plan.js +53 -20
- package/dist/deploy/railway/run.d.ts +13 -5
- package/dist/deploy/railway/run.js +22 -7
- package/dist/deploy/runner.d.ts +7 -3
- package/dist/deploy/runner.js +9 -3
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +11 -10
- package/dist/dev-supervisor.js +56 -50
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +92 -62
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +20 -55
- package/dist/engines/pi/config.js +49 -121
- package/dist/engines/pi/create.d.ts +30 -10
- package/dist/engines/pi/create.js +33 -26
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +12 -1
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +59 -13
- package/dist/engines/pi/models.js +62 -24
- package/dist/engines/pi/open.d.ts +114 -0
- package/dist/engines/pi/open.js +127 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +10 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +25 -8
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +6 -5
- package/dist/pi.js +4 -3
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +26 -9
- package/dist/scaffold/add-channel.js +175 -40
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +5 -4
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -3
- package/dist/schedule/wakeups.js +19 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +23 -12
- package/package.json +22 -7
- package/dist/channels/feishu/owned-threads.d.ts +0 -11
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-add-feishu.js +0 -223
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
- package/dist/engines/pi/workspace.d.ts +0 -56
- package/dist/engines/pi/workspace.js +0 -72
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical Feishu bot-channel engine:
|
|
3
|
-
*
|
|
2
|
+
* Canonical Feishu bot-channel engine: verified webhook or official-SDK WebSocket → dedup → route →
|
|
3
|
+
* persist → enqueue → stream a live card. Feishu (open.feishu.cn) is the reference cloud. Lark
|
|
4
4
|
* international binds this engine through an explicit compatibility profile because its control plane
|
|
5
5
|
* trails Feishu; protocol reuse does not make Lark the design center.
|
|
6
6
|
*
|
|
7
7
|
* The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
|
|
8
|
-
* workspace may
|
|
9
|
-
*
|
|
8
|
+
* workspace may run both without sharing state. Webhook returns the existing route factory; WebSocket
|
|
9
|
+
* returns an explicit long-connection module. Both feed the same acceptance/turn engine. See docs/feishu.md.
|
|
10
10
|
*/
|
|
11
11
|
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 {
|
|
15
|
+
import { createSeenRing } from "../seen.js";
|
|
16
|
+
import { createTaskTracker } from "../tasks.js";
|
|
17
|
+
import { ensureStateHome, removeRetiredStateFile } from "../state.js";
|
|
18
|
+
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
16
19
|
import { createTurnQueue } from "../turn-queue.js";
|
|
17
20
|
import { createTurnStore } from "../turn-store.js";
|
|
18
21
|
import { FEISHU_CLOUD } from "./cloud.js";
|
|
@@ -21,10 +24,11 @@ import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
|
|
|
21
24
|
import { invokeFeishuTurn } from "./invoke-turn.js";
|
|
22
25
|
import { createFeishuApi } from "./feishu-api.js";
|
|
23
26
|
import { normalizeFeishuMessage } from "./normalize.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
27
|
+
import { createThreadParticipants } from "../thread-participants.js";
|
|
28
|
+
import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
|
|
29
|
+
import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
|
|
26
30
|
import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
|
|
27
|
-
import {
|
|
31
|
+
import { connectFeishuWs } from "./ws-ingress.js";
|
|
28
32
|
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
29
33
|
export { defaultFeishuRoute, feishuEnvelope };
|
|
30
34
|
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
@@ -48,7 +52,7 @@ function isStoredFeishuTurn(t) {
|
|
|
48
52
|
typeof r.seq === "number" &&
|
|
49
53
|
typeof r.session === "string" &&
|
|
50
54
|
typeof r.baseText === "string" &&
|
|
51
|
-
|
|
55
|
+
typeof r.bufferKey === "string" &&
|
|
52
56
|
typeof r.chatId === "string" &&
|
|
53
57
|
(r.replyTo === undefined || typeof r.replyTo === "string") &&
|
|
54
58
|
(r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
|
|
@@ -58,40 +62,66 @@ function isStoredFeishuTurn(t) {
|
|
|
58
62
|
refs(r.files) &&
|
|
59
63
|
typeof r.attempts === "number");
|
|
60
64
|
}
|
|
61
|
-
/** Build the canonical Feishu
|
|
65
|
+
/** Build the canonical Feishu Request-URL webhook channel. */
|
|
62
66
|
export function feishuChannel(opts) {
|
|
63
67
|
return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
|
|
64
68
|
}
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (directMessageSession !== "continuous" && directMessageSession !== "threaded") {
|
|
79
|
-
throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
|
|
80
|
-
}
|
|
81
|
-
if (groupMessageSession !== "continuous" && groupMessageSession !== "threaded") {
|
|
82
|
-
throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
|
|
69
|
+
/** Build the canonical Feishu WebSocket long-connection channel. */
|
|
70
|
+
export function feishuWebSocketChannel(opts) {
|
|
71
|
+
return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
|
|
72
|
+
}
|
|
73
|
+
/** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
|
|
74
|
+
* workspace still passing one would otherwise start fine and silently get different placement AND a
|
|
75
|
+
* different memory boundary — the one breaking change most likely to be hit, and invisible. */
|
|
76
|
+
function rejectRemovedSessionOptions(opts, factoryName) {
|
|
77
|
+
const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
|
|
78
|
+
if (removed.length > 0) {
|
|
79
|
+
throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
|
|
80
|
+
"and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
|
|
83
81
|
}
|
|
84
|
-
|
|
82
|
+
}
|
|
83
|
+
function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
84
|
+
const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
|
|
85
|
+
const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
|
|
86
|
+
const { kind } = profile;
|
|
87
|
+
const label = `[${kind}]`;
|
|
88
|
+
return ({ agent, stateRoot, control }) => {
|
|
89
|
+
// Credential checks run when serving starts, not while the authored module is imported: deployment
|
|
90
|
+
// can inspect the module shape before secrets exist, while serving still fails before ready.
|
|
91
|
+
if (!appId || !appSecret) {
|
|
92
|
+
throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
|
|
93
|
+
}
|
|
85
94
|
const formatError = onError ?? defaultErrorMessage;
|
|
86
95
|
const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
|
|
87
96
|
// One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
|
|
88
|
-
// Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works.
|
|
97
|
+
// Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works. A
|
|
98
|
+
// mention landing in that first moment is buffered as context rather than answered; it is folded
|
|
99
|
+
// into the next answered turn in that place, so the ask is delayed, never lost.
|
|
89
100
|
let botOpenId;
|
|
90
101
|
void api.botInfo().then((me) => {
|
|
91
102
|
botOpenId = me.openId;
|
|
92
103
|
if (!botOpenId)
|
|
93
104
|
log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
|
|
94
105
|
}, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
|
|
106
|
+
void api.listAppScopes().then((scopes) => {
|
|
107
|
+
const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
|
|
108
|
+
if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
|
|
109
|
+
// This scope settles the rule's whole input: it delivers the un-mentioned group messages the
|
|
110
|
+
// channel buffers, which is also what lets it HEAR a thread. Nothing is fetched, so nothing
|
|
111
|
+
// is pending — the only bootstrap left is social, one mention inside a thread. The read scope
|
|
112
|
+
// is a separate, softer dependency, so it is reported separately rather than folded in.
|
|
113
|
+
log.info(`${label} group visibility: context-aware — buffered discussion enabled; bare replies work in a thread once the agent has been mentioned in it`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare replies in the agent's threads + group context buffering are unavailable`);
|
|
117
|
+
}
|
|
118
|
+
// Reported OUTSIDE the branch above: the quoted-message read runs in every chat type and every
|
|
119
|
+
// posture (a p2p thread's opening ask, any quoted @mention in a group), so pairing this warning
|
|
120
|
+
// with the group scope would leave a mention-only deployment silently losing every referent.
|
|
121
|
+
if (!scopeSatisfied(FEISHU_MESSAGE_READ_REQUEST, grantedScope)) {
|
|
122
|
+
log.warn(`${label} ${FEISHU_MESSAGE_READ_SCOPE} is not granted — a message quoted by an ask cannot be read, and degrades to a marker in the prompt`);
|
|
123
|
+
}
|
|
124
|
+
}, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
|
|
95
125
|
const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
|
|
96
126
|
// The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
|
|
97
127
|
// (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
|
|
@@ -100,8 +130,16 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
100
130
|
throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
|
|
101
131
|
}
|
|
102
132
|
const stateHome = join(stateRoot, "channels", kind);
|
|
103
|
-
ensureStateHome(stateHome); //
|
|
104
|
-
|
|
133
|
+
ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
|
|
134
|
+
// The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
|
|
135
|
+
// after the release following the participant model ships — by then no live deployment can still
|
|
136
|
+
// be carrying the file. test/migration-deadline.test.ts fails when due.
|
|
137
|
+
removeRetiredStateFile(stateHome, "owned-threads.json", label);
|
|
138
|
+
const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
|
|
139
|
+
/** This channel's place key for a thread (the shared store is key-agnostic). */
|
|
140
|
+
// The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
|
|
141
|
+
// future re-keying silently split participation from the sessions it is supposed to describe.
|
|
142
|
+
const threadKey = (chatId, threadId) => placeKey(kind, { chat_id: chatId, thread_id: threadId });
|
|
105
143
|
const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
|
|
106
144
|
const store = createTurnStore(join(stateHome, "turns.json"), {
|
|
107
145
|
label,
|
|
@@ -109,6 +147,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
109
147
|
order: (a, b) => a.seq - b.seq,
|
|
110
148
|
});
|
|
111
149
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
150
|
+
// Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
|
|
151
|
+
const sideTasks = createTaskTracker();
|
|
112
152
|
const toStored = (r) => {
|
|
113
153
|
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
114
154
|
return { ...intent, attempts: 0 };
|
|
@@ -226,7 +266,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
226
266
|
// when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
|
|
227
267
|
const submit = (rec, persist) => {
|
|
228
268
|
if (persist) {
|
|
229
|
-
store.add(toStored(rec)); // failed write →
|
|
269
|
+
store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
|
|
230
270
|
seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
|
|
231
271
|
}
|
|
232
272
|
queue.accept(rec);
|
|
@@ -244,122 +284,73 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
244
284
|
if (recovered.length > 0)
|
|
245
285
|
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
246
286
|
let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
|
|
247
|
-
for (const { attempts: _a, ...intent } of recovered)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
const sig = {
|
|
276
|
-
timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
|
|
277
|
-
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
278
|
-
signature: req.headers.get("x-lark-signature") ?? "",
|
|
279
|
-
};
|
|
280
|
-
// Ordinary encrypted events MUST verify the signature over the raw body before decryption.
|
|
281
|
-
// Feishu's documented exception is Request URL verification: its encrypted challenge carries
|
|
282
|
-
// no event-signature headers, so it is decrypted first and admitted ONLY when its type is
|
|
283
|
-
// url_verification; the common constant-time Token check below then authenticates it.
|
|
284
|
-
if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
|
|
285
|
-
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
286
|
-
return text("invalid signature\n", 401);
|
|
287
|
-
}
|
|
288
|
-
try {
|
|
289
|
-
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
290
|
-
}
|
|
291
|
-
catch {
|
|
292
|
-
if (!sig.signature) {
|
|
293
|
-
log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
|
|
294
|
-
return text("invalid encrypted payload\n", 401);
|
|
295
|
-
}
|
|
296
|
-
return text("invalid encrypted payload\n", 400);
|
|
297
|
-
}
|
|
298
|
-
if (!sig.signature && envelope.type !== "url_verification") {
|
|
299
|
-
log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
|
|
300
|
-
return text("invalid signature\n", 401);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
if (encryptKey) {
|
|
305
|
-
// With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
|
|
306
|
-
// mismatch — surfaced in the log): accepting it would let a sender skip the signature.
|
|
307
|
-
log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
|
|
308
|
-
return text("plaintext events not accepted\n", 401);
|
|
309
|
-
}
|
|
310
|
-
envelope = outer;
|
|
311
|
-
}
|
|
312
|
-
// The Verification Token authenticates plaintext mode and the platform-documented unsigned,
|
|
313
|
-
// encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
|
|
314
|
-
// in header.token, while url_verification carries it at the top level. Fail closed when absent.
|
|
315
|
-
const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
|
|
316
|
-
(typeof envelope.header?.token === "string"
|
|
317
|
-
? envelope.header.token
|
|
318
|
-
: undefined);
|
|
319
|
-
if (!token || !timingSafeEqualStr(token, verificationToken)) {
|
|
320
|
-
// Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
|
|
321
|
-
// the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
|
|
322
|
-
log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
|
|
323
|
-
return text("invalid token\n", 401);
|
|
324
|
-
}
|
|
325
|
-
// ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
|
|
326
|
-
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
|
|
327
|
-
// The console fires this when the operator saves the Request URL; without this line a PASSING
|
|
328
|
-
// handshake is invisible and "did the challenge even arrive?" becomes guesswork.
|
|
329
|
-
log.info(`${label} answered the console's url_verification challenge`);
|
|
330
|
-
return Response.json({ challenge: envelope.challenge });
|
|
331
|
-
}
|
|
332
|
-
// ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
|
|
333
|
-
// (a non-2xx would just make the platform retry an event this channel will never act on). ──────
|
|
334
|
-
const header = envelope.header;
|
|
335
|
-
if (header?.event_type !== "im.message.receive_v1") {
|
|
336
|
-
log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
|
|
337
|
-
return new Response(null, { status: 200 });
|
|
287
|
+
for (const { attempts: _a, ...intent } of recovered)
|
|
288
|
+
submit({ ...intent, preview: undefined }, false);
|
|
289
|
+
// Who the agent has heard in a thread decides whether a bare message addresses it (participant
|
|
290
|
+
// model §3), and it comes from what this channel observed — see thread-participants.ts.
|
|
291
|
+
let warnedUnidentified = false;
|
|
292
|
+
/**
|
|
293
|
+
* What this delivery contributes to thread participation, or undefined when it contributes nothing.
|
|
294
|
+
*
|
|
295
|
+
* ONE gate for BOTH writes (the humans observation on the way in, and the `agentSpoke` merge once
|
|
296
|
+
* the turn is durable), and one definition of the synthetic speaker id — hand-written conditions in
|
|
297
|
+
* two places is the drift that has already bitten this branch once. Structural facts only; see
|
|
298
|
+
* thread-participants.ts for why configuration must not appear here.
|
|
299
|
+
*
|
|
300
|
+
* Feishu-specific: p2p is excluded (nothing reads those records), and a custom route may admit a
|
|
301
|
+
* bot the default route filters out — answering one is not participation the summon rule should
|
|
302
|
+
* act on, so such a thread keeps no record and the first human still needs the mention bootstrap.
|
|
303
|
+
*/
|
|
304
|
+
const heardIn = (m, sender) => {
|
|
305
|
+
if (m.chat_type !== "group" || m.thread_id === undefined || sender?.sender_type !== "user")
|
|
306
|
+
return undefined;
|
|
307
|
+
const speakerId = senderId(sender);
|
|
308
|
+
if (speakerId === undefined && !warnedUnidentified) {
|
|
309
|
+
// Once per MOUNT — the flag lives in this channel's closure on purpose: the condition is a
|
|
310
|
+
// property of THIS app's event configuration, so a `feishu` and a `lark` mount must each be
|
|
311
|
+
// able to report it. A per-thread set would instead grow without bound to repeat one fact.
|
|
312
|
+
warnedUnidentified = true;
|
|
313
|
+
log.warn(`${label} human senders arrive with no usable id (first seen in thread ${m.thread_id}) — each counts as a distinct speaker, so affected threads permanently require an @mention until thread-participants.json is deleted`);
|
|
338
314
|
}
|
|
339
|
-
|
|
315
|
+
// A human whose id no tenant flavour carries still SPOKE, and no human may speak unrecorded. A
|
|
316
|
+
// per-MESSAGE synthetic id keeps them distinct; a per-thread one would collapse every human on an
|
|
317
|
+
// id-less tenant into one, which is the direction that barges into a crowd. Its cost is PERMANENT
|
|
318
|
+
// — two such messages fill MAX_HUMANS and records never shed, so that thread needs an @mention
|
|
319
|
+
// from then on and only deleting the file resets it (§3).
|
|
320
|
+
return { key: threadKey(m.chat_id, m.thread_id), speaker: speakerId ?? `unidentified:${m.message_id}` };
|
|
321
|
+
};
|
|
322
|
+
// Transport-neutral acceptance boundary: normalize, route, persist intent/context, enqueue. It
|
|
323
|
+
// touches no network, so it stays synchronous inside the platform's ACK window and the delivery
|
|
324
|
+
// dedup ring alone is enough — there is no await for a duplicate push to race through. The
|
|
325
|
+
// minutes-long Agent turn remains fire-and-forget.
|
|
326
|
+
const acceptEvent = (event) => {
|
|
340
327
|
const m = event.message;
|
|
341
328
|
if (!m?.message_id || !m.chat_id)
|
|
342
|
-
return
|
|
329
|
+
return;
|
|
343
330
|
if (seen.has(m.message_id)) {
|
|
344
331
|
log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
|
|
345
|
-
return
|
|
332
|
+
return;
|
|
346
333
|
}
|
|
347
334
|
let r = decide(event);
|
|
348
335
|
const normalized = normalizeFeishuMessage(event);
|
|
349
336
|
if (!normalized)
|
|
350
|
-
return
|
|
337
|
+
return;
|
|
351
338
|
const bufferKey = feishuBufferPlaceKey(normalized.conversation);
|
|
352
339
|
const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
|
|
353
|
-
|
|
340
|
+
// Listening is not speaking: every message the channel can see refines who takes part in its
|
|
341
|
+
// thread, whether or not it is answered. The sender counts toward the rule immediately — a
|
|
342
|
+
// second human speaking is exactly what makes addressing ambiguous again.
|
|
343
|
+
const heard = heardIn(m, event.sender);
|
|
344
|
+
if (heard)
|
|
345
|
+
threadParticipants.merge(heard.key, { humans: [heard.speaker] });
|
|
346
|
+
if (!r &&
|
|
347
|
+
route === undefined &&
|
|
354
348
|
isHumanGroup &&
|
|
355
349
|
m.thread_id !== undefined &&
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// In an Agent-created thread, a bare user continuation still summons. Any explicit mention changes
|
|
359
|
-
// that intent: only defaultFeishuRoute's structural @THIS-bot match summons; @other-only discussion
|
|
360
|
-
// is buffered like unsummoned group context. A custom route remains fully authoritative.
|
|
361
|
-
if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
|
|
350
|
+
!normalized.content.hasMentions &&
|
|
351
|
+
threadParticipants.admitsBareMessage(threadKey(m.chat_id, m.thread_id))) {
|
|
362
352
|
r = {};
|
|
353
|
+
}
|
|
363
354
|
if (!r) {
|
|
364
355
|
if (route === undefined && isHumanGroup) {
|
|
365
356
|
const bodyText = feishuBufferText(normalized.content.text);
|
|
@@ -375,7 +366,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
375
366
|
key: resource.key,
|
|
376
367
|
name: resource.name,
|
|
377
368
|
}));
|
|
378
|
-
//
|
|
369
|
+
// A write failure escapes this boundary. HTTP turns it into a 500 response; the official WS
|
|
370
|
+
// SDK turns it into a 500 ACK frame. Both transports therefore ask the platform to re-push.
|
|
379
371
|
buffer.push(bufferKey, {
|
|
380
372
|
sender: senderLabel(event.sender) ?? "someone",
|
|
381
373
|
body: bodyText,
|
|
@@ -384,7 +376,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
384
376
|
files: files.length ? files : undefined,
|
|
385
377
|
images: images.length ? images : undefined,
|
|
386
378
|
});
|
|
387
|
-
seen.add(m.message_id);
|
|
379
|
+
seen.add(m.message_id);
|
|
388
380
|
log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
|
|
389
381
|
}
|
|
390
382
|
else {
|
|
@@ -394,79 +386,198 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
|
|
|
394
386
|
else {
|
|
395
387
|
log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
|
|
396
388
|
}
|
|
397
|
-
return
|
|
389
|
+
return;
|
|
398
390
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
391
|
+
// Memory follows the place (participant model §5): one session per chat, and one per thread.
|
|
392
|
+
// Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
|
|
393
|
+
// differ between messages of ONE thread, which would split a side conversation in two.
|
|
394
|
+
const session = r.session ?? placeKey(kind, m);
|
|
395
|
+
const chatId = r.chatId ?? m.chat_id;
|
|
396
|
+
const sameTarget = chatId === m.chat_id;
|
|
397
|
+
// Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
|
|
398
|
+
// stay plain in an ordinary direct message, and stay inside a thread whenever the question came
|
|
399
|
+
// from one — a direct message's thread is a place too, and relocating out of it is the silent
|
|
400
|
+
// move the model refuses.
|
|
401
|
+
const replyTo = sameTarget && (m.chat_type === "group" || m.thread_id !== undefined) ? m.message_id : undefined;
|
|
402
|
+
const replyInThread = replyTo !== undefined && m.thread_id !== undefined ? true : undefined;
|
|
403
|
+
const queueReplyTo = sameTarget ? m.message_id : undefined;
|
|
404
|
+
// Explicit user stop: a control action, never a turn — it must not queue behind the run it
|
|
405
|
+
// stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
|
|
406
|
+
// message id so a platform re-push doesn't double-abort or double-notify.
|
|
407
|
+
if (isStopText(normalized.content.text.replace(/@\S+/g, " "))) {
|
|
408
|
+
seen.add(m.message_id);
|
|
409
|
+
sideTasks.track(dispatchStop(control, session, label)
|
|
410
|
+
.then((feedback) => api.sendText({ chatId, replyTo, replyInThread }, feedback).then(() => undefined))
|
|
411
|
+
.catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const resources = normalized.content.resources;
|
|
415
|
+
const images = resources
|
|
416
|
+
.filter((resource) => resource.kind === "image")
|
|
417
|
+
.map((resource) => ({ msg: resource.messageId, key: resource.key }));
|
|
418
|
+
const files = resources
|
|
419
|
+
.filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
|
|
420
|
+
.map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
|
|
421
|
+
const baseText = r.text ?? cloudEnvelope(event, kind);
|
|
422
|
+
if (baseText.trim() === "" && images.length === 0 && files.length === 0)
|
|
423
|
+
return;
|
|
424
|
+
submit({
|
|
425
|
+
id: m.message_id,
|
|
426
|
+
seq: ++seqCounter, // arrival order; the turn store replays by it
|
|
427
|
+
session,
|
|
428
|
+
baseText,
|
|
429
|
+
bufferKey,
|
|
430
|
+
chatId,
|
|
431
|
+
replyTo,
|
|
432
|
+
queueReplyTo,
|
|
433
|
+
replyInThread,
|
|
434
|
+
// A quote is the user explicitly pointing at something that may predate this session
|
|
435
|
+
// (§8 rung 2), so it is always loaded.
|
|
436
|
+
//
|
|
437
|
+
// There used to be an exception: skip it inside a thread the agent had already answered in,
|
|
438
|
+
// since the session would hold it. That needed a second fact — did the channel RECEIVE the
|
|
439
|
+
// messages in between? — which depends on a scope that changes over time, while the record it
|
|
440
|
+
// was read against is durable. Every attempt to gate it correctly failed in the same
|
|
441
|
+
// direction (a silently missing quote), for an optimisation worth one `getMessage` on a
|
|
442
|
+
// quote-reply inside an active thread. Loading it costs a call and some text the session may
|
|
443
|
+
// already have; it also pins WHICH message is being answered, which a long thread benefits
|
|
444
|
+
// from anyway.
|
|
445
|
+
parentId: m.parent_id,
|
|
446
|
+
images,
|
|
447
|
+
files,
|
|
448
|
+
}, true);
|
|
449
|
+
// Answering inside a thread makes the agent a participant of it, which is what lets the NEXT
|
|
450
|
+
// bare message address it without a mention (§3).
|
|
451
|
+
//
|
|
452
|
+
// `r.session === undefined` is what makes this fact mean what its reader assumes. Participation
|
|
453
|
+
// is keyed by THREAD while the memory it stands in for is keyed by SESSION, and those agree only
|
|
454
|
+
// when the session is derived from the place. A route supplying its own (the scaffold's
|
|
455
|
+
// `session: user:<open_id>` example) can put two people's turns in one thread into different
|
|
456
|
+
// sessions — recording `agentSpoke` from one of them would tell the summon rule the agent took
|
|
457
|
+
// part in a conversation it cannot remember. So the flag records "the agent answered into THIS
|
|
458
|
+
// THREAD'S session". Such a thread keeps a bystander record and needs the ordinary mention to
|
|
459
|
+
// bootstrap if the route is later dropped. `group` matches the observation above so the record is
|
|
460
|
+
// never half-written.
|
|
461
|
+
//
|
|
462
|
+
// Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
|
|
463
|
+
// the thread as the agent has actually left it. A later delivery failure does not undo it —
|
|
464
|
+
// entering the conversation is the intent, not the send.
|
|
465
|
+
// Reuses `heardIn` from the way in, so both writes share ONE gate by construction. The extra
|
|
466
|
+
// conditions are about the ANSWER, not the speaker: it must land in this thread (`sameTarget`,
|
|
467
|
+
// `replyInThread`) and in the place's own session, or the flag would claim a memory that never
|
|
468
|
+
// held the turn.
|
|
469
|
+
if (heard && replyInThread === true && sameTarget && r.session === undefined) {
|
|
470
|
+
// Both halves in ONE merge, like Slack's: a record that needed an earlier merge to survive
|
|
471
|
+
// could otherwise say "answered here, heard nobody" — which admits bare messages forever.
|
|
472
|
+
threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
479
|
+
const { verificationToken, encryptKey } = opts;
|
|
480
|
+
const { kind, envPrefix } = profile;
|
|
481
|
+
const label = `[${kind}]`;
|
|
482
|
+
const handler = async (req) => {
|
|
483
|
+
if (req.method !== "POST")
|
|
484
|
+
return text("POST only\n", 405);
|
|
485
|
+
const body = await readBodyCapped(req, MAX_EVENT_BYTES);
|
|
486
|
+
if ("tooLarge" in body)
|
|
487
|
+
return text("payload too large\n", 413);
|
|
488
|
+
let outer;
|
|
489
|
+
try {
|
|
490
|
+
outer = JSON.parse(body.text);
|
|
491
|
+
if (typeof outer !== "object" || outer === null)
|
|
492
|
+
throw new Error("not an object");
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return text("invalid json\n", 400);
|
|
496
|
+
}
|
|
497
|
+
let envelope;
|
|
498
|
+
if (typeof outer.encrypt === "string") {
|
|
499
|
+
if (!encryptKey) {
|
|
500
|
+
log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
|
|
501
|
+
return text("encrypt key not configured\n", 400);
|
|
502
|
+
}
|
|
503
|
+
const sig = {
|
|
504
|
+
timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
|
|
505
|
+
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
506
|
+
signature: req.headers.get("x-lark-signature") ?? "",
|
|
507
|
+
};
|
|
508
|
+
if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
|
|
509
|
+
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
510
|
+
return text("invalid signature\n", 401);
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
if (!sig.signature) {
|
|
517
|
+
log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
|
|
518
|
+
return text("invalid encrypted payload\n", 401);
|
|
462
519
|
}
|
|
520
|
+
return text("invalid encrypted payload\n", 400);
|
|
521
|
+
}
|
|
522
|
+
if (!sig.signature && envelope.type !== "url_verification") {
|
|
523
|
+
log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
|
|
524
|
+
return text("invalid signature\n", 401);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
if (encryptKey) {
|
|
529
|
+
log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
|
|
530
|
+
return text("plaintext events not accepted\n", 401);
|
|
463
531
|
}
|
|
464
|
-
|
|
465
|
-
|
|
532
|
+
envelope = outer;
|
|
533
|
+
}
|
|
534
|
+
const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
|
|
535
|
+
(typeof envelope.header?.token === "string"
|
|
536
|
+
? envelope.header.token
|
|
537
|
+
: undefined);
|
|
538
|
+
if (!token || !timingSafeEqualStr(token, verificationToken)) {
|
|
539
|
+
log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
|
|
540
|
+
return text("invalid token\n", 401);
|
|
541
|
+
}
|
|
542
|
+
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
|
|
543
|
+
log.info(`${label} answered the console's url_verification challenge`);
|
|
544
|
+
return Response.json({ challenge: envelope.challenge });
|
|
545
|
+
}
|
|
546
|
+
const header = envelope.header;
|
|
547
|
+
if (header?.event_type !== "im.message.receive_v1") {
|
|
548
|
+
log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
|
|
466
549
|
return new Response(null, { status: 200 });
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
550
|
+
}
|
|
551
|
+
runtime.acceptEvent((envelope.event ?? {}));
|
|
552
|
+
return new Response(null, { status: 200 });
|
|
553
|
+
};
|
|
554
|
+
handler.turnsIdle = runtime.turnsIdle;
|
|
555
|
+
return { [`POST /${kind}`]: handler };
|
|
556
|
+
}
|
|
557
|
+
export function buildFeishuChannel(profile, opts, factoryName) {
|
|
558
|
+
rejectRemovedSessionOptions(opts, factoryName);
|
|
559
|
+
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
560
|
+
return (ctx) => {
|
|
561
|
+
if (!opts.verificationToken) {
|
|
562
|
+
throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks)`);
|
|
563
|
+
}
|
|
564
|
+
return createFeishuWebhookRoutes(profile, opts, createRuntime(ctx));
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
|
|
568
|
+
rejectRemovedSessionOptions(opts, factoryName);
|
|
569
|
+
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
570
|
+
return {
|
|
571
|
+
name: `${profile.kind} websocket`,
|
|
572
|
+
connect(ctx, signal) {
|
|
573
|
+
const runtime = createRuntime(ctx);
|
|
574
|
+
return (deps.connectWs ?? connectFeishuWs)({
|
|
575
|
+
kind: profile.kind,
|
|
576
|
+
appId: opts.appId,
|
|
577
|
+
appSecret: opts.appSecret,
|
|
578
|
+
domain: opts.apiBaseUrl ?? profile.apiBase,
|
|
579
|
+
onEvent: runtime.acceptEvent,
|
|
580
|
+
}, signal);
|
|
581
|
+
},
|
|
471
582
|
};
|
|
472
583
|
}
|