@fastagent-sh/fastagent 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
22
|
-
/** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
|
|
23
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
19
|
+
export function readSlackOnboardingState(stateRoot) {
|
|
19
20
|
const file = slackOnboardingStatePath(stateRoot);
|
|
20
21
|
let raw;
|
|
21
22
|
try {
|
|
22
|
-
raw =
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
60
|
+
writeSlackOnboardingState(stateRoot, next);
|
|
68
61
|
return { token: next.configToken, state: next };
|
|
69
62
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RegistrationOutcome } from "../registration.ts";
|
|
2
2
|
export interface RegisterSlackWebhookOptions {
|
|
3
3
|
stateRoot: string;
|
|
4
4
|
log?: (message: string) => void;
|
|
5
|
-
|
|
5
|
+
attempts?: number;
|
|
6
|
+
retryMs?: number;
|
|
6
7
|
apiBaseUrl?: string;
|
|
7
8
|
fetch?: typeof fetch;
|
|
8
9
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
//
|
|
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
|
-
|
|
46
|
-
`
|
|
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.
|
|
12
|
-
//
|
|
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-…
|
|
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 {
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
+
}
|