pi-quiver 4.5.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.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Name->ID cache for the slack extension: one JSON file per Slack workspace
3
+ * (keyed by team ID), holding channel and user name->ID maps. Sits on top of
4
+ * `lib/slack-core.ts`'s ApiCall/SlackError/SlackConfig - stays pi-free like
5
+ * its sibling core modules.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { dirname, isAbsolute, join } from "node:path";
10
+ import type { ApiCall, SlackConfig } from "./slack-core.ts";
11
+ import { SlackError } from "./slack-core.ts";
12
+
13
+ export interface SlackCacheFile {
14
+ team_id: string;
15
+ channels: Record<string, string>;
16
+ users: Record<string, { id: string; display_name: string; real_name: string }>;
17
+ refreshed_at: string;
18
+ }
19
+
20
+ export interface CacheCtx {
21
+ apiCall: ApiCall;
22
+ token: string;
23
+ filePath: string;
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ const RAW_CHANNEL_ID = /^[CDG][A-Z0-9]{5,}$/;
28
+ const RAW_USER_ID = /^[UW][A-Z0-9]{5,}$/;
29
+
30
+ /** Hard cap on cursor-following pagination loops - a runaway/misbehaving API must fail loudly, never spin or write a partial cache. */
31
+ export const MAX_LIST_PAGES = 100;
32
+
33
+ /** Same per-OS convention as `lib/doc-to-md-core.ts`'s `cacheDir`, defined locally per spec. */
34
+ export function userCacheDir(platform: NodeJS.Platform, env: Record<string, string | undefined>, home: string): string {
35
+ if (platform === "win32") return join(env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "pi-quiver");
36
+ if (platform === "darwin") return join(home, "Library", "Caches", "pi-quiver");
37
+ return join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "pi-quiver");
38
+ }
39
+
40
+ export function cacheFilePath(
41
+ cfg: SlackConfig,
42
+ repoRoot: string,
43
+ teamId: string,
44
+ dirs?: { platform: NodeJS.Platform; env: Record<string, string | undefined>; home: string },
45
+ ): string {
46
+ if (cfg.cachePath) {
47
+ return isAbsolute(cfg.cachePath) ? cfg.cachePath : join(repoRoot, cfg.cachePath);
48
+ }
49
+ const dir = dirs
50
+ ? userCacheDir(dirs.platform, dirs.env, dirs.home)
51
+ : userCacheDir(process.platform, process.env, process.env.HOME ?? "");
52
+ return join(dir, `slack-${teamId}.json`);
53
+ }
54
+
55
+ const teamIdCache = new Map<string, string>();
56
+
57
+ export async function teamIdFor(token: string, apiCall: ApiCall, signal?: AbortSignal): Promise<string> {
58
+ const cached = teamIdCache.get(token);
59
+ if (cached !== undefined) return cached;
60
+ const data = await apiCall("auth.test", token, {}, { retry: false, signal });
61
+ const teamId = data.team_id as string;
62
+ teamIdCache.set(token, teamId);
63
+ return teamId;
64
+ }
65
+
66
+ export function assertSameTeam(a: string | undefined, b: string | undefined): void {
67
+ if (a !== undefined && b !== undefined && a !== b) {
68
+ throw new SlackError("team_mismatch", `User and bot tokens belong to different workspaces (${a} vs ${b}).`);
69
+ }
70
+ }
71
+
72
+ function readCacheFile(filePath: string): SlackCacheFile | undefined {
73
+ try {
74
+ return JSON.parse(readFileSync(filePath, "utf8")) as SlackCacheFile;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ function emptyCache(teamId: string): SlackCacheFile {
81
+ return { team_id: teamId, channels: {}, users: {}, refreshed_at: new Date().toISOString() };
82
+ }
83
+
84
+ function atomicWrite(filePath: string, data: SlackCacheFile): void {
85
+ mkdirSync(dirname(filePath), { recursive: true });
86
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
87
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
88
+ renameSync(tmpPath, filePath);
89
+ }
90
+
91
+ function mergeAndWrite(filePath: string, teamId: string, patch: (base: SlackCacheFile) => void): void {
92
+ const base = existsSync(filePath) ? (readCacheFile(filePath) ?? emptyCache(teamId)) : emptyCache(teamId);
93
+ patch(base);
94
+ base.refreshed_at = new Date().toISOString();
95
+ atomicWrite(filePath, base);
96
+ }
97
+
98
+ function stripPrefix(input: string): string {
99
+ return input.startsWith("#") || input.startsWith("@") ? input.slice(1) : input;
100
+ }
101
+
102
+ export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
103
+ if (RAW_CHANNEL_ID.test(input)) return input;
104
+ if (input.startsWith("@")) {
105
+ throw new SlackError(
106
+ "invalid_channel",
107
+ `"${input}" looks like a user name, which is not accepted in a channel position (opening a DM is out of scope).`,
108
+ );
109
+ }
110
+ const name = stripPrefix(input);
111
+
112
+ const cached = readCacheFile(ctx.filePath);
113
+ const hit = cached?.channels[name];
114
+ if (hit !== undefined) return hit;
115
+
116
+ let cursor = "";
117
+ for (let page = 1; ; page++) {
118
+ if (page > MAX_LIST_PAGES) {
119
+ throw new SlackError(
120
+ "pagination_overflow",
121
+ `resolveChannel: conversations.list did not terminate within ${MAX_LIST_PAGES} pages.`,
122
+ );
123
+ }
124
+ const data = await ctx.apiCall(
125
+ "conversations.list",
126
+ ctx.token,
127
+ { limit: 1000, types: "public_channel,private_channel", ...(cursor ? { cursor } : {}) },
128
+ { retry: false, signal: ctx.signal },
129
+ );
130
+ const channels = (data.channels as { id: string; name: string }[]) ?? [];
131
+ const match = channels.find((c) => c.name === name);
132
+ if (match) {
133
+ mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
134
+ base.channels[name] = match.id;
135
+ });
136
+ return match.id;
137
+ }
138
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
139
+ const nextCursor = meta?.next_cursor ?? "";
140
+ // Slack can echo the same cursor repeatedly under throttling - treat a repeated non-empty
141
+ // cursor as exhaustion rather than spinning until MAX_LIST_PAGES.
142
+ if (nextCursor !== "" && nextCursor === cursor) break;
143
+ cursor = nextCursor;
144
+ if (!cursor) break;
145
+ }
146
+
147
+ throw new SlackError("name_not_found", `No channel named "${name}" was found in the workspace.`);
148
+ }
149
+
150
+ interface SlackUser {
151
+ id: string;
152
+ name: string;
153
+ profile?: { display_name?: string; real_name?: string };
154
+ real_name?: string;
155
+ }
156
+
157
+ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string> {
158
+ if (RAW_USER_ID.test(input)) return input;
159
+ if (input.startsWith("#")) {
160
+ throw new SlackError(
161
+ "invalid_user",
162
+ `"${input}" looks like a channel name, which is not accepted in a user position.`,
163
+ );
164
+ }
165
+ const name = stripPrefix(input);
166
+
167
+ const cached = readCacheFile(ctx.filePath);
168
+ if (cached) {
169
+ const byUsername = cached.users[name];
170
+ if (byUsername) return byUsername.id;
171
+
172
+ const displayMatches = Object.values(cached.users).filter((u) => u.display_name === name);
173
+ if (displayMatches.length === 1) return displayMatches[0].id;
174
+ if (displayMatches.length > 1) {
175
+ throw new SlackError(
176
+ "ambiguous_user",
177
+ `Multiple users have display name "${name}": ${displayMatches.map((u) => u.id).join(", ")}.`,
178
+ );
179
+ }
180
+
181
+ const realMatches = Object.values(cached.users).filter((u) => u.real_name === name);
182
+ if (realMatches.length === 1) return realMatches[0].id;
183
+ if (realMatches.length > 1) {
184
+ throw new SlackError(
185
+ "ambiguous_user",
186
+ `Multiple users have real name "${name}": ${realMatches.map((u) => u.id).join(", ")}.`,
187
+ );
188
+ }
189
+ }
190
+
191
+ let cursor = "";
192
+ const displayCandidates: SlackUser[] = [];
193
+ const realCandidates: SlackUser[] = [];
194
+ for (let page = 1; ; page++) {
195
+ if (page > MAX_LIST_PAGES) {
196
+ throw new SlackError(
197
+ "pagination_overflow",
198
+ `resolveUser: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
199
+ );
200
+ }
201
+ const data = await ctx.apiCall(
202
+ "users.list",
203
+ ctx.token,
204
+ { limit: 1000, ...(cursor ? { cursor } : {}) },
205
+ { retry: false, signal: ctx.signal },
206
+ );
207
+ const members = (data.members as SlackUser[]) ?? [];
208
+ for (const u of members) {
209
+ if (u.name === name) {
210
+ 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
+ };
216
+ });
217
+ return u.id;
218
+ }
219
+ if (u.profile?.display_name === name) displayCandidates.push(u);
220
+ if ((u.profile?.real_name ?? u.real_name) === name) realCandidates.push(u);
221
+ }
222
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
223
+ const nextCursor = meta?.next_cursor ?? "";
224
+ // Slack can echo the same cursor repeatedly under throttling - treat a repeated non-empty
225
+ // cursor as exhaustion rather than spinning until MAX_LIST_PAGES.
226
+ if (nextCursor !== "" && nextCursor === cursor) break;
227
+ cursor = nextCursor;
228
+ if (!cursor) break;
229
+ }
230
+
231
+ if (displayCandidates.length === 1) {
232
+ const u = displayCandidates[0];
233
+ 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
+ };
239
+ });
240
+ return u.id;
241
+ }
242
+ if (displayCandidates.length > 1) {
243
+ throw new SlackError(
244
+ "ambiguous_user",
245
+ `Multiple users have display name "${name}": ${displayCandidates.map((u) => u.id).join(", ")}.`,
246
+ );
247
+ }
248
+
249
+ if (realCandidates.length === 1) {
250
+ const u = realCandidates[0];
251
+ 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
+ };
257
+ });
258
+ return u.id;
259
+ }
260
+ if (realCandidates.length > 1) {
261
+ throw new SlackError(
262
+ "ambiguous_user",
263
+ `Multiple users have real name "${name}": ${realCandidates.map((u) => u.id).join(", ")}.`,
264
+ );
265
+ }
266
+
267
+ throw new SlackError("name_not_found", `No user named "${name}" was found in the workspace.`);
268
+ }
269
+
270
+ /**
271
+ * Full snapshot replace, deliberately asymmetric with mergeAndWrite: refresh's job is to
272
+ * atomically overwrite the whole file, so a concurrent mergeAndWrite write racing this one may
273
+ * be clobbered (last-writer-wins). Accepted per spec (doc/specs/2026-08-29-gh-7-slack-extension.md
274
+ * Cache section) - a clobbered merge self-heals on the next cache miss.
275
+ */
276
+ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number }> {
277
+ const teamId = await teamIdFor(ctx.token, ctx.apiCall, ctx.signal);
278
+ const channels: Record<string, string> = {};
279
+ const users: Record<string, { id: string; display_name: string; real_name: string }> = {};
280
+
281
+ let cursor = "";
282
+ for (let page = 1; ; page++) {
283
+ if (page > MAX_LIST_PAGES) {
284
+ throw new SlackError(
285
+ "pagination_overflow",
286
+ `refreshCache: conversations.list did not terminate within ${MAX_LIST_PAGES} pages.`,
287
+ );
288
+ }
289
+ const data = await ctx.apiCall(
290
+ "conversations.list",
291
+ ctx.token,
292
+ { limit: 1000, types: "public_channel,private_channel", ...(cursor ? { cursor } : {}) },
293
+ { retry: false, signal: ctx.signal },
294
+ );
295
+ const list = (data.channels as { id: string; name: string }[]) ?? [];
296
+ for (const c of list) channels[c.name] = c.id;
297
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
298
+ const nextCursor = meta?.next_cursor ?? "";
299
+ // A repeated non-empty cursor never terminates via has_more logic - refresh must not write a
300
+ // partial cache, so this is a hard overflow, not silent exhaustion (unlike the scan loops).
301
+ if (nextCursor !== "" && nextCursor === cursor) {
302
+ throw new SlackError(
303
+ "pagination_overflow",
304
+ `refreshCache: conversations.list returned the same cursor twice (${nextCursor}); aborting without writing a partial cache.`,
305
+ );
306
+ }
307
+ cursor = nextCursor;
308
+ if (!cursor) break;
309
+ }
310
+
311
+ cursor = "";
312
+ for (let page = 1; ; page++) {
313
+ if (page > MAX_LIST_PAGES) {
314
+ throw new SlackError(
315
+ "pagination_overflow",
316
+ `refreshCache: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
317
+ );
318
+ }
319
+ const data = await ctx.apiCall("users.list", ctx.token, { limit: 1000, ...(cursor ? { cursor } : {}) }, { retry: false, signal: ctx.signal });
320
+ const members = (data.members as SlackUser[]) ?? [];
321
+ for (const u of members) {
322
+ users[u.name] = { id: u.id, display_name: u.profile?.display_name ?? "", real_name: u.profile?.real_name ?? u.real_name ?? "" };
323
+ }
324
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
325
+ const nextCursor = meta?.next_cursor ?? "";
326
+ if (nextCursor !== "" && nextCursor === cursor) {
327
+ throw new SlackError(
328
+ "pagination_overflow",
329
+ `refreshCache: users.list returned the same cursor twice (${nextCursor}); aborting without writing a partial cache.`,
330
+ );
331
+ }
332
+ cursor = nextCursor;
333
+ if (!cursor) break;
334
+ }
335
+
336
+ atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: new Date().toISOString() });
337
+ return { channels: Object.keys(channels).length, users: Object.keys(users).length };
338
+ }