slack-term 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -175,12 +175,21 @@ async function formatMsgLine(
175
175
 
176
176
  // --- msgs <target> — channel/DM history with timestamps ---
177
177
  async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
178
+ const parsed = parseSlackPermalink(target);
178
179
  const channelId = await resolveChannel(token, target);
179
- const hist = (await history(token, channelId, limit)) as Record<string, Json>;
180
- const msgs = asArray(hist.messages).map(asRecord);
181
180
  const cache = new Map<string, string>();
182
- for (const m of msgs.reverse()) {
183
- console.log(await formatMsgLine(token, m, cache));
181
+ if (parsed?.threadTs) {
182
+ const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
183
+ const msgs = asArray(resp.messages).map(asRecord);
184
+ for (const m of msgs) {
185
+ console.log(await formatMsgLine(token, m, cache));
186
+ }
187
+ } else {
188
+ const hist = (await history(token, channelId, limit)) as Record<string, Json>;
189
+ const msgs = asArray(hist.messages).map(asRecord);
190
+ for (const m of msgs.reverse()) {
191
+ console.log(await formatMsgLine(token, m, cache));
192
+ }
184
193
  }
185
194
  }
186
195
 
@@ -1267,9 +1276,11 @@ async function main(): Promise<void> {
1267
1276
  .option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
1268
1277
  .option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
1269
1278
  .option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
1270
- .option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" }),
1279
+ .option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
1280
+ .option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
1271
1281
  async (argv) => {
1272
1282
  const token = tok(argv as W);
1283
+ const cookie = ck(argv as W);
1273
1284
  const signal = new AbortController();
1274
1285
  process.on("SIGINT", () => { signal.abort(); process.exit(0); });
1275
1286
  await cmdTail(token, argv.target, {
@@ -1277,6 +1288,8 @@ async function main(): Promise<void> {
1277
1288
  ...(argv.thread !== undefined ? { thread: argv.thread } : {}),
1278
1289
  me: argv.me,
1279
1290
  interval: argv.interval,
1291
+ ...(cookie !== undefined ? { cookie } : {}),
1292
+ ...(argv.rtm === false ? { noRtm: true } : {}),
1280
1293
  }, signal.signal);
1281
1294
  },
1282
1295
  )
package/ts/rtm.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { type Json, userInfoPair, clientBoot as clientBootImpl } from "./slack.ts";
2
+ import { resolveDateMarkup, resolveMentions } from "./format.ts";
3
+
4
+ export type RtmPollOpts = {
5
+ thread?: string;
6
+ me?: boolean;
7
+ myUserId?: string;
8
+ };
9
+
10
+ // Minimal WebSocket interface — avoids requiring DOM lib in tsconfig
11
+ interface WsLike {
12
+ addEventListener(type: string, handler: (event: unknown) => void): void;
13
+ send(data: string): void;
14
+ close(): void;
15
+ }
16
+ interface WsConstructor {
17
+ new(url: string): WsLike;
18
+ }
19
+
20
+ function slackTsToIso(tsRaw: string): string {
21
+ const [secStr, fracStr = "000000"] = tsRaw.split(".");
22
+ const d = new Date(Number(secStr) * 1000);
23
+ const y = d.getUTCFullYear();
24
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
25
+ const da = String(d.getUTCDate()).padStart(2, "0");
26
+ const h = String(d.getUTCHours()).padStart(2, "0");
27
+ const mi = String(d.getUTCMinutes()).padStart(2, "0");
28
+ const s = String(d.getUTCSeconds()).padStart(2, "0");
29
+ const frac = fracStr.padEnd(6, "0").slice(0, 6);
30
+ return `${y}-${mo}-${da}T${h}:${mi}:${s}.${frac}`;
31
+ }
32
+
33
+ async function formatRtmLine(
34
+ token: string,
35
+ m: Record<string, Json>,
36
+ cache: Map<string, string>,
37
+ ): Promise<string> {
38
+ const rawTs = typeof m.ts === "string" ? m.ts : "0.000000";
39
+ const stamp = slackTsToIso(rawTs);
40
+ let handle = "?";
41
+ if (typeof m.user === "string") {
42
+ const uid = m.user;
43
+ const handleKey = "@" + uid;
44
+ if (!cache.has(handleKey)) {
45
+ const [, h] = await userInfoPair(token, uid);
46
+ cache.set(handleKey, h);
47
+ }
48
+ handle = cache.get(handleKey) ?? uid;
49
+ } else if (typeof m.username === "string") {
50
+ handle = m.username;
51
+ }
52
+ const raw = typeof m.text === "string" ? m.text : "";
53
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
54
+ const lines = resolved.split("\n");
55
+ const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((l) => ` ${l}`).join("\n") : "");
56
+ return `${stamp} @${handle}: ${body}`;
57
+ }
58
+
59
+ async function connectAndStream(
60
+ WS: WsConstructor,
61
+ token: string,
62
+ wsUrl: string,
63
+ channelId: string,
64
+ opts: RtmPollOpts,
65
+ seen: Set<string>,
66
+ cache: Map<string, string>,
67
+ signal?: AbortSignal,
68
+ ): Promise<void> {
69
+ // If already aborted before we even create the WS, resolve immediately.
70
+ if (signal?.aborted) return;
71
+ return new Promise<void>((resolve) => {
72
+ const ws = new WS(wsUrl);
73
+
74
+ signal?.addEventListener("abort", () => {
75
+ ws.close();
76
+ resolve();
77
+ }, { once: true } as AddEventListenerOptions);
78
+
79
+ ws.addEventListener("open", () => {
80
+ process.stderr.write("Connected via RTM (experimental).\n");
81
+ });
82
+
83
+ ws.addEventListener("close", () => resolve());
84
+
85
+ ws.addEventListener("error", () => resolve());
86
+
87
+ ws.addEventListener("message", (event: unknown) => {
88
+ void (async () => {
89
+ try {
90
+ let data: Record<string, Json>;
91
+ try {
92
+ data = JSON.parse(String((event as { data?: unknown }).data)) as Record<string, Json>;
93
+ } catch {
94
+ return;
95
+ }
96
+
97
+ const type = typeof data.type === "string" ? data.type : "";
98
+
99
+ if (type === "ping") {
100
+ ws.send(JSON.stringify({ type: "pong", reply_to: data.id ?? 0 }));
101
+ return;
102
+ }
103
+
104
+ if (type !== "message") return;
105
+
106
+ const msgChannel = typeof data.channel === "string" ? data.channel : "";
107
+ if (msgChannel !== channelId) return;
108
+
109
+ const ts = typeof data.ts === "string" ? data.ts : "";
110
+ if (!ts || seen.has(ts)) return;
111
+
112
+ const subtype = typeof data.subtype === "string" ? data.subtype : "";
113
+ if (subtype === "message_changed" || subtype === "message_deleted") return;
114
+
115
+ if (opts.thread) {
116
+ const parentTs = typeof data.thread_ts === "string" ? data.thread_ts : ts;
117
+ if (parentTs !== opts.thread && ts !== opts.thread) return;
118
+ }
119
+
120
+ if (opts.me && opts.myUserId) {
121
+ const text = typeof data.text === "string" ? data.text : "";
122
+ if (!text.includes(`<@${opts.myUserId}>`)) return;
123
+ }
124
+
125
+ seen.add(ts);
126
+ if (seen.size > 1000) {
127
+ const oldest = seen.values().next().value;
128
+ if (oldest !== undefined) seen.delete(oldest);
129
+ }
130
+
131
+ const line = await formatRtmLine(token, data, cache);
132
+ process.stdout.write(line + "\n");
133
+ } catch (e: unknown) {
134
+ process.stderr.write(`RTM message error: ${e instanceof Error ? e.message : String(e)}\n`);
135
+ }
136
+ })();
137
+ });
138
+ });
139
+ }
140
+
141
+ const MAX_RETRIES = 3;
142
+ const RETRY_DELAY_MS = 2000;
143
+
144
+ export const _internals = {
145
+ sleep: (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms)),
146
+ clientBoot: clientBootImpl,
147
+ getWebSocket: (): WsConstructor | undefined => {
148
+ const ws = (globalThis as Record<string, unknown>).WebSocket;
149
+ return typeof ws === "function" ? ws as WsConstructor : undefined;
150
+ },
151
+ };
152
+
153
+ export async function tailRTMImpl(
154
+ token: string,
155
+ cookie: string,
156
+ channelId: string,
157
+ opts: RtmPollOpts,
158
+ seen: Set<string>,
159
+ cache: Map<string, string>,
160
+ signal?: AbortSignal,
161
+ ): Promise<void> {
162
+ const WS = _internals.getWebSocket();
163
+ if (!WS) return;
164
+
165
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
166
+ if (signal?.aborted) return;
167
+
168
+ try {
169
+ const { wsUrl } = await _internals.clientBoot(token, cookie);
170
+ await connectAndStream(WS, token, wsUrl, channelId, opts, seen, cache, signal);
171
+ } catch {
172
+ // boot or connection error — retry
173
+ }
174
+
175
+ if (signal?.aborted) return;
176
+ if (attempt < MAX_RETRIES - 1) {
177
+ await _internals.sleep(RETRY_DELAY_MS);
178
+ }
179
+ }
180
+ }
package/ts/slack.ts CHANGED
@@ -285,19 +285,27 @@ function normName(s: string): string {
285
285
  return s.toLowerCase().replace(/[-_\s]/g, "");
286
286
  }
