@app-vitals/agent-runtime 0.0.1 → 0.1.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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
package/slack.ts
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Bolt event handlers for the Shipwright agent.
|
|
3
|
+
*
|
|
4
|
+
* All dependencies are injected — no global config reads, no hardcoded
|
|
5
|
+
* runClaude import. Call createSlackApp() with the runner returned by
|
|
6
|
+
* createRunClaude() and your injected config values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
|
|
14
|
+
import type { App } from "@slack/bolt";
|
|
15
|
+
import type { WebAPIPlatformError } from "@slack/web-api";
|
|
16
|
+
import {
|
|
17
|
+
type AgentAccountStatusRef,
|
|
18
|
+
agentAccountStatusRef,
|
|
19
|
+
} from "./agent-account-status-ref.ts";
|
|
20
|
+
import {
|
|
21
|
+
type AgentSlackMembershipRef,
|
|
22
|
+
agentSlackMembershipRef,
|
|
23
|
+
} from "./agent-slack-membership-ref.ts";
|
|
24
|
+
import {
|
|
25
|
+
type AgentTrialExpiryRef,
|
|
26
|
+
agentTrialExpiryRef,
|
|
27
|
+
} from "./agent-trial-expiry-ref.ts";
|
|
28
|
+
import {
|
|
29
|
+
type ChatTokenReporter,
|
|
30
|
+
NoopChatTokenReporter,
|
|
31
|
+
} from "./chat-token-reporter.ts";
|
|
32
|
+
import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
|
|
33
|
+
import {
|
|
34
|
+
ClaudeRunError,
|
|
35
|
+
ClaudeTimeoutError,
|
|
36
|
+
reportClaudeError,
|
|
37
|
+
} from "./claude.ts";
|
|
38
|
+
import {
|
|
39
|
+
formatPlanLink,
|
|
40
|
+
markdownToBlocks,
|
|
41
|
+
markdownToSlack,
|
|
42
|
+
richTextToMarkdown,
|
|
43
|
+
} from "./format.ts";
|
|
44
|
+
import { type Marker, parseMarkers } from "./markers.ts";
|
|
45
|
+
import { threadKey as defaultThreadKey } from "./sessions.ts";
|
|
46
|
+
import { SlackProgress } from "./slack-progress.ts";
|
|
47
|
+
import { ThreadStatusTracker } from "./slack-thread-status-tracker.ts";
|
|
48
|
+
import type { VoiceConfig } from "./voice.ts";
|
|
49
|
+
import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
|
|
50
|
+
|
|
51
|
+
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
|
|
52
|
+
|
|
53
|
+
function isInvalidBlocksError(err: unknown): boolean {
|
|
54
|
+
const e = err as Partial<WebAPIPlatformError>;
|
|
55
|
+
return (
|
|
56
|
+
e.code === "slack_webapi_platform_error" &&
|
|
57
|
+
e.data?.error === "invalid_blocks"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SlackFile {
|
|
62
|
+
name: string;
|
|
63
|
+
mimetype: string;
|
|
64
|
+
size: number;
|
|
65
|
+
url_private?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function downloadFile(
|
|
69
|
+
file: SlackFile,
|
|
70
|
+
botToken: string,
|
|
71
|
+
): Promise<string | null> {
|
|
72
|
+
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[slack] skipping ${file.name} (${file.size} bytes) — exceeds 10MB limit`,
|
|
75
|
+
);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const url = file.url_private;
|
|
79
|
+
if (!url) return null;
|
|
80
|
+
|
|
81
|
+
let resp: Response;
|
|
82
|
+
try {
|
|
83
|
+
resp = await fetch(url, {
|
|
84
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.warn(`[slack] file fetch error for ${file.name}:`, err);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!resp.ok) {
|
|
92
|
+
console.warn(
|
|
93
|
+
`[slack] file download failed for ${file.name}: ${resp.status}`,
|
|
94
|
+
);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
99
|
+
const tmpPath = join(tmpdir(), `shipwright-agent-${Date.now()}-${file.name}`);
|
|
100
|
+
await writeFile(tmpPath, buffer);
|
|
101
|
+
return tmpPath;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SlackConfig {
|
|
105
|
+
botToken: string;
|
|
106
|
+
appToken: string;
|
|
107
|
+
signingSecret: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the agent has the credentials required to start its Slack Bolt App.
|
|
112
|
+
*
|
|
113
|
+
* Bolt's Socket Mode constructor throws "Must provide an App-Level Token" when
|
|
114
|
+
* `appToken` is empty, so the agent must skip Slack startup (offline mode)
|
|
115
|
+
* unless BOTH the bot token and the app-level token are present. Whitespace-only
|
|
116
|
+
* values are treated as absent.
|
|
117
|
+
*/
|
|
118
|
+
export function hasSlackCredentials(cfg: {
|
|
119
|
+
botToken: string;
|
|
120
|
+
appToken: string;
|
|
121
|
+
}): boolean {
|
|
122
|
+
return cfg.botToken.trim() !== "" && cfg.appToken.trim() !== "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type AppFactory = (cfg: {
|
|
126
|
+
token: string;
|
|
127
|
+
appToken: string;
|
|
128
|
+
socketMode: true;
|
|
129
|
+
signingSecret: string;
|
|
130
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt App type is complex
|
|
131
|
+
}) => any;
|
|
132
|
+
|
|
133
|
+
const defaultAppFactory: AppFactory = (cfg) =>
|
|
134
|
+
new (require("@slack/bolt").App)(cfg);
|
|
135
|
+
|
|
136
|
+
const noopSentryClient: ErrorCapturingClient = {
|
|
137
|
+
captureException: () => {},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type FileDownloaderFn = (
|
|
141
|
+
file: SlackFile,
|
|
142
|
+
botToken: string,
|
|
143
|
+
) => Promise<string | null>;
|
|
144
|
+
|
|
145
|
+
type ConversationsRepliesFn = (
|
|
146
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
|
|
147
|
+
client: any,
|
|
148
|
+
channel: string,
|
|
149
|
+
ts: string,
|
|
150
|
+
) => Promise<{ messages?: { user?: string; text?: string; ts?: string }[] }>;
|
|
151
|
+
|
|
152
|
+
export type TranscribeAudioFn = typeof transcribeAudio;
|
|
153
|
+
export type SynthesizeSpeechFn = typeof synthesizeSpeech;
|
|
154
|
+
type GetSessionFn = (key: string) => Promise<string | undefined>;
|
|
155
|
+
|
|
156
|
+
export type ClaudeRunner = (
|
|
157
|
+
message: string,
|
|
158
|
+
sessionKey?: string,
|
|
159
|
+
onProgress?: ProgressCallback,
|
|
160
|
+
extraEnv?: Record<string, string>,
|
|
161
|
+
) => Promise<ClaudeRunResult>;
|
|
162
|
+
|
|
163
|
+
type ResolveUserFn = (
|
|
164
|
+
userId: string,
|
|
165
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt client is complex
|
|
166
|
+
client: any,
|
|
167
|
+
) => Promise<string>;
|
|
168
|
+
|
|
169
|
+
type ResolveUserEmailFn = (
|
|
170
|
+
userId: string,
|
|
171
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt client is complex
|
|
172
|
+
client: any,
|
|
173
|
+
) => Promise<string | undefined>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The single shared Slack membership gate used by all three event handlers
|
|
177
|
+
* (message, app_mention, reaction_added) — SMR-4.1.
|
|
178
|
+
*
|
|
179
|
+
* Fails OPEN (allow) while the membership ref has never synced, or when
|
|
180
|
+
* restrict is false, so a persistent config-sync failure or a deliberately
|
|
181
|
+
* unrestricted config never blocks Slack access. Once restrict is true and
|
|
182
|
+
* the ref has synced, only a case-insensitive match against the synced
|
|
183
|
+
* member email list is allowed — an undefined email (resolution failed or
|
|
184
|
+
* absent) is rejected, never treated as a wildcard.
|
|
185
|
+
*/
|
|
186
|
+
export function isAllowedSlackSender(
|
|
187
|
+
email: string | undefined,
|
|
188
|
+
membershipRef: AgentSlackMembershipRef,
|
|
189
|
+
): boolean {
|
|
190
|
+
if (!membershipRef.hasSynced()) return true;
|
|
191
|
+
const { restrict, emails } = membershipRef.get();
|
|
192
|
+
if (!restrict) return true;
|
|
193
|
+
if (!email) return false;
|
|
194
|
+
const lowered = email.toLowerCase();
|
|
195
|
+
return emails.some((e) => e.toLowerCase() === lowered);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The message posted (once per inbound event, no dedup) when isTrialExpired()
|
|
200
|
+
* rejects a Slack message/mention/reaction — ATE-3.1. Unlike
|
|
201
|
+
* shouldRejectSlackSender (which silently drops the message), the caller
|
|
202
|
+
* always replies with this so the user isn't met with unexplained silence.
|
|
203
|
+
*/
|
|
204
|
+
const TRIAL_EXPIRED_NOTICE = "This trial has ended — contact us to continue.";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The shared trial-expiry gate used by all three event handlers (message,
|
|
208
|
+
* app_mention, reaction_added) — ATE-3.1. Runs before shouldRejectSlackSender
|
|
209
|
+
* and before any Claude session is invoked.
|
|
210
|
+
*
|
|
211
|
+
* Fails OPEN (not expired) while the ref has never synced, or when no trial
|
|
212
|
+
* is configured (trialExpiresAt null), so a persistent config-sync failure
|
|
213
|
+
* never locks a paying customer out of their own agent. Only a
|
|
214
|
+
* trialExpiresAt strictly in the past counts as expired — a trial expiring
|
|
215
|
+
* at exactly `now` is not yet locked out.
|
|
216
|
+
*/
|
|
217
|
+
export function isTrialExpired(
|
|
218
|
+
trialExpiryRef: AgentTrialExpiryRef,
|
|
219
|
+
now: () => Date = () => new Date(),
|
|
220
|
+
): boolean {
|
|
221
|
+
if (!trialExpiryRef.hasSynced()) return false;
|
|
222
|
+
const trialExpiresAt = trialExpiryRef.get();
|
|
223
|
+
if (!trialExpiresAt) return false;
|
|
224
|
+
return trialExpiresAt.getTime() < now().getTime();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The message posted (once per inbound event) when isAccountPaused() rejects a
|
|
229
|
+
* Slack message/mention/reaction — SSP-8.3.
|
|
230
|
+
*/
|
|
231
|
+
export const ACCOUNT_PAUSED_NOTICE =
|
|
232
|
+
"This agent is paused because its account is not active — contact us to resume.";
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The shared account-status gate used by all three event handlers — SSP-8.3.
|
|
236
|
+
* Paused when the owning account is 'suspended' or 'trial_expired'. Fails OPEN
|
|
237
|
+
* while the ref has never synced, and for agents with no account (null) or an
|
|
238
|
+
* active account. Self-heals on the next config sync after reactivation.
|
|
239
|
+
*/
|
|
240
|
+
export function isAccountPaused(
|
|
241
|
+
accountStatusRef: AgentAccountStatusRef,
|
|
242
|
+
): boolean {
|
|
243
|
+
if (!accountStatusRef.hasSynced()) return false;
|
|
244
|
+
const status = accountStatusRef.get();
|
|
245
|
+
return status === "suspended" || status === "trial_expired";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Returns the notice to reply with when an inbound event must be rejected
|
|
250
|
+
* (trial ended, or account paused), or null when the event may proceed.
|
|
251
|
+
*/
|
|
252
|
+
function gateNotice(
|
|
253
|
+
trialExpiryRef: AgentTrialExpiryRef,
|
|
254
|
+
accountStatusRef: AgentAccountStatusRef,
|
|
255
|
+
): string | null {
|
|
256
|
+
if (isTrialExpired(trialExpiryRef)) return TRIAL_EXPIRED_NOTICE;
|
|
257
|
+
if (isAccountPaused(accountStatusRef)) return ACCOUNT_PAUSED_NOTICE;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolves the sender's email and checks it against isAllowedSlackSender(),
|
|
263
|
+
* logging a debug line for rejected senders. Returns true when the handler
|
|
264
|
+
* should return early (reject) — shared by all three event handlers so the
|
|
265
|
+
* resolve/check/log sequence isn't repeated per handler.
|
|
266
|
+
*/
|
|
267
|
+
async function shouldRejectSlackSender(
|
|
268
|
+
userId: string,
|
|
269
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
|
|
270
|
+
client: any,
|
|
271
|
+
resolveUserEmailFn: ResolveUserEmailFn,
|
|
272
|
+
membershipRef: AgentSlackMembershipRef,
|
|
273
|
+
): Promise<boolean> {
|
|
274
|
+
const senderEmail = await resolveUserEmailFn(userId, client).catch(
|
|
275
|
+
() => undefined,
|
|
276
|
+
);
|
|
277
|
+
if (isAllowedSlackSender(senderEmail, membershipRef)) return false;
|
|
278
|
+
console.log(`[slack] rejected sender (not a member): user=${userId}`);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Normalizes text for the speak-marker fallback's duplicate-content check:
|
|
283
|
+
// lowercased, trimmed, trailing punctuation stripped, internal whitespace
|
|
284
|
+
// collapsed. Deliberately simple — not fuzzy matching, just enough to catch
|
|
285
|
+
// the common "Here is the answer. [speak:Here is the answer]" pattern where
|
|
286
|
+
// the marker text is a near-verbatim echo of the already-posted reply.
|
|
287
|
+
function normalizeForComparison(text: string | undefined): string {
|
|
288
|
+
if (!text) return "";
|
|
289
|
+
return text
|
|
290
|
+
.trim()
|
|
291
|
+
.toLowerCase()
|
|
292
|
+
.replace(/[.!?]+$/, "")
|
|
293
|
+
.replace(/\s+/g, " ")
|
|
294
|
+
.trim();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function dispatchMarkers(
|
|
298
|
+
markers: Marker[],
|
|
299
|
+
{
|
|
300
|
+
client,
|
|
301
|
+
channel,
|
|
302
|
+
postedTs,
|
|
303
|
+
threadTs,
|
|
304
|
+
workspace,
|
|
305
|
+
synthesizeSpeechFn,
|
|
306
|
+
voiceConfig = {},
|
|
307
|
+
postedText,
|
|
308
|
+
}: {
|
|
309
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
|
|
310
|
+
client: any;
|
|
311
|
+
channel: string;
|
|
312
|
+
postedTs?: string;
|
|
313
|
+
threadTs?: string;
|
|
314
|
+
workspace?: string;
|
|
315
|
+
synthesizeSpeechFn?: SynthesizeSpeechFn;
|
|
316
|
+
voiceConfig?: VoiceConfig;
|
|
317
|
+
// The already-posted visible reply text (e.g. `cleaned`/`formatted`), if
|
|
318
|
+
// any. Used so the speak-marker text fallback can skip posting when it
|
|
319
|
+
// would just duplicate content the user already sees.
|
|
320
|
+
postedText?: string;
|
|
321
|
+
},
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
for (const marker of markers) {
|
|
324
|
+
if (marker.type === "silent") continue;
|
|
325
|
+
|
|
326
|
+
if (marker.type === "react") {
|
|
327
|
+
if (postedTs) {
|
|
328
|
+
for (const emoji of marker.emojis) {
|
|
329
|
+
await client.reactions
|
|
330
|
+
.add({ channel, timestamp: postedTs, name: emoji })
|
|
331
|
+
.catch((err: unknown) =>
|
|
332
|
+
console.warn("[markers] react failed:", err),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} else if (marker.type === "upload") {
|
|
337
|
+
const absPath = marker.path.startsWith("/")
|
|
338
|
+
? marker.path
|
|
339
|
+
: workspace
|
|
340
|
+
? join(workspace, marker.path)
|
|
341
|
+
: marker.path;
|
|
342
|
+
if (existsSync(absPath)) {
|
|
343
|
+
await client.files
|
|
344
|
+
.uploadV2({
|
|
345
|
+
channel_id: channel,
|
|
346
|
+
thread_ts: threadTs,
|
|
347
|
+
file: await readFile(absPath),
|
|
348
|
+
filename: absPath.split("/").pop() ?? "file",
|
|
349
|
+
})
|
|
350
|
+
.catch((err: unknown) =>
|
|
351
|
+
console.warn("[markers] upload failed:", err),
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
console.warn(`[markers] upload: file not found: ${absPath}`);
|
|
355
|
+
}
|
|
356
|
+
} else if (marker.type === "plan") {
|
|
357
|
+
const { text, blocks } = formatPlanLink(marker.url);
|
|
358
|
+
await client.chat
|
|
359
|
+
.postMessage({ channel, thread_ts: threadTs, text, blocks })
|
|
360
|
+
.catch((err: unknown) =>
|
|
361
|
+
console.warn("[markers] plan link post failed:", err),
|
|
362
|
+
);
|
|
363
|
+
} else if (marker.type === "speak") {
|
|
364
|
+
if (!synthesizeSpeechFn) {
|
|
365
|
+
console.warn(
|
|
366
|
+
"[markers] speak marker found but synthesizeSpeechFn not injected — skipping",
|
|
367
|
+
);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const postSpeakTextFallback = () => {
|
|
371
|
+
const normalizedPosted = normalizeForComparison(postedText);
|
|
372
|
+
if (
|
|
373
|
+
normalizedPosted.length > 0 &&
|
|
374
|
+
normalizedPosted === normalizeForComparison(marker.text)
|
|
375
|
+
) {
|
|
376
|
+
console.warn(
|
|
377
|
+
"[markers] speak text fallback skipped — duplicates already-posted reply",
|
|
378
|
+
);
|
|
379
|
+
return Promise.resolve();
|
|
380
|
+
}
|
|
381
|
+
return client.chat
|
|
382
|
+
.postMessage({ channel, thread_ts: threadTs, text: marker.text })
|
|
383
|
+
.catch((err: unknown) =>
|
|
384
|
+
console.warn("[markers] speak text fallback post failed:", err),
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const audioPath = await synthesizeSpeechFn(marker.text, voiceConfig);
|
|
390
|
+
if (audioPath) {
|
|
391
|
+
await client.files
|
|
392
|
+
.uploadV2({
|
|
393
|
+
channel_id: channel,
|
|
394
|
+
thread_ts: threadTs,
|
|
395
|
+
file: await readFile(audioPath),
|
|
396
|
+
filename: audioPath.split("/").pop() ?? "response.mp3",
|
|
397
|
+
})
|
|
398
|
+
.catch((err: unknown) =>
|
|
399
|
+
console.warn("[markers] speak upload failed:", err),
|
|
400
|
+
);
|
|
401
|
+
} else {
|
|
402
|
+
console.warn(
|
|
403
|
+
"[markers] speak synthesis returned null — posting text fallback",
|
|
404
|
+
);
|
|
405
|
+
await postSpeakTextFallback();
|
|
406
|
+
}
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.warn(
|
|
409
|
+
"[markers] speak synthesis failed — posting text fallback:",
|
|
410
|
+
err,
|
|
411
|
+
);
|
|
412
|
+
await postSpeakTextFallback();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function _classifyError(err: unknown): { lead: string; known: boolean } {
|
|
419
|
+
if (err instanceof ClaudeTimeoutError) {
|
|
420
|
+
return {
|
|
421
|
+
lead: `:hourglass_flowing_sand: Session timed out after ${Math.round(err.timeoutMs / 60_000)} minutes. Please retry.`,
|
|
422
|
+
known: true,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (err instanceof ClaudeRunError) {
|
|
426
|
+
const status = err.apiErrorStatus;
|
|
427
|
+
const detail = err.resultMessage;
|
|
428
|
+
if (status === 429 && /usage limit/i.test(detail)) {
|
|
429
|
+
return {
|
|
430
|
+
lead: ":warning: I've hit the org's monthly Claude usage limit. Please try again later or contact the agent owner.",
|
|
431
|
+
known: true,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (status === 429) {
|
|
435
|
+
return {
|
|
436
|
+
lead: ":warning: Rate-limited by Anthropic — try again in a moment.",
|
|
437
|
+
known: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (status === 529) {
|
|
441
|
+
return {
|
|
442
|
+
lead: ":warning: Anthropic's API is overloaded. This is usually transient — try again in ~30s. Status: https://status.claude.com",
|
|
443
|
+
known: true,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
447
|
+
return {
|
|
448
|
+
lead: `:warning: Anthropic API hiccup (${status}). This is server-side and usually transient — please retry. Status: https://status.claude.com`,
|
|
449
|
+
known: true,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
if (status === 401 || status === 403) {
|
|
453
|
+
return {
|
|
454
|
+
lead: `:warning: Auth failure with Anthropic (${status}). The agent's API key or OAuth token may need to be refreshed.`,
|
|
455
|
+
known: true,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
lead: `:warning: Claude returned an error${status ? ` (${status})` : ""}.`,
|
|
460
|
+
known: false,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return { lead: ":warning: Something went wrong.", known: false };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function _detailLine(err: unknown): string {
|
|
467
|
+
if (err instanceof ClaudeRunError) return err.resultMessage;
|
|
468
|
+
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
469
|
+
return String(err);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function formatRunErrorForSlack(err: unknown): string {
|
|
473
|
+
const { lead, known } = _classifyError(err);
|
|
474
|
+
const detail = _detailLine(err);
|
|
475
|
+
const stack =
|
|
476
|
+
!known && err instanceof Error && err.stack ? err.stack : undefined;
|
|
477
|
+
const stackBlock = stack ? `\n\n\`\`\`\n${stack}\n\`\`\`` : "";
|
|
478
|
+
return `${lead}\n\n_Detail:_ ${detail}${stackBlock}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const defaultConversationsRepliesFn: ConversationsRepliesFn = async (
|
|
482
|
+
client,
|
|
483
|
+
channel,
|
|
484
|
+
ts,
|
|
485
|
+
) => client.conversations.replies({ channel, ts, limit: 200 });
|
|
486
|
+
|
|
487
|
+
async function buildPromptWithFiles(
|
|
488
|
+
text: string,
|
|
489
|
+
files: SlackFile[] | undefined,
|
|
490
|
+
fileDownloaderFn: FileDownloaderFn,
|
|
491
|
+
botToken: string,
|
|
492
|
+
transcribeAudioFn: TranscribeAudioFn,
|
|
493
|
+
voiceConfig: VoiceConfig,
|
|
494
|
+
): Promise<string> {
|
|
495
|
+
if (!files?.length) return text;
|
|
496
|
+
const fileParts: string[] = [];
|
|
497
|
+
for (const file of files) {
|
|
498
|
+
try {
|
|
499
|
+
const filePath = await fileDownloaderFn(file, botToken);
|
|
500
|
+
if (filePath) {
|
|
501
|
+
if (file.mimetype.startsWith("audio/")) {
|
|
502
|
+
try {
|
|
503
|
+
const transcript = await transcribeAudioFn(filePath, voiceConfig);
|
|
504
|
+
fileParts.push(
|
|
505
|
+
transcript
|
|
506
|
+
? `[voice transcript: ${transcript}]`
|
|
507
|
+
: `[file: ${filePath}]`,
|
|
508
|
+
);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
console.warn("[voice] transcription error:", err);
|
|
511
|
+
fileParts.push(`[file: ${filePath}]`);
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
fileParts.push(`[file: ${filePath}]`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch (err) {
|
|
518
|
+
console.warn(`[slack] file download error for ${file.name}:`, err);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return fileParts.length ? `${fileParts.join("\n")}\n${text}`.trim() : text;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Starts a run's progress tracking. enter() on ThreadStatusTracker is
|
|
526
|
+
* ALWAYS called by every handler (every enter() needs a matching exit(), or
|
|
527
|
+
* the refcount leaks upward forever) — every message gets its own
|
|
528
|
+
* independent stream lifecycle regardless of the refcount; the boolean only
|
|
529
|
+
* picks the seed card's title ("Queued…" when another message on this
|
|
530
|
+
* thread is already in flight, "Thinking…" otherwise).
|
|
531
|
+
*/
|
|
532
|
+
async function startProgress(
|
|
533
|
+
progress: SlackProgress,
|
|
534
|
+
isFirstInThread: boolean,
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
await progress.start({ queued: !isFirstInThread });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The finish()-side counterpart of startProgress() — see its doc comment.
|
|
541
|
+
* `wasSuppressed` (STS2-3.1) signals the run's reply was suppressed (e.g. a
|
|
542
|
+
* [silent] marker) so finish() titles the stream's fallback completion card
|
|
543
|
+
* "Ack" instead of "Done" — see SlackProgressFinishOptions. Defaults to
|
|
544
|
+
* `false`, matching prior behavior for callers that don't compute silence.
|
|
545
|
+
*/
|
|
546
|
+
async function finishProgress(
|
|
547
|
+
progress: SlackProgress,
|
|
548
|
+
isLastInThread: boolean,
|
|
549
|
+
wasSuppressed = false,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
await progress.finish({
|
|
552
|
+
stillInFlight: !isLastInThread,
|
|
553
|
+
silent: wasSuppressed,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function createSlackApp(
|
|
558
|
+
runner: ClaudeRunner,
|
|
559
|
+
formatter: (text: string) => string = markdownToSlack,
|
|
560
|
+
getThreadKey: typeof defaultThreadKey = defaultThreadKey,
|
|
561
|
+
appFactory: AppFactory = defaultAppFactory,
|
|
562
|
+
slackConfig: SlackConfig = { botToken: "", appToken: "", signingSecret: "" },
|
|
563
|
+
sentryClient: ErrorCapturingClient = noopSentryClient,
|
|
564
|
+
fileDownloaderFn: FileDownloaderFn = downloadFile,
|
|
565
|
+
voiceConfig: VoiceConfig = {},
|
|
566
|
+
transcribeAudioFn: TranscribeAudioFn = transcribeAudio,
|
|
567
|
+
synthesizeSpeechFn: SynthesizeSpeechFn = synthesizeSpeech,
|
|
568
|
+
resolveUserFn: ResolveUserFn = async (userId) => userId,
|
|
569
|
+
botUserId: string | undefined = undefined,
|
|
570
|
+
conversationsRepliesFn: ConversationsRepliesFn = defaultConversationsRepliesFn,
|
|
571
|
+
getSessionFn: GetSessionFn = async () => undefined,
|
|
572
|
+
blocksConverter: typeof markdownToBlocks = markdownToBlocks,
|
|
573
|
+
chatTokenReporter: ChatTokenReporter = new NoopChatTokenReporter(),
|
|
574
|
+
resolveUserEmailFn: ResolveUserEmailFn = async () => undefined,
|
|
575
|
+
membershipRef: AgentSlackMembershipRef = agentSlackMembershipRef,
|
|
576
|
+
trialExpiryRef: AgentTrialExpiryRef = agentTrialExpiryRef,
|
|
577
|
+
accountStatusRef: AgentAccountStatusRef = agentAccountStatusRef,
|
|
578
|
+
): App {
|
|
579
|
+
const app = appFactory({
|
|
580
|
+
token: slackConfig.botToken,
|
|
581
|
+
appToken: slackConfig.appToken,
|
|
582
|
+
socketMode: true,
|
|
583
|
+
signingSecret: slackConfig.signingSecret,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Per-thread refcount gating SlackProgress.start()/finish() — see
|
|
587
|
+
// slack-thread-status-tracker.ts for why this exists. Closure-scoped (one
|
|
588
|
+
// instance per createSlackApp() call), never module-level, so multiple app
|
|
589
|
+
// instances (e.g. in tests) stay isolated from each other's refcount state.
|
|
590
|
+
const threadStatusTracker = new ThreadStatusTracker();
|
|
591
|
+
|
|
592
|
+
// Registry of live streams: SlackProgress.streamKey() ("channel:ts") ->
|
|
593
|
+
// the instance itself (AGS-1.1). Populated when startStream() succeeds
|
|
594
|
+
// (i.e. progress.streamKey() first becomes defined, right after start())
|
|
595
|
+
// and cleared unconditionally when the stream closes, success or failure
|
|
596
|
+
// (right after finish()) — so it never leaks entries for completed runs.
|
|
597
|
+
// Closure-scoped (one instance per createSlackApp() call), same lifecycle
|
|
598
|
+
// as threadStatusTracker above — never module-level.
|
|
599
|
+
const liveStreams = new Map<string, SlackProgress>();
|
|
600
|
+
|
|
601
|
+
function registerStream(progress: SlackProgress): void {
|
|
602
|
+
const key = progress.streamKey();
|
|
603
|
+
if (key) liveStreams.set(key, progress);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function unregisterStream(progress: SlackProgress): void {
|
|
607
|
+
const key = progress.streamKey();
|
|
608
|
+
if (key) liveStreams.delete(key);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// The bot's own workspace team ID — chat.startStream()'s recipient_team_id
|
|
612
|
+
// requires it for every non-DM stream (STS-2.1; see SlackProgress.isDM).
|
|
613
|
+
// Resolved once via client.auth.test() here at app construction and cached
|
|
614
|
+
// in this closure for the life of this createSlackApp() instance — mirrors
|
|
615
|
+
// threadStatusTracker/liveStreams above (closure-scoped, never
|
|
616
|
+
// module-level, so multiple app instances in tests stay isolated).
|
|
617
|
+
// Kicked off eagerly (not lazily per-message) so the round-trip overlaps
|
|
618
|
+
// Bolt's own startup instead of adding latency to the first non-DM
|
|
619
|
+
// message. Resolves to undefined — never throws, never rejects — when the
|
|
620
|
+
// app's client lacks `auth.test` entirely (e.g. a test/mock appFactory) or
|
|
621
|
+
// the call itself fails; callers then just omit recipient_team_id, same as
|
|
622
|
+
// a DM payload.
|
|
623
|
+
const teamIdPromise: Promise<string | undefined> =
|
|
624
|
+
typeof app.client?.auth?.test === "function"
|
|
625
|
+
? app.client.auth.test().then(
|
|
626
|
+
(resp: { team_id?: string }) => resp?.team_id,
|
|
627
|
+
(err: unknown) => {
|
|
628
|
+
console.warn(
|
|
629
|
+
"[slack] auth.test() failed — recipient_team_id unavailable for non-DM Thinking Steps streams:",
|
|
630
|
+
err instanceof Error ? err.message : String(err),
|
|
631
|
+
);
|
|
632
|
+
return undefined;
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
: Promise.resolve(undefined);
|
|
636
|
+
|
|
637
|
+
// DMs: respond to every message.
|
|
638
|
+
// Channels: respond only if bot has already replied in this thread.
|
|
639
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
|
|
640
|
+
app.message(async ({ message, say, client }: any) => {
|
|
641
|
+
if (message.subtype && message.subtype !== "file_share") return;
|
|
642
|
+
const msg = message as {
|
|
643
|
+
text?: string;
|
|
644
|
+
channel: string;
|
|
645
|
+
ts: string;
|
|
646
|
+
thread_ts?: string;
|
|
647
|
+
channel_type?: string;
|
|
648
|
+
files?: SlackFile[];
|
|
649
|
+
user?: string;
|
|
650
|
+
blocks?: Array<{ type: string; elements?: unknown[] }>;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const isDM = msg.channel_type === "im";
|
|
654
|
+
const hasText = !!msg.text?.trim();
|
|
655
|
+
const hasFiles = !!msg.files?.length;
|
|
656
|
+
const hasRichText = !!msg.blocks?.some((b) => b.type === "rich_text");
|
|
657
|
+
|
|
658
|
+
if (!hasText && !hasRichText && !hasFiles) return;
|
|
659
|
+
|
|
660
|
+
if (!isDM) {
|
|
661
|
+
if (!msg.thread_ts) return;
|
|
662
|
+
if (!(await getSessionFn(getThreadKey(msg.channel, msg.thread_ts))))
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
|
|
667
|
+
if (gateMsg) {
|
|
668
|
+
await say({
|
|
669
|
+
text: gateMsg,
|
|
670
|
+
thread_ts: msg.thread_ts ?? msg.ts,
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (
|
|
676
|
+
msg.user &&
|
|
677
|
+
(await shouldRejectSlackSender(
|
|
678
|
+
msg.user,
|
|
679
|
+
client,
|
|
680
|
+
resolveUserEmailFn,
|
|
681
|
+
membershipRef,
|
|
682
|
+
))
|
|
683
|
+
) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const sessionKey = getThreadKey(msg.channel, msg.thread_ts ?? msg.ts);
|
|
688
|
+
const replyTs = msg.thread_ts ?? msg.ts;
|
|
689
|
+
|
|
690
|
+
// recipient_team_id is only meaningful (and only fetched) for non-DM
|
|
691
|
+
// threads — see teamIdPromise's doc comment and SlackProgress.isDM
|
|
692
|
+
// (STS-2.1).
|
|
693
|
+
const recipientTeamId = isDM ? undefined : await teamIdPromise;
|
|
694
|
+
const progress = new SlackProgress({
|
|
695
|
+
client,
|
|
696
|
+
channel: msg.channel,
|
|
697
|
+
threadTs: replyTs,
|
|
698
|
+
isDM,
|
|
699
|
+
recipientUserId: msg.user,
|
|
700
|
+
recipientTeamId,
|
|
701
|
+
});
|
|
702
|
+
// enter()/exit() are ALWAYS called (every enter() needs a matching
|
|
703
|
+
// exit(), or the refcount leaks upward forever) — see startProgress()'s
|
|
704
|
+
// doc comment for what the boolean gates (AC #5).
|
|
705
|
+
await startProgress(progress, threadStatusTracker.enter(sessionKey));
|
|
706
|
+
// Registers the stream (no-op when startStream() failed to open one —
|
|
707
|
+
// streamKey() is undefined either way) so agent_session_stopped can look
|
|
708
|
+
// this instance up (AGS-1.1).
|
|
709
|
+
registerStream(progress);
|
|
710
|
+
|
|
711
|
+
const rawText =
|
|
712
|
+
msg.text || (hasRichText ? richTextToMarkdown(msg.blocks ?? []) : "");
|
|
713
|
+
|
|
714
|
+
let prompt = await buildPromptWithFiles(
|
|
715
|
+
rawText,
|
|
716
|
+
msg.files,
|
|
717
|
+
fileDownloaderFn,
|
|
718
|
+
slackConfig.botToken,
|
|
719
|
+
transcribeAudioFn,
|
|
720
|
+
voiceConfig,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
if (msg.user) {
|
|
724
|
+
const name = await resolveUserFn(msg.user, client);
|
|
725
|
+
prompt = `[${name}]: ${prompt}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (!isDM && msg.thread_ts) {
|
|
729
|
+
prompt = `[Thread message — respond normally, or use [silent] if no response is needed]\n${prompt}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
|
|
733
|
+
// in the finally block can title the stream's completion card "Ack"
|
|
734
|
+
// rather than the generic "Done" when the reply was suppressed.
|
|
735
|
+
let wasSuppressed = false;
|
|
736
|
+
try {
|
|
737
|
+
const runResult = await runner(prompt, sessionKey, progress.onProgress, {
|
|
738
|
+
SLACK_CHANNEL_ID: msg.channel,
|
|
739
|
+
SLACK_THREAD_TS: replyTs,
|
|
740
|
+
});
|
|
741
|
+
if (runResult.streamIncomplete) {
|
|
742
|
+
// Clean process exit, but the stream never emitted a terminal
|
|
743
|
+
// `result` event — treat this the same as a genuine failure rather
|
|
744
|
+
// than silently posting nothing and logging it as a normal
|
|
745
|
+
// markers-only response (see CSU-1.1 review).
|
|
746
|
+
throw new ClaudeRunError(
|
|
747
|
+
"claude stream ended without a terminal result event",
|
|
748
|
+
undefined,
|
|
749
|
+
"stream incomplete — no terminal result event",
|
|
750
|
+
runResult.sessionId,
|
|
751
|
+
runResult.modelUsage,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const { result, usage, totalCostUsd, modelUsage } = runResult;
|
|
755
|
+
await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
|
|
756
|
+
const { cleaned, markers } = parseMarkers(result);
|
|
757
|
+
|
|
758
|
+
const isSilent = markers.some((m) => m.type === "silent");
|
|
759
|
+
const dmOverride = isSilent && isDM && cleaned.trim().length > 0;
|
|
760
|
+
const shouldSuppress = isSilent && !dmOverride;
|
|
761
|
+
wasSuppressed = shouldSuppress;
|
|
762
|
+
|
|
763
|
+
if (dmOverride) {
|
|
764
|
+
console.log("[slack] ignoring [silent] marker in DM — posting reply");
|
|
765
|
+
}
|
|
766
|
+
if (shouldSuppress) {
|
|
767
|
+
console.log(
|
|
768
|
+
`[slack] silent response — not posting (cleaned: ${cleaned.length} chars)`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let postedTs: string | undefined;
|
|
773
|
+
if (!shouldSuppress && cleaned.trim().length > 0) {
|
|
774
|
+
const delivered = await progress.deliverContent(cleaned);
|
|
775
|
+
if (!delivered) {
|
|
776
|
+
const blocks = blocksConverter(cleaned);
|
|
777
|
+
let sayResult: unknown;
|
|
778
|
+
if (blocks) {
|
|
779
|
+
try {
|
|
780
|
+
sayResult = await say({
|
|
781
|
+
text: blocks.text,
|
|
782
|
+
blocks: blocks.blocks,
|
|
783
|
+
thread_ts: msg.thread_ts ?? msg.ts,
|
|
784
|
+
});
|
|
785
|
+
} catch (err: unknown) {
|
|
786
|
+
if (!isInvalidBlocksError(err)) throw err;
|
|
787
|
+
console.warn("[slack] invalid_blocks — retrying with plain text");
|
|
788
|
+
sayResult = await say({
|
|
789
|
+
text: blocks.text || formatter(cleaned),
|
|
790
|
+
thread_ts: msg.thread_ts ?? msg.ts,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
sayResult = await say({
|
|
795
|
+
text: formatter(cleaned),
|
|
796
|
+
thread_ts: msg.thread_ts ?? msg.ts,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
postedTs = (sayResult as { ts?: string } | undefined)?.ts;
|
|
800
|
+
}
|
|
801
|
+
} else if (!shouldSuppress) {
|
|
802
|
+
console.log("[slack] markers-only response — skipping text post");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
await dispatchMarkers(markers, {
|
|
806
|
+
client,
|
|
807
|
+
channel: msg.channel,
|
|
808
|
+
postedTs,
|
|
809
|
+
threadTs: msg.thread_ts ?? msg.ts,
|
|
810
|
+
synthesizeSpeechFn,
|
|
811
|
+
voiceConfig,
|
|
812
|
+
postedText: shouldSuppress ? undefined : cleaned,
|
|
813
|
+
});
|
|
814
|
+
} catch (err) {
|
|
815
|
+
console.error("[slack] error:", err);
|
|
816
|
+
reportClaudeError(sentryClient, err);
|
|
817
|
+
// Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
|
|
818
|
+
// say() below — a UI-state signal only; reportClaudeError(sentryClient, err)
|
|
819
|
+
// above remains the single log point for this error. Ordering matters: if
|
|
820
|
+
// say() throws (rate limit, invalid_auth, channel_not_found), this must
|
|
821
|
+
// still run so finishProgress() doesn't send a "Done" card for a run that
|
|
822
|
+
// both errored and failed to notify the user (matches reaction_added).
|
|
823
|
+
progress.reportError();
|
|
824
|
+
// Wrap the error notification in its own try/catch: a failed say()
|
|
825
|
+
// (rate limit, invalid_auth, channel_not_found) must not escape the
|
|
826
|
+
// handler and skip the finally block's finishProgress().
|
|
827
|
+
try {
|
|
828
|
+
await say({
|
|
829
|
+
text: formatRunErrorForSlack(err),
|
|
830
|
+
thread_ts: msg.thread_ts ?? msg.ts,
|
|
831
|
+
});
|
|
832
|
+
} catch (sayErr) {
|
|
833
|
+
console.error("[slack] failed to post error notification:", sayErr);
|
|
834
|
+
}
|
|
835
|
+
} finally {
|
|
836
|
+
// exit() is ALWAYS called (matching the always-called enter() above)
|
|
837
|
+
// — see finishProgress()'s doc comment for what it gates.
|
|
838
|
+
await finishProgress(
|
|
839
|
+
progress,
|
|
840
|
+
threadStatusTracker.exit(sessionKey),
|
|
841
|
+
wasSuppressed,
|
|
842
|
+
);
|
|
843
|
+
// Clears the registry entry unconditionally — success or failure —
|
|
844
|
+
// so it never leaks a stale instance for a run that's already done
|
|
845
|
+
// (AGS-1.1).
|
|
846
|
+
unregisterStream(progress);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
|
|
851
|
+
app.event("app_mention", async ({ event, say, client }: any) => {
|
|
852
|
+
const ev = event as {
|
|
853
|
+
text: string;
|
|
854
|
+
channel: string;
|
|
855
|
+
ts: string;
|
|
856
|
+
thread_ts?: string;
|
|
857
|
+
user?: string;
|
|
858
|
+
files?: SlackFile[];
|
|
859
|
+
};
|
|
860
|
+
const sessionKey = getThreadKey(ev.channel, ev.thread_ts ?? ev.ts);
|
|
861
|
+
const replyTs = ev.thread_ts ?? ev.ts;
|
|
862
|
+
|
|
863
|
+
// Drop @mention if bot is already participating in this thread —
|
|
864
|
+
// the message handler covers it and would double-respond otherwise.
|
|
865
|
+
if (ev.thread_ts && (await getSessionFn(sessionKey))) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
|
|
870
|
+
if (gateMsg) {
|
|
871
|
+
await say({ text: gateMsg, thread_ts: replyTs });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (
|
|
876
|
+
ev.user &&
|
|
877
|
+
(await shouldRejectSlackSender(
|
|
878
|
+
ev.user,
|
|
879
|
+
client,
|
|
880
|
+
resolveUserEmailFn,
|
|
881
|
+
membershipRef,
|
|
882
|
+
))
|
|
883
|
+
) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// app_mention never fires for a DM (mentions in a DM arrive as a plain
|
|
888
|
+
// message event, handled above) — always a channel/group thread, so
|
|
889
|
+
// recipient_team_id is always fetched here (STS-2.1).
|
|
890
|
+
const progress = new SlackProgress({
|
|
891
|
+
client,
|
|
892
|
+
channel: ev.channel,
|
|
893
|
+
threadTs: replyTs,
|
|
894
|
+
isDM: false,
|
|
895
|
+
recipientUserId: ev.user,
|
|
896
|
+
recipientTeamId: await teamIdPromise,
|
|
897
|
+
});
|
|
898
|
+
// enter() is ALWAYS called — see startProgress()'s doc comment for
|
|
899
|
+
// what it gates (STS2-1.1 AC #5).
|
|
900
|
+
await startProgress(progress, threadStatusTracker.enter(sessionKey));
|
|
901
|
+
// Registers the stream so agent_session_stopped can look this instance
|
|
902
|
+
// up (AGS-1.1) — see the app.message handler's identical comment above.
|
|
903
|
+
registerStream(progress);
|
|
904
|
+
|
|
905
|
+
let prompt = await buildPromptWithFiles(
|
|
906
|
+
ev.text,
|
|
907
|
+
ev.files,
|
|
908
|
+
fileDownloaderFn,
|
|
909
|
+
slackConfig.botToken,
|
|
910
|
+
transcribeAudioFn,
|
|
911
|
+
voiceConfig,
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
if (ev.user) {
|
|
915
|
+
const name = await resolveUserFn(ev.user as string, client);
|
|
916
|
+
prompt = `[${name}]: ${prompt}`;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (event.thread_ts && !(await getSessionFn(sessionKey))) {
|
|
920
|
+
try {
|
|
921
|
+
const repliesResult = await conversationsRepliesFn(
|
|
922
|
+
client,
|
|
923
|
+
event.channel as string,
|
|
924
|
+
event.thread_ts as string,
|
|
925
|
+
);
|
|
926
|
+
const messages = repliesResult.messages ?? [];
|
|
927
|
+
const historyLines = await Promise.all(
|
|
928
|
+
messages
|
|
929
|
+
.filter(
|
|
930
|
+
(m) =>
|
|
931
|
+
m.text &&
|
|
932
|
+
m.text.trim() !== "" &&
|
|
933
|
+
m.user &&
|
|
934
|
+
(!botUserId || m.user !== botUserId) &&
|
|
935
|
+
m.ts !== (event.ts as string),
|
|
936
|
+
)
|
|
937
|
+
.map(async (m) => {
|
|
938
|
+
const name = await resolveUserFn(m.user as string, client);
|
|
939
|
+
return `[${name}]: ${m.text}`;
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
if (historyLines.length > 0) {
|
|
943
|
+
prompt = `[Thread context]\n${historyLines.join("\n")}\n[end thread context]\n\n${prompt}`;
|
|
944
|
+
}
|
|
945
|
+
} catch (err) {
|
|
946
|
+
console.warn("[slack] failed to fetch thread history:", err);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
|
|
951
|
+
// in the finally block can title the stream's completion card "Ack"
|
|
952
|
+
// rather than the generic "Done" when the reply was suppressed.
|
|
953
|
+
let wasSuppressed = false;
|
|
954
|
+
try {
|
|
955
|
+
const runResult = await runner(prompt, sessionKey, progress.onProgress, {
|
|
956
|
+
SLACK_CHANNEL_ID: ev.channel,
|
|
957
|
+
SLACK_THREAD_TS: replyTs,
|
|
958
|
+
});
|
|
959
|
+
if (runResult.streamIncomplete) {
|
|
960
|
+
// Clean process exit, but the stream never emitted a terminal
|
|
961
|
+
// `result` event — treat this the same as a genuine failure rather
|
|
962
|
+
// than silently posting nothing and logging it as a normal
|
|
963
|
+
// markers-only response (see CSU-1.1 review).
|
|
964
|
+
throw new ClaudeRunError(
|
|
965
|
+
"claude stream ended without a terminal result event",
|
|
966
|
+
undefined,
|
|
967
|
+
"stream incomplete — no terminal result event",
|
|
968
|
+
runResult.sessionId,
|
|
969
|
+
runResult.modelUsage,
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
const { result, usage, totalCostUsd, modelUsage } = runResult;
|
|
973
|
+
await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
|
|
974
|
+
const { cleaned, markers } = parseMarkers(result);
|
|
975
|
+
|
|
976
|
+
const isSilent = markers.some((m) => m.type === "silent");
|
|
977
|
+
wasSuppressed = isSilent;
|
|
978
|
+
if (isSilent) {
|
|
979
|
+
console.log(
|
|
980
|
+
`[slack] silent response — not posting (channel mention, cleaned: ${cleaned.length} chars)`,
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let postedMentionTs: string | undefined;
|
|
985
|
+
if (!isSilent && cleaned.trim().length > 0) {
|
|
986
|
+
const delivered = await progress.deliverContent(cleaned);
|
|
987
|
+
if (!delivered) {
|
|
988
|
+
const blocks = blocksConverter(cleaned);
|
|
989
|
+
let sayResult: unknown;
|
|
990
|
+
if (blocks) {
|
|
991
|
+
try {
|
|
992
|
+
sayResult = await say({
|
|
993
|
+
text: blocks.text,
|
|
994
|
+
blocks: blocks.blocks,
|
|
995
|
+
thread_ts: replyTs,
|
|
996
|
+
});
|
|
997
|
+
} catch (err: unknown) {
|
|
998
|
+
if (!isInvalidBlocksError(err)) throw err;
|
|
999
|
+
console.warn("[slack] invalid_blocks — retrying with plain text");
|
|
1000
|
+
sayResult = await say({
|
|
1001
|
+
text: blocks.text || formatter(cleaned),
|
|
1002
|
+
thread_ts: replyTs,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
sayResult = await say({
|
|
1007
|
+
text: formatter(cleaned),
|
|
1008
|
+
thread_ts: replyTs,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
postedMentionTs = (sayResult as { ts?: string } | undefined)?.ts;
|
|
1012
|
+
}
|
|
1013
|
+
} else if (!isSilent) {
|
|
1014
|
+
console.log("[slack] markers-only response — skipping text post");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
await dispatchMarkers(markers, {
|
|
1018
|
+
client,
|
|
1019
|
+
channel: ev.channel,
|
|
1020
|
+
postedTs: postedMentionTs,
|
|
1021
|
+
threadTs: replyTs,
|
|
1022
|
+
synthesizeSpeechFn,
|
|
1023
|
+
voiceConfig,
|
|
1024
|
+
postedText: isSilent ? undefined : cleaned,
|
|
1025
|
+
});
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
console.error("[slack] error:", err);
|
|
1028
|
+
reportClaudeError(sentryClient, err);
|
|
1029
|
+
// Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
|
|
1030
|
+
// say() below — a UI-state signal only; reportClaudeError(sentryClient, err)
|
|
1031
|
+
// above remains the single log point for this error. Ordering matters: if
|
|
1032
|
+
// say() throws (rate limit, invalid_auth, channel_not_found), this must
|
|
1033
|
+
// still run so finishProgress() doesn't send a "Done" card for a run that
|
|
1034
|
+
// both errored and failed to notify the user (matches reaction_added).
|
|
1035
|
+
progress.reportError();
|
|
1036
|
+
// Wrap the error notification in its own try/catch: a failed say()
|
|
1037
|
+
// (rate limit, invalid_auth, channel_not_found) must not escape the
|
|
1038
|
+
// handler and skip the finally block's finishProgress().
|
|
1039
|
+
try {
|
|
1040
|
+
await say({ text: formatRunErrorForSlack(err), thread_ts: replyTs });
|
|
1041
|
+
} catch (sayErr) {
|
|
1042
|
+
console.error("[slack] failed to post error notification:", sayErr);
|
|
1043
|
+
}
|
|
1044
|
+
} finally {
|
|
1045
|
+
// See finishProgress()'s doc comment.
|
|
1046
|
+
await finishProgress(
|
|
1047
|
+
progress,
|
|
1048
|
+
threadStatusTracker.exit(sessionKey),
|
|
1049
|
+
wasSuppressed,
|
|
1050
|
+
);
|
|
1051
|
+
unregisterStream(progress);
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
|
|
1056
|
+
app.event("reaction_added", async ({ event, client }: any) => {
|
|
1057
|
+
const ev = event as {
|
|
1058
|
+
reaction: string;
|
|
1059
|
+
item: { type: string; channel: string; ts: string };
|
|
1060
|
+
item_user: string;
|
|
1061
|
+
user: string;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
if (ev.item.type !== "message") return;
|
|
1065
|
+
if (!botUserId || ev.item_user !== botUserId) return;
|
|
1066
|
+
if (!ev.item.channel.startsWith("D")) return;
|
|
1067
|
+
|
|
1068
|
+
const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
|
|
1069
|
+
if (gateMsg) {
|
|
1070
|
+
await client.chat.postMessage({
|
|
1071
|
+
channel: ev.item.channel,
|
|
1072
|
+
text: gateMsg,
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (
|
|
1078
|
+
await shouldRejectSlackSender(
|
|
1079
|
+
ev.user,
|
|
1080
|
+
client,
|
|
1081
|
+
resolveUserEmailFn,
|
|
1082
|
+
membershipRef,
|
|
1083
|
+
)
|
|
1084
|
+
) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const sessionKey = getThreadKey(ev.item.channel, ev.item.ts);
|
|
1089
|
+
const name = await resolveUserFn(ev.user, client).catch(() => ev.user);
|
|
1090
|
+
const prompt = `[${name} reacted with :${ev.reaction}: to your message]`;
|
|
1091
|
+
|
|
1092
|
+
// Gated above (`ev.item.channel.startsWith("D")`) — this handler only
|
|
1093
|
+
// ever fires for DMs, so recipient_team_id/recipient_user_id are never
|
|
1094
|
+
// fetched or needed here (STS-2.1).
|
|
1095
|
+
const progress = new SlackProgress({
|
|
1096
|
+
client,
|
|
1097
|
+
channel: ev.item.channel,
|
|
1098
|
+
threadTs: ev.item.ts,
|
|
1099
|
+
isDM: true,
|
|
1100
|
+
});
|
|
1101
|
+
// enter() is ALWAYS called — see startProgress()'s doc comment for
|
|
1102
|
+
// what it gates (STS2-1.1 AC #5).
|
|
1103
|
+
await startProgress(progress, threadStatusTracker.enter(sessionKey));
|
|
1104
|
+
// Registers the stream so agent_session_stopped can look this instance
|
|
1105
|
+
// up (AGS-1.1) — see the app.message handler's identical comment above.
|
|
1106
|
+
registerStream(progress);
|
|
1107
|
+
|
|
1108
|
+
// Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
|
|
1109
|
+
// in the finally block can title the stream's completion card "Ack"
|
|
1110
|
+
// rather than the generic "Done" when the reply was suppressed.
|
|
1111
|
+
let wasSuppressed = false;
|
|
1112
|
+
try {
|
|
1113
|
+
const runResult = await runner(prompt, sessionKey, progress.onProgress, {
|
|
1114
|
+
SLACK_CHANNEL_ID: ev.item.channel,
|
|
1115
|
+
SLACK_THREAD_TS: ev.item.ts,
|
|
1116
|
+
});
|
|
1117
|
+
if (runResult.streamIncomplete) {
|
|
1118
|
+
// Clean process exit, but the stream never emitted a terminal
|
|
1119
|
+
// `result` event — treat this the same as a genuine failure rather
|
|
1120
|
+
// than silently posting nothing and logging it as a normal
|
|
1121
|
+
// markers-only response (see CSU-1.1 review).
|
|
1122
|
+
throw new ClaudeRunError(
|
|
1123
|
+
"claude stream ended without a terminal result event",
|
|
1124
|
+
undefined,
|
|
1125
|
+
"stream incomplete — no terminal result event",
|
|
1126
|
+
runResult.sessionId,
|
|
1127
|
+
runResult.modelUsage,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
const { result, usage, totalCostUsd, modelUsage } = runResult;
|
|
1131
|
+
await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
|
|
1132
|
+
const { cleaned, markers } = parseMarkers(result);
|
|
1133
|
+
|
|
1134
|
+
const isSilent = markers.some((m) => m.type === "silent");
|
|
1135
|
+
wasSuppressed = isSilent;
|
|
1136
|
+
if (isSilent) {
|
|
1137
|
+
console.log("[slack] reaction_added: silent response — not posting");
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
let postResult: unknown;
|
|
1142
|
+
if (cleaned.trim().length > 0) {
|
|
1143
|
+
const delivered = await progress.deliverContent(cleaned);
|
|
1144
|
+
if (!delivered) {
|
|
1145
|
+
const blocks = blocksConverter(cleaned);
|
|
1146
|
+
if (blocks) {
|
|
1147
|
+
try {
|
|
1148
|
+
postResult = await client.chat.postMessage({
|
|
1149
|
+
channel: ev.item.channel,
|
|
1150
|
+
text: blocks.text,
|
|
1151
|
+
blocks: blocks.blocks,
|
|
1152
|
+
});
|
|
1153
|
+
} catch (err: unknown) {
|
|
1154
|
+
if (isInvalidBlocksError(err)) {
|
|
1155
|
+
console.warn(
|
|
1156
|
+
"[slack] invalid_blocks — retrying with plain text",
|
|
1157
|
+
);
|
|
1158
|
+
postResult = await client.chat
|
|
1159
|
+
.postMessage({
|
|
1160
|
+
channel: ev.item.channel,
|
|
1161
|
+
text: blocks.text || formatter(cleaned),
|
|
1162
|
+
})
|
|
1163
|
+
.catch((err2: unknown) => {
|
|
1164
|
+
console.warn(
|
|
1165
|
+
"[slack] reaction_added: postMessage failed:",
|
|
1166
|
+
err2,
|
|
1167
|
+
);
|
|
1168
|
+
return undefined;
|
|
1169
|
+
});
|
|
1170
|
+
} else {
|
|
1171
|
+
console.warn(
|
|
1172
|
+
"[slack] reaction_added: postMessage failed:",
|
|
1173
|
+
err,
|
|
1174
|
+
);
|
|
1175
|
+
postResult = undefined;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
} else {
|
|
1179
|
+
postResult = await client.chat
|
|
1180
|
+
.postMessage({
|
|
1181
|
+
channel: ev.item.channel,
|
|
1182
|
+
text: formatter(cleaned),
|
|
1183
|
+
})
|
|
1184
|
+
.catch((err: unknown) => {
|
|
1185
|
+
console.warn(
|
|
1186
|
+
"[slack] reaction_added: postMessage failed:",
|
|
1187
|
+
err,
|
|
1188
|
+
);
|
|
1189
|
+
return undefined;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const postedTs = (postResult as { ts?: string } | undefined)?.ts;
|
|
1196
|
+
await dispatchMarkers(markers, {
|
|
1197
|
+
client,
|
|
1198
|
+
channel: ev.item.channel,
|
|
1199
|
+
postedTs,
|
|
1200
|
+
threadTs: ev.item.ts,
|
|
1201
|
+
synthesizeSpeechFn,
|
|
1202
|
+
postedText: cleaned,
|
|
1203
|
+
voiceConfig,
|
|
1204
|
+
});
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
console.error("[slack] reaction_added error:", err);
|
|
1207
|
+
// Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) — a
|
|
1208
|
+
// UI-state signal only, not a second log path.
|
|
1209
|
+
progress.reportError();
|
|
1210
|
+
} finally {
|
|
1211
|
+
// See finishProgress()'s doc comment.
|
|
1212
|
+
await finishProgress(
|
|
1213
|
+
progress,
|
|
1214
|
+
threadStatusTracker.exit(sessionKey),
|
|
1215
|
+
wasSuppressed,
|
|
1216
|
+
);
|
|
1217
|
+
unregisterStream(progress);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// Slack's native Stop/session-cancellation event for the Agent Sessions
|
|
1222
|
+
// API (AGS-1.1). Without subscribing to this, the agent has no way to know
|
|
1223
|
+
// when Slack has externally halted a stream, so it keeps trying to
|
|
1224
|
+
// append/finalize into a stream Slack already discarded server-side
|
|
1225
|
+
// (message_not_in_streaming_state) — which cascades into a permanently
|
|
1226
|
+
// stuck "is working" status, since finish()'s stopStream() call fails with
|
|
1227
|
+
// no fallback to reset it. Mirrors openclaw's applySlackStreamStop /
|
|
1228
|
+
// markSlackStreamsStopped pattern (extensions/slack/src/streaming.ts,
|
|
1229
|
+
// monitor/events/agent.ts).
|
|
1230
|
+
//
|
|
1231
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
|
|
1232
|
+
app.event("agent_session_stopped", async ({ event, client }: any) => {
|
|
1233
|
+
const ev = event as { channel?: string; streaming_message_ts?: string };
|
|
1234
|
+
|
|
1235
|
+
// Look up the matching live stream and mark it externally-stopped —
|
|
1236
|
+
// any further appendStream/stopStream call against it becomes a no-op
|
|
1237
|
+
// instead of a failed API call. An unknown/already-cleared channel:ts
|
|
1238
|
+
// (stream already closed via its own success/failure path, or a
|
|
1239
|
+
// duplicate delivery of the same event) is a safe no-op — never throws.
|
|
1240
|
+
// Also recovers the run's actual thread_ts (distinct from the stream's
|
|
1241
|
+
// own streaming_message_ts) for the direct status reset below, when a
|
|
1242
|
+
// match was found. On a registry miss threadTs stays undefined — we
|
|
1243
|
+
// deliberately do NOT fall back to ev.streaming_message_ts, which is the
|
|
1244
|
+
// stream's own message ts, a distinct value the Agent Sessions API does
|
|
1245
|
+
// not accept as a thread identifier (see SlackProgress.getThreadTs()).
|
|
1246
|
+
let threadTs: string | undefined;
|
|
1247
|
+
if (ev.channel && ev.streaming_message_ts) {
|
|
1248
|
+
const key = `${ev.channel}:${ev.streaming_message_ts}`;
|
|
1249
|
+
const progress = liveStreams.get(key);
|
|
1250
|
+
if (progress) {
|
|
1251
|
+
threadTs = progress.getThreadTs();
|
|
1252
|
+
progress.markExternallyStopped();
|
|
1253
|
+
liveStreams.delete(key);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Independent of the stream's own close path: a direct status reset,
|
|
1258
|
+
// since it's a separate API call from the stream itself and should
|
|
1259
|
+
// succeed even when the stream is already dead server-side (the actual
|
|
1260
|
+
// fix for the stuck "is working" status — matches openclaw's own
|
|
1261
|
+
// handler comment: "Recover stale processing if the turn's earlier
|
|
1262
|
+
// active write failed. An unauthorized Stop may clear the indicator
|
|
1263
|
+
// cosmetically until the still-running turn ends.").
|
|
1264
|
+
//
|
|
1265
|
+
// On a registry miss (duplicate/late event for an already-cleared run) we
|
|
1266
|
+
// still fire the reset so the "is working" indicator can't stick, but omit
|
|
1267
|
+
// thread_ts entirely rather than send the wrong (streaming_message_ts)
|
|
1268
|
+
// value.
|
|
1269
|
+
if (ev.channel) {
|
|
1270
|
+
try {
|
|
1271
|
+
await client.agents.sessions.setStatus({
|
|
1272
|
+
channel_id: ev.channel,
|
|
1273
|
+
status: "active",
|
|
1274
|
+
...(threadTs !== undefined ? { thread_ts: threadTs } : {}),
|
|
1275
|
+
});
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
console.warn(
|
|
1278
|
+
"[slack] agent_session_stopped: setStatus(active) failed:",
|
|
1279
|
+
err instanceof Error ? err.message : String(err),
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
return app;
|
|
1286
|
+
}
|