slack-term 1.39.2 → 1.41.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/slack.ts CHANGED
@@ -1,12 +1,30 @@
1
1
  // Slack Web API client (user token, Authorization: Bearer)
2
2
  import { resolveCookie } from "./profiles.ts";
3
+ import { linkifyRichText } from "./urlGuard.ts";
3
4
 
4
5
  export class RateLimitError extends Error {
5
6
  retryAfter: number;
6
- constructor(retryAfter: number) {
7
- super(`Slack rate limited — retry after ${retryAfter}s`);
7
+ /**
8
+ * The Web API method that was throttled ("conversations.history", ...).
9
+ *
10
+ * Slack's limits are PER METHOD, so "retry after 30s" alone cannot say whether
11
+ * a caller's own polling caused the 429 or something else sharing the token
12
+ * did. The method name is already in scope at every throw site below; not
13
+ * passing it was the only reason the question was unanswerable.
14
+ *
15
+ * Optional because callers (and tests) constructed this with one argument
16
+ * before, and a required field would break them for no diagnostic gain.
17
+ */
18
+ method?: string;
19
+ constructor(retryAfter: number, method?: string) {
20
+ super(
21
+ method
22
+ ? `Slack rate limited on ${method} — retry after ${retryAfter}s`
23
+ : `Slack rate limited — retry after ${retryAfter}s`,
24
+ );
8
25
  this.name = "RateLimitError";
9
26
  this.retryAfter = retryAfter;
27
+ if (method) this.method = method;
10
28
  }
11
29
  }
12
30
 
@@ -36,12 +54,12 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
36
54
  });
37
55
  if (res.status === 429) {
38
56
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
39
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
57
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
40
58
  }
41
59
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
42
60
  if (body.ok !== true) {
43
61
  const err = body.error ?? "unknown";
44
- if (err === "ratelimited") throw new RateLimitError(60);
62
+ if (err === "ratelimited") throw new RateLimitError(60, method);
45
63
  // A desktop session token (xoxc-) is only accepted by the public Web API when
46
64
  // paired with its xoxd session cookie — without one, point at the xoxp fallback.
47
65
  if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
@@ -89,21 +107,21 @@ async function callSession(token: string, method: string, init: RequestInit, coo
89
107
  });
90
108
  if (res.status === 429) {
91
109
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
92
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
110
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
93
111
  }
