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/README.md +20 -4
- package/SKILL.md +66 -2
- package/dist/cli.js +227 -185
- package/package.json +1 -1
- package/ts/ask.ts +377 -45
- package/ts/cli.ts +1197 -38
- package/ts/slack.ts +68 -11
- package/ts/slackTodos.ts +85 -0
- package/ts/urlGuard.ts +162 -10
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|
package/ts/slackTodos.ts
ADDED
|
@@ -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
|
-
|
|
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(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|