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