@fastagent-sh/fastagent 0.20.0 → 0.21.1
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/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- 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 +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- 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/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- 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/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- 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 +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +25 -37
- 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 +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- 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 +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- 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 +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
-
import {
|
|
3
|
-
import { basename } from "node:path";
|
|
2
|
+
import { slackTransport } from "@fastagent-sh/fastagent/slack";
|
|
4
3
|
|
|
5
4
|
// Send a message or upload a local file to Slack. In a CHAT turn the channel delivers the reply
|
|
6
5
|
// itself — do NOT call this to answer a normal chat turn (that posts the message twice). This tool is
|
|
@@ -8,68 +7,10 @@ import { basename } from "node:path";
|
|
|
8
7
|
// self-scheduled wake-up, whose plain reply is not delivered anywhere. The channelId/threadTs come
|
|
9
8
|
// from the [slack: …] context line in a chat turn; a scheduled/woken turn has no such line, so its
|
|
10
9
|
// prompt must name the target channel id. tools/ is auto-discovered.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const MAX_RETRY_AFTER_S = 30;
|
|
16
|
-
|
|
17
|
-
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
-
|
|
19
|
-
function splitText(text: string): string[] {
|
|
20
|
-
const points = Array.from(text);
|
|
21
|
-
const chunks: string[] = [];
|
|
22
|
-
let offset = 0;
|
|
23
|
-
while (offset < points.length) {
|
|
24
|
-
let end = Math.min(points.length, offset + MAX_TEXT);
|
|
25
|
-
if (end < points.length) {
|
|
26
|
-
const block = points.slice(offset, end).join("");
|
|
27
|
-
const newline = block.lastIndexOf("\n");
|
|
28
|
-
if (newline > 0) end = offset + Array.from(block.slice(0, newline)).length;
|
|
29
|
-
}
|
|
30
|
-
chunks.push(points.slice(offset, end).join(""));
|
|
31
|
-
offset = end;
|
|
32
|
-
if (points[offset] === "\n") offset++;
|
|
33
|
-
}
|
|
34
|
-
return chunks.length ? chunks : [""];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function callSlack<T extends { ok?: boolean; error?: string }>(
|
|
38
|
-
token: string,
|
|
39
|
-
method: string,
|
|
40
|
-
body: Record<string, unknown>,
|
|
41
|
-
): Promise<T> {
|
|
42
|
-
for (let attempt = 0; ; attempt++) {
|
|
43
|
-
let response: Response;
|
|
44
|
-
let raw: string;
|
|
45
|
-
try {
|
|
46
|
-
response = await fetch(`${API}/${method}`, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
|
|
49
|
-
body: JSON.stringify(body),
|
|
50
|
-
signal: AbortSignal.timeout(120_000),
|
|
51
|
-
});
|
|
52
|
-
raw = await response.text();
|
|
53
|
-
} catch (error) {
|
|
54
|
-
throw new Error(`slack ${method}: ${String(error)}`, { cause: error });
|
|
55
|
-
}
|
|
56
|
-
let data: T;
|
|
57
|
-
try {
|
|
58
|
-
data = JSON.parse(raw) as T;
|
|
59
|
-
} catch {
|
|
60
|
-
data = {} as T;
|
|
61
|
-
}
|
|
62
|
-
if (response.ok && data.ok === true) return data;
|
|
63
|
-
if ((response.status === 429 || data.error === "ratelimited") && attempt < RETRIES) {
|
|
64
|
-
const retryAfter = Number(response.headers.get("retry-after") ?? attempt + 1);
|
|
65
|
-
if (Number.isFinite(retryAfter) && retryAfter <= MAX_RETRY_AFTER_S) {
|
|
66
|
-
await wait(Math.max(1, retryAfter) * 1000);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`slack ${method} failed: ${response.status} ${data.error ?? "unexpected response"}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
10
|
+
//
|
|
11
|
+
// Delivery rides the channel's own transport (slackTransport): its token, API base, Markdown splitting
|
|
12
|
+
// and rate-limit handling. With no channel mounted (`fastagent fire` / `invoke`) it is built from
|
|
13
|
+
// SLACK_BOT_TOKEN in .env.
|
|
73
14
|
|
|
74
15
|
export default defineTool({
|
|
75
16
|
description:
|
|
@@ -77,80 +18,31 @@ export default defineTool({
|
|
|
77
18
|
"carrying — a scheduled or self-scheduled (wake) turn. In a normal chat turn the channel already " +
|
|
78
19
|
"delivers your reply, so do NOT call this to answer (it would post the message twice). Pass exactly " +
|
|
79
20
|
"one of `text`/`path`. channelId/threadTs come from the [slack: …] context line in a chat turn; a " +
|
|
80
|
-
"scheduled/woken turn has no context line, so name the destination in your instruction."
|
|
21
|
+
"scheduled/woken turn has no context line, so name the destination in your instruction. A user ID " +
|
|
22
|
+
"(U…) as channelId messages that user's DM; a file upload needs the DM channel ID (D…) the text send reports.",
|
|
81
23
|
input: z.object({
|
|
82
|
-
channelId: z.string().describe("target Slack channel ID"),
|
|
24
|
+
channelId: z.string().describe("target Slack channel ID, or a user ID (U…) to message that user's DM"),
|
|
83
25
|
text: z.string().optional().describe("standard Markdown message text"),
|
|
84
26
|
path: z.string().optional().describe("absolute path of a local file to upload"),
|
|
85
27
|
title: z.string().optional().describe("file title (file mode only)"),
|
|
86
28
|
initialComment: z.string().optional().describe("message posted with the file (file mode only)"),
|
|
87
29
|
threadTs: z.string().optional().describe("thread parent timestamp, if replying in a thread"),
|
|
88
30
|
}),
|
|
89
|
-
async execute({ channelId, text, path, title, initialComment, threadTs }) {
|
|
90
|
-
const token = process.env.SLACK_BOT_TOKEN;
|
|
91
|
-
if (!token) throw new Error("SLACK_BOT_TOKEN is not set");
|
|
31
|
+
async execute({ channelId, text, path, title, initialComment, threadTs }, ctx) {
|
|
92
32
|
if ((text === undefined) === (path === undefined)) throw new Error("pass exactly one of `text` or `path`");
|
|
93
|
-
|
|
33
|
+
const slack = slackTransport(ctx.cwd);
|
|
34
|
+
const target = { channelId, threadTs };
|
|
94
35
|
if (text !== undefined) {
|
|
95
36
|
if (title !== undefined || initialComment !== undefined) {
|
|
96
37
|
throw new Error("`title`/`initialComment` are file-mode only");
|
|
97
38
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
104
|
-
unfurl_links: false,
|
|
105
|
-
unfurl_media: false,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
return chunks.length === 1
|
|
109
|
-
? `sent message to Slack channel ${channelId}`
|
|
110
|
-
: `sent ${chunks.length} messages to Slack channel ${channelId}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const filePath = path as string;
|
|
114
|
-
const info = await stat(filePath);
|
|
115
|
-
if (!info.isFile()) throw new Error(`${filePath} is not a regular file`);
|
|
116
|
-
const filename = basename(filePath);
|
|
117
|
-
const upload = await callSlack<{ ok?: boolean; error?: string; upload_url?: string; file_id?: string }>(
|
|
118
|
-
token,
|
|
119
|
-
"files.getUploadURLExternal",
|
|
120
|
-
{ filename, length: info.size },
|
|
121
|
-
);
|
|
122
|
-
if (!upload.upload_url || !upload.file_id) throw new Error("Slack upload URL response carried no upload_url/file_id");
|
|
123
|
-
|
|
124
|
-
let byteResponse: Response;
|
|
125
|
-
const handle = await open(filePath, "r");
|
|
126
|
-
try {
|
|
127
|
-
// where getUploadURLExternal points is Slack's call: these bytes are on their way to Slack either way
|
|
128
|
-
byteResponse = await fetch(upload.upload_url, {
|
|
129
|
-
method: "POST",
|
|
130
|
-
headers: { "content-type": "application/octet-stream" },
|
|
131
|
-
body: handle.readableWebStream(),
|
|
132
|
-
signal: AbortSignal.timeout(120_000),
|
|
133
|
-
// Required by Node fetch for a streaming request body; not part of the browser RequestInit type.
|
|
134
|
-
duplex: "half",
|
|
135
|
-
} as RequestInit & { duplex: "half" });
|
|
136
|
-
} catch (error) {
|
|
137
|
-
throw new Error(`Slack file byte upload failed: ${String(error)}`, { cause: error });
|
|
138
|
-
} finally {
|
|
139
|
-
await handle.close().catch(() => {});
|
|
39
|
+
const sent = await slack.sendMarkdown(target, text);
|
|
40
|
+
// A user-id target reports the DM channel Slack resolved it to — the id a file upload needs.
|
|
41
|
+
return sent.channelId === channelId
|
|
42
|
+
? `sent message to Slack channel ${channelId} (ts ${sent.ts})`
|
|
43
|
+
: `sent message to Slack user ${channelId} in DM channel ${sent.channelId} (ts ${sent.ts})`;
|
|
140
44
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// At-least-once: if Slack commits this call but the response is lost, a tool retry may post a
|
|
146
|
-
// duplicate. Retrying automatically here would hide that ambiguity, so only explicit agent/user
|
|
147
|
-
// retry crosses this final side-effect boundary.
|
|
148
|
-
await callSlack(token, "files.completeUploadExternal", {
|
|
149
|
-
files: [{ id: upload.file_id, title: title ?? filename }],
|
|
150
|
-
channel_id: channelId,
|
|
151
|
-
...(initialComment ? { initial_comment: initialComment } : {}),
|
|
152
|
-
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
153
|
-
});
|
|
154
|
-
return `uploaded ${filename} to Slack channel ${channelId}`;
|
|
45
|
+
const file = await slack.uploadFile(target, path as string, { title, initialComment });
|
|
46
|
+
return `uploaded ${file.name} to Slack channel ${channelId} (file ${file.id})`;
|
|
155
47
|
},
|
|
156
48
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SlackApi } from "./slack-api.ts";
|
|
2
|
+
/** What a proactive sender needs: Markdown delivery and file upload. */
|
|
3
|
+
export type SlackTransport = Pick<SlackApi, "sendMarkdown" | "uploadFile">;
|
|
4
|
+
/** A re-mount replaces the entry: the channel's transport is the authoritative one for its root. */
|
|
5
|
+
export declare function registerSlackApi(stateRoot: string, api: SlackApi): void;
|
|
6
|
+
/**
|
|
7
|
+
* The transport of the agent whose workspace is `cwd` (a tool's `ctx.cwd`). The state root is derived
|
|
8
|
+
* exactly as the opener derives it for the channel, so both name the same mount.
|
|
9
|
+
*/
|
|
10
|
+
export declare function slackTransport(cwd: string): SlackTransport;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE Slack transport per state root a process holds: the mounted channel's, shared with the
|
|
3
|
+
* scaffolded send tool.
|
|
4
|
+
*
|
|
5
|
+
* The tool used to carry a transport of its own over `SLACK_BOT_TOKEN` (#458). Sharing the channel's
|
|
6
|
+
* gives it the channel's `apiBaseUrl`, Markdown splitting and rate-limit handling, and one place to
|
|
7
|
+
* read the credential. The channel registers at mount; the tool resolves at EXECUTE, never at load,
|
|
8
|
+
* and builds a transport from the env only where no channel is mounted (`fastagent fire` / `invoke`)
|
|
9
|
+
* — that fallback knows no `apiBaseUrl`, since the option lives in the channel's glue, which is not
|
|
10
|
+
* loaded on those paths.
|
|
11
|
+
*/
|
|
12
|
+
import { resolvePlacement, resolveStateRoot } from "../../paths.js";
|
|
13
|
+
import { createSlackApi } from "./slack-api.js";
|
|
14
|
+
const byStateRoot = new Map();
|
|
15
|
+
/** A re-mount replaces the entry: the channel's transport is the authoritative one for its root. */
|
|
16
|
+
export function registerSlackApi(stateRoot, api) {
|
|
17
|
+
byStateRoot.set(stateRoot, api);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The transport of the agent whose workspace is `cwd` (a tool's `ctx.cwd`). The state root is derived
|
|
21
|
+
* exactly as the opener derives it for the channel, so both name the same mount.
|
|
22
|
+
*/
|
|
23
|
+
export function slackTransport(cwd) {
|
|
24
|
+
const stateRoot = resolveStateRoot(resolvePlacement(cwd).agentDir);
|
|
25
|
+
let api = byStateRoot.get(stateRoot);
|
|
26
|
+
if (!api) {
|
|
27
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
28
|
+
if (!botToken)
|
|
29
|
+
throw new Error("SLACK_BOT_TOKEN is not set and no Slack channel is mounted");
|
|
30
|
+
api = createSlackApi({ botToken });
|
|
31
|
+
byStateRoot.set(stateRoot, api);
|
|
32
|
+
}
|
|
33
|
+
return api;
|
|
34
|
+
}
|
|
@@ -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,9 +1,11 @@
|
|
|
1
|
-
/** Slack Web API transport: one JSON pipeline
|
|
2
|
-
|
|
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";
|
|
3
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;
|
|
@@ -132,9 +134,17 @@ async function readBytesCapped(response) {
|
|
|
132
134
|
}
|
|
133
135
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
134
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
|
+
}
|
|
135
145
|
export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
|
|
146
|
+
assertLongLivedBotToken(botToken);
|
|
136
147
|
const apiBase = baseUrl.replace(/\/$/, "");
|
|
137
|
-
const currentToken = typeof botToken === "string" ? async () => botToken : botToken;
|
|
138
148
|
const call = async (method, body, httpMethod = "POST") => {
|
|
139
149
|
const url = new URL(`${apiBase}/${method}`);
|
|
140
150
|
if (httpMethod === "GET") {
|
|
@@ -147,11 +157,10 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
147
157
|
let response;
|
|
148
158
|
let raw;
|
|
149
159
|
try {
|
|
150
|
-
const token = await currentToken();
|
|
151
160
|
response = await fetch(url, {
|
|
152
161
|
method: httpMethod,
|
|
153
162
|
headers: {
|
|
154
|
-
authorization: `Bearer ${
|
|
163
|
+
authorization: `Bearer ${botToken}`,
|
|
155
164
|
...(httpMethod === "POST" ? { "content-type": "application/json; charset=utf-8" } : {}),
|
|
156
165
|
},
|
|
157
166
|
...(httpMethod === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
@@ -195,10 +204,9 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
195
204
|
const download = async (file) => {
|
|
196
205
|
let response;
|
|
197
206
|
try {
|
|
198
|
-
const token = await currentToken();
|
|
199
207
|
response = await fetch(fileDownloadUrl(file), {
|
|
200
|
-
headers: { authorization: `Bearer ${
|
|
201
|
-
signal: AbortSignal.timeout(
|
|
208
|
+
headers: { authorization: `Bearer ${botToken}` },
|
|
209
|
+
signal: AbortSignal.timeout(FILE_TRANSFER_TIMEOUT_MS),
|
|
202
210
|
});
|
|
203
211
|
}
|
|
204
212
|
catch (error) {
|
|
@@ -210,6 +218,19 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
210
218
|
}
|
|
211
219
|
return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
|
|
212
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 };
|
|
233
|
+
};
|
|
213
234
|
const api = {
|
|
214
235
|
async authTest() {
|
|
215
236
|
const data = await call("auth.test", {});
|
|
@@ -228,16 +249,7 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
228
249
|
return data.ts;
|
|
229
250
|
},
|
|
230
251
|
async postMarkdown(target, markdown) {
|
|
231
|
-
|
|
232
|
-
channel: target.channelId,
|
|
233
|
-
markdown_text: markdown,
|
|
234
|
-
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
235
|
-
unfurl_links: false,
|
|
236
|
-
unfurl_media: false,
|
|
237
|
-
});
|
|
238
|
-
if (!data.ts)
|
|
239
|
-
throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
|
|
240
|
-
return data.ts;
|
|
252
|
+
return (await postMarkdown(target, markdown)).ts;
|
|
241
253
|
},
|
|
242
254
|
async updateMessage(channelId, ts, text) {
|
|
243
255
|
try {
|
|
@@ -265,10 +277,11 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
265
277
|
async sendMarkdown(target, markdown) {
|
|
266
278
|
let first;
|
|
267
279
|
for (const chunk of chunkSlackMarkdown(markdown)) {
|
|
268
|
-
|
|
269
|
-
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;
|
|
270
283
|
}
|
|
271
|
-
return first;
|
|
284
|
+
return first; // chunkSlackMarkdown never yields zero chunks
|
|
272
285
|
},
|
|
273
286
|
async startStream(target, markdown) {
|
|
274
287
|
if (!target.threadTs)
|
|
@@ -362,6 +375,52 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
|
|
|
362
375
|
await writeFile(path, bytes);
|
|
363
376
|
return { path, name, size: bytes.byteLength };
|
|
364
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
|
+
},
|
|
365
424
|
};
|
|
366
425
|
return api;
|
|
367
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
|