@fastagent-sh/fastagent 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -2,7 +2,7 @@
2
2
  import type { Agent, AgentEvent } from "../../agent.ts";
3
3
  import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
4
4
  import type { SlackBufferedFileRef } from "./context-buffer.ts";
5
- import type { SlackApi } from "./slack-api.ts";
5
+ import { type SlackApi } from "./slack-api.ts";
6
6
  export interface SlackTurnTransport {
7
7
  api: SlackApi;
8
8
  channelId: string;
@@ -1,5 +1,6 @@
1
1
  import { log } from "../../log.js";
2
2
  import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { SlackApiError } from "./slack-api.js";
3
4
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
4
5
  async function resolveFile(transport, fileId) {
5
6
  const info = await transport.api.fileInfo(fileId);
@@ -58,7 +59,10 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
58
59
  resolved = await resolveInputs(transport, attachments);
59
60
  }
60
61
  catch (error) {
61
- yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable: true };
62
+ // transient (network, exhausted 429 retries, Slack 5xx) is worth re-sending; an access or shape
63
+ // error reads the same every time, and "try again in a moment" is the wrong thing to tell the user
64
+ const retryable = error instanceof SlackApiError && (error.status === 0 || error.status === 429 || error.status >= 500);
65
+ yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable };
62
66
  return;
63
67
  }
64
68
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
@@ -63,7 +63,11 @@ export function buildSlackManifest(input) {
63
63
  : {}),
64
64
  org_deploy_enabled: false,
65
65
  socket_mode_enabled: false,
66
- token_rotation_enabled: true,
66
+ // Off, and Slack makes the alternative permanent: an app with rotation on can never turn it off
67
+ // (`cannot_disable_once_enabled`, verified against the API). Rotation would ship the refresh token
68
+ // and the client secret to every host beside the access token — the same blast radius as one
69
+ // long-lived bot token, plus a 12-hour refresh lineage to keep single and durable.
70
+ token_rotation_enabled: false,
67
71
  },
68
72
  };
69
73
  }
@@ -10,13 +10,10 @@ export interface SlackOnboardIO {
10
10
  state?: string;
11
11
  error?: string;
12
12
  }>;
13
- /** Stage runtime-only credentials into the gitignored .env. */
13
+ /** Stage the runtime secrets into the gitignored .env — the bot token and the signing secret, nothing
14
+ * else: the client credentials are setup-only (the OAuth code exchange) and stay in onboarding state. */
14
15
  writeRuntimeSecrets(values: {
15
16
  botToken?: string;
16
- botRefreshToken?: string;
17
- botTokenExpiresAt?: number;
18
- clientId?: string;
19
- clientSecret?: string;
20
17
  signingSecret?: string;
21
18
  }): Promise<void>;
22
19
  }
@@ -31,6 +28,9 @@ export declare function onboardSlackApp(input: SlackOnboardInput, io: SlackOnboa
31
28
  createApp?: typeof createSlackApp;
32
29
  updateManifest?: typeof updateSlackAppManifest;
33
30
  exchangeCode?: typeof exchangeSlackOAuthCode;
31
+ /** Attempts absorbing the setup tunnel's warm-up before Slack can verify its URL. */
32
+ attempts?: number;
33
+ retryMs?: number;
34
34
  }): Promise<SlackOnboardingState>;