287
287
 
288
- /** Parse a Slack permalink, returning channel ID and (optional) message ts.
288
+ /** Parse a Slack permalink, returning channel ID, optional message ts, and optional thread_ts.
289
289
  * Supports both forms:
290
290
  * https://app.slack.com/client/T.../C...[/p1700000000000100]
291
- * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...]
291
+ * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...][&cid=C...]
292
292
  */
293
- export function parseSlackPermalink(s: string): { channel: string; ts?: string } | undefined {
293
+ export function parseSlackPermalink(s: string): { channel: string; ts?: string; threadTs?: string } | undefined {
294
294
  const m = s.match(
295
295
  /(?: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
296
  );
297
297
  if (!m) return undefined;
298
298
  const channel = m[1]!;
299
299
  const ts = m[2] && m[3] ? `${m[2]}.${m[3]}` : undefined;
300
- return ts ? { channel, ts } : { channel };
300
+ const qsStart = s.indexOf("?");
301
+ const qs = qsStart >= 0 ? new URLSearchParams(s.slice(qsStart)) : undefined;
302
+ const threadTs = qs?.get("thread_ts") ?? undefined;
303
+ const cidFromQs = qs?.get("cid") ?? undefined;
304
+ const resolvedChannel = cidFromQs ?? channel;
305
+ const result: { channel: string; ts?: string; threadTs?: string } = { channel: resolvedChannel };
306
+ if (ts) result.ts = ts;
307
+ if (threadTs) result.threadTs = threadTs;
308
+ return result;
301
309
  }
302
310
 
303
311
  function parseSlackUrl(s: string): string | undefined {
@@ -562,6 +570,20 @@ export async function uploadFile(
562
570
  return result;
563
571
  }
564
572
 
573
+ // client.boot — internal endpoint that returns a WebSocket URL for RTM
574
+ export async function clientBoot(token: string, cookie: string): Promise<{ wsUrl: string; selfId: string }> {
575
+ const resp = (await postSession(token, "client.boot", {}, cookie)) as Record<string, Json>;
576
+ const wsUrl = typeof resp.url === "string" ? resp.url : undefined;
577
+ if (!wsUrl) {
578
+ const keys = Object.keys(resp).join(", ");
579
+ throw new Error(`client.boot did not return a WebSocket URL. Keys: ${keys}`);
580
+ }
581
+ const self_ = resp.self && typeof resp.self === "object" && !Array.isArray(resp.self)
582
+ ? resp.self as Record<string, Json>
583
+ : {};
584
+ return { wsUrl, selfId: typeof self_.id === "string" ? self_.id : "" };
585
+ }
586
+
565
587
  // Safe nested access
566
588
  export function getPath(obj: Json, path: readonly (string | number)[]): Json | undefined {
567
589
  let cur: Json | undefined = obj;
package/ts/tail.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { history, resolveChannel, 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> {
@@ -137,6 +139,8 @@ export type TailOpts = {
137
139
  thread?: string;
138
140
  me?: boolean;
139
141
  interval?: number;
142
+ cookie?: string;
143
+ noRtm?: boolean;
140
144
  };
141
145
 
142
146
  export async function cmdTail(
@@ -217,6 +221,13 @@ export async function cmdTail(
217
221
  ...(myUserId !== undefined ? { myUserId } : {}),
218
222
  };
219
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
+
220
231
  let isFirstPoll = true;
221
232
  let lastPollEndTime = _internals.now();
222
233