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/README.md +22 -3
- package/SKILL.md +15 -4
- package/dist/cli.js +87 -60
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +375 -57
- package/ts/profiles.ts +165 -36
- package/ts/rtm.ts +197 -0
- package/ts/slack-app.ts +207 -3
- package/ts/slack.ts +138 -4
- package/ts/tail.ts +120 -10
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|