pi-quiver 5.0.0 → 5.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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/extensions/slack.ts +116 -14
- package/lib/slack-cache.ts +222 -23
- package/lib/slack-core.ts +104 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
|
|
|
8
8
|
via OIDC trusted publishing. The release helper at
|
|
9
9
|
`.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
|
|
10
10
|
|
|
11
|
+
## v5.1.0 - 2026-09-01
|
|
12
|
+
|
|
13
|
+
- `slack`: optional `policyPath` config injects a repo policy file into the system prompt every turn (`<slack-policy source="...">`); a missing/unreadable/empty file degrades to a `status=` block plus one deduped warning, tools stay fully usable either way (#9). `slack_post`/`slack_update` now resolve `@name` mentions to `<@U...>` (cache-first, one batched `users.list` live pass), leaving unresolvable names literal and reported via `unresolved mentions: ...` plus `details.unresolvedMentions`. The name cache gained an optional per-user `email` field and a file-level `snapshot_at` marker (set only by a full `slack_cache_refresh`, gating whether an alias match can be trusted straight from cache); `slack_cache_refresh`'s result line now reports an email/user ratio with a missing-scope hint. `slack_post` gained `unfurl_links`/`unfurl_media` params, applied to the headline and inline detail leg (never the upload stub), omitted when unset so Slack's default stands; `slack_update` has no equivalent (`chat.update` has no unfurl argument). See [doc/slack.md](doc/slack.md).
|
|
14
|
+
|
|
11
15
|
## v5.0.0 - 2026-08-29
|
|
12
16
|
|
|
13
17
|
- **New opt-in `slack` extension** (#7): eight `slack_*` tools (search, thread, post, update, delete, pin, upload, cache refresh) for context-safe Slack search/threads/posting. Dual `user`/`bot` token identities resolved per call from process env or the repo's `.env`, never cross-identity fallback. Workspace-keyed channel/user name->ID cache with a repo-overridable `cachePath`. Fetch-style output size gating on search/thread reads. `slack_post`'s `thread_body` (no `thread_ts`) posts a transactional headline+thread announce - oversized detail bodies upload as a file - with a documented recovery path on delivery failure. OFF by default; nested-only `quiver.slack` config, no legacy flat form. See [doc/slack.md](doc/slack.md).
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of
|
|
|
18
18
|
|
|
19
19
|
`fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
|
|
20
20
|
|
|
21
|
-
`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting.
|
|
21
|
+
`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting with repo-policy injection, `@name` mention resolution, cached emails, and per-call unfurl control.
|
|
22
22
|
|
|
23
23
|
## Part of the pi agent toolkit
|
|
24
24
|
|
package/extensions/slack.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { basename, isAbsolute, join } from "node:path";
|
|
|
16
16
|
import {
|
|
17
17
|
defaultApiCall,
|
|
18
18
|
defaultUploadBytes,
|
|
19
|
+
buildPolicyBlock,
|
|
19
20
|
discoverRepoRoot,
|
|
20
21
|
resolveSlackConfig,
|
|
21
22
|
resolveToken,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
deleteMessage,
|
|
27
28
|
pinMessage,
|
|
28
29
|
uploadFile,
|
|
30
|
+
formatUnresolvedSuffix,
|
|
29
31
|
SlackError,
|
|
30
32
|
type SlackConfig,
|
|
31
33
|
type CoreDeps,
|
|
@@ -33,8 +35,9 @@ import {
|
|
|
33
35
|
type AnnounceResult,
|
|
34
36
|
type SearchResult,
|
|
35
37
|
type ThreadResult,
|
|
38
|
+
type UnresolvedMention,
|
|
36
39
|
} from "../lib/slack-core.ts";
|
|
37
|
-
import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
|
|
40
|
+
import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
|
|
38
41
|
|
|
39
42
|
const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
|
|
40
43
|
description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which token env var is used and whose name shows as the author.',
|
|
@@ -139,6 +142,19 @@ export function channelLine(result: MutationResult | (MutationResult & { fileId:
|
|
|
139
142
|
return parts.join(" | ");
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
/** Shared by slack_post/slack_update: the channelLine + unresolved-mentions suffix result shape. */
|
|
146
|
+
function mentionAwareResult(
|
|
147
|
+
result: MutationResult | AnnounceResult,
|
|
148
|
+
mentions: { unresolved: UnresolvedMention[]; lookupError?: string },
|
|
149
|
+
opts: { detailUploaded?: boolean } = {},
|
|
150
|
+
) {
|
|
151
|
+
const suffix = formatUnresolvedSuffix(mentions.unresolved, { lookupError: mentions.lookupError, ...opts });
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text" as const, text: suffix ? `${channelLine(result)} | ${suffix}` : channelLine(result) }],
|
|
154
|
+
details: { ...result, ...(mentions.unresolved.length > 0 ? { unresolvedMentions: mentions.unresolved } : {}) },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
export function searchResultText(result: SearchResult): string {
|
|
143
159
|
return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
|
|
144
160
|
}
|
|
@@ -191,6 +207,36 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
191
207
|
|
|
192
208
|
const repoRoot = discoverRepoRoot(ctx.cwd);
|
|
193
209
|
|
|
210
|
+
const policyPath = cfg.policyPath;
|
|
211
|
+
if (policyPath !== undefined) {
|
|
212
|
+
const resolvedPolicyPath = isAbsolute(policyPath) ? policyPath : join(repoRoot, policyPath);
|
|
213
|
+
let policyWarned = false;
|
|
214
|
+
const warnOnce = (eventCtx: typeof ctx, message: string): void => {
|
|
215
|
+
if (policyWarned) return;
|
|
216
|
+
policyWarned = true;
|
|
217
|
+
if (eventCtx.hasUI) eventCtx.ui.notify(message, "warning");
|
|
218
|
+
else console.warn(message);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
pi.on("before_agent_start", async (event, eventCtx) => {
|
|
222
|
+
let block: string;
|
|
223
|
+
try {
|
|
224
|
+
const body = readFileSync(resolvedPolicyPath, "utf8");
|
|
225
|
+
if (body.trim() === "") {
|
|
226
|
+
warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} is empty; posting policy is unknown this session.`);
|
|
227
|
+
block = buildPolicyBlock({ source: policyPath, status: "empty" });
|
|
228
|
+
} else {
|
|
229
|
+
block = buildPolicyBlock({ source: policyPath, status: "ok", body });
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const code = (err as { code?: string }).code ?? (err instanceof Error ? err.message : String(err));
|
|
233
|
+
warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} could not be read (${code}); posting policy is unknown this session.`);
|
|
234
|
+
block = buildPolicyBlock({ source: policyPath, status: "unreadable", code });
|
|
235
|
+
}
|
|
236
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
pi.registerTool({
|
|
195
241
|
name: "slack_search",
|
|
196
242
|
label: "Slack Search",
|
|
@@ -248,7 +294,7 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
248
294
|
label: "Slack Post",
|
|
249
295
|
promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
|
|
250
296
|
description:
|
|
251
|
-
"Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path.",
|
|
297
|
+
"Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path. `unfurl_links`/`unfurl_media` apply to this post only, are omitted when unset (Slack's default stands), and slack_update cannot change unfurling after the fact.",
|
|
252
298
|
parameters: Type.Object({
|
|
253
299
|
as: IDENTITY,
|
|
254
300
|
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
@@ -256,20 +302,69 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
256
302
|
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
257
303
|
thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
|
|
258
304
|
thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
|
|
305
|
+
unfurl_links: Type.Optional(
|
|
306
|
+
Type.Boolean({ description: "Slack unfurls link previews by default; pass false to suppress text-link previews for this message." }),
|
|
307
|
+
),
|
|
308
|
+
unfurl_media: Type.Optional(
|
|
309
|
+
Type.Boolean({ description: "Pass false to suppress image/video previews for this message." }),
|
|
310
|
+
),
|
|
259
311
|
}),
|
|
260
312
|
async execute(_toolCallId, params, signal) {
|
|
261
313
|
return guarded(async () => {
|
|
262
314
|
assertNoMarkdownText(params);
|
|
263
315
|
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
264
316
|
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
317
|
+
|
|
318
|
+
// Only the fields core will actually send: announce uses text + thread_body (both
|
|
319
|
+
// scanned), a threaded reply collapses to thread_body ?? text (whichever param carried
|
|
320
|
+
// the body is the one core reads, so substitute into that same field - never the
|
|
321
|
+
// other), a plain post is text alone.
|
|
322
|
+
const isAnnounce = params.thread_body !== undefined && params.thread_ts === undefined;
|
|
323
|
+
const isReply = params.thread_ts !== undefined;
|
|
324
|
+
let text: string | undefined;
|
|
325
|
+
let threadBody: string | undefined;
|
|
326
|
+
let mentions: { unresolved: UnresolvedMention[]; lookupError?: string };
|
|
327
|
+
if (isAnnounce) {
|
|
328
|
+
const resolved = await resolveMentions(
|
|
329
|
+
[
|
|
330
|
+
{ field: "text", value: params.text ?? "" },
|
|
331
|
+
{ field: "thread_body", value: params.thread_body ?? "" },
|
|
332
|
+
],
|
|
333
|
+
cacheCtx,
|
|
334
|
+
);
|
|
335
|
+
mentions = resolved;
|
|
336
|
+
text = resolved.values[0];
|
|
337
|
+
threadBody = resolved.values[1];
|
|
338
|
+
} else if (isReply) {
|
|
339
|
+
const replyField: "text" | "thread_body" = params.thread_body !== undefined ? "thread_body" : "text";
|
|
340
|
+
const resolved = await resolveMentions([{ field: replyField, value: params.thread_body ?? params.text ?? "" }], cacheCtx);
|
|
341
|
+
mentions = resolved;
|
|
342
|
+
if (replyField === "thread_body") {
|
|
343
|
+
text = params.text;
|
|
344
|
+
threadBody = resolved.values[0];
|
|
345
|
+
} else {
|
|
346
|
+
text = params.text === undefined ? undefined : resolved.values[0];
|
|
347
|
+
threadBody = undefined;
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
const resolved = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
|
|
351
|
+
mentions = resolved;
|
|
352
|
+
text = params.text === undefined ? undefined : resolved.values[0];
|
|
353
|
+
}
|
|
354
|
+
|
|
265
355
|
const result = await postMessage(
|
|
266
|
-
{
|
|
356
|
+
{
|
|
357
|
+
channel,
|
|
358
|
+
text,
|
|
359
|
+
blocks: params.blocks,
|
|
360
|
+
thread_ts: params.thread_ts,
|
|
361
|
+
thread_body: threadBody,
|
|
362
|
+
unfurl_links: params.unfurl_links,
|
|
363
|
+
unfurl_media: params.unfurl_media,
|
|
364
|
+
},
|
|
267
365
|
{ ...deps, thresholdChars: cfg.uploadThresholdChars, uploadBytes: defaultUploadBytes },
|
|
268
366
|
);
|
|
269
|
-
return {
|
|
270
|
-
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
271
|
-
details: result,
|
|
272
|
-
};
|
|
367
|
+
return mentionAwareResult(result, mentions, { detailUploaded: "detailUploaded" in result && result.detailUploaded === true });
|
|
273
368
|
}, params.as);
|
|
274
369
|
},
|
|
275
370
|
renderCall: (args, theme) => oneLine(theme, "slack_post", `as:${args.as} ${args.channel}`),
|
|
@@ -294,11 +389,12 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
294
389
|
assertNoMarkdownText(params);
|
|
295
390
|
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
296
391
|
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
392
|
+
const mentions = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
|
|
393
|
+
const result = await updateMessage(
|
|
394
|
+
{ channel, ts: params.ts, text: params.text === undefined ? undefined : mentions.values[0], blocks: params.blocks },
|
|
395
|
+
deps,
|
|
396
|
+
);
|
|
397
|
+
return mentionAwareResult(result, mentions);
|
|
302
398
|
}, params.as);
|
|
303
399
|
},
|
|
304
400
|
renderCall: (args, theme) => oneLine(theme, "slack_update", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
@@ -410,15 +506,21 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
410
506
|
label: "Slack Cache Refresh",
|
|
411
507
|
promptSnippet: "Rebuild the Slack channel/user name->ID cache",
|
|
412
508
|
description:
|
|
413
|
-
'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel and user
|
|
509
|
+
'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel, user, and email counts - a low email/user ratio hints the users:read.email scope may be missing.',
|
|
414
510
|
parameters: Type.Object({}),
|
|
415
511
|
async execute(_toolCallId, _params, signal) {
|
|
416
512
|
const identity = pickCacheRefreshIdentity(cfg, process.env, repoRoot);
|
|
417
513
|
return guarded(async () => {
|
|
418
514
|
const { cacheCtx } = await resolveCall(identity, cfg, ctx, signal, repoRoot);
|
|
419
515
|
const result = await refreshCache(cacheCtx);
|
|
516
|
+
const ratio =
|
|
517
|
+
result.users === 0
|
|
518
|
+
? ""
|
|
519
|
+
: result.emails === 0
|
|
520
|
+
? ` | emails: 0/${result.users} (users:read.email scope may be missing)`
|
|
521
|
+
: ` | emails: ${result.emails}/${result.users}`;
|
|
420
522
|
return {
|
|
421
|
-
content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}` }],
|
|
523
|
+
content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}${ratio}` }],
|
|
422
524
|
details: result,
|
|
423
525
|
};
|
|
424
526
|
}, identity);
|
package/lib/slack-cache.ts
CHANGED
|
@@ -7,14 +7,24 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, isAbsolute, join } from "node:path";
|
|
10
|
-
import type { ApiCall, SlackConfig } from "./slack-core.ts";
|
|
10
|
+
import type { ApiCall, SlackConfig, UnresolvedMention } from "./slack-core.ts";
|
|
11
11
|
import { SlackError } from "./slack-core.ts";
|
|
12
12
|
|
|
13
|
+
export interface UserEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
display_name: string;
|
|
16
|
+
real_name: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export interface SlackCacheFile {
|
|
14
21
|
team_id: string;
|
|
15
22
|
channels: Record<string, string>;
|
|
16
|
-
users: Record<string,
|
|
23
|
+
users: Record<string, UserEntry>;
|
|
17
24
|
refreshed_at: string;
|
|
25
|
+
/** Set only by refreshCache's full replacement; presence means this file once held a complete
|
|
26
|
+
* workspace listing, which is the only state where an alias match may be trusted from cache. */
|
|
27
|
+
snapshot_at?: string;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export interface CacheCtx {
|
|
@@ -99,6 +109,16 @@ function stripPrefix(input: string): string {
|
|
|
99
109
|
return input.startsWith("#") || input.startsWith("@") ? input.slice(1) : input;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
function toUserEntry(u: SlackUser): UserEntry {
|
|
113
|
+
const email = u.profile?.email;
|
|
114
|
+
return {
|
|
115
|
+
id: u.id,
|
|
116
|
+
display_name: u.profile?.display_name ?? "",
|
|
117
|
+
real_name: u.profile?.real_name ?? u.real_name ?? "",
|
|
118
|
+
...(email ? { email } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
|
|
103
123
|
if (RAW_CHANNEL_ID.test(input)) return input;
|
|
104
124
|
if (input.startsWith("@")) {
|
|
@@ -150,7 +170,7 @@ export async function resolveChannel(input: string, ctx: CacheCtx): Promise<stri
|
|
|
150
170
|
interface SlackUser {
|
|
151
171
|
id: string;
|
|
152
172
|
name: string;
|
|
153
|
-
profile?: { display_name?: string; real_name?: string };
|
|
173
|
+
profile?: { display_name?: string; real_name?: string; email?: string };
|
|
154
174
|
real_name?: string;
|
|
155
175
|
}
|
|
156
176
|
|
|
@@ -208,11 +228,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
|
|
|
208
228
|
for (const u of members) {
|
|
209
229
|
if (u.name === name) {
|
|
210
230
|
mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
|
|
211
|
-
base.users[name] =
|
|
212
|
-
id: u.id,
|
|
213
|
-
display_name: u.profile?.display_name ?? "",
|
|
214
|
-
real_name: u.profile?.real_name ?? u.real_name ?? "",
|
|
215
|
-
};
|
|
231
|
+
base.users[name] = toUserEntry(u);
|
|
216
232
|
});
|
|
217
233
|
return u.id;
|
|
218
234
|
}
|
|
@@ -231,11 +247,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
|
|
|
231
247
|
if (displayCandidates.length === 1) {
|
|
232
248
|
const u = displayCandidates[0];
|
|
233
249
|
mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
|
|
234
|
-
base.users[u.name] =
|
|
235
|
-
id: u.id,
|
|
236
|
-
display_name: u.profile?.display_name ?? "",
|
|
237
|
-
real_name: u.profile?.real_name ?? u.real_name ?? "",
|
|
238
|
-
};
|
|
250
|
+
base.users[u.name] = toUserEntry(u);
|
|
239
251
|
});
|
|
240
252
|
return u.id;
|
|
241
253
|
}
|
|
@@ -249,11 +261,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
|
|
|
249
261
|
if (realCandidates.length === 1) {
|
|
250
262
|
const u = realCandidates[0];
|
|
251
263
|
mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
|
|
252
|
-
base.users[u.name] =
|
|
253
|
-
id: u.id,
|
|
254
|
-
display_name: u.profile?.display_name ?? "",
|
|
255
|
-
real_name: u.profile?.real_name ?? u.real_name ?? "",
|
|
256
|
-
};
|
|
264
|
+
base.users[u.name] = toUserEntry(u);
|
|
257
265
|
});
|
|
258
266
|
return u.id;
|
|
259
267
|
}
|
|
@@ -267,16 +275,202 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
|
|
|
267
275
|
throw new SlackError("name_not_found", `No user named "${name}" was found in the workspace.`);
|
|
268
276
|
}
|
|
269
277
|
|
|
278
|
+
const MENTION_DENY = new Set(["here", "channel", "everyone"]);
|
|
279
|
+
const MENTION_BOUNDARY = new Set([" ", "\t", "\n", "\r", "(", "[", "*", "_", '"', "'"]);
|
|
280
|
+
// `_` is trimmed here even though it is also a boundary character: it is a legal username
|
|
281
|
+
// character too, so an italic-wrapped mention like `_@alice_` is otherwise unreachable - the
|
|
282
|
+
// trailing `_` must be stripped for the candidate to resolve.
|
|
283
|
+
const MENTION_TRAILING = /[.,;:!?)_]+$/;
|
|
284
|
+
const MENTION_SCAN = /(\\?)@([A-Za-z0-9._-]+)/g;
|
|
285
|
+
|
|
286
|
+
function isBoundary(text: string, atIndex: number): boolean {
|
|
287
|
+
if (atIndex === 0) return true;
|
|
288
|
+
return MENTION_BOUNDARY.has(text[atIndex - 1]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Substitutes `@name` -> `<@U...>` across every field a mutation will actually send.
|
|
293
|
+
* Never throws for an unresolvable name: not-found, ambiguous, and a failed live lookup all
|
|
294
|
+
* leave the text literal and report it, because a mention is prose, not an addressed parameter.
|
|
295
|
+
*/
|
|
296
|
+
export async function resolveMentions(
|
|
297
|
+
fields: { field: "text" | "thread_body"; value: string }[],
|
|
298
|
+
ctx: CacheCtx,
|
|
299
|
+
): Promise<{
|
|
300
|
+
values: string[];
|
|
301
|
+
unresolved: UnresolvedMention[];
|
|
302
|
+
lookupError?: string;
|
|
303
|
+
}> {
|
|
304
|
+
interface Candidate {
|
|
305
|
+
field: "text" | "thread_body";
|
|
306
|
+
start: number;
|
|
307
|
+
end: number;
|
|
308
|
+
escaped: boolean;
|
|
309
|
+
/** [name, endOffsetForThatName] pairs, trimmed variant first so it wins the lookup race. */
|
|
310
|
+
lookups: [string, number][];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const perField: Candidate[][] = fields.map(() => []);
|
|
314
|
+
const wanted = new Set<string>();
|
|
315
|
+
|
|
316
|
+
fields.forEach((f, fi) => {
|
|
317
|
+
for (const m of f.value.matchAll(MENTION_SCAN)) {
|
|
318
|
+
const escaped = m[1] === "\\";
|
|
319
|
+
const at = m.index + m[1].length;
|
|
320
|
+
if (escaped) {
|
|
321
|
+
if (!isBoundary(f.value, m.index)) continue;
|
|
322
|
+
} else if (!isBoundary(f.value, at)) continue;
|
|
323
|
+
|
|
324
|
+
const raw = m[2];
|
|
325
|
+
const fullEnd = m.index + m[0].length;
|
|
326
|
+
const trimmed = raw.replace(MENTION_TRAILING, "");
|
|
327
|
+
// Check the deny list against both forms: `_@channel_`/`@here.` still carry the
|
|
328
|
+
// trailing punctuation stripped below, so a raw-only check misses them.
|
|
329
|
+
if (MENTION_DENY.has(raw) || MENTION_DENY.has(trimmed)) continue;
|
|
330
|
+
// A name that is entirely trailing-punctuation (e.g. `@...`) trims to "" - not a
|
|
331
|
+
// real candidate, so skip it rather than looking up an empty username.
|
|
332
|
+
if (trimmed === "") continue;
|
|
333
|
+
const trimmedEnd = fullEnd - (raw.length - trimmed.length);
|
|
334
|
+
const lookups: [string, number][] =
|
|
335
|
+
trimmed === raw ? [[raw, fullEnd]] : [[trimmed, trimmedEnd], [raw, fullEnd]];
|
|
336
|
+
perField[fi].push({ field: f.field, start: m.index, end: fullEnd, escaped, lookups });
|
|
337
|
+
if (!escaped) for (const [name] of lookups) wanted.add(name);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const cached = readCacheFile(ctx.filePath);
|
|
342
|
+
const resolved = new Map<string, string>();
|
|
343
|
+
const aliasTrusted = cached?.snapshot_at !== undefined;
|
|
344
|
+
// Names an aliasTrusted FULL snapshot already proved ambiguous: a live users.list call
|
|
345
|
+
// cannot un-ambiguate them, so they must not fall into `outstanding`.
|
|
346
|
+
const conclusivelyAmbiguous = new Set<string>();
|
|
347
|
+
|
|
348
|
+
if (cached) {
|
|
349
|
+
for (const name of wanted) {
|
|
350
|
+
const byUsername = cached.users[name];
|
|
351
|
+
if (byUsername) {
|
|
352
|
+
resolved.set(name, byUsername.id);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!aliasTrusted) continue;
|
|
356
|
+
const entries = Object.values(cached.users);
|
|
357
|
+
const display = entries.filter((u) => u.display_name === name);
|
|
358
|
+
if (display.length === 1) {
|
|
359
|
+
resolved.set(name, display[0].id);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (display.length > 1) {
|
|
363
|
+
conclusivelyAmbiguous.add(name);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const real = entries.filter((u) => u.real_name === name);
|
|
367
|
+
if (real.length === 1) resolved.set(name, real[0].id);
|
|
368
|
+
else if (real.length > 1) conclusivelyAmbiguous.add(name);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const outstanding = [...wanted].filter((n) => !resolved.has(n) && !conclusivelyAmbiguous.has(n));
|
|
373
|
+
let lookupError: string | undefined;
|
|
374
|
+
|
|
375
|
+
if (outstanding.length > 0) {
|
|
376
|
+
try {
|
|
377
|
+
const found = new Map<string, SlackUser>();
|
|
378
|
+
const aliasHits = new Map<string, SlackUser[]>();
|
|
379
|
+
let cursor = "";
|
|
380
|
+
for (let page = 1; ; page++) {
|
|
381
|
+
if (page > MAX_LIST_PAGES) {
|
|
382
|
+
throw new SlackError(
|
|
383
|
+
"pagination_overflow",
|
|
384
|
+
`resolveMentions: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const data = await ctx.apiCall(
|
|
388
|
+
"users.list",
|
|
389
|
+
ctx.token,
|
|
390
|
+
{ limit: 1000, ...(cursor ? { cursor } : {}) },
|
|
391
|
+
{ retry: false, signal: ctx.signal },
|
|
392
|
+
);
|
|
393
|
+
for (const u of (data.members as SlackUser[]) ?? []) {
|
|
394
|
+
for (const name of outstanding) {
|
|
395
|
+
if (u.name === name) found.set(name, u);
|
|
396
|
+
else if (u.profile?.display_name === name || (u.profile?.real_name ?? u.real_name) === name) {
|
|
397
|
+
aliasHits.set(name, [...(aliasHits.get(name) ?? []), u]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const meta = data.response_metadata as { next_cursor?: string } | undefined;
|
|
402
|
+
const nextCursor = meta?.next_cursor ?? "";
|
|
403
|
+
if (nextCursor !== "" && nextCursor === cursor) break;
|
|
404
|
+
cursor = nextCursor;
|
|
405
|
+
if (!cursor) break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const writes: SlackUser[] = [];
|
|
409
|
+
for (const name of outstanding) {
|
|
410
|
+
const exact = found.get(name);
|
|
411
|
+
const aliases = aliasHits.get(name) ?? [];
|
|
412
|
+
const pick = exact ?? (aliases.length === 1 ? aliases[0] : undefined);
|
|
413
|
+
if (!pick) continue;
|
|
414
|
+
resolved.set(name, pick.id);
|
|
415
|
+
writes.push(pick);
|
|
416
|
+
}
|
|
417
|
+
if (writes.length > 0) {
|
|
418
|
+
const teamId = cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal));
|
|
419
|
+
mergeAndWrite(ctx.filePath, teamId, (base) => {
|
|
420
|
+
for (const u of writes) base.users[u.name] = toUserEntry(u);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
lookupError = err instanceof Error ? err.message : String(err);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const unresolved: UnresolvedMention[] = [];
|
|
429
|
+
const seenUnresolved = new Set<string>();
|
|
430
|
+
const values = fields.map((f, fi) => {
|
|
431
|
+
let out = "";
|
|
432
|
+
let cursor = 0;
|
|
433
|
+
for (const c of perField[fi]) {
|
|
434
|
+
out += f.value.slice(cursor, c.start);
|
|
435
|
+
const literal = f.value.slice(c.start, c.end);
|
|
436
|
+
if (c.escaped) {
|
|
437
|
+
out += literal.slice(1);
|
|
438
|
+
cursor = c.end;
|
|
439
|
+
} else {
|
|
440
|
+
const hit = c.lookups.find(([n]) => resolved.has(n));
|
|
441
|
+
if (hit !== undefined) {
|
|
442
|
+
out += `<@${resolved.get(hit[0])}>`;
|
|
443
|
+
cursor = hit[1];
|
|
444
|
+
} else {
|
|
445
|
+
out += literal;
|
|
446
|
+
const name = `@${c.lookups[0][0]}`;
|
|
447
|
+
// Documented contract (doc/slack.md): unresolvedMentions is deduplicated by
|
|
448
|
+
// (field, name), so "cc @bob @bob" reports @bob once, not once per occurrence.
|
|
449
|
+
const key = `${c.field}\u0000${name}`;
|
|
450
|
+
if (!seenUnresolved.has(key)) {
|
|
451
|
+
seenUnresolved.add(key);
|
|
452
|
+
unresolved.push({ field: c.field, name });
|
|
453
|
+
}
|
|
454
|
+
cursor = c.end;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return out + f.value.slice(cursor);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return { values, unresolved, ...(lookupError !== undefined ? { lookupError } : {}) };
|
|
462
|
+
}
|
|
463
|
+
|
|
270
464
|
/**
|
|
271
465
|
* Full snapshot replace, deliberately asymmetric with mergeAndWrite: refresh's job is to
|
|
272
466
|
* atomically overwrite the whole file, so a concurrent mergeAndWrite write racing this one may
|
|
273
467
|
* be clobbered (last-writer-wins). Accepted per spec (doc/specs/2026-08-29-gh-7-slack-extension.md
|
|
274
468
|
* Cache section) - a clobbered merge self-heals on the next cache miss.
|
|
275
469
|
*/
|
|
276
|
-
export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number }> {
|
|
470
|
+
export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number; emails: number }> {
|
|
277
471
|
const teamId = await teamIdFor(ctx.token, ctx.apiCall, ctx.signal);
|
|
278
472
|
const channels: Record<string, string> = {};
|
|
279
|
-
const users: Record<string,
|
|
473
|
+
const users: Record<string, UserEntry> = {};
|
|
280
474
|
|
|
281
475
|
let cursor = "";
|
|
282
476
|
for (let page = 1; ; page++) {
|
|
@@ -319,7 +513,7 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
|
|
|
319
513
|
const data = await ctx.apiCall("users.list", ctx.token, { limit: 1000, ...(cursor ? { cursor } : {}) }, { retry: false, signal: ctx.signal });
|
|
320
514
|
const members = (data.members as SlackUser[]) ?? [];
|
|
321
515
|
for (const u of members) {
|
|
322
|
-
users[u.name] =
|
|
516
|
+
users[u.name] = toUserEntry(u);
|
|
323
517
|
}
|
|
324
518
|
const meta = data.response_metadata as { next_cursor?: string } | undefined;
|
|
325
519
|
const nextCursor = meta?.next_cursor ?? "";
|
|
@@ -333,6 +527,11 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
|
|
|
333
527
|
if (!cursor) break;
|
|
334
528
|
}
|
|
335
529
|
|
|
336
|
-
|
|
337
|
-
|
|
530
|
+
const snapshotAt = new Date().toISOString();
|
|
531
|
+
atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: snapshotAt, snapshot_at: snapshotAt });
|
|
532
|
+
return {
|
|
533
|
+
channels: Object.keys(channels).length,
|
|
534
|
+
users: Object.keys(users).length,
|
|
535
|
+
emails: Object.values(users).filter((u) => u.email !== undefined).length,
|
|
536
|
+
};
|
|
338
537
|
}
|
package/lib/slack-core.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { resolveConfig } from "./extension-config.ts";
|
|
|
19
19
|
export interface SlackConfig {
|
|
20
20
|
enabled: boolean;
|
|
21
21
|
cachePath: string | undefined;
|
|
22
|
+
policyPath: string | undefined;
|
|
22
23
|
userTokenEnv: string;
|
|
23
24
|
botTokenEnv: string;
|
|
24
25
|
uploadThresholdChars: number;
|
|
@@ -27,6 +28,7 @@ export interface SlackConfig {
|
|
|
27
28
|
export const DEFAULT_SLACK_CONFIG: SlackConfig = {
|
|
28
29
|
enabled: false,
|
|
29
30
|
cachePath: undefined,
|
|
31
|
+
policyPath: undefined,
|
|
30
32
|
userTokenEnv: "SLACK_USER_TOKEN",
|
|
31
33
|
botTokenEnv: "SLACK_BOT_TOKEN",
|
|
32
34
|
uploadThresholdChars: 4000,
|
|
@@ -49,6 +51,7 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
|
|
|
49
51
|
const patch: Partial<SlackConfig> = {};
|
|
50
52
|
if (typeof o.enabled === "boolean") patch.enabled = o.enabled;
|
|
51
53
|
if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
|
|
54
|
+
if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
|
|
52
55
|
if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
|
|
53
56
|
if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
|
|
54
57
|
if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
|
|
@@ -635,7 +638,14 @@ async function withPermalink(deps: CoreDeps, channel: string, ts: string): Promi
|
|
|
635
638
|
}
|
|
636
639
|
|
|
637
640
|
export async function postPlain(
|
|
638
|
-
args: {
|
|
641
|
+
args: {
|
|
642
|
+
channel: string;
|
|
643
|
+
text?: string;
|
|
644
|
+
blocks?: unknown[];
|
|
645
|
+
thread_ts?: string;
|
|
646
|
+
unfurl_links?: boolean;
|
|
647
|
+
unfurl_media?: boolean;
|
|
648
|
+
},
|
|
639
649
|
deps: CoreDeps,
|
|
640
650
|
): Promise<MutationResult> {
|
|
641
651
|
assertTextWithinLimit(args.text);
|
|
@@ -644,6 +654,8 @@ export async function postPlain(
|
|
|
644
654
|
if (args.text !== undefined) params.text = args.text;
|
|
645
655
|
if (args.blocks !== undefined) params.blocks = args.blocks;
|
|
646
656
|
if (args.thread_ts !== undefined) params.thread_ts = args.thread_ts;
|
|
657
|
+
if (args.unfurl_links !== undefined) params.unfurl_links = args.unfurl_links;
|
|
658
|
+
if (args.unfurl_media !== undefined) params.unfurl_media = args.unfurl_media;
|
|
647
659
|
|
|
648
660
|
const data = await deps.apiCall("chat.postMessage", deps.token, params, { retry: true, signal: deps.signal });
|
|
649
661
|
const channel = typeof data.channel === "string" ? data.channel : args.channel;
|
|
@@ -740,6 +752,49 @@ export function linkCollapsedLength(text: string): number {
|
|
|
740
752
|
return text.replace(/<([^|>]+)\|([^>]+)>/g, "$2").replace(/<[^>]+>/g, "x").length;
|
|
741
753
|
}
|
|
742
754
|
|
|
755
|
+
function escapeAttr(value: string): string {
|
|
756
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Pure: the handler in extensions/slack.ts owns readFileSync and maps the outcome to this input. */
|
|
760
|
+
export function buildPolicyBlock(input: {
|
|
761
|
+
source: string;
|
|
762
|
+
status: "ok" | "unreadable" | "empty";
|
|
763
|
+
body?: string;
|
|
764
|
+
code?: string;
|
|
765
|
+
}): string {
|
|
766
|
+
const source = escapeAttr(input.source);
|
|
767
|
+
if (input.status === "ok") {
|
|
768
|
+
return `<slack-policy source="${source}">\n${input.body ?? ""}</slack-policy>`;
|
|
769
|
+
}
|
|
770
|
+
const reason =
|
|
771
|
+
input.status === "empty"
|
|
772
|
+
? `Configured Slack policy file is empty.`
|
|
773
|
+
: `Configured Slack policy file could not be read (${input.code ?? "unknown error"}).`;
|
|
774
|
+
return `<slack-policy source="${source}" status="${input.status}">\n${reason} Slack tools are available but the repository's posting policy is unknown - ask the operator before posting.\n</slack-policy>`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export interface UnresolvedMention {
|
|
778
|
+
field: "text" | "thread_body";
|
|
779
|
+
name: string;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Pure: the `channelLine` suffix segment for unresolved mentions. Empty string means "append nothing". */
|
|
783
|
+
export function formatUnresolvedSuffix(
|
|
784
|
+
unresolved: UnresolvedMention[],
|
|
785
|
+
opts: { lookupError?: string; detailUploaded?: boolean },
|
|
786
|
+
): string {
|
|
787
|
+
if (unresolved.length === 0) return "";
|
|
788
|
+
const names: string[] = [];
|
|
789
|
+
for (const u of unresolved) if (!names.includes(u.name)) names.push(u.name);
|
|
790
|
+
let suffix = `unresolved mentions: ${names.join(", ")}`;
|
|
791
|
+
if (opts.lookupError !== undefined) suffix += ` (lookup failed: ${opts.lookupError})`;
|
|
792
|
+
if (opts.detailUploaded && unresolved.some((u) => u.field === "thread_body")) {
|
|
793
|
+
suffix += ` (detail uploaded as a file - slack_update cannot repair it; repost to fix)`;
|
|
794
|
+
}
|
|
795
|
+
return suffix;
|
|
796
|
+
}
|
|
797
|
+
|
|
743
798
|
export function persistDetail(body: string): string {
|
|
744
799
|
const dir = join(tmpdir(), "pi-slack");
|
|
745
800
|
mkdirSync(dir, { recursive: true });
|
|
@@ -763,6 +818,11 @@ function assertHeadline(text: string): void {
|
|
|
763
818
|
|
|
764
819
|
export interface AnnounceResult extends MutationResult {
|
|
765
820
|
detailTs?: string;
|
|
821
|
+
// Set only when the detail leg took the file-upload path (deliverDetailUpload) rather than the
|
|
822
|
+
// inline threaded chat.postMessage reply - detailTs alone can't distinguish the two, since both
|
|
823
|
+
// paths set it. slack_update can edit the headline or the "Detail attached." stub, never an
|
|
824
|
+
// uploaded file's contents, so callers need this to know a mention-repair edit won't reach it.
|
|
825
|
+
detailUploaded?: true;
|
|
766
826
|
}
|
|
767
827
|
|
|
768
828
|
async function deliverDetailUpload(
|
|
@@ -874,14 +934,23 @@ async function recoverFromDetailFailure(
|
|
|
874
934
|
|
|
875
935
|
export async function announce(
|
|
876
936
|
args: { channel: string; text: string; thread_body: string },
|
|
877
|
-
deps: CoreDeps & {
|
|
937
|
+
deps: CoreDeps & {
|
|
938
|
+
uploadBytes: UploadBytes;
|
|
939
|
+
thresholdChars: number;
|
|
940
|
+
persist?: (body: string) => string;
|
|
941
|
+
unfurl_links?: boolean;
|
|
942
|
+
unfurl_media?: boolean;
|
|
943
|
+
},
|
|
878
944
|
): Promise<AnnounceResult> {
|
|
879
945
|
assertHeadline(args.text);
|
|
880
946
|
const persist = deps.persist ?? persistDetail;
|
|
947
|
+
const unfurl: Record<string, unknown> = {};
|
|
948
|
+
if (deps.unfurl_links !== undefined) unfurl.unfurl_links = deps.unfurl_links;
|
|
949
|
+
if (deps.unfurl_media !== undefined) unfurl.unfurl_media = deps.unfurl_media;
|
|
881
950
|
|
|
882
951
|
let headlineData: Record<string, unknown>;
|
|
883
952
|
try {
|
|
884
|
-
headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text }, { retry: false, signal: deps.signal });
|
|
953
|
+
headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text, ...unfurl }, { retry: false, signal: deps.signal });
|
|
885
954
|
} catch (err) {
|
|
886
955
|
if (err instanceof SlackError && err.code === "transport") {
|
|
887
956
|
const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
|
|
@@ -923,7 +992,7 @@ export async function announce(
|
|
|
923
992
|
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
924
993
|
return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
|
|
925
994
|
}
|
|
926
|
-
return { channel, ts, permalink, warning, detailTs };
|
|
995
|
+
return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
|
|
927
996
|
}
|
|
928
997
|
|
|
929
998
|
let detailData: Record<string, unknown>;
|
|
@@ -931,14 +1000,14 @@ export async function announce(
|
|
|
931
1000
|
detailData = await deps.apiCall(
|
|
932
1001
|
"chat.postMessage",
|
|
933
1002
|
deps.token,
|
|
934
|
-
{ channel, text: args.thread_body, thread_ts: ts },
|
|
1003
|
+
{ channel, text: args.thread_body, thread_ts: ts, ...unfurl },
|
|
935
1004
|
{ retry: true, signal: deps.signal },
|
|
936
1005
|
);
|
|
937
1006
|
} catch (err) {
|
|
938
1007
|
if (err instanceof SlackError && err.code === "msg_too_long") {
|
|
939
1008
|
try {
|
|
940
1009
|
const { detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps);
|
|
941
|
-
return { channel, ts, permalink, warning, detailTs };
|
|
1010
|
+
return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
|
|
942
1011
|
} catch (uploadErr) {
|
|
943
1012
|
const causeMessage = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
|
|
944
1013
|
return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
|
|
@@ -953,11 +1022,22 @@ export async function announce(
|
|
|
953
1022
|
}
|
|
954
1023
|
|
|
955
1024
|
export async function postMessage(
|
|
956
|
-
args: {
|
|
1025
|
+
args: {
|
|
1026
|
+
channel: string;
|
|
1027
|
+
text?: string;
|
|
1028
|
+
blocks?: unknown[];
|
|
1029
|
+
thread_ts?: string;
|
|
1030
|
+
thread_body?: string;
|
|
1031
|
+
unfurl_links?: boolean;
|
|
1032
|
+
unfurl_media?: boolean;
|
|
1033
|
+
},
|
|
957
1034
|
deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
|
|
958
1035
|
): Promise<MutationResult | AnnounceResult> {
|
|
959
1036
|
if (args.thread_body !== undefined && args.thread_ts === undefined) {
|
|
960
|
-
return announce(
|
|
1037
|
+
return announce(
|
|
1038
|
+
{ channel: args.channel, text: args.text ?? "", thread_body: args.thread_body },
|
|
1039
|
+
{ ...deps, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
|
|
1040
|
+
);
|
|
961
1041
|
}
|
|
962
1042
|
|
|
963
1043
|
if (args.thread_ts !== undefined) {
|
|
@@ -965,7 +1045,14 @@ export async function postMessage(
|
|
|
965
1045
|
// the threshold/upload path only applies when composing plain mrkdwn from thread_body/text.
|
|
966
1046
|
if (args.blocks !== undefined) {
|
|
967
1047
|
return postPlain(
|
|
968
|
-
{
|
|
1048
|
+
{
|
|
1049
|
+
channel: args.channel,
|
|
1050
|
+
text: args.thread_body ?? args.text,
|
|
1051
|
+
blocks: args.blocks,
|
|
1052
|
+
thread_ts: args.thread_ts,
|
|
1053
|
+
unfurl_links: args.unfurl_links,
|
|
1054
|
+
unfurl_media: args.unfurl_media,
|
|
1055
|
+
},
|
|
969
1056
|
deps,
|
|
970
1057
|
);
|
|
971
1058
|
}
|
|
@@ -980,7 +1067,10 @@ export async function postMessage(
|
|
|
980
1067
|
// (server-side). This body is extension-composed detail, same as announce's detail leg, so
|
|
981
1068
|
// it gets the same upload fallback instead of a bare throw.
|
|
982
1069
|
try {
|
|
983
|
-
return await postPlain(
|
|
1070
|
+
return await postPlain(
|
|
1071
|
+
{ channel: args.channel, text: body, thread_ts: args.thread_ts, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
|
|
1072
|
+
deps,
|
|
1073
|
+
);
|
|
984
1074
|
} catch (err) {
|
|
985
1075
|
if (err instanceof SlackError && (err.code === "text_too_long" || err.code === "msg_too_long")) {
|
|
986
1076
|
const { detailTs } = await deliverDetailUploadOrPersist(args.channel, args.thread_ts, body, deps);
|
|
@@ -990,5 +1080,8 @@ export async function postMessage(
|
|
|
990
1080
|
}
|
|
991
1081
|
}
|
|
992
1082
|
|
|
993
|
-
return postPlain(
|
|
1083
|
+
return postPlain(
|
|
1084
|
+
{ channel: args.channel, text: args.text, blocks: args.blocks, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
|
|
1085
|
+
deps,
|
|
1086
|
+
);
|
|
994
1087
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|