@fastagent-sh/fastagent 0.14.0 → 0.16.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 +16 -6
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +35 -0
- package/dist/channels/context-buffer.js +103 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +15 -13
- package/dist/channels/feishu/context-buffer.js +62 -69
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +24 -3
- package/dist/channels/feishu/feishu.d.ts +27 -27
- package/dist/channels/feishu/feishu.js +318 -207
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +44 -79
- package/dist/channels/feishu/model.d.ts +2 -2
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +22 -8
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +27 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +37 -0
- package/dist/channels/feishu/setup-mode.js +28 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +16 -7
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +36 -3
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +115 -0
- package/dist/channels/preview-kit.js +184 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/parse.d.ts +38 -0
- package/dist/channels/slack/parse.js +124 -0
- package/dist/channels/slack/preview.d.ts +23 -0
- package/dist/channels/slack/preview.js +403 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +57 -0
- package/dist/channels/slack/slack-api.js +399 -0
- package/dist/channels/slack/slack.d.ts +51 -0
- package/dist/channels/slack/slack.js +496 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +22 -15
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +7 -0
- package/dist/channels/tasks.js +25 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +24 -10
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
- package/dist/channels/{feishu/text.js → text.js} +15 -1
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +8 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +30 -0
- package/dist/cli/add-feishu.js +366 -0
- package/dist/cli/add-slack.d.ts +11 -0
- package/dist/cli/add-slack.js +199 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +191 -60
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +563 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +12 -7
- package/dist/cli/commands/deploy.d.ts +3 -1
- package/dist/cli/commands/deploy.js +384 -82
- package/dist/cli/commands/dev.js +34 -26
- package/dist/cli/commands/fire.js +17 -18
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +11 -9
- package/dist/cli/commands/login.js +48 -45
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +102 -39
- package/dist/cli/commands/tool.js +23 -18
- package/dist/cli/fail.d.ts +17 -2
- package/dist/cli/fail.js +25 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +135 -56
- package/dist/cli/serve.d.ts +60 -20
- package/dist/cli/serve.js +252 -47
- package/dist/cli/shared.d.ts +40 -7
- package/dist/cli/shared.js +180 -38
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +3 -3
- package/dist/deploy/docker/plan.js +20 -23
- package/dist/deploy/fly/plan.d.ts +5 -1
- package/dist/deploy/fly/plan.js +39 -26
- package/dist/deploy/fly/run.d.ts +17 -5
- package/dist/deploy/fly/run.js +52 -7
- package/dist/deploy/preflight.d.ts +20 -8
- package/dist/deploy/preflight.js +267 -71
- package/dist/deploy/railway/plan.d.ts +10 -1
- package/dist/deploy/railway/plan.js +53 -20
- package/dist/deploy/railway/run.d.ts +13 -5
- package/dist/deploy/railway/run.js +22 -7
- package/dist/deploy/runner.d.ts +7 -3
- package/dist/deploy/runner.js +9 -3
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +11 -10
- package/dist/dev-supervisor.js +56 -50
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +92 -62
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +20 -55
- package/dist/engines/pi/config.js +49 -121
- package/dist/engines/pi/create.d.ts +30 -10
- package/dist/engines/pi/create.js +33 -26
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +12 -1
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +59 -13
- package/dist/engines/pi/models.js +62 -24
- package/dist/engines/pi/open.d.ts +114 -0
- package/dist/engines/pi/open.js +127 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +10 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +25 -8
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +6 -5
- package/dist/pi.js +4 -3
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +26 -9
- package/dist/scaffold/add-channel.js +175 -40
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +5 -4
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -3
- package/dist/schedule/wakeups.js +19 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +23 -12
- package/package.json +22 -7
- package/dist/channels/feishu/owned-threads.d.ts +0 -11
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-add-feishu.js +0 -223
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
- package/dist/engines/pi/workspace.d.ts +0 -56
- package/dist/engines/pi/workspace.js +0 -72
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FeishuGroupBehavior, FeishuSubscriptionMode } from "../feishu/setup-mode.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Guided Lark-international onboarding. The intl cloud cannot complete the BOUND scan-to-create flow,
|
|
3
4
|
* so a new/partial setup opens its unbound one-click launcher and collects one App-scoped credential
|
|
@@ -17,7 +18,7 @@ export interface LarkOnboardIO {
|
|
|
17
18
|
hidden?: boolean;
|
|
18
19
|
}): Promise<string | undefined>;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
interface LarkBootstrapResult {
|
|
21
22
|
/** Challenge-captured token: the PATCH also switched Subscription mode to webhook. */
|
|
22
23
|
token?: string;
|
|
23
24
|
/** Present only for a definitive config-route 404; tells the user why the manual path is active. */
|
|
@@ -26,14 +27,17 @@ export interface LarkBootstrapResult {
|
|
|
26
27
|
export interface LarkOnboardOptions {
|
|
27
28
|
/** Existing active .env values. A complete credential pair is reused (and still validated). */
|
|
28
29
|
existing?: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
ingress?: FeishuSubscriptionMode;
|
|
31
|
+
groupBehavior?: FeishuGroupBehavior;
|
|
29
32
|
verifyCredentials(appId: string, appSecret: string): Promise<void>;
|
|
30
|
-
bootstrapWebhook(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
|
|
33
|
+
bootstrapWebhook?(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
|
|
31
34
|
}
|
|
32
|
-
export interface LarkOnboardCredentials
|
|
35
|
+
export interface LarkOnboardCredentials {
|
|
33
36
|
LARK_APP_ID: string;
|
|
34
37
|
LARK_APP_SECRET: string;
|
|
35
|
-
LARK_VERIFICATION_TOKEN
|
|
38
|
+
LARK_VERIFICATION_TOKEN?: string;
|
|
36
39
|
}
|
|
37
40
|
/** Open the stable app console and collect everything the runtime needs. Cancellation is a visible
|
|
38
41
|
* failure: the scaffold remains and `add lark` is deliberately re-runnable to resume onboarding. */
|
|
39
42
|
export declare function onboardLarkApp(io: LarkOnboardIO, opts: LarkOnboardOptions): Promise<LarkOnboardCredentials>;
|
|
43
|
+
export {};
|
|
@@ -39,7 +39,15 @@ export async function onboardLarkApp(io, opts) {
|
|
|
39
39
|
const eventSecurityUrl = larkEventSecurityUrl(appId);
|
|
40
40
|
io.note(`App ID / Secret verified. Opening Events & Callbacks → Security: ${eventSecurityUrl}`);
|
|
41
41
|
io.openUrl(eventSecurityUrl);
|
|
42
|
+
if (opts.ingress === "websocket") {
|
|
43
|
+
io.note(opts.groupBehavior === "mentions"
|
|
44
|
+
? "Choose long connection, subscribe im.message.receive_v1, then create + publish an app version. No Verification Token or Request URL is needed."
|
|
45
|
+
: "Choose long connection and subscribe im.message.receive_v1, but do not publish yet — context-aware group permission setup follows. No Verification Token or Request URL is needed.");
|
|
46
|
+
return { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret };
|
|
47
|
+
}
|
|
42
48
|
io.note("Trying automatic webhook-mode + Verification-Token bootstrap…");
|
|
49
|
+
if (!opts.bootstrapWebhook)
|
|
50
|
+
throw new Error("Lark webhook onboarding requires bootstrapWebhook");
|
|
43
51
|
const bootstrap = await opts.bootstrapWebhook(appId, appSecret);
|
|
44
52
|
if (bootstrap.token) {
|
|
45
53
|
io.note("Verification Token captured; Subscription mode changed to webhook in the app draft.");
|
|
@@ -8,8 +8,10 @@ import { larkChannel } from "@fastagent-sh/fastagent/lark";
|
|
|
8
8
|
// 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
|
|
9
9
|
// (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
|
|
10
10
|
// card scope ("Create and update card" — the live preview streams through a card). To answer bare
|
|
11
|
-
// messages in Agent
|
|
12
|
-
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
|
|
11
|
+
// messages in threads the Agent takes part in, and buffer other unsummoned group/thread context, also add the
|
|
12
|
+
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version. Add a
|
|
13
|
+
// message-read scope (e.g. `im:message:readonly`) too, so a thread's opening ask can carry the
|
|
14
|
+
// message it quotes; without it that quote degrades to a marker in the prompt.
|
|
13
15
|
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
14
16
|
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in LARK_ENCRYPT_KEY
|
|
15
17
|
// 4. run `fastagent dev --tunnel`: it attempts to switch Subscription mode to webhook + register
|
|
@@ -21,18 +23,17 @@ export default larkChannel({
|
|
|
21
23
|
appSecret: process.env.LARK_APP_SECRET ?? "",
|
|
22
24
|
verificationToken: process.env.LARK_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
23
25
|
encryptKey: process.env.LARK_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// groupMessageSession: "continuous",
|
|
26
|
+
// No session modes: a chat is one session and a thread is another, and where the answer goes follows
|
|
27
|
+
// from that (docs/design/participant-model.md).
|
|
27
28
|
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
28
29
|
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
29
30
|
// full details always go to the server log regardless.
|
|
30
31
|
onError: (failed) => `⚠️ ${failed.details}`,
|
|
31
32
|
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
32
33
|
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultLarkRoute:
|
|
33
|
-
// p2p chats always answer; groups answer on @this-bot, plus bare
|
|
34
|
-
//
|
|
35
|
-
// @other-only messages
|
|
34
|
+
// p2p chats always answer; groups answer on @this-bot, plus bare messages in a thread where the
|
|
35
|
+
// Agent takes part and exactly ONE human does. Other human group/thread discussion buffers until
|
|
36
|
+
// that place's next answered turn; @other-only messages buffer rather than triggering the Agent.
|
|
36
37
|
// Override to customise explicit routing, reusing the export:
|
|
37
38
|
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
38
39
|
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { text: `${larkEnvelope(e)}\n[extra]` },
|
|
@@ -6,7 +6,7 @@ import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
|
6
6
|
// context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
|
|
7
7
|
// the target chat id. tools/ is auto-discovered.
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
|
|
10
10
|
// the tool call (and the turn), named errors, and success gated on the body's own code===0.
|
|
11
11
|
// Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
|
|
12
12
|
// fail-fast beats a silently sleeping tool.
|
|
@@ -57,9 +57,11 @@ async function tenantToken(): Promise<string> {
|
|
|
57
57
|
export default defineTool({
|
|
58
58
|
description:
|
|
59
59
|
"Send a message to a Lark chat: plain `text`, or `markdown` (rendered as a card — headings, " +
|
|
60
|
-
"bold, code blocks, links). Exactly one of the two.
|
|
61
|
-
"
|
|
62
|
-
"
|
|
60
|
+
"bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
|
|
61
|
+
"scheduled or self-scheduled (wake) turn — or to reach a chat OTHER than the one you are " +
|
|
62
|
+
"answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
|
|
63
|
+
"answer (it would post the message twice). chatId comes from the [lark: chat …] context line in a " +
|
|
64
|
+
"chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
|
|
63
65
|
input: z.object({
|
|
64
66
|
chatId: z.string().describe("target chat id (oc_…)"),
|
|
65
67
|
text: z.string().optional().describe("plain text message to send"),
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-neutral live-preview pieces shared by every messaging channel's preview renderer
|
|
3
|
+
* (telegram/preview.ts, feishu/preview.ts, slack/preview.ts): the turn-view REDUCER (the one
|
|
4
|
+
* event → view-state machine every renderer consumes), its line renderers, the terminal-failure
|
|
5
|
+
* shape a channel hands to its `onError`, the customer-facing wording for it, and the serialized
|
|
6
|
+
* single-writer pump. DELIVERY stays per-platform — message edits vs streaming cards vs chat.update,
|
|
7
|
+
* pacing constants, reveal timing, and terminal-write policies are real platform differences — but
|
|
8
|
+
* everything platform-independent lives here, so a new event type or a wording change lands in ONE
|
|
9
|
+
* place instead of one hunk per channel.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentEvent, Json } from "../agent.ts";
|
|
12
|
+
/** A terminal failure, as a channel hands it to its `onError`. */
|
|
13
|
+
export interface ChannelFailure {
|
|
14
|
+
details: string;
|
|
15
|
+
retryable: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
|
|
18
|
+
* offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
|
|
19
|
+
* The non-retryable branch keeps the "something went wrong" phrase deliberately — it is neutral (we only
|
|
20
|
+
* know a boolean, never the specific limitation) and shared verbatim across channels. */
|
|
21
|
+
export declare function defaultErrorMessage(failed: ChannelFailure): string;
|
|
22
|
+
/** Customer-facing live-preview line for an engine-internal retry backoff (the advisory `retrying`
|
|
23
|
+
* event): neutral, no leaked internals — the reason stays in operator logs. */
|
|
24
|
+
export declare const RETRY_NOTICE = "\u23F3 Temporary problem \u2014 retrying\u2026";
|
|
25
|
+
/** The placeholder shown before any reasoning/tool/text arrives. */
|
|
26
|
+
export declare const THINKING_PLACEHOLDER = "\uD83D\uDCAD Thinking\u2026";
|
|
27
|
+
/** One tool call's line in the live view. */
|
|
28
|
+
interface ToolLine {
|
|
29
|
+
label: string;
|
|
30
|
+
status: "running" | "ok" | "error";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The channel-neutral view STATE of one in-flight turn. Renderers own everything after this state:
|
|
34
|
+
* when to reveal the young answer (age vs timer), how to format (HTML / card markdown / mrkdwn),
|
|
35
|
+
* and how to deliver frames. Terminal events are deliberately NOT view state — completed/failed
|
|
36
|
+
* resolve the preview into a final write, which is each platform's terminal-write policy.
|
|
37
|
+
*/
|
|
38
|
+
export interface TurnView {
|
|
39
|
+
thinking: string;
|
|
40
|
+
tools: ToolLine[];
|
|
41
|
+
/** tool-call id → its line, for `tool_ended` status flips (bookkeeping; renderers read `tools`). */
|
|
42
|
+
toolById: Map<string, ToolLine>;
|
|
43
|
+
answer: string;
|
|
44
|
+
/** Arrival time of the first non-empty answer delta; age-based reveal policies read it. */
|
|
45
|
+
answerSince?: number;
|
|
46
|
+
/** An advisory retry backoff is in progress (closed again by any subsequent progress event). */
|
|
47
|
+
retrying: boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare function createTurnView(): TurnView;
|
|
50
|
+
/**
|
|
51
|
+
* Apply one event to the view state. Returns true when the view changed (the caller repaints).
|
|
52
|
+
* This is the ONE place the shared view rules live: tool labels are humanized with a compact arg
|
|
53
|
+
* summary, and any progress event closes an open retry notice (a stale "retrying" line must never
|
|
54
|
+
* outlive actual progress). Terminal events only close the notice — they are the caller's business.
|
|
55
|
+
*/
|
|
56
|
+
export declare function applyTurnEvent(view: TurnView, e: AgentEvent): boolean;
|
|
57
|
+
/** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
|
|
58
|
+
export declare function toolLines(view: TurnView): string;
|
|
59
|
+
/** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe.
|
|
60
|
+
* Process, not the answer — renderers show it live only, never in the persisted final message. */
|
|
61
|
+
export declare function thinkingLine(view: TurnView, maxTail: number): string;
|
|
62
|
+
/**
|
|
63
|
+
* The shared answer-reveal policy: the answer stays hidden until its first delta has aged one
|
|
64
|
+
* throttle window (`ageMs` — each platform passes its own pacing constant). The pump's leading-edge
|
|
65
|
+
* flush would otherwise turn the very first content delta (often a lone character or unbalanced
|
|
66
|
+
* markup) into its own frame — the short-reply flicker (placeholder → "O" → "OK."). Aging is
|
|
67
|
+
* anchored at delta ARRIVAL (`answerSince`, set by the reducer) so an in-flight write can't skew the
|
|
68
|
+
* clock, and there is deliberately NO timer at the boundary: a young answer surfaces on the next
|
|
69
|
+
* content-driven pass, so a turn completing within the window delivers the final answer only.
|
|
70
|
+
*/
|
|
71
|
+
export declare function revealedAnswer(view: TurnView, ageMs: number): string;
|
|
72
|
+
/** Compose body parts (thinking/tools/retry/answer) into one frame: skip empties, blank-line joins. */
|
|
73
|
+
export declare function composeTurnBody(parts: readonly string[]): string;
|
|
74
|
+
/**
|
|
75
|
+
* The serialized live-preview writer shared by the edit/snapshot renderers (telegram, feishu).
|
|
76
|
+
* Events mark the view dirty; the pump repaints to the LATEST view with at most ONE write in
|
|
77
|
+
* flight, paced by `throttleMs`. One-in-flight is the whole point: concurrent writes can land out
|
|
78
|
+
* of order — an older frame over a newer one is the "shows 3-4 steps, blanks, re-fills" flicker —
|
|
79
|
+
* so a single writer keeps frames monotonic (and makes feishu's strictly-increasing card `sequence`
|
|
80
|
+
* correct by construction). NOT used by slack-classic: its pacing lives inside the flush (the 3s
|
|
81
|
+
* chat.update rate slot), not at the frame boundary.
|
|
82
|
+
*/
|
|
83
|
+
export interface PreviewPump {
|
|
84
|
+
/** Mark the view dirty and ensure the single writer runs (an in-flight write picks the new state
|
|
85
|
+
* up on its next loop). Synchronous — callers never await a network write. */
|
|
86
|
+
touch(): void;
|
|
87
|
+
/** Stop the pump, cut an in-flight throttle short, and await any in-flight write — so the
|
|
88
|
+
* caller's terminal write is strictly the LAST one (no stale frame landing after the answer). */
|
|
89
|
+
finish(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
export declare function createPreviewPump(opts: {
|
|
92
|
+
/** Write the LATEST view. Best-effort — the terminal write is authoritative. */
|
|
93
|
+
flush: () => Promise<void>;
|
|
94
|
+
/** Pace + coalesce a burst into one write; finish() interrupts a throttle in progress. */
|
|
95
|
+
throttleMs: number;
|
|
96
|
+
/** Called for the FIRST failing flush only (the pump keeps running): a never-rendering preview
|
|
97
|
+
* must be diagnosable, not silent — and not a log flood. */
|
|
98
|
+
onError: (error: unknown) => void;
|
|
99
|
+
}): PreviewPump;
|
|
100
|
+
/**
|
|
101
|
+
* A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
|
|
102
|
+
* rather than just `🔧 read`. Generic (a channel knows no tool schemas): show the salient value — the
|
|
103
|
+
* first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
|
|
104
|
+
*/
|
|
105
|
+
export declare function summarizeToolArgs(args: Json): string;
|
|
106
|
+
/**
|
|
107
|
+
* A plain-language label for a tool call, following Slack's agent-design guidance to name what a tool
|
|
108
|
+
* does rather than expose a raw identifier ("Create issue", not "create_issue"; "Github: create issue",
|
|
109
|
+
* not "mcp__github__create_issue"). Deliberately generic and engine-neutral: it only reshapes the
|
|
110
|
+
* identifier string — it never invents semantics and never exposes arguments. An `mcp__server__tool`
|
|
111
|
+
* identifier becomes `server: tool`; any other identifier has its separators normalized to spaces and
|
|
112
|
+
* its first letter capitalized.
|
|
113
|
+
*/
|
|
114
|
+
export declare function humanizeToolName(name: string): string;
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { truncateCodePointPrefix, truncateCodePointSuffix } from "./text.js";
|
|
2
|
+
/** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
|
|
3
|
+
* offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
|
|
4
|
+
* The non-retryable branch keeps the "something went wrong" phrase deliberately — it is neutral (we only
|
|
5
|
+
* know a boolean, never the specific limitation) and shared verbatim across channels. */
|
|
6
|
+
export function defaultErrorMessage(failed) {
|
|
7
|
+
return failed.retryable
|
|
8
|
+
? "⚠️ Temporary problem — please try again in a moment."
|
|
9
|
+
: "⚠️ Sorry, something went wrong. Try rephrasing, or check I have access to what you need.";
|
|
10
|
+
}
|
|
11
|
+
/** Customer-facing live-preview line for an engine-internal retry backoff (the advisory `retrying`
|
|
12
|
+
* event): neutral, no leaked internals — the reason stays in operator logs. */
|
|
13
|
+
export const RETRY_NOTICE = "⏳ Temporary problem — retrying…";
|
|
14
|
+
/** The placeholder shown before any reasoning/tool/text arrives. */
|
|
15
|
+
export const THINKING_PLACEHOLDER = "💭 Thinking…";
|
|
16
|
+
export function createTurnView() {
|
|
17
|
+
return { thinking: "", tools: [], toolById: new Map(), answer: "", retrying: false };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Apply one event to the view state. Returns true when the view changed (the caller repaints).
|
|
21
|
+
* This is the ONE place the shared view rules live: tool labels are humanized with a compact arg
|
|
22
|
+
* summary, and any progress event closes an open retry notice (a stale "retrying" line must never
|
|
23
|
+
* outlive actual progress). Terminal events only close the notice — they are the caller's business.
|
|
24
|
+
*/
|
|
25
|
+
export function applyTurnEvent(view, e) {
|
|
26
|
+
const closedRetry = view.retrying && e.type !== "retrying";
|
|
27
|
+
if (closedRetry)
|
|
28
|
+
view.retrying = false;
|
|
29
|
+
switch (e.type) {
|
|
30
|
+
case "text":
|
|
31
|
+
view.answer += e.delta;
|
|
32
|
+
if (view.answerSince === undefined && view.answer.trim() !== "")
|
|
33
|
+
view.answerSince = Date.now();
|
|
34
|
+
return true;
|
|
35
|
+
case "thinking":
|
|
36
|
+
view.thinking += e.delta;
|
|
37
|
+
return true;
|
|
38
|
+
case "tool_started": {
|
|
39
|
+
const arg = summarizeToolArgs(e.args);
|
|
40
|
+
const name = humanizeToolName(e.name);
|
|
41
|
+
const line = { label: arg ? `${name} ${arg}` : name, status: "running" };
|
|
42
|
+
view.tools.push(line);
|
|
43
|
+
view.toolById.set(e.id, line);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
case "tool_ended": {
|
|
47
|
+
const line = view.toolById.get(e.id);
|
|
48
|
+
if (line)
|
|
49
|
+
line.status = e.isError ? "error" : "ok";
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
case "retrying":
|
|
53
|
+
view.retrying = true;
|
|
54
|
+
return true;
|
|
55
|
+
default:
|
|
56
|
+
return closedRetry;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
|
|
60
|
+
/** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
|
|
61
|
+
export function toolLines(view) {
|
|
62
|
+
return view.tools.map((t) => `🔧 ${t.label} ${TOOL_MARK[t.status]}`).join("\n");
|
|
63
|
+
}
|
|
64
|
+
/** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe.
|
|
65
|
+
* Process, not the answer — renderers show it live only, never in the persisted final message. */
|
|
66
|
+
export function thinkingLine(view, maxTail) {
|
|
67
|
+
const t = view.thinking.replace(/\s+/g, " ").trim();
|
|
68
|
+
return t === "" ? "" : `💭 ${truncateCodePointSuffix(t, maxTail)}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The shared answer-reveal policy: the answer stays hidden until its first delta has aged one
|
|
72
|
+
* throttle window (`ageMs` — each platform passes its own pacing constant). The pump's leading-edge
|
|
73
|
+
* flush would otherwise turn the very first content delta (often a lone character or unbalanced
|
|
74
|
+
* markup) into its own frame — the short-reply flicker (placeholder → "O" → "OK."). Aging is
|
|
75
|
+
* anchored at delta ARRIVAL (`answerSince`, set by the reducer) so an in-flight write can't skew the
|
|
76
|
+
* clock, and there is deliberately NO timer at the boundary: a young answer surfaces on the next
|
|
77
|
+
* content-driven pass, so a turn completing within the window delivers the final answer only.
|
|
78
|
+
*/
|
|
79
|
+
export function revealedAnswer(view, ageMs) {
|
|
80
|
+
if (view.answer.trim() === "" || view.answerSince === undefined)
|
|
81
|
+
return "";
|
|
82
|
+
return Date.now() - view.answerSince >= ageMs ? view.answer : "";
|
|
83
|
+
}
|
|
84
|
+
/** Compose body parts (thinking/tools/retry/answer) into one frame: skip empties, blank-line joins. */
|
|
85
|
+
export function composeTurnBody(parts) {
|
|
86
|
+
return parts
|
|
87
|
+
.filter((s) => s.trim() !== "")
|
|
88
|
+
.join("\n\n")
|
|
89
|
+
.trim();
|
|
90
|
+
}
|
|
91
|
+
export function createPreviewPump(opts) {
|
|
92
|
+
let dirty = false;
|
|
93
|
+
let pumping = false;
|
|
94
|
+
let stopped = false;
|
|
95
|
+
let errored = false;
|
|
96
|
+
let pumpDone;
|
|
97
|
+
let wakeThrottle; // set while mid-throttle; finish() cuts it short
|
|
98
|
+
const runPump = async () => {
|
|
99
|
+
pumping = true;
|
|
100
|
+
try {
|
|
101
|
+
while (dirty && !stopped) {
|
|
102
|
+
dirty = false;
|
|
103
|
+
try {
|
|
104
|
+
await opts.flush();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!errored) {
|
|
108
|
+
errored = true;
|
|
109
|
+
opts.onError(error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (dirty && !stopped) {
|
|
113
|
+
await new Promise((resolve) => {
|
|
114
|
+
const t = setTimeout(resolve, opts.throttleMs);
|
|
115
|
+
wakeThrottle = () => {
|
|
116
|
+
clearTimeout(t);
|
|
117
|
+
resolve();
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
wakeThrottle = undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
pumping = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
touch() {
|
|
130
|
+
dirty = true;
|
|
131
|
+
if (!pumping)
|
|
132
|
+
pumpDone = runPump();
|
|
133
|
+
},
|
|
134
|
+
async finish() {
|
|
135
|
+
stopped = true;
|
|
136
|
+
wakeThrottle?.();
|
|
137
|
+
await pumpDone?.catch(() => { });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Max length (code points) of a tool's arg preview. Slack's native stream cannot be retracted, so
|
|
142
|
+
* this is a disclosure bound, not only a layout one. */
|
|
143
|
+
const TOOL_ARG_MAX = 48;
|
|
144
|
+
/** Max length (code points) of a humanized tool label. */
|
|
145
|
+
const TOOL_NAME_MAX = 80;
|
|
146
|
+
/** One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg
|
|
147
|
+
* stays on one line, and never tear a surrogate pair mid-emoji. */
|
|
148
|
+
function clip(s) {
|
|
149
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
150
|
+
return truncateCodePointPrefix(one, TOOL_ARG_MAX);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
|
|
154
|
+
* rather than just `🔧 read`. Generic (a channel knows no tool schemas): show the salient value — the
|
|
155
|
+
* first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
|
|
156
|
+
*/
|
|
157
|
+
export function summarizeToolArgs(args) {
|
|
158
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
159
|
+
return clip(String(args));
|
|
160
|
+
const values = Object.values(args);
|
|
161
|
+
const primary = values.find((v) => typeof v === "string" || typeof v === "number");
|
|
162
|
+
if (primary !== undefined)
|
|
163
|
+
return clip(String(primary));
|
|
164
|
+
return values.length > 0 ? clip(JSON.stringify(args)) : "";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A plain-language label for a tool call, following Slack's agent-design guidance to name what a tool
|
|
168
|
+
* does rather than expose a raw identifier ("Create issue", not "create_issue"; "Github: create issue",
|
|
169
|
+
* not "mcp__github__create_issue"). Deliberately generic and engine-neutral: it only reshapes the
|
|
170
|
+
* identifier string — it never invents semantics and never exposes arguments. An `mcp__server__tool`
|
|
171
|
+
* identifier becomes `server: tool`; any other identifier has its separators normalized to spaces and
|
|
172
|
+
* its first letter capitalized.
|
|
173
|
+
*/
|
|
174
|
+
export function humanizeToolName(name) {
|
|
175
|
+
const normalize = (s) => s
|
|
176
|
+
.replace(/[_\-.]+/g, " ")
|
|
177
|
+
.replace(/\s+/g, " ")
|
|
178
|
+
.trim();
|
|
179
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
|
|
180
|
+
const base = mcp ? `${normalize(mcp[1] ?? "")}: ${normalize(mcp[2] ?? "")}` : normalize(name);
|
|
181
|
+
const label = base.trim() || name.trim() || "Tool";
|
|
182
|
+
const capitalized = label.charAt(0).toUpperCase() + label.slice(1);
|
|
183
|
+
return truncateCodePointPrefix(capitalized, TOOL_NAME_MAX);
|
|
184
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
|
|
2
|
+
import { log } from "../log.js";
|
|
3
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
4
|
+
export function createSeenRing(path, label, cap = 2000) {
|
|
5
|
+
const raw = loadStateFile(path);
|
|
6
|
+
const order = raw === undefined
|
|
7
|
+
? []
|
|
8
|
+
: Array.isArray(raw) && raw.every((id) => typeof id === "string")
|
|
9
|
+
? raw.slice(-cap)
|
|
10
|
+
: undefined;
|
|
11
|
+
if (order === undefined)
|
|
12
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no seen ids`);
|
|
13
|
+
const values = order ?? [];
|
|
14
|
+
const ids = new Set(values);
|
|
15
|
+
return {
|
|
16
|
+
has: (id) => ids.has(id),
|
|
17
|
+
add(id) {
|
|
18
|
+
if (ids.has(id))
|
|
19
|
+
return;
|
|
20
|
+
ids.add(id);
|
|
21
|
+
values.push(id);
|
|
22
|
+
while (values.length > cap) {
|
|
23
|
+
const evicted = values.shift();
|
|
24
|
+
if (evicted !== undefined)
|
|
25
|
+
ids.delete(evicted);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
saveStateFile(path, values);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
log.warn(`${label} seen-ring write failed (delivery dedup is in-memory until restart): ${String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SlackBotTokenProviderOptions {
|
|
2
|
+
statePath: string;
|
|
3
|
+
botToken: string;
|
|
4
|
+
botRefreshToken?: string;
|
|
5
|
+
clientId?: string;
|
|
6
|
+
clientSecret?: string;
|
|
7
|
+
botTokenExpiresAt?: number;
|
|
8
|
+
apiBaseUrl?: string;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
/** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
|
|
12
|
+
* durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
|
|
13
|
+
export declare function readSlackBotAuthEnv(statePath: string): Record<string, string>;
|
|
14
|
+
/** Resolve the current bot token, refreshing once per process when it approaches expiry. */
|
|
15
|
+
export declare function createSlackBotTokenProvider(options: SlackBotTokenProviderOptions): () => Promise<string>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Rotating Slack bot-token provider backed by owner-only channel state. */
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
6
|
+
function isState(value) {
|
|
7
|
+
const state = value;
|
|
8
|
+
return (typeof state === "object" &&
|
|
9
|
+
state !== null &&
|
|
10
|
+
state.version === 1 &&
|
|
11
|
+
typeof state.accessToken === "string" &&
|
|
12
|
+
typeof state.refreshToken === "string" &&
|
|
13
|
+
typeof state.expiresAt === "number");
|
|
14
|
+
}
|
|
15
|
+
function load(path) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(path, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT")
|
|
22
|
+
return undefined;
|
|
23
|
+
throw new Error(`Slack bot auth state ${path} is unreadable: ${String(error)}`, { cause: error });
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(raw);
|
|
27
|
+
if (!isState(value))
|
|
28
|
+
throw new Error("unexpected shape/version");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// A stale env refresh token may already have been consumed. Never hide corrupt rotating state by
|
|
33
|
+
// falling back to it: recovery requires restoring the durable file or reinstalling the app.
|
|
34
|
+
throw new Error(`Slack bot auth state ${path} is invalid: ${String(error)}`, { cause: error });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function save(path, state) {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
|
|
42
|
+
chmodSync(temp, 0o600);
|
|
43
|
+
renameSync(temp, path);
|
|
44
|
+
chmodSync(path, 0o600);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
rmSync(temp, { force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function refreshSlackBotToken(input) {
|
|
52
|
+
const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
|
|
53
|
+
let lastError;
|
|
54
|
+
// Slack keeps a short grace window for a just-consumed refresh token. One retry recovers the
|
|
55
|
+
// committed-but-response-lost boundary without creating an unbounded refresh loop.
|
|
56
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
const response = await input.fetch(`${input.apiBaseUrl}/oauth.v2.access`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
authorization: `Basic ${authorization}`,
|
|
62
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
63
|
+
},
|
|
64
|
+
body: new URLSearchParams({
|
|
65
|
+
grant_type: "refresh_token",
|
|
66
|
+
refresh_token: input.refreshToken,
|
|
67
|
+
}).toString(),
|
|
68
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
const data = (await response.json().catch(() => ({})));
|
|
71
|
+
if (!response.ok ||
|
|
72
|
+
data.ok !== true ||
|
|
73
|
+
!data.access_token ||
|
|
74
|
+
!data.refresh_token ||
|
|
75
|
+
typeof data.expires_in !== "number") {
|
|
76
|
+
throw new Error(`Slack rejected bot-token refresh (HTTP ${response.status})`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
version: 1,
|
|
80
|
+
accessToken: data.access_token,
|
|
81
|
+
refreshToken: data.refresh_token,
|
|
82
|
+
expiresAt: Date.now() + data.expires_in * 1_000,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
lastError = error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new Error("Slack bot-token rotation failed; restore credentials or reinstall the app", { cause: lastError });
|
|
90
|
+
}
|
|
91
|
+
/** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
|
|
92
|
+
* durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
|
|
93
|
+
export function readSlackBotAuthEnv(statePath) {
|
|
94
|
+
const state = load(statePath);
|
|
95
|
+
return state
|
|
96
|
+
? {
|
|
97
|
+
SLACK_BOT_TOKEN: state.accessToken,
|
|
98
|
+
SLACK_BOT_REFRESH_TOKEN: state.refreshToken,
|
|
99
|
+
SLACK_BOT_TOKEN_EXPIRES_AT: String(state.expiresAt),
|
|
100
|
+
}
|
|
101
|
+
: {};
|
|
102
|
+
}
|
|
103
|
+
/** Resolve the current bot token, refreshing once per process when it approaches expiry. */
|
|
104
|
+
export function createSlackBotTokenProvider(options) {
|
|
105
|
+
const rotatingFields = [options.botRefreshToken, options.clientId, options.clientSecret, options.botTokenExpiresAt];
|
|
106
|
+
const rotating = rotatingFields.some((value) => value !== undefined);
|
|
107
|
+
if (rotating && rotatingFields.some((value) => value === undefined || value === "")) {
|
|
108
|
+
throw new Error("Slack token rotation requires botRefreshToken, clientId, clientSecret, and botTokenExpiresAt together");
|
|
109
|
+
}
|
|
110
|
+
if (rotating && (!Number.isFinite(options.botTokenExpiresAt) || options.botTokenExpiresAt <= 0)) {
|
|
111
|
+
throw new Error("Slack botTokenExpiresAt must be a positive epoch-millisecond value");
|
|
112
|
+
}
|
|
113
|
+
if (!rotating)
|
|
114
|
+
return async () => options.botToken;
|
|
115
|
+
const configured = {
|
|
116
|
+
version: 1,
|
|
117
|
+
accessToken: options.botToken,
|
|
118
|
+
refreshToken: options.botRefreshToken,
|
|
119
|
+
expiresAt: options.botTokenExpiresAt,
|
|
120
|
+
};
|
|
121
|
+
const persisted = load(options.statePath);
|
|
122
|
+
// Deploy may carry a newer pair from the owner machine onto an existing remote volume; ordinary
|
|
123
|
+
// restarts carry the original stale env and therefore keep the newer persisted pair.
|
|
124
|
+
let state = persisted && persisted.expiresAt >= configured.expiresAt ? persisted : configured;
|
|
125
|
+
let refreshing;
|
|
126
|
+
return async () => {
|
|
127
|
+
if (state.expiresAt > Date.now() + REFRESH_EARLY_MS)
|
|
128
|
+
return state.accessToken;
|
|
129
|
+
refreshing ??= refreshSlackBotToken({
|
|
130
|
+
refreshToken: state.refreshToken,
|
|
131
|
+
clientId: options.clientId,
|
|
132
|
+
clientSecret: options.clientSecret,
|
|
133
|
+
apiBaseUrl: (options.apiBaseUrl ?? "https://slack.com/api").replace(/\/$/, ""),
|
|
134
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
135
|
+
})
|
|
136
|
+
.then((next) => {
|
|
137
|
+
save(options.statePath, next);
|
|
138
|
+
state = next;
|
|
139
|
+
})
|
|
140
|
+
.finally(() => {
|
|
141
|
+
refreshing = undefined;
|
|
142
|
+
});
|
|
143
|
+
await refreshing;
|
|
144
|
+
return state.accessToken;
|
|
145
|
+
};
|
|
146
|
+
}
|