94
112
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
95
113
  if (body.ok !== true) {
96
114
  const err = body.error ?? "unknown";
97
- if (err === "ratelimited") throw new RateLimitError(60);
115
+ if (err === "ratelimited") throw new RateLimitError(60, method);
98
116
  if ((err === "invalid_auth" || err === "not_authed") && !token.startsWith("xoxc-")) {
99
117
  throw new Error(
100
- `The draft API requires a desktop app session token (xoxc-).\n` +
118
+ `${method} requires a desktop app session token (xoxc-).\n` +
101
119
  `Import from Slack desktop: slack auth login`,
102
120
  );
103
121
  }
104
122
  if ((err === "invalid_auth" || err === "not_authed") && !cookie) {
105
123
  throw new Error(
106
- `drafts.list also requires the xoxd session cookie.\n` +
124
+ `${method} also requires the xoxd session cookie.\n` +
107
125
  `Attach it with: slack auth firefox (Linux/macOS/Windows) or slack auth chrome (macOS/Linux)`,
108
126
  );
109
127
  }
@@ -185,7 +203,7 @@ export async function authScopes(token: string, cookie?: string): Promise<{
185
203
  const res = await fetch(`${base()}/auth.test`, { method: "GET", headers });
186
204
  if (res.status === 429) {
187
205
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
188
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
206
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, "auth.test");
189
207
  }
190
208
  const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
191
209
  const body = (await res.json()) as {
@@ -594,6 +612,20 @@ export async function createChannel(
594
612
  return { id: resp.channel?.id ?? "", name: resp.channel?.name ?? name };
595
613
  }
596
614
 
615
+ // Rename a channel via conversations.rename. Needs channels:manage (public) or
616
+ // groups:write (private) — and, for a user token, rights to rename it.
617
+ export async function renameChannel(
618
+ token: string,
619
+ channel: string,
620
+ name: string,
621
+ cookie?: string,
622
+ ): Promise<{ id: string; name: string }> {
623
+ const resp = (await post(token, "conversations.rename", { channel, name }, cookie)) as {
624
+ channel?: { id?: string; name?: string };
625
+ };
626
+ return { id: resp.channel?.id ?? channel, name: resp.channel?.name ?? name };
627
+ }
628
+
597
629
  // Invite users to a channel via conversations.invite (up to ~1000 ids).
598
630
  export async function inviteToChannel(
599
631
  token: string,
@@ -1025,6 +1057,19 @@ export async function listConversationMembers(token: string, channel: string, co
1025
1057
  return all;
1026
1058
  }
1027
1059
 
1060
+ // To-dos tab (internal — requires xoxc session token + xoxd cookie). The
1061
+ // desktop client's own read of Slack's personal To-dos; see slackTodos.ts.
1062
+ export async function savedList(
1063
+ token: string,
1064
+ opts: { filter?: string; limit?: number; cursor?: string | undefined } = {},
1065
+ cookie?: string,
1066
+ ): Promise<Json> {
1067
+ const params: Record<string, string> = { limit: String(opts.limit ?? 50) };
1068
+ if (opts.filter) params.filter = opts.filter;
1069
+ if (opts.cursor) params.cursor = opts.cursor;
1070
+ return postSession(token, "saved.list", params, cookie);
1071
+ }
1072
+
1028
1073
  // Draft API (internal — requires xoxc session token + xoxd cookie)
1029
1074
  export async function listDrafts(token: string, cookie?: string): Promise<Json> {
1030
1075
  return postSession(token, "drafts.list", {}, cookie);
@@ -1039,7 +1084,7 @@ export async function createDraft(
1039
1084
  const { randomUUID } = await import("node:crypto");
1040
1085
  const blocks = JSON.stringify([{
1041
1086
  type: "rich_text",
1042
- elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
1087
+ elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
1043
1088
  }]);
1044
1089
  return postSession(token, "drafts.create", {
1045
1090
  client_msg_id: randomUUID(),
@@ -1059,7 +1104,7 @@ export async function updateDraft(
1059
1104
  ): Promise<Json> {
1060
1105
  const blocks = JSON.stringify([{
1061
1106
  type: "rich_text",
1062
- elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
1107
+ elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
1063
1108
  }]);
1064
1109
  // client_last_updated_ts must be the current time (server uses it as new stored ts)
1065
1110
  const nowTs = (Date.now() / 1000).toFixed(6);
@@ -1081,6 +1126,18 @@ export async function userInfo(token: string, userId: string, cookie?: string):
1081
1126
  return get(token, "users.info", { user: userId }, cookie);
1082
1127
  }
1083
1128
 
1129
+ /** A person's Slack UI locale (`ja-JP`, `en-US`), or undefined when it cannot
1130
+ * be read. Fail-soft: it only picks the language of `ask`'s copy, and a lookup
1131
+ * failure must never stop a question from being asked. */
1132
+ export async function userLocale(token: string, userId: string, cookie?: string): Promise<string | undefined> {
1133
+ try {
1134
+ const r = (await get(token, "users.info", { user: userId, include_locale: "true" }, cookie)) as { user?: { locale?: unknown } };
1135
+ return typeof r.user?.locale === "string" ? r.user.locale : undefined;
1136
+ } catch {
1137
+ return undefined;
1138
+ }
1139
+ }
1140
+
1084
1141
  export async function conversationInfo(token: string, channelId: string, cookie?: string): Promise<Json> {
1085
1142
  return get(token, "conversations.info", { channel: channelId }, cookie);
1086
1143
  }
@@ -0,0 +1,85 @@
1
+ // Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — read-only helpers.
2
+ //
3
+ // Not to be confused with `slack todo` (todo.ts), which tracks tasks as
4
+ // reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
5
+ // "Later" in Oct 2026, and there is no public API for it. The desktop client
6
+ // reads it through the internal `saved.list` (xoxc session token + d cookie),
7
+ // which is what `slack todos` calls — so it is per-user, and bot tokens cannot
8
+ // see it at all.
9
+ //
10
+ // A saved item points at a message: `item_id` is the channel id, `ts` the
11
+ // message ts. `todo_state` is "to_do" for a to-do and "saved" for a plain
12
+ // saved item; `state` is "in_progress" until it is checked off. Dates are
13
+ // epoch seconds, 0 when unset. The shape is undocumented, so every field is
14
+ // read defensively.
15
+
16
+ import type { Json } from "./slack.ts";
17
+
18
+ /** `--filter` values `saved.list` accepts (verified against the live API). */
19
+ export const TODO_FILTERS = ["todo", "saved", "completed", "archived"] as const;
20
+ export type TodoFilter = (typeof TODO_FILTERS)[number];
21
+
22
+ export type TodoItem = {
23
+ channel: string;
24
+ ts: string;
25
+ /** Epoch seconds; 0 = no due date. */
26
+ due: number;
27
+ /** Epoch seconds; 0 = not completed. */
28
+ completed: number;
29
+ /** "to_do" | "saved" (as Slack reports it). */
30
+ todoState: string;
31
+ state: string;
32
+ archived: boolean;
33
+ itemType: string;
34
+ };
35
+
36
+ const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
37
+ const num = (v: Json | undefined): number => {
38
+ const n = typeof v === "number" || typeof v === "string" ? Number(v) : 0;
39
+ return Number.isFinite(n) ? n : 0;
40
+ };
41
+
42
+ /** The items of one `saved.list` page, normalized. Non-message items are kept
43
+ * (their `itemType` says what they are) but carry no channel/ts to read. */
44
+ export function parseSavedItems(resp: Json): TodoItem[] {
45
+ const r = (resp && typeof resp === "object" && !Array.isArray(resp) ? resp : {}) as Record<string, Json>;
46
+ const items = Array.isArray(r.saved_items) ? r.saved_items : [];
47
+ return items
48
+ .filter((x): x is Record<string, Json> => !!x && typeof x === "object" && !Array.isArray(x))
49
+ .map((x) => ({
50
+ channel: str(x.item_id),
51
+ ts: str(x.ts),
52
+ due: num(x.date_due),
53
+ completed: num(x.date_completed),
54
+ todoState: str(x.todo_state),
55
+ state: str(x.state),
56
+ archived: x.is_archived === true,
57
+ itemType: str(x.item_type),
58
+ }));
59
+ }
60
+
61
+ /** Soonest due first; items without a due date last, newest message first among them. */
62
+ export function sortTodos(items: TodoItem[]): TodoItem[] {
63
+ return [...items].sort((a, b) => {
64
+ if (a.due && b.due) return a.due - b.due;
65
+ if (a.due !== b.due) return a.due ? -1 : 1;
66
+ return Number(b.ts) - Number(a.ts);
67
+ });
68
+ }
69
+
70
+ export function isDone(t: TodoItem): boolean {
71
+ return t.completed > 0 || t.state === "completed";
72
+ }
73
+
74
+ /** The status column: a checkbox, plus the due date (and OVERDUE when past it). */
75
+ export function todoStatus(t: TodoItem, nowSec: number, fmt: (epochSec: number) => string): string {
76
+ const box = isDone(t) ? "☑" : t.todoState === "to_do" ? "☐" : "·";
77
+ if (!t.due) return `${box} no due date`;
78
+ const overdue = !isDone(t) && t.due < nowSec ? " OVERDUE" : "";
79
+ return `${box} due ${fmt(t.due)}${overdue}`;
80
+ }
81
+
82
+ /** A message permalink built from the workspace URL — no API call per item. */
83
+ export function messagePermalink(teamUrl: string, channel: string, ts: string): string {
84
+ return `${teamUrl.replace(/\/+$/, "")}/archives/${channel}/p${ts.replace(".", "")}`;
85
+ }
package/ts/urlGuard.ts CHANGED
@@ -1,20 +1,172 @@
1
1
  /** Bare Unicode URL text has an ambiguous boundary for Slack's autolinker.
2
2
  * Explicit Slack links delimit the URL, including intentional Unicode paths.
3
3
  * ASCII whitespace (especially newlines) is a safe boundary; Unicode spaces
4
- * should be made explicit too. Do not rewrite the message or its newlines. */
4
+ * should be made explicit too. Do not rewrite the message or its newlines.
5
+ *
6
+ * Both ends matter. A URL glued to the text AFTER it (`https://x/内容`) gets the
7
+ * text swallowed into the link; a URL glued to the text BEFORE it
8
+ * (`说明:https://x/`) is not autolinked at all — Slack stores it as a plain
9
+ * `text` element and every client shows it as dead text. The markdown
10
+ * autolinker (GFM rules) only starts a link at the start of a line, after
11
+ * ASCII whitespace, or after one of `( * _ ~`. */
12
+
13
+ // `\x00` is maskCode's filler: a bare URL stops at a masked span, so a URL
14
+ // on the far side of one is its own token with its own left boundary.
15
+ const URL_TOKEN = /<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f\r <\x00]+/gi;
16
+ /** What may sit right before a bare URL and still let it autolink. */
17
+ const SAFE_BEFORE = /[\t\n\v\f\r (*_~]/;
18
+
19
+ export type UrlBoundaryIssue = { url: string; side: "before" | "after"; char: string };
20
+
21
+ /** Code spans and fences are shown verbatim and never autolinked, so a URL in
22
+ * one is not a boundary problem; neither is a `[label](url)` link, which is
23
+ * explicit. Blanked (length-preserving, newlines kept) rather than removed so
24
+ * indices still line up with the original text; the filler is a non-space so a
25
+ * URL glued to the END of a span is still caught.
26
+ *
27
+ * A small CommonMark-shaped scanner, not a regex: a backslash escapes the
28
+ * next character (`\`` is a literal backtick, not a span opener), a span
29
+ * opened by a run of N backticks closes only at the next run of exactly N,
30
+ * and an unclosed run is literal text. A fence is ``` or ~~~ at line start
31
+ * and runs to a matching fence line, or to the end. */
32
+ function maskCode(text: string): string {
33
+ const out = text.split("");
34
+ const blank = (from: number, to: number) => {
35
+ for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = "\x00";
36
+ };
37
+ let i = 0;
38
+ while (i < text.length) {
39
+ const lineStart = i === 0 || text[i - 1] === "\n";
40
+ // A backtick fence's info string may not contain a backtick (```x``` on
41
+ // one line is an inline span, not a fence opener).
42
+ const fence = lineStart ? /^ {0,3}(?:(`{3,})[^`\n]*|(~{3,})[^\n]*)(?=\n|$)/.exec(text.slice(i)) : null;
43
+ if (fence) {
44
+ const marker = fence[1] ?? fence[2]!;
45
+ const close = new RegExp(`\n {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}[ \t]*\r?(?=\n|$)`, "g");
46
+ close.lastIndex = i + fence[0].length;
47
+ const m = close.exec(text);
48
+ const end = m ? m.index + m[0].length : text.length;
49
+ blank(i, end);
50
+ i = end;
51
+ continue;
52
+ }
53
+ const ch = text[i]!;
54
+ if (ch === "\\") { i += 2; continue; }
55
+ if (ch === "`") {
56
+ let n = 0;
57
+ while (text[i + n] === "`") n++;
58
+ let j = i + n;
59
+ let end = -1;
60
+ while (j < text.length) {
61
+ // A blank line ends the paragraph, and a span cannot cross it.
62
+ if (text[j] === "\n" && /^\n[ \t\r]*(?:\n|$)/.test(text.slice(j))) break;
63
+ if (text[j] !== "`") { j++; continue; }
64
+ let r = 0;
65
+ while (text[j + r] === "`") r++;
66
+ if (r === n) { end = j + r; break; }
67
+ j += r;
68
+ }
69
+ if (end < 0) { i += n; continue; }
70
+ blank(i, end);
71
+ i = end;
72
+ continue;
73
+ }
74
+ if (ch === "[") {
75
+ // Escape-aware: `\]` does not close the label.
76
+ const link = /^\[(?:\\.|[^\]\\\n])*\]\([^()\s]*\)/.exec(text.slice(i));
77
+ if (link) { blank(i, i + link[0].length); i += link[0].length; continue; }
78
+ }
79
+ i++;
80
+ }
81
+ return out.join("");
82
+ }
83
+
84
+ /** `markdown: false` is for text that is NOT parsed as markdown (drafts, whose
85
+ * rich_text is built by `linkifyRichText`): there a backtick or `[label](url)`
86
+ * hides nothing, so nothing is masked. */
87
+ export function urlBoundaryIssues(text: string, { markdown = true }: { markdown?: boolean } = {}): UrlBoundaryIssue[] {
88
+ const masked = markdown ? maskCode(text) : text;
89
+ const issues: UrlBoundaryIssue[] = [];
90
+ for (const m of masked.matchAll(URL_TOKEN)) {
91
+ const url = m[0];
92
+ if (url.startsWith("<")) continue;
93
+ const at = m.index ?? 0;
94
+ const before = at > 0 ? masked[at - 1]! : "";
95
+ if (before && !SAFE_BEFORE.test(before)) {
96
+ issues.push({ url, side: "before", char: text[at - 1]! });
97
+ }
98
+ const after = url.match(/[^\x00-\x7f]|>/);
99
+ if (after) issues.push({ url, side: "after", char: after[0] });
100
+ }
101
+ return issues;
102
+ }
103
+
5
104
  export function adjacentUrls(text: string): string[] {
6
- const tokens = text.matchAll(/<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f\r <]+/gi);
7
- return [...tokens]
8
- .map(([token]) => token)
9
- .filter((token) => !token.startsWith("<") && (/[^\x00-\x7f]/.test(token) || token.includes(">")));
105
+ return [...new Set(urlBoundaryIssues(text).map((i) => i.url))];
10
106
  }
11
107
 
12
- export function guardUrlBoundaries(text: string, allow = false, warn: (message: string) => void = console.error): void {
13
- const urls = adjacentUrls(text);
14
- if (!urls.length) return;
15
- const message = `Ambiguous URL boundary: ${urls.map((url) => JSON.stringify(url)).join(", ")}. ` +
16
- "Put the URL on its own line, or wrap it as <url> / <url|label>. " +
108
+ export function guardUrlBoundaries(
109
+ text: string, allow = false, warn: (message: string) => void = console.error,
110
+ { sides = ["before", "after"], markdown = true }: {
111
+ sides?: ReadonlyArray<UrlBoundaryIssue["side"]>; markdown?: boolean;
112
+ } = {},
113
+ ): void {
114
+ const issues = urlBoundaryIssues(text, { markdown }).filter((i) => sides.includes(i.side));
115
+ if (!issues.length) return;
116
+ const described = issues.map((i) => i.side === "before"
117
+ ? `${JSON.stringify(i.url)} (glued to ${JSON.stringify(i.char)} before it — Slack will NOT make it a link)`
118
+ : `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it)`);
119
+ const fixes = issues.some((i) => i.side === "before")
120
+ ? "Put an ASCII space (or a newline) before the URL, " : "";
121
+ const message = `Ambiguous URL boundary: ${described.join(", ")}. ` +
122
+ `${fixes}Put the URL on its own line, or wrap it as <url> / <url|label>. ` +
17
123
  "Use --allow-url-adjacent to warn only.";
18
124
  if (!allow) throw new Error(message);
19
125
  warn(`Warning: ${message}`);
20
126
  }
127
+
128
+ type RichTextElement =
129
+ | { type: "text"; text: string }
130
+ | { type: "link"; url: string; text?: string };
131
+
132
+ /** Elements for a `rich_text_section` with every URL as a real `link` element.
133
+ * Drafts are posted as raw rich_text, which Slack never autolinks: a URL left
134
+ * in a `text` element stays dead text however it is spaced. A bare URL ends at
135
+ * whitespace, `<`, or the first non-ASCII character (`https://x/内容` links
136
+ * `https://x/` only), and trailing sentence punctuation is left outside it.
137
+ * Whether that cut is the intended one is ambiguous, so callers gate it with
138
+ * `guardUrlBoundaries(text, allow, warn, { sides: ["after"], markdown: false })`
139
+ * first. */
140
+ export function linkifyRichText(text: string): RichTextElement[] {
141
+ const out: RichTextElement[] = [];
142
+ const pushText = (s: string) => {
143
+ if (!s) return;
144
+ const last = out[out.length - 1];
145
+ if (last?.type === "text") last.text += s;
146
+ else out.push({ type: "text", text: s });
147
+ };
148
+ const re = /<(https?:\/\/[^\s<>|]+)(?:\|([^<>\r\n]*))?>|https?:\/\/[\x21-\x3b\x3d\x3f-\x7e]+/gi;
149
+ let pos = 0;
150
+ for (const m of text.matchAll(re)) {
151
+ const at = m.index ?? 0;
152
+ pushText(text.slice(pos, at));
153
+ if (m[1]) {
154
+ out.push(m[2] ? { type: "link", url: m[1], text: m[2] } : { type: "link", url: m[1] });
155
+ pos = at + m[0].length;
156
+ continue;
157
+ }
158
+ let url = m[0];
159
+ // GFM-style trailing trim: sentence punctuation, and `)` with no `(` to match.
160
+ while (/[.,:;!?'"*_~]$/.test(url) || (url.endsWith(")") && count(url, "(") < count(url, ")"))) {
161
+ url = url.slice(0, -1);
162
+ }
163
+ out.push({ type: "link", url });
164
+ pos = at + url.length;
165
+ }
166
+ pushText(text.slice(pos));
167
+ return out;
168
+ }
169
+
170
+ function count(s: string, ch: string): number {
171
+ return s.split(ch).length - 1;
172
+ }