@vercel/board 0.0.0-stage → 0.15.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 +179 -2
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-ojFEXEyN.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +262 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +499 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +165 -3
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
|
|
2
|
+
import "./contract-CuJmlrhw.mjs";
|
|
3
|
+
import { c as eventSchema, i as UnreadableError, s as envelope } from "./provider-CuewcDNZ.mjs";
|
|
4
|
+
import { r as withUserTokens, t as connectUserTokens } from "./users-Dls7_ANV.mjs";
|
|
5
|
+
import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-jFSeyNmB.mjs";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { stringifyMarkdown } from "chat";
|
|
8
|
+
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
9
|
+
import { connectSlackAdapter } from "@vercel/connect/chat";
|
|
10
|
+
//#region src/sync/slack.ts
|
|
11
|
+
/** The `**name**: ` a message's text opens with, taken out when Slack shows the name as the sender. */
|
|
12
|
+
function withoutLabel(markdown, name) {
|
|
13
|
+
const label = new RegExp(`^\\*\\*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\*\\*: `);
|
|
14
|
+
return markdown.replace(label, "");
|
|
15
|
+
}
|
|
16
|
+
/** HTML elements GitHub renders in comments; anything else in angle brackets (`<@U123>`, `<https://…>`, `a < b`) is left alone. */
|
|
17
|
+
const HTML_TAGS = new Set("a abbr b bdi bdo blockquote br caption center cite code dd del details dfn div dl dt em figcaption figure font h1 h2 h3 h4 h5 h6 hr i img ins kbd li mark ol p picture pre q rp rt ruby s samp section small source span strike strong sub summary sup table tbody td tfoot th thead time tr tt u ul var video wbr".split(" "));
|
|
18
|
+
const MARKS = {
|
|
19
|
+
b: "**",
|
|
20
|
+
strong: "**",
|
|
21
|
+
i: "_",
|
|
22
|
+
em: "_",
|
|
23
|
+
code: "`",
|
|
24
|
+
kbd: "`",
|
|
25
|
+
s: "~~",
|
|
26
|
+
del: "~~",
|
|
27
|
+
strike: "~~"
|
|
28
|
+
};
|
|
29
|
+
const BLOCKS = /* @__PURE__ */ new Set([
|
|
30
|
+
"p",
|
|
31
|
+
"div",
|
|
32
|
+
"details",
|
|
33
|
+
"table",
|
|
34
|
+
"tr",
|
|
35
|
+
"ul",
|
|
36
|
+
"ol",
|
|
37
|
+
"li",
|
|
38
|
+
"blockquote",
|
|
39
|
+
"pre",
|
|
40
|
+
"hr",
|
|
41
|
+
"h1",
|
|
42
|
+
"h2",
|
|
43
|
+
"h3",
|
|
44
|
+
"h4",
|
|
45
|
+
"h5",
|
|
46
|
+
"h6"
|
|
47
|
+
]);
|
|
48
|
+
const attribute = (attrs, name) => new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs)?.slice(1).find((v) => v !== void 0) ?? "";
|
|
49
|
+
/** GitHub's markdown allows inline HTML, which Slack shows as raw tags: as markdown instead, outside code. */
|
|
50
|
+
function withoutHtml(markdown) {
|
|
51
|
+
if (!/<\/?[a-z!]/i.test(markdown) || /[\uE000\uE001]/.test(markdown)) return markdown;
|
|
52
|
+
const code = [];
|
|
53
|
+
let hidden = false;
|
|
54
|
+
const text = markdown.replace(/(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|``[^\n]*?``|`[^`\n]+`)|<!--[\s\S]*?(?:-->|$)/g, (_, span) => span === void 0 ? (hidden = true, "") : `\uE000${code.push(span) - 1}\uE001`);
|
|
55
|
+
const out = text.replace(/<img\b([^>]*)>/gi, (_, attrs) => attribute(attrs, "alt")).replace(/<source\b[^>]*>/gi, "").replace(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi, (_, attrs, inner) => {
|
|
56
|
+
const href = attribute(attrs, "href");
|
|
57
|
+
const label = inner.replace(/<[^>]*>/g, "").trim();
|
|
58
|
+
return href ? label ? `[${label}](${href})` : href : label;
|
|
59
|
+
}).replace(/<br\s*\/?>/gi, "\n").replace(/<\/?([a-z][a-z0-9]*)\b(?:\s[^<>]*)?\/?>/gi, (tag, name) => {
|
|
60
|
+
const lower = name.toLowerCase();
|
|
61
|
+
if (!HTML_TAGS.has(lower)) return tag;
|
|
62
|
+
const closing = tag.startsWith("</");
|
|
63
|
+
if (lower === "summary") return closing ? "**\n\n" : "**";
|
|
64
|
+
return MARKS[lower] ?? (BLOCKS.has(lower) && closing ? "\n" : "");
|
|
65
|
+
});
|
|
66
|
+
if (!hidden && out === text) return markdown;
|
|
67
|
+
return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+|\n+$/g, "").replace(/\uE000(\d+)\uE001/g, (_, i) => code[Number(i)]);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A sender's picture as Slack's `icon_url`: an https image. The board's built-in agent
|
|
71
|
+
* avatars are SVGs, which Slack won't show, so those keep the app's icon.
|
|
72
|
+
*/
|
|
73
|
+
function slackIcon(avatar) {
|
|
74
|
+
if (!avatar) return null;
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(avatar);
|
|
77
|
+
return url.protocol === "https:" && !/\/avatars\/[a-z0-9-]+$/.test(url.pathname) && !url.pathname.endsWith(".svg") ? avatar : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Slack message permalinks in mrkdwn: `https://acme.slack.com/archives/C0123/p1727000000123456`,
|
|
84
|
+
* with `?thread_ts=…` for a reply. Slack escapes `&` in text as `&`.
|
|
85
|
+
*/
|
|
86
|
+
const PERMALINK = /https:\/\/[a-z0-9-]+(?:\.enterprise)?\.slack\.com\/archives\/([A-Z0-9]+)\/p(\d{10})(\d{6})((?:\?|&|&)[^\s|>]*)?/g;
|
|
87
|
+
/** `1727000000.123456` → its ISO time. */
|
|
88
|
+
const tsTime = (ts) => {
|
|
89
|
+
const seconds = Number(ts);
|
|
90
|
+
return ts && Number.isFinite(seconds) ? (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString() : null;
|
|
91
|
+
};
|
|
92
|
+
const filesOf = (files) => (files ?? []).slice(0, 20).map((file) => ({
|
|
93
|
+
name: (file.name || file.title || "file").slice(0, 1e3),
|
|
94
|
+
url: file.permalink?.slice(0, 4e3) ?? null
|
|
95
|
+
}));
|
|
96
|
+
/** A permalink's channel, ts and thread, or null when `url` isn't a Slack message's. */
|
|
97
|
+
function permalinkOf(url) {
|
|
98
|
+
const match = new RegExp(PERMALINK.source).exec(url);
|
|
99
|
+
if (!match || match.index !== 0) return null;
|
|
100
|
+
const [, channel, seconds, micros, query] = match;
|
|
101
|
+
const ts = `${seconds}.${micros}`;
|
|
102
|
+
return {
|
|
103
|
+
url: match[0].replaceAll("&", "&"),
|
|
104
|
+
channel,
|
|
105
|
+
ts,
|
|
106
|
+
thread: /[?&]thread_ts=(\d+\.\d+)/.exec(query?.replaceAll("&", "&") ?? "")?.[1] ?? ts
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** A Slack attachment as a link's snapshot. */
|
|
110
|
+
const snapshotOf = (att, ts, markdown) => ({
|
|
111
|
+
author: (att.author_subname || att.author_name || att.author_id || "someone").slice(0, 256),
|
|
112
|
+
text: markdown(att.text ?? "").slice(0, MESSAGE_TEXT_MAX),
|
|
113
|
+
createdAt: tsTime(att.ts ?? ts),
|
|
114
|
+
files: filesOf(att.files)
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Every Slack message a message's mrkdwn links to, in order, with the unfurls Slack attached for them,
|
|
118
|
+
* then the messages it forwards: a forward's shared message is only in its `is_share` attachments.
|
|
119
|
+
*/
|
|
120
|
+
function slackLinks(key, text, attachments = [], markdown = (t) => t) {
|
|
121
|
+
const links = [];
|
|
122
|
+
const add = (found, att) => {
|
|
123
|
+
const known = links.find((link) => link.url === found.url || link.thread === `${key}:${found.channel}:${found.thread}` && link.message === found.ts);
|
|
124
|
+
if (known) {
|
|
125
|
+
if (!known.snapshot && att) known.snapshot = snapshotOf(att, found.ts, markdown);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (found.url.length > 4e3 || links.length === 10) return;
|
|
129
|
+
links.push({
|
|
130
|
+
url: found.url,
|
|
131
|
+
thread: `${key}:${found.channel}:${found.thread}`,
|
|
132
|
+
message: found.ts,
|
|
133
|
+
snapshot: att ? snapshotOf(att, found.ts, markdown) : null
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
for (const match of text.matchAll(PERMALINK)) {
|
|
137
|
+
const found = permalinkOf(match[0]);
|
|
138
|
+
add(found, attachments.find((att) => att.is_msg_unfurl && (att.channel_id === found.channel && att.ts === found.ts || att.from_url?.replaceAll("&", "&") === found.url)));
|
|
139
|
+
}
|
|
140
|
+
for (const att of attachments) {
|
|
141
|
+
const found = att.is_share ? permalinkOf(att.from_url ?? att.original_url ?? "") : null;
|
|
142
|
+
if (found) add(found, att);
|
|
143
|
+
}
|
|
144
|
+
return links;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* What a forward with no text of its own says: the messages it shares, quoted,
|
|
148
|
+
* so the board (and whoever watches it) reads what was forwarded, not "(no text)".
|
|
149
|
+
*/
|
|
150
|
+
function forwardText(links) {
|
|
151
|
+
return links.filter((link) => link.snapshot).map((link) => {
|
|
152
|
+
const { author, text } = link.snapshot;
|
|
153
|
+
const lines = text.trim() ? text.trim().split("\n") : [""];
|
|
154
|
+
return [.../^(```|~~~|[-*+] |\d+[.)] |#{1,6} |>)/.test(lines[0]) ? [`> **${author}**:`, `> ${lines[0]}`] : [`> **${author}**: ${lines[0]}`], ...lines.slice(1).map((line) => `> ${line}`)].map((line) => line.trimEnd()).concat("", link.url).join("\n");
|
|
155
|
+
}).join("\n\n");
|
|
156
|
+
}
|
|
157
|
+
/** Slack's answers that mean "no files from this app", not "try again". */
|
|
158
|
+
const REFUSED = /* @__PURE__ */ new Set([
|
|
159
|
+
"missing_scope",
|
|
160
|
+
"not_allowed_token_type",
|
|
161
|
+
"no_permission",
|
|
162
|
+
"restricted_action",
|
|
163
|
+
"file_uploads_disabled",
|
|
164
|
+
"file_uploads_except_images_disabled",
|
|
165
|
+
"file_upload_size_restricted"
|
|
166
|
+
]);
|
|
167
|
+
/** A Slack API error's code (`missing_scope`), with the scope it needed. */
|
|
168
|
+
function slackError(error) {
|
|
169
|
+
const data = error.data;
|
|
170
|
+
if (!data?.error) return error.message;
|
|
171
|
+
return data.needed ? `${data.error}, needs ${data.needed}` : data.error;
|
|
172
|
+
}
|
|
173
|
+
/** Extensions Slack shows as text (a preview, not a download): it calls any other extension binary. */
|
|
174
|
+
const SLACK_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
175
|
+
"txt",
|
|
176
|
+
"md",
|
|
177
|
+
"markdown",
|
|
178
|
+
"csv",
|
|
179
|
+
"json",
|
|
180
|
+
"yaml",
|
|
181
|
+
"yml",
|
|
182
|
+
"xml",
|
|
183
|
+
"html",
|
|
184
|
+
"css",
|
|
185
|
+
"js",
|
|
186
|
+
"ts",
|
|
187
|
+
"py",
|
|
188
|
+
"rb",
|
|
189
|
+
"go",
|
|
190
|
+
"rs",
|
|
191
|
+
"java",
|
|
192
|
+
"c",
|
|
193
|
+
"h",
|
|
194
|
+
"cpp",
|
|
195
|
+
"sh",
|
|
196
|
+
"bash",
|
|
197
|
+
"sql",
|
|
198
|
+
"diff",
|
|
199
|
+
"patch"
|
|
200
|
+
]);
|
|
201
|
+
const TEXT_TYPES = /^(text\/|application\/(json|x-ndjson|xml|yaml|x-yaml|javascript|x-sh)\b)/;
|
|
202
|
+
/**
|
|
203
|
+
* The name a file goes to Slack under. Slack picks a file's type from its
|
|
204
|
+
* extension, so a text file named `run.log` arrives as a binary download;
|
|
205
|
+
* `run.log.txt` arrives as text it previews. The board keeps the file's own name.
|
|
206
|
+
*/
|
|
207
|
+
function slackFileName(filename, mimeType) {
|
|
208
|
+
if (!mimeType || !TEXT_TYPES.test(mimeType)) return filename;
|
|
209
|
+
const extension = /\.([^./]+)$/.exec(filename)?.[1]?.toLowerCase();
|
|
210
|
+
return extension && SLACK_TEXT_EXTENSIONS.has(extension) ? filename : `${filename}.txt`;
|
|
211
|
+
}
|
|
212
|
+
const SlackEvent = eventSchema("slack", "message");
|
|
213
|
+
/**
|
|
214
|
+
* Syncs Slack channels with board channels. Keys are channels by name
|
|
215
|
+
* (`'#prs'`) or ID (`'C0123ABC'`); values are a board channel (a two-way
|
|
216
|
+
* mirror) or a callback that handles every incoming event. The app joins public
|
|
217
|
+
* channels it isn't in; private ones need an invite.
|
|
218
|
+
*/
|
|
219
|
+
function slack(options) {
|
|
220
|
+
return new SlackProvider(options);
|
|
221
|
+
}
|
|
222
|
+
/** A Slack adapter, client, and people's tokens, all from one Vercel Connect connector. */
|
|
223
|
+
function fromConnector(connector) {
|
|
224
|
+
const credentials = connectSlackAdapter(connector);
|
|
225
|
+
const adapter = withUserTokens(createSlackAdapter(credentials), connectUserTokens(connector));
|
|
226
|
+
const botToken = credentials.botToken;
|
|
227
|
+
const client = async () => adapter.withBotToken(typeof botToken === "function" ? await botToken() : botToken, () => adapter.webClient);
|
|
228
|
+
return {
|
|
229
|
+
adapter,
|
|
230
|
+
client
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
var SlackProvider = class extends ChatSyncProvider {
|
|
234
|
+
web;
|
|
235
|
+
customize;
|
|
236
|
+
constructor(given) {
|
|
237
|
+
const connected = typeof given.adapter === "string" ? fromConnector(given.adapter) : null;
|
|
238
|
+
const options = connected ? {
|
|
239
|
+
...given,
|
|
240
|
+
adapter: connected.adapter,
|
|
241
|
+
client: given.client ?? connected.client
|
|
242
|
+
} : {
|
|
243
|
+
...given,
|
|
244
|
+
adapter: given.adapter
|
|
245
|
+
};
|
|
246
|
+
const tokens = options.adapter;
|
|
247
|
+
let directClient;
|
|
248
|
+
if (!options.client) try {
|
|
249
|
+
directClient = options.adapter.webClient;
|
|
250
|
+
} catch {
|
|
251
|
+
throw new Error("slack requires a public client resolver when the adapter uses async tokens");
|
|
252
|
+
}
|
|
253
|
+
async function client() {
|
|
254
|
+
return options.client ? options.client() : directClient;
|
|
255
|
+
}
|
|
256
|
+
let listing;
|
|
257
|
+
let publicOnly = false;
|
|
258
|
+
async function list(client, types) {
|
|
259
|
+
const channels = [];
|
|
260
|
+
let cursor;
|
|
261
|
+
do {
|
|
262
|
+
const page = await client.conversations.list({
|
|
263
|
+
types,
|
|
264
|
+
exclude_archived: true,
|
|
265
|
+
limit: 1e3,
|
|
266
|
+
...cursor ? { cursor } : {}
|
|
267
|
+
});
|
|
268
|
+
channels.push(...page.channels ?? []);
|
|
269
|
+
cursor = page.response_metadata?.next_cursor || void 0;
|
|
270
|
+
} while (cursor);
|
|
271
|
+
return channels;
|
|
272
|
+
}
|
|
273
|
+
async function listAll(client) {
|
|
274
|
+
if (!publicOnly) try {
|
|
275
|
+
return await list(client, "public_channel,private_channel");
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (!slackError(error).startsWith("missing_scope")) throw error;
|
|
278
|
+
publicOnly = true;
|
|
279
|
+
}
|
|
280
|
+
return list(client, "public_channel");
|
|
281
|
+
}
|
|
282
|
+
function channels(client, fresh) {
|
|
283
|
+
if (fresh || !listing) {
|
|
284
|
+
const promise = listAll(client);
|
|
285
|
+
promise.catch(() => {
|
|
286
|
+
if (listing === promise) listing = void 0;
|
|
287
|
+
});
|
|
288
|
+
listing = promise;
|
|
289
|
+
}
|
|
290
|
+
return listing;
|
|
291
|
+
}
|
|
292
|
+
async function find(client, name) {
|
|
293
|
+
const match = (all) => all.find((channel) => channel.name === name && !!channel.id);
|
|
294
|
+
let found;
|
|
295
|
+
try {
|
|
296
|
+
found = match(await channels(client, false)) ?? match(await channels(client, true));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new UnreadableError("list_failed", `can't list Slack channels to find #${name} (${slackError(error)}); use its ID instead`);
|
|
299
|
+
}
|
|
300
|
+
if (!found) throw new UnreadableError("channel_not_found", publicOnly ? `no public Slack channel named #${name} (the app lacks groups:read, so a private channel needs its ID)` : `no Slack channel named #${name} (a private channel shows up once the app is invited)`);
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
303
|
+
const joined = /* @__PURE__ */ new Map();
|
|
304
|
+
function join(client, id, known) {
|
|
305
|
+
let promise = joined.get(id);
|
|
306
|
+
if (!promise) {
|
|
307
|
+
promise = (async () => {
|
|
308
|
+
const channel = known ?? await client.conversations.info({ channel: id }).then((r) => r.channel, () => void 0);
|
|
309
|
+
if (!channel || channel.is_member) return;
|
|
310
|
+
const label = channel.name ? `#${channel.name}` : id;
|
|
311
|
+
if (channel.is_private) return console.warn(`[board] slack: invite the app to ${label}; it can't join private channels itself`);
|
|
312
|
+
try {
|
|
313
|
+
await client.conversations.join({ channel: id });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
joined.delete(id);
|
|
316
|
+
console.warn(`[board] slack: couldn't join ${label} (${slackError(error)}); invite the app to it`);
|
|
317
|
+
}
|
|
318
|
+
})();
|
|
319
|
+
joined.set(id, promise);
|
|
320
|
+
}
|
|
321
|
+
return promise;
|
|
322
|
+
}
|
|
323
|
+
const avatars = /* @__PURE__ */ new Map();
|
|
324
|
+
function avatarOf(user) {
|
|
325
|
+
let promise = avatars.get(user);
|
|
326
|
+
if (!promise) {
|
|
327
|
+
promise = client().then(async (web) => {
|
|
328
|
+
const profile = (await web?.users?.info({ user }))?.user?.profile;
|
|
329
|
+
return profile?.image_72 ?? profile?.image_48 ?? null;
|
|
330
|
+
}).catch(() => null);
|
|
331
|
+
avatars.set(user, promise);
|
|
332
|
+
}
|
|
333
|
+
return promise;
|
|
334
|
+
}
|
|
335
|
+
const converter = options.adapter.formatConverter;
|
|
336
|
+
const mrkdwn = (text) => {
|
|
337
|
+
try {
|
|
338
|
+
return converter ? stringifyMarkdown(converter.toAst(text)).trim() : text;
|
|
339
|
+
} catch {
|
|
340
|
+
return text;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
const names = /* @__PURE__ */ new Map();
|
|
344
|
+
function nameOf(user) {
|
|
345
|
+
let promise = names.get(user);
|
|
346
|
+
if (!promise) {
|
|
347
|
+
promise = client().then(async (web) => {
|
|
348
|
+
const found = (await web?.users?.info({ user }))?.user;
|
|
349
|
+
return found?.profile?.real_name || found?.real_name || found?.profile?.display_name || found?.name || user;
|
|
350
|
+
}).catch(() => user);
|
|
351
|
+
names.set(user, promise);
|
|
352
|
+
}
|
|
353
|
+
return promise;
|
|
354
|
+
}
|
|
355
|
+
const fileIds = (raw) => (raw?.uploadedFileIds ?? []).filter(Boolean);
|
|
356
|
+
const internals = options.adapter;
|
|
357
|
+
const singleWorkspace = !!internals.defaultBotTokenProvider && !internals.installationProvider && !internals.clientId;
|
|
358
|
+
super({
|
|
359
|
+
fileIds,
|
|
360
|
+
fileToFetch(file) {
|
|
361
|
+
if (!singleWorkspace || !file.fetchMetadata) return file;
|
|
362
|
+
const { teamId: _team, enterpriseId: _enterprise, isEnterpriseInstall: _install, ...rest } = file.fetchMetadata;
|
|
363
|
+
return {
|
|
364
|
+
...file,
|
|
365
|
+
fetchMetadata: rest
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
render: options.render,
|
|
369
|
+
platform: "slack",
|
|
370
|
+
adapter: options.adapter,
|
|
371
|
+
canStartThreads: true,
|
|
372
|
+
async uploadFiles(thread, files) {
|
|
373
|
+
const sent = await options.adapter.postMessage(thread, {
|
|
374
|
+
markdown: "",
|
|
375
|
+
files
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
message: sent.id,
|
|
379
|
+
files: fileIds(sent.raw)
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
uploadName: (file) => slackFileName(file.filename, file.mimeType),
|
|
383
|
+
refusesFiles: (error) => REFUSED.has(error?.data?.error ?? ""),
|
|
384
|
+
async deleteFiles(ids) {
|
|
385
|
+
const web = await client();
|
|
386
|
+
for (const file of ids) await web?.files?.delete({ file }).catch((error) => console.warn(`[board] slack: couldn't delete file ${file} (${slackError(error)})`));
|
|
387
|
+
},
|
|
388
|
+
async findByEmail(email) {
|
|
389
|
+
const user = (await (await client())?.users?.lookupByEmail?.({ email }).catch(() => void 0))?.user;
|
|
390
|
+
if (!user?.id) return null;
|
|
391
|
+
const handle = user.profile?.display_name || user.name || user.id;
|
|
392
|
+
return {
|
|
393
|
+
id: `slack:user:${user.id}`,
|
|
394
|
+
handle,
|
|
395
|
+
name: user.profile?.real_name || user.real_name || handle,
|
|
396
|
+
avatar: user.profile?.image_72 ?? user.profile?.image_48 ?? null,
|
|
397
|
+
isBot: user.is_bot === true
|
|
398
|
+
};
|
|
399
|
+
},
|
|
400
|
+
mention: (actor) => actor.id.startsWith("slack:user:") ? `<@${actor.id.slice(11)}>` : null,
|
|
401
|
+
async resolveChannel(key) {
|
|
402
|
+
if (!key.startsWith("#")) {
|
|
403
|
+
const web = await client().catch(() => void 0);
|
|
404
|
+
if (web) await join(web, key);
|
|
405
|
+
return key;
|
|
406
|
+
}
|
|
407
|
+
const web = await client();
|
|
408
|
+
if (!web) throw new Error(`can't look up Slack channel ${key}: use its ID instead`);
|
|
409
|
+
const channel = await find(web, key.slice(1));
|
|
410
|
+
await join(web, channel.id, channel);
|
|
411
|
+
return channel.id;
|
|
412
|
+
},
|
|
413
|
+
markdown(message) {
|
|
414
|
+
const text = markdownOf(message);
|
|
415
|
+
if (text.trim()) return text;
|
|
416
|
+
const raw = message.raw;
|
|
417
|
+
const shared = Array.isArray(raw?.attachments) ? raw.attachments.filter((att) => att.is_share) : [];
|
|
418
|
+
return shared.length ? forwardText(slackLinks("", "", shared, mrkdwn)) : text;
|
|
419
|
+
},
|
|
420
|
+
links(key, message) {
|
|
421
|
+
const raw = message.raw;
|
|
422
|
+
return slackLinks(key, typeof raw?.text === "string" ? raw.text : message.text, Array.isArray(raw?.attachments) ? raw.attachments : [], mrkdwn);
|
|
423
|
+
},
|
|
424
|
+
async fetchLink(link) {
|
|
425
|
+
const [, channel, thread] = link.thread.split(":");
|
|
426
|
+
const web = await client();
|
|
427
|
+
if (!web?.conversations.replies || !channel || !thread) return {
|
|
428
|
+
snapshot: null,
|
|
429
|
+
unavailable: "no Slack client to read it with"
|
|
430
|
+
};
|
|
431
|
+
let found;
|
|
432
|
+
try {
|
|
433
|
+
const info = (await web.conversations.info({ channel })).channel;
|
|
434
|
+
if (!info) return {
|
|
435
|
+
snapshot: null,
|
|
436
|
+
unavailable: "channel_not_found"
|
|
437
|
+
};
|
|
438
|
+
if (info.is_private || info.is_im || info.is_mpim) return {
|
|
439
|
+
snapshot: null,
|
|
440
|
+
unavailable: "private_channel"
|
|
441
|
+
};
|
|
442
|
+
if (!info.is_member) return {
|
|
443
|
+
snapshot: null,
|
|
444
|
+
unavailable: "not_in_channel"
|
|
445
|
+
};
|
|
446
|
+
const { messages } = await web.conversations.replies({
|
|
447
|
+
channel,
|
|
448
|
+
ts: thread,
|
|
449
|
+
oldest: link.message,
|
|
450
|
+
latest: link.message,
|
|
451
|
+
inclusive: true,
|
|
452
|
+
limit: 1
|
|
453
|
+
});
|
|
454
|
+
found = messages?.find((m) => m.ts === link.message);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return {
|
|
457
|
+
snapshot: null,
|
|
458
|
+
unavailable: slackError(error)
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (!found) return {
|
|
462
|
+
snapshot: null,
|
|
463
|
+
unavailable: "message_not_found"
|
|
464
|
+
};
|
|
465
|
+
return {
|
|
466
|
+
unavailable: null,
|
|
467
|
+
snapshot: {
|
|
468
|
+
author: (found.user ? await nameOf(found.user) : found.username || found.bot_profile?.name || "someone").slice(0, 256),
|
|
469
|
+
text: mrkdwn(found.text ?? "").slice(0, MESSAGE_TEXT_MAX),
|
|
470
|
+
createdAt: tsTime(found.ts),
|
|
471
|
+
files: filesOf(found.files)
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
async read(target, { limit }) {
|
|
476
|
+
const web = await client();
|
|
477
|
+
if (!web) throw new UnreadableError("no_client", "no Slack client to read it with");
|
|
478
|
+
let channel;
|
|
479
|
+
let thread = null;
|
|
480
|
+
const link = new RegExp(PERMALINK.source).exec(target);
|
|
481
|
+
if (link) {
|
|
482
|
+
const [, id, seconds, micros, query] = link;
|
|
483
|
+
channel = id;
|
|
484
|
+
thread = /[?&]thread_ts=(\d+\.\d+)/.exec(query?.replaceAll("&", "&") ?? "")?.[1] ?? `${seconds}.${micros}`;
|
|
485
|
+
} else if (target.startsWith("#")) channel = (await find(web, target.slice(1))).id;
|
|
486
|
+
else {
|
|
487
|
+
const parsed = /^([A-Z0-9]+)(?::(\d+\.\d+))?$/.exec(target);
|
|
488
|
+
if (!parsed) throw new UnreadableError("invalid_target", `not a Slack channel, thread, or message link: ${target} (try #name, C0123, C0123:1727000000.123456, or a permalink)`);
|
|
489
|
+
channel = parsed[1];
|
|
490
|
+
thread = parsed[2] ?? null;
|
|
491
|
+
}
|
|
492
|
+
let found;
|
|
493
|
+
let more = false;
|
|
494
|
+
let name = null;
|
|
495
|
+
try {
|
|
496
|
+
const info = (await web.conversations.info({ channel })).channel;
|
|
497
|
+
if (!info) throw new UnreadableError("channel_not_found", `no Slack channel ${channel}`);
|
|
498
|
+
name = info.name ?? null;
|
|
499
|
+
const label = name ? `#${name}` : channel;
|
|
500
|
+
if (info.is_private || info.is_im || info.is_mpim) throw new UnreadableError("private_channel", `${label} is private: the board only reads public channels on demand`);
|
|
501
|
+
if (info.is_ext_shared) throw new UnreadableError("shared_channel", `${label} is shared with another organization: the board doesn't read those on demand`);
|
|
502
|
+
if (!info.is_member) throw new UnreadableError("not_in_channel", `the app isn't in ${label}: someone needs to add it (it won't join on its own)`);
|
|
503
|
+
if (thread) {
|
|
504
|
+
const replies = web.conversations.replies;
|
|
505
|
+
if (!replies) throw new UnreadableError("no_client", "this Slack client can't read threads");
|
|
506
|
+
let page = await replies({
|
|
507
|
+
channel,
|
|
508
|
+
ts: thread,
|
|
509
|
+
limit
|
|
510
|
+
});
|
|
511
|
+
const root = page.messages?.[0]?.thread_ts;
|
|
512
|
+
if (root && root !== thread) {
|
|
513
|
+
thread = root;
|
|
514
|
+
page = await replies({
|
|
515
|
+
channel,
|
|
516
|
+
ts: root,
|
|
517
|
+
limit
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
found = page.messages ?? [];
|
|
521
|
+
more = page.has_more ?? false;
|
|
522
|
+
} else {
|
|
523
|
+
if (!web.conversations.history) throw new UnreadableError("no_client", "this Slack client can't read channels");
|
|
524
|
+
const page = await web.conversations.history({
|
|
525
|
+
channel,
|
|
526
|
+
limit
|
|
527
|
+
});
|
|
528
|
+
found = (page.messages ?? []).reverse();
|
|
529
|
+
more = page.has_more ?? false;
|
|
530
|
+
}
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error instanceof UnreadableError) throw error;
|
|
533
|
+
throw new UnreadableError(slackError(error).split(",")[0], `Slack refused to read ${target}: ${slackError(error)}`);
|
|
534
|
+
}
|
|
535
|
+
const messages = await Promise.all(found.slice(0, limit).filter((m) => m.ts).map(async (m) => ({
|
|
536
|
+
id: m.ts,
|
|
537
|
+
thread: `${channel}:${m.thread_ts ?? m.ts}`,
|
|
538
|
+
author: (m.user ? await nameOf(m.user) : m.username || m.bot_profile?.name || "someone").slice(0, 256),
|
|
539
|
+
text: mrkdwn(m.text ?? "").slice(0, MESSAGE_TEXT_MAX),
|
|
540
|
+
createdAt: tsTime(m.ts),
|
|
541
|
+
replies: m.reply_count ?? 0,
|
|
542
|
+
files: filesOf(m.files)
|
|
543
|
+
})));
|
|
544
|
+
return {
|
|
545
|
+
channel,
|
|
546
|
+
name,
|
|
547
|
+
thread,
|
|
548
|
+
messages,
|
|
549
|
+
more: more || found.length > limit
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
intercept(key, _headers, body) {
|
|
553
|
+
let event;
|
|
554
|
+
try {
|
|
555
|
+
event = JSON.parse(body).event;
|
|
556
|
+
} catch {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
const inner = event?.message;
|
|
560
|
+
if (event?.type !== "message" || event.subtype !== "message_changed" || !event.channel || !inner?.ts) return [];
|
|
561
|
+
if (!inner.attachments?.some((att) => att.is_msg_unfurl) || inner.text !== event.previous_message?.text) return [];
|
|
562
|
+
const links = slackLinks(key, inner.text ?? "", inner.attachments, mrkdwn);
|
|
563
|
+
const unfurled = links.filter((link) => link.snapshot);
|
|
564
|
+
if (unfurled.length === 0) return [];
|
|
565
|
+
const thread = `${key}:${event.channel}:${inner.thread_ts ?? inner.ts}`;
|
|
566
|
+
return [{
|
|
567
|
+
dedupe: `unfurl:${thread}|${inner.ts}|${unfurled.map((link) => link.message).join(",")}`,
|
|
568
|
+
raw: event,
|
|
569
|
+
event: {
|
|
570
|
+
kind: "edited",
|
|
571
|
+
channel: `${key}:${event.channel}`,
|
|
572
|
+
thread,
|
|
573
|
+
subthread: null,
|
|
574
|
+
message: inner.ts,
|
|
575
|
+
text: inner.text ?? "",
|
|
576
|
+
linksOnly: true,
|
|
577
|
+
links,
|
|
578
|
+
author: {
|
|
579
|
+
id: "slack:system",
|
|
580
|
+
handle: "slack",
|
|
581
|
+
name: "Slack",
|
|
582
|
+
avatar: null,
|
|
583
|
+
isBot: true
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}];
|
|
587
|
+
},
|
|
588
|
+
avatar: (message) => message.author.userId ? avatarOf(message.author.userId) : Promise.resolve(null),
|
|
589
|
+
groupThread: (thread) => thread,
|
|
590
|
+
isRoot: (thread, messageId) => thread.slice(thread.lastIndexOf(":") + 1) === messageId,
|
|
591
|
+
context: () => null,
|
|
592
|
+
...typeof tokens.withBotToken === "function" ? {
|
|
593
|
+
runAs: (token, fn) => tokens.withBotToken(token, fn),
|
|
594
|
+
async whoAmI(token) {
|
|
595
|
+
const me = await tokens.withBotToken(token, () => tokens.webClient.auth.test());
|
|
596
|
+
if (!me.user_id) throw new Error("Slack didn't say whose token this is");
|
|
597
|
+
const user = (await (await client().catch(() => void 0))?.users?.info({ user: me.user_id }).catch(() => void 0))?.user;
|
|
598
|
+
const handle = user?.profile?.display_name || user?.name || me.user || me.user_id;
|
|
599
|
+
return {
|
|
600
|
+
id: `slack:user:${me.user_id}`,
|
|
601
|
+
handle,
|
|
602
|
+
name: user?.profile?.real_name || user?.real_name || handle,
|
|
603
|
+
avatar: user?.profile?.image_72 ?? user?.profile?.image_48 ?? null,
|
|
604
|
+
isBot: false
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
} : {},
|
|
608
|
+
async resolveTarget(target) {
|
|
609
|
+
const at = target.indexOf(":");
|
|
610
|
+
const channel = await this.resolveChannel(at === -1 ? target : target.slice(0, at));
|
|
611
|
+
return {
|
|
612
|
+
target: at === -1 ? channel : `${channel}:${target.slice(at + 1)}`,
|
|
613
|
+
channel,
|
|
614
|
+
kind: at === -1 ? "channel" : "thread"
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
for (const [key, value] of Object.entries(options.channels)) this.map(key, value);
|
|
619
|
+
this.web = client;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* An agent's message goes out under its own name (`clever-lemur via vboard-factory`), and its
|
|
623
|
+
* picture when it has one Slack can show, instead of the app's, with the text's
|
|
624
|
+
* `**name**: ` label dropped. Without `chat:write.customize` it posts as the app, label and all.
|
|
625
|
+
*/
|
|
626
|
+
async post(target, kind, body) {
|
|
627
|
+
const { as, ...given } = body;
|
|
628
|
+
const post = {
|
|
629
|
+
...given,
|
|
630
|
+
markdown: withoutHtml(given.markdown)
|
|
631
|
+
};
|
|
632
|
+
return (as && !post.files?.length ? await this.postNamed(target, as, post.markdown) : null) ?? super.post(target, kind, post);
|
|
633
|
+
}
|
|
634
|
+
/** An edit keeps the label out only where the message went out under the agent's name. */
|
|
635
|
+
async edit(thread, message, body) {
|
|
636
|
+
const { as, ...given } = body;
|
|
637
|
+
const post = {
|
|
638
|
+
...given,
|
|
639
|
+
markdown: withoutHtml(given.markdown)
|
|
640
|
+
};
|
|
641
|
+
const named = as ? await this.sentAs(thread, message, as.name) : false;
|
|
642
|
+
await super.edit(thread, message, named && as ? {
|
|
643
|
+
...post,
|
|
644
|
+
markdown: withoutLabel(post.markdown, as.name)
|
|
645
|
+
} : post);
|
|
646
|
+
}
|
|
647
|
+
async postNamed(target, as, markdown) {
|
|
648
|
+
const adapter = await this.ready();
|
|
649
|
+
const web = await this.web();
|
|
650
|
+
if (!web?.chat || !adapter.formatConverter?.toSlackPayload || !await this.canCustomize(web)) return null;
|
|
651
|
+
const threadId = this.toChat(target);
|
|
652
|
+
const { channel, threadTs } = adapter.decodeThreadId(threadId);
|
|
653
|
+
const message = { markdown: withoutLabel(markdown, as.name) };
|
|
654
|
+
const resolved = await adapter.resolveMessageMentions?.(message, threadId) ?? message;
|
|
655
|
+
const icon = slackIcon(as.avatar);
|
|
656
|
+
const sent = await web.chat.postMessage({
|
|
657
|
+
channel,
|
|
658
|
+
...threadTs ? { thread_ts: threadTs } : {},
|
|
659
|
+
...adapter.formatConverter.toSlackPayload(resolved),
|
|
660
|
+
unfurl_links: false,
|
|
661
|
+
unfurl_media: false,
|
|
662
|
+
username: as.name.slice(0, 80),
|
|
663
|
+
...icon ? { icon_url: icon } : {}
|
|
664
|
+
});
|
|
665
|
+
if (!sent.ts) throw new Error("slack: chat.postMessage returned no ts");
|
|
666
|
+
return {
|
|
667
|
+
thread: this.toNative(`${adapter.name}:${channel}:${threadTs || sent.ts}`),
|
|
668
|
+
message: sent.ts
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** Whether the app may set a message's username and icon (`chat:write.customize`), asked once. */
|
|
672
|
+
canCustomize(web) {
|
|
673
|
+
this.customize ??= (async () => (await web.auth?.test())?.response_metadata?.scopes?.includes("chat:write.customize") ?? false)().catch((error) => {
|
|
674
|
+
this.customize = void 0;
|
|
675
|
+
console.warn(`[board] slack: couldn't check the app's scopes (${slackError(error)}); posting as the app`);
|
|
676
|
+
return false;
|
|
677
|
+
});
|
|
678
|
+
return this.customize;
|
|
679
|
+
}
|
|
680
|
+
/** Whether a message Slack has went out under `name`. */
|
|
681
|
+
async sentAs(thread, message, name) {
|
|
682
|
+
const web = await this.web().catch(() => void 0);
|
|
683
|
+
const channel = thread.slice(0, thread.indexOf(":"));
|
|
684
|
+
if (!web?.conversations.replies || !channel) return false;
|
|
685
|
+
return (await web.conversations.replies({
|
|
686
|
+
channel,
|
|
687
|
+
ts: message,
|
|
688
|
+
oldest: message,
|
|
689
|
+
latest: message,
|
|
690
|
+
inclusive: true,
|
|
691
|
+
limit: 1
|
|
692
|
+
}).then((page) => page.messages?.find((m) => m.ts === message), () => void 0))?.username === name.slice(0, 80);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Slack's link to the event's message, from `chat.getPermalink`, so it has the workspace's
|
|
696
|
+
* own host (Enterprise Grid included) and a reply's `thread_ts`. Null for an event without
|
|
697
|
+
* one message to link (a lifecycle event, or a deleted message Slack no longer has); throws
|
|
698
|
+
* when Slack won't say. It reads only the URL: nothing is synced or joined.
|
|
699
|
+
*/
|
|
700
|
+
async permalink(event) {
|
|
701
|
+
if (event.kind === "lifecycle" || event.kind === "deleted") return null;
|
|
702
|
+
const channel = event.channel.slice(event.key.length + 1);
|
|
703
|
+
const web = await this.web();
|
|
704
|
+
if (!web?.chat?.getPermalink) throw new Error("slack: no client with chat.getPermalink to link the message with");
|
|
705
|
+
let found;
|
|
706
|
+
try {
|
|
707
|
+
found = (await web.chat.getPermalink({
|
|
708
|
+
channel,
|
|
709
|
+
message_ts: event.message
|
|
710
|
+
})).permalink;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
throw new Error(`slack: couldn't get the permalink for ${channel} ${event.message} (${slackError(error)})`);
|
|
713
|
+
}
|
|
714
|
+
if (!found) throw new Error("slack: chat.getPermalink returned no permalink");
|
|
715
|
+
return found;
|
|
716
|
+
}
|
|
717
|
+
eventId(key, input) {
|
|
718
|
+
if (input.kind !== "reaction") return super.eventId(key, input);
|
|
719
|
+
const reaction = input.reaction;
|
|
720
|
+
const stamp = z.looseObject({ event_ts: z.string().min(1) }).parse(reaction.raw).event_ts;
|
|
721
|
+
return `reaction:${key}:${this.toNative(reaction.threadId)}|${reaction.messageId}|${reaction.rawEmoji}|${reaction.user.userId}|${reaction.added}|${stamp}`;
|
|
722
|
+
}
|
|
723
|
+
event(context) {
|
|
724
|
+
return SlackEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id));
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
//#endregion
|
|
728
|
+
export { SlackEvent, SlackProvider, forwardText, slack, slackFileName, slackIcon, slackLinks, withUserTokens, withoutHtml, withoutLabel };
|