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/profiles.ts CHANGED
@@ -29,8 +29,12 @@ type ProfileStore = {
29
29
  profiles: Record<string, Profile>;
30
30
  };
31
31
 
32
+ function home(): string {
33
+ return process.env.HOME || homedir();
34
+ }
35
+
32
36
  function profilesPath(): string {
33
- return join(homedir(), ".config", "slack-cli", "profiles.json");
37
+ return join(home(), ".config", "slack-cli", "profiles.json");
34
38
  }
35
39
 
36
40
  function localLockfilePath(): string {
@@ -38,7 +42,7 @@ function localLockfilePath(): string {
38
42
  }
39
43
 
40
44
  function globalLockfilePath(): string {
41
- return join(homedir(), ".slack-cli", "workspace");
45
+ return join(home(), ".slack-cli", "workspace");
42
46
  }
43
47
 
44
48
  function load(): ProfileStore {
@@ -115,18 +119,19 @@ export function resolveToken(workspaceFlag?: string): string {
115
119
  const names = Object.keys(profiles);
116
120
  const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
117
121
 
118
- // Conflict: env token and profiles are mutually exclusive
122
+ // Conflict: env token and profiles are mutually exclusive — prefer profiles
119
123
  if (envToken && names.length > 0) {
120
- throw new Error(
121
- "Both SLACK_MCP_XOXP_TOKEN and workspace profiles are configured — keep only one.\n" +
122
- " • Unset SLACK_MCP_XOXP_TOKEN to use profiles\n" +
123
- " Or remove all profiles: slack workspace remove <name>",
124
- );
124
+ if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
125
+ (globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
126
+ console.error(
127
+ "Warning: SLACK_MCP_XOXP_TOKEN is set but workspace profiles exist - using profiles.\n" +
128
+ " Run: unset SLACK_MCP_XOXP_TOKEN (and remove from ~/.zshrc or ~/.bashrc)",
129
+ );
130
+ }
131
+ } else if (envToken) {
132
+ return envToken;
125
133
  }
126
134
 
127
- // Env token only (no profiles) — highest priority when unambiguous
128
- if (envToken) return envToken;
129
-
130
135
  // Explicit per-command selection
131
136
  const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
132
137
  if (selected) {
@@ -157,7 +162,7 @@ export function resolveToken(workspaceFlag?: string): string {
157
162
 
158
163
  // No profiles and no env token
159
164
  if (names.length === 0) {
160
- throw new Error("No profiles configured. Run: slack workspace add <name> <token> — or set SLACK_MCP_XOXP_TOKEN");
165
+ throw new Error("No profiles configured. Run: slack auth login");
161
166
  }
162
167
 
163
168
  // Profiles exist but none selected — never silently pick one
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
@@ -1,5 +1,14 @@
1
1
  // Slack Web API client (user token, Authorization: Bearer)
2
2
 
3
+ export class RateLimitError extends Error {
4
+ retryAfter: number;
5
+ constructor(retryAfter: number) {
6
+ super(`Slack rate limited — retry after ${retryAfter}s`);
7
+ this.name = "RateLimitError";
8
+ this.retryAfter = retryAfter;
9
+ }
10
+ }
11
+
3
12
  export type Json =
4
13
  | string
5
14
  | number
@@ -23,9 +32,14 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
23
32
  ...extraHeaders,
24
33
  },
25
34
  });
35
+ if (res.status === 429) {
36
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
37
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
38
+ }
26
39
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
27
40
  if (body.ok !== true) {
28
41
  const err = body.error ?? "unknown";
42
+ if (err === "ratelimited") throw new RateLimitError(60);
29
43
  if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
30
44
  throw new Error(
31
45
  `Desktop app token (xoxc-) is not accepted by the public Slack API.\n` +
@@ -52,9 +66,14 @@ async function callSession(token: string, method: string, init: RequestInit, coo
52
66
  ...extraHeaders,
53
67
  },
54
68
  });
69
+ if (res.status === 429) {
70
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
71
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
72
+ }
55
73
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
56
74
  if (body.ok !== true) {
57
75
  const err = body.error ?? "unknown";
76
+ if (err === "ratelimited") throw new RateLimitError(60);
58
77
  if ((err === "invalid_auth" || err === "not_authed") && !token.startsWith("xoxc-")) {
59
78
  throw new Error(
60
79
  `The draft API requires a desktop app session token (xoxc-).\n` +
@@ -96,20 +115,24 @@ function post(token: string, method: string, body: Record<string, Json>): Promis
96
115
  });
97
116
  }
98
117
 
99
- export async function authTest(token: string): Promise<{ team: string; teamId: string; url: string; user: string }> {
118
+ export async function authTest(token: string): Promise<{ team: string; teamId: string; url: string; user: string; userId: string }> {
100
119
  const resp = (await get(token, "auth.test", {})) as {
101
- team?: string; team_id?: string; url?: string; user?: string;
120
+ team?: string; team_id?: string; url?: string; user?: string; user_id?: string;
102
121
  };
103
122
  return {
104
123
  team: resp.team ?? "",
105
124
  teamId: resp.team_id ?? "",
106
125
  url: resp.url ?? "",
107
126
  user: resp.user ?? "",
127
+ userId: resp.user_id ?? "",
108
128
  };
109
129
  }
110
130
 
111
- export async function history(token: string, channel: string, limit = 20): Promise<Json> {
112
- return get(token, "conversations.history", { channel, limit: String(limit) });
131
+ export async function history(token: string, channel: string, limit = 20, oldest?: string, cursor?: string): Promise<Json> {
132
+ const params: Record<string, string> = { channel, limit: String(limit) };
133
+ if (oldest !== undefined) params.oldest = oldest;
134
+ if (cursor !== undefined) params.cursor = cursor;
135
+ return get(token, "conversations.history", params);
113
136
  }
114
137
 
115
138
  export async function replies(
@@ -177,6 +200,44 @@ export async function send(
177
200
  return resp.ts ?? "";
178
201
  }
179
202
 
203
+ export async function scheduleMessage(
204
+ token: string,
205
+ channel: string,
206
+ text: string,
207
+ postAt: number,
208
+ threadTs?: string,
209
+ ): Promise<string> {
210
+ const body: Record<string, Json> = {
211
+ channel,
212
+ text,
213
+ post_at: postAt,
214
+ blocks: [{ type: "markdown", text }],
215
+ };
216
+ if (threadTs !== undefined) body.thread_ts = threadTs;
217
+ const resp = (await post(token, "chat.scheduleMessage", body)) as { scheduled_message_id?: string };
218
+ return resp.scheduled_message_id ?? "";
219
+ }
220
+
221
+ export async function listScheduledMessages(
222
+ token: string,
223
+ channel?: string,
224
+ ): Promise<Json> {
225
+ const params: Record<string, string> = {};
226
+ if (channel) params.channel = channel;
227
+ return get(token, "chat.scheduledMessages.list", params);
228
+ }
229
+
230
+ export async function deleteScheduledMessage(
231
+ token: string,
232
+ channel: string,
233
+ scheduledMessageId: string,
234
+ ): Promise<void> {
235
+ await post(token, "chat.deleteScheduledMessage", {
236
+ channel,
237
+ scheduled_message_id: scheduledMessageId,
238
+ });
239
+ }
240
+
180
241
  export async function editMessage(
181
242
  token: string,
182
243
  channel: string,
@@ -224,19 +285,27 @@ function normName(s: string): string {
224
285
  return s.toLowerCase().replace(/[-_\s]/g, "");
225
286
  }
226
287
 
227
- /** 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.
228
289
  * Supports both forms:
229
290
  * https://app.slack.com/client/T.../C...[/p1700000000000100]
230
- * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...]
291
+ * https://<ws>.slack.com/archives/C...[/p1700000000000100][?thread_ts=...][&cid=C...]
231
292
  */
232
- export function parseSlackPermalink(s: string): { channel: string; ts?: string } | undefined {
293
+ export function parseSlackPermalink(s: string): { channel: string; ts?: string; threadTs?: string } | undefined {
233
294
  const m = s.match(
234
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}))?/,
235
296
  );
236
297
  if (!m) return undefined;
237
298
  const channel = m[1]!;
238
299
  const ts = m[2] && m[3] ? `${m[2]}.${m[3]}` : undefined;
239
- 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;
240
309
  }
241
310
 
242
311
  function parseSlackUrl(s: string): string | undefined {
@@ -360,6 +429,23 @@ export async function userName(token: string, userId: string): Promise<string> {
360
429
  }
361
430
  }
362
431
 
432
+ export async function listUsers(token: string): Promise<Json> {
433
+ const allMembers: Json[] = [];
434
+ let cursor = "";
435
+ while (true) {
436
+ const params: Record<string, string> = { limit: "200" };
437
+ if (cursor) params.cursor = cursor;
438
+ const resp = (await get(token, "users.list", params)) as {
439
+ members?: Json[];
440
+ response_metadata?: { next_cursor?: string };
441
+ };
442
+ allMembers.push(...(resp.members ?? []));
443
+ cursor = resp.response_metadata?.next_cursor ?? "";
444
+ if (!cursor) break;
445
+ }
446
+ return { members: allMembers };
447
+ }
448
+
363
449
  // Draft API (internal — requires xoxc session token + xoxd cookie)
364
450
  export async function listDrafts(token: string, cookie?: string): Promise<Json> {
365
451
  return postSession(token, "drafts.list", {}, cookie);
@@ -412,6 +498,14 @@ export async function conversationInfoSession(token: string, channelId: string,
412
498
  return postSession(token, "conversations.info", { channel: channelId }, cookie);
413
499
  }
414
500
 
501
+ export async function userInfo(token: string, userId: string): Promise<Json> {
502
+ return get(token, "users.info", { user: userId });
503
+ }
504
+
505
+ export async function conversationInfo(token: string, channelId: string): Promise<Json> {
506
+ return get(token, "conversations.info", { channel: channelId });
507
+ }
508
+
415
509
  export async function deleteDraft(token: string, draftId: string, cookie?: string): Promise<Json> {
416
510
  const nowTs = (Date.now() / 1000).toFixed(6);
417
511
  return postSession(token, "drafts.delete", { draft_id: draftId, client_last_updated_ts: nowTs }, cookie);
@@ -476,6 +570,20 @@ export async function uploadFile(
476
570
  return result;
477
571
  }
478
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
+
479
587
  // Safe nested access
480
588
  export function getPath(obj: Json, path: readonly (string | number)[]): Json | undefined {
481
589
  let cur: Json | undefined = obj;