pi-quiver 4.4.0 → 5.0.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/CHANGELOG.md +8 -0
- package/README.md +103 -25
- package/extensions/fast-mode.ts +1 -1
- package/extensions/provider-stall-watchdog.ts +3 -3
- package/extensions/session-name.ts +1 -1
- package/extensions/slack.ts +430 -0
- package/extensions/sword-header.ts +1 -1
- package/lib/extension-config.ts +46 -3
- package/lib/slack-cache.ts +338 -0
- package/lib/slack-core.ts +994 -0
- package/package.json +1 -1
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config + token resolution for the (future) slack extension. Stays
|
|
3
|
+
* pi-free: `warn` is an injected callback, `cwd`/`env` are explicit
|
|
4
|
+
* parameters, so callers (extensions/slack.ts, tests) control everything
|
|
5
|
+
* this module reads.
|
|
6
|
+
*
|
|
7
|
+
* Config ladder: `resolveConfig` (shared, `lib/extension-config.ts`) for the
|
|
8
|
+
* settings.json layers (pi-home, repo `.pi/settings.json`), then a
|
|
9
|
+
* slack-local `PI_QUIVER_SLACK_*` env-var overlay applied on top.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { resolveConfig } from "./extension-config.ts";
|
|
18
|
+
|
|
19
|
+
export interface SlackConfig {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
cachePath: string | undefined;
|
|
22
|
+
userTokenEnv: string;
|
|
23
|
+
botTokenEnv: string;
|
|
24
|
+
uploadThresholdChars: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_SLACK_CONFIG: SlackConfig = {
|
|
28
|
+
enabled: false,
|
|
29
|
+
cachePath: undefined,
|
|
30
|
+
userTokenEnv: "SLACK_USER_TOKEN",
|
|
31
|
+
botTokenEnv: "SLACK_BOT_TOKEN",
|
|
32
|
+
uploadThresholdChars: 4000,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class SlackError extends Error {
|
|
36
|
+
code: string;
|
|
37
|
+
data?: Record<string, unknown>;
|
|
38
|
+
constructor(code: string, message: string, data?: Record<string, unknown>) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.data = data;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
|
|
46
|
+
if (typeof raw === "boolean") return { enabled: raw };
|
|
47
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
48
|
+
const o = raw as Record<string, unknown>;
|
|
49
|
+
const patch: Partial<SlackConfig> = {};
|
|
50
|
+
if (typeof o.enabled === "boolean") patch.enabled = o.enabled;
|
|
51
|
+
if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
|
|
52
|
+
if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
|
|
53
|
+
if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
|
|
54
|
+
if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
|
|
55
|
+
patch.uploadThresholdChars = o.uploadThresholdChars;
|
|
56
|
+
}
|
|
57
|
+
return patch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const emittedEnvWarnings = new Set<string>();
|
|
61
|
+
|
|
62
|
+
function emitEnvWarning(warn: ((message: string) => void) | undefined, key: string, message: string): void {
|
|
63
|
+
if (!warn || emittedEnvWarnings.has(key)) return;
|
|
64
|
+
emittedEnvWarnings.add(key);
|
|
65
|
+
warn(message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseBooleanEnv(value: string): boolean | undefined {
|
|
69
|
+
if (value === "true" || value === "1") return true;
|
|
70
|
+
if (value === "false" || value === "0") return false;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parsePositiveIntEnv(value: string): number | undefined {
|
|
75
|
+
if (!/^\d+$/.test(value)) return undefined;
|
|
76
|
+
const n = Number.parseInt(value, 10);
|
|
77
|
+
return n > 0 ? n : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyEnvOverrides(
|
|
81
|
+
cfg: SlackConfig,
|
|
82
|
+
env: Record<string, string | undefined>,
|
|
83
|
+
warn?: (message: string) => void,
|
|
84
|
+
): SlackConfig {
|
|
85
|
+
const result = { ...cfg };
|
|
86
|
+
|
|
87
|
+
const enabledRaw = env.PI_QUIVER_SLACK_ENABLED;
|
|
88
|
+
if (enabledRaw !== undefined && enabledRaw !== "") {
|
|
89
|
+
const parsed = parseBooleanEnv(enabledRaw);
|
|
90
|
+
if (parsed !== undefined) result.enabled = parsed;
|
|
91
|
+
else emitEnvWarning(warn, "PI_QUIVER_SLACK_ENABLED", `pi-quiver: env var PI_QUIVER_SLACK_ENABLED has an unrecognized value; ignored.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cachePathRaw = env.PI_QUIVER_SLACK_CACHE_PATH;
|
|
95
|
+
if (cachePathRaw !== undefined && cachePathRaw !== "") result.cachePath = cachePathRaw;
|
|
96
|
+
|
|
97
|
+
const userTokenEnvRaw = env.PI_QUIVER_SLACK_USER_TOKEN_ENV;
|
|
98
|
+
if (userTokenEnvRaw !== undefined && userTokenEnvRaw !== "") result.userTokenEnv = userTokenEnvRaw;
|
|
99
|
+
|
|
100
|
+
const botTokenEnvRaw = env.PI_QUIVER_SLACK_BOT_TOKEN_ENV;
|
|
101
|
+
if (botTokenEnvRaw !== undefined && botTokenEnvRaw !== "") result.botTokenEnv = botTokenEnvRaw;
|
|
102
|
+
|
|
103
|
+
const uploadThresholdRaw = env.PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS;
|
|
104
|
+
if (uploadThresholdRaw !== undefined && uploadThresholdRaw !== "") {
|
|
105
|
+
const parsed = parsePositiveIntEnv(uploadThresholdRaw);
|
|
106
|
+
if (parsed !== undefined) result.uploadThresholdChars = parsed;
|
|
107
|
+
else
|
|
108
|
+
emitEnvWarning(
|
|
109
|
+
warn,
|
|
110
|
+
"PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS",
|
|
111
|
+
`pi-quiver: env var PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS has an unrecognized value; ignored.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resolveSlackConfig(cwd: string, env: Record<string, string | undefined>, warn?: (message: string) => void): SlackConfig {
|
|
119
|
+
return applyEnvOverrides(resolveConfig(cwd, "slack", DEFAULT_SLACK_CONFIG, coerce, warn), env, warn);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function discoverRepoRoot(cwd: string): string {
|
|
123
|
+
try {
|
|
124
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
|
125
|
+
} catch {
|
|
126
|
+
return cwd;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function primaryCheckoutRoot(cwd: string): string | undefined {
|
|
131
|
+
try {
|
|
132
|
+
const gitCommonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
133
|
+
cwd,
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
}).trim();
|
|
136
|
+
return dirname(gitCommonDir);
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseEnvFile(content: string): Map<string, string> {
|
|
143
|
+
// Deliberate: inline `#` comments after unquoted values are NOT stripped - a token may
|
|
144
|
+
// legitimately contain `#`. Quote the value if you need a trailing comment.
|
|
145
|
+
const result = new Map<string, string>();
|
|
146
|
+
for (const line of content.split(/\r?\n/)) {
|
|
147
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
148
|
+
if (!match) continue;
|
|
149
|
+
const [, key, rawValue] = match;
|
|
150
|
+
let value = rawValue;
|
|
151
|
+
if (value.length >= 2) {
|
|
152
|
+
const first = value[0];
|
|
153
|
+
const last = value[value.length - 1];
|
|
154
|
+
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
155
|
+
value = value.slice(1, -1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
result.set(key, value);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readEnvFile(dir: string): Map<string, string> | undefined {
|
|
164
|
+
try {
|
|
165
|
+
return parseEnvFile(readFileSync(join(dir, ".env"), "utf8"));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Deliberate: a genuinely absent .env degrades to "token not found", not a thrown error.
|
|
168
|
+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
169
|
+
// Present but unreadable (e.g. permissions): re-thrown so callers treat it as present-but-empty,
|
|
170
|
+
// not absent - this must NOT authorize the primary-checkout fallback.
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resolveToken(
|
|
176
|
+
identity: "user" | "bot",
|
|
177
|
+
cfg: SlackConfig,
|
|
178
|
+
env: Record<string, string | undefined>,
|
|
179
|
+
repoRoot: string,
|
|
180
|
+
): string {
|
|
181
|
+
const envVar = identity === "user" ? cfg.userTokenEnv : cfg.botTokenEnv;
|
|
182
|
+
|
|
183
|
+
const fromEnv = env[envVar];
|
|
184
|
+
if (fromEnv) return fromEnv;
|
|
185
|
+
|
|
186
|
+
let repoEnvFile: Map<string, string> | undefined;
|
|
187
|
+
let repoEnvUnreadable = false;
|
|
188
|
+
try {
|
|
189
|
+
repoEnvFile = readEnvFile(repoRoot);
|
|
190
|
+
} catch {
|
|
191
|
+
repoEnvUnreadable = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (repoEnvFile) {
|
|
195
|
+
// An empty (after quote-strip) .env value is treated as missing, not a usable empty token.
|
|
196
|
+
const value = repoEnvFile.get(envVar);
|
|
197
|
+
if (value) return value;
|
|
198
|
+
} else if (!repoEnvUnreadable) {
|
|
199
|
+
// File-level fallback only, per spec: this only triggers when the worktree root has no .env at
|
|
200
|
+
// all (ENOENT); an existing-but-unreadable local .env blocks the fallback just like an existing
|
|
201
|
+
// one that lacks this key.
|
|
202
|
+
const primaryRoot = primaryCheckoutRoot(repoRoot);
|
|
203
|
+
if (primaryRoot && primaryRoot !== repoRoot) {
|
|
204
|
+
// Best-effort: any error reading the primary .env (missing or otherwise) just means no fallback.
|
|
205
|
+
let primaryEnvFile: Map<string, string> | undefined;
|
|
206
|
+
try {
|
|
207
|
+
primaryEnvFile = readEnvFile(primaryRoot);
|
|
208
|
+
} catch {
|
|
209
|
+
primaryEnvFile = undefined;
|
|
210
|
+
}
|
|
211
|
+
const value = primaryEnvFile?.get(envVar);
|
|
212
|
+
if (value) return value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new SlackError(
|
|
217
|
+
"missing_token",
|
|
218
|
+
`No Slack ${identity} token: env var ${envVar} is empty and no .env entry was found.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Data-plane transport: every Slack API call and file upload funnels through
|
|
224
|
+
* these two injectable functions so tests script responses without network.
|
|
225
|
+
* Retry policy lives with the caller (`opts.retry`), not the transport.
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
export const REQUEST_TIMEOUT_MS = 20_000;
|
|
229
|
+
export const MAX_RETRY_AFTER_MS = 30_000;
|
|
230
|
+
const RETRY_AFTER_DEFAULT_S = 2;
|
|
231
|
+
|
|
232
|
+
export interface ApiOpts {
|
|
233
|
+
retry?: boolean;
|
|
234
|
+
signal?: AbortSignal;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type ApiCall = (
|
|
238
|
+
method: string,
|
|
239
|
+
token: string,
|
|
240
|
+
params: Record<string, unknown>,
|
|
241
|
+
opts?: ApiOpts,
|
|
242
|
+
) => Promise<Record<string, unknown>>;
|
|
243
|
+
|
|
244
|
+
export type UploadBytes = (url: string, bytes: Uint8Array, opts?: { signal?: AbortSignal }) => Promise<void>;
|
|
245
|
+
|
|
246
|
+
const JSON_METHODS = new Set(["chat.postMessage", "chat.update"]);
|
|
247
|
+
|
|
248
|
+
export const ERROR_HINTS: Record<string, string> = {
|
|
249
|
+
channel_not_found: "check the name or run slack_cache_refresh",
|
|
250
|
+
not_in_channel: "invite the bot to the channel",
|
|
251
|
+
msg_too_long: "message exceeds Slack's length ceiling",
|
|
252
|
+
is_archived: "the channel is archived",
|
|
253
|
+
edit_window_closed: "Slack's edit window for this message has closed",
|
|
254
|
+
already_pinned: "the message is already pinned",
|
|
255
|
+
not_pinnable: "this message type cannot be pinned",
|
|
256
|
+
too_many_pins: "the channel has reached Slack's pin limit",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function mapSlackApiError(data: Record<string, unknown>): SlackError {
|
|
260
|
+
const code = typeof data.error === "string" ? data.error : "unknown_error";
|
|
261
|
+
if (code === "missing_scope") {
|
|
262
|
+
const needed = typeof data.needed === "string" ? data.needed : "unknown";
|
|
263
|
+
return new SlackError(
|
|
264
|
+
code,
|
|
265
|
+
`missing_scope: the token passed to this call does not have the "${needed}" scope.`,
|
|
266
|
+
data,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const hint = ERROR_HINTS[code];
|
|
270
|
+
const message = hint ? `${code}: ${hint}` : code;
|
|
271
|
+
return new SlackError(code, message, data);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildRequest(
|
|
275
|
+
baseUrl: string,
|
|
276
|
+
method: string,
|
|
277
|
+
token: string,
|
|
278
|
+
params: Record<string, unknown>,
|
|
279
|
+
): { url: string; init: RequestInit } {
|
|
280
|
+
const url = `${baseUrl}/api/${method}`;
|
|
281
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
282
|
+
if (JSON_METHODS.has(method)) {
|
|
283
|
+
headers["content-type"] = "application/json";
|
|
284
|
+
return { url, init: { method: "POST", headers, body: JSON.stringify(params) } };
|
|
285
|
+
}
|
|
286
|
+
const body = new URLSearchParams();
|
|
287
|
+
for (const [key, value] of Object.entries(params)) {
|
|
288
|
+
if (value !== undefined) body.set(key, String(value));
|
|
289
|
+
}
|
|
290
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
291
|
+
return { url, init: { method: "POST", headers, body: body.toString() } };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
295
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
298
|
+
const onAbort = () => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
reject(signal!.reason ?? new DOMException("Aborted", "AbortError"));
|
|
301
|
+
};
|
|
302
|
+
timer = setTimeout(() => {
|
|
303
|
+
signal?.removeEventListener("abort", onAbort);
|
|
304
|
+
resolve();
|
|
305
|
+
}, ms);
|
|
306
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Shared fetch wrapper for apiCall and uploadBytes: builds the caller-signal + timeout-signal
|
|
312
|
+
* combo, runs the fetch, and maps any failure to a SlackError. Caller-initiated cancellation
|
|
313
|
+
* (opts.signal aborted) gets its own "aborted" code. A timeout is deliberately NOT a distinct
|
|
314
|
+
* code - it stays "transport", because a later wave treats "transport" as "outcome unknown, the
|
|
315
|
+
* request may have reached Slack", which is true for timeouts too - only the message changes.
|
|
316
|
+
*/
|
|
317
|
+
async function fetchOrTransportError(
|
|
318
|
+
url: string,
|
|
319
|
+
init: RequestInit,
|
|
320
|
+
callerSignal: AbortSignal | undefined,
|
|
321
|
+
timeoutMs: number,
|
|
322
|
+
describe: (detail: string) => string,
|
|
323
|
+
): Promise<Response> {
|
|
324
|
+
if (callerSignal?.aborted) throw new SlackError("aborted", "Request was cancelled.");
|
|
325
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
326
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
327
|
+
try {
|
|
328
|
+
return await fetch(url, { ...init, signal });
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (callerSignal?.aborted) throw new SlackError("aborted", "Request was cancelled.");
|
|
331
|
+
const detail = timeoutSignal.aborted ? `timed out after ${timeoutMs}ms` : (err as Error).message;
|
|
332
|
+
throw new SlackError("transport", describe(detail));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function makeApiCall(
|
|
337
|
+
baseUrl: string = "https://slack.com",
|
|
338
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void> = defaultSleep,
|
|
339
|
+
timeoutMs: number = REQUEST_TIMEOUT_MS,
|
|
340
|
+
): ApiCall {
|
|
341
|
+
async function performCall(
|
|
342
|
+
method: string,
|
|
343
|
+
token: string,
|
|
344
|
+
params: Record<string, unknown>,
|
|
345
|
+
opts: ApiOpts,
|
|
346
|
+
isRetry: boolean,
|
|
347
|
+
): Promise<Record<string, unknown>> {
|
|
348
|
+
const { url, init } = buildRequest(baseUrl, method, token, params);
|
|
349
|
+
const res = await fetchOrTransportError(
|
|
350
|
+
url,
|
|
351
|
+
init,
|
|
352
|
+
opts.signal,
|
|
353
|
+
timeoutMs,
|
|
354
|
+
(detail) => `Slack API request to ${method} failed: ${detail}`,
|
|
355
|
+
);
|
|
356
|
+
if (res.status === 429) {
|
|
357
|
+
const rawRetryAfter = Number(res.headers.get("retry-after") ?? String(RETRY_AFTER_DEFAULT_S));
|
|
358
|
+
const retryAfterSec = Number.isFinite(rawRetryAfter) && rawRetryAfter > 0 ? rawRetryAfter : RETRY_AFTER_DEFAULT_S;
|
|
359
|
+
if (opts.retry && !isRetry) {
|
|
360
|
+
const waitMs = Math.min(retryAfterSec * 1000, MAX_RETRY_AFTER_MS);
|
|
361
|
+
await sleep(waitMs, opts.signal);
|
|
362
|
+
return performCall(method, token, params, opts, true);
|
|
363
|
+
}
|
|
364
|
+
throw new SlackError("rate_limited", `Slack rate-limited ${method}; retry after ${retryAfterSec}s.`);
|
|
365
|
+
}
|
|
366
|
+
if (!res.ok) {
|
|
367
|
+
throw new SlackError(`http_${res.status}`, `Slack API ${method} returned HTTP ${res.status}.`);
|
|
368
|
+
}
|
|
369
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
370
|
+
if (data.ok === false) throw mapSlackApiError(data);
|
|
371
|
+
return data;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return (method, token, params, opts = {}) => performCall(method, token, params, opts, false);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const defaultApiCall: ApiCall = makeApiCall();
|
|
378
|
+
|
|
379
|
+
export const defaultUploadBytes: UploadBytes = async (url, bytes, opts) => {
|
|
380
|
+
const res = await fetchOrTransportError(
|
|
381
|
+
url,
|
|
382
|
+
{ method: "POST", body: bytes as BodyInit },
|
|
383
|
+
opts?.signal,
|
|
384
|
+
REQUEST_TIMEOUT_MS,
|
|
385
|
+
(detail) => `Upload request failed: ${detail}`,
|
|
386
|
+
);
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
throw new SlackError(`http_${res.status}`, `Upload returned HTTP ${res.status}.`);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Size gating for slack_search / slack_thread output: same thresholds as
|
|
394
|
+
* fetch-core, defined locally to avoid a cross-module dependency. Output
|
|
395
|
+
* under both caps is returned inline; over either cap, the full text is
|
|
396
|
+
* written once to a timestamped+hashed file under tmpdir()/pi-slack (never
|
|
397
|
+
* deleted by the tool) and a bounded preview + the file path is returned.
|
|
398
|
+
*/
|
|
399
|
+
|
|
400
|
+
export const INLINE_MAX_BYTES = 32_000;
|
|
401
|
+
export const INLINE_MAX_LINES = 1_000;
|
|
402
|
+
export const PREVIEW_MAX_LINES = 60;
|
|
403
|
+
export const PREVIEW_MAX_BYTES = 4_000;
|
|
404
|
+
|
|
405
|
+
export function gateOutput(text: string, slug: string): { output: string; spilled: boolean; path?: string } {
|
|
406
|
+
const byteLength = Buffer.byteLength(text, "utf8");
|
|
407
|
+
const lineCount = text === "" ? 1 : text.split("\n").length;
|
|
408
|
+
if (byteLength <= INLINE_MAX_BYTES && lineCount <= INLINE_MAX_LINES) {
|
|
409
|
+
return { output: text, spilled: false };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const hash = createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
413
|
+
const dir = join(tmpdir(), "pi-slack");
|
|
414
|
+
mkdirSync(dir, { recursive: true });
|
|
415
|
+
const path = join(dir, `${Date.now()}-${hash}-${slug}.md`);
|
|
416
|
+
writeFileSync(path, text, "utf8");
|
|
417
|
+
|
|
418
|
+
let preview = text.split("\n").slice(0, PREVIEW_MAX_LINES).join("\n");
|
|
419
|
+
if (Buffer.byteLength(preview, "utf8") > PREVIEW_MAX_BYTES) {
|
|
420
|
+
const truncated = Buffer.from(preview, "utf8").subarray(0, PREVIEW_MAX_BYTES).toString("utf8");
|
|
421
|
+
// A byte-boundary cut can land mid multi-byte char, which toString("utf8") replaces with
|
|
422
|
+
// U+FFFD - strip a trailing one so the preview never ends on a mangled character.
|
|
423
|
+
preview = truncated.endsWith("\uFFFD") ? truncated.slice(0, -1) : truncated;
|
|
424
|
+
}
|
|
425
|
+
const output = `${preview}\n\n[truncated - full output written to ${path}]`;
|
|
426
|
+
return { output, spilled: true, path };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* slack_search / slack_thread data plane. Both tools accept only a user
|
|
431
|
+
* token (search.messages is a user-token-only method; thread reads follow
|
|
432
|
+
* the same identity for consistency) and always pass {retry: false} - the
|
|
433
|
+
* generic 429-retry-once policy (lib-level, opts.retry) is for mutations;
|
|
434
|
+
* search/thread reads surface the throttle instead of spinning.
|
|
435
|
+
*/
|
|
436
|
+
|
|
437
|
+
export interface CoreDeps {
|
|
438
|
+
apiCall: ApiCall;
|
|
439
|
+
token: string;
|
|
440
|
+
signal?: AbortSignal;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export const THREAD_PAGE_CAP = 50;
|
|
444
|
+
export const THREAD_MESSAGE_CAP = 5_000;
|
|
445
|
+
|
|
446
|
+
export interface SearchResult {
|
|
447
|
+
output: string;
|
|
448
|
+
spilled: boolean;
|
|
449
|
+
path?: string;
|
|
450
|
+
total: number;
|
|
451
|
+
page: number;
|
|
452
|
+
pageCount: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface ThreadResult {
|
|
456
|
+
output: string;
|
|
457
|
+
spilled: boolean;
|
|
458
|
+
path?: string;
|
|
459
|
+
complete: boolean;
|
|
460
|
+
nextCursor?: string;
|
|
461
|
+
caveat?: string;
|
|
462
|
+
messageCount: number;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const THREAD_THROTTLE_CAVEAT =
|
|
466
|
+
"Since 2025-05-29, conversations.replies is rate limited to ~1 request/minute with limit capped at 15 for apps that are neither Marketplace-listed nor classified internal.";
|
|
467
|
+
|
|
468
|
+
export function parsePermalink(url: string): { channel: string; ts: string } | undefined {
|
|
469
|
+
const match = /\/archives\/([A-Za-z0-9]+)\/p(\d+)/.exec(url);
|
|
470
|
+
if (!match) return undefined;
|
|
471
|
+
const [, channel, digits] = match;
|
|
472
|
+
if (digits.length <= 6) return undefined;
|
|
473
|
+
return { channel, ts: `${digits.slice(0, -6)}.${digits.slice(-6)}` };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function renderSearchLine(match: Record<string, unknown>): string {
|
|
477
|
+
const author = typeof match.username === "string" ? match.username : String(match.user ?? "unknown");
|
|
478
|
+
const channelObj = match.channel as Record<string, unknown> | undefined;
|
|
479
|
+
const channelName = typeof channelObj?.name === "string" ? channelObj.name : String(channelObj?.id ?? "unknown");
|
|
480
|
+
const ts = String(match.ts ?? "");
|
|
481
|
+
const permalink = String(match.permalink ?? "");
|
|
482
|
+
const text = typeof match.text === "string" ? match.text.replace(/\r?\n/g, " ") : "";
|
|
483
|
+
return `${author} | #${channelName} | ${ts} | ${permalink} | ${text}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function searchMessages(
|
|
487
|
+
args: { query: string; count?: number; page?: number },
|
|
488
|
+
deps: CoreDeps,
|
|
489
|
+
): Promise<SearchResult> {
|
|
490
|
+
const count = Math.min(Math.max(Math.trunc(args.count ?? 20), 1), 100);
|
|
491
|
+
const params: Record<string, unknown> = { query: args.query, count };
|
|
492
|
+
if (args.page !== undefined) params.page = args.page;
|
|
493
|
+
|
|
494
|
+
const data = await deps.apiCall("search.messages", deps.token, params, { retry: false, signal: deps.signal });
|
|
495
|
+
const messagesBlock = (data.messages as Record<string, unknown> | undefined) ?? {};
|
|
496
|
+
const matches = (messagesBlock.matches as Record<string, unknown>[] | undefined) ?? [];
|
|
497
|
+
const paging = (messagesBlock.paging as Record<string, unknown> | undefined) ?? {};
|
|
498
|
+
const total = typeof paging.total === "number" ? paging.total : matches.length;
|
|
499
|
+
const page = typeof paging.page === "number" ? paging.page : (args.page ?? 1);
|
|
500
|
+
const pageCount = typeof paging.pages === "number" ? paging.pages : 1;
|
|
501
|
+
|
|
502
|
+
const rendered = matches.map(renderSearchLine).join("\n");
|
|
503
|
+
const gated = gateOutput(rendered, "search");
|
|
504
|
+
return { ...gated, total, page, pageCount };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function renderThreadLine(message: Record<string, unknown>): string {
|
|
508
|
+
const author = typeof message.user === "string" ? message.user : String(message.username ?? "unknown");
|
|
509
|
+
const ts = String(message.ts ?? "");
|
|
510
|
+
const text = typeof message.text === "string" ? message.text.replace(/\r?\n/g, " ") : "";
|
|
511
|
+
return `${author} | ${ts} | ${text}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function readThread(
|
|
515
|
+
args: { channel?: string; ts?: string; permalink?: string; cursor?: string },
|
|
516
|
+
deps: CoreDeps,
|
|
517
|
+
): Promise<ThreadResult> {
|
|
518
|
+
let channel = args.channel;
|
|
519
|
+
let ts = args.ts;
|
|
520
|
+
if (args.permalink !== undefined) {
|
|
521
|
+
const parsed = parsePermalink(args.permalink);
|
|
522
|
+
if (!parsed) throw new SlackError("invalid_permalink", "Could not parse a channel/ts pair out of that Slack permalink.");
|
|
523
|
+
channel = parsed.channel;
|
|
524
|
+
ts = parsed.ts;
|
|
525
|
+
}
|
|
526
|
+
if (!channel || !ts) {
|
|
527
|
+
throw new SlackError("invalid_args", "slack_thread requires either channel+ts or a permalink.");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const messages: Record<string, unknown>[] = [];
|
|
531
|
+
let cursor: string | undefined = args.cursor;
|
|
532
|
+
let complete = false;
|
|
533
|
+
let nextCursor: string | undefined;
|
|
534
|
+
let caveat: string | undefined;
|
|
535
|
+
|
|
536
|
+
for (let page = 1; page <= THREAD_PAGE_CAP; page++) {
|
|
537
|
+
let data: Record<string, unknown>;
|
|
538
|
+
try {
|
|
539
|
+
data = await deps.apiCall(
|
|
540
|
+
"conversations.replies",
|
|
541
|
+
deps.token,
|
|
542
|
+
{ channel, ts, cursor },
|
|
543
|
+
{ retry: false, signal: deps.signal },
|
|
544
|
+
);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
if (err instanceof SlackError && err.code === "rate_limited") {
|
|
547
|
+
nextCursor = cursor;
|
|
548
|
+
caveat = THREAD_THROTTLE_CAVEAT;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const batch = (data.messages as Record<string, unknown>[] | undefined) ?? [];
|
|
555
|
+
messages.push(...batch);
|
|
556
|
+
const hasMore = data.has_more === true;
|
|
557
|
+
const meta = data.response_metadata as Record<string, unknown> | undefined;
|
|
558
|
+
const fetchedCursor = typeof meta?.next_cursor === "string" && meta.next_cursor !== "" ? meta.next_cursor : undefined;
|
|
559
|
+
|
|
560
|
+
if (messages.length > THREAD_MESSAGE_CAP) {
|
|
561
|
+
// Hard 5,000-message bound: slice off this page's overshoot so messages.length never
|
|
562
|
+
// exceeds THREAD_MESSAGE_CAP. Resuming means refetching this same page from `cursor`
|
|
563
|
+
// (its start) - the sliced-off remainder came from this page, so a resume re-fetch will
|
|
564
|
+
// duplicate the already-emitted messages, which is acceptable per the cap contract.
|
|
565
|
+
messages.length = THREAD_MESSAGE_CAP;
|
|
566
|
+
nextCursor = cursor;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!hasMore) {
|
|
571
|
+
complete = true;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
if (messages.length >= THREAD_MESSAGE_CAP || page === THREAD_PAGE_CAP) {
|
|
575
|
+
nextCursor = fetchedCursor;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
cursor = fetchedCursor;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const rendered = messages.map(renderThreadLine).join("\n");
|
|
582
|
+
const gated = gateOutput(rendered, "thread");
|
|
583
|
+
return { ...gated, complete, nextCursor, caveat, messageCount: messages.length };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* slack_post_plain / slack_update / slack_delete / slack_pin / slack_upload
|
|
588
|
+
* data plane. Mutation success is authoritative: a chat.getPermalink
|
|
589
|
+
* failure never fails the call, it degrades to `warning` on the result.
|
|
590
|
+
* All mutating calls pass {retry: true} (429 gets one retry); the
|
|
591
|
+
* decorative getPermalink lookup passes {retry: false} - it is never worth
|
|
592
|
+
* a sleep.
|
|
593
|
+
*/
|
|
594
|
+
|
|
595
|
+
export interface MutationResult {
|
|
596
|
+
channel: string;
|
|
597
|
+
ts?: string;
|
|
598
|
+
permalink?: string;
|
|
599
|
+
warning?: string;
|
|
600
|
+
// Set only when an upload-fallback path posted a threaded intro reply (deliverDetailUpload) -
|
|
601
|
+
// its own message coordinate, distinct from the headline/reply `ts` above.
|
|
602
|
+
detailTs?: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Deliberately separate from SlackConfig.uploadThresholdChars (doc/specs/2026-08-29-gh-7-slack-extension.md
|
|
606
|
+
// Announce protocol): this is a fixed hard error for plain text/headline; uploadThresholdChars is a
|
|
607
|
+
// configurable cutoff that only gates thread_body -> upload in the announce protocol.
|
|
608
|
+
export const MAX_TEXT_LENGTH = 4_000;
|
|
609
|
+
|
|
610
|
+
function assertTextWithinLimit(text: string | undefined): void {
|
|
611
|
+
if (text !== undefined && text.length > MAX_TEXT_LENGTH) {
|
|
612
|
+
throw new SlackError(
|
|
613
|
+
"text_too_long",
|
|
614
|
+
`text is ${text.length} UTF-16 code units, over the ${MAX_TEXT_LENGTH} limit; use thread_body for announcements or slack_upload for documents.`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function requireResponseString(data: Record<string, unknown>, method: string, field: string): string {
|
|
620
|
+
const value = data[field];
|
|
621
|
+
if (typeof value !== "string" || value === "") {
|
|
622
|
+
throw new SlackError("unexpected_response", `${method} returned an unexpected response: missing "${field}".`);
|
|
623
|
+
}
|
|
624
|
+
return value;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function withPermalink(deps: CoreDeps, channel: string, ts: string): Promise<{ permalink?: string; warning?: string }> {
|
|
628
|
+
try {
|
|
629
|
+
const data = await deps.apiCall("chat.getPermalink", deps.token, { channel, message_ts: ts }, { retry: false, signal: deps.signal });
|
|
630
|
+
return { permalink: typeof data.permalink === "string" ? data.permalink : undefined };
|
|
631
|
+
} catch (err) {
|
|
632
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
633
|
+
return { warning: `Could not fetch a permalink for this message: ${detail}` };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function postPlain(
|
|
638
|
+
args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string },
|
|
639
|
+
deps: CoreDeps,
|
|
640
|
+
): Promise<MutationResult> {
|
|
641
|
+
assertTextWithinLimit(args.text);
|
|
642
|
+
|
|
643
|
+
const params: Record<string, unknown> = { channel: args.channel };
|
|
644
|
+
if (args.text !== undefined) params.text = args.text;
|
|
645
|
+
if (args.blocks !== undefined) params.blocks = args.blocks;
|
|
646
|
+
if (args.thread_ts !== undefined) params.thread_ts = args.thread_ts;
|
|
647
|
+
|
|
648
|
+
const data = await deps.apiCall("chat.postMessage", deps.token, params, { retry: true, signal: deps.signal });
|
|
649
|
+
const channel = typeof data.channel === "string" ? data.channel : args.channel;
|
|
650
|
+
const ts = requireResponseString(data, "chat.postMessage", "ts");
|
|
651
|
+
const { permalink, warning } = await withPermalink(deps, channel, ts);
|
|
652
|
+
return { channel, ts, permalink, warning };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function updateMessage(
|
|
656
|
+
args: { channel: string; ts: string; text?: string; blocks?: unknown[] },
|
|
657
|
+
deps: CoreDeps,
|
|
658
|
+
): Promise<MutationResult> {
|
|
659
|
+
assertTextWithinLimit(args.text);
|
|
660
|
+
|
|
661
|
+
const params: Record<string, unknown> = { channel: args.channel, ts: args.ts };
|
|
662
|
+
if (args.text !== undefined) params.text = args.text;
|
|
663
|
+
if (args.blocks !== undefined) params.blocks = args.blocks;
|
|
664
|
+
|
|
665
|
+
const data = await deps.apiCall("chat.update", deps.token, params, { retry: true, signal: deps.signal });
|
|
666
|
+
const channel = typeof data.channel === "string" ? data.channel : args.channel;
|
|
667
|
+
const ts = requireResponseString(data, "chat.update", "ts");
|
|
668
|
+
const { permalink, warning } = await withPermalink(deps, channel, ts);
|
|
669
|
+
return { channel, ts, permalink, warning };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export async function deleteMessage(args: { channel: string; ts: string }, deps: CoreDeps): Promise<MutationResult> {
|
|
673
|
+
await deps.apiCall("chat.delete", deps.token, { channel: args.channel, ts: args.ts }, { retry: true, signal: deps.signal });
|
|
674
|
+
return { channel: args.channel, ts: args.ts };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function pinMessage(args: { channel: string; ts: string }, deps: CoreDeps): Promise<MutationResult> {
|
|
678
|
+
await deps.apiCall("pins.add", deps.token, { channel: args.channel, timestamp: args.ts }, { retry: true, signal: deps.signal });
|
|
679
|
+
const { permalink, warning } = await withPermalink(deps, args.channel, args.ts);
|
|
680
|
+
return { channel: args.channel, ts: args.ts, permalink, warning };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function uploadFile(
|
|
684
|
+
args: { channel: string; bytes: Uint8Array; filename: string; title?: string; thread_ts?: string; initial_comment?: string },
|
|
685
|
+
deps: CoreDeps & { uploadBytes: UploadBytes },
|
|
686
|
+
): Promise<MutationResult & { fileId: string }> {
|
|
687
|
+
const urlData = await deps.apiCall(
|
|
688
|
+
"files.getUploadURLExternal",
|
|
689
|
+
deps.token,
|
|
690
|
+
{ filename: args.filename, length: args.bytes.length },
|
|
691
|
+
{ retry: true, signal: deps.signal },
|
|
692
|
+
);
|
|
693
|
+
const uploadUrl = requireResponseString(urlData, "files.getUploadURLExternal", "upload_url");
|
|
694
|
+
const fileId = requireResponseString(urlData, "files.getUploadURLExternal", "file_id");
|
|
695
|
+
|
|
696
|
+
await deps.uploadBytes(uploadUrl, args.bytes, { signal: deps.signal });
|
|
697
|
+
|
|
698
|
+
const fileEntry: Record<string, unknown> = { id: fileId };
|
|
699
|
+
if (args.title !== undefined) fileEntry.title = args.title;
|
|
700
|
+
|
|
701
|
+
const completeParams: Record<string, unknown> = {
|
|
702
|
+
files: JSON.stringify([fileEntry]),
|
|
703
|
+
channel_id: args.channel,
|
|
704
|
+
};
|
|
705
|
+
if (args.thread_ts !== undefined) completeParams.thread_ts = args.thread_ts;
|
|
706
|
+
if (args.initial_comment !== undefined) completeParams.initial_comment = args.initial_comment;
|
|
707
|
+
|
|
708
|
+
// One-shot per spec ("the complete call is one-shot"): retry:false, unlike getUploadURLExternal
|
|
709
|
+
// above, which is idempotent and safe to retry.
|
|
710
|
+
const completeData = await deps.apiCall("files.completeUploadExternal", deps.token, completeParams, {
|
|
711
|
+
retry: false,
|
|
712
|
+
signal: deps.signal,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// permalink is not reliably present on completeUploadExternal's response - degrade to a warning
|
|
716
|
+
// like withPermalink does, rather than requireResponseString (which would hard-fail the call).
|
|
717
|
+
const completedFiles = completeData.files as Record<string, unknown>[] | undefined;
|
|
718
|
+
const permalink = typeof completedFiles?.[0]?.permalink === "string" ? (completedFiles[0].permalink as string) : undefined;
|
|
719
|
+
const warning = permalink === undefined ? "Could not find a permalink on the completed upload response." : undefined;
|
|
720
|
+
|
|
721
|
+
return { channel: args.channel, fileId, permalink, warning };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Announce protocol (doc/specs/2026-08-29-gh-7-slack-extension.md § Announce protocol):
|
|
726
|
+
* `slack_post` with `thread_body` and no `thread_ts` posts a headline plus its detail as a
|
|
727
|
+
* threaded reply in one call. The headline posts exactly once - `retry: false`, because a
|
|
728
|
+
* transport failure after send leaves the outcome genuinely unknown and a retry could double-post
|
|
729
|
+
* a notification. The detail leg gets the transport's own single Retry-After retry (`retry:
|
|
730
|
+
* true`); if it still fails, the headline is edited with a frozen "pending" marker and the detail
|
|
731
|
+
* is persisted to disk so nothing is lost. Recovery (`thread_ts` supplied) never re-enters this
|
|
732
|
+
* function - it is a plain threaded reply, so a second headline can never reach the transport.
|
|
733
|
+
*/
|
|
734
|
+
|
|
735
|
+
export const DETAIL_PENDING_MARKER = " _(detail pending)_";
|
|
736
|
+
export const DETAIL_INTRO = "Detail attached.";
|
|
737
|
+
export const DETAIL_FILENAME = "slack-detail.md";
|
|
738
|
+
|
|
739
|
+
export function linkCollapsedLength(text: string): number {
|
|
740
|
+
return text.replace(/<([^|>]+)\|([^>]+)>/g, "$2").replace(/<[^>]+>/g, "x").length;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function persistDetail(body: string): string {
|
|
744
|
+
const dir = join(tmpdir(), "pi-slack");
|
|
745
|
+
mkdirSync(dir, { recursive: true });
|
|
746
|
+
const hash = createHash("sha256").update(body).digest("hex").slice(0, 8);
|
|
747
|
+
// Same-millisecond re-invocation with identical content hashes to the same filename and
|
|
748
|
+
// overwrites with byte-identical bytes - harmless, so no collision handling is needed here.
|
|
749
|
+
const path = join(dir, `${Date.now()}-${hash}-detail.md`);
|
|
750
|
+
writeFileSync(path, body, "utf8");
|
|
751
|
+
return path;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function assertHeadline(text: string): void {
|
|
755
|
+
if (text.length === 0) {
|
|
756
|
+
throw new SlackError("invalid_headline", "text is empty; announce requires a non-empty single-line headline.");
|
|
757
|
+
}
|
|
758
|
+
if (/\r?\n/.test(text)) {
|
|
759
|
+
throw new SlackError("invalid_headline", "text must be a single line for an announce headline; use thread_body for the detail.");
|
|
760
|
+
}
|
|
761
|
+
assertTextWithinLimit(text);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export interface AnnounceResult extends MutationResult {
|
|
765
|
+
detailTs?: string;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function deliverDetailUpload(
|
|
769
|
+
channel: string,
|
|
770
|
+
threadTs: string,
|
|
771
|
+
body: string,
|
|
772
|
+
deps: CoreDeps & { uploadBytes: UploadBytes },
|
|
773
|
+
): Promise<{ detailTs?: string }> {
|
|
774
|
+
const introData = await deps.apiCall(
|
|
775
|
+
"chat.postMessage",
|
|
776
|
+
deps.token,
|
|
777
|
+
{ channel, text: DETAIL_INTRO, thread_ts: threadTs },
|
|
778
|
+
{ retry: true, signal: deps.signal },
|
|
779
|
+
);
|
|
780
|
+
const detailTs = typeof introData.ts === "string" ? introData.ts : undefined;
|
|
781
|
+
const bytes = new TextEncoder().encode(body);
|
|
782
|
+
await uploadFile({ channel, bytes, filename: DETAIL_FILENAME, thread_ts: threadTs }, deps);
|
|
783
|
+
return { detailTs };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function capitalize(s: string): string {
|
|
787
|
+
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* postMessage's plain threaded-reply leg has no headline to mark pending, so on upload failure it
|
|
792
|
+
* just persists the reply body (F2-guarded) and rethrows a structured detail_failed SlackError.
|
|
793
|
+
*/
|
|
794
|
+
async function deliverDetailUploadOrPersist(
|
|
795
|
+
channel: string,
|
|
796
|
+
threadTs: string,
|
|
797
|
+
body: string,
|
|
798
|
+
deps: CoreDeps & { uploadBytes: UploadBytes; persist?: (body: string) => string },
|
|
799
|
+
): Promise<{ detailTs?: string }> {
|
|
800
|
+
try {
|
|
801
|
+
return await deliverDetailUpload(channel, threadTs, body, deps);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
804
|
+
const { detailPath, note } = persistOrDescribe(body, deps.persist ?? persistDetail);
|
|
805
|
+
throw new SlackError(
|
|
806
|
+
"detail_failed",
|
|
807
|
+
`Threaded reply upload to ${channel} (thread_ts ${threadTs}) failed (${causeMessage}). ${capitalize(note)}.`,
|
|
808
|
+
{ channel, thread_ts: threadTs, ...(detailPath ? { detailPath } : {}) },
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* F2 guard: persistDetail (or an injected replacement) can itself throw (disk full, permissions,
|
|
815
|
+
* ...). Never let that mask the primary failure - degrade to a message that states persistence
|
|
816
|
+
* also failed and the (now unrecoverable) detail body's length, and omit detailPath from the
|
|
817
|
+
* caller's structured error data.
|
|
818
|
+
*/
|
|
819
|
+
function persistOrDescribe(body: string, persist: (body: string) => string): { detailPath?: string; note: string } {
|
|
820
|
+
try {
|
|
821
|
+
const detailPath = persist(body);
|
|
822
|
+
return { detailPath, note: `the full detail was saved to ${detailPath}` };
|
|
823
|
+
} catch (err) {
|
|
824
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
825
|
+
return {
|
|
826
|
+
note: `persisting the ${body.length}-char detail body ALSO failed (${msg}) - the detail is unrecoverable`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* F1: single recovery path for every detail-delivery failure (direct threaded post, oversized
|
|
833
|
+
* upload, msg_too_long fallback upload) - persists the detail (F2-guarded), best-effort edits the
|
|
834
|
+
* headline with the "detail pending" marker, and throws a structured detail_failed SlackError.
|
|
835
|
+
* Always throws; declared Promise<never> so callers can `return recoverFromDetailFailure(...)`
|
|
836
|
+
* and keep TS's definite-assignment analysis happy across the try/catch that precedes it.
|
|
837
|
+
*/
|
|
838
|
+
async function recoverFromDetailFailure(
|
|
839
|
+
causeMessage: string,
|
|
840
|
+
headlineText: string,
|
|
841
|
+
detailBody: string,
|
|
842
|
+
channel: string,
|
|
843
|
+
ts: string,
|
|
844
|
+
permalink: string | undefined,
|
|
845
|
+
deps: CoreDeps,
|
|
846
|
+
persist: (body: string) => string,
|
|
847
|
+
): Promise<never> {
|
|
848
|
+
const { detailPath, note } = persistOrDescribe(detailBody, persist);
|
|
849
|
+
|
|
850
|
+
let markerFailureMessage: string | undefined;
|
|
851
|
+
try {
|
|
852
|
+
await deps.apiCall(
|
|
853
|
+
"chat.update",
|
|
854
|
+
deps.token,
|
|
855
|
+
{ channel, ts, text: `${headlineText}${DETAIL_PENDING_MARKER}` },
|
|
856
|
+
{ retry: true, signal: deps.signal },
|
|
857
|
+
);
|
|
858
|
+
} catch (markerErr) {
|
|
859
|
+
markerFailureMessage = markerErr instanceof Error ? markerErr.message : String(markerErr);
|
|
860
|
+
}
|
|
861
|
+
const markerSuffix = markerFailureMessage
|
|
862
|
+
? ` The headline marker edit also failed: ${markerFailureMessage}.`
|
|
863
|
+
: "";
|
|
864
|
+
|
|
865
|
+
const data: Record<string, unknown> = { ts, channel, permalink };
|
|
866
|
+
if (detailPath) data.detailPath = detailPath;
|
|
867
|
+
|
|
868
|
+
throw new SlackError(
|
|
869
|
+
"detail_failed",
|
|
870
|
+
`Detail post to the thread failed (${causeMessage}); the headline was marked "detail pending" and ${note}.${markerSuffix}`,
|
|
871
|
+
data,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export async function announce(
|
|
876
|
+
args: { channel: string; text: string; thread_body: string },
|
|
877
|
+
deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
|
|
878
|
+
): Promise<AnnounceResult> {
|
|
879
|
+
assertHeadline(args.text);
|
|
880
|
+
const persist = deps.persist ?? persistDetail;
|
|
881
|
+
|
|
882
|
+
let headlineData: Record<string, unknown>;
|
|
883
|
+
try {
|
|
884
|
+
headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text }, { retry: false, signal: deps.signal });
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (err instanceof SlackError && err.code === "transport") {
|
|
887
|
+
const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
|
|
888
|
+
throw new SlackError(
|
|
889
|
+
"outcome_unknown",
|
|
890
|
+
`Headline post to ${args.channel} may or may not have reached Slack (${err.message}); check the channel before re-invoking slack_post. ${capitalize(note)}.`,
|
|
891
|
+
{ channel: args.channel, ...(detailPath ? { detailPath } : {}) },
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
throw err;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const channel = typeof headlineData.channel === "string" ? headlineData.channel : args.channel;
|
|
898
|
+
|
|
899
|
+
// F3: chat.postMessage can return ok:true with a malformed/missing ts. That is indistinguishable
|
|
900
|
+
// from "never posted" to a naive caller, inviting a duplicate re-invoke of a real channel post.
|
|
901
|
+
// Treat it as "accepted, response unparseable" instead: never re-invoke, persist the detail, and
|
|
902
|
+
// tell the operator to thread it manually.
|
|
903
|
+
let ts: string;
|
|
904
|
+
try {
|
|
905
|
+
ts = requireResponseString(headlineData, "chat.postMessage", "ts");
|
|
906
|
+
} catch (err) {
|
|
907
|
+
const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
|
|
908
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
909
|
+
throw new SlackError(
|
|
910
|
+
"outcome_unknown",
|
|
911
|
+
`Headline post to ${channel} WAS accepted by Slack (ok:true) but the response was unparseable (${detail}); do NOT re-invoke slack_post - thread the detail manually. ${capitalize(note)}.`,
|
|
912
|
+
{ channel, ...(detailPath ? { detailPath } : {}) },
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const { permalink, warning } = await withPermalink(deps, channel, ts);
|
|
917
|
+
|
|
918
|
+
if (linkCollapsedLength(args.thread_body) > deps.thresholdChars) {
|
|
919
|
+
let detailTs: string | undefined;
|
|
920
|
+
try {
|
|
921
|
+
({ detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps));
|
|
922
|
+
} catch (err) {
|
|
923
|
+
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
924
|
+
return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
|
|
925
|
+
}
|
|
926
|
+
return { channel, ts, permalink, warning, detailTs };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let detailData: Record<string, unknown>;
|
|
930
|
+
try {
|
|
931
|
+
detailData = await deps.apiCall(
|
|
932
|
+
"chat.postMessage",
|
|
933
|
+
deps.token,
|
|
934
|
+
{ channel, text: args.thread_body, thread_ts: ts },
|
|
935
|
+
{ retry: true, signal: deps.signal },
|
|
936
|
+
);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
if (err instanceof SlackError && err.code === "msg_too_long") {
|
|
939
|
+
try {
|
|
940
|
+
const { detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps);
|
|
941
|
+
return { channel, ts, permalink, warning, detailTs };
|
|
942
|
+
} catch (uploadErr) {
|
|
943
|
+
const causeMessage = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
|
|
944
|
+
return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
948
|
+
return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const detailTs = requireResponseString(detailData, "chat.postMessage", "ts");
|
|
952
|
+
return { channel, ts, permalink, warning, detailTs };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export async function postMessage(
|
|
956
|
+
args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string; thread_body?: string },
|
|
957
|
+
deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
|
|
958
|
+
): Promise<MutationResult | AnnounceResult> {
|
|
959
|
+
if (args.thread_body !== undefined && args.thread_ts === undefined) {
|
|
960
|
+
return announce({ channel: args.channel, text: args.text ?? "", thread_body: args.thread_body }, deps);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (args.thread_ts !== undefined) {
|
|
964
|
+
// Caller-supplied blocks bypass upload-fallback logic entirely (spec: Announce protocol) -
|
|
965
|
+
// the threshold/upload path only applies when composing plain mrkdwn from thread_body/text.
|
|
966
|
+
if (args.blocks !== undefined) {
|
|
967
|
+
return postPlain(
|
|
968
|
+
{ channel: args.channel, text: args.thread_body ?? args.text, blocks: args.blocks, thread_ts: args.thread_ts },
|
|
969
|
+
deps,
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const body = args.thread_body ?? args.text ?? "";
|
|
974
|
+
if (linkCollapsedLength(body) > deps.thresholdChars) {
|
|
975
|
+
const { detailTs } = await deliverDetailUploadOrPersist(args.channel, args.thread_ts, body, deps);
|
|
976
|
+
return { channel: args.channel, ts: args.thread_ts, detailTs };
|
|
977
|
+
}
|
|
978
|
+
// F4: the collapsed-length gate above can still admit a link-heavy body whose raw length
|
|
979
|
+
// trips postPlain's hard MAX_TEXT_LENGTH assert (client-side) or Slack's own msg_too_long
|
|
980
|
+
// (server-side). This body is extension-composed detail, same as announce's detail leg, so
|
|
981
|
+
// it gets the same upload fallback instead of a bare throw.
|
|
982
|
+
try {
|
|
983
|
+
return await postPlain({ channel: args.channel, text: body, thread_ts: args.thread_ts }, deps);
|
|
984
|
+
} catch (err) {
|
|
985
|
+
if (err instanceof SlackError && (err.code === "text_too_long" || err.code === "msg_too_long")) {
|
|
986
|
+
const { detailTs } = await deliverDetailUploadOrPersist(args.channel, args.thread_ts, body, deps);
|
|
987
|
+
return { channel: args.channel, ts: args.thread_ts, detailTs };
|
|
988
|
+
}
|
|
989
|
+
throw err;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return postPlain({ channel: args.channel, text: args.text, blocks: args.blocks }, deps);
|
|
994
|
+
}
|