slack-term 1.13.0 → 1.15.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 CHANGED
@@ -128,6 +128,43 @@ export async function authTest(token: string): Promise<{ team: string; teamId: s
128
128
  };
129
129
  }
130
130
 
131
+ // auth.test plus the token's granted scopes. Slack returns the granted bot/user
132
+ // scopes in the `X-OAuth-Scopes` response header on every Web API call, which is
133
+ // the only way to learn what a token can do without probing each endpoint.
134
+ export async function authScopes(token: string): Promise<{
135
+ userId: string; botId: string; team: string; url: string; scopes: string[];
136
+ }> {
137
+ const res = await fetch(`${base()}/auth.test`, {
138
+ method: "GET",
139
+ headers: { Authorization: `Bearer ${token}` },
140
+ });
141
+ if (res.status === 429) {
142
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
143
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
144
+ }
145
+ const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
146
+ const body = (await res.json()) as {
147
+ ok?: boolean; error?: string; user_id?: string; bot_id?: string; team?: string; url?: string;
148
+ };
149
+ if (body.ok !== true) throw new Error(`Slack error on auth.test: ${body.error ?? "unknown"}`);
150
+ return {
151
+ userId: body.user_id ?? "",
152
+ botId: body.bot_id ?? "",
153
+ team: body.team ?? "",
154
+ url: body.url ?? "",
155
+ scopes: scopesHeader.split(",").map((s) => s.trim()).filter(Boolean),
156
+ };
157
+ }
158
+
159
+ // Resolve a bot's app_id (and bot user id) from its bot_id — needed to build the
160
+ // app-settings deep link in messaging diagnostics.
161
+ export async function botsInfo(token: string, botId: string): Promise<{ appId: string; userId: string }> {
162
+ const resp = (await get(token, "bots.info", { bot: botId })) as {
163
+ bot?: { app_id?: string; user_id?: string };
164
+ };
165
+ return { appId: resp.bot?.app_id ?? "", userId: resp.bot?.user_id ?? "" };
166
+ }
167
+
131
168
  export async function history(token: string, channel: string, limit = 20, oldest?: string, cursor?: string): Promise<Json> {
132
169
  const params: Record<string, string> = { channel, limit: String(limit) };
133
170
  if (oldest !== undefined) params.oldest = oldest;
@@ -189,6 +226,7 @@ export async function send(
189
226
  channel: string,
190
227
  text: string,
191
228
  threadTs?: string,
229
+ replyBroadcast?: boolean,
192
230
  ): Promise<string> {
193
231
  const body: Record<string, Json> = {
194
232
  channel,
@@ -196,6 +234,9 @@ export async function send(
196
234
  blocks: [{ type: "markdown", text }],
197
235
  };
198
236
  if (threadTs !== undefined) body.thread_ts = threadTs;
237
+ // "Also send to channel": broadcast a threaded reply back to the channel.
238
+ // Only meaningful alongside thread_ts; Slack ignores it on top-level sends.
239
+ if (replyBroadcast && threadTs !== undefined) body.reply_broadcast = true;
199
240
  const resp = (await post(token, "chat.postMessage", body)) as { ts?: string };
200
241
  return resp.ts ?? "";
201
242
  }
@@ -238,6 +279,18 @@ export async function deleteScheduledMessage(
238
279
  });
239
280
  }
240
281
 
