slack-term 1.14.0 → 1.16.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/tail.ts CHANGED
@@ -1,4 +1,4 @@
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
3
  import { tailRTMImpl } from "./rtm.ts";
4
4
 
@@ -29,6 +29,21 @@ function asArray(v: Json | undefined): Json[] {
29
29
  return Array.isArray(v) ? v : [];
30
30
  }
31
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
+
32
47
  function slackTsToIso(tsRaw: string): string {
33
48
  const [secStr, fracStr = "000000"] = tsRaw.split(".");
34
49
  const d = new Date(Number(secStr) * 1000);
@@ -66,12 +81,18 @@ async function formatTailLine(
66
81
  const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
67
82
  const lines = resolved.split("\n");
68
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) ? "↳ " : "";
69
86
  const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
70
- return `${stamp} ${who}: ${body}`;
87
+ return `${stamp} ${mark}${who}: ${body}`;
71
88
  }
72
89
 
73
90
  type PollOpts = {
74
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;
75
96
  me?: boolean;
76
97
  myUserId?: string;
77
98
  };
@@ -84,7 +105,7 @@ export async function pollCycle(
84
105
  seen: Set<string>,
85
106
  cache: Map<string, string>,
86
107
  pageCursor?: string,
87
- ): Promise<{ newCursor: string; lines: string[]; hasMore: boolean; nextPageCursor: string | undefined }> {
108
+ ): Promise<{ newCursor: string; lines: string[]; hasMore: boolean; nextPageCursor: string | undefined; emittedOther: boolean }> {
88
109
  const histResp = asRecord((await history(
89
110
  token,
90
111
  channelId,
@@ -93,17 +114,37 @@ export async function pollCycle(
93
114
  pageCursor,
94
115
  )) as Json);
95
116
  // history returns newest-first; reverse to emit oldest-first
96
- const msgs = asArray(histResp.messages).map(asRecord).reverse();
117
+ const histMsgs = asArray(histResp.messages).map(asRecord).reverse();
97
118
 
98
119
  const meta = asRecord(histResp.response_metadata as Json | undefined);
99
120
  const nextPageCursor = (histResp.has_more === true && typeof meta.next_cursor === "string")
100
121
  ? meta.next_cursor
101
122
  : undefined;
102
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
+
103
143
  const lines: string[] = [];
104
144
  let newCursor = cursor;
145
+ let emittedOther = false;
105
146
 
106
- for (const m of msgs) {
147
+ for (const { m, fromHistory } of entries) {
107
148
  const ts = typeof m.ts === "string" ? m.ts : "";
108
149
  if (!ts || seen.has(ts)) continue;
109
150
 
@@ -116,6 +157,14 @@ export async function pollCycle(
116
157
  if (parentTs !== opts.thread && ts !== opts.thread) continue;
117
158
  }
118
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
+
119
168
  if (opts.me && opts.myUserId) {
120
169
  const text = typeof m.text === "string" ? m.text : "";
121
170
  if (!text.includes(`<@${opts.myUserId}>`)) continue;
@@ -128,21 +177,44 @@ export async function pollCycle(
128
177
  }
129
178
 
130
179
  lines.push(await formatTailLine(token, m, cache));
131
- 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;
132
187
  }
133
188
 
134
- return { newCursor, lines, hasMore: histResp.has_more === true, nextPageCursor };
189
+ return { newCursor, lines, hasMore: histResp.has_more === true, nextPageCursor, emittedOther };
135
190
  }
136
191
 
137
192
  export type TailOpts = {
138
193
  since?: string;
139
194
  thread?: string;
195
+ watchThread?: string;
140
196
  me?: boolean;
141
197
  interval?: number;
198
+ timeout?: string;
199
+ exitOnMessage?: boolean;
142
200
  cookie?: string;
143
201
  noRtm?: boolean;
144
202
  };
145
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
+
146
218
  export async function cmdTail(
147
219
  token: string,
148
220
  target: string | undefined,
@@ -165,6 +237,8 @@ export async function cmdTail(
165
237
 
166
238
  const channelId = await resolveChannel(token, target);
167
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;
168
242
  const cache = new Map<string, string>();
169
243
  const seen = new Set<string>();
170
244
 
@@ -210,19 +284,34 @@ export async function cmdTail(
210
284
  }
211
285
 
212
286
  let myUserId: string | undefined;
213
- if (opts.me) {
287
+ if (opts.me || opts.exitOnMessage) {
214
288
  const info = await authTest(token);
215
289
  myUserId = info.userId || undefined;
216
290
  }
217
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
+
218
302
  const pollOpts: PollOpts = {
219
303
  ...(opts.thread !== undefined ? { thread: opts.thread } : {}),
304
+ ...(watchThreadTs !== undefined ? { watchThread: watchThreadTs } : {}),
220
305
  ...(opts.me !== undefined ? { me: opts.me } : {}),
221
306
  ...(myUserId !== undefined ? { myUserId } : {}),
222
307
  };
223
308
 
224
- // RTM path: xoxc token + cookie + no backfill requested
225
- if (token.startsWith("xoxc-") && opts.cookie !== undefined && opts.noRtm !== true && opts.since === undefined) {
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
+ ) {
226
315
  await _internals.tailRTM(token, opts.cookie, channelId, pollOpts, seen, cache, signal);
227
316
  if (signal?.aborted) return;
228
317
  console.error("RTM unavailable — switching to polling.");
@@ -232,6 +321,9 @@ export async function cmdTail(
232
321
  let lastPollEndTime = _internals.now();
233
322
 
234
323
  while (!signal?.aborted) {
324
+ if (_internals.now() >= deadline) return; // --timeout reached
325
+
326
+ let sawOther = false;
235
327
  try {
236
328
  const now = _internals.now();
237
329
  const elapsed = now - lastPollEndTime;
@@ -243,7 +335,7 @@ export async function cmdTail(
243
335
  let isFirstPage = true;
244
336
 
245
337
  do {
246
- const { newCursor, lines, nextPageCursor } = await pollCycle(
338
+ const { newCursor, lines, nextPageCursor, emittedOther } = await pollCycle(
247
339
  token,
248
340
  channelId,
249
341
  cursor,
@@ -260,6 +352,7 @@ export async function cmdTail(
260
352
  isFirstPage = false;
261
353
 
262
354
  for (const line of lines) process.stdout.write(line + "\n");
355
+ if (emittedOther) sawOther = true;
263
356
  pageCursor = shouldPaginate ? nextPageCursor : undefined;
264
357
  } while (pageCursor);
265
358
 
@@ -273,7 +366,13 @@ export async function cmdTail(
273
366
  throw e;
274
367
  }
275
368
 
369
+ // Stop as soon as a reply from someone else arrives.
370
+ if (opts.exitOnMessage && sawOther) return;
371
+
276
372
  lastPollEndTime = _internals.now();
277
- 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));
278
377
  }
279
378
  }