@fastagent-sh/fastagent 0.28.0 → 0.30.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 +10 -4
- package/dist/atomic-write.d.ts +4 -5
- package/dist/atomic-write.js +4 -5
- package/dist/authoring.d.ts +32 -15
- package/dist/authoring.js +147 -127
- package/dist/channels/agentcore-protocol.d.ts +8 -3
- package/dist/channels/agentcore-protocol.js +3 -1
- package/dist/channels/agentcore-service.d.ts +1 -0
- package/dist/channels/agentcore-service.js +14 -4
- package/dist/channels/agentcore.d.ts +9 -0
- package/dist/channels/agentcore.js +29 -4
- package/dist/channels/context.d.ts +2 -0
- package/dist/channels/context.js +5 -1
- package/dist/channels/feishu/feishu-api.d.ts +3 -1
- package/dist/channels/feishu/feishu-api.js +4 -0
- package/dist/channels/feishu/feishu.d.ts +0 -3
- package/dist/channels/feishu/feishu.js +10 -10
- package/dist/channels/feishu/register-app.d.ts +2 -0
- package/dist/channels/feishu/register-app.js +4 -6
- package/dist/channels/feishu/scaffold/channel.ts +32 -34
- package/dist/channels/feishu/scaffold/feishu-send.ts +5 -5
- package/dist/channels/feishu/scaffold/feishu-threads.ts +3 -3
- package/dist/channels/feishu/setup-mode.d.ts +2 -12
- package/dist/channels/feishu/setup-mode.js +2 -22
- package/dist/channels/feishu/setup.d.ts +12 -0
- package/dist/channels/feishu/setup.js +197 -0
- package/dist/channels/kit/abortable.d.ts +8 -0
- package/dist/channels/kit/abortable.js +22 -0
- package/dist/channels/lark/lark.d.ts +0 -3
- package/dist/channels/lark/lark.js +0 -5
- package/dist/channels/lark/scaffold/channel.ts +33 -35
- package/dist/channels/lark/scaffold/lark-send.ts +5 -5
- package/dist/channels/lark/scaffold/lark-threads.ts +3 -3
- package/dist/channels/lark/setup.d.ts +9 -0
- package/dist/channels/lark/setup.js +57 -0
- package/dist/channels/setup.d.ts +112 -0
- package/dist/channels/setup.js +31 -0
- package/dist/channels/slack/history.js +1 -1
- package/dist/channels/slack/manifest.d.ts +17 -7
- package/dist/channels/slack/manifest.js +12 -16
- package/dist/channels/slack/scaffold/channel.ts +44 -30
- package/dist/channels/slack/scaffold/slack-send.ts +8 -8
- package/dist/channels/slack/scaffold/slack-threads.ts +4 -4
- package/dist/channels/slack/setup.d.ts +7 -0
- package/dist/channels/slack/setup.js +119 -0
- package/dist/channels/slack/slack-api.d.ts +2 -0
- package/dist/channels/slack/slack-api.js +4 -0
- package/dist/channels/slack/slack.d.ts +21 -2
- package/dist/channels/slack/slack.js +398 -338
- package/dist/channels/slack/socket.d.ts +57 -0
- package/dist/channels/slack/socket.js +305 -0
- package/dist/channels/telegram/polling.d.ts +27 -0
- package/dist/channels/telegram/polling.js +105 -0
- package/dist/channels/telegram/scaffold/channel.ts +52 -32
- package/dist/channels/telegram/scaffold/telegram-send.ts +9 -9
- package/dist/channels/telegram/setup.d.ts +2 -0
- package/dist/channels/telegram/setup.js +42 -0
- package/dist/channels/telegram/telegram-api.d.ts +27 -1
- package/dist/channels/telegram/telegram-api.js +7 -6
- package/dist/channels/telegram/telegram.d.ts +11 -3
- package/dist/channels/telegram/telegram.js +184 -157
- package/dist/channels/wait-health.js +1 -1
- package/dist/channels/webhook-evidence.d.ts +11 -0
- package/dist/channels/webhook-evidence.js +71 -0
- package/dist/channels/webhooks.d.ts +37 -0
- package/dist/channels/webhooks.js +70 -0
- package/dist/child-env.d.ts +16 -0
- package/dist/child-env.js +30 -0
- package/dist/cli/box-login.js +4 -1
- package/dist/cli/channel-app.d.ts +3 -0
- package/dist/cli/channel-app.js +35 -0
- package/dist/cli/channel-setup.d.ts +38 -0
- package/dist/cli/channel-setup.js +195 -0
- package/dist/cli/commands/add.d.ts +6 -14
- package/dist/cli/commands/add.js +45 -310
- package/dist/cli/commands/auth.js +8 -2
- package/dist/cli/commands/build.d.ts +5 -0
- package/dist/cli/commands/build.js +16 -0
- package/dist/cli/commands/channels.d.ts +19 -0
- package/dist/cli/commands/channels.js +230 -0
- package/dist/cli/commands/content.d.ts +7 -3
- package/dist/cli/commands/content.js +16 -10
- package/dist/cli/commands/deploy/agentcore.js +55 -23
- package/dist/cli/commands/deploy/channel-apps.d.ts +17 -0
- package/dist/cli/commands/deploy/channel-apps.js +30 -0
- package/dist/cli/commands/deploy/docker.js +32 -65
- package/dist/cli/commands/deploy/fly.js +28 -12
- package/dist/cli/commands/deploy/railway.js +38 -13
- package/dist/cli/commands/deploy/secrets-step.d.ts +19 -0
- package/dist/cli/commands/deploy/secrets-step.js +66 -0
- package/dist/cli/commands/deploy/shared.d.ts +16 -5
- package/dist/cli/commands/deploy/shared.js +25 -8
- package/dist/cli/commands/deploy.js +53 -67
- package/dist/cli/commands/dev.d.ts +2 -0
- package/dist/cli/commands/dev.js +15 -3
- package/dist/cli/commands/env.js +1 -1
- package/dist/cli/commands/info.d.ts +2 -0
- package/dist/cli/commands/info.js +63 -24
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/commands/invoke.d.ts +2 -0
- package/dist/cli/commands/invoke.js +20 -7
- package/dist/cli/commands/login.d.ts +0 -7
- package/dist/cli/commands/login.js +22 -10
- package/dist/cli/commands/logout.js +1 -1
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +164 -0
- package/dist/cli/commands/models.js +3 -2
- package/dist/cli/commands/schedules.d.ts +4 -2
- package/dist/cli/commands/schedules.js +14 -10
- package/dist/cli/commands/secrets.d.ts +14 -0
- package/dist/cli/commands/secrets.js +213 -0
- package/dist/cli/commands/start.d.ts +14 -14
- package/dist/cli/commands/start.js +46 -60
- package/dist/cli/content-view.d.ts +4 -6
- package/dist/cli/content-view.js +5 -11
- package/dist/cli/first-run-channels.d.ts +3 -0
- package/dist/cli/first-run-channels.js +63 -0
- package/dist/cli/instance.d.ts +41 -0
- package/dist/cli/instance.js +155 -0
- package/dist/cli/invoke-stream.d.ts +5 -2
- package/dist/cli/invoke-stream.js +7 -2
- package/dist/cli/program.js +339 -108
- package/dist/cli/serve.d.ts +4 -5
- package/dist/cli/serve.js +4 -7
- package/dist/cli/shared.d.ts +11 -2
- package/dist/cli/shared.js +32 -19
- package/dist/cli/ticket.js +50 -6
- package/dist/content/declare.d.ts +31 -37
- package/dist/content/declare.js +59 -60
- package/dist/content/file.d.ts +23 -12
- package/dist/content/file.js +48 -35
- package/dist/content/git.d.ts +0 -10
- package/dist/content/git.js +5 -39
- package/dist/content/resolve.d.ts +18 -22
- package/dist/content/resolve.js +32 -66
- package/dist/content/source.d.ts +7 -8
- package/dist/content/source.js +10 -28
- package/dist/credentials-page.d.ts +26 -0
- package/dist/credentials-page.js +185 -0
- package/dist/declared-secrets.d.ts +10 -3
- package/dist/declared-secrets.js +8 -4
- package/dist/deploy/agentcore/destroy.d.ts +4 -1
- package/dist/deploy/agentcore/destroy.js +26 -2
- package/dist/deploy/agentcore/forwarder.js +1 -1
- package/dist/deploy/agentcore/plan.d.ts +7 -21
- package/dist/deploy/agentcore/plan.js +47 -49
- package/dist/deploy/agentcore/run.d.ts +18 -10
- package/dist/deploy/agentcore/run.js +93 -70
- package/dist/deploy/agentcore/runtime-secrets.d.ts +7 -0
- package/dist/deploy/agentcore/runtime-secrets.js +108 -0
- package/dist/deploy/agentcore/shell.js +12 -19
- package/dist/deploy/agentcore/sigv4.d.ts +7 -0
- package/dist/deploy/agentcore/sigv4.js +15 -0
- package/dist/deploy/agentcore/slr.d.ts +18 -0
- package/dist/deploy/agentcore/slr.js +27 -0
- package/dist/deploy/agentcore/store.d.ts +7 -0
- package/dist/deploy/agentcore/store.js +115 -0
- package/dist/deploy/api.d.ts +14 -14
- package/dist/deploy/api.js +26 -24
- package/dist/deploy/box-shell.d.ts +3 -1
- package/dist/deploy/build-context.d.ts +4 -4
- package/dist/deploy/build-context.js +11 -11
- package/dist/deploy/channel-ingress.d.ts +35 -9
- package/dist/deploy/channel-ingress.js +119 -22
- package/dist/deploy/container.d.ts +35 -10
- package/dist/deploy/container.js +80 -39
- package/dist/deploy/delivered-text.d.ts +13 -0
- package/dist/deploy/delivered-text.js +86 -0
- package/dist/deploy/docker/plan.d.ts +7 -8
- package/dist/deploy/docker/plan.js +31 -52
- package/dist/deploy/docker/run.d.ts +18 -43
- package/dist/deploy/docker/run.js +57 -79
- package/dist/deploy/file-store.d.ts +2 -0
- package/dist/deploy/file-store.js +17 -0
- package/dist/deploy/fly/plan.d.ts +3 -2
- package/dist/deploy/fly/plan.js +20 -9
- package/dist/deploy/fly/run.d.ts +18 -22
- package/dist/deploy/fly/run.js +25 -44
- package/dist/deploy/fly/store.d.ts +12 -0
- package/dist/deploy/fly/store.js +83 -0
- package/dist/deploy/hosts.d.ts +1 -2
- package/dist/deploy/hosts.js +1 -2
- package/dist/deploy/preflight.d.ts +18 -5
- package/dist/deploy/preflight.js +135 -63
- package/dist/deploy/railway/ignore.d.ts +8 -0
- package/dist/deploy/railway/ignore.js +57 -0
- package/dist/deploy/railway/plan.d.ts +1 -2
- package/dist/deploy/railway/plan.js +8 -6
- package/dist/deploy/railway/run.d.ts +21 -15
- package/dist/deploy/railway/run.js +32 -78
- package/dist/deploy/railway/store.d.ts +29 -0
- package/dist/deploy/railway/store.js +143 -0
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +4 -3
- package/dist/deploy/secret-store.d.ts +63 -0
- package/dist/deploy/secret-store.js +98 -0
- package/dist/deploy/secrets.d.ts +29 -46
- package/dist/deploy/secrets.js +49 -122
- package/dist/deploy/workspace.d.ts +6 -12
- package/dist/deploy/workspace.js +18 -94
- package/dist/dev-supervisor.js +6 -7
- package/dist/environment/declare.js +3 -2
- package/dist/environment/mise.d.ts +3 -1
- package/dist/environment/mise.js +4 -2
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/harnesses/models/catalog.d.ts +1 -0
- package/dist/harnesses/models/catalog.js +1 -2
- package/dist/harnesses/models/environment.d.ts +7 -2
- package/dist/harnesses/models/environment.js +9 -5
- package/dist/harnesses/models/host.d.ts +0 -7
- package/dist/harnesses/models/host.js +10 -1
- package/dist/harnesses/models/login.d.ts +0 -8
- package/dist/harnesses/models/login.js +5 -4
- package/dist/harnesses/models/models.d.ts +7 -2
- package/dist/harnesses/models/models.js +28 -4
- package/dist/harnesses/pi-durable/agent.js +59 -38
- package/dist/harnesses/pi-durable/build.d.ts +12 -0
- package/dist/harnesses/pi-durable/build.js +270 -0
- package/dist/harnesses/pi-durable/built.d.ts +24 -0
- package/dist/harnesses/pi-durable/built.js +136 -0
- package/dist/harnesses/pi-durable/config.d.ts +81 -0
- package/dist/harnesses/pi-durable/config.js +266 -0
- package/dist/harnesses/pi-durable/define.d.ts +63 -13
- package/dist/harnesses/pi-durable/define.js +23 -2
- package/dist/harnesses/pi-durable/definition.d.ts +72 -0
- package/dist/harnesses/pi-durable/definition.js +143 -0
- package/dist/harnesses/pi-durable/events.d.ts +4 -3
- package/dist/harnesses/pi-durable/events.js +179 -136
- package/dist/harnesses/pi-durable/execution-env.d.ts +17 -0
- package/dist/harnesses/pi-durable/execution-env.js +11 -0
- package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
- package/dist/harnesses/pi-durable/mcp.js +32 -0
- package/dist/harnesses/pi-durable/open.d.ts +36 -38
- package/dist/harnesses/pi-durable/open.js +97 -150
- package/dist/harnesses/pi-durable/reads.d.ts +0 -1
- package/dist/harnesses/pi-durable/reads.js +1 -3
- package/dist/harnesses/pi-durable/registry.d.ts +4 -6
- package/dist/harnesses/pi-durable/registry.js +29 -26
- package/dist/harnesses/pi-durable/release.d.ts +2 -2
- package/dist/harnesses/pi-durable/release.js +10 -7
- package/dist/harnesses/pi-durable/serve.d.ts +95 -15
- package/dist/harnesses/pi-durable/serve.js +151 -59
- package/dist/harnesses/pi-durable/sessions.js +15 -12
- package/dist/harnesses/pi-durable/text-sources.d.ts +30 -0
- package/dist/harnesses/pi-durable/text-sources.js +55 -0
- package/dist/harnesses/pi-durable/text.d.ts +16 -4
- package/dist/harnesses/pi-durable/text.js +115 -59
- package/dist/harnesses/pi-durable/wake.js +8 -8
- package/dist/http/client.js +9 -3
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +8 -1
- package/dist/instance-name.d.ts +7 -0
- package/dist/instance-name.js +26 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/locked-file.d.ts +2 -2
- package/dist/locked-file.js +8 -3
- package/dist/mcp/auth.d.ts +22 -0
- package/dist/mcp/auth.js +180 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +179 -0
- package/dist/mcp/config.d.ts +86 -0
- package/dist/mcp/config.js +225 -0
- package/dist/mcp/grants.d.ts +4 -0
- package/dist/mcp/grants.js +132 -0
- package/dist/mcp/host.d.ts +19 -0
- package/dist/mcp/host.js +128 -0
- package/dist/mcp/network.d.ts +12 -0
- package/dist/mcp/network.js +150 -0
- package/dist/mcp/sign-in.d.ts +2 -0
- package/dist/mcp/sign-in.js +204 -0
- package/dist/mcp-api.d.ts +107 -0
- package/dist/mcp-api.js +10 -0
- package/dist/paths.d.ts +22 -8
- package/dist/paths.js +44 -11
- package/dist/runtime-websocket.d.ts +14 -0
- package/dist/runtime-websocket.js +39 -0
- package/dist/scaffold/add-channel.d.ts +9 -16
- package/dist/scaffold/add-channel.js +13 -83
- package/dist/scaffold/templates/env.example +4 -5
- package/dist/scaffold/templates/fastagent.config.ts +20 -14
- package/dist/scaffold/templates/gitignore +9 -6
- package/dist/scaffold/templates.d.ts +6 -2
- package/dist/scaffold/templates.js +7 -2
- package/dist/scaffold/vendor-skill.js +4 -2
- package/dist/service.d.ts +44 -6
- package/dist/service.js +110 -10
- package/dist/serving-command.d.ts +10 -0
- package/dist/serving-command.js +1 -0
- package/dist/setup-page.d.ts +51 -0
- package/dist/setup-page.js +149 -0
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +1 -1
- package/dist/strip-edges.d.ts +10 -0
- package/dist/strip-edges.js +22 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +0 -1
- package/dist/tunnel.js +2 -8
- package/dist/value-file.d.ts +12 -6
- package/dist/value-file.js +36 -25
- package/dist/version.d.ts +7 -1
- package/dist/version.js +9 -3
- package/package.json +8 -6
- package/dist/channels/lark/onboard.d.ts +0 -36
- package/dist/channels/lark/onboard.js +0 -56
- package/dist/channels/slack/config-api.d.ts +0 -69
- package/dist/channels/slack/config-api.js +0 -162
- package/dist/channels/slack/onboard.d.ts +0 -40
- package/dist/channels/slack/onboard.js +0 -136
- package/dist/channels/slack/onboarding-state.d.ts +0 -31
- package/dist/channels/slack/onboarding-state.js +0 -64
- package/dist/channels/slack/register-webhook.d.ts +0 -12
- package/dist/channels/slack/register-webhook.js +0 -67
- package/dist/channels/slack/setup-server.d.ts +0 -32
- package/dist/channels/slack/setup-server.js +0 -201
- package/dist/cli/add-feishu.d.ts +0 -50
- package/dist/cli/add-feishu.js +0 -367
- package/dist/cli/add-slack.d.ts +0 -18
- package/dist/cli/add-slack.js +0 -216
- package/dist/harnesses/pi-durable/parked.d.ts +0 -18
- package/dist/harnesses/pi-durable/parked.js +0 -27
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* First-party Slack channel: durable turns/context, files, and edited previews, over one runtime, with two ways in: the
|
|
3
|
+
* Events API's signed HTTP Request URL, and Socket Mode (socket.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { recordReached } from "../webhook-evidence.js";
|
|
2
6
|
import { createHmac } from "node:crypto";
|
|
3
7
|
import { join } from "node:path";
|
|
4
8
|
import { log } from "../../log.js";
|
|
@@ -21,6 +25,7 @@ import { createTurnStore } from "../kit/turn-store.js";
|
|
|
21
25
|
import { discussionBlock } from "../kit/context-buffer.js";
|
|
22
26
|
import { collectFoldedFiles, createSlackPlaceHistory, slackHistoryKey, slackPlaceOf, } from "./history.js";
|
|
23
27
|
import { slackTurnStream } from "./invoke-turn.js";
|
|
28
|
+
import { connectSlackSocket } from "./socket.js";
|
|
24
29
|
import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackEnvelope, slackFileIds, slackMessageText, slackTeamId, } from "./parse.js";
|
|
25
30
|
import { defaultErrorMessage, deliverSlackAnswer, settleSlackPreview, slackReply, } from "./preview.js";
|
|
26
31
|
import { completeSlackReaction, resolveReactionEmojis, startSlackReaction } from "./reaction.js";
|
|
@@ -109,351 +114,379 @@ function createSlackAuthentication(api, label) {
|
|
|
109
114
|
}),
|
|
110
115
|
};
|
|
111
116
|
}
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
/** The policy as the runtime reads it: defaults applied and what can be refused refused, at construction. */
|
|
118
|
+
function resolveSlackPolicy(options) {
|
|
119
|
+
const { rendering = "native", welcome = DEFAULT_WELCOME, reactionAck = {} } = options;
|
|
114
120
|
if (!["native", "classic"].includes(rendering)) {
|
|
115
121
|
throw new Error('slackChannel rendering must be "native" or "classic"');
|
|
116
122
|
}
|
|
117
123
|
if (welcome !== false && typeof welcome !== "string") {
|
|
118
124
|
throw new Error("slackChannel welcome must be a string or false");
|
|
119
125
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
* so the place it LANDS in records it, and that place's next read leaves it out — an answer to a channel's
|
|
169
|
-
* top-level ask lands in the thread it opens. The send tool uses the plain client, so what the agent posts itself
|
|
170
|
-
* stays discussion. A routed turn has no place and reads no history.
|
|
171
|
-
*/
|
|
172
|
-
const placeApi = (turn) => turn.historyKey === undefined
|
|
173
|
-
? api
|
|
174
|
-
: api.recordingSends((where, ts) => history.recordOutput(slackHistoryKey(turn.teamId, where), ts));
|
|
175
|
-
const store = createTurnStore(join(stateHome, "turns.json"), {
|
|
176
|
-
label,
|
|
177
|
-
isRecord: isStoredSlackTurn,
|
|
178
|
-
order: (a, b) => a.seq - b.seq,
|
|
179
|
-
});
|
|
180
|
-
const decide = route ?? defaultSlackRoute;
|
|
181
|
-
/** A channel turn under the default routing reads its channel's threads; a DM has none to read. */
|
|
182
|
-
const enterRoom = (turn) => {
|
|
183
|
-
if (!turn.group || turn.historyKey === undefined)
|
|
184
|
-
return {};
|
|
185
|
-
const { teamId, channelId } = slackPlaceOf(turn.historyKey);
|
|
186
|
-
const room = slackHistoryKey(teamId, { channelId });
|
|
187
|
-
return { enter: (sessionId) => rooms.enter(sessionId, room) };
|
|
188
|
-
};
|
|
189
|
-
const targetOf = (turn) => ({
|
|
190
|
-
channelId: turn.channelId,
|
|
191
|
-
threadTs: turn.threadTs,
|
|
192
|
-
recipientUserId: turn.requesterUserId,
|
|
193
|
-
recipientTeamId: turn.teamId,
|
|
194
|
-
});
|
|
195
|
-
const notifyDropped = (turn) => {
|
|
196
|
-
const target = targetOf(turn);
|
|
197
|
-
if (turn.nativeQueueStatus)
|
|
198
|
-
void api.setThreadStatus(target, "").catch(() => { });
|
|
199
|
-
void settleSlackPreview(placeApi(turn), 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)}`));
|
|
200
|
-
};
|
|
201
|
-
const runner = createTurnRunner({
|
|
202
|
-
label,
|
|
203
|
-
store,
|
|
204
|
-
discussion: history,
|
|
205
|
-
seen,
|
|
206
|
-
toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
207
|
-
fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
|
|
208
|
-
// The read ends at the ask (`messageRefOf`: the ts of the message that asked).
|
|
209
|
-
discussionKey: (turn) => {
|
|
210
|
-
const ask = messageRefOf(turn.id);
|
|
211
|
-
return turn.historyKey === undefined || ask === undefined ? undefined : { key: turn.historyKey, until: ask.ts };
|
|
212
|
-
},
|
|
213
|
-
where: (turn) => `channel=${turn.channelId}`,
|
|
214
|
-
onQueuedBehind(turn) {
|
|
215
|
-
const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
|
|
216
|
-
if (nativeDmStatus)
|
|
217
|
-
turn.nativeQueueStatus = true;
|
|
218
|
-
return {
|
|
219
|
-
done: auth.settled
|
|
220
|
-
.then(() => nativeDmStatus
|
|
221
|
-
? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
|
|
222
|
-
: placeApi(turn)
|
|
223
|
-
.postMessage(targetOf(turn), QUEUED_PLACEHOLDER)
|
|
224
|
-
.then((ts) => {
|
|
225
|
-
turn.previewTs = ts;
|
|
226
|
-
}))
|
|
227
|
-
.catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
|
|
228
|
-
};
|
|
229
|
-
},
|
|
230
|
-
// Leave the intent untouched when Slack auth failed.
|
|
231
|
-
beforeRun: async (turn) => {
|
|
232
|
-
try {
|
|
233
|
-
await auth.settled;
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
catch (error) {
|
|
237
|
-
log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
onDeferred: (turn) => {
|
|
242
|
-
if (turn.nativeQueueStatus) {
|
|
243
|
-
void api
|
|
244
|
-
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
245
|
-
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
246
|
-
}
|
|
247
|
-
else if (turn.previewTs) {
|
|
248
|
-
void settleSlackPreview(placeApi(turn), targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
notifyDropped,
|
|
252
|
-
deliverAnswer: (turn, answer) => portJoin(async () => {
|
|
253
|
-
// A queue notice THIS process put up outlives the run the answer came from, so it is settled here as it
|
|
254
|
-
// would be by a turn that ran: the status line cleared, the compatibility message taken over.
|
|
255
|
-
if (turn.nativeQueueStatus) {
|
|
256
|
-
await api
|
|
257
|
-
.setThreadStatus(targetOf(turn), "")
|
|
258
|
-
.catch((error) => log.warn(`${label} could not clear a queued Agent status: ${String(error)}`));
|
|
259
|
-
}
|
|
260
|
-
await deliverSlackAnswer(placeApi(turn), targetOf(turn), answer, turn.previewTs);
|
|
261
|
-
// The 👀 on the asker's message was added by the run that produced this answer, and `execute`'s
|
|
262
|
-
// acquire/release — the only thing that turns it into ✅ — is not on this path.
|
|
263
|
-
const messageRef = messageRefOf(turn.id);
|
|
264
|
-
if (reactionEmojis && messageRef) {
|
|
265
|
-
await completeSlackReaction({
|
|
266
|
-
api,
|
|
267
|
-
channelId: messageRef.channelId,
|
|
268
|
-
ts: messageRef.ts,
|
|
269
|
-
emojis: reactionEmojis,
|
|
270
|
-
label,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}),
|
|
274
|
-
execute: (turn, discussion, onAnswered) => {
|
|
275
|
-
const messageRef = messageRefOf(turn.id);
|
|
276
|
-
return Effect.acquireUseRelease(portJoin(async () => reactionEmojis && messageRef
|
|
277
|
-
? startSlackReaction({
|
|
278
|
-
api,
|
|
279
|
-
channelId: messageRef.channelId,
|
|
280
|
-
ts: messageRef.ts,
|
|
281
|
-
emojis: reactionEmojis,
|
|
282
|
-
label,
|
|
283
|
-
})
|
|
284
|
-
: undefined), () => Effect.scoped(slackReply(slackTurnStream(agent, {
|
|
285
|
-
session: () => sessionFor(turn.conversation),
|
|
286
|
-
...enterRoom(turn),
|
|
287
|
-
key: turn.id,
|
|
288
|
-
text: `${discussionBlock(discussion.text)}${turn.baseText}`,
|
|
289
|
-
}, { api, channelId: turn.channelId, filesDir: attachmentsDir(stateHome), label }, {
|
|
290
|
-
primaryFileIds: turn.fileIds,
|
|
291
|
-
buffered: collectFoldedFiles(discussion.consumed.flatMap((read) => read.folded), new Set(turn.fileIds)),
|
|
292
|
-
}), placeApi(turn), targetOf(turn), formatError, {
|
|
293
|
-
rendering,
|
|
294
|
-
initialPreviewTs: turn.previewTs,
|
|
295
|
-
threadTitle: turn.threadTitle,
|
|
296
|
-
disclaimer: aiDisclaimer,
|
|
297
|
-
label,
|
|
298
|
-
onAnswered,
|
|
299
|
-
})), (reaction, exit) => portJoin(async () => {
|
|
300
|
-
if (Exit.isSuccess(exit))
|
|
301
|
-
await reaction?.complete();
|
|
302
|
-
else
|
|
303
|
-
await reaction?.remove();
|
|
304
|
-
}).pipe(
|
|
305
|
-
// A release does not fail: its defect would make the turn's typed failure one too. Unreachable while
|
|
306
|
-
// the reaction's `complete` and `remove` catch their own failures (reaction.ts).
|
|
307
|
-
Effect.catchTag("PortFailure", (error) => Effect.sync(() => log.warn(`${label} could not settle the Slack reaction: ${String(error.cause)}`)))));
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
311
|
-
// Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the delivery dedup ring
|
|
312
|
-
// alone is enough.
|
|
313
|
-
const acceptEvent = (envelope) => {
|
|
314
|
-
const event = envelope.event;
|
|
315
|
-
if (!isSlackHumanMessage(event))
|
|
316
|
-
return;
|
|
317
|
-
const botUserId = auth.botUserId();
|
|
318
|
-
if (botUserId && event.user === botUserId)
|
|
319
|
-
return;
|
|
320
|
-
const teamId = slackTeamId(envelope) ?? auth.teamId();
|
|
321
|
-
if (!teamId) {
|
|
322
|
-
log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
|
|
323
|
-
return;
|
|
126
|
+
return {
|
|
127
|
+
botToken: options.botToken,
|
|
128
|
+
rendering,
|
|
129
|
+
welcome,
|
|
130
|
+
reactionEmojis: resolveReactionEmojis(reactionAck),
|
|
131
|
+
aiDisclaimer: options.aiDisclaimer,
|
|
132
|
+
route: options.route,
|
|
133
|
+
onError: options.onError,
|
|
134
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://slack.com/api",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function createSlackRuntime(policy, { agent, sessionFor, recordedSession, stateDir: stateHome }) {
|
|
138
|
+
const { botToken, rendering, welcome, reactionEmojis, aiDisclaimer, route, onError, apiBaseUrl } = policy;
|
|
139
|
+
if (!botToken)
|
|
140
|
+
throw new Error("slackChannel requires a non-empty botToken (Bot User OAuth Token)");
|
|
141
|
+
const label = "[slack]";
|
|
142
|
+
const formatError = onError ?? defaultErrorMessage;
|
|
143
|
+
const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
|
|
144
|
+
registerSlackApi(botToken, api); // the send tool delivers through this one (shared-api.ts)
|
|
145
|
+
const auth = createSlackAuthentication(api, label);
|
|
146
|
+
// Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
|
|
147
|
+
const sideTasks = createTaskTracker(label);
|
|
148
|
+
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
149
|
+
const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
|
|
150
|
+
/**
|
|
151
|
+
* A thread's participation is keyed by the CONVERSATION it describes — "the agent answered here" is a claim about
|
|
152
|
+
* a memory, so the two must not be re-keyable independently.
|
|
153
|
+
*/
|
|
154
|
+
const threadKey = (teamId, channelId, threadTs) => `${teamId}:${channelId}:${threadTs}`;
|
|
155
|
+
const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
|
|
156
|
+
// Every message this channel took as input (a turn, a /stop) is in `seen`. A history read also leaves these out,
|
|
157
|
+
// to shape the prompt only — what keeps a queued ask out of an earlier turn is the read's bound, not this ring.
|
|
158
|
+
const history = createSlackPlaceHistory({
|
|
159
|
+
api,
|
|
160
|
+
self: () => ({ userId: auth.botUserId(), botId: auth.botId() }),
|
|
161
|
+
label,
|
|
162
|
+
path: join(stateHome, "history.json"),
|
|
163
|
+
isTurnInput: (key, ts) => {
|
|
164
|
+
const place = slackPlaceOf(key);
|
|
165
|
+
return seen.has(`${place.teamId}:${place.channelId}:${ts}`);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
// A group turn's room, for the thread-reading tool (`slackThreads`): the channel it was asked in, and its threads.
|
|
169
|
+
const rooms = createRoomThreads({
|
|
170
|
+
list: (room) => history.threads(room),
|
|
171
|
+
read: async (room, threadTs) => {
|
|
172
|
+
if (!/^\d+\.\d+$/.test(threadTs)) {
|
|
173
|
+
throw new Error(`"${threadTs}" is not a Slack thread id: pass a thread's id from the list (like 1712345678.123456)`);
|
|
324
174
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
175
|
+
const { teamId, channelId } = slackPlaceOf(room);
|
|
176
|
+
const key = slackHistoryKey(teamId, { channelId, threadTs });
|
|
177
|
+
return (await history.snapshot(key)).text || `(thread ${threadTs} shows no messages)`;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
registerRoomThreads("slack", stateHome, rooms);
|
|
181
|
+
/**
|
|
182
|
+
* The client a turn posts through: what it posts (answer, queue notice, stop feedback) is in the session already,
|
|
183
|
+
* so the place it LANDS in records it, and that place's next read leaves it out — an answer to a channel's
|
|
184
|
+
* top-level ask lands in the thread it opens. The send tool uses the plain client, so what the agent posts itself
|
|
185
|
+
* stays discussion. A routed turn has no place and reads no history.
|
|
186
|
+
*/
|
|
187
|
+
const placeApi = (turn) => turn.historyKey === undefined
|
|
188
|
+
? api
|
|
189
|
+
: api.recordingSends((where, ts) => history.recordOutput(slackHistoryKey(turn.teamId, where), ts));
|
|
190
|
+
const store = createTurnStore(join(stateHome, "turns.json"), {
|
|
191
|
+
label,
|
|
192
|
+
isRecord: isStoredSlackTurn,
|
|
193
|
+
order: (a, b) => a.seq - b.seq,
|
|
194
|
+
});
|
|
195
|
+
const decide = route ?? defaultSlackRoute;
|
|
196
|
+
/** A channel turn under the default routing reads its channel's threads; a DM has none to read. */
|
|
197
|
+
const enterRoom = (turn) => {
|
|
198
|
+
if (!turn.group || turn.historyKey === undefined)
|
|
199
|
+
return {};
|
|
200
|
+
const { teamId, channelId } = slackPlaceOf(turn.historyKey);
|
|
201
|
+
const room = slackHistoryKey(teamId, { channelId });
|
|
202
|
+
return { enter: (sessionId) => rooms.enter(sessionId, room) };
|
|
203
|
+
};
|
|
204
|
+
const targetOf = (turn) => ({
|
|
205
|
+
channelId: turn.channelId,
|
|
206
|
+
threadTs: turn.threadTs,
|
|
207
|
+
recipientUserId: turn.requesterUserId,
|
|
208
|
+
recipientTeamId: turn.teamId,
|
|
209
|
+
});
|
|
210
|
+
const notifyDropped = (turn) => {
|
|
211
|
+
const target = targetOf(turn);
|
|
212
|
+
if (turn.nativeQueueStatus)
|
|
213
|
+
void api.setThreadStatus(target, "").catch(() => { });
|
|
214
|
+
void settleSlackPreview(placeApi(turn), 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)}`));
|
|
215
|
+
};
|
|
216
|
+
const runner = createTurnRunner({
|
|
217
|
+
label,
|
|
218
|
+
store,
|
|
219
|
+
discussion: history,
|
|
220
|
+
seen,
|
|
221
|
+
toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
222
|
+
fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
|
|
223
|
+
// The read ends at the ask (`messageRefOf`: the ts of the message that asked).
|
|
224
|
+
discussionKey: (turn) => {
|
|
225
|
+
const ask = messageRefOf(turn.id);
|
|
226
|
+
return turn.historyKey === undefined || ask === undefined ? undefined : { key: turn.historyKey, until: ask.ts };
|
|
227
|
+
},
|
|
228
|
+
where: (turn) => `channel=${turn.channelId}`,
|
|
229
|
+
onQueuedBehind(turn) {
|
|
230
|
+
const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
|
|
231
|
+
if (nativeDmStatus)
|
|
232
|
+
turn.nativeQueueStatus = true;
|
|
233
|
+
return {
|
|
234
|
+
done: auth.settled
|
|
235
|
+
.then(() => nativeDmStatus
|
|
236
|
+
? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
|
|
237
|
+
: placeApi(turn)
|
|
238
|
+
.postMessage(targetOf(turn), QUEUED_PLACEHOLDER)
|
|
239
|
+
.then((ts) => {
|
|
240
|
+
turn.previewTs = ts;
|
|
241
|
+
}))
|
|
242
|
+
.catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
// Leave the intent untouched when Slack auth failed.
|
|
246
|
+
beforeRun: async (turn) => {
|
|
247
|
+
try {
|
|
248
|
+
await auth.settled;
|
|
249
|
+
return true;
|
|
329
250
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// Listening is not speaking: every message the channel can see refines who takes part in its thread, whether or
|
|
334
|
-
// not it is answered.
|
|
335
|
-
if (group && event.thread_ts !== undefined) {
|
|
336
|
-
threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
|
|
251
|
+
catch (error) {
|
|
252
|
+
log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
|
|
253
|
+
return false;
|
|
337
254
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// The participant model's thread rule (§3): a bare message reaches the agent while it takes part and has not
|
|
345
|
-
// heard a second human.
|
|
346
|
-
if (!routed &&
|
|
347
|
-
route === undefined &&
|
|
348
|
-
group &&
|
|
349
|
-
event.thread_ts !== undefined &&
|
|
350
|
-
event.type !== "app_mention" &&
|
|
351
|
-
// Mentioning only other people is targeted discussion, never an ask (§3) — the same guard Feishu applies with
|
|
352
|
-
// `hasMentions`.
|
|
353
|
-
!addressesSomeone &&
|
|
354
|
-
threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
|
|
355
|
-
routed = {};
|
|
255
|
+
},
|
|
256
|
+
onDeferred: (turn) => {
|
|
257
|
+
if (turn.nativeQueueStatus) {
|
|
258
|
+
void api
|
|
259
|
+
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
260
|
+
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
356
261
|
}
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
log.debug(`${label} not summoned — message ${logicalId}`);
|
|
360
|
-
return;
|
|
262
|
+
else if (turn.previewTs) {
|
|
263
|
+
void settleSlackPreview(placeApi(turn), targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
361
264
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
? slackHistoryKey(teamId, { channelId: event.channel, threadTs: event.thread_ts })
|
|
372
|
-
: undefined;
|
|
373
|
-
// Explicit user stop: a control action, never a turn — it must not queue behind the run it stops.
|
|
374
|
-
if (isStopText(stripSlackMentions(event.text ?? ""))) {
|
|
375
|
-
seen.add(logicalId);
|
|
376
|
-
const target = { channelId: event.channel, threadTs: event.thread_ts };
|
|
377
|
-
sideTasks.track(dispatchStop(agent, recordedSession(routed.conversation ?? defaultConversation), label)
|
|
378
|
-
.then((feedback) => placeApi({ historyKey, teamId })
|
|
379
|
-
.postMessage(target, feedback)
|
|
380
|
-
.then(() => undefined))
|
|
381
|
-
.catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
|
|
382
|
-
return;
|
|
265
|
+
},
|
|
266
|
+
notifyDropped,
|
|
267
|
+
deliverAnswer: (turn, answer) => portJoin(async () => {
|
|
268
|
+
// A queue notice THIS process put up outlives the run the answer came from, so it is settled here as it
|
|
269
|
+
// would be by a turn that ran: the status line cleared, the compatibility message taken over.
|
|
270
|
+
if (turn.nativeQueueStatus) {
|
|
271
|
+
await api
|
|
272
|
+
.setThreadStatus(targetOf(turn), "")
|
|
273
|
+
.catch((error) => log.warn(`${label} could not clear a queued Agent status: ${String(error)}`));
|
|
383
274
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
baseText,
|
|
396
|
-
...(historyKey !== undefined ? { historyKey } : {}),
|
|
397
|
-
group,
|
|
398
|
-
teamId,
|
|
399
|
-
channelId: targetChannel,
|
|
400
|
-
threadTs,
|
|
401
|
-
requesterUserId: event.user,
|
|
402
|
-
threadTitle: threadTitle || undefined,
|
|
403
|
-
fileIds,
|
|
404
|
-
}, true);
|
|
405
|
-
// Answering inside a GROUP thread makes the agent a participant of it, which is what lets the NEXT bare message
|
|
406
|
-
// address it without a mention.
|
|
407
|
-
if (group &&
|
|
408
|
-
threadTs !== undefined &&
|
|
409
|
-
sameChannel &&
|
|
410
|
-
routed.conversation === undefined &&
|
|
411
|
-
routed.threadTs === undefined) {
|
|
412
|
-
// The ASKER counts as heard in this thread too, and both halves are written together so the record can never
|
|
413
|
-
// say "the agent takes part and nobody has spoken".
|
|
414
|
-
threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
|
|
415
|
-
agentSpoke: true,
|
|
416
|
-
humans: [event.user],
|
|
275
|
+
await deliverSlackAnswer(placeApi(turn), targetOf(turn), answer, turn.previewTs);
|
|
276
|
+
// The 👀 on the asker's message was added by the run that produced this answer, and `execute`'s
|
|
277
|
+
// acquire/release — the only thing that turns it into ✅ — is not on this path.
|
|
278
|
+
const messageRef = messageRefOf(turn.id);
|
|
279
|
+
if (reactionEmojis && messageRef) {
|
|
280
|
+
await completeSlackReaction({
|
|
281
|
+
api,
|
|
282
|
+
channelId: messageRef.channelId,
|
|
283
|
+
ts: messageRef.ts,
|
|
284
|
+
emojis: reactionEmojis,
|
|
285
|
+
label,
|
|
417
286
|
});
|
|
418
287
|
}
|
|
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
|
-
|
|
288
|
+
}),
|
|
289
|
+
execute: (turn, discussion, onAnswered) => {
|
|
290
|
+
const messageRef = messageRefOf(turn.id);
|
|
291
|
+
return Effect.acquireUseRelease(portJoin(async () => reactionEmojis && messageRef
|
|
292
|
+
? startSlackReaction({
|
|
293
|
+
api,
|
|
294
|
+
channelId: messageRef.channelId,
|
|
295
|
+
ts: messageRef.ts,
|
|
296
|
+
emojis: reactionEmojis,
|
|
297
|
+
label,
|
|
298
|
+
})
|
|
299
|
+
: undefined), () => Effect.scoped(slackReply(slackTurnStream(agent, {
|
|
300
|
+
session: () => sessionFor(turn.conversation),
|
|
301
|
+
...enterRoom(turn),
|
|
302
|
+
key: turn.id,
|
|
303
|
+
text: `${discussionBlock(discussion.text)}${turn.baseText}`,
|
|
304
|
+
}, { api, channelId: turn.channelId, filesDir: attachmentsDir(stateHome), label }, {
|
|
305
|
+
primaryFileIds: turn.fileIds,
|
|
306
|
+
buffered: collectFoldedFiles(discussion.consumed.flatMap((read) => read.folded), new Set(turn.fileIds)),
|
|
307
|
+
}), placeApi(turn), targetOf(turn), formatError, {
|
|
308
|
+
rendering,
|
|
309
|
+
initialPreviewTs: turn.previewTs,
|
|
310
|
+
threadTitle: turn.threadTitle,
|
|
311
|
+
disclaimer: aiDisclaimer,
|
|
312
|
+
label,
|
|
313
|
+
onAnswered,
|
|
314
|
+
})), (reaction, exit) => portJoin(async () => {
|
|
315
|
+
if (Exit.isSuccess(exit))
|
|
316
|
+
await reaction?.complete();
|
|
317
|
+
else
|
|
318
|
+
await reaction?.remove();
|
|
319
|
+
}).pipe(
|
|
320
|
+
// A release does not fail: its defect would make the turn's typed failure one too. Unreachable while
|
|
321
|
+
// the reaction's `complete` and `remove` catch their own failures (reaction.ts).
|
|
322
|
+
Effect.catchTag("PortFailure", (error) => Effect.sync(() => log.warn(`${label} could not settle the Slack reaction: ${String(error.cause)}`)))));
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
326
|
+
// Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the delivery dedup ring
|
|
327
|
+
// alone is enough.
|
|
328
|
+
const acceptEvent = (envelope) => {
|
|
329
|
+
const event = envelope.event;
|
|
330
|
+
if (!isSlackHumanMessage(event))
|
|
331
|
+
return;
|
|
332
|
+
const botUserId = auth.botUserId();
|
|
333
|
+
if (botUserId && event.user === botUserId)
|
|
334
|
+
return;
|
|
335
|
+
const teamId = slackTeamId(envelope) ?? auth.teamId();
|
|
336
|
+
if (!teamId) {
|
|
337
|
+
log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const logicalId = `${teamId}:${event.channel}:${event.ts}`;
|
|
341
|
+
if (seen.has(logicalId)) {
|
|
342
|
+
log.debug(`${label} duplicate logical message ${logicalId} — skipping`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const group = isSlackGroupMessage(event);
|
|
346
|
+
const direct = isSlackDirectMessage(event);
|
|
347
|
+
const rootTs = event.thread_ts ?? event.ts;
|
|
348
|
+
// Listening is not speaking: every message the channel can see refines who takes part in its thread, whether or
|
|
349
|
+
// not it is answered.
|
|
350
|
+
if (group && event.thread_ts !== undefined) {
|
|
351
|
+
threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
|
|
352
|
+
}
|
|
353
|
+
let routed = decide(envelope);
|
|
354
|
+
const addressesSomeone = hasSlackMention(event.text ?? "");
|
|
355
|
+
const structurallyMentionsBot = botUserId !== undefined && mentionsSlackUser(event.text ?? "", botUserId);
|
|
356
|
+
// app_mention and message.* subscriptions can overlap.
|
|
357
|
+
if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
|
|
358
|
+
routed = {};
|
|
359
|
+
// The participant model's thread rule (§3): a bare message reaches the agent while it takes part and has not
|
|
360
|
+
// heard a second human.
|
|
361
|
+
if (!routed &&
|
|
362
|
+
route === undefined &&
|
|
363
|
+
group &&
|
|
364
|
+
event.thread_ts !== undefined &&
|
|
365
|
+
event.type !== "app_mention" &&
|
|
366
|
+
// Mentioning only other people is targeted discussion, never an ask (§3) — the same guard Feishu applies with
|
|
367
|
+
// `hasMentions`.
|
|
368
|
+
!addressesSomeone &&
|
|
369
|
+
threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
|
|
370
|
+
routed = {};
|
|
371
|
+
}
|
|
372
|
+
if (!routed) {
|
|
373
|
+
// Not lost: the turn that is summoned here reads it from the place's history.
|
|
374
|
+
log.debug(`${label} not summoned — message ${logicalId}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const targetChannel = routed.channelId ?? event.channel;
|
|
378
|
+
const sameChannel = targetChannel === event.channel;
|
|
379
|
+
// Answer where asked (participant model §4).
|
|
380
|
+
const defaultThread = event.thread_ts ?? event.ts;
|
|
381
|
+
const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
|
|
382
|
+
const defaultConversation = threadKey(teamId, event.channel, rootTs);
|
|
383
|
+
// Every place's discussion under the default routing, a DM's too (what a send tool posted there is in no session);
|
|
384
|
+
// a routed session's place is the router's to decide.
|
|
385
|
+
const historyKey = route === undefined && (group || direct)
|
|
386
|
+
? slackHistoryKey(teamId, { channelId: event.channel, threadTs: event.thread_ts })
|
|
387
|
+
: undefined;
|
|
388
|
+
// Explicit user stop: a control action, never a turn — it must not queue behind the run it stops.
|
|
389
|
+
if (isStopText(stripSlackMentions(event.text ?? ""))) {
|
|
390
|
+
seen.add(logicalId);
|
|
391
|
+
const target = { channelId: event.channel, threadTs: event.thread_ts };
|
|
392
|
+
sideTasks.track(dispatchStop(agent, recordedSession(routed.conversation ?? defaultConversation), label)
|
|
393
|
+
.then((feedback) => placeApi({ historyKey, teamId })
|
|
394
|
+
.postMessage(target, feedback)
|
|
395
|
+
.then(() => undefined))
|
|
396
|
+
.catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const fileIds = slackFileIds(event);
|
|
400
|
+
const baseText = routed.text ?? slackEnvelope(envelope);
|
|
401
|
+
if (!baseText.trim() && fileIds.length === 0)
|
|
402
|
+
return;
|
|
403
|
+
const threadTitle = direct && event.thread_ts === undefined
|
|
404
|
+
? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
|
|
405
|
+
: undefined;
|
|
406
|
+
runner.submit({
|
|
407
|
+
id: logicalId,
|
|
408
|
+
seq: ++seq,
|
|
409
|
+
conversation: routed.conversation ?? defaultConversation,
|
|
410
|
+
baseText,
|
|
411
|
+
...(historyKey !== undefined ? { historyKey } : {}),
|
|
412
|
+
group,
|
|
413
|
+
teamId,
|
|
414
|
+
channelId: targetChannel,
|
|
415
|
+
threadTs,
|
|
416
|
+
requesterUserId: event.user,
|
|
417
|
+
threadTitle: threadTitle || undefined,
|
|
418
|
+
fileIds,
|
|
419
|
+
}, true);
|
|
420
|
+
// Answering inside a GROUP thread makes the agent a participant of it, which is what lets the NEXT bare message
|
|
421
|
+
// address it without a mention.
|
|
422
|
+
if (group &&
|
|
423
|
+
threadTs !== undefined &&
|
|
424
|
+
sameChannel &&
|
|
425
|
+
routed.conversation === undefined &&
|
|
426
|
+
routed.threadTs === undefined) {
|
|
427
|
+
// The ASKER counts as heard in this thread too, and both halves are written together so the record can never
|
|
428
|
+
// say "the agent takes part and nobody has spoken".
|
|
429
|
+
threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
|
|
430
|
+
agentSpoke: true,
|
|
431
|
+
humans: [event.user],
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
// First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
|
|
436
|
+
const welcomeInFlight = new Set();
|
|
437
|
+
const maybeWelcome = (envelope) => {
|
|
438
|
+
if (welcome === false)
|
|
439
|
+
return;
|
|
440
|
+
const body = welcome.trim();
|
|
441
|
+
if (!body)
|
|
442
|
+
return;
|
|
443
|
+
const event = envelope.event;
|
|
444
|
+
if (!event)
|
|
445
|
+
return;
|
|
446
|
+
if (event.type !== "app_home_opened" || event.tab !== "messages")
|
|
447
|
+
return;
|
|
448
|
+
const userId = event.user;
|
|
449
|
+
const channelId = event.channel;
|
|
450
|
+
if (!userId || !channelId)
|
|
451
|
+
return;
|
|
452
|
+
const teamId = slackTeamId(envelope) ?? auth.teamId();
|
|
453
|
+
if (!teamId)
|
|
454
|
+
return;
|
|
455
|
+
const id = `${teamId}:${userId}`;
|
|
456
|
+
if (welcomed.has(teamId, userId) || welcomeInFlight.has(id))
|
|
457
|
+
return;
|
|
458
|
+
// Reserve in-memory so rapid re-opens don't double-post; persist to the durable set only on a successful post,
|
|
459
|
+
// so a failed post simply retries on the next open.
|
|
460
|
+
welcomeInFlight.add(id);
|
|
461
|
+
sideTasks.track(api
|
|
462
|
+
.postMarkdown({ channelId }, body)
|
|
463
|
+
.then(() => {
|
|
464
|
+
welcomed.add(teamId, userId);
|
|
465
|
+
log.info(`${label} sent first-run welcome to ${id}`);
|
|
466
|
+
})
|
|
467
|
+
.catch((error) => log.warn(`${label} could not send first-run welcome (retries on next open): ${String(error)}`))
|
|
468
|
+
.finally(() => {
|
|
469
|
+
welcomeInFlight.delete(id);
|
|
470
|
+
}));
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
ingest: async (envelope) => {
|
|
474
|
+
await auth.ready();
|
|
475
|
+
maybeWelcome(envelope);
|
|
476
|
+
acceptEvent(envelope);
|
|
477
|
+
},
|
|
478
|
+
turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** Build a Slack channel that receives by the Events API's HTTP Request URL: signed requests in, durable turns out. */
|
|
482
|
+
export function slackChannel(options) {
|
|
483
|
+
const { signingSecret } = options;
|
|
484
|
+
const policy = resolveSlackPolicy(options);
|
|
485
|
+
return (ctx) => {
|
|
486
|
+
if (!signingSecret)
|
|
487
|
+
throw new Error("slackChannel requires a non-empty signingSecret (Basic Information → App Credentials)");
|
|
488
|
+
const runtime = createSlackRuntime(policy, ctx);
|
|
489
|
+
const { stateDir: stateHome } = ctx;
|
|
457
490
|
const handler = async (request) => {
|
|
458
491
|
if (request.method !== "POST")
|
|
459
492
|
return text("POST only\n", 405);
|
|
@@ -463,9 +496,11 @@ export function slackChannel(options) {
|
|
|
463
496
|
const timestamp = request.headers.get("x-slack-request-timestamp") ?? "";
|
|
464
497
|
const signature = request.headers.get("x-slack-signature") ?? "";
|
|
465
498
|
if (!verifySlackSignature(signingSecret, timestamp, signature, body.text)) {
|
|
466
|
-
log.warn(
|
|
499
|
+
log.warn("[slack] rejected an event with an invalid/stale X-Slack-Signature");
|
|
467
500
|
return text("invalid signature\n", 401);
|
|
468
501
|
}
|
|
502
|
+
// Slack signed it: the Request URL a person set reaches this instance, at the address it was sent to.
|
|
503
|
+
recordReached(stateHome, request);
|
|
469
504
|
let envelope;
|
|
470
505
|
try {
|
|
471
506
|
envelope = JSON.parse(body.text);
|
|
@@ -481,17 +516,42 @@ export function slackChannel(options) {
|
|
|
481
516
|
if (envelope.type !== "event_callback")
|
|
482
517
|
return new Response(null, { status: 200 });
|
|
483
518
|
try {
|
|
484
|
-
await
|
|
519
|
+
await runtime.ingest(envelope);
|
|
485
520
|
}
|
|
486
521
|
catch (error) {
|
|
487
|
-
log.error(
|
|
522
|
+
log.error(`[slack] refusing to ACK an event because Slack authentication is unavailable: ${String(error)}`);
|
|
488
523
|
return text("slack authentication unavailable\n", 503);
|
|
489
524
|
}
|
|
490
|
-
maybeWelcome(envelope);
|
|
491
|
-
acceptEvent(envelope);
|
|
492
525
|
return new Response(null, { status: 200 });
|
|
493
526
|
};
|
|
494
|
-
handler.turnsIdle =
|
|
527
|
+
handler.turnsIdle = runtime.turnsIdle;
|
|
495
528
|
return { "POST /slack": handler };
|
|
496
529
|
};
|
|
497
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Build a Slack channel that receives by Socket Mode: the process opens a WebSocket to Slack with the app-level token
|
|
533
|
+
* and takes its events there, so no public URL and no signing secret are involved. An app in Socket Mode receives over
|
|
534
|
+
* the socket alone: it has no Request URL to point anywhere else.
|
|
535
|
+
*/
|
|
536
|
+
export function slackSocketChannel(options,
|
|
537
|
+
/** Internal seam for deterministic tests: the socket the connection opens. */
|
|
538
|
+
deps = {}) {
|
|
539
|
+
const policy = resolveSlackPolicy(options);
|
|
540
|
+
const { appToken } = options;
|
|
541
|
+
return {
|
|
542
|
+
name: "slack socket mode",
|
|
543
|
+
connect(ctx, signal) {
|
|
544
|
+
if (!/^xapp-/.test(appToken)) {
|
|
545
|
+
throw new Error("slackSocketChannel requires an app-level token (xapp-…, scope connections:write: Basic Information → " +
|
|
546
|
+
"App-Level Tokens), not a bot token");
|
|
547
|
+
}
|
|
548
|
+
const runtime = createSlackRuntime(policy, ctx);
|
|
549
|
+
return connectSlackSocket({
|
|
550
|
+
apiBaseUrl: policy.apiBaseUrl,
|
|
551
|
+
appToken,
|
|
552
|
+
ingest: runtime.ingest,
|
|
553
|
+
...(deps.openSocket === undefined ? {} : { openSocket: deps.openSocket }),
|
|
554
|
+
}, signal);
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|