282
+ export async function getPermalink(
283
+ token: string,
284
+ channel: string,
285
+ messageTs: string,
286
+ ): Promise<string> {
287
+ const resp = (await get(token, "chat.getPermalink", {
288
+ channel,
289
+ message_ts: messageTs,
290
+ })) as { permalink?: string };
291
+ return resp.permalink ?? "";
292
+ }
293
+
241
294
  export async function editMessage(
242
295
  token: string,
243
296
  channel: string,
@@ -253,6 +306,14 @@ export async function editMessage(
253
306
  return resp.ts ?? ts;
254
307
  }
255
308
 
309
+ export async function deleteMessage(
310
+ token: string,
311
+ channel: string,
312
+ ts: string,
313
+ ): Promise<void> {
314
+ await post(token, "chat.delete", { channel, ts });
315
+ }
316
+
256
317
  export async function listConversations(token: string): Promise<Json> {
257
318
  const allChannels: Json[] = [];
258
319
  let cursor = "";
@@ -285,19 +346,63 @@ function normName(s: string): string {
285
346
  return s.toLowerCase().replace(/[-_\s]/g, "");
286
347
  }
287
348
 
288
- /** Parse a Slack permalink, returning channel ID and (optional) message ts.
349
+ /**
350
+ * Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
351
+ * the lookup runs on a *user* token (which has users:read), then the caller
352
+ * opens the DM with the bot token — so the bot never needs users:read.
353
+ */
354
+ export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
355
+ if (!ref.startsWith("@")) {
356
+ if (/^[UW][A-Za-z0-9]{6,}$/.test(ref)) return ref;
357
+ throw new Error(`Expected @user or a user ID, got: ${ref}`);
358
+ }
359
+ const nameNorm = normName(ref.slice(1));
360
+ const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
361
+ if (nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm) {
362
+ if (!selfInfo.user_id) throw new Error("auth.test did not return user_id");
363
+ return selfInfo.user_id;
364
+ }
365
+ let cursor = "";
366
+ while (true) {
367
+ const params: Record<string, string> = { limit: "200" };
368
+ if (cursor) params.cursor = cursor;
369
+ const resp = (await get(token, "users.list", params, cookie)) as {
370
+ members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
371
+ response_metadata?: { next_cursor?: string };
372
+ };
373
+ for (const u of resp.members ?? []) {
374
+ const n = normName(u.name ?? "");
375
+ const rn = normName(u.real_name ?? "");
376
+ const dn = normName(u.profile?.display_name ?? "");
377
+ if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) return u.id ?? "";
378
+ }
379
+ cursor = resp.response_metadata?.next_cursor ?? "";
380
+ if (!cursor) break;
381
+ }
382
+ throw new Error(`User not found: ${ref}`);
383
+ }
384
+
385
+ /** Parse a Slack permalink, returning channel ID, optional message ts, and optional thread_ts.
289
386
  * Supports both forms:
290
387
  * https://app.slack.com/client/T.../C...[/p1700000000000100]
291
- * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...]
388
+ * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...][&cid=C...]
292
389
  */
293
- export function parseSlackPermalink(s: string): { channel: string; ts?: string } | undefined {
390
+ export function parseSlackPermalink(s: string): { channel: string; ts?: string; threadTs?: string } | undefined {
294
391
  const m = s.match(
295
392
  /(?: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}))?/,
296
393
  );
297
394
  if (!m) return undefined;
298
395
  const channel = m[1]!;
299
396
  const ts = m[2] && m[3] ? `${m[2]}.${m[3]}` : undefined;
300
- return ts ? { channel, ts } : { channel };
397
+ const qsStart = s.indexOf("?");
398
+ const qs = qsStart >= 0 ? new URLSearchParams(s.slice(qsStart)) : undefined;
399
+ const threadTs = qs?.get("thread_ts") ?? undefined;
400
+ const cidFromQs = qs?.get("cid") ?? undefined;
401
+ const resolvedChannel = cidFromQs ?? channel;
402
+ const result: { channel: string; ts?: string; threadTs?: string } = { channel: resolvedChannel };
403
+ if (ts) result.ts = ts;
404
+ if (threadTs) result.threadTs = threadTs;
405
+ return result;
301
406
  }
302
407
 