35
35
  export declare function newSlackOnboardingState(input: {
36
36
  appName: string;
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { createSlackApp, exchangeSlackOAuthCode, SlackConfigApiError, updateSlackAppManifest } from "./config-api.js";
2
+ import { retryWhile } from "../registration.js";
3
+ import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked, SLACK_ROTATING_APP_UPGRADE, SlackConfigApiError, updateSlackAppManifest, } from "./config-api.js";
3
4
  import { buildSlackManifest, slackBotScopes } from "./manifest.js";
4
5
  import { currentSlackConfigToken, writeSlackOnboardingState } from "./onboarding-state.js";
5
6
  /** Create/resume one internal Slack app and complete its workspace OAuth installation. */
@@ -7,6 +8,16 @@ export async function onboardSlackApp(input, io, deps = {}) {
7
8
  let state = input.state;
8
9
  const current = await currentSlackConfigToken(input.stateRoot, state);
9
10
  state = current.state;
11
+ /** Slack challenges request_url from ITS network during a manifest call and validates the whole
12
+ * manifest before acting, so an unverifiable URL created nothing and the call is safe to repeat. */
13
+ const whileUnverified = (call, onRetry) => retryWhile(call, isSlackRequestUrlUnverified, {
14
+ attempts: deps.attempts,
15
+ retryMs: deps.retryMs,
16
+ onRetry: ({ attempt, attempts }) => {
17
+ onRetry?.();
18
+ io.note(`Slack cannot reach the temporary setup URL yet (attempt ${attempt}/${attempts}); retrying…`);
19
+ },
20
+ });
10
21
  const manifest = buildSlackManifest({
11
22
  name: state.appName,
12
23
  groupBehavior: state.groupBehavior,
@@ -19,21 +30,26 @@ export async function onboardSlackApp(input, io, deps = {}) {
19
30
  "Inspect https://api.slack.com/apps before retrying; if an app exists, delete that incomplete app first, then remove the local Slack onboarding state and re-run");
20
31
  }
21
32
  io.note("Creating the internal Slack app from its FastAgent manifest…");
22
- // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the app;
23
- // refusing a blind retry is safer than silently producing duplicates.
24
- state = { ...state, createAttemptedAt: new Date().toISOString() };
25
- await writeSlackOnboardingState(input.stateRoot, state);
33
+ const setMarker = (value) => {
34
+ state = { ...state, createAttemptedAt: value };
35
+ writeSlackOnboardingState(input.stateRoot, state);
36
+ };
26
37
  let created;
27
38
  try {
28
- created = await (deps.createApp ?? createSlackApp)(current.token, manifest);
39
+ created = await whileUnverified(() => {
40
+ // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the
41
+ // app; refusing a blind retry is safer than silently producing duplicates. The marker spans
42
+ // exactly one in-flight call: an unverifiable URL created nothing, so it comes off again before
43
+ // the wait — a Ctrl-C during that wait must not wedge the next run on an app that never existed.
44
+ setMarker(new Date().toISOString());
45
+ return (deps.createApp ?? createSlackApp)(current.token, manifest);
46
+ }, () => setMarker(undefined));
29
47
  }
30
48
  catch (error) {
31
49
  const ambiguous = !(error instanceof SlackConfigApiError) ||
32
50
  ["fatal_error", "internal_error", "request_timeout", "service_unavailable", "failed_creating_app"].includes(error.code);
33
- if (!ambiguous) {
34
- state = { ...state, createAttemptedAt: undefined };
35
- await writeSlackOnboardingState(input.stateRoot, state);
36
- }
51
+ if (!ambiguous)
52
+ setMarker(undefined);
37
53
  throw error;
38
54
  }
39
55
  state = {
@@ -45,7 +61,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
45
61
  signingSecret: created.signingSecret,
46
62
  };
47
63
  // Irreversible boundary first: a cancellation or .env write failure can resume without creating a duplicate.
48
- await writeSlackOnboardingState(input.stateRoot, state);
64
+ writeSlackOnboardingState(input.stateRoot, state);
49
65
  io.note(`Created Slack app ${created.appId}; credentials captured locally.`);
50
66
  }
51
67
  else {
@@ -53,13 +69,22 @@ export async function onboardSlackApp(input, io, deps = {}) {
53
69
  throw new Error(`Slack app ${state.appId} exists but OAuth client credentials are no longer available — ` +
54
70
  "the app appears already installed; set SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET in .env or remove the app and onboarding state to start over");
55
71
  }
56
- io.note(`Resuming Slack app ${state.appId}; refreshing its temporary setup URLs…`);
57
- await (deps.updateManifest ?? updateSlackAppManifest)(current.token, state.appId, manifest);
72
+ const appId = state.appId;
73
+ io.note(`Resuming Slack app ${appId}; refreshing its temporary setup URLs…`);
74
+ try {
75
+ await whileUnverified(() => (deps.updateManifest ?? updateSlackAppManifest)(current.token, appId, manifest));
76
+ }
77
+ catch (error) {
78
+ // A resume of an app created with rotation on: the manifest is refused for good, so say what fixes it.
79
+ if (isSlackRotationLocked(error))
80
+ throw new Error(SLACK_ROTATING_APP_UPGRADE, { cause: error });
81
+ throw error;
82
+ }
58
83
  }
59
84
  if (state.signingSecret) {
60
85
  await io.writeRuntimeSecrets({ signingSecret: state.signingSecret });
61
86
  state = { ...state, signingSecret: undefined };
62
- await writeSlackOnboardingState(input.stateRoot, state);
87
+ writeSlackOnboardingState(input.stateRoot, state);
63
88
  }
64
89
  if (!state.appId || !state.clientId || !state.clientSecret) {
65
90
  throw new Error("Slack onboarding state lost app OAuth credentials before installation");
@@ -70,7 +95,9 @@ export async function onboardSlackApp(input, io, deps = {}) {
70
95
  authorize.searchParams.set("scope", slackBotScopes(state.groupBehavior).join(","));
71
96
  authorize.searchParams.set("redirect_uri", input.redirectUrl);
72
97
  authorize.searchParams.set("state", oauthState);
73
- io.note(`Approve the internal app installation in Slack: ${authorize}`);
98
+ // The redirect lands on the tunnel hostname, which THIS machine may not resolve yet (#421) — a broken
99
+ // page beside a terminal that looks hung, unless the reload is named before it happens.
100
+ io.note(`Click Allow in Slack to install the app: ${authorize}\nIf the page it returns to does not load, reload it.`);
74
101
  io.openUrl(authorize.toString());
75
102
  const callback = await io.waitForOAuth();
76
103
  if (callback.error)
@@ -90,13 +117,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
90
117
  if (slackBotScopes(state.groupBehavior).some((scope) => !oauth.scopes.includes(scope))) {
91
118
  throw new Error("Slack OAuth completed without all required bot scopes; re-run fastagent add slack to reinstall");
92
119
  }
93
- await io.writeRuntimeSecrets({
94
- botToken: oauth.botToken,
95
- botRefreshToken: oauth.botRefreshToken,
96
- botTokenExpiresAt: oauth.botTokenExpiresAt,
97
- clientId: state.clientId,
98
- clientSecret: state.clientSecret,
99
- });
120
+ await io.writeRuntimeSecrets({ botToken: oauth.botToken });
100
121
  state = {
101
122
  ...state,
102
123
  clientSecret: undefined,
@@ -104,7 +125,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
104
125
  teamName: oauth.teamName,
105
126
  installedAt: new Date().toISOString(),
106
127
  };
107
- await writeSlackOnboardingState(input.stateRoot, state);
128
+ writeSlackOnboardingState(input.stateRoot, state);
108
129
  return state;
109
130
  }
110
131
  export function newSlackOnboardingState(input) {
@@ -18,9 +18,13 @@ export interface SlackOnboardingState {
18
18
  teamName?: string;
19
19
  installedAt?: string;
20
20
  }
21
- export declare function readSlackOnboardingState(stateRoot: string): Promise<SlackOnboardingState | undefined>;
22
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
23
- export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): Promise<void>;
21
+ export declare function readSlackOnboardingState(stateRoot: string): SlackOnboardingState | undefined;
22
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
23
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
24
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
25
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
26
+ * and permission rules to keep true. */
27
+ export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): void;
24
28
  export declare function currentSlackConfigToken(stateRoot: string, state: SlackOnboardingState, options?: {
25
29
  now?: number;
26
30
  apiBaseUrl?: string;
@@ -1,5 +1,6 @@
1
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "../../atomic-write.js";
3
4
  import { rotateSlackConfigToken } from "./config-api.js";
4
5
  function slackOnboardingStatePath(stateRoot) {
5
6
  return join(stateRoot, "channels", "slack", "onboarding.json");
@@ -15,11 +16,11 @@ function validState(value) {
15
16
  typeof state.configRefreshToken === "string" &&
16
17
  typeof state.configTokenExpiresAt === "number");
17
18
  }
18
- export async function readSlackOnboardingState(stateRoot) {
19
+ export function readSlackOnboardingState(stateRoot) {
19
20
  const file = slackOnboardingStatePath(stateRoot);
20
21
  let raw;
21
22
  try {
22
- raw = await readFile(file, "utf8");
23
+ raw = readFileSync(file, "utf8");
23
24
  }
24
25
  catch (error) {
25
26
  if (error.code === "ENOENT")
@@ -36,21 +37,13 @@ export async function readSlackOnboardingState(stateRoot) {
36
37
  throw new Error(`invalid Slack onboarding state ${file}: ${error.message}`);
37
38
  }
38
39
  }
39
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
40
- export async function writeSlackOnboardingState(stateRoot, state) {
41
- const file = slackOnboardingStatePath(stateRoot);
42
- await mkdir(dirname(file), { recursive: true });
43
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
44
- try {
45
- await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
46
- await chmod(temp, 0o600);
47
- await rename(temp, file);
48
- await chmod(file, 0o600);
49
- }
50
- catch (error) {
51
- await rm(temp, { force: true }).catch(() => { });
52
- throw error;
53
- }
40
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
41
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
42
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
43
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
44
+ * and permission rules to keep true. */
45
+ export function writeSlackOnboardingState(stateRoot, state) {
46
+ writeFileAtomic(slackOnboardingStatePath(stateRoot), `${JSON.stringify(state, null, 2)}\n`, 0o600);
54
47
  }
55
48
  export async function currentSlackConfigToken(stateRoot, state, options = {}) {
56
49
  const now = options.now ?? Date.now();
@@ -64,6 +57,6 @@ export async function currentSlackConfigToken(stateRoot, state, options = {}) {
64
57
  configTokenExpiresAt: rotated.expiresAt,
65
58
  teamId: state.teamId ?? rotated.teamId,
66
59
  };
67
- await writeSlackOnboardingState(stateRoot, next);
60
+ writeSlackOnboardingState(stateRoot, next);
68
61
  return { token: next.configToken, state: next };
69
62
  }
@@ -1,8 +1,9 @@
1
- import type { RegistrationOutcome } from "../registration.ts";
1
+ import { type RegistrationOutcome } from "../registration.ts";
2
2
  export interface RegisterSlackWebhookOptions {
3
3
  stateRoot: string;
4
4
  log?: (message: string) => void;
5
- healthTimeoutMs?: number;
5
+ attempts?: number;
6
+ retryMs?: number;
6
7
  apiBaseUrl?: string;
7
8
  fetch?: typeof fetch;
8
9
  }
@@ -1,5 +1,5 @@
1
- import { waitForHealth } from "../wait-health.js";
2
- import { updateSlackAppManifest } from "./config-api.js";
1
+ import { retryWhile } from "../registration.js";
2
+ import { SLACK_ROTATING_APP_UPGRADE, isSlackRequestUrlUnverified, isSlackRotationLocked, updateSlackAppManifest, } from "./config-api.js";
3
3
  import { buildSlackManifest } from "./manifest.js";
4
4
  import { currentSlackConfigToken, readSlackOnboardingState } from "./onboarding-state.js";
5
5
  /** Update one onboarded internal Slack app without sending its configuration credential to the host. */
@@ -8,7 +8,7 @@ export async function registerSlackWebhook(baseUrl, options) {
8
8
  const publicBaseUrl = baseUrl.replace(/\/$/, "");
9
9
  let state;
10
10
  try {
11
- state = await readSlackOnboardingState(options.stateRoot);
11
+ state = readSlackOnboardingState(options.stateRoot);
12
12
  }
13
13
  catch (error) {
14
14
  note(`[fastagent] slack: cannot read local onboarding state: ${String(error)}`);
@@ -20,30 +20,50 @@ export async function registerSlackWebhook(baseUrl, options) {
20
20
  `(repair its expired/revoked tokens with \`fastagent add slack --replace-config\`)`);
21
21
  return "manual";
22
22
  }
23
- const healthy = await waitForHealth(`${publicBaseUrl}/health`, options.healthTimeoutMs ?? 45_000, 500);
24
- if (!healthy) {
25
- note(`[fastagent] slack: ${publicBaseUrl}/health did not become reachable; Request URL was not changed`);
26
- return "failed";
27
- }
23
+ const consoleFallback = `set ${publicBaseUrl}/slack as the Event Subscriptions Request URL in the Slack console`;
24
+ let current;
28
25
  try {
29
- const current = await currentSlackConfigToken(options.stateRoot, state, {
26
+ // Rotation is not retried with the manifest below: each rotate invalidates the previous pair, so a
27
+ // repeat would spend tokens on a failure that is about the URL, not about the credential.
28
+ current = await currentSlackConfigToken(options.stateRoot, state, {
30
29
  apiBaseUrl: options.apiBaseUrl,
31
30
  fetch: options.fetch,
32
31
  });
33
- await updateSlackAppManifest(current.token, current.state.appId, buildSlackManifest({
32
+ }
33
+ catch (error) {
34
+ note(`[fastagent] slack: could not refresh the App Configuration token: ${String(error)} — ` +
35
+ `re-run \`fastagent add slack --replace-config\` to repair it, or ${consoleFallback}`);
36
+ return "failed";
37
+ }
38
+ // Slack verifies the new request_url with a challenge DURING apps.manifest.update, from Slack's own
39
+ // network — that verdict IS the readiness signal, so it is retried while a fresh tunnel/container
40
+ // warms up. No local `/health` poll precedes it: this machine's reach is the wrong question (#421).
41
+ try {
42
+ await retryWhile(() => updateSlackAppManifest(current.token, current.state.appId, buildSlackManifest({
34
43
  name: current.state.appName,
35
44
  groupBehavior: current.state.groupBehavior,
36
45
  requestUrl: `${publicBaseUrl}/slack`,
37
- // Token-rotation manifests require at least one OAuth redirect URL even after installation.
38
- // Actual reinstall flows replace this placeholder with their one-shot local setup callback.
46
+ // A manifest update replaces the whole manifest and redirect_urls may not be empty, so a
47
+ // placeholder is declared; a reinstall replaces it with its one-shot local setup callback.
39
48
  redirectUrl: `${publicBaseUrl}/slack/oauth/callback`,
40
- }), { apiBaseUrl: options.apiBaseUrl, fetch: options.fetch });
49
+ }), { apiBaseUrl: options.apiBaseUrl, fetch: options.fetch }), isSlackRequestUrlUnverified, {
50
+ attempts: options.attempts,
51
+ retryMs: options.retryMs,
52
+ onRetry: ({ attempt, attempts }) => note(`[fastagent] slack: Slack cannot verify ${publicBaseUrl}/slack yet (attempt ${attempt}/${attempts}); retrying…`),
53
+ });
41
54
  note(`[fastagent] slack: Event Subscriptions Request URL registered → ${publicBaseUrl}/slack`);
42
55
  return "registered";
43
56
  }
44
57
  catch (error) {
45
- note(`[fastagent] slack: automatic Request URL registration failed: ${String(error)} — ` +
46
- `re-run \`fastagent add slack --replace-config\` to repair the configuration tokens, or set ${publicBaseUrl}/slack in the Slack console`);
58
+ if (isSlackRotationLocked(error)) {
59
+ note(`[fastagent] slack: ${SLACK_ROTATING_APP_UPGRADE}, or ${consoleFallback}`);
60
+ return "failed";
61
+ }
62
+ note(isSlackRequestUrlUnverified(error)
63
+ ? `[fastagent] slack: Slack could not verify ${publicBaseUrl}/slack after retries (last error: ${String(error)}) — ` +
64
+ `once the app is up, ${consoleFallback}`
65
+ : `[fastagent] slack: automatic Request URL registration failed: ${String(error)} — ` +
66
+ `re-run \`fastagent add slack --replace-config\` to repair the configuration tokens, or ${consoleFallback}`);
47
67
  return "failed";
48
68
  }
49
69
  }
@@ -8,18 +8,11 @@ import { slackChannel } from "@fastagent-sh/fastagent/slack";
8
8
  // 3. Event Subscriptions: app_home_opened, app_context_changed, app_mention, message.im,
9
9
  // message.channels, message.groups, and message.mpim. Mention-only may omit the last three.
10
10
  // Set Request URL to https://<host>/slack.
11
- // 4. Enable token rotation and install/reinstall the app after changing scopes. Rotating credentials
12
- // are written by `fastagent add slack`; manual long-lived tokens may omit the four rotation fields.
11
+ // 4. Install the app (reinstall after changing scopes) and leave token rotation OFF: it cannot be
12
+ // turned off again, and this channel takes one long-lived Bot User OAuth Token.
13
13
  export default slackChannel({
14
- botToken: process.env.SLACK_BOT_TOKEN ?? "", // Bot User OAuth Token (xoxb-… or rotating xoxe.xoxb-…)
14
+ botToken: process.env.SLACK_BOT_TOKEN ?? "", // OAuth & Permissions → Bot User OAuth Token (xoxb-…)
15
15
  signingSecret: process.env.SLACK_SIGNING_SECRET ?? "", // Basic Information → App Credentials
16
- botRefreshToken: process.env.SLACK_BOT_REFRESH_TOKEN || undefined,
17
- clientId: process.env.SLACK_CLIENT_ID || undefined,
18
- clientSecret: process.env.SLACK_CLIENT_SECRET || undefined,
19
- botTokenExpiresAt: process.env.SLACK_BOT_TOKEN_EXPIRES_AT
20
- ? Number(process.env.SLACK_BOT_TOKEN_EXPIRES_AT)
21
- : undefined,
22
- groupBehavior: "context", // default; use "mentions" only for an explicit least-privilege setup
23
16
  // Slack Agent stream; its inline tool traces show each call's first argument and stay in the
24
17
  // delivered message. "classic" settles into the answer alone (and gives up native streaming).
25
18
  rendering: "native",
@@ -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,84 +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
- }
73
-
74
- function trustedUploadUrl(value: string): URL {
75
- const url = new URL(value);
76
- const host = url.hostname.toLowerCase();
77
- const slackHost =
78
- host === "slack-files.com" ||
79
- host.endsWith(".slack-files.com") ||
80
- host === "slack.com" ||
81
- host.endsWith(".slack.com") ||
82
- host === "slack-edge.com" ||
83
- host.endsWith(".slack-edge.com");
84
- if (url.protocol !== "https:" || !slackHost) {
85
- throw new Error(`Slack returned an untrusted upload URL host: ${host}`);
86
- }
87
- return url;
88
- }
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.
89
14
 
90
15
  export default defineTool({
91
16
  description:
@@ -93,79 +18,31 @@ export default defineTool({
93
18
  "carrying — a scheduled or self-scheduled (wake) turn. In a normal chat turn the channel already " +
94
19
  "delivers your reply, so do NOT call this to answer (it would post the message twice). Pass exactly " +
95
20
  "one of `text`/`path`. channelId/threadTs come from the [slack: …] context line in a chat turn; a " +
96
- "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.",
97
23
  input: z.object({
98
- 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"),
99
25
  text: z.string().optional().describe("standard Markdown message text"),
100
26
  path: z.string().optional().describe("absolute path of a local file to upload"),
101
27
  title: z.string().optional().describe("file title (file mode only)"),
102
28
  initialComment: z.string().optional().describe("message posted with the file (file mode only)"),
103
29
  threadTs: z.string().optional().describe("thread parent timestamp, if replying in a thread"),
104
30
  }),
105
- async execute({ channelId, text, path, title, initialComment, threadTs }) {
106
- const token = process.env.SLACK_BOT_TOKEN;
107
- if (!token) throw new Error("SLACK_BOT_TOKEN is not set");
31
+ async execute({ channelId, text, path, title, initialComment, threadTs }, ctx) {
108
32
  if ((text === undefined) === (path === undefined)) throw new Error("pass exactly one of `text` or `path`");
109
-
33
+ const slack = slackTransport(ctx.cwd);
34
+ const target = { channelId, threadTs };
110
35
  if (text !== undefined) {
111
36
  if (title !== undefined || initialComment !== undefined) {
112
37
  throw new Error("`title`/`initialComment` are file-mode only");
113
38
  }
114
- const chunks = splitText(text);
115
- for (const chunk of chunks) {
116
- await callSlack(token, "chat.postMessage", {
117
- channel: channelId,
118
- markdown_text: chunk,
119
- ...(threadTs ? { thread_ts: threadTs } : {}),
120
- unfurl_links: false,
121
- unfurl_media: false,
122
- });
123
- }
124
- return chunks.length === 1
125
- ? `sent message to Slack channel ${channelId}`
126
- : `sent ${chunks.length} messages to Slack channel ${channelId}`;
127
- }
128
-
129
- const filePath = path as string;
130
- const info = await stat(filePath);
131
- if (!info.isFile()) throw new Error(`${filePath} is not a regular file`);
132
- const filename = basename(filePath);
133
- const upload = await callSlack<{ ok?: boolean; error?: string; upload_url?: string; file_id?: string }>(
134
- token,
135
- "files.getUploadURLExternal",
136
- { filename, length: info.size },
137
- );
138
- if (!upload.upload_url || !upload.file_id) throw new Error("Slack upload URL response carried no upload_url/file_id");
139
-
140
- let byteResponse: Response;
141
- const handle = await open(filePath, "r");
142
- try {
143
- byteResponse = await fetch(trustedUploadUrl(upload.upload_url), {
144
- method: "POST",
145
- headers: { "content-type": "application/octet-stream" },
146
- body: handle.readableWebStream(),
147
- signal: AbortSignal.timeout(120_000),
148
- // Required by Node fetch for a streaming request body; not part of the browser RequestInit type.
149
- duplex: "half",
150
- } as RequestInit & { duplex: "half" });
151
- } catch (error) {
152
- throw new Error(`Slack file byte upload failed: ${String(error)}`, { cause: error });
153
- } finally {
154
- 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})`;
155
44
  }
156
- if (!byteResponse.ok) {
157
- throw new Error(`Slack file byte upload failed: ${byteResponse.status} ${await byteResponse.text()}`);
158
- }
159
-
160
- // At-least-once: if Slack commits this call but the response is lost, a tool retry may post a
161
- // duplicate. Retrying automatically here would hide that ambiguity, so only explicit agent/user
162
- // retry crosses this final side-effect boundary.
163
- await callSlack(token, "files.completeUploadExternal", {
164
- files: [{ id: upload.file_id, title: title ?? filename }],
165
- channel_id: channelId,
166
- ...(initialComment ? { initial_comment: initialComment } : {}),
167
- ...(threadTs ? { thread_ts: threadTs } : {}),
168
- });
169
- 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})`;
170
47
  },
171
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
+ }