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/dist/cli.js +65 -56
- package/package.json +1 -1
- package/ts/cli.ts +18 -5
- package/ts/rtm.ts +180 -0
- package/ts/slack.ts +26 -4
- package/ts/tail.ts +11 -0
package/package.json
CHANGED
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
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|