303
408
  function parseSlackUrl(s: string): string | undefined {
@@ -318,6 +423,21 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
318
423
  const nameNorm = normName(rawName);
319
424
 
320
425
  if (isIm) {
426
+ // Use auth.test to check if @name refers to self (avoids users:read scope requirement).
427
+ // auth.test is always available; users.list requires users:read which xoxc- tokens lack.
428
+ const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
429
+ const isSelf = nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm;
430
+ if (isSelf) {
431
+ const userId = selfInfo.user_id;
432
+ if (!userId) throw new Error("auth.test did not return user_id");
433
+ const dmResp = (await get(token, "conversations.list", { types: "im", limit: "200" }, cookie)) as {
434
+ channels?: Array<{ id?: string; user?: string }>;
435
+ };
436
+ const dm = (dmResp.channels ?? []).find((ch) => ch.user === userId);
437
+ if (dm?.id) return String(dm.id);
438
+ return openDm(token, userId);
439
+ }
440
+
321
441
  // Find user ID first via users.list (batch), then locate existing DM.
322
442
  let userId = "";
323
443
  let userCursor = "";
@@ -562,6 +682,20 @@ export async function uploadFile(
562
682
  return result;
563
683
  }
564
684
 
685
+ // client.boot — internal endpoint that returns a WebSocket URL for RTM
686
+ export async function clientBoot(token: string, cookie: string): Promise<{ wsUrl: string; selfId: string }> {
687
+ const resp = (await postSession(token, "client.boot", {}, cookie)) as Record<string, Json>;
688
+ const wsUrl = typeof resp.url === "string" ? resp.url : undefined;
689
+ if (!wsUrl) {
690
+ const keys = Object.keys(resp).join(", ");
691
+ throw new Error(`client.boot did not return a WebSocket URL. Keys: ${keys}`);
692
+ }
693
+ const self_ = resp.self && typeof resp.self === "object" && !Array.isArray(resp.self)
694
+ ? resp.self as Record<string, Json>
695
+ : {};
696
+ return { wsUrl, selfId: typeof self_.id === "string" ? self_.id : "" };
697
+ }
698
+
565
699
  // Safe nested access
566
700
  export function getPath(obj: Json, path: readonly (string | number)[]): Json | undefined {
567
701
  let cur: Json | undefined = obj;
package/ts/tail.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { history, resolveChannel, userInfoPair, authTest, conversationInfo, RateLimitError, type Json } from "./slack.ts";
1
+ import { history, replies, resolveChannel, parseSlackPermalink, userInfoPair, authTest, conversationInfo, RateLimitError, type Json } from "./slack.ts";
2
2
  import { resolveDateMarkup, resolveMentions } from "./format.ts";
3
+ import { tailRTMImpl } from "./rtm.ts";
3
4
 
4
5
  export function parseSince(s: string): number {
5
6
  const m = s.match(/^(\d+(?:\.\d+)?)(s|m|h|d)$/);
@@ -17,6 +18,7 @@ export function parseSince(s: string): number {
17
18
  export const _internals = {
18
19
  sleep: (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms)),
19
20
  now: (): number => Date.now(),
21
+ tailRTM: tailRTMImpl,
20
22
  };
21
23
 
22
24
  function asRecord(v: Json | undefined): Record<string, Json> {
@@ -27,6 +29,21 @@ function asArray(v: Json | undefined): Json[] {
27
29
  return Array.isArray(v) ? v : [];
28
30
  }
29
31
 
32
+ // Numeric comparison of Slack ts strings ("1700000001.000000"). ts is a
33
+ // per-channel-unique, monotonically increasing message id, so this gives a
34
+ // total order for merging history (top-level) with replies (thread) streams.
35
+ function tsNum(ts: string): number {
36
+ return Number(ts) || 0;
37
+ }
38
+
39
+ // A message is a thread reply (not a root/broadcast) when it carries a
40
+ // thread_ts that differs from its own ts.
41
+ function isThreadReply(m: Record<string, Json>): boolean {
42
+ const ts = typeof m.ts === "string" ? m.ts : "";
43
+ const tts = typeof m.thread_ts === "string" ? m.thread_ts : "";
44
+ return tts !== "" && tts !== ts;
45
+ }
46
+
30
47
  function slackTsToIso(tsRaw: string): string {
31
48
  const [secStr, fracStr = "000000"] = tsRaw.split(".");
32
49
  const d = new Date(Number(secStr) * 1000);
@@ -64,12 +81,18 @@ async function formatTailLine(
64
81
  const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
65
82
  const lines = resolved.split("\n");
66
83
  const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((l) => ` ${l}`).join("\n") : "");
84
+ // Annotate thread replies so a merged channel+thread stream stays readable.
85
+ const mark = isThreadReply(m) ? "↳ " : "";
67
86
  const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
68
- return `${stamp} ${who}: ${body}`;
87
+ return `${stamp} ${mark}${who}: ${body}`;
69
88
  }
70
89
 
71
90
  type PollOpts = {
72
91
  thread?: string;
92
+ // Watch one thread AND the channel's top-level timeline in a single stream.
93
+ // history() never returns non-broadcast thread replies, so we additionally
94
+ // pull conversations.replies(watchThread) each cycle and merge.
95
+ watchThread?: string;
73
96
  me?: boolean;
74
97
  myUserId?: string;
75
98
  };
@@ -82,7 +105,7 @@ export async function pollCycle(
82
105
  seen: Set<string>,
83
106
  cache: Map<string, string>,
84
107
  pageCursor?: string,
85
- ): Promise<{ newCursor: string; lines: string[]; hasMore: boolean; nextPageCursor: string | undefined }> {
108
+ ): Promise<{ newCursor: string; lines: string[]; hasMore: boolean; nextPageCursor: string | undefined; emittedOther: boolean }> {
86
109
  const histResp = asRecord((await history(
87
110
  token,
88
111
  channelId,
@@ -91,17 +114,37 @@ export async function pollCycle(
91
114
  pageCursor,
92
115
  )) as Json);
93
116
  // history returns newest-first; reverse to emit oldest-first
94
- const msgs = asArray(histResp.messages).map(asRecord).reverse();
117
+ const histMsgs = asArray(histResp.messages).map(asRecord).reverse();
95
118
 
96
119
  const meta = asRecord(histResp.response_metadata as Json | undefined);
97
120
  const nextPageCursor = (histResp.has_more === true && typeof meta.next_cursor === "string")
98
121
  ? meta.next_cursor
99
122
  : undefined;
100
123
 
124
+ // --watch-thread: history omits non-broadcast thread replies, so pull the
125
+ // watched thread separately and merge it into the channel timeline. Only on
126
+ // the first (non-paginated) page so we fetch it once per cycle. The parent
127
+ // (ts === watchThread) is also returned by replies() but already lives in
128
+ // history — drop it to avoid double-emit.
129
+ let replyMsgs: Record<string, Json>[] = [];
130
+ if (opts.watchThread && !pageCursor) {
131
+ const repResp = asRecord((await replies(token, channelId, opts.watchThread, 50)) as Json);
132
+ replyMsgs = asArray(repResp.messages).map(asRecord)
133
+ .filter((m) => (typeof m.ts === "string" ? m.ts : "") !== opts.watchThread);
134
+ }
135
+
136
+ // Merge and sort ascending by ts (a total order across both streams).
137
+ type Entry = { m: Record<string, Json>; fromHistory: boolean };
138
+ const entries: Entry[] = [
139
+ ...histMsgs.map((m) => ({ m, fromHistory: true })),
140
+ ...replyMsgs.map((m) => ({ m, fromHistory: false })),
141
+ ].sort((a, b) => tsNum(typeof a.m.ts === "string" ? a.m.ts : "") - tsNum(typeof b.m.ts === "string" ? b.m.ts : ""));
142
+
101
143
  const lines: string[] = [];
102
144
  let newCursor = cursor;
145
+ let emittedOther = false;
103
146
 
104
- for (const m of msgs) {
147
+ for (const { m, fromHistory } of entries) {
105
148
  const ts = typeof m.ts === "string" ? m.ts : "";
106
149
  if (!ts || seen.has(ts)) continue;
107
150
 
@@ -114,6 +157,14 @@ export async function pollCycle(
114
157
  if (parentTs !== opts.thread && ts !== opts.thread) continue;
115
158
  }
116
159
 
160
+ // --watch-thread shows all top-level posts plus the watched thread's
161
+ // replies; drop replies belonging to *other* threads (e.g. a stray
162
+ // reply_broadcast surfaced by history).
163
+ if (opts.watchThread && isThreadReply(m)) {
164
+ const tts = typeof m.thread_ts === "string" ? m.thread_ts : "";
165
+ if (tts !== opts.watchThread) continue;
166
+ }
167
+
117
168
  if (opts.me && opts.myUserId) {
118
169
  const text = typeof m.text === "string" ? m.text : "";
119
170
  if (!text.includes(`<@${opts.myUserId}>`)) continue;
@@ -126,19 +177,44 @@ export async function pollCycle(
126
177
  }
127
178
 
128
179
  lines.push(await formatTailLine(token, m, cache));
129
- newCursor = ts;
180
+ // Advance the history cursor only on top-level messages — thread replies
181
+ // are never returned by history(), so their ts must not move `oldest`.
182
+ if (fromHistory) newCursor = ts;
183
+ // Track whether a message from someone *other than me* arrived, so
184
+ // --exit-on-message can wait for an actual reply (not my own posts).
185
+ const u = typeof m.user === "string" ? m.user : "";
186
+ if (!opts.myUserId || u !== opts.myUserId) emittedOther = true;
130
187
  }
131
188
 
132
- return { newCursor, lines, hasMore: histResp.has_more === true, nextPageCursor };
189
+ return { newCursor, lines, hasMore: histResp.has_more === true, nextPageCursor, emittedOther };
133
190
  }
134
191
 
135
192
  export type TailOpts = {
136
193
  since?: string;
137
194
  thread?: string;
195
+ watchThread?: string;
138
196
  me?: boolean;
139
197
  interval?: number;
198
+ timeout?: string;
199
+ exitOnMessage?: boolean;
200
+ cookie?: string;
201
+ noRtm?: boolean;
140
202
  };
141
203
 
204
+ // Normalize a --watch-thread argument into a Slack ts ("1700000000.000000").
205
+ // Accepts a raw dotted ts, a compact 16-digit ts (no dot), or a permalink.
206
+ export function resolveThreadTs(ref: string): string {
207
+ if (ref.includes("slack.com")) {
208
+ const parsed = parseSlackPermalink(ref);
209
+ const ts = parsed?.threadTs ?? parsed?.ts;
210
+ if (ts) return ts;
211
+ throw new Error(`--watch-thread: could not extract a timestamp from "${ref}"`);
212
+ }
213
+ if (/^\d{10}\.\d{6}$/.test(ref)) return ref;
214
+ if (/^\d{16}$/.test(ref)) return `${ref.slice(0, 10)}.${ref.slice(10)}`;
215
+ throw new Error(`--watch-thread: "${ref}" is not a valid ts or permalink`);
216
+ }
217
+
142
218
  export async function cmdTail(
143
219
  token: string,
144
220
  target: string | undefined,
@@ -161,6 +237,8 @@ export async function cmdTail(
161
237
 
162
238
  const channelId = await resolveChannel(token, target);
163
239
  const interval = opts.interval ?? 60000;
240
+ // Optional auto-stop deadline (e.g. wait at most 30m for a reply).
241
+ const deadline = opts.timeout ? _internals.now() + parseSince(opts.timeout) * 1000 : Infinity;
164
242
  const cache = new Map<string, string>();
165
243
  const seen = new Set<string>();
166
244
 
@@ -206,21 +284,46 @@ export async function cmdTail(
206
284
  }
207
285
 
208
286
  let myUserId: string | undefined;
209
- if (opts.me) {
287
+ if (opts.me || opts.exitOnMessage) {
210
288
  const info = await authTest(token);
211
289
  myUserId = info.userId || undefined;
212
290
  }
213
291
 
292
+ let watchThreadTs: string | undefined;
293
+ if (opts.watchThread !== undefined) {
294
+ try {
295
+ watchThreadTs = resolveThreadTs(opts.watchThread);
296
+ } catch (e: unknown) {
297
+ console.error(e instanceof Error ? e.message : String(e));
298
+ process.exit(1);
299
+ }
300
+ }
301
+
214
302
  const pollOpts: PollOpts = {
215
303
  ...(opts.thread !== undefined ? { thread: opts.thread } : {}),
304
+ ...(watchThreadTs !== undefined ? { watchThread: watchThreadTs } : {}),
216
305
  ...(opts.me !== undefined ? { me: opts.me } : {}),
217
306
  ...(myUserId !== undefined ? { myUserId } : {}),
218
307
  };
219
308
 
309
+ // RTM path: xoxc token + cookie + no backfill requested. Skipped when a
310
+ // deadline or exit-on-message is set so that logic stays in the poll loop.
311
+ if (
312
+ token.startsWith("xoxc-") && opts.cookie !== undefined && opts.noRtm !== true &&
313
+ opts.since === undefined && opts.timeout === undefined && opts.exitOnMessage !== true
314
+ ) {
315
+ await _internals.tailRTM(token, opts.cookie, channelId, pollOpts, seen, cache, signal);
316
+ if (signal?.aborted) return;
317
+ console.error("RTM unavailable — switching to polling.");
318
+ }
319
+
220
320
  let isFirstPoll = true;
221
321
  let lastPollEndTime = _internals.now();
222
322
 
223
323
  while (!signal?.aborted) {
324
+ if (_internals.now() >= deadline) return; // --timeout reached
325
+
326
+ let sawOther = false;
224
327
  try {
225
328
  const now = _internals.now();
226
329
  const elapsed = now - lastPollEndTime;
@@ -232,7 +335,7 @@ export async function cmdTail(
232
335
  let isFirstPage = true;
233
336
 
234
337
  do {
235
- const { newCursor, lines, nextPageCursor } = await pollCycle(
338
+ const { newCursor, lines, nextPageCursor, emittedOther } = await pollCycle(
236
339
  token,
237
340
  channelId,
238
341
  cursor,
@@ -249,6 +352,7 @@ export async function cmdTail(
249
352
  isFirstPage = false;
250
353
 
251
354
  for (const line of lines) process.stdout.write(line + "\n");
355
+ if (emittedOther) sawOther = true;
252
356
  pageCursor = shouldPaginate ? nextPageCursor : undefined;
253
357
  } while (pageCursor);
254
358
 
@@ -262,7 +366,13 @@ export async function cmdTail(
262
366
  throw e;
263
367
  }
264
368
 
369
+ // Stop as soon as a reply from someone else arrives.
370
+ if (opts.exitOnMessage && sawOther) return;
371
+
265
372
  lastPollEndTime = _internals.now();
266
- await _internals.sleep(interval);
373
+ // Sleep the poll interval, but never past the deadline.
374
+ const remaining = deadline - _internals.now();
375
+ if (remaining <= 0) return;
376
+ await _internals.sleep(Math.min(interval, remaining));
267
377
  }
268
378
  }