@fastagent-sh/fastagent 0.13.0 → 0.15.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 -5
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/context-buffer.d.ts +30 -0
- package/dist/channels/context-buffer.js +98 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +43 -0
- package/dist/channels/feishu/context-buffer.js +72 -0
- package/dist/channels/feishu/crypto.d.ts +4 -2
- package/dist/channels/feishu/crypto.js +4 -2
- package/dist/channels/feishu/feishu-api.d.ts +15 -7
- package/dist/channels/feishu/feishu-api.js +22 -4
- package/dist/channels/feishu/feishu.d.ts +38 -16
- package/dist/channels/feishu/feishu.js +286 -151
- package/dist/channels/feishu/invoke-turn.d.ts +24 -31
- package/dist/channels/feishu/invoke-turn.js +61 -62
- package/dist/channels/feishu/model.d.ts +98 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +23 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +7 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +21 -103
- package/dist/channels/feishu/parse.js +35 -145
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +26 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +10 -3
- package/dist/channels/feishu/setup-mode.d.ts +8 -0
- package/dist/channels/feishu/setup-mode.js +2 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +8 -6
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +35 -2
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +10 -3
- package/dist/channels/preview-kit.d.ts +109 -0
- package/dist/channels/preview-kit.js +183 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/owned-threads.d.ts +6 -0
- package/dist/channels/slack/owned-threads.js +43 -0
- package/dist/channels/slack/parse.d.ts +23 -0
- package/dist/channels/slack/parse.js +81 -0
- package/dist/channels/slack/preview.d.ts +24 -0
- package/dist/channels/slack/preview.js +359 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +77 -0
- package/dist/channels/slack/slack-api.js +415 -0
- package/dist/channels/slack/slack.d.ts +58 -0
- package/dist/channels/slack/slack.js +451 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.js +3 -3
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +13 -0
- package/dist/channels/tasks.js +10 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/telegram.js +23 -9
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
- package/dist/channels/{feishu/text.js → text.js} +1 -1
- package/dist/channels/turn-queue.js +1 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +27 -0
- package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
- package/dist/cli/add-slack.d.ts +10 -0
- package/dist/cli/add-slack.js +204 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +13 -0
- package/dist/cli/commands/add.js +274 -0
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +559 -0
- package/dist/cli/commands/chat.d.ts +4 -0
- package/dist/cli/commands/chat.js +21 -0
- package/dist/cli/commands/deploy.d.ts +15 -0
- package/dist/cli/commands/deploy.js +394 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +82 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +28 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +52 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +97 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +38 -0
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +32 -0
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +90 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +479 -0
- package/dist/cli/serve.d.ts +48 -0
- package/dist/cli/serve.js +248 -0
- package/dist/cli/shared.d.ts +44 -0
- package/dist/cli/shared.js +246 -0
- package/dist/cli.js +8 -1329
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +141 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +3 -1
- package/dist/deploy/fly/plan.js +12 -7
- package/dist/deploy/fly/run.d.ts +5 -4
- package/dist/deploy/fly/run.js +16 -5
- package/dist/deploy/preflight.d.ts +10 -4
- package/dist/deploy/preflight.js +57 -12
- package/dist/deploy/railway/plan.d.ts +3 -1
- package/dist/deploy/railway/plan.js +12 -4
- package/dist/deploy/railway/run.d.ts +5 -4
- package/dist/deploy/railway/run.js +15 -5
- package/dist/deploy/runner.d.ts +6 -2
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +0 -2
- package/dist/dev-supervisor.js +7 -3
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +90 -60
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -188
- package/dist/engines/pi/config.d.ts +23 -9
- package/dist/engines/pi/config.js +35 -5
- package/dist/engines/pi/create.d.ts +36 -7
- package/dist/engines/pi/create.js +63 -22
- package/dist/engines/pi/harness.d.ts +65 -1
- package/dist/engines/pi/harness.js +166 -2
- package/dist/engines/pi/invoke.d.ts +56 -3
- package/dist/engines/pi/invoke.js +340 -20
- package/dist/engines/pi/login.d.ts +11 -0
- package/dist/engines/pi/login.js +17 -5
- package/dist/engines/pi/models.d.ts +56 -10
- package/dist/engines/pi/models.js +61 -23
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +46 -11
- package/dist/engines/pi/tool-context.js +11 -9
- package/dist/engines/pi/tool.d.ts +35 -6
- package/dist/engines/pi/tool.js +47 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/engines/pi/workspace.d.ts +56 -1
- package/dist/engines/pi/workspace.js +75 -16
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +23 -6
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/pi.d.ts +6 -4
- package/dist/pi.js +2 -1
- package/dist/scaffold/add-channel.d.ts +19 -6
- package/dist/scaffold/add-channel.js +127 -16
- package/dist/scaffold/templates/fastagent.config.mjs +5 -3
- package/dist/schedule/wakeups.d.ts +0 -3
- package/dist/schedule/wakeups.js +1 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +21 -10
- package/package.json +22 -7
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/** Slack Web API transport: one JSON pipeline plus authenticated, capped private-file downloads. */
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { codePointPrefix } from "../text.js";
|
|
5
|
+
const API_TIMEOUT_MS = 30_000;
|
|
6
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
7
|
+
const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
|
8
|
+
const RETRIES = 3;
|
|
9
|
+
const MAX_RETRY_AFTER_S = 30;
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
/** Slack's standard-Markdown fields cap each call at 12,000 characters. Keep headroom for
|
|
12
|
+
* code-fence balancing and future server-side transformations. */
|
|
13
|
+
const SLACK_MAX_MARKDOWN = 10_000;
|
|
14
|
+
export class SlackApiError extends Error {
|
|
15
|
+
method;
|
|
16
|
+
status;
|
|
17
|
+
slackError;
|
|
18
|
+
constructor(method, status, description, slackError, options) {
|
|
19
|
+
super(status === 0 ? `slack ${method}: ${description}` : `slack ${method} failed: ${status} ${description}`, options);
|
|
20
|
+
this.name = "SlackApiError";
|
|
21
|
+
this.method = method;
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.slackError = slackError;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const NATIVE_UNAVAILABLE_ERRORS = new Set([
|
|
27
|
+
"channel_type_not_supported",
|
|
28
|
+
"messages_tab_disabled",
|
|
29
|
+
"method_deprecated",
|
|
30
|
+
"missing_scope",
|
|
31
|
+
"no_permission",
|
|
32
|
+
"not_allowed_token_type",
|
|
33
|
+
]);
|
|
34
|
+
/** A definitive capability rejection is safe to route through the compatibility renderer. Network,
|
|
35
|
+
* internal, and timeout failures are ambiguous: Slack may already have created the stream. */
|
|
36
|
+
export function isSlackNativeUnavailable(error) {
|
|
37
|
+
return error instanceof SlackApiError && !!error.slackError && NATIVE_UNAVAILABLE_ERRORS.has(error.slackError);
|
|
38
|
+
}
|
|
39
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
/** Split a Slack text/Markdown field at a code-point-safe boundary, preferring a newline. */
|
|
41
|
+
export function chunkSlackText(text, maxPoints = SLACK_MAX_MARKDOWN) {
|
|
42
|
+
if (!Number.isSafeInteger(maxPoints) || maxPoints <= 0)
|
|
43
|
+
throw new RangeError("maxPoints must be a positive integer");
|
|
44
|
+
const points = Array.from(text);
|
|
45
|
+
if (points.length <= maxPoints)
|
|
46
|
+
return [text];
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < points.length) {
|
|
50
|
+
let end = Math.min(points.length, offset + maxPoints);
|
|
51
|
+
if (end < points.length) {
|
|
52
|
+
const window = points.slice(offset, end).join("");
|
|
53
|
+
const paragraph = window.lastIndexOf("\n\n");
|
|
54
|
+
const newline = window.lastIndexOf("\n");
|
|
55
|
+
const boundary = paragraph >= 0 ? paragraph + 2 : newline >= 0 ? newline + 1 : -1;
|
|
56
|
+
if (boundary > 0)
|
|
57
|
+
end = offset + Array.from(window.slice(0, boundary)).length;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(points.slice(offset, end).join(""));
|
|
60
|
+
offset = end;
|
|
61
|
+
}
|
|
62
|
+
return chunks.length ? chunks : [""];
|
|
63
|
+
}
|
|
64
|
+
/** Split standard Markdown while balancing fenced code blocks across separately posted messages. */
|
|
65
|
+
export function chunkSlackMarkdown(markdown, maxPoints = SLACK_MAX_MARKDOWN) {
|
|
66
|
+
if (!Number.isSafeInteger(maxPoints) || maxPoints < 256) {
|
|
67
|
+
throw new RangeError("Markdown chunk size must be an integer of at least 256 code points");
|
|
68
|
+
}
|
|
69
|
+
if (Array.from(markdown).length <= maxPoints)
|
|
70
|
+
return [markdown];
|
|
71
|
+
const rawChunks = chunkSlackText(markdown, maxPoints - 128);
|
|
72
|
+
let fence;
|
|
73
|
+
const output = [];
|
|
74
|
+
for (const raw of rawChunks) {
|
|
75
|
+
const entering = fence;
|
|
76
|
+
for (const line of raw.split("\n")) {
|
|
77
|
+
const match = line.trim().match(/^(`{3,}|~{3,})(.*)$/);
|
|
78
|
+
if (!match)
|
|
79
|
+
continue;
|
|
80
|
+
const marker = match[1] ?? "```";
|
|
81
|
+
// Pathological fences longer than our reserved balancing headroom are left untouched rather than
|
|
82
|
+
// making a generated chunk exceed Slack's API limit.
|
|
83
|
+
if (marker.length > 64)
|
|
84
|
+
continue;
|
|
85
|
+
if (!fence) {
|
|
86
|
+
fence = { marker, opening: `${marker}${(match[2] ?? "").slice(0, 64)}` };
|
|
87
|
+
}
|
|
88
|
+
else if (marker[0] === fence.marker[0] && marker.length >= fence.marker.length) {
|
|
89
|
+
fence = undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const prefix = entering ? `${entering.opening}\n` : "";
|
|
93
|
+
const suffix = fence ? `\n${fence.marker}` : "";
|
|
94
|
+
output.push(`${prefix}${raw}${suffix}`);
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
98
|
+
/** Slack locks a stream to top-level `markdown_text` or `chunks` mode on its first write. Tasks need
|
|
99
|
+
* chunks, so encode text as markdown chunks too; mixing the two modes yields `streaming_mode_mismatch`. */
|
|
100
|
+
function streamChunks(content) {
|
|
101
|
+
return [
|
|
102
|
+
...(content.markdownText ? [{ type: "markdown_text", text: content.markdownText }] : []),
|
|
103
|
+
...(content.chunks ?? []),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
function safeFileName(file) {
|
|
107
|
+
const raw = file.name ?? file.title ?? file.id ?? "file";
|
|
108
|
+
const safe = raw.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
|
|
109
|
+
return `${file.id ?? "slack"}-${safe}`;
|
|
110
|
+
}
|
|
111
|
+
function fileDownloadUrl(file) {
|
|
112
|
+
if (file.file_access === "check_file_info")
|
|
113
|
+
throw new Error(`Slack file ${file.id ?? "(unknown)"} is not ready`);
|
|
114
|
+
if (file.file_access === "access_denied")
|
|
115
|
+
throw new Error(`Slack file ${file.id ?? "(unknown)"} is not accessible`);
|
|
116
|
+
const url = file.url_private_download ?? file.url_private;
|
|
117
|
+
if (!url) {
|
|
118
|
+
const kind = file.is_external ? `external (${file.external_type ?? "unknown"})` : (file.mode ?? "unknown");
|
|
119
|
+
throw new Error(`Slack file ${file.id ?? "(unknown)"} has no downloadable bytes (${kind})`);
|
|
120
|
+
}
|
|
121
|
+
if ((file.size ?? 0) > MAX_DOWNLOAD_BYTES)
|
|
122
|
+
throw new Error("Slack file is too large (max 20 MB)");
|
|
123
|
+
return url;
|
|
124
|
+
}
|
|
125
|
+
async function readBytesCapped(response) {
|
|
126
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
127
|
+
if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES)
|
|
128
|
+
throw new Error("Slack file is too large (max 20 MB)");
|
|
129
|
+
if (!response.body)
|
|
130
|
+
return Buffer.alloc(0);
|
|
131
|
+
const reader = response.body.getReader();
|
|
132
|
+
const chunks = [];
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done)
|
|
137
|
+
break;
|
|
138
|
+
if (!value)
|
|
139
|
+
continue;
|
|
140
|
+
total += value.byteLength;
|
|
141
|
+
if (total > MAX_DOWNLOAD_BYTES) {
|
|
142
|
+
await reader.cancel("download exceeds cap").catch(() => { });
|
|
143
|
+
throw new Error("Slack file is too large (max 20 MB)");
|
|
144
|
+
}
|
|
145
|
+
chunks.push(value);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
148
|
+
}
|
|
149
|
+
export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
|
|
150
|
+
const apiBase = baseUrl.replace(/\/$/, "");
|
|
151
|
+
const configuredOrigin = new URL(apiBase).origin;
|
|
152
|
+
const currentToken = typeof botToken === "string" ? async () => botToken : botToken;
|
|
153
|
+
const trustedDownloadUrl = (value) => {
|
|
154
|
+
const url = new URL(value);
|
|
155
|
+
const host = url.hostname.toLowerCase();
|
|
156
|
+
const slackHost = host === "slack-files.com" ||
|
|
157
|
+
host.endsWith(".slack-files.com") ||
|
|
158
|
+
host === "slack.com" ||
|
|
159
|
+
host.endsWith(".slack.com") ||
|
|
160
|
+
host === "slack-edge.com" ||
|
|
161
|
+
host.endsWith(".slack-edge.com");
|
|
162
|
+
if (!slackHost && url.origin !== configuredOrigin)
|
|
163
|
+
throw new Error(`refusing non-Slack file URL host ${host}`);
|
|
164
|
+
if (url.protocol !== "https:" && url.origin !== configuredOrigin)
|
|
165
|
+
throw new Error("refusing non-HTTPS Slack file URL");
|
|
166
|
+
return url;
|
|
167
|
+
};
|
|
168
|
+
const call = async (method, body, httpMethod = "POST") => {
|
|
169
|
+
const url = new URL(`${apiBase}/${method}`);
|
|
170
|
+
if (httpMethod === "GET") {
|
|
171
|
+
for (const [name, value] of Object.entries(body)) {
|
|
172
|
+
if (value !== undefined)
|
|
173
|
+
url.searchParams.set(name, String(value));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (let attempt = 0;; attempt++) {
|
|
177
|
+
let response;
|
|
178
|
+
let raw;
|
|
179
|
+
try {
|
|
180
|
+
const token = await currentToken();
|
|
181
|
+
response = await fetch(url, {
|
|
182
|
+
method: httpMethod,
|
|
183
|
+
headers: {
|
|
184
|
+
authorization: `Bearer ${token}`,
|
|
185
|
+
...(httpMethod === "POST" ? { "content-type": "application/json; charset=utf-8" } : {}),
|
|
186
|
+
},
|
|
187
|
+
...(httpMethod === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
188
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
189
|
+
});
|
|
190
|
+
raw = await response.text();
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw new SlackApiError(method, 0, String(error), undefined, { cause: error });
|
|
194
|
+
}
|
|
195
|
+
let data;
|
|
196
|
+
try {
|
|
197
|
+
data = JSON.parse(raw);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
data = {};
|
|
201
|
+
}
|
|
202
|
+
if (response.ok && data.ok === true)
|
|
203
|
+
return data;
|
|
204
|
+
const rateLimited = response.status === 429 || data.error === "ratelimited";
|
|
205
|
+
if (rateLimited && attempt < RETRIES) {
|
|
206
|
+
const retryAfter = Number(response.headers.get("retry-after") ?? attempt + 1);
|
|
207
|
+
if (Number.isFinite(retryAfter) && retryAfter <= MAX_RETRY_AFTER_S) {
|
|
208
|
+
await wait(Math.max(1, retryAfter) * 1000);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const detail = [
|
|
213
|
+
data.error ?? "response was not the expected Slack JSON",
|
|
214
|
+
data.needed ? `needed ${data.needed}` : undefined,
|
|
215
|
+
...(data.response_metadata?.messages ?? []),
|
|
216
|
+
]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join("; ");
|
|
219
|
+
throw new SlackApiError(method, response.status, detail, data.error);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const download = async (file) => {
|
|
223
|
+
let url = trustedDownloadUrl(fileDownloadUrl(file));
|
|
224
|
+
for (let redirect = 0;; redirect++) {
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
const token = await currentToken();
|
|
228
|
+
response = await fetch(url, {
|
|
229
|
+
headers: { authorization: `Bearer ${token}` },
|
|
230
|
+
redirect: "manual",
|
|
231
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
|
|
236
|
+
}
|
|
237
|
+
if (response.status >= 300 && response.status < 400) {
|
|
238
|
+
const location = response.headers.get("location");
|
|
239
|
+
if (!location || redirect >= MAX_REDIRECTS) {
|
|
240
|
+
throw new SlackApiError("file download", response.status, "invalid or excessive redirect");
|
|
241
|
+
}
|
|
242
|
+
await response.body?.cancel().catch(() => { });
|
|
243
|
+
url = trustedDownloadUrl(new URL(location, url).toString());
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
|
|
248
|
+
throw new SlackApiError("file download", response.status, detail);
|
|
249
|
+
}
|
|
250
|
+
return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const api = {
|
|
254
|
+
async authTest() {
|
|
255
|
+
const data = await call("auth.test", {});
|
|
256
|
+
return { teamId: data.team_id, userId: data.user_id, botId: data.bot_id };
|
|
257
|
+
},
|
|
258
|
+
async postMessage(target, text) {
|
|
259
|
+
const data = await call("chat.postMessage", {
|
|
260
|
+
channel: target.channelId,
|
|
261
|
+
text,
|
|
262
|
+
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
263
|
+
unfurl_links: false,
|
|
264
|
+
unfurl_media: false,
|
|
265
|
+
});
|
|
266
|
+
if (!data.ts)
|
|
267
|
+
throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
|
|
268
|
+
return data.ts;
|
|
269
|
+
},
|
|
270
|
+
async postMarkdown(target, markdown) {
|
|
271
|
+
const data = await call("chat.postMessage", {
|
|
272
|
+
channel: target.channelId,
|
|
273
|
+
markdown_text: markdown,
|
|
274
|
+
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
275
|
+
unfurl_links: false,
|
|
276
|
+
unfurl_media: false,
|
|
277
|
+
});
|
|
278
|
+
if (!data.ts)
|
|
279
|
+
throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
|
|
280
|
+
return data.ts;
|
|
281
|
+
},
|
|
282
|
+
async updateMessage(channelId, ts, text) {
|
|
283
|
+
try {
|
|
284
|
+
await call("chat.update", { channel: channelId, ts, text });
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error instanceof SlackApiError && error.slackError === "message_not_modified")
|
|
288
|
+
return;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
async updateMarkdown(channelId, ts, markdown) {
|
|
293
|
+
try {
|
|
294
|
+
await call("chat.update", { channel: channelId, ts, markdown_text: markdown });
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof SlackApiError && error.slackError === "message_not_modified")
|
|
298
|
+
return;
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
async deleteMessage(channelId, ts) {
|
|
303
|
+
await call("chat.delete", { channel: channelId, ts });
|
|
304
|
+
},
|
|
305
|
+
async sendMarkdown(target, markdown) {
|
|
306
|
+
let first;
|
|
307
|
+
for (const chunk of chunkSlackMarkdown(markdown)) {
|
|
308
|
+
const ts = await api.postMarkdown(target, chunk);
|
|
309
|
+
first ??= ts;
|
|
310
|
+
}
|
|
311
|
+
return first;
|
|
312
|
+
},
|
|
313
|
+
async startStream(target, content = {}, taskDisplayMode = "plan") {
|
|
314
|
+
if (!target.threadTs)
|
|
315
|
+
throw new Error("Slack native streams require a parent thread timestamp");
|
|
316
|
+
const channelRecipient = target.channelId.startsWith("D")
|
|
317
|
+
? {}
|
|
318
|
+
: {
|
|
319
|
+
recipient_user_id: target.recipientUserId,
|
|
320
|
+
recipient_team_id: target.recipientTeamId,
|
|
321
|
+
};
|
|
322
|
+
if (!target.channelId.startsWith("D") && (!target.recipientUserId || !target.recipientTeamId)) {
|
|
323
|
+
throw new Error("Slack native channel streams require recipient user and team IDs");
|
|
324
|
+
}
|
|
325
|
+
const chunks = streamChunks(content);
|
|
326
|
+
const data = await call("chat.startStream", {
|
|
327
|
+
channel: target.channelId,
|
|
328
|
+
thread_ts: target.threadTs,
|
|
329
|
+
task_display_mode: taskDisplayMode,
|
|
330
|
+
...channelRecipient,
|
|
331
|
+
...(chunks.length ? { chunks } : {}),
|
|
332
|
+
});
|
|
333
|
+
if (!data.ts)
|
|
334
|
+
throw new SlackApiError("chat.startStream", 200, "response carried no ts");
|
|
335
|
+
return data.ts;
|
|
336
|
+
},
|
|
337
|
+
async appendStream(channelId, ts, content) {
|
|
338
|
+
const chunks = streamChunks(content);
|
|
339
|
+
await call("chat.appendStream", {
|
|
340
|
+
channel: channelId,
|
|
341
|
+
ts,
|
|
342
|
+
...(chunks.length ? { chunks } : {}),
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
async stopStream(channelId, ts, content = {}) {
|
|
346
|
+
const chunks = streamChunks(content);
|
|
347
|
+
await call("chat.stopStream", {
|
|
348
|
+
channel: channelId,
|
|
349
|
+
ts,
|
|
350
|
+
...(chunks.length ? { chunks } : {}),
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
async setThreadStatus(target, status) {
|
|
354
|
+
if (!target.threadTs)
|
|
355
|
+
return;
|
|
356
|
+
await call("assistant.threads.setStatus", {
|
|
357
|
+
channel_id: target.channelId,
|
|
358
|
+
thread_ts: target.threadTs,
|
|
359
|
+
status,
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
async setThreadTitle(target, title) {
|
|
363
|
+
if (!target.threadTs)
|
|
364
|
+
return;
|
|
365
|
+
await call("assistant.threads.setTitle", {
|
|
366
|
+
channel_id: target.channelId,
|
|
367
|
+
thread_ts: target.threadTs,
|
|
368
|
+
title,
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
async addReaction(channelId, timestamp, emoji) {
|
|
372
|
+
try {
|
|
373
|
+
await call("reactions.add", { channel: channelId, timestamp, name: emoji });
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error instanceof SlackApiError && error.slackError === "already_reacted")
|
|
377
|
+
return;
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
async removeReaction(channelId, timestamp, emoji) {
|
|
382
|
+
try {
|
|
383
|
+
await call("reactions.remove", { channel: channelId, timestamp, name: emoji });
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (error instanceof SlackApiError && error.slackError === "no_reaction")
|
|
387
|
+
return;
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
async fileInfo(fileId) {
|
|
392
|
+
const data = await call("files.info", { file: fileId }, "GET");
|
|
393
|
+
if (!data.file?.id)
|
|
394
|
+
throw new SlackApiError("files.info", 200, `response carried no file for ${fileId}`);
|
|
395
|
+
return data.file;
|
|
396
|
+
},
|
|
397
|
+
async fetchImage(file) {
|
|
398
|
+
const { bytes, contentType } = await download(file);
|
|
399
|
+
const mime = (file.mimetype ?? contentType?.split(";")[0]?.trim() ?? "").toLowerCase();
|
|
400
|
+
if (!mime.startsWith("image/"))
|
|
401
|
+
throw new Error(`Slack file ${file.id ?? "(unknown)"} is not an image (${mime || "unknown type"})`);
|
|
402
|
+
return { mimeType: mime, data: bytes.toString("base64") };
|
|
403
|
+
},
|
|
404
|
+
async fetchFile(file, channelId, filesDir) {
|
|
405
|
+
const { bytes } = await download(file);
|
|
406
|
+
const dir = join(filesDir, channelId);
|
|
407
|
+
await mkdir(dir, { recursive: true });
|
|
408
|
+
const name = safeFileName(file);
|
|
409
|
+
const path = join(dir, name);
|
|
410
|
+
await writeFile(path, bytes);
|
|
411
|
+
return { path, name, size: bytes.byteLength };
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
return api;
|
|
415
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ChannelModule } from "../../host/node.ts";
|
|
2
|
+
import { type SlackEventEnvelope, type SlackFile, type SlackMessageEvent, type SlackRoute, defaultSlackRoute, slackEnvelope } from "./parse.ts";
|
|
3
|
+
import { type SlackFailure, type SlackRendering } from "./preview.ts";
|
|
4
|
+
import { type SlackTaskDisplayMode } from "./slack-api.ts";
|
|
5
|
+
export { defaultSlackRoute, slackEnvelope };
|
|
6
|
+
export type { SlackEventEnvelope, SlackFailure, SlackFile, SlackMessageEvent, SlackRendering, SlackRoute };
|
|
7
|
+
export interface SlackChannelOptions {
|
|
8
|
+
/** Bot User OAuth Token (`xoxb-…`) used for replies and files. */
|
|
9
|
+
botToken: string;
|
|
10
|
+
/** App signing secret used to verify the raw Events API request body. */
|
|
11
|
+
signingSecret: string;
|
|
12
|
+
/** Rotating OAuth credentials. Omit all four only for a manually managed long-lived bot token. */
|
|
13
|
+
botRefreshToken?: string;
|
|
14
|
+
clientId?: string;
|
|
15
|
+
clientSecret?: string;
|
|
16
|
+
botTokenExpiresAt?: number;
|
|
17
|
+
/** Direct-message policy. `threaded` (default) gives every top-level DM its own session/thread;
|
|
18
|
+
* `continuous` keeps one linear session per DM channel. */
|
|
19
|
+
directMessageSession?: "continuous" | "threaded";
|
|
20
|
+
/** Group-message context + delivery policy. `threaded` (default) gives every top-level summon its
|
|
21
|
+
* own session/thread; `continuous` keeps one session for channel-top-level turns while preserving
|
|
22
|
+
* existing Slack threads as separate root sessions. */
|
|
23
|
+
groupMessageSession?: "continuous" | "threaded";
|
|
24
|
+
/** `context` (default) admits bare replies in managed group threads and buffers unsummoned group
|
|
25
|
+
* discussion. `mentions` answers only app_mention plus DMs for an explicit least-privilege setup. */
|
|
26
|
+
groupBehavior?: "context" | "mentions";
|
|
27
|
+
/** `native` (default) uses Slack Agent streams/tasks for threaded replies. `classic` retains the
|
|
28
|
+
* compatibility renderer based on one rate-limited edited message. A top-level target selected by an
|
|
29
|
+
* explicit continuous/custom policy necessarily uses the classic renderer because Slack streams
|
|
30
|
+
* require a parent user message. */
|
|
31
|
+
rendering?: SlackRendering;
|
|
32
|
+
/** Native task-card layout (`chat.startStream` `task_display_mode`): `plan` (default) groups steps
|
|
33
|
+
* under a single collapsible heading, `timeline` lists each step sequentially, `dense` collapses
|
|
34
|
+
* consecutive tool calls into one summarized card. Applies to the native renderer only. */
|
|
35
|
+
taskDisplay?: SlackTaskDisplayMode;
|
|
36
|
+
/** Optional footer for successful Agent replies. Omitted or `false` sends no repetitive disclaimer. */
|
|
37
|
+
aiDisclaimer?: string | false;
|
|
38
|
+
/** First-run direct-message welcome, sent once when a user first opens the DM (`app_home_opened`,
|
|
39
|
+
* `tab: "messages"`). A string customizes it; `false` disables it. Plain Markdown only — no interactive
|
|
40
|
+
* buttons until an interactivity endpoint exists. Defaults to a generic greeting. */
|
|
41
|
+
welcome?: string | false;
|
|
42
|
+
/** Lightweight emoji ack on the user's triggering message: 👀 while working, ✅ when done. `false`
|
|
43
|
+
* disables it; an object overrides either emoji name. Requires the `reactions:write` scope; a missing
|
|
44
|
+
* scope degrades to no ack. */
|
|
45
|
+
reactionAck?: false | {
|
|
46
|
+
processing?: string;
|
|
47
|
+
completed?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Custom route policy. Providing it disables the default managed-thread/context admission policy. */
|
|
50
|
+
route?: (envelope: SlackEventEnvelope) => SlackRoute | null;
|
|
51
|
+
/** Customer-facing failure formatter; full details always remain in operator logs. */
|
|
52
|
+
onError?: (failure: SlackFailure) => string | undefined;
|
|
53
|
+
/** Slack Web API base override for tests or an operator-controlled gateway. */
|
|
54
|
+
apiBaseUrl?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
|
|
57
|
+
export declare function verifySlackSignature(signingSecret: string, timestamp: string, signature: string, rawBody: string, nowMs?: number): boolean;
|
|
58
|
+
export declare function slackChannel({ botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, directMessageSession, groupMessageSession, groupBehavior, rendering, taskDisplay, aiDisclaimer, welcome, reactionAck, route, onError, apiBaseUrl, }: SlackChannelOptions): ChannelModule;
|