@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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { log } from "../../log.js";
|
|
5
|
+
import { readBodyCapped } from "../body.js";
|
|
6
|
+
import { text } from "../respond.js";
|
|
7
|
+
import { createSeenRing } from "../seen.js";
|
|
8
|
+
import { createThreadParticipants } from "../thread-participants.js";
|
|
9
|
+
import { createTaskTracker } from "../tasks.js";
|
|
10
|
+
import { ensureStateHome, removeRetiredStateFile } from "../state.js";
|
|
11
|
+
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
12
|
+
import { codePointPrefix } from "../text.js";
|
|
13
|
+
import { createTurnQueue } from "../turn-queue.js";
|
|
14
|
+
import { createTurnStore } from "../turn-store.js";
|
|
15
|
+
import { createSlackBotTokenProvider } from "./bot-auth.js";
|
|
16
|
+
import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
|
|
17
|
+
import { invokeSlackTurn } from "./invoke-turn.js";
|
|
18
|
+
import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
|
|
19
|
+
import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
|
|
20
|
+
import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
|
|
21
|
+
import { createSlackApi } from "./slack-api.js";
|
|
22
|
+
import { createWelcomedUsers } from "./welcomed.js";
|
|
23
|
+
export { defaultSlackRoute, slackEnvelope };
|
|
24
|
+
const MAX_EVENT_BYTES = 1 << 20;
|
|
25
|
+
const MAX_TURN_ATTEMPTS = 3;
|
|
26
|
+
const MAX_SIGNATURE_AGE_S = 5 * 60;
|
|
27
|
+
const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
|
|
28
|
+
const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
|
|
29
|
+
const DEFAULT_WELCOME = "👋 Hi! I'm an AI agent here to help. Ask a question or describe a task and I'll get to work.";
|
|
30
|
+
function isStoredSlackTurn(value) {
|
|
31
|
+
const turn = value;
|
|
32
|
+
return (typeof turn?.id === "string" &&
|
|
33
|
+
typeof turn.seq === "number" &&
|
|
34
|
+
typeof turn.session === "string" &&
|
|
35
|
+
typeof turn.baseText === "string" &&
|
|
36
|
+
typeof turn.bufferKey === "string" &&
|
|
37
|
+
typeof turn.teamId === "string" &&
|
|
38
|
+
typeof turn.channelId === "string" &&
|
|
39
|
+
(turn.threadTs === undefined || typeof turn.threadTs === "string") &&
|
|
40
|
+
(turn.requesterUserId === undefined || typeof turn.requesterUserId === "string") &&
|
|
41
|
+
(turn.threadTitle === undefined || typeof turn.threadTitle === "string") &&
|
|
42
|
+
Array.isArray(turn.fileIds) &&
|
|
43
|
+
turn.fileIds.every((id) => typeof id === "string") &&
|
|
44
|
+
typeof turn.attempts === "number");
|
|
45
|
+
}
|
|
46
|
+
/** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
|
|
47
|
+
export function verifySlackSignature(signingSecret, timestamp, signature, rawBody, nowMs = Date.now()) {
|
|
48
|
+
if (!/^\d+$/.test(timestamp) || !/^v0=[a-f0-9]{64}$/i.test(signature))
|
|
49
|
+
return false;
|
|
50
|
+
const seconds = Number(timestamp);
|
|
51
|
+
if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(nowMs / 1000) - seconds) > MAX_SIGNATURE_AGE_S)
|
|
52
|
+
return false;
|
|
53
|
+
const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
|
|
54
|
+
const actualBytes = Buffer.from(signature);
|
|
55
|
+
const expectedBytes = Buffer.from(expected);
|
|
56
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
57
|
+
}
|
|
58
|
+
/** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
|
|
59
|
+
* (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
|
|
60
|
+
function messageRefOf(turnId) {
|
|
61
|
+
const parts = turnId.split(":");
|
|
62
|
+
return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
|
|
63
|
+
}
|
|
64
|
+
export function slackChannel(options) {
|
|
65
|
+
const { botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, groupBehavior = "context", rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
|
|
66
|
+
// The participant model derives placement instead of selecting it: Slack has no quote primitive, so
|
|
67
|
+
// answering in place means answering in a thread on the ask, whichever renderer draws it. An
|
|
68
|
+
// upgraded workspace still passing one of the removed modes would otherwise start fine and silently
|
|
69
|
+
// get a different placement AND a different memory boundary.
|
|
70
|
+
const removedModes = ["directMessageSession", "groupMessageSession"].filter((name) => options[name] !== undefined);
|
|
71
|
+
if (removedModes.length > 0) {
|
|
72
|
+
throw new Error(`slackChannel no longer accepts ${removedModes.join(" / ")}: an answer goes in a thread on the ask, and ` +
|
|
73
|
+
"that thread is the session — see docs/design/participant-model.md");
|
|
74
|
+
}
|
|
75
|
+
if (!["context", "mentions"].includes(groupBehavior)) {
|
|
76
|
+
throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
|
|
77
|
+
}
|
|
78
|
+
if (!["native", "classic"].includes(rendering)) {
|
|
79
|
+
throw new Error('slackChannel rendering must be "native" or "classic"');
|
|
80
|
+
}
|
|
81
|
+
if (welcome !== false && typeof welcome !== "string") {
|
|
82
|
+
throw new Error("slackChannel welcome must be a string or false");
|
|
83
|
+
}
|
|
84
|
+
const reactionEmojis = resolveReactionEmojis(reactionAck);
|
|
85
|
+
return ({ agent, stateRoot, control }) => {
|
|
86
|
+
if (!botToken)
|
|
87
|
+
throw new Error("slackChannel requires a non-empty botToken (Bot User OAuth Token)");
|
|
88
|
+
if (!signingSecret)
|
|
89
|
+
throw new Error("slackChannel requires a non-empty signingSecret (Basic Information → App Credentials)");
|
|
90
|
+
if (!isAbsolute(stateRoot))
|
|
91
|
+
throw new Error(`slackChannel requires an absolute ctx.stateRoot, got "${stateRoot}"`);
|
|
92
|
+
const label = "[slack]";
|
|
93
|
+
const formatError = onError ?? defaultErrorMessage;
|
|
94
|
+
const stateHome = join(stateRoot, "channels", "slack");
|
|
95
|
+
ensureStateHome(stateHome);
|
|
96
|
+
const currentBotToken = createSlackBotTokenProvider({
|
|
97
|
+
statePath: join(stateHome, "bot-auth.json"),
|
|
98
|
+
botToken,
|
|
99
|
+
botRefreshToken,
|
|
100
|
+
clientId,
|
|
101
|
+
clientSecret,
|
|
102
|
+
botTokenExpiresAt,
|
|
103
|
+
apiBaseUrl,
|
|
104
|
+
});
|
|
105
|
+
const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
|
|
106
|
+
let authenticatedTeamId;
|
|
107
|
+
let botUserId;
|
|
108
|
+
let authenticationState = "pending";
|
|
109
|
+
const authentication = api.authTest().then((identity) => {
|
|
110
|
+
authenticatedTeamId = identity.teamId;
|
|
111
|
+
botUserId = identity.userId;
|
|
112
|
+
authenticationState = "ready";
|
|
113
|
+
log.info(`${label} authenticated${identity.teamId ? ` for workspace ${identity.teamId}` : ""}`);
|
|
114
|
+
}, (error) => {
|
|
115
|
+
authenticationState = "failed";
|
|
116
|
+
throw new Error(`Slack auth.test failed — fix SLACK_BOT_TOKEN before accepting events: ${String(error)}`, {
|
|
117
|
+
cause: error,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
// Construction cannot await Web API IO. Keep the rejection observed; recovered turns and ingress
|
|
121
|
+
// both gate on the same promise/state below and surface the failure without ACKing new work.
|
|
122
|
+
void authentication.catch((error) => log.error(`${label} ${String(error)}`));
|
|
123
|
+
const waitForAuthentication = () => {
|
|
124
|
+
if (authenticationState !== "pending")
|
|
125
|
+
return authentication;
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const timeout = setTimeout(() => reject(new Error("Slack auth.test did not finish inside the Events API ACK budget")), 2_000);
|
|
128
|
+
void authentication.then(() => {
|
|
129
|
+
clearTimeout(timeout);
|
|
130
|
+
resolve();
|
|
131
|
+
}, (error) => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
reject(error);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
138
|
+
const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
|
|
139
|
+
/** A thread's participation is keyed by the SESSION it describes — "the agent answered here" is a
|
|
140
|
+
* claim about a memory, so the two must not be re-keyable independently. What keeps the claim true
|
|
141
|
+
* under a custom route is the `routed.session === undefined` condition on the write, not the
|
|
142
|
+
* absence of a route: a route that supplies its own session records nothing here. */
|
|
143
|
+
const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
|
|
144
|
+
// The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
|
|
145
|
+
// after the release following the participant model ships — by then no live deployment can still
|
|
146
|
+
// be carrying the file. test/migration-deadline.test.ts fails when due.
|
|
147
|
+
removeRetiredStateFile(stateHome, "owned-threads.json", label);
|
|
148
|
+
const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
|
|
149
|
+
const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
|
|
150
|
+
const store = createTurnStore(join(stateHome, "turns.json"), {
|
|
151
|
+
label,
|
|
152
|
+
isRecord: isStoredSlackTurn,
|
|
153
|
+
order: (a, b) => a.seq - b.seq,
|
|
154
|
+
});
|
|
155
|
+
const decide = route ?? defaultSlackRoute;
|
|
156
|
+
const toStored = (turn) => {
|
|
157
|
+
const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
|
|
158
|
+
return { ...intent, attempts: 0 };
|
|
159
|
+
};
|
|
160
|
+
const targetOf = (turn) => ({
|
|
161
|
+
channelId: turn.channelId,
|
|
162
|
+
threadTs: turn.threadTs,
|
|
163
|
+
recipientUserId: turn.requesterUserId,
|
|
164
|
+
recipientTeamId: turn.teamId,
|
|
165
|
+
});
|
|
166
|
+
const notices = new Map();
|
|
167
|
+
const queue = createTurnQueue({
|
|
168
|
+
label,
|
|
169
|
+
onQueuedBehind(turn) {
|
|
170
|
+
const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
|
|
171
|
+
if (nativeDmStatus)
|
|
172
|
+
turn.nativeQueueStatus = true;
|
|
173
|
+
notices.set(turn.id, authentication
|
|
174
|
+
.then(() => nativeDmStatus
|
|
175
|
+
? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
|
|
176
|
+
: api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
|
|
177
|
+
turn.previewTs = ts;
|
|
178
|
+
}))
|
|
179
|
+
.catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)));
|
|
180
|
+
},
|
|
181
|
+
run: async (turn) => {
|
|
182
|
+
await notices.get(turn.id);
|
|
183
|
+
notices.delete(turn.id);
|
|
184
|
+
try {
|
|
185
|
+
await authentication;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// Leave the intent untouched. A fixed token + restart replays it; running now would execute an
|
|
189
|
+
// Agent turn whose only customer-facing transport is known to be unavailable.
|
|
190
|
+
log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const attempt = store.startAttempt(turn.id, MAX_TURN_ATTEMPTS);
|
|
194
|
+
if (attempt === "exceeded") {
|
|
195
|
+
notifyDropped(turn);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (attempt === "defer") {
|
|
199
|
+
if (turn.nativeQueueStatus) {
|
|
200
|
+
void api
|
|
201
|
+
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
202
|
+
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
203
|
+
}
|
|
204
|
+
else if (turn.previewTs) {
|
|
205
|
+
void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
log.info(`${label} turn start: turn=${turn.id} session=${turn.session} channel=${turn.channelId}`);
|
|
211
|
+
const { text: recent, consumed } = buffer.peek(turn.bufferKey);
|
|
212
|
+
const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${turn.baseText}` : turn.baseText;
|
|
213
|
+
const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
|
|
214
|
+
const messageRef = messageRefOf(turn.id);
|
|
215
|
+
const reaction = reactionEmojis && messageRef
|
|
216
|
+
? await startSlackReaction({
|
|
217
|
+
api,
|
|
218
|
+
channelId: messageRef.channelId,
|
|
219
|
+
ts: messageRef.ts,
|
|
220
|
+
emojis: reactionEmojis,
|
|
221
|
+
label,
|
|
222
|
+
})
|
|
223
|
+
: undefined;
|
|
224
|
+
try {
|
|
225
|
+
await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => {
|
|
226
|
+
store.remove(turn.id);
|
|
227
|
+
buffer.commit(turn.bufferKey, consumed);
|
|
228
|
+
}), api, targetOf(turn), formatError, {
|
|
229
|
+
rendering,
|
|
230
|
+
initialPreviewTs: turn.previewTs,
|
|
231
|
+
threadTitle: turn.threadTitle,
|
|
232
|
+
disclaimer: aiDisclaimer,
|
|
233
|
+
label,
|
|
234
|
+
});
|
|
235
|
+
log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
|
|
236
|
+
await reaction?.complete();
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
|
|
240
|
+
await reaction?.remove();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
store.remove(turn.id);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const notifyDropped = (turn) => {
|
|
248
|
+
const target = targetOf(turn);
|
|
249
|
+
if (turn.nativeQueueStatus)
|
|
250
|
+
void api.setThreadStatus(target, "").catch(() => { });
|
|
251
|
+
void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
|
|
252
|
+
};
|
|
253
|
+
const submit = (turn, persist) => {
|
|
254
|
+
if (persist) {
|
|
255
|
+
store.add(toStored(turn));
|
|
256
|
+
seen.add(turn.id);
|
|
257
|
+
}
|
|
258
|
+
queue.accept(turn);
|
|
259
|
+
};
|
|
260
|
+
const recovered = store.recover();
|
|
261
|
+
if (recovered.length)
|
|
262
|
+
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
263
|
+
let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
264
|
+
for (const { attempts: _attempts, ...intent } of recovered)
|
|
265
|
+
submit({ ...intent }, false);
|
|
266
|
+
// Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
|
|
267
|
+
// delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
|
|
268
|
+
// through. The minutes-long Agent turn remains fire-and-forget.
|
|
269
|
+
const acceptEvent = (envelope) => {
|
|
270
|
+
const event = envelope.event;
|
|
271
|
+
if (!isSlackHumanMessage(event))
|
|
272
|
+
return;
|
|
273
|
+
if (botUserId && event.user === botUserId)
|
|
274
|
+
return;
|
|
275
|
+
const teamId = slackTeamId(envelope) ?? authenticatedTeamId;
|
|
276
|
+
if (!teamId) {
|
|
277
|
+
log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const logicalId = `${teamId}:${event.channel}:${event.ts}`;
|
|
281
|
+
if (seen.has(logicalId)) {
|
|
282
|
+
log.debug(`${label} duplicate logical message ${logicalId} — skipping`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const group = isSlackGroupMessage(event);
|
|
286
|
+
const direct = isSlackDirectMessage(event);
|
|
287
|
+
const rootTs = event.thread_ts ?? event.ts;
|
|
288
|
+
const bufferKey = slackPlaceKey(teamId, event);
|
|
289
|
+
// Listening is not speaking: every message the channel can see refines who takes part in its
|
|
290
|
+
// thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
|
|
291
|
+
// are known — this channel answering.
|
|
292
|
+
// Structural facts only, never `groupBehavior` or `route` — see thread-participants.ts. Slack
|
|
293
|
+
// adds no delta of its own here; its summon rule is the only consumer.
|
|
294
|
+
if (group && event.thread_ts !== undefined) {
|
|
295
|
+
threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
|
|
296
|
+
}
|
|
297
|
+
let routed = decide(envelope);
|
|
298
|
+
// Two different questions (see parse.ts). `addressesSomeone` is the "@-mentions only other people
|
|
299
|
+
// is discussion, never an ask" guard (§3) and counts broadcasts and user groups too;
|
|
300
|
+
// `mightBeTheBot` gates the identity-window deferral below, where only a USER mention qualifies.
|
|
301
|
+
const addressesSomeone = hasSlackMention(event.text ?? "");
|
|
302
|
+
const mightBeTheBot = hasSlackUserMention(event.text ?? "");
|
|
303
|
+
const structurallyMentionsBot = botUserId !== undefined && mentionsSlackUser(event.text ?? "", botUserId);
|
|
304
|
+
// app_mention and message.* subscriptions can overlap. If message.* arrives first, structural bot
|
|
305
|
+
// identity routes it now; while auth.test is still unresolved, defer any mentioned message rather
|
|
306
|
+
// than buffer+dedup it and accidentally suppress the later app_mention callback.
|
|
307
|
+
if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
|
|
308
|
+
routed = {};
|
|
309
|
+
// The participant model's thread rule (§3): a bare message reaches the agent while it takes part
|
|
310
|
+
// and has not heard a second human. Note the ORDER — the mention guard below runs first and is
|
|
311
|
+
// structural: it reads who THIS message addresses, which is the one thing an observation-only
|
|
312
|
+
// store cannot know about someone it has never heard.
|
|
313
|
+
if (!routed &&
|
|
314
|
+
groupBehavior === "context" &&
|
|
315
|
+
route === undefined &&
|
|
316
|
+
group &&
|
|
317
|
+
event.thread_ts !== undefined &&
|
|
318
|
+
event.type !== "app_mention" &&
|
|
319
|
+
// Mentioning only other people is targeted discussion, never an ask (§3) — the same guard
|
|
320
|
+
// Feishu applies with `hasMentions`.
|
|
321
|
+
!addressesSomeone &&
|
|
322
|
+
threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
|
|
323
|
+
routed = {};
|
|
324
|
+
}
|
|
325
|
+
if (!routed) {
|
|
326
|
+
if (route === undefined && group && botUserId === undefined && mightBeTheBot)
|
|
327
|
+
return;
|
|
328
|
+
if (groupBehavior === "context" && route === undefined && group) {
|
|
329
|
+
const body = slackBufferText(slackMessageText(event));
|
|
330
|
+
if (body) {
|
|
331
|
+
const fileIds = slackFileIds(event);
|
|
332
|
+
buffer.push(bufferKey, {
|
|
333
|
+
sender: slackSenderLabel(event),
|
|
334
|
+
body,
|
|
335
|
+
messageId: event.ts,
|
|
336
|
+
replyTo: event.thread_ts,
|
|
337
|
+
fileIds: fileIds.length ? fileIds : undefined,
|
|
338
|
+
});
|
|
339
|
+
seen.add(logicalId);
|
|
340
|
+
log.debug(`${label} buffered unsummoned group message ${logicalId} (place ${bufferKey})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const targetChannel = routed.channelId ?? event.channel;
|
|
346
|
+
const sameChannel = targetChannel === event.channel;
|
|
347
|
+
// Answer where asked (participant model §4). Slack has no quote primitive, so the only way to
|
|
348
|
+
// attach an answer to its question is a thread on it — which then IS the place, and carries the
|
|
349
|
+
// memory (§5). Native streaming additionally REQUIRES a thread, but the shape does not depend on
|
|
350
|
+
// the renderer: `classic` attaches its answer the same way.
|
|
351
|
+
const defaultThread = event.thread_ts ?? event.ts;
|
|
352
|
+
const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
|
|
353
|
+
const defaultSession = threadKey(teamId, event.channel, rootTs);
|
|
354
|
+
// Explicit user stop: a control action, never a turn — it must not queue behind the run it
|
|
355
|
+
// stops. Match the bare word after stripping the bot mention; record the logical id so a Slack
|
|
356
|
+
// redelivery doesn't double-abort or double-notify.
|
|
357
|
+
if (isStopText(stripSlackMentions(event.text ?? ""))) {
|
|
358
|
+
seen.add(logicalId);
|
|
359
|
+
const target = { channelId: event.channel, threadTs: event.thread_ts };
|
|
360
|
+
sideTasks.track(dispatchStop(control, routed.session ?? defaultSession, label)
|
|
361
|
+
.then((feedback) => api.postMessage(target, feedback).then(() => undefined))
|
|
362
|
+
.catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const fileIds = slackFileIds(event);
|
|
366
|
+
const baseText = routed.text ?? slackEnvelope(envelope);
|
|
367
|
+
if (!baseText.trim() && fileIds.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
const threadTitle = direct && event.thread_ts === undefined
|
|
370
|
+
? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
|
|
371
|
+
: undefined;
|
|
372
|
+
submit({
|
|
373
|
+
id: logicalId,
|
|
374
|
+
seq: ++seq,
|
|
375
|
+
session: routed.session ?? defaultSession,
|
|
376
|
+
baseText,
|
|
377
|
+
bufferKey,
|
|
378
|
+
teamId,
|
|
379
|
+
channelId: targetChannel,
|
|
380
|
+
threadTs,
|
|
381
|
+
requesterUserId: event.user,
|
|
382
|
+
threadTitle: threadTitle || undefined,
|
|
383
|
+
fileIds,
|
|
384
|
+
}, true);
|
|
385
|
+
// Answering inside a GROUP thread makes the agent a participant of it, which is what lets the
|
|
386
|
+
// NEXT bare message address it without a mention.
|
|
387
|
+
//
|
|
388
|
+
// `group` excludes DMs, whose `threadTs` is always defined (the answer opens its assistant
|
|
389
|
+
// thread) and which no rule could ever read — safe because it is structural: a channel never
|
|
390
|
+
// becomes a DM.
|
|
391
|
+
//
|
|
392
|
+
// The two `routed` conditions keep the record describing what it claims. `session` undefined: the
|
|
393
|
+
// flag asserts "the agent answered into THIS thread's session", so a route supplying its own
|
|
394
|
+
// would record participation in a memory that never held the turn. `threadTs` undefined: a route
|
|
395
|
+
// can send the answer to a DIFFERENT thread, where the asker never spoke — recording them there
|
|
396
|
+
// would invent a participant. Feishu carries the same two conditions for the same reasons.
|
|
397
|
+
//
|
|
398
|
+
// Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
|
|
399
|
+
// the thread as the agent has actually left it.
|
|
400
|
+
if (group &&
|
|
401
|
+
threadTs !== undefined &&
|
|
402
|
+
sameChannel &&
|
|
403
|
+
routed.session === undefined &&
|
|
404
|
+
routed.threadTs === undefined) {
|
|
405
|
+
// The ASKER counts as heard in this thread too, and both halves are written together so the
|
|
406
|
+
// record can never say "the agent takes part and nobody has spoken". When the ask is top
|
|
407
|
+
// level, the answer is what CREATES the thread, so the observation above never ran for it (no
|
|
408
|
+
// `thread_ts` on the ask) — without this, a thread whose root is a human would not count them,
|
|
409
|
+
// and a stranger's first bare reply would read as a two-party exchange. `event.user` is
|
|
410
|
+
// guaranteed here (isSlackHumanMessage). Idempotent when the ask was already in the thread.
|
|
411
|
+
threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
|
|
412
|
+
agentSpoke: true,
|
|
413
|
+
humans: [event.user],
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
// Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
|
|
418
|
+
const sideTasks = createTaskTracker();
|
|
419
|
+
// First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
|
|
420
|
+
const welcomeInFlight = new Set();
|
|
421
|
+
const maybeWelcome = (envelope) => {
|
|
422
|
+
if (welcome === false)
|
|
423
|
+
return;
|
|
424
|
+
const body = welcome.trim();
|
|
425
|
+
if (!body)
|
|
426
|
+
return;
|
|
427
|
+
const event = envelope.event;
|
|
428
|
+
if (!event)
|
|
429
|
+
return;
|
|
430
|
+
if (event.type !== "app_home_opened" || event.tab !== "messages")
|
|
431
|
+
return;
|
|
432
|
+
const userId = event.user;
|
|
433
|
+
const channelId = event.channel;
|
|
434
|
+
if (!userId || !channelId)
|
|
435
|
+
return;
|
|
436
|
+
const teamId = slackTeamId(envelope) ?? authenticatedTeamId;
|
|
437
|
+
if (!teamId)
|
|
438
|
+
return;
|
|
439
|
+
const id = `${teamId}:${userId}`;
|
|
440
|
+
if (welcomed.has(teamId, userId) || welcomeInFlight.has(id))
|
|
441
|
+
return;
|
|
442
|
+
// Reserve in-memory so rapid re-opens don't double-post; persist to the durable set only on a
|
|
443
|
+
// successful post, so a failed post simply retries on the next open.
|
|
444
|
+
welcomeInFlight.add(id);
|
|
445
|
+
sideTasks.track(api
|
|
446
|
+
.postMarkdown({ channelId }, body)
|
|
447
|
+
.then(() => {
|
|
448
|
+
welcomed.add(teamId, userId);
|
|
449
|
+
log.info(`${label} sent first-run welcome to ${id}`);
|
|
450
|
+
})
|
|
451
|
+
.catch((error) => log.warn(`${label} could not send first-run welcome (retries on next open): ${String(error)}`))
|
|
452
|
+
.finally(() => {
|
|
453
|
+
welcomeInFlight.delete(id);
|
|
454
|
+
}));
|
|
455
|
+
};
|
|
456
|
+
const handler = async (request) => {
|
|
457
|
+
if (request.method !== "POST")
|
|
458
|
+
return text("POST only\n", 405);
|
|
459
|
+
const body = await readBodyCapped(request, MAX_EVENT_BYTES);
|
|
460
|
+
if ("tooLarge" in body)
|
|
461
|
+
return text("payload too large\n", 413);
|
|
462
|
+
const timestamp = request.headers.get("x-slack-request-timestamp") ?? "";
|
|
463
|
+
const signature = request.headers.get("x-slack-signature") ?? "";
|
|
464
|
+
if (!verifySlackSignature(signingSecret, timestamp, signature, body.text)) {
|
|
465
|
+
log.warn(`${label} rejected an event with an invalid/stale X-Slack-Signature`);
|
|
466
|
+
return text("invalid signature\n", 401);
|
|
467
|
+
}
|
|
468
|
+
let envelope;
|
|
469
|
+
try {
|
|
470
|
+
envelope = JSON.parse(body.text);
|
|
471
|
+
if (typeof envelope !== "object" || envelope === null)
|
|
472
|
+
throw new Error("not an object");
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return text("invalid json\n", 400);
|
|
476
|
+
}
|
|
477
|
+
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
|
|
478
|
+
return Response.json({ challenge: envelope.challenge });
|
|
479
|
+
}
|
|
480
|
+
if (envelope.type !== "event_callback")
|
|
481
|
+
return new Response(null, { status: 200 });
|
|
482
|
+
try {
|
|
483
|
+
await waitForAuthentication();
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
log.error(`${label} refusing to ACK an event because Slack authentication is unavailable: ${String(error)}`);
|
|
487
|
+
return text("slack authentication unavailable\n", 503);
|
|
488
|
+
}
|
|
489
|
+
maybeWelcome(envelope);
|
|
490
|
+
acceptEvent(envelope);
|
|
491
|
+
return new Response(null, { status: 200 });
|
|
492
|
+
};
|
|
493
|
+
handler.turnsIdle = () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined);
|
|
494
|
+
return { "POST /slack": handler };
|
|
495
|
+
};
|
|
496
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Durable set of users who have already received the first-run DM welcome, so it is sent at most once. */
|
|
2
|
+
import { log } from "../../log.js";
|
|
3
|
+
import { loadStateFile, saveStateFile } from "../state.js";
|
|
4
|
+
function key(teamId, userId) {
|
|
5
|
+
return `${teamId}:${userId}`;
|
|
6
|
+
}
|
|
7
|
+
export function createWelcomedUsers(path, label = "[slack]") {
|
|
8
|
+
const raw = loadStateFile(path);
|
|
9
|
+
let ids = new Set();
|
|
10
|
+
if (raw !== undefined) {
|
|
11
|
+
if (Array.isArray(raw) && raw.every((id) => typeof id === "string")) {
|
|
12
|
+
ids = new Set(raw);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no welcomed users`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
has(teamId, userId) {
|
|
20
|
+
return ids.has(key(teamId, userId));
|
|
21
|
+
},
|
|
22
|
+
add(teamId, userId) {
|
|
23
|
+
const id = key(teamId, userId);
|
|
24
|
+
if (ids.has(id))
|
|
25
|
+
return;
|
|
26
|
+
const next = new Set(ids);
|
|
27
|
+
next.add(id);
|
|
28
|
+
saveStateFile(path, [...next]);
|
|
29
|
+
ids = next;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/channels/state.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
/** Create the state home
|
|
2
|
-
* files) can carry chat content and must never be committable. The workspace opener already protects
|
|
3
|
-
* an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
|
|
4
|
-
* operator's own file. */
|
|
1
|
+
/** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
|
|
5
2
|
export declare function ensureStateHome(dir: string): void;
|
|
6
3
|
/** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
|
|
7
4
|
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
8
5
|
export declare function loadStateFile(path: string): unknown;
|
|
9
6
|
export declare function saveStateFile(path: string, value: unknown): void;
|
|
7
|
+
/**
|
|
8
|
+
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
9
|
+
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
10
|
+
* whose loss changes behaviour needs a migration, not a delete.
|
|
11
|
+
*
|
|
12
|
+
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
13
|
+
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
14
|
+
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
15
|
+
*/
|
|
16
|
+
export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
|
package/dist/channels/state.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Durable channel state for a SINGLE-PROCESS deployment (the supported production shape — no
|
|
3
3
|
* cross-instance locking; two processes must not share a state dir). Small JSON files, written
|
|
4
4
|
* atomically (tmp + rename), so a crash leaves the previous version on disk, never a torn file.
|
|
5
|
-
* Writes are synchronous: the files are KB-sized and a write that completes BEFORE the
|
|
6
|
-
* is what makes the state actually durable (an ACKed
|
|
7
|
-
* Channel-neutral: every stateful channel (
|
|
5
|
+
* Writes are synchronous: the files are KB-sized and a write that completes BEFORE the transport ACK
|
|
6
|
+
* is what makes the state actually durable (an ACKed delivery is not redelivered).
|
|
7
|
+
* Channel-neutral: every stateful channel (Telegram, Feishu/Lark, Slack) derives its home from the ctx state root
|
|
8
8
|
* and persists through these three primitives.
|
|
9
9
|
*
|
|
10
10
|
* Failure split: a CORRUPT file (bad JSON) degrades visibly — log.warn + start empty — because channel
|
|
@@ -12,22 +12,12 @@
|
|
|
12
12
|
* is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
|
|
13
13
|
* with silently-empty state would hide real data behind a config mistake.
|
|
14
14
|
*/
|
|
15
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
import { log } from "../log.js";
|
|
18
|
-
/** Create the state home
|
|
19
|
-
* files) can carry chat content and must never be committable. The workspace opener already protects
|
|
20
|
-
* an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
|
|
21
|
-
* operator's own file. */
|
|
18
|
+
/** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
|
|
22
19
|
export function ensureStateHome(dir) {
|
|
23
20
|
mkdirSync(dir, { recursive: true });
|
|
24
|
-
try {
|
|
25
|
-
writeFileSync(join(dir, ".gitignore"), "*\n", { flag: "wx" });
|
|
26
|
-
}
|
|
27
|
-
catch (e) {
|
|
28
|
-
if (e.code !== "EEXIST")
|
|
29
|
-
throw e;
|
|
30
|
-
}
|
|
31
21
|
}
|
|
32
22
|
/** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
|
|
33
23
|
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
@@ -58,3 +48,20 @@ export function saveStateFile(path, value) {
|
|
|
58
48
|
writeFileSync(tmp, JSON.stringify(value));
|
|
59
49
|
renameSync(tmp, path);
|
|
60
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
53
|
+
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
54
|
+
* whose loss changes behaviour needs a migration, not a delete.
|
|
55
|
+
*
|
|
56
|
+
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
57
|
+
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
58
|
+
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
59
|
+
*/
|
|
60
|
+
export function removeRetiredStateFile(stateHome, name, label) {
|
|
61
|
+
try {
|
|
62
|
+
rmSync(join(stateHome, name), { force: true });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SessionControl } from "../session.ts";
|
|
2
|
+
/** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
|
|
3
|
+
export declare function isStopText(text: string): boolean;
|
|
4
|
+
/** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
|
|
5
|
+
* full details go to the operator log. */
|
|
6
|
+
export declare function dispatchStop(control: SessionControl | undefined, session: string, label: string): Promise<string>;
|