slack-term 1.7.0 → 1.14.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 ADDED
@@ -0,0 +1,279 @@
1
+ import { history, resolveChannel, userInfoPair, authTest, conversationInfo, RateLimitError, type Json } from "./slack.ts";
2
+ import { resolveDateMarkup, resolveMentions } from "./format.ts";
3
+ import { tailRTMImpl } from "./rtm.ts";
4
+
5
+ export function parseSince(s: string): number {
6
+ const m = s.match(/^(\d+(?:\.\d+)?)(s|m|h|d)$/);
7
+ if (!m) throw new Error(`Invalid --since format: "${s}" (expected e.g. 10m, 2h, 1d)`);
8
+ const n = parseFloat(m[1]!);
9
+ const unit = m[2]! as "s" | "m" | "h" | "d";
10
+ switch (unit) {
11
+ case "s": return n;
12
+ case "m": return n * 60;
13
+ case "h": return n * 3600;
14
+ case "d": return n * 86400;
15
+ }
16
+ }
17
+
18
+ export const _internals = {
19
+ sleep: (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms)),
20
+ now: (): number => Date.now(),
21
+ tailRTM: tailRTMImpl,
22
+ };
23
+
24
+ function asRecord(v: Json | undefined): Record<string, Json> {
25
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
26
+ }
27
+
28
+ function asArray(v: Json | undefined): Json[] {
29
+ return Array.isArray(v) ? v : [];
30
+ }
31
+
32
+ function slackTsToIso(tsRaw: string): string {
33
+ const [secStr, fracStr = "000000"] = tsRaw.split(".");
34
+ const d = new Date(Number(secStr) * 1000);
35
+ const y = d.getUTCFullYear();
36
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
37
+ const da = String(d.getUTCDate()).padStart(2, "0");
38
+ const h = String(d.getUTCHours()).padStart(2, "0");
39
+ const mi = String(d.getUTCMinutes()).padStart(2, "0");
40
+ const s = String(d.getUTCSeconds()).padStart(2, "0");
41
+ const frac = fracStr.padEnd(6, "0").slice(0, 6);
42
+ return `${y}-${mo}-${da}T${h}:${mi}:${s}.${frac}`;
43
+ }
44
+
45
+ async function formatTailLine(
46
+ token: string,
47
+ m: Record<string, Json>,
48
+ cache: Map<string, string>,
49
+ chLabel?: string,
50
+ ): Promise<string> {
51
+ const rawTs = typeof m.ts === "string" ? m.ts : "0.000000";
52
+ const stamp = slackTsToIso(rawTs);
53
+ let handle = "?";
54
+ if (typeof m.user === "string") {
55
+ const uid = m.user;
56
+ const handleKey = "@" + uid;
57
+ if (!cache.has(handleKey)) {
58
+ const [, h] = await userInfoPair(token, uid);
59
+ cache.set(handleKey, h);
60
+ }
61
+ handle = cache.get(handleKey) ?? uid;
62
+ } else if (typeof m.username === "string") {
63
+ handle = m.username;
64
+ }
65
+ const raw = typeof m.text === "string" ? m.text : "";
66
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
67
+ const lines = resolved.split("\n");
68
+ const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((l) => ` ${l}`).join("\n") : "");
69
+ const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
70
+ return `${stamp} ${who}: ${body}`;
71
+ }
72
+
73
+ type PollOpts = {
74
+ thread?: string;
75
+ me?: boolean;
76
+ myUserId?: string;
77
+ };
78
+
79
+ export async function pollCycle(
80
+ token: string,
81
+ channelId: string,
82
+ cursor: string,
83
+ opts: PollOpts,
84
+ seen: Set<string>,
85
+ cache: Map<string, string>,
86
+ pageCursor?: string,
87
+ ): Promise<{ newCursor: string; lines: string[]; hasMore: boolean; nextPageCursor: string | undefined }> {
88
+ const histResp = asRecord((await history(
89
+ token,
90
+ channelId,
91
+ 20,
92
+ pageCursor ? undefined : (cursor || undefined),
93
+ pageCursor,
94
+ )) as Json);
95
+ // history returns newest-first; reverse to emit oldest-first
96
+ const msgs = asArray(histResp.messages).map(asRecord).reverse();
97
+
98
+ const meta = asRecord(histResp.response_metadata as Json | undefined);
99
+ const nextPageCursor = (histResp.has_more === true && typeof meta.next_cursor === "string")
100
+ ? meta.next_cursor
101
+ : undefined;
102
+
103
+ const lines: string[] = [];
104
+ let newCursor = cursor;
105
+
106
+ for (const m of msgs) {
107
+ const ts = typeof m.ts === "string" ? m.ts : "";
108
+ if (!ts || seen.has(ts)) continue;
109
+
110
+ // Skip edit/delete events; only display original messages
111
+ const subtype = typeof m.subtype === "string" ? m.subtype : "";
112
+ if (subtype === "message_changed" || subtype === "message_deleted") continue;
113
+
114
+ if (opts.thread) {
115
+ const parentTs = typeof m.thread_ts === "string" ? m.thread_ts : ts;
116
+ if (parentTs !== opts.thread && ts !== opts.thread) continue;
117
+ }
118
+
119
+ if (opts.me && opts.myUserId) {
120
+ const text = typeof m.text === "string" ? m.text : "";
121
+ if (!text.includes(`<@${opts.myUserId}>`)) continue;
122
+ }
123
+
124
+ seen.add(ts);
125
+ if (seen.size > 1000) {
126
+ const oldest = seen.values().next().value;
127
+ if (oldest !== undefined) seen.delete(oldest);
128
+ }
129
+
130
+ lines.push(await formatTailLine(token, m, cache));
131
+ newCursor = ts;
132
+ }
133
+
134
+ return { newCursor, lines, hasMore: histResp.has_more === true, nextPageCursor };
135
+ }
136
+
137
+ export type TailOpts = {
138
+ since?: string;
139
+ thread?: string;
140
+ me?: boolean;
141
+ interval?: number;
142
+ cookie?: string;
143
+ noRtm?: boolean;
144
+ };
145
+
146
+ export async function cmdTail(
147
+ token: string,
148
+ target: string | undefined,
149
+ opts: TailOpts = {},
150
+ signal?: AbortSignal,
151
+ ): Promise<void> {
152
+ if (opts.me && !target) {
153
+ console.error(
154
+ "slack tail --me requires a <target> channel.\n" +
155
+ "Cross-channel mention streaming is not yet supported.\n" +
156
+ "Use: slack tail \"#channel\" --me",
157
+ );
158
+ process.exit(1);
159
+ }
160
+
161
+ if (!target) {
162
+ console.error("Usage: slack tail <#channel|@user>\n Run `slack tail --help` for details.");
163
+ process.exit(1);
164
+ }
165
+
166
+ const channelId = await resolveChannel(token, target);
167
+ const interval = opts.interval ?? 60000;
168
+ const cache = new Map<string, string>();
169
+ const seen = new Set<string>();
170
+
171
+ // Preflight: verify channel access before entering the poll loop
172
+ try {
173
+ const info = asRecord(await conversationInfo(token, channelId) as Json);
174
+ const ch = asRecord(info.channel as Json);
175
+ if (ch.is_archived === true) {
176
+ console.error(`Warning: this channel is archived — no new messages will arrive.`);
177
+ }
178
+ } catch (e: unknown) {
179
+ const msg = e instanceof Error ? e.message : String(e);
180
+ if (msg.includes("not_in_channel")) {
181
+ console.error(`Error: you are not a member of that channel. Join it in Slack first, then retry.`);
182
+ process.exit(1);
183
+ }
184
+ if (msg.includes("missing_scope")) {
185
+ console.error(`Error: token lacks the required history scope. Check your token's channels:history (or groups:history) permission.`);
186
+ process.exit(1);
187
+ }
188
+ if (msg.includes("channel_not_found")) {
189
+ console.error(`Error: channel not found — it may be a private channel inaccessible with your token.`);
190
+ process.exit(1);
191
+ }
192
+ // Non-fatal: warn and continue (e.g. transient network error)
193
+ console.error(`Warning: preflight check failed: ${msg}`);
194
+ }
195
+
196
+ let cursor = "";
197
+ if (opts.since) {
198
+ const secondsBack = parseSince(opts.since);
199
+ cursor = (_internals.now() / 1000 - secondsBack).toFixed(6);
200
+ } else {
201
+ // Seed with most recent message so we only emit new messages going forward
202
+ const histResp = asRecord((await history(token, channelId, 1)) as Json);
203
+ const msgs = asArray(histResp.messages).map(asRecord);
204
+ if (msgs.length > 0 && typeof msgs[0]?.ts === "string") {
205
+ cursor = msgs[0].ts;
206
+ seen.add(cursor);
207
+ } else {
208
+ cursor = (_internals.now() / 1000).toFixed(6);
209
+ }
210
+ }
211
+
212
+ let myUserId: string | undefined;
213
+ if (opts.me) {
214
+ const info = await authTest(token);
215
+ myUserId = info.userId || undefined;
216
+ }
217
+
218
+ const pollOpts: PollOpts = {
219
+ ...(opts.thread !== undefined ? { thread: opts.thread } : {}),
220
+ ...(opts.me !== undefined ? { me: opts.me } : {}),
221
+ ...(myUserId !== undefined ? { myUserId } : {}),
222
+ };
223
+
224
+ // RTM path: xoxc token + cookie + no backfill requested
225
+ if (token.startsWith("xoxc-") && opts.cookie !== undefined && opts.noRtm !== true && opts.since === undefined) {
226
+ await _internals.tailRTM(token, opts.cookie, channelId, pollOpts, seen, cache, signal);
227
+ if (signal?.aborted) return;
228
+ console.error("RTM unavailable — switching to polling.");
229
+ }
230
+
231
+ let isFirstPoll = true;
232
+ let lastPollEndTime = _internals.now();
233
+
234
+ while (!signal?.aborted) {
235
+ try {
236
+ const now = _internals.now();
237
+ const elapsed = now - lastPollEndTime;
238
+ // If we woke after an unexpectedly long gap (e.g. laptop sleep), paginate to
239
+ // avoid missing messages beyond the single-page limit.
240
+ const shouldPaginate = interval > 0 && elapsed > interval * 5;
241
+
242
+ let pageCursor: string | undefined;
243
+ let isFirstPage = true;
244
+
245
+ do {
246
+ const { newCursor, lines, nextPageCursor } = await pollCycle(
247
+ token,
248
+ channelId,
249
+ cursor,
250
+ pollOpts,
251
+ seen,
252
+ cache,
253
+ pageCursor,
254
+ );
255
+ cursor = newCursor;
256
+
257
+ if (isFirstPoll && isFirstPage && opts.since && lines.length === 0) {
258
+ process.stdout.write(`(no messages in the last ${opts.since} — watching for new)\n`);
259
+ }
260
+ isFirstPage = false;
261
+
262
+ for (const line of lines) process.stdout.write(line + "\n");
263
+ pageCursor = shouldPaginate ? nextPageCursor : undefined;
264
+ } while (pageCursor);
265
+
266
+ isFirstPoll = false;
267
+ } catch (e: unknown) {
268
+ if (e instanceof RateLimitError) {
269
+ console.error(`Warning: Slack rate limit hit — backing off for ${e.retryAfter}s`);
270
+ await _internals.sleep(e.retryAfter * 1000);
271
+ continue;
272
+ }
273
+ throw e;
274
+ }
275
+
276
+ lastPollEndTime = _internals.now();
277
+ await _internals.sleep(interval);
278
+ }
279
+ }