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/README.md +22 -3
- package/SKILL.md +15 -4
- package/dist/cli.js +82 -64
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +400 -68
- package/ts/profiles.ts +165 -36
- package/ts/rtm.ts +18 -1
- package/ts/slack-app.ts +207 -3
- package/ts/slack.ts +112 -0
- package/ts/tail.ts +111 -12
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|