@fastagent-sh/fastagent 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -12,6 +12,16 @@ export interface DownloadedSlackFile {
|
|
|
12
12
|
name: string;
|
|
13
13
|
size: number;
|
|
14
14
|
}
|
|
15
|
+
export interface UploadedSlackFile {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SentSlackMessage {
|
|
20
|
+
/** The first message's ts (a long Markdown body continues in further messages). */
|
|
21
|
+
ts: string;
|
|
22
|
+
/** Where it landed: a user-id target (`U…`) resolves to that user's DM channel (`D…`). */
|
|
23
|
+
channelId: string;
|
|
24
|
+
}
|
|
15
25
|
export declare class SlackApiError extends Error {
|
|
16
26
|
readonly method: string;
|
|
17
27
|
readonly status: number;
|
|
@@ -24,7 +34,7 @@ export declare class SlackApiError extends Error {
|
|
|
24
34
|
* internal, and timeout failures are ambiguous: Slack may already have created the stream. */
|
|
25
35
|
export declare function isSlackNativeUnavailable(error: unknown): boolean;
|
|
26
36
|
export interface SlackApiOptions {
|
|
27
|
-
botToken: string
|
|
37
|
+
botToken: string;
|
|
28
38
|
baseUrl?: string;
|
|
29
39
|
}
|
|
30
40
|
export interface SlackApi {
|
|
@@ -38,7 +48,9 @@ export interface SlackApi {
|
|
|
38
48
|
updateMessage(channelId: string, ts: string, text: string): Promise<void>;
|
|
39
49
|
updateMarkdown(channelId: string, ts: string, markdown: string): Promise<void>;
|
|
40
50
|
deleteMessage(channelId: string, ts: string): Promise<void>;
|
|
41
|
-
|
|
51
|
+
/** Post standard Markdown, split under Slack's limit. `target.channelId` may be a user id: Slack
|
|
52
|
+
* then opens (or reuses) the app's DM with that user, and the result names it. */
|
|
53
|
+
sendMarkdown(target: SlackTarget, markdown: string): Promise<SentSlackMessage>;
|
|
42
54
|
startStream(target: SlackTarget, markdown?: string): Promise<string>;
|
|
43
55
|
appendStream(channelId: string, ts: string, markdown: string): Promise<void>;
|
|
44
56
|
stopStream(channelId: string, ts: string, markdown?: string): Promise<void>;
|
|
@@ -49,6 +61,12 @@ export interface SlackApi {
|
|
|
49
61
|
fileInfo(fileId: string): Promise<SlackFile>;
|
|
50
62
|
fetchImage(file: SlackFile): Promise<ImageRef>;
|
|
51
63
|
fetchFile(file: SlackFile, channelId: string, filesDir: string): Promise<DownloadedSlackFile>;
|
|
64
|
+
/** Upload one local file into a channel/thread through Slack's external-upload protocol
|
|
65
|
+
* (getUploadURLExternal → bytes → completeUploadExternal). */
|
|
66
|
+
uploadFile(target: SlackTarget, path: string, options?: {
|
|
67
|
+
title?: string;
|
|
68
|
+
initialComment?: string;
|
|
69
|
+
}): Promise<UploadedSlackFile>;
|
|
52
70
|
}
|
|
53
71
|
/** Split a Slack text/Markdown field at a code-point-safe boundary, preferring a newline. */
|
|
54
72
|
export declare function chunkSlackText(text: string, maxPoints?: number): string[];
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
/** Slack Web API transport: one JSON pipeline
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
/** Slack Web API transport: one JSON pipeline, authenticated capped private-file downloads, and the
|
|
2
|
+
* external-upload protocol. */
|
|
3
|
+
import { mkdir, open, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { attachmentPath } from "../kit/attachment-path.js";
|
|
4
6
|
import { codePointPrefix } from "../kit/text.js";
|
|
5
7
|
const API_TIMEOUT_MS = 30_000;
|
|
6
|
-
const
|
|
8
|
+
const FILE_TRANSFER_TIMEOUT_MS = 120_000;
|
|
7
9
|
const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
|
8
10
|
const RETRIES = 3;
|
|
9
11
|
const MAX_RETRY_AFTER_S = 30;
|
|
10
|
-
const MAX_REDIRECTS = 3;
|
|
11
12
|
/** Slack's standard-Markdown fields cap each call at 12,000 characters. Keep headroom for
|
|
12
13
|
* code-fence balancing and future server-side transformations. */
|
|
13
14
|
const SLACK_MAX_MARKDOWN = 10_000;
|
|
@@ -95,11 +96,6 @@ export function chunkSlackMarkdown(markdown, maxPoints = SLACK_MAX_MARKDOWN) {
|
|
|
95
96
|
}
|
|
96
97
|
return output;
|
|
97
98
|
}
|
|
98
|
-
function safeFileName(file) {
|
|
99
|
-
const raw = file.name ?? file.title ?? file.id ?? "file";
|
|
100
|
-
const safe = raw.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
|
|
101
|
-
return `${file.id ?? "slack"}-${safe}`;
|
|
102
|
-
}
|
|
103
99
|
function fileDownloadUrl(file) {
|
|
104
100
|
if (file.file_access === "check_file_info")
|
|
105
101
|
throw new Error(`Slack file ${file.id ?? "(unknown)"} is not ready`);
|
|
@@ -138,25 +134,17 @@ async function readBytesCapped(response) {
|
|
|
138
134
|
}
|
|
139
135
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
140
136
|
}
|
|
137
|
+
/** A rotating token (an app created by a release up to 0.20) would work for up to 12 hours and then
|
|
138
|
+
* fail as `token_expired`, far from the cause — refuse it where the token enters, on both paths. */
|
|
139
|
+
function assertLongLivedBotToken(botToken) {
|
|
140
|
+
if (botToken.startsWith("xoxe.")) {
|
|
141
|
+
throw new Error("got a rotating Slack bot token (xoxe.…); this release uses a long-lived xoxb- token — " +
|
|
142
|
+
'create a new app (docs/slack.md → "Upgrading from a rotating-token app")');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
141
145
|
export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
|
|
146
|
+
assertLongLivedBotToken(botToken);
|
|
142
147
|
const apiBase = baseUrl.replace(/\/$/, "");
|
|
143
|
-
const configuredOrigin = new URL(apiBase).origin;
|
|
144
|
-
const currentToken = typeof botToken === "string" ? async () => botToken : botToken;
|
|
145
|
-
const trustedDownloadUrl = (value) => {
|
|
146
|
-
const url = new URL(value);
|
|
147
|
-
const host = url.hostname.toLowerCase();
|
|
148
|
-
const slackHost = host === "slack-files.com" ||
|
|
149
|
-
host.endsWith(".slack-files.com") ||
|
|
150
|
-
host === "slack.com" ||
|
|
151
|
-
host.endsWith(".slack.com") ||
|
|
152
|
-
host === "slack-edge.com" ||
|
|
153
|
-
host.endsWith(".slack-edge.com");
|
|
154
|
-
if (!slackHost && url.origin !== configuredOrigin)
|
|
155
|
-
throw new Error(`refusing non-Slack file URL host ${host}`);
|
|
156
|
-
if (url.protocol !== "https:" && url.origin !== configuredOrigin)
|
|
157
|
-
throw new Error("refusing non-HTTPS Slack file URL");
|
|
158
|
-
return url;
|
|
159
|
-
};
|
|
160
148
|
const call = async (method, body, httpMethod = "POST") => {
|
|
161
149
|
const url = new URL(`${apiBase}/${method}`);
|
|
162
150
|
if (httpMethod === "GET") {
|
|
@@ -169,11 +157,10 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
169
157
|
let response;
|
|
170
158
|
let raw;
|
|
171
159
|
try {
|
|
172
|
-
const token = await currentToken();
|
|
173
160
|
response = await fetch(url, {
|
|
174
161
|
method: httpMethod,
|
|
175
162
|
headers: {
|
|
176
|
-
authorization: `Bearer ${
|
|
163
|
+
authorization: `Bearer ${botToken}`,
|
|
177
164
|
...(httpMethod === "POST" ? { "content-type": "application/json; charset=utf-8" } : {}),
|
|
178
165
|
},
|
|
179
166
|
...(httpMethod === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
@@ -211,36 +198,38 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
211
198
|
throw new SlackApiError(method, response.status, detail, data.error);
|
|
212
199
|
}
|
|
213
200
|
};
|
|
201
|
+
// Slack owns both halves of this download: the URL comes from its own files.info response, and it
|
|
202
|
+
// documents url_private as taking our bearer token. Where that URL leads is Slack's call; whether a
|
|
203
|
+
// redirect hop still carries the token is fetch's, which drops the header cross-origin.
|
|
214
204
|
const download = async (file) => {
|
|
215
|
-
let
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
headers: { authorization: `Bearer ${token}` },
|
|
222
|
-
redirect: "manual",
|
|
223
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
|
|
228
|
-
}
|
|
229
|
-
if (response.status >= 300 && response.status < 400) {
|
|
230
|
-
const location = response.headers.get("location");
|
|
231
|
-
if (!location || redirect >= MAX_REDIRECTS) {
|
|
232
|
-
throw new SlackApiError("file download", response.status, "invalid or excessive redirect");
|
|
233
|
-
}
|
|
234
|
-
await response.body?.cancel().catch(() => { });
|
|
235
|
-
url = trustedDownloadUrl(new URL(location, url).toString());
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
if (!response.ok) {
|
|
239
|
-
const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
|
|
240
|
-
throw new SlackApiError("file download", response.status, detail);
|
|
241
|
-
}
|
|
242
|
-
return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await fetch(fileDownloadUrl(file), {
|
|
208
|
+
headers: { authorization: `Bearer ${botToken}` },
|
|
209
|
+
signal: AbortSignal.timeout(FILE_TRANSFER_TIMEOUT_MS),
|
|
210
|
+
});
|
|
243
211
|
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
|
|
214
|
+
}
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
|
|
217
|
+
throw new SlackApiError("file download", response.status, detail);
|
|
218
|
+
}
|
|
219
|
+
return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
|
|
220
|
+
};
|
|
221
|
+
const postMarkdown = async (target, markdown) => {
|
|
222
|
+
const data = await call("chat.postMessage", {
|
|
223
|
+
channel: target.channelId,
|
|
224
|
+
markdown_text: markdown,
|
|
225
|
+
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
226
|
+
unfurl_links: false,
|
|
227
|
+
unfurl_media: false,
|
|
228
|
+
});
|
|
229
|
+
if (!data.ts)
|
|
230
|
+
throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
|
|
231
|
+
// The response's `channel` is what resolves a user-id target to its DM; a channel target echoes itself.
|
|
232
|
+
return { ts: data.ts, channelId: data.channel ?? target.channelId };
|
|
244
233
|
};
|
|
245
234
|
const api = {
|
|
246
235
|
async authTest() {
|
|
@@ -260,16 +249,7 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
260
249
|
return data.ts;
|
|
261
250
|
},
|
|
262
251
|
async postMarkdown(target, markdown) {
|
|
263
|
-
|
|
264
|
-
channel: target.channelId,
|
|
265
|
-
markdown_text: markdown,
|
|
266
|
-
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
267
|
-
unfurl_links: false,
|
|
268
|
-
unfurl_media: false,
|
|
269
|
-
});
|
|
270
|
-
if (!data.ts)
|
|
271
|
-
throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
|
|
272
|
-
return data.ts;
|
|
252
|
+
return (await postMarkdown(target, markdown)).ts;
|
|
273
253
|
},
|
|
274
254
|
async updateMessage(channelId, ts, text) {
|
|
275
255
|
try {
|
|
@@ -297,10 +277,11 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
297
277
|
async sendMarkdown(target, markdown) {
|
|
298
278
|
let first;
|
|
299
279
|
for (const chunk of chunkSlackMarkdown(markdown)) {
|
|
300
|
-
|
|
301
|
-
first
|
|
280
|
+
// Continuations go to the resolved channel: a user-id target must not reopen the DM per chunk.
|
|
281
|
+
const sent = await postMarkdown(first ? { ...target, channelId: first.channelId } : target, chunk);
|
|
282
|
+
first ??= sent;
|
|
302
283
|
}
|
|
303
|
-
return first;
|
|
284
|
+
return first; // chunkSlackMarkdown never yields zero chunks
|
|
304
285
|
},
|
|
305
286
|
async startStream(target, markdown) {
|
|
306
287
|
if (!target.threadTs)
|
|
@@ -387,13 +368,59 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
387
368
|
},
|
|
388
369
|
async fetchFile(file, channelId, filesDir) {
|
|
389
370
|
const { bytes } = await download(file);
|
|
390
|
-
|
|
371
|
+
// The id prefix keeps two same-named uploads in one channel apart.
|
|
372
|
+
const suggested = `${file.id ?? "slack"}-${file.name ?? file.title ?? file.id ?? "file"}`;
|
|
373
|
+
const { dir, name, path } = attachmentPath(filesDir, channelId, suggested);
|
|
391
374
|
await mkdir(dir, { recursive: true });
|
|
392
|
-
const name = safeFileName(file);
|
|
393
|
-
const path = join(dir, name);
|
|
394
375
|
await writeFile(path, bytes);
|
|
395
376
|
return { path, name, size: bytes.byteLength };
|
|
396
377
|
},
|
|
378
|
+
async uploadFile(target, path, options = {}) {
|
|
379
|
+
const info = await stat(path);
|
|
380
|
+
if (!info.isFile())
|
|
381
|
+
throw new Error(`${path} is not a regular file`);
|
|
382
|
+
const name = basename(path);
|
|
383
|
+
const upload = await call("files.getUploadURLExternal", {
|
|
384
|
+
filename: name,
|
|
385
|
+
length: info.size,
|
|
386
|
+
});
|
|
387
|
+
if (!upload.upload_url || !upload.file_id) {
|
|
388
|
+
throw new SlackApiError("files.getUploadURLExternal", 200, "response carried no upload_url/file_id");
|
|
389
|
+
}
|
|
390
|
+
// Where upload_url points is Slack's call, as with url_private above; the bytes carry no token.
|
|
391
|
+
const handle = await open(path, "r");
|
|
392
|
+
let response;
|
|
393
|
+
try {
|
|
394
|
+
response = await fetch(upload.upload_url, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: { "content-type": "application/octet-stream" },
|
|
397
|
+
body: handle.readableWebStream(),
|
|
398
|
+
signal: AbortSignal.timeout(FILE_TRANSFER_TIMEOUT_MS),
|
|
399
|
+
// Required by Node fetch for a streaming request body; not part of the browser RequestInit type.
|
|
400
|
+
duplex: "half",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
throw new SlackApiError("file upload", 0, String(error), undefined, { cause: error });
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
await handle.close().catch(() => { });
|
|
408
|
+
}
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
|
|
411
|
+
throw new SlackApiError("file upload", response.status, detail);
|
|
412
|
+
}
|
|
413
|
+
// At-least-once: if Slack commits this call but the response is lost, a retry may post a
|
|
414
|
+
// duplicate. Retrying here would hide that ambiguity, so only an explicit agent/user retry
|
|
415
|
+
// crosses this final side-effect boundary.
|
|
416
|
+
await call("files.completeUploadExternal", {
|
|
417
|
+
files: [{ id: upload.file_id, title: options.title ?? name }],
|
|
418
|
+
channel_id: target.channelId,
|
|
419
|
+
...(options.initialComment ? { initial_comment: options.initialComment } : {}),
|
|
420
|
+
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
421
|
+
});
|
|
422
|
+
return { id: upload.file_id, name };
|
|
423
|
+
},
|
|
397
424
|
};
|
|
398
425
|
return api;
|
|
399
426
|
}
|
|
@@ -8,16 +8,6 @@ export interface SlackChannelOptions {
|
|
|
8
8
|
botToken: string;
|
|
9
9
|
/** App signing secret used to verify the raw Events API request body. */
|
|
10
10
|
signingSecret: string;
|
|
11
|
-
/** Rotating OAuth credentials. Omit all four only for a manually managed long-lived bot token. */
|
|
12
|
-
botRefreshToken?: string;
|
|
13
|
-
clientId?: string;
|
|
14
|
-
clientSecret?: string;
|
|
15
|
-
botTokenExpiresAt?: number;
|
|
16
|
-
/** `context` (default) subscribes to group message streams, which is what lets the channel HEAR a
|
|
17
|
-
* thread: bare replies are then admitted by the participation rule (design/participant-model.md §3),
|
|
18
|
-
* and other discussion is buffered. `mentions` answers only app_mention plus DMs for an explicit
|
|
19
|
-
* least-privilege setup. */
|
|
20
|
-
groupBehavior?: "context" | "mentions";
|
|
21
11
|
/** `native` (default) uses Slack Agent streams for threaded replies. Its inline tool traces carry a
|
|
22
12
|
* bounded summary of each call's first argument and cannot be retracted, so they stay in the
|
|
23
13
|
* delivered message beside the answer. `classic` retains the compatibility renderer based on one
|
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
/** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
|
|
2
|
-
import { createHmac
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import { log } from "../../log.js";
|
|
5
5
|
import { readBodyCapped } from "../body.js";
|
|
6
6
|
import { text } from "../respond.js";
|
|
7
|
+
import { secretEquals } from "../secret.js";
|
|
7
8
|
import { createSeenRing } from "../kit/seen.js";
|
|
9
|
+
import { signatureIsFresh } from "../kit/signature.js";
|
|
8
10
|
import { createThreadParticipants } from "../kit/thread-participants.js";
|
|
9
11
|
import { createTaskTracker } from "../kit/tasks.js";
|
|
10
12
|
import { ensureStateHome } from "../kit/state.js";
|
|
11
13
|
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
12
14
|
import { codePointPrefix } from "../kit/text.js";
|
|
13
|
-
import {
|
|
15
|
+
import { createTurnRunner } from "../kit/turn-runner.js";
|
|
14
16
|
import { createTurnStore } from "../kit/turn-store.js";
|
|
15
|
-
import {
|
|
17
|
+
import { discussionBlock } from "../kit/context-buffer.js";
|
|
16
18
|
import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
|
|
17
19
|
import { invokeSlackTurn } from "./invoke-turn.js";
|
|
18
20
|
import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
|
|
19
21
|
import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
|
|
20
22
|
import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
|
|
23
|
+
import { registerSlackApi } from "./shared-api.js";
|
|
21
24
|
import { createSlackApi } from "./slack-api.js";
|
|
22
25
|
import { createWelcomedUsers } from "./welcomed.js";
|
|
23
26
|
export { defaultSlackRoute, slackEnvelope };
|
|
24
27
|
const MAX_EVENT_BYTES = 1 << 20;
|
|
25
|
-
|
|
28
|
+
/** Slack's own documented window — it re-signs every redelivery with a current timestamp, so a tight
|
|
29
|
+
* one costs nothing. The Feishu ingress reads hours off the same helper for the opposite reason. */
|
|
26
30
|
const MAX_SIGNATURE_AGE_S = 5 * 60;
|
|
27
31
|
const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
|
|
28
32
|
const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
|
|
@@ -45,15 +49,12 @@ function isStoredSlackTurn(value) {
|
|
|
45
49
|
}
|
|
46
50
|
/** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
|
|
47
51
|
export function verifySlackSignature(signingSecret, timestamp, signature, rawBody, nowMs = Date.now()) {
|
|
48
|
-
if (
|
|
52
|
+
if (!/^v0=[a-f0-9]{64}$/i.test(signature))
|
|
49
53
|
return false;
|
|
50
|
-
|
|
51
|
-
if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(nowMs / 1000) - seconds) > MAX_SIGNATURE_AGE_S)
|
|
54
|
+
if (!signatureIsFresh(timestamp, MAX_SIGNATURE_AGE_S, nowMs))
|
|
52
55
|
return false;
|
|
53
56
|
const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
|
|
54
|
-
|
|
55
|
-
const expectedBytes = Buffer.from(expected);
|
|
56
|
-
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
57
|
+
return secretEquals(signature, expected);
|
|
57
58
|
}
|
|
58
59
|
/** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
|
|
59
60
|
* (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
|
|
@@ -62,19 +63,7 @@ function messageRefOf(turnId) {
|
|
|
62
63
|
return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
|
|
63
64
|
}
|
|
64
65
|
export function slackChannel(options) {
|
|
65
|
-
const { botToken, signingSecret,
|
|
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
|
-
}
|
|
66
|
+
const { botToken, signingSecret, rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
|
|
78
67
|
if (!["native", "classic"].includes(rendering)) {
|
|
79
68
|
throw new Error('slackChannel rendering must be "native" or "classic"');
|
|
80
69
|
}
|
|
@@ -93,16 +82,8 @@ export function slackChannel(options) {
|
|
|
93
82
|
const formatError = onError ?? defaultErrorMessage;
|
|
94
83
|
const stateHome = join(stateRoot, "channels", "slack");
|
|
95
84
|
ensureStateHome(stateHome);
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
botToken,
|
|
99
|
-
botRefreshToken,
|
|
100
|
-
clientId,
|
|
101
|
-
clientSecret,
|
|
102
|
-
botTokenExpiresAt,
|
|
103
|
-
apiBaseUrl,
|
|
104
|
-
});
|
|
105
|
-
const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
|
|
85
|
+
const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
|
|
86
|
+
registerSlackApi(stateRoot, api); // the send tool delivers through this one (shared-api.ts)
|
|
106
87
|
let authenticatedTeamId;
|
|
107
88
|
let botUserId;
|
|
108
89
|
let authenticationState = "pending";
|
|
@@ -134,6 +115,9 @@ export function slackChannel(options) {
|
|
|
134
115
|
});
|
|
135
116
|
});
|
|
136
117
|
};
|
|
118
|
+
// Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle. Declared
|
|
119
|
+
// ahead of its consumers so no construction-time path can reach it in the temporal dead zone.
|
|
120
|
+
const sideTasks = createTaskTracker(label);
|
|
137
121
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
138
122
|
const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
|
|
139
123
|
/** A thread's participation is keyed by the SESSION it describes — "the agent answered here" is a
|
|
@@ -149,64 +133,66 @@ export function slackChannel(options) {
|
|
|
149
133
|
order: (a, b) => a.seq - b.seq,
|
|
150
134
|
});
|
|
151
135
|
const decide = route ?? defaultSlackRoute;
|
|
152
|
-
const toStored = (turn) => {
|
|
153
|
-
const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
|
|
154
|
-
return { ...intent, attempts: 0 };
|
|
155
|
-
};
|
|
156
136
|
const targetOf = (turn) => ({
|
|
157
137
|
channelId: turn.channelId,
|
|
158
138
|
threadTs: turn.threadTs,
|
|
159
139
|
recipientUserId: turn.requesterUserId,
|
|
160
140
|
recipientTeamId: turn.teamId,
|
|
161
141
|
});
|
|
162
|
-
const
|
|
163
|
-
|
|
142
|
+
const notifyDropped = (turn) => {
|
|
143
|
+
const target = targetOf(turn);
|
|
144
|
+
if (turn.nativeQueueStatus)
|
|
145
|
+
void api.setThreadStatus(target, "").catch(() => { });
|
|
146
|
+
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)}`));
|
|
147
|
+
};
|
|
148
|
+
const runner = createTurnRunner({
|
|
164
149
|
label,
|
|
150
|
+
store,
|
|
151
|
+
buffer,
|
|
152
|
+
seen,
|
|
153
|
+
toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
154
|
+
fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
|
|
155
|
+
bufferKey: (turn) => turn.bufferKey,
|
|
156
|
+
where: (turn) => `channel=${turn.channelId}`,
|
|
165
157
|
onQueuedBehind(turn) {
|
|
166
158
|
const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
|
|
167
159
|
if (nativeDmStatus)
|
|
168
160
|
turn.nativeQueueStatus = true;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
turn.
|
|
174
|
-
|
|
175
|
-
|
|
161
|
+
return {
|
|
162
|
+
done: authentication
|
|
163
|
+
.then(() => nativeDmStatus
|
|
164
|
+
? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
|
|
165
|
+
: api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
|
|
166
|
+
turn.previewTs = ts;
|
|
167
|
+
}))
|
|
168
|
+
.catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
|
|
169
|
+
};
|
|
176
170
|
},
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
// Leave the intent untouched when Slack auth failed. A fixed token + restart replays it;
|
|
172
|
+
// running now would execute an Agent turn whose only customer-facing transport is known to be
|
|
173
|
+
// unavailable.
|
|
174
|
+
beforeRun: async (turn) => {
|
|
180
175
|
try {
|
|
181
176
|
await authentication;
|
|
177
|
+
return true;
|
|
182
178
|
}
|
|
183
179
|
catch (error) {
|
|
184
|
-
// Leave the intent untouched. A fixed token + restart replays it; running now would execute an
|
|
185
|
-
// Agent turn whose only customer-facing transport is known to be unavailable.
|
|
186
180
|
log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
|
|
187
|
-
return;
|
|
181
|
+
return false;
|
|
188
182
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
183
|
+
},
|
|
184
|
+
onDeferred: (turn) => {
|
|
185
|
+
if (turn.nativeQueueStatus) {
|
|
186
|
+
void api
|
|
187
|
+
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
188
|
+
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
193
189
|
}
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
void api
|
|
197
|
-
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
198
|
-
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
199
|
-
}
|
|
200
|
-
else if (turn.previewTs) {
|
|
201
|
-
void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
202
|
-
}
|
|
203
|
-
return;
|
|
190
|
+
else if (turn.previewTs) {
|
|
191
|
+
void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
204
192
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${turn.baseText}` : turn.baseText;
|
|
209
|
-
const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
|
|
193
|
+
},
|
|
194
|
+
notifyDropped,
|
|
195
|
+
execute: async (turn, discussion, onCompleted) => {
|
|
210
196
|
const messageRef = messageRefOf(turn.id);
|
|
211
197
|
const reaction = reactionEmojis && messageRef
|
|
212
198
|
? await startSlackReaction({
|
|
@@ -218,47 +204,25 @@ export function slackChannel(options) {
|
|
|
218
204
|
})
|
|
219
205
|
: undefined;
|
|
220
206
|
try {
|
|
221
|
-
await streamSlackReply(invokeSlackTurn(agent, turn.session,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}), api, targetOf(turn), formatError, {
|
|
207
|
+
await streamSlackReply(invokeSlackTurn(agent, turn.session, `${discussionBlock(discussion.text)}${turn.baseText}`, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, {
|
|
208
|
+
primaryFileIds: turn.fileIds,
|
|
209
|
+
buffered: collectSlackBufferedFiles(discussion.consumed, new Set(turn.fileIds)),
|
|
210
|
+
}, onCompleted), api, targetOf(turn), formatError, {
|
|
225
211
|
rendering,
|
|
226
212
|
initialPreviewTs: turn.previewTs,
|
|
227
213
|
threadTitle: turn.threadTitle,
|
|
228
214
|
disclaimer: aiDisclaimer,
|
|
229
215
|
label,
|
|
230
216
|
});
|
|
231
|
-
log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
|
|
232
|
-
await reaction?.complete();
|
|
233
217
|
}
|
|
234
218
|
catch (error) {
|
|
235
|
-
log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
|
|
236
219
|
await reaction?.remove();
|
|
220
|
+
throw error;
|
|
237
221
|
}
|
|
238
|
-
|
|
239
|
-
store.remove(turn.id);
|
|
240
|
-
}
|
|
222
|
+
await reaction?.complete();
|
|
241
223
|
},
|
|
242
224
|
});
|
|
243
|
-
|
|
244
|
-
const target = targetOf(turn);
|
|
245
|
-
if (turn.nativeQueueStatus)
|
|
246
|
-
void api.setThreadStatus(target, "").catch(() => { });
|
|
247
|
-
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)}`));
|
|
248
|
-
};
|
|
249
|
-
const submit = (turn, persist) => {
|
|
250
|
-
if (persist) {
|
|
251
|
-
store.add(toStored(turn));
|
|
252
|
-
seen.add(turn.id);
|
|
253
|
-
}
|
|
254
|
-
queue.accept(turn);
|
|
255
|
-
};
|
|
256
|
-
const recovered = store.recover();
|
|
257
|
-
if (recovered.length)
|
|
258
|
-
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
259
|
-
let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
260
|
-
for (const { attempts: _attempts, ...intent } of recovered)
|
|
261
|
-
submit({ ...intent }, false);
|
|
225
|
+
let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
262
226
|
// Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
|
|
263
227
|
// delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
|
|
264
228
|
// through. The minutes-long Agent turn remains fire-and-forget.
|
|
@@ -285,7 +249,7 @@ export function slackChannel(options) {
|
|
|
285
249
|
// Listening is not speaking: every message the channel can see refines who takes part in its
|
|
286
250
|
// thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
|
|
287
251
|
// are known — this channel answering.
|
|
288
|
-
// Structural facts only, never `
|
|
252
|
+
// Structural facts only, never `route` — see thread-participants.ts. Slack
|
|
289
253
|
// adds no delta of its own here; its summon rule is the only consumer.
|
|
290
254
|
if (group && event.thread_ts !== undefined) {
|
|
291
255
|
threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
|
|
@@ -307,7 +271,6 @@ export function slackChannel(options) {
|
|
|
307
271
|
// structural: it reads who THIS message addresses, which is the one thing an observation-only
|
|
308
272
|
// store cannot know about someone it has never heard.
|
|
309
273
|
if (!routed &&
|
|
310
|
-
groupBehavior === "context" &&
|
|
311
274
|
route === undefined &&
|
|
312
275
|
group &&
|
|
313
276
|
event.thread_ts !== undefined &&
|
|
@@ -321,7 +284,7 @@ export function slackChannel(options) {
|
|
|
321
284
|
if (!routed) {
|
|
322
285
|
if (route === undefined && group && botUserId === undefined && mightBeTheBot)
|
|
323
286
|
return;
|
|
324
|
-
if (
|
|
287
|
+
if (route === undefined && group) {
|
|
325
288
|
const body = slackBufferText(slackMessageText(event));
|
|
326
289
|
if (body) {
|
|
327
290
|
const fileIds = slackFileIds(event);
|
|
@@ -365,7 +328,7 @@ export function slackChannel(options) {
|
|
|
365
328
|
const threadTitle = direct && event.thread_ts === undefined
|
|
366
329
|
? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
|
|
367
330
|
: undefined;
|
|
368
|
-
submit({
|
|
331
|
+
runner.submit({
|
|
369
332
|
id: logicalId,
|
|
370
333
|
seq: ++seq,
|
|
371
334
|
session: routed.session ?? defaultSession,
|
|
@@ -410,8 +373,6 @@ export function slackChannel(options) {
|
|
|
410
373
|
});
|
|
411
374
|
}
|
|
412
375
|
};
|
|
413
|
-
// Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
|
|
414
|
-
const sideTasks = createTaskTracker();
|
|
415
376
|
// First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
|
|
416
377
|
const welcomeInFlight = new Set();
|
|
417
378
|
const maybeWelcome = (envelope) => {
|
|
@@ -486,7 +447,7 @@ export function slackChannel(options) {
|
|
|
486
447
|
acceptEvent(envelope);
|
|
487
448
|
return new Response(null, { status: 200 });
|
|
488
449
|
};
|
|
489
|
-
handler.turnsIdle = () => Promise.all([
|
|
450
|
+
handler.turnsIdle = () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined);
|
|
490
451
|
return { "POST /slack": handler };
|
|
491
452
|
};
|
|
492
453
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
|
|
2
|
+
/** The remote client's idle watchdog allows three missed heartbeats. */
|
|
3
|
+
export declare const SSE_HEARTBEAT_MS = 30000;
|
|
4
|
+
export declare function sseResponse<T>(events: AsyncIterable<T>, project?: (event: T) => unknown): Response;
|