slack-term 1.7.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/ts/slack.ts ADDED
@@ -0,0 +1,493 @@
1
+ // Slack Web API client (user token, Authorization: Bearer)
2
+
3
+ export type Json =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | Json[]
9
+ | { [k: string]: Json };
10
+
11
+ function base(): string {
12
+ return (process.env.SLACK_API_BASE ?? "https://slack.com/api").replace(/\/$/, "");
13
+ }
14
+
15
+ async function call(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
16
+ const extraHeaders: Record<string, string> = {};
17
+ if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
18
+ const res = await fetch(`${base()}/${method}`, {
19
+ ...init,
20
+ headers: {
21
+ ...(init.headers ?? {}),
22
+ Authorization: `Bearer ${token}`,
23
+ ...extraHeaders,
24
+ },
25
+ });
26
+ const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
27
+ if (body.ok !== true) {
28
+ const err = body.error ?? "unknown";
29
+ if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
30
+ throw new Error(
31
+ `Desktop app token (xoxc-) is not accepted by the public Slack API.\n` +
32
+ `Replace it with an xoxp- user token:\n` +
33
+ ` slack workspace set-token <name> <xoxp-token>`,
34
+ );
35
+ }
36
+ throw new Error(`Slack error on ${method}: ${err}`);
37
+ }
38
+ return body as Json;
39
+ }
40
+
41
+ // Internal Slack API caller — does NOT reject xoxc tokens.
42
+ // Used for hidden endpoints (drafts, etc.) only accessible via session tokens.
43
+ // cookie: raw xoxd value (without "d=" prefix); injected as Cookie header when present.
44
+ async function callSession(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
45
+ const extraHeaders: Record<string, string> = {};
46
+ if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
47
+ const res = await fetch(`${base()}/${method}`, {
48
+ ...init,
49
+ headers: {
50
+ ...(init.headers ?? {}),
51
+ Authorization: `Bearer ${token}`,
52
+ ...extraHeaders,
53
+ },
54
+ });
55
+ const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
56
+ if (body.ok !== true) {
57
+ const err = body.error ?? "unknown";
58
+ if ((err === "invalid_auth" || err === "not_authed") && !token.startsWith("xoxc-")) {
59
+ throw new Error(
60
+ `The draft API requires a desktop app session token (xoxc-).\n` +
61
+ `Import from Slack desktop: slack workspace import`,
62
+ );
63
+ }
64
+ if ((err === "invalid_auth" || err === "not_authed") && !cookie) {
65
+ throw new Error(
66
+ `drafts.list also requires the xoxd session cookie.\n` +
67
+ `Set it with: slack workspace set-cookie <workspace-name> <xoxd-value>\n` +
68
+ `Get xoxd from browser: DevTools → Application → Cookies → slack.com → d`,
69
+ );
70
+ }
71
+ throw new Error(`Slack error on ${method}: ${err}`);
72
+ }
73
+ return body as Json;
74
+ }
75
+
76
+ function postSession(token: string, method: string, params: Record<string, string> = {}, cookie?: string): Promise<Json> {
77
+ // Internal Slack APIs accept form-encoded body with token field.
78
+ const body = new URLSearchParams({ token, ...params });
79
+ return callSession(token, method, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
82
+ body: body.toString(),
83
+ }, cookie);
84
+ }
85
+
86
+ function get(token: string, method: string, params: Record<string, string>, cookie?: string): Promise<Json> {
87
+ const qs = new URLSearchParams(params).toString();
88
+ return call(token, `${method}?${qs}`, { method: "GET" }, cookie);
89
+ }
90
+
91
+ function post(token: string, method: string, body: Record<string, Json>): Promise<Json> {
92
+ return call(token, method, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify(body),
96
+ });
97
+ }
98
+
99
+ export async function authTest(token: string): Promise<{ team: string; teamId: string; url: string; user: string }> {
100
+ const resp = (await get(token, "auth.test", {})) as {
101
+ team?: string; team_id?: string; url?: string; user?: string;
102
+ };
103
+ return {
104
+ team: resp.team ?? "",
105
+ teamId: resp.team_id ?? "",
106
+ url: resp.url ?? "",
107
+ user: resp.user ?? "",
108
+ };
109
+ }
110
+
111
+ export async function history(token: string, channel: string, limit = 20): Promise<Json> {
112
+ return get(token, "conversations.history", { channel, limit: String(limit) });
113
+ }
114
+
115
+ export async function replies(
116
+ token: string,
117
+ channel: string,
118
+ ts: string,
119
+ limit = 50,
120
+ ): Promise<Json> {
121
+ return get(token, "conversations.replies", { channel, ts, limit: String(limit) });
122
+ }
123
+
124
+ export async function searchPage(
125
+ token: string,
126
+ query: string,
127
+ count: number,
128
+ page: number,
129
+ ): Promise<Json> {
130
+ return get(token, "search.messages", {
131
+ query,
132
+ sort: "timestamp",
133
+ sort_dir: "desc",
134
+ count: String(Math.min(Math.max(count, 1), 100)),
135
+ page: String(Math.max(page, 1)),
136
+ });
137
+ }
138
+
139
+ export async function search(token: string, query: string): Promise<Json> {
140
+ return searchPage(token, query, 100, 1);
141
+ }
142
+
143
+ export async function searchAll(token: string, query: string, max: number): Promise<Json> {
144
+ const perPage = 100;
145
+ let page = 1;
146
+ const all: Json[] = [];
147
+ let last: Json = { ok: true, messages: {} };
148
+ while (true) {
149
+ const resp = await searchPage(token, query, perPage, page);
150
+ const matches = getPath(resp, ["messages", "matches"]);
151
+ const arr = Array.isArray(matches) ? matches : [];
152
+ all.push(...arr);
153
+ const pages = Number(getPath(resp, ["messages", "paging", "pages"]) ?? 1);
154
+ last = resp;
155
+ if (arr.length === 0 || page >= pages || all.length >= max) break;
156
+ page += 1;
157
+ }
158
+ const out = last as { messages?: { matches?: Json } };
159
+ if (!out.messages) out.messages = {};
160
+ out.messages.matches = all.slice(0, max);
161
+ return out as Json;
162
+ }
163
+
164
+ export async function send(
165
+ token: string,
166
+ channel: string,
167
+ text: string,
168
+ threadTs?: string,
169
+ ): Promise<string> {
170
+ const body: Record<string, Json> = {
171
+ channel,
172
+ text,
173
+ blocks: [{ type: "markdown", text }],
174
+ };
175
+ if (threadTs !== undefined) body.thread_ts = threadTs;
176
+ const resp = (await post(token, "chat.postMessage", body)) as { ts?: string };
177
+ return resp.ts ?? "";
178
+ }
179
+
180
+ export async function editMessage(
181
+ token: string,
182
+ channel: string,
183
+ ts: string,
184
+ text: string,
185
+ ): Promise<string> {
186
+ const resp = (await post(token, "chat.update", {
187
+ channel,
188
+ ts,
189
+ text,
190
+ blocks: [{ type: "markdown", text }],
191
+ })) as { ts?: string };
192
+ return resp.ts ?? ts;
193
+ }
194
+
195
+ export async function listConversations(token: string): Promise<Json> {
196
+ const allChannels: Json[] = [];
197
+ let cursor = "";
198
+ while (true) {
199
+ const params: Record<string, string> = { limit: "200", types: "public_channel,private_channel,im,mpim" };
200
+ if (cursor) params.cursor = cursor;
201
+ const resp = (await get(token, "conversations.list", params)) as {
202
+ channels?: Json[];
203
+ response_metadata?: { next_cursor?: string };
204
+ };
205
+ allChannels.push(...(resp.channels ?? []));
206
+ cursor = resp.response_metadata?.next_cursor ?? "";
207
+ if (!cursor) break;
208
+ }
209
+ return { channels: allChannels };
210
+ }
211
+
212
+ export async function openDm(token: string, userId: string): Promise<string> {
213
+ const resp = (await post(token, "conversations.open", { users: userId })) as {
214
+ channel?: { id?: string };
215
+ };
216
+ const id = resp.channel?.id;
217
+ if (!id) throw new Error(`Failed to open DM with user ${userId}`);
218
+ return id;
219
+ }
220
+
221
+ /** Normalize for loose matching: lowercase + strip hyphens/underscores/whitespace.
222
+ * Lets `@deploy-bot` match handles like `deploybot` or display names like `Deploy-Bot`. */
223
+ function normName(s: string): string {
224
+ return s.toLowerCase().replace(/[-_\s]/g, "");
225
+ }
226
+
227
+ /** Parse a Slack permalink, returning channel ID and (optional) message ts.
228
+ * Supports both forms:
229
+ * https://app.slack.com/client/T.../C...[/p1700000000000100]
230
+ * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...]
231
+ */
232
+ export function parseSlackPermalink(s: string): { channel: string; ts?: string } | undefined {
233
+ const m = s.match(
234
+ /(?:app\.slack\.com\/client\/T[A-Za-z0-9]+|[A-Za-z0-9-]+\.slack\.com\/archives)\/([A-Za-z0-9]+)(?:\/p(\d{10})(\d{6}))?/,
235
+ );
236
+ if (!m) return undefined;
237
+ const channel = m[1]!;
238
+ const ts = m[2] && m[3] ? `${m[2]}.${m[3]}` : undefined;
239
+ return ts ? { channel, ts } : { channel };
240
+ }
241
+
242
+ function parseSlackUrl(s: string): string | undefined {
243
+ return parseSlackPermalink(s)?.channel;
244
+ }
245
+
246
+ export async function resolveChannel(token: string, ref: string, cookie?: string): Promise<string> {
247
+ // Accept Slack permalinks directly
248
+ const fromUrl = parseSlackUrl(ref);
249
+ if (fromUrl) return fromUrl;
250
+ // Accept raw IDs (C..., D..., G...) as-is
251
+ if (!ref.startsWith("@") && !ref.startsWith("#")) {
252
+ if (/^[A-Za-z0-9]{9,}$/.test(ref)) return ref;
253
+ throw new Error(`Target must start with # or @ (or be a Slack URL/ID), got: ${ref}`);
254
+ }
255
+ const isIm = ref.startsWith("@");
256
+ const rawName = ref.slice(1);
257
+ const nameNorm = normName(rawName);
258
+
259
+ if (isIm) {
260
+ // Find user ID first via users.list (batch), then locate existing DM.
261
+ let userId = "";
262
+ let userCursor = "";
263
+ while (true) {
264
+ const params: Record<string, string> = { limit: "200" };
265
+ if (userCursor) params.cursor = userCursor;
266
+ const resp = (await get(token, "users.list", params, cookie)) as {
267
+ members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
268
+ response_metadata?: { next_cursor?: string };
269
+ };
270
+ for (const u of resp.members ?? []) {
271
+ const n = normName(u.name ?? "");
272
+ const rn = normName(u.real_name ?? "");
273
+ const dn = normName(u.profile?.display_name ?? "");
274
+ if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) {
275
+ userId = u.id ?? "";
276
+ break;
277
+ }
278
+ }
279
+ if (userId) break;
280
+ userCursor = resp.response_metadata?.next_cursor ?? "";
281
+ if (!userCursor) break;
282
+ }
283
+ if (!userId) throw new Error(`User not found: ${ref}`);
284
+
285
+ // Find an existing DM (avoids needing im:write scope)
286
+ let dmCursor = "";
287
+ while (true) {
288
+ const params: Record<string, string> = { types: "im", limit: "200" };
289
+ if (dmCursor) params.cursor = dmCursor;
290
+ const resp = (await get(token, "conversations.list", params, cookie)) as {
291
+ channels?: Array<{ id?: string; user?: string }>;
292
+ response_metadata?: { next_cursor?: string };
293
+ };
294
+ for (const ch of resp.channels ?? []) {
295
+ if (ch.user === userId) return String(ch.id ?? "");
296
+ }
297
+ dmCursor = resp.response_metadata?.next_cursor ?? "";
298
+ if (!dmCursor) break;
299
+ }
300
+ throw new Error(`No existing DM with ${ref} (${userId}). Open it once in Slack first.`);
301
+ }
302
+
303
+ // Channel lookup
304
+ const lcName = rawName.toLowerCase();
305
+ let cursor = "";
306
+ while (true) {
307
+ const params: Record<string, string> = {
308
+ limit: "200",
309
+ types: "public_channel,private_channel",
310
+ exclude_archived: "true",
311
+ };
312
+ if (cursor) params.cursor = cursor;
313
+ const resp = (await get(token, "conversations.list", params, cookie)) as {
314
+ channels?: Array<Record<string, Json>>;
315
+ response_metadata?: { next_cursor?: string };
316
+ };
317
+ for (const ch of resp.channels ?? []) {
318
+ if (String(ch.name ?? "").toLowerCase() === lcName) {
319
+ return String(ch.id ?? "");
320
+ }
321
+ }
322
+ cursor = resp.response_metadata?.next_cursor ?? "";
323
+ if (!cursor) break;
324
+ }
325
+ throw new Error(`channel not found: ${ref}`);
326
+ }
327
+
328
+ /** Look up both a display label and the `@handle` for a user.
329
+ * Returns `[display_name || real_name || id, name || id]`. */
330
+ export async function userInfoPair(
331
+ token: string,
332
+ userId: string,
333
+ ): Promise<[string, string]> {
334
+ try {
335
+ const resp = (await get(token, "users.info", { user: userId })) as {
336
+ user?: { profile?: { display_name?: string }; real_name?: string; name?: string };
337
+ };
338
+ const display = resp.user?.profile?.display_name;
339
+ const first =
340
+ display && display.length > 0
341
+ ? display
342
+ : (resp.user?.real_name ?? resp.user?.name ?? userId);
343
+ const handle = resp.user?.name ?? userId;
344
+ return [first, handle];
345
+ } catch {
346
+ return [userId, userId];
347
+ }
348
+ }
349
+
350
+ export async function userName(token: string, userId: string): Promise<string> {
351
+ try {
352
+ const resp = (await get(token, "users.info", { user: userId })) as {
353
+ user?: { profile?: { display_name?: string }; real_name?: string; name?: string };
354
+ };
355
+ const display = resp.user?.profile?.display_name;
356
+ if (display && display.length > 0) return display;
357
+ return resp.user?.real_name ?? resp.user?.name ?? userId;
358
+ } catch {
359
+ return userId;
360
+ }
361
+ }
362
+
363
+ // Draft API (internal — requires xoxc session token + xoxd cookie)
364
+ export async function listDrafts(token: string, cookie?: string): Promise<Json> {
365
+ return postSession(token, "drafts.list", {}, cookie);
366
+ }
367
+
368
+ export async function createDraft(
369
+ token: string,
370
+ channelId: string,
371
+ text: string,
372
+ cookie?: string,
373
+ ): Promise<Json> {
374
+ const { randomUUID } = await import("node:crypto");
375
+ const blocks = JSON.stringify([{
376
+ type: "rich_text",
377
+ elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
378
+ }]);
379
+ return postSession(token, "drafts.create", {
380
+ client_msg_id: randomUUID(),
381
+ destinations: JSON.stringify([{ channel_id: channelId }]),
382
+ blocks,
383
+ file_ids: JSON.stringify([]),
384
+ is_from_composer: "true",
385
+ }, cookie);
386
+ }
387
+
388
+ export async function updateDraft(
389
+ token: string,
390
+ draftId: string,
391
+ channelId: string,
392
+ text: string,
393
+ cookie?: string,
394
+ ): Promise<Json> {
395
+ const blocks = JSON.stringify([{
396
+ type: "rich_text",
397
+ elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
398
+ }]);
399
+ // client_last_updated_ts must be the current time (server uses it as new stored ts)
400
+ const nowTs = (Date.now() / 1000).toFixed(6);
401
+ return postSession(token, "drafts.update", {
402
+ draft_id: draftId,
403
+ client_last_updated_ts: nowTs,
404
+ destinations: JSON.stringify([{ channel_id: channelId }]),
405
+ blocks,
406
+ file_ids: JSON.stringify([]),
407
+ }, cookie);
408
+ }
409
+
410
+ // Channel info via session API (works with xoxc + xoxd cookie)
411
+ export async function conversationInfoSession(token: string, channelId: string, cookie?: string): Promise<Json> {
412
+ return postSession(token, "conversations.info", { channel: channelId }, cookie);
413
+ }
414
+
415
+ export async function deleteDraft(token: string, draftId: string, cookie?: string): Promise<Json> {
416
+ const nowTs = (Date.now() / 1000).toFixed(6);
417
+ return postSession(token, "drafts.delete", { draft_id: draftId, client_last_updated_ts: nowTs }, cookie);
418
+ }
419
+
420
+ // auth.test via session API (works with xoxc + xoxd cookie)
421
+ export async function authTestSession(
422
+ token: string,
423
+ cookie?: string,
424
+ ): Promise<{ userId: string; teamId: string }> {
425
+ const resp = (await postSession(token, "auth.test", {}, cookie)) as Record<string, Json>;
426
+ return { userId: String(resp.user_id ?? ""), teamId: String(resp.team_id ?? "") };
427
+ }
428
+
429
+ // File upload via Slack v2 upload API (files.getUploadURLExternal → upload → completeUploadExternal)
430
+ export async function uploadFile(
431
+ token: string,
432
+ channel: string,
433
+ filePath: string,
434
+ opts: { title?: string; threadTs?: string; initialComment?: string } = {},
435
+ ): Promise<{ fileId: string; permalink?: string }> {
436
+ const { statSync, readFileSync } = await import("node:fs");
437
+ const { basename } = await import("node:path");
438
+
439
+ const stat = statSync(filePath);
440
+ const filename = basename(filePath);
441
+
442
+ // Step 1: 外部アップロードURLを取得
443
+ const urlResp = (await get(token, "files.getUploadURLExternal", {
444
+ filename,
445
+ length: String(stat.size),
446
+ })) as { upload_url?: string; file_id?: string };
447
+
448
+ const uploadUrl = urlResp.upload_url;
449
+ const fileId = urlResp.file_id;
450
+ if (!uploadUrl || !fileId) throw new Error("files.getUploadURLExternal returned no upload_url/file_id");
451
+
452
+ // Step 2: ファイルデータを外部URLへ PUT
453
+ const fileData = readFileSync(filePath);
454
+ const putResp = await fetch(uploadUrl, {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/octet-stream" },
457
+ body: fileData,
458
+ });
459
+ if (!putResp.ok) throw new Error(`Upload PUT failed: ${putResp.status} ${putResp.statusText}`);
460
+
461
+ // Step 3: アップロード完了 → チャンネルへ共有
462
+ const completeBody: Record<string, Json> = {
463
+ files: [{ id: fileId, title: opts.title ?? filename }],
464
+ channel_id: channel,
465
+ };
466
+ if (opts.threadTs) completeBody.thread_ts = opts.threadTs;
467
+ if (opts.initialComment) completeBody.initial_comment = opts.initialComment;
468
+
469
+ const completeResp = (await post(token, "files.completeUploadExternal", completeBody)) as {
470
+ files?: Array<{ permalink?: string }>;
471
+ };
472
+
473
+ const result: { fileId: string; permalink?: string } = { fileId };
474
+ const plink = completeResp.files?.[0]?.permalink;
475
+ if (plink) result.permalink = plink;
476
+ return result;
477
+ }
478
+
479
+ // Safe nested access
480
+ export function getPath(obj: Json, path: readonly (string | number)[]): Json | undefined {
481
+ let cur: Json | undefined = obj;
482
+ for (const key of path) {
483
+ if (cur === undefined || cur === null) return undefined;
484
+ if (typeof key === "number") {
485
+ if (!Array.isArray(cur)) return undefined;
486
+ cur = cur[key] ?? undefined;
487
+ } else {
488
+ if (typeof cur !== "object" || Array.isArray(cur)) return undefined;
489
+ cur = (cur as Record<string, Json>)[key];
490
+ }
491
+ }
492
+ return cur;
493
+ }