@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.
Files changed (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +25 -37
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,6 +1,5 @@
1
1
  import { defineTool, z } from "@fastagent-sh/fastagent";
2
- import { open, stat } from "node:fs/promises";
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
- const API = "https://slack.com/api";
13
- const MAX_TEXT = 10_000;
14
- const RETRIES = 3;
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 chunks = splitText(text);
99
- for (const chunk of chunks) {
100
- await callSlack(token, "chat.postMessage", {
101
- channel: channelId,
102
- markdown_text: chunk,
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
- if (!byteResponse.ok) {
142
- throw new Error(`Slack file byte upload failed: ${byteResponse.status} ${await byteResponse.text()}`);
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 | (() => Promise<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
- sendMarkdown(target: SlackTarget, markdown: string): Promise<string | undefined>;
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 plus authenticated, capped private-file downloads. */
2
- import { mkdir, writeFile } from "node:fs/promises";
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 DOWNLOAD_TIMEOUT_MS = 120_000;
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 ${token}`,
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 ${token}` },
201
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
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
- const data = await call("chat.postMessage", {
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
- const ts = await api.postMarkdown(target, chunk);
269
- first ??= ts;
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