emulate 0.5.0 → 0.6.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 +141 -19
- package/dist/api.js +460 -24
- package/dist/api.js.map +1 -1
- package/dist/{dist-PWGOAQC6.js → dist-2ZZGNPJI.js} +1 -1
- package/dist/dist-2ZZGNPJI.js.map +1 -0
- package/dist/{dist-4X2KPMAJ.js → dist-CXRPM6BK.js} +1 -3
- package/dist/dist-CXRPM6BK.js.map +1 -0
- package/dist/{dist-LDUHEJAN.js → dist-DSJSF3GY.js} +1 -3
- package/dist/dist-DSJSF3GY.js.map +1 -0
- package/dist/{dist-ETHHYBGF.js → dist-IFULY5LE.js} +1 -2
- package/dist/dist-IFULY5LE.js.map +1 -0
- package/dist/{dist-J6LHUR52.js → dist-IRUBHCZU.js} +1 -2
- package/dist/dist-IRUBHCZU.js.map +1 -0
- package/dist/{dist-ENKE2S7V.js → dist-NJJLJT2N.js} +1 -3
- package/dist/dist-NJJLJT2N.js.map +1 -0
- package/dist/dist-OGSAVJ25.js +4874 -0
- package/dist/dist-OGSAVJ25.js.map +1 -0
- package/dist/{dist-REDHDZ3V.js → dist-PO4CL5SJ.js} +1 -3
- package/dist/dist-PO4CL5SJ.js.map +1 -0
- package/dist/{dist-IBXD3O6A.js → dist-R3TNKUIE.js} +1 -3
- package/dist/dist-R3TNKUIE.js.map +1 -0
- package/dist/{dist-CFST4X4K.js → dist-WACHAAVU.js} +1 -2
- package/dist/dist-WACHAAVU.js.map +1 -0
- package/dist/{dist-5JVGPOL3.js → dist-XWWZVLQQ.js} +1 -2
- package/dist/dist-XWWZVLQQ.js.map +1 -0
- package/dist/{dist-KKTYBE5S.js → dist-ZY5SZSJ2.js} +8 -3
- package/dist/dist-ZY5SZSJ2.js.map +1 -0
- package/dist/index.js +464 -26
- package/dist/index.js.map +1 -1
- package/package.json +14 -15
- package/dist/chunk-AQ2CLRU3.js +0 -2146
- package/dist/chunk-AQ2CLRU3.js.map +0 -1
- package/dist/dist-4X2KPMAJ.js.map +0 -1
- package/dist/dist-5JVGPOL3.js.map +0 -1
- package/dist/dist-CE6BUCWQ.js +0 -1438
- package/dist/dist-CE6BUCWQ.js.map +0 -1
- package/dist/dist-CFST4X4K.js.map +0 -1
- package/dist/dist-ENKE2S7V.js.map +0 -1
- package/dist/dist-ETHHYBGF.js.map +0 -1
- package/dist/dist-IBXD3O6A.js.map +0 -1
- package/dist/dist-J6LHUR52.js.map +0 -1
- package/dist/dist-KKTYBE5S.js.map +0 -1
- package/dist/dist-LDUHEJAN.js.map +0 -1
- package/dist/dist-PWGOAQC6.js.map +0 -1
- package/dist/dist-REDHDZ3V.js.map +0 -1
|
@@ -0,0 +1,4874 @@
|
|
|
1
|
+
// ../@emulators/slack/dist/index.js
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
import { timingSafeEqual } from "crypto";
|
|
8
|
+
function getSlackStore(store) {
|
|
9
|
+
return {
|
|
10
|
+
teams: store.collection("slack.teams", ["team_id"]),
|
|
11
|
+
users: store.collection("slack.users", ["user_id", "email"]),
|
|
12
|
+
channels: store.collection("slack.channels", ["channel_id", "name"]),
|
|
13
|
+
messages: store.collection("slack.messages", ["ts", "channel_id"]),
|
|
14
|
+
ephemeralMessages: store.collection("slack.ephemeral_messages", [
|
|
15
|
+
"ts",
|
|
16
|
+
"channel_id",
|
|
17
|
+
"target_user"
|
|
18
|
+
]),
|
|
19
|
+
scheduledMessages: store.collection("slack.scheduled_messages", [
|
|
20
|
+
"scheduled_message_id",
|
|
21
|
+
"channel_id"
|
|
22
|
+
]),
|
|
23
|
+
bots: store.collection("slack.bots", ["bot_id"]),
|
|
24
|
+
oauthApps: store.collection("slack.oauth_apps", ["client_id"]),
|
|
25
|
+
installations: store.collection("slack.installations", [
|
|
26
|
+
"installation_id",
|
|
27
|
+
"app_id",
|
|
28
|
+
"client_id",
|
|
29
|
+
"team_id"
|
|
30
|
+
]),
|
|
31
|
+
tokens: store.collection("slack.tokens", ["token", "user_id", "app_id", "team_id"]),
|
|
32
|
+
incomingWebhooks: store.collection("slack.incoming_webhooks", ["token"]),
|
|
33
|
+
files: store.collection("slack.files", ["file_id", "user"]),
|
|
34
|
+
fileUploadSessions: store.collection("slack.file_upload_sessions", ["file_id"]),
|
|
35
|
+
pins: store.collection("slack.pins", ["pin_id", "channel_id", "message_ts"]),
|
|
36
|
+
bookmarks: store.collection("slack.bookmarks", ["bookmark_id", "channel_id"]),
|
|
37
|
+
views: store.collection("slack.views", ["view_id", "user_id", "external_id", "root_view_id"]),
|
|
38
|
+
viewTriggers: store.collection("slack.view_triggers", ["trigger_id", "user_id", "view_id"])
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
var tsCounter = 0;
|
|
42
|
+
function generateSlackId(prefix) {
|
|
43
|
+
return prefix + randomBytes(5).toString("hex").toUpperCase().slice(0, 9);
|
|
44
|
+
}
|
|
45
|
+
function generateTs() {
|
|
46
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
47
|
+
tsCounter++;
|
|
48
|
+
return `${now}.${String(tsCounter).padStart(6, "0")}`;
|
|
49
|
+
}
|
|
50
|
+
function slackOk(c, data) {
|
|
51
|
+
return c.json({ ok: true, ...data });
|
|
52
|
+
}
|
|
53
|
+
function slackError(c, error, status = 200) {
|
|
54
|
+
return c.json({ ok: false, error }, status);
|
|
55
|
+
}
|
|
56
|
+
function isSlackStrictScopes(store) {
|
|
57
|
+
return store.getData("slack.strict_scopes") === true;
|
|
58
|
+
}
|
|
59
|
+
function requireSlackScopes(c, store, requirements) {
|
|
60
|
+
if (!isSlackStrictScopes(store)) return void 0;
|
|
61
|
+
const provided = slackProvidedScopes(c);
|
|
62
|
+
const providedSet = new Set(provided);
|
|
63
|
+
const missing = requirements.filter((requirement) => {
|
|
64
|
+
if (Array.isArray(requirement)) {
|
|
65
|
+
return !requirement.some((scope) => providedSet.has(scope));
|
|
66
|
+
}
|
|
67
|
+
return !providedSet.has(requirement);
|
|
68
|
+
});
|
|
69
|
+
if (missing.length === 0) return void 0;
|
|
70
|
+
return c.json({
|
|
71
|
+
ok: false,
|
|
72
|
+
error: "missing_scope",
|
|
73
|
+
needed: missing.map((requirement) => Array.isArray(requirement) ? requirement.join("|") : requirement).join(","),
|
|
74
|
+
provided: provided.join(",")
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function hasSlackScope(c, scope) {
|
|
78
|
+
return slackProvidedScopes(c).includes(scope);
|
|
79
|
+
}
|
|
80
|
+
function slackProvidedScopes(c) {
|
|
81
|
+
return c.get("authScopes") ?? c.get("authUser")?.scopes ?? [];
|
|
82
|
+
}
|
|
83
|
+
function slackConversationReadScope(ch) {
|
|
84
|
+
if (ch.is_im) return "im:read";
|
|
85
|
+
if (ch.is_mpim) return "mpim:read";
|
|
86
|
+
if (ch.is_private) return "groups:read";
|
|
87
|
+
return "channels:read";
|
|
88
|
+
}
|
|
89
|
+
function slackConversationHistoryScope(ch) {
|
|
90
|
+
if (ch.is_im) return "im:history";
|
|
91
|
+
if (ch.is_mpim) return "mpim:history";
|
|
92
|
+
if (ch.is_private) return "groups:history";
|
|
93
|
+
return "channels:history";
|
|
94
|
+
}
|
|
95
|
+
function slackConversationWriteScope(ch) {
|
|
96
|
+
if (ch.is_im) return "im:write";
|
|
97
|
+
if (ch.is_mpim) return "mpim:write";
|
|
98
|
+
if (ch.is_private) return "groups:write";
|
|
99
|
+
return ["channels:manage", "channels:write"];
|
|
100
|
+
}
|
|
101
|
+
function slackConversationJoinScope(ch) {
|
|
102
|
+
if (ch.is_private) return "groups:write";
|
|
103
|
+
return ["channels:join", "channels:write"];
|
|
104
|
+
}
|
|
105
|
+
async function parseSlackBody(c) {
|
|
106
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
107
|
+
const rawText = await c.req.text();
|
|
108
|
+
if (contentType.includes("application/json")) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(rawText);
|
|
111
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
} catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const params = new URLSearchParams(rawText);
|
|
120
|
+
const result = {};
|
|
121
|
+
for (const [key, value] of params) {
|
|
122
|
+
result[key] = value;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
function formatSlackMessage(msg) {
|
|
127
|
+
return {
|
|
128
|
+
type: msg.type,
|
|
129
|
+
user: msg.user,
|
|
130
|
+
text: msg.text,
|
|
131
|
+
ts: msg.ts,
|
|
132
|
+
...msg.subtype ? { subtype: msg.subtype } : {},
|
|
133
|
+
...msg.bot_id ? { bot_id: msg.bot_id } : {},
|
|
134
|
+
...msg.app_id ? { app_id: msg.app_id } : {},
|
|
135
|
+
...msg.username ? { username: msg.username } : {},
|
|
136
|
+
...msg.icon_url ? { icon_url: msg.icon_url } : {},
|
|
137
|
+
...msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {},
|
|
138
|
+
...msg.client_msg_id ? { client_msg_id: msg.client_msg_id } : {},
|
|
139
|
+
...msg.topic !== void 0 ? { topic: msg.topic } : {},
|
|
140
|
+
...msg.purpose !== void 0 ? { purpose: msg.purpose } : {},
|
|
141
|
+
...msg.old_name !== void 0 ? { old_name: msg.old_name } : {},
|
|
142
|
+
...msg.name !== void 0 ? { name: msg.name } : {},
|
|
143
|
+
...msg.files !== void 0 ? { files: msg.files.map(formatSlackFile) } : {},
|
|
144
|
+
...msg.upload !== void 0 ? { upload: msg.upload } : {},
|
|
145
|
+
...msg.blocks !== void 0 ? { blocks: msg.blocks } : {},
|
|
146
|
+
...msg.attachments !== void 0 ? { attachments: msg.attachments } : {},
|
|
147
|
+
...msg.metadata !== void 0 ? { metadata: msg.metadata } : {},
|
|
148
|
+
...msg.mrkdwn !== void 0 ? { mrkdwn: msg.mrkdwn } : {},
|
|
149
|
+
...msg.parse !== void 0 ? { parse: msg.parse } : {},
|
|
150
|
+
...msg.link_names !== void 0 ? { link_names: msg.link_names } : {},
|
|
151
|
+
...msg.unfurl_links !== void 0 ? { unfurl_links: msg.unfurl_links } : {},
|
|
152
|
+
...msg.unfurl_media !== void 0 ? { unfurl_media: msg.unfurl_media } : {},
|
|
153
|
+
...msg.reply_broadcast !== void 0 ? { reply_broadcast: msg.reply_broadcast } : {},
|
|
154
|
+
...msg.edited ? { edited: msg.edited } : {},
|
|
155
|
+
...msg.thread_ts ? { thread_ts: msg.thread_ts } : {},
|
|
156
|
+
...msg.reply_count > 0 ? { reply_count: msg.reply_count, reply_users: msg.reply_users } : {},
|
|
157
|
+
...msg.reactions.length > 0 ? { reactions: msg.reactions } : {}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function formatSlackFile(file) {
|
|
161
|
+
return {
|
|
162
|
+
id: file.file_id,
|
|
163
|
+
created: file.created,
|
|
164
|
+
timestamp: file.timestamp,
|
|
165
|
+
name: file.name,
|
|
166
|
+
title: file.title,
|
|
167
|
+
mimetype: file.mimetype,
|
|
168
|
+
filetype: file.filetype,
|
|
169
|
+
pretty_type: file.pretty_type,
|
|
170
|
+
user: file.user,
|
|
171
|
+
user_team: file.team_id,
|
|
172
|
+
editable: file.editable,
|
|
173
|
+
size: file.size,
|
|
174
|
+
mode: file.mode,
|
|
175
|
+
is_external: file.is_external,
|
|
176
|
+
external_type: file.external_type,
|
|
177
|
+
is_public: file.is_public,
|
|
178
|
+
public_url_shared: file.public_url_shared,
|
|
179
|
+
display_as_bot: file.display_as_bot,
|
|
180
|
+
url_private: file.url_private,
|
|
181
|
+
url_private_download: file.url_private_download,
|
|
182
|
+
permalink: file.permalink,
|
|
183
|
+
channels: file.channels,
|
|
184
|
+
groups: file.groups,
|
|
185
|
+
ims: file.ims,
|
|
186
|
+
shares: file.shares,
|
|
187
|
+
comments_count: 0,
|
|
188
|
+
is_starred: false,
|
|
189
|
+
has_rich_preview: false,
|
|
190
|
+
...file.alt_txt ? { alt_txt: file.alt_txt } : {},
|
|
191
|
+
...file.initial_comment ? { initial_comment: file.initial_comment } : {},
|
|
192
|
+
...file.thread_ts ? { thread_ts: file.thread_ts } : {}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function formatSlackPermalink(baseUrl, channel, msg) {
|
|
196
|
+
const permalink = `${baseUrl.replace(/\/$/, "")}/archives/${channel}/p${msg.ts.replace(".", "")}`;
|
|
197
|
+
if (!msg.thread_ts || msg.thread_ts === msg.ts) return permalink;
|
|
198
|
+
const params = new URLSearchParams({ thread_ts: msg.thread_ts, cid: channel });
|
|
199
|
+
return `${permalink}?${params.toString()}`;
|
|
200
|
+
}
|
|
201
|
+
function formatSlackScheduledMessage(msg) {
|
|
202
|
+
return {
|
|
203
|
+
text: msg.text,
|
|
204
|
+
type: msg.type,
|
|
205
|
+
subtype: msg.subtype,
|
|
206
|
+
...msg.username ? { username: msg.username } : {},
|
|
207
|
+
...msg.bot_id ? { bot_id: msg.bot_id } : {},
|
|
208
|
+
...msg.app_id ? { app_id: msg.app_id } : {},
|
|
209
|
+
...msg.icon_url ? { icon_url: msg.icon_url } : {},
|
|
210
|
+
...msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {},
|
|
211
|
+
...msg.client_msg_id ? { client_msg_id: msg.client_msg_id } : {},
|
|
212
|
+
...msg.blocks !== void 0 ? { blocks: msg.blocks } : {},
|
|
213
|
+
...msg.attachments !== void 0 ? { attachments: msg.attachments } : {},
|
|
214
|
+
...msg.metadata !== void 0 ? { metadata: msg.metadata } : {},
|
|
215
|
+
...msg.mrkdwn !== void 0 ? { mrkdwn: msg.mrkdwn } : {},
|
|
216
|
+
...msg.parse !== void 0 ? { parse: msg.parse } : {},
|
|
217
|
+
...msg.link_names !== void 0 ? { link_names: msg.link_names } : {},
|
|
218
|
+
...msg.unfurl_links !== void 0 ? { unfurl_links: msg.unfurl_links } : {},
|
|
219
|
+
...msg.unfurl_media !== void 0 ? { unfurl_media: msg.unfurl_media } : {},
|
|
220
|
+
...msg.reply_broadcast !== void 0 ? { reply_broadcast: msg.reply_broadcast } : {},
|
|
221
|
+
...msg.thread_ts ? { thread_ts: msg.thread_ts } : {}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function formatSlackScheduledMessageListItem(msg) {
|
|
225
|
+
return {
|
|
226
|
+
id: msg.scheduled_message_id,
|
|
227
|
+
channel_id: msg.channel_id,
|
|
228
|
+
post_at: msg.post_at,
|
|
229
|
+
date_created: msg.date_created,
|
|
230
|
+
text: msg.text
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function formatSlackView(view) {
|
|
234
|
+
return {
|
|
235
|
+
id: view.view_id,
|
|
236
|
+
team_id: view.team_id,
|
|
237
|
+
type: view.type,
|
|
238
|
+
title: view.title,
|
|
239
|
+
close: view.close,
|
|
240
|
+
submit: view.submit,
|
|
241
|
+
blocks: view.blocks,
|
|
242
|
+
private_metadata: view.private_metadata,
|
|
243
|
+
callback_id: view.callback_id,
|
|
244
|
+
external_id: view.external_id,
|
|
245
|
+
state: view.state,
|
|
246
|
+
hash: view.hash,
|
|
247
|
+
clear_on_close: view.clear_on_close,
|
|
248
|
+
notify_on_close: view.notify_on_close,
|
|
249
|
+
root_view_id: view.root_view_id,
|
|
250
|
+
previous_view_id: view.previous_view_id ?? null,
|
|
251
|
+
app_id: view.app_id,
|
|
252
|
+
bot_id: view.bot_id
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function getSlackConversationOpenState(ch, userId) {
|
|
256
|
+
if ((ch.is_im || ch.is_mpim) && userId && ch.is_open_by_user) {
|
|
257
|
+
return ch.is_open_by_user[userId] === true;
|
|
258
|
+
}
|
|
259
|
+
return ch.is_open ?? false;
|
|
260
|
+
}
|
|
261
|
+
function setSlackConversationOpenState(ch, userId, isOpen) {
|
|
262
|
+
if (!ch.is_im && !ch.is_mpim) return { is_open: isOpen };
|
|
263
|
+
return { is_open_by_user: { ...ch.is_open_by_user ?? {}, [userId]: isOpen } };
|
|
264
|
+
}
|
|
265
|
+
function parseSlackRichMessageFields(body) {
|
|
266
|
+
const fields = {};
|
|
267
|
+
const providedFields = [];
|
|
268
|
+
const blocks = parseSlackObjectArray(body.blocks, "invalid_blocks");
|
|
269
|
+
if (blocks.error) return { fields, providedFields, error: blocks.error };
|
|
270
|
+
if (hasBodyField(body, "blocks")) {
|
|
271
|
+
providedFields.push("blocks");
|
|
272
|
+
if (blocks.value !== void 0) fields.blocks = blocks.value;
|
|
273
|
+
}
|
|
274
|
+
const attachments = parseSlackObjectArray(body.attachments, "invalid_attachments");
|
|
275
|
+
if (attachments.error) return { fields, providedFields, error: attachments.error };
|
|
276
|
+
if (hasBodyField(body, "attachments")) {
|
|
277
|
+
providedFields.push("attachments");
|
|
278
|
+
if (attachments.value !== void 0) fields.attachments = attachments.value;
|
|
279
|
+
}
|
|
280
|
+
const metadata = parseSlackObject(body.metadata, "invalid_metadata_format");
|
|
281
|
+
if (metadata.error) return { fields, providedFields, error: metadata.error };
|
|
282
|
+
if (hasBodyField(body, "metadata")) {
|
|
283
|
+
providedFields.push("metadata");
|
|
284
|
+
if (metadata.value !== void 0) fields.metadata = metadata.value;
|
|
285
|
+
}
|
|
286
|
+
setOptionalStringField(body, fields, providedFields, "parse");
|
|
287
|
+
setOptionalStringField(body, fields, providedFields, "username");
|
|
288
|
+
setOptionalStringField(body, fields, providedFields, "icon_url");
|
|
289
|
+
setOptionalStringField(body, fields, providedFields, "icon_emoji");
|
|
290
|
+
setOptionalStringField(body, fields, providedFields, "bot_id");
|
|
291
|
+
setOptionalStringField(body, fields, providedFields, "app_id");
|
|
292
|
+
setOptionalStringField(body, fields, providedFields, "client_msg_id");
|
|
293
|
+
setOptionalBooleanField(body, fields, providedFields, "mrkdwn");
|
|
294
|
+
setOptionalBooleanField(body, fields, providedFields, "link_names");
|
|
295
|
+
setOptionalBooleanField(body, fields, providedFields, "unfurl_links");
|
|
296
|
+
setOptionalBooleanField(body, fields, providedFields, "unfurl_media");
|
|
297
|
+
setOptionalBooleanField(body, fields, providedFields, "reply_broadcast");
|
|
298
|
+
return { fields, providedFields };
|
|
299
|
+
}
|
|
300
|
+
function hasSlackMessageContent(text, fields) {
|
|
301
|
+
return text.length > 0 || (fields.blocks?.length ?? 0) > 0 || (fields.attachments?.length ?? 0) > 0;
|
|
302
|
+
}
|
|
303
|
+
function hasBodyField(body, field) {
|
|
304
|
+
return Object.prototype.hasOwnProperty.call(body, field);
|
|
305
|
+
}
|
|
306
|
+
function isSlackJsonObject(value) {
|
|
307
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
308
|
+
}
|
|
309
|
+
function parseSlackJsonString(value) {
|
|
310
|
+
if (value.length === 0) return {};
|
|
311
|
+
try {
|
|
312
|
+
return { value: JSON.parse(value) };
|
|
313
|
+
} catch {
|
|
314
|
+
return { error: "invalid_json" };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function parseSlackObjectArray(value, error) {
|
|
318
|
+
let parsed = value;
|
|
319
|
+
if (parsed === void 0 || parsed === null || parsed === "") return {};
|
|
320
|
+
if (typeof parsed === "string") {
|
|
321
|
+
const result = parseSlackJsonString(parsed);
|
|
322
|
+
if (result.error) return { error };
|
|
323
|
+
parsed = result.value;
|
|
324
|
+
}
|
|
325
|
+
if (!Array.isArray(parsed) || !parsed.every(isSlackJsonObject)) {
|
|
326
|
+
return { error };
|
|
327
|
+
}
|
|
328
|
+
return { value: parsed };
|
|
329
|
+
}
|
|
330
|
+
function parseSlackObject(value, error) {
|
|
331
|
+
let parsed = value;
|
|
332
|
+
if (parsed === void 0 || parsed === null || parsed === "") return {};
|
|
333
|
+
if (typeof parsed === "string") {
|
|
334
|
+
const result = parseSlackJsonString(parsed);
|
|
335
|
+
if (result.error) return { error };
|
|
336
|
+
parsed = result.value;
|
|
337
|
+
}
|
|
338
|
+
if (!isSlackJsonObject(parsed)) {
|
|
339
|
+
return { error };
|
|
340
|
+
}
|
|
341
|
+
return { value: parsed };
|
|
342
|
+
}
|
|
343
|
+
function parseSlackBoolean(value) {
|
|
344
|
+
if (typeof value === "boolean") return value;
|
|
345
|
+
if (typeof value === "number") {
|
|
346
|
+
if (value === 1) return true;
|
|
347
|
+
if (value === 0) return false;
|
|
348
|
+
}
|
|
349
|
+
if (typeof value === "string") {
|
|
350
|
+
const normalized = value.toLowerCase();
|
|
351
|
+
if (normalized === "true" || normalized === "1") return true;
|
|
352
|
+
if (normalized === "false" || normalized === "0") return false;
|
|
353
|
+
}
|
|
354
|
+
return void 0;
|
|
355
|
+
}
|
|
356
|
+
function setOptionalStringField(body, fields, providedFields, field) {
|
|
357
|
+
if (!hasBodyField(body, field)) return;
|
|
358
|
+
providedFields.push(field);
|
|
359
|
+
const value = body[field];
|
|
360
|
+
if (typeof value === "string" && value.length > 0) {
|
|
361
|
+
fields[field] = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function setOptionalBooleanField(body, fields, providedFields, field) {
|
|
365
|
+
if (!hasBodyField(body, field)) return;
|
|
366
|
+
providedFields.push(field);
|
|
367
|
+
const value = parseSlackBoolean(body[field]);
|
|
368
|
+
if (value !== void 0) {
|
|
369
|
+
fields[field] = value;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function authRoutes(ctx) {
|
|
373
|
+
const { app, store } = ctx;
|
|
374
|
+
const ss = () => getSlackStore(store);
|
|
375
|
+
app.post("/api/auth.test", (c) => {
|
|
376
|
+
const authUser = c.get("authUser");
|
|
377
|
+
if (!authUser) {
|
|
378
|
+
return slackError(c, "not_authed");
|
|
379
|
+
}
|
|
380
|
+
const user = ss().users.findOneBy("user_id", authUser.login) ?? ss().users.all().find((u) => u.name === authUser.login);
|
|
381
|
+
if (!user) {
|
|
382
|
+
return slackError(c, "invalid_auth");
|
|
383
|
+
}
|
|
384
|
+
const team = ss().teams.all()[0];
|
|
385
|
+
const token = c.get("authToken");
|
|
386
|
+
const tokenRecord = token ? ss().tokens.findOneBy("token", token) : void 0;
|
|
387
|
+
const bot = (tokenRecord?.bot_id ? ss().bots.findOneBy("bot_id", tokenRecord.bot_id) : void 0) ?? (user.is_bot ? ss().bots.all().find((item) => item.user_id === user.user_id) : void 0);
|
|
388
|
+
const installation = tokenRecord?.installation_id ? ss().installations.findOneBy("installation_id", tokenRecord.installation_id) : void 0;
|
|
389
|
+
return slackOk(c, {
|
|
390
|
+
url: `https://${team?.domain ?? "emulate"}.slack.com/`,
|
|
391
|
+
team: team?.name ?? "Emulate",
|
|
392
|
+
user: user.name,
|
|
393
|
+
team_id: team?.team_id ?? "T000000001",
|
|
394
|
+
user_id: user.user_id,
|
|
395
|
+
bot_id: bot?.bot_id,
|
|
396
|
+
app_id: tokenRecord?.app_id,
|
|
397
|
+
app_name: installation?.app_name
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function chatRoutes(ctx) {
|
|
402
|
+
const { app, store, webhooks, baseUrl } = ctx;
|
|
403
|
+
const ss = () => getSlackStore(store);
|
|
404
|
+
const findChannel = (channel) => ss().channels.findOneBy("channel_id", channel) ?? ss().channels.all().find((ch) => !ch.is_im && !ch.is_mpim && ch.name === channel);
|
|
405
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
406
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
407
|
+
const isAuthChannelMember = (channel, authUser) => {
|
|
408
|
+
const user = getAuthSlackUser(authUser);
|
|
409
|
+
const userId = user?.user_id ?? authUser.login;
|
|
410
|
+
return channel.members.includes(userId) || (user ? channel.members.includes(user.name) : false);
|
|
411
|
+
};
|
|
412
|
+
const canAccessConversation = (channel, authUser) => !channel.is_private || isAuthChannelMember(channel, authUser);
|
|
413
|
+
const isAuthoredByUser = (msg, authUser) => {
|
|
414
|
+
const user = getAuthSlackUser(authUser);
|
|
415
|
+
return msg.user === authUser.login || msg.user === user?.user_id || msg.user === user?.name;
|
|
416
|
+
};
|
|
417
|
+
const isChannelMember = (channel, user) => channel.members.includes(user.user_id) || channel.members.includes(user.name);
|
|
418
|
+
const deletePinsForMessage = (channel, ts) => {
|
|
419
|
+
for (const pin of ss().pins.findBy("message_ts", ts).filter((pin2) => pin2.channel_id === channel)) {
|
|
420
|
+
ss().pins.delete(pin.id);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const dispatchConversationEvent = async (type, event) => {
|
|
424
|
+
await webhooks.dispatch(
|
|
425
|
+
type,
|
|
426
|
+
void 0,
|
|
427
|
+
{
|
|
428
|
+
type: "event_callback",
|
|
429
|
+
event: { type, ...event }
|
|
430
|
+
},
|
|
431
|
+
"slack"
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
const findOrCreateDirectMessage = async (authUser, userId) => {
|
|
435
|
+
const targetUser = ss().users.findOneBy("user_id", userId);
|
|
436
|
+
if (!targetUser || targetUser.deleted) return void 0;
|
|
437
|
+
const authUserId = getAuthUserId(authUser);
|
|
438
|
+
if (targetUser.user_id === authUserId) return void 0;
|
|
439
|
+
const members = [authUserId, targetUser.user_id].sort();
|
|
440
|
+
const existing = ss().channels.all().find(
|
|
441
|
+
(ch) => ch.is_im && ch.members.length === members.length && [...ch.members].sort().join(",") === members.join(",")
|
|
442
|
+
);
|
|
443
|
+
if (existing) {
|
|
444
|
+
if (!getSlackConversationOpenState(existing, authUserId)) {
|
|
445
|
+
const updated = ss().channels.update(existing.id, setSlackConversationOpenState(existing, authUserId, true));
|
|
446
|
+
if (updated) await dispatchConversationEvent("im_open", { channel: updated.channel_id });
|
|
447
|
+
return updated;
|
|
448
|
+
}
|
|
449
|
+
return existing;
|
|
450
|
+
}
|
|
451
|
+
const team = ss().teams.all()[0];
|
|
452
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
453
|
+
const created = ss().channels.insert({
|
|
454
|
+
channel_id: generateSlackId("D"),
|
|
455
|
+
team_id: team?.team_id ?? "T000000001",
|
|
456
|
+
name: targetUser.name,
|
|
457
|
+
is_channel: false,
|
|
458
|
+
is_private: true,
|
|
459
|
+
is_im: true,
|
|
460
|
+
is_mpim: false,
|
|
461
|
+
is_open_by_user: { [authUserId]: true },
|
|
462
|
+
user: targetUser.user_id,
|
|
463
|
+
is_archived: false,
|
|
464
|
+
topic: { value: "", creator: authUserId, last_set: now },
|
|
465
|
+
purpose: { value: "", creator: authUserId, last_set: now },
|
|
466
|
+
members,
|
|
467
|
+
creator: authUserId,
|
|
468
|
+
num_members: members.length,
|
|
469
|
+
last_read: {}
|
|
470
|
+
});
|
|
471
|
+
await dispatchConversationEvent("im_created", {
|
|
472
|
+
channel: formatDirectMessageChannel(created, authUserId, targetUser.user_id)
|
|
473
|
+
});
|
|
474
|
+
await dispatchConversationEvent("im_open", { channel: created.channel_id });
|
|
475
|
+
return created;
|
|
476
|
+
};
|
|
477
|
+
const findWritableConversation = async (authUser, channel) => findChannel(channel) ?? await findOrCreateDirectMessage(authUser, channel);
|
|
478
|
+
app.post("/api/chat.postMessage", async (c) => {
|
|
479
|
+
const authUser = c.get("authUser");
|
|
480
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
481
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
482
|
+
if (scopeError) return scopeError;
|
|
483
|
+
const body = await parseSlackBody(c);
|
|
484
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
485
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
486
|
+
const thread_ts = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
487
|
+
const richMessage = parseSlackRichMessageFields(body);
|
|
488
|
+
if (richMessage.error) return slackError(c, richMessage.error);
|
|
489
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
490
|
+
if (!hasSlackMessageContent(text, richMessage.fields)) return slackError(c, "no_text");
|
|
491
|
+
const ch = await findWritableConversation(authUser, channel);
|
|
492
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
493
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
494
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
495
|
+
const authUserId = getAuthUserId(authUser);
|
|
496
|
+
const ts = generateTs();
|
|
497
|
+
const msg = ss().messages.insert({
|
|
498
|
+
ts,
|
|
499
|
+
channel_id: ch.channel_id,
|
|
500
|
+
user: authUserId,
|
|
501
|
+
text,
|
|
502
|
+
type: "message",
|
|
503
|
+
thread_ts,
|
|
504
|
+
...richMessage.fields,
|
|
505
|
+
reply_count: 0,
|
|
506
|
+
reply_users: [],
|
|
507
|
+
reactions: []
|
|
508
|
+
});
|
|
509
|
+
if (thread_ts) {
|
|
510
|
+
const parent = ss().messages.all().find((m) => m.ts === thread_ts && m.channel_id === ch.channel_id);
|
|
511
|
+
if (parent) {
|
|
512
|
+
const replyUsers = parent.reply_users.includes(authUserId) ? parent.reply_users : [...parent.reply_users, authUserId];
|
|
513
|
+
ss().messages.update(parent.id, {
|
|
514
|
+
reply_count: parent.reply_count + 1,
|
|
515
|
+
reply_users: replyUsers
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
await webhooks.dispatch(
|
|
520
|
+
"message",
|
|
521
|
+
void 0,
|
|
522
|
+
{
|
|
523
|
+
type: "event_callback",
|
|
524
|
+
event: {
|
|
525
|
+
...formatSlackMessage(msg),
|
|
526
|
+
type: "message",
|
|
527
|
+
channel: ch.channel_id
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
"slack"
|
|
531
|
+
);
|
|
532
|
+
return slackOk(c, {
|
|
533
|
+
channel: ch.channel_id,
|
|
534
|
+
ts,
|
|
535
|
+
message: formatSlackMessage(msg)
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
app.post("/api/chat.postEphemeral", async (c) => {
|
|
539
|
+
const authUser = c.get("authUser");
|
|
540
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
541
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
542
|
+
if (scopeError) return scopeError;
|
|
543
|
+
const body = await parseSlackBody(c);
|
|
544
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
545
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
546
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
547
|
+
const thread_ts = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
548
|
+
const richMessage = parseSlackRichMessageFields(body);
|
|
549
|
+
if (richMessage.error) return slackError(c, richMessage.error);
|
|
550
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
551
|
+
if (!user) return slackError(c, "user_not_found");
|
|
552
|
+
if (!hasSlackMessageContent(text, richMessage.fields)) return slackError(c, "no_text");
|
|
553
|
+
const ch = findChannel(channel);
|
|
554
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
555
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
556
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
557
|
+
const targetUser = ss().users.findOneBy("user_id", user);
|
|
558
|
+
if (!targetUser) return slackError(c, "user_not_found");
|
|
559
|
+
if (!isChannelMember(ch, targetUser)) return slackError(c, "user_not_in_channel");
|
|
560
|
+
const authUserId = getAuthUserId(authUser);
|
|
561
|
+
const ts = generateTs();
|
|
562
|
+
ss().ephemeralMessages.insert({
|
|
563
|
+
ts,
|
|
564
|
+
channel_id: ch.channel_id,
|
|
565
|
+
user: authUserId,
|
|
566
|
+
target_user: targetUser.user_id,
|
|
567
|
+
text,
|
|
568
|
+
type: "message",
|
|
569
|
+
thread_ts,
|
|
570
|
+
...richMessage.fields,
|
|
571
|
+
reply_count: 0,
|
|
572
|
+
reply_users: [],
|
|
573
|
+
reactions: []
|
|
574
|
+
});
|
|
575
|
+
return slackOk(c, { message_ts: ts });
|
|
576
|
+
});
|
|
577
|
+
app.post("/api/chat.update", async (c) => {
|
|
578
|
+
const authUser = c.get("authUser");
|
|
579
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
580
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
581
|
+
if (scopeError) return scopeError;
|
|
582
|
+
const body = await parseSlackBody(c);
|
|
583
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
584
|
+
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
585
|
+
const hasText = typeof body.text === "string";
|
|
586
|
+
const text = hasText ? body.text : "";
|
|
587
|
+
const richMessage = parseSlackRichMessageFields(body);
|
|
588
|
+
if (richMessage.error) return slackError(c, richMessage.error);
|
|
589
|
+
if (!channel || !ts) return slackError(c, "message_not_found");
|
|
590
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
591
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
592
|
+
const msg = ss().messages.all().find((m) => m.ts === ts && m.channel_id === channel);
|
|
593
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
594
|
+
if (!isAuthoredByUser(msg, authUser)) return slackError(c, "cant_update_message");
|
|
595
|
+
const updates = { ...richMessage.fields };
|
|
596
|
+
if (hasText) {
|
|
597
|
+
updates.text = text;
|
|
598
|
+
if (!richMessage.providedFields.includes("blocks")) updates.blocks = void 0;
|
|
599
|
+
if (!richMessage.providedFields.includes("attachments")) updates.attachments = void 0;
|
|
600
|
+
}
|
|
601
|
+
if (!hasText && Object.keys(updates).length === 0) {
|
|
602
|
+
return slackError(c, "no_text");
|
|
603
|
+
}
|
|
604
|
+
const authUserId = getAuthUserId(authUser);
|
|
605
|
+
const eventTs = generateTs();
|
|
606
|
+
const updated = ss().messages.update(msg.id, {
|
|
607
|
+
...updates,
|
|
608
|
+
edited: { user: authUserId, ts: eventTs }
|
|
609
|
+
});
|
|
610
|
+
await webhooks.dispatch(
|
|
611
|
+
"message",
|
|
612
|
+
void 0,
|
|
613
|
+
{
|
|
614
|
+
type: "event_callback",
|
|
615
|
+
event: {
|
|
616
|
+
type: "message",
|
|
617
|
+
subtype: "message_changed",
|
|
618
|
+
hidden: true,
|
|
619
|
+
channel,
|
|
620
|
+
ts: eventTs,
|
|
621
|
+
event_ts: eventTs,
|
|
622
|
+
message: formatSlackMessage(updated),
|
|
623
|
+
previous_message: formatSlackMessage(msg)
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
"slack"
|
|
627
|
+
);
|
|
628
|
+
return slackOk(c, {
|
|
629
|
+
channel,
|
|
630
|
+
ts,
|
|
631
|
+
text: updated.text,
|
|
632
|
+
message: formatSlackMessage(updated)
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
app.post("/api/chat.delete", async (c) => {
|
|
636
|
+
const authUser = c.get("authUser");
|
|
637
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
638
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
639
|
+
if (scopeError) return scopeError;
|
|
640
|
+
const body = await parseSlackBody(c);
|
|
641
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
642
|
+
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
643
|
+
if (!channel || !ts) return slackError(c, "message_not_found");
|
|
644
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
645
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
646
|
+
const msg = ss().messages.all().find((m) => m.ts === ts && m.channel_id === channel);
|
|
647
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
648
|
+
if (!isAuthoredByUser(msg, authUser)) return slackError(c, "cant_delete_message");
|
|
649
|
+
ss().messages.delete(msg.id);
|
|
650
|
+
deletePinsForMessage(channel, ts);
|
|
651
|
+
const eventTs = generateTs();
|
|
652
|
+
await webhooks.dispatch(
|
|
653
|
+
"message",
|
|
654
|
+
void 0,
|
|
655
|
+
{
|
|
656
|
+
type: "event_callback",
|
|
657
|
+
event: {
|
|
658
|
+
type: "message",
|
|
659
|
+
subtype: "message_deleted",
|
|
660
|
+
hidden: true,
|
|
661
|
+
channel,
|
|
662
|
+
ts: eventTs,
|
|
663
|
+
event_ts: eventTs,
|
|
664
|
+
deleted_ts: ts,
|
|
665
|
+
previous_message: formatSlackMessage(msg)
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
"slack"
|
|
669
|
+
);
|
|
670
|
+
return slackOk(c, { channel, ts });
|
|
671
|
+
});
|
|
672
|
+
async function getPermalink(c) {
|
|
673
|
+
const authUser = c.get("authUser");
|
|
674
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
675
|
+
const body = c.req.method === "GET" ? {} : await parseSlackBody(c);
|
|
676
|
+
const channel = typeof body.channel === "string" ? body.channel : c.req.query("channel") ?? "";
|
|
677
|
+
const messageTs = typeof body.message_ts === "string" ? body.message_ts : c.req.query("message_ts") ?? "";
|
|
678
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
679
|
+
if (!messageTs) return slackError(c, "message_not_found");
|
|
680
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
681
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
682
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
683
|
+
const msg = ss().messages.all().find((m) => m.ts === messageTs && m.channel_id === channel);
|
|
684
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
685
|
+
return slackOk(c, {
|
|
686
|
+
channel,
|
|
687
|
+
permalink: formatSlackPermalink(baseUrl, ch.channel_id, msg)
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
app.get("/api/chat.getPermalink", getPermalink);
|
|
691
|
+
app.post("/api/chat.getPermalink", getPermalink);
|
|
692
|
+
app.post("/api/chat.scheduleMessage", async (c) => {
|
|
693
|
+
const authUser = c.get("authUser");
|
|
694
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
695
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
696
|
+
if (scopeError) return scopeError;
|
|
697
|
+
const body = await parseSlackBody(c);
|
|
698
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
699
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
700
|
+
const postAt = Number(body.post_at);
|
|
701
|
+
const thread_ts = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
702
|
+
const richMessage = parseSlackRichMessageFields(body);
|
|
703
|
+
if (richMessage.error) return slackError(c, richMessage.error);
|
|
704
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
705
|
+
if (!hasSlackMessageContent(text, richMessage.fields)) return slackError(c, "no_text");
|
|
706
|
+
if (!Number.isFinite(postAt) || postAt <= 0) return slackError(c, "invalid_time");
|
|
707
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
708
|
+
const postAtSeconds = Math.floor(postAt);
|
|
709
|
+
if (postAtSeconds <= now) return slackError(c, "time_in_past");
|
|
710
|
+
if (postAtSeconds > now + 120 * 24 * 60 * 60) return slackError(c, "time_too_far");
|
|
711
|
+
const ch = findChannel(channel);
|
|
712
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
713
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
714
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
715
|
+
const authUserId = getAuthUserId(authUser);
|
|
716
|
+
const scheduled = ss().scheduledMessages.insert({
|
|
717
|
+
scheduled_message_id: generateSlackId("Q"),
|
|
718
|
+
channel_id: ch.channel_id,
|
|
719
|
+
user: authUserId,
|
|
720
|
+
text,
|
|
721
|
+
type: "delayed_message",
|
|
722
|
+
subtype: "bot_message",
|
|
723
|
+
thread_ts,
|
|
724
|
+
...richMessage.fields,
|
|
725
|
+
post_at: postAtSeconds,
|
|
726
|
+
date_created: now
|
|
727
|
+
});
|
|
728
|
+
return slackOk(c, {
|
|
729
|
+
channel: ch.channel_id,
|
|
730
|
+
scheduled_message_id: scheduled.scheduled_message_id,
|
|
731
|
+
post_at: scheduled.post_at,
|
|
732
|
+
message: formatSlackScheduledMessage(scheduled)
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
app.post("/api/chat.deleteScheduledMessage", async (c) => {
|
|
736
|
+
const authUser = c.get("authUser");
|
|
737
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
738
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
739
|
+
if (scopeError) return scopeError;
|
|
740
|
+
const body = await parseSlackBody(c);
|
|
741
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
742
|
+
const scheduledMessageId = typeof body.scheduled_message_id === "string" ? body.scheduled_message_id : "";
|
|
743
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
744
|
+
if (!scheduledMessageId) return slackError(c, "invalid_scheduled_message_id");
|
|
745
|
+
const ch = findChannel(channel);
|
|
746
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
747
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
748
|
+
const scheduled = ss().scheduledMessages.all().find((m) => m.channel_id === ch.channel_id && m.scheduled_message_id === scheduledMessageId);
|
|
749
|
+
if (!scheduled) return slackError(c, "invalid_scheduled_message_id");
|
|
750
|
+
if (!isAuthoredByUser(scheduled, authUser)) return slackError(c, "cant_delete_message");
|
|
751
|
+
ss().scheduledMessages.delete(scheduled.id);
|
|
752
|
+
return slackOk(c, {});
|
|
753
|
+
});
|
|
754
|
+
app.post("/api/chat.scheduledMessages.list", async (c) => {
|
|
755
|
+
const authUser = c.get("authUser");
|
|
756
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
757
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
758
|
+
if (scopeError) return scopeError;
|
|
759
|
+
const body = await parseSlackBody(c);
|
|
760
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
761
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
762
|
+
const requestedLimit = body.limit === void 0 ? 100 : Number(body.limit);
|
|
763
|
+
const oldest = body.oldest === void 0 ? void 0 : Number(body.oldest);
|
|
764
|
+
const latest = body.latest === void 0 ? void 0 : Number(body.latest);
|
|
765
|
+
if (!Number.isFinite(requestedLimit) || requestedLimit < 1) {
|
|
766
|
+
return slackError(c, "invalid_arguments");
|
|
767
|
+
}
|
|
768
|
+
if (oldest !== void 0 && !Number.isFinite(oldest) || latest !== void 0 && !Number.isFinite(latest)) {
|
|
769
|
+
return slackError(c, "invalid_arguments");
|
|
770
|
+
}
|
|
771
|
+
if (oldest !== void 0 && latest !== void 0 && oldest > latest) {
|
|
772
|
+
return slackError(c, "invalid_arguments");
|
|
773
|
+
}
|
|
774
|
+
const limit = Math.min(Math.floor(requestedLimit), 1e3);
|
|
775
|
+
const ch = channel ? findChannel(channel) : void 0;
|
|
776
|
+
if (channel && !ch) return slackError(c, "channel_not_found");
|
|
777
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
778
|
+
const allScheduled = ss().scheduledMessages.all().filter((msg) => isAuthoredByUser(msg, authUser)).filter((msg) => !ch || msg.channel_id === ch.channel_id).filter((msg) => {
|
|
779
|
+
const messageChannel = ss().channels.findOneBy("channel_id", msg.channel_id);
|
|
780
|
+
return messageChannel ? canAccessConversation(messageChannel, authUser) : false;
|
|
781
|
+
}).filter((msg) => oldest === void 0 || msg.post_at >= oldest).filter((msg) => latest === void 0 || msg.post_at <= latest).sort((a, b) => a.post_at - b.post_at || a.scheduled_message_id.localeCompare(b.scheduled_message_id));
|
|
782
|
+
let startIndex = 0;
|
|
783
|
+
if (cursor) {
|
|
784
|
+
const idx = allScheduled.findIndex((msg) => msg.scheduled_message_id === cursor);
|
|
785
|
+
if (idx < 0) return slackError(c, "invalid_cursor");
|
|
786
|
+
if (idx >= 0) startIndex = idx;
|
|
787
|
+
}
|
|
788
|
+
const page = allScheduled.slice(startIndex, startIndex + limit);
|
|
789
|
+
const nextCursor = startIndex + limit < allScheduled.length ? allScheduled[startIndex + limit].scheduled_message_id : "";
|
|
790
|
+
return slackOk(c, {
|
|
791
|
+
scheduled_messages: page.map(formatSlackScheduledMessageListItem),
|
|
792
|
+
response_metadata: { next_cursor: nextCursor }
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
app.post("/api/chat.meMessage", async (c) => {
|
|
796
|
+
const authUser = c.get("authUser");
|
|
797
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
798
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
799
|
+
if (scopeError) return scopeError;
|
|
800
|
+
const body = await parseSlackBody(c);
|
|
801
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
802
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
803
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
804
|
+
const ch = findChannel(channel);
|
|
805
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
806
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
807
|
+
if (!canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
808
|
+
const authUserId = getAuthUserId(authUser);
|
|
809
|
+
const ts = generateTs();
|
|
810
|
+
ss().messages.insert({
|
|
811
|
+
ts,
|
|
812
|
+
channel_id: ch.channel_id,
|
|
813
|
+
user: authUserId,
|
|
814
|
+
text,
|
|
815
|
+
type: "message",
|
|
816
|
+
subtype: "me_message",
|
|
817
|
+
reply_count: 0,
|
|
818
|
+
reply_users: [],
|
|
819
|
+
reactions: []
|
|
820
|
+
});
|
|
821
|
+
return slackOk(c, { channel: ch.channel_id, ts });
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function formatDirectMessageChannel(ch, viewer, user) {
|
|
825
|
+
return {
|
|
826
|
+
id: ch.channel_id,
|
|
827
|
+
name: ch.name,
|
|
828
|
+
name_normalized: ch.name,
|
|
829
|
+
is_channel: ch.is_channel,
|
|
830
|
+
is_group: false,
|
|
831
|
+
is_im: true,
|
|
832
|
+
is_mpim: false,
|
|
833
|
+
is_private: ch.is_private,
|
|
834
|
+
is_archived: ch.is_archived,
|
|
835
|
+
is_open: getSlackConversationOpenState(ch, viewer),
|
|
836
|
+
user,
|
|
837
|
+
is_member: true,
|
|
838
|
+
last_read: ch.last_read?.[viewer] ?? "0000000000.000000",
|
|
839
|
+
topic: ch.topic,
|
|
840
|
+
purpose: ch.purpose,
|
|
841
|
+
creator: ch.creator,
|
|
842
|
+
num_members: ch.num_members,
|
|
843
|
+
created: Math.floor(new Date(ch.created_at).getTime() / 1e3)
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function conversationsRoutes(ctx) {
|
|
847
|
+
const { app, store, webhooks } = ctx;
|
|
848
|
+
const ss = () => getSlackStore(store);
|
|
849
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
850
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
851
|
+
const memberAliases = (user, userId) => new Set([userId, user?.name].filter((value) => Boolean(value)));
|
|
852
|
+
const getChannelMemberKey = (channel, user, userId) => {
|
|
853
|
+
const aliases = memberAliases(user, userId);
|
|
854
|
+
return channel.members.find((member) => aliases.has(member));
|
|
855
|
+
};
|
|
856
|
+
const isChannelMember = (channel, user, userId) => getChannelMemberKey(channel, user, userId) !== void 0;
|
|
857
|
+
const canReadConversation = (channel, user, userId) => !channel.is_private || isChannelMember(channel, user, userId);
|
|
858
|
+
const visibleFileChannelIds = (file, authUser) => {
|
|
859
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
860
|
+
const authUserId = authSlackUser?.user_id ?? authUser.login;
|
|
861
|
+
return fileChannels(file).filter((channelId) => {
|
|
862
|
+
const channel = ss().channels.findOneBy("channel_id", channelId);
|
|
863
|
+
return channel ? canReadConversation(channel, authSlackUser, authUserId) : false;
|
|
864
|
+
});
|
|
865
|
+
};
|
|
866
|
+
const visibleFileForAuth = (file, authUser) => {
|
|
867
|
+
const visibleIds = new Set(visibleFileChannelIds(file, authUser));
|
|
868
|
+
const publicShares = filterVisibleShares(file.shares.public, visibleIds);
|
|
869
|
+
const privateShares = filterVisibleShares(file.shares.private, visibleIds);
|
|
870
|
+
const shares = {};
|
|
871
|
+
if (publicShares) shares.public = publicShares;
|
|
872
|
+
if (privateShares) shares.private = privateShares;
|
|
873
|
+
return {
|
|
874
|
+
...file,
|
|
875
|
+
channels: file.channels.filter((channelId) => visibleIds.has(channelId)),
|
|
876
|
+
groups: file.groups.filter((channelId) => visibleIds.has(channelId)),
|
|
877
|
+
ims: file.ims.filter((channelId) => visibleIds.has(channelId)),
|
|
878
|
+
shares
|
|
879
|
+
};
|
|
880
|
+
};
|
|
881
|
+
const formatSlackMessageForAuth = (msg, authUser) => formatSlackMessage({
|
|
882
|
+
...msg,
|
|
883
|
+
...msg.files ? {
|
|
884
|
+
files: msg.files.map((file) => ss().files.findOneBy("file_id", file.file_id) ?? file).filter((file) => !file.deleted).map((file) => visibleFileForAuth(file, authUser))
|
|
885
|
+
} : {}
|
|
886
|
+
});
|
|
887
|
+
const dispatchConversationEvent = async (type, event) => {
|
|
888
|
+
await webhooks.dispatch(
|
|
889
|
+
type,
|
|
890
|
+
void 0,
|
|
891
|
+
{
|
|
892
|
+
type: "event_callback",
|
|
893
|
+
event: { type, ...event }
|
|
894
|
+
},
|
|
895
|
+
"slack"
|
|
896
|
+
);
|
|
897
|
+
};
|
|
898
|
+
const insertAndDispatchMessageEvent = async (channel, user, message) => {
|
|
899
|
+
const msg = ss().messages.insert({
|
|
900
|
+
ts: generateTs(),
|
|
901
|
+
channel_id: channel.channel_id,
|
|
902
|
+
user,
|
|
903
|
+
type: "message",
|
|
904
|
+
...message,
|
|
905
|
+
reply_count: 0,
|
|
906
|
+
reply_users: [],
|
|
907
|
+
reactions: []
|
|
908
|
+
});
|
|
909
|
+
await webhooks.dispatch(
|
|
910
|
+
"message",
|
|
911
|
+
void 0,
|
|
912
|
+
{
|
|
913
|
+
type: "event_callback",
|
|
914
|
+
event: {
|
|
915
|
+
...formatSlackMessage(msg),
|
|
916
|
+
channel: channel.channel_id,
|
|
917
|
+
event_ts: msg.ts
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
"slack"
|
|
921
|
+
);
|
|
922
|
+
return msg;
|
|
923
|
+
};
|
|
924
|
+
const dispatchMemberJoined = async (channel, user, inviter) => {
|
|
925
|
+
await dispatchConversationEvent("member_joined_channel", {
|
|
926
|
+
user,
|
|
927
|
+
channel: channel.channel_id,
|
|
928
|
+
channel_type: channelTypeLetter(channel),
|
|
929
|
+
team: channel.team_id,
|
|
930
|
+
...inviter ? { inviter } : {}
|
|
931
|
+
});
|
|
932
|
+
};
|
|
933
|
+
const dispatchMemberLeft = async (channel, user) => {
|
|
934
|
+
await dispatchConversationEvent("member_left_channel", {
|
|
935
|
+
user,
|
|
936
|
+
channel: channel.channel_id,
|
|
937
|
+
channel_type: channelTypeLetter(channel),
|
|
938
|
+
team: channel.team_id
|
|
939
|
+
});
|
|
940
|
+
};
|
|
941
|
+
app.post("/api/conversations.list", async (c) => {
|
|
942
|
+
const authUser = c.get("authUser");
|
|
943
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
944
|
+
const body = await parseSlackBody(c);
|
|
945
|
+
const limit = Math.min(Number(body.limit) || 100, 1e3);
|
|
946
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
947
|
+
const excludeArchived = isTruthySlackBoolean(body.exclude_archived);
|
|
948
|
+
const types = parseConversationTypes(body.types);
|
|
949
|
+
const scopeError = requireSlackScopes(c, store, readScopesForConversationTypes(types));
|
|
950
|
+
if (scopeError) return scopeError;
|
|
951
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
952
|
+
const authUserId = getAuthUserId(authUser);
|
|
953
|
+
const allChannels = ss().channels.all().filter((ch) => matchesConversationTypes(ch, types)).filter((ch) => canReadConversation(ch, authSlackUser, authUserId)).filter((ch) => !excludeArchived || !ch.is_archived);
|
|
954
|
+
let startIndex = 0;
|
|
955
|
+
if (cursor) {
|
|
956
|
+
const idx = allChannels.findIndex((ch) => ch.channel_id === cursor);
|
|
957
|
+
if (idx >= 0) startIndex = idx;
|
|
958
|
+
}
|
|
959
|
+
const page = allChannels.slice(startIndex, startIndex + limit);
|
|
960
|
+
const nextCursor = startIndex + limit < allChannels.length ? allChannels[startIndex + limit].channel_id : "";
|
|
961
|
+
return slackOk(c, {
|
|
962
|
+
channels: page.map((ch) => formatChannel(ch, authUserId, authSlackUser?.name)),
|
|
963
|
+
response_metadata: { next_cursor: nextCursor }
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
app.post("/api/conversations.info", async (c) => {
|
|
967
|
+
const authUser = c.get("authUser");
|
|
968
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
969
|
+
const body = await parseSlackBody(c);
|
|
970
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
971
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
972
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
973
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationReadScope(ch)]);
|
|
974
|
+
if (scopeError) return scopeError;
|
|
975
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
976
|
+
const authUserId = getAuthUserId(authUser);
|
|
977
|
+
if (!canReadConversation(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
978
|
+
return slackOk(c, { channel: formatChannel(ch, authUserId, authSlackUser?.name) });
|
|
979
|
+
});
|
|
980
|
+
app.post("/api/conversations.create", async (c) => {
|
|
981
|
+
const authUser = c.get("authUser");
|
|
982
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
983
|
+
const body = await parseSlackBody(c);
|
|
984
|
+
const name = normalizeChannelName(typeof body.name === "string" ? body.name : "");
|
|
985
|
+
const isPrivate = body.is_private === true || body.is_private === "true";
|
|
986
|
+
const scopeError = requireSlackScopes(c, store, [
|
|
987
|
+
isPrivate ? "groups:write" : ["channels:manage", "channels:write"]
|
|
988
|
+
]);
|
|
989
|
+
if (scopeError) return scopeError;
|
|
990
|
+
if (!name) return slackError(c, "invalid_name_specials");
|
|
991
|
+
const nameError = validateChannelName(name);
|
|
992
|
+
if (nameError) return slackError(c, nameError);
|
|
993
|
+
const existing = findNamedChannel(ss().channels.all(), name);
|
|
994
|
+
if (existing) return slackError(c, "name_taken");
|
|
995
|
+
const team = ss().teams.all()[0];
|
|
996
|
+
const channelId = generateSlackId("C");
|
|
997
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
998
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
999
|
+
const authUserId = getAuthUserId(authUser);
|
|
1000
|
+
const ch = ss().channels.insert({
|
|
1001
|
+
channel_id: channelId,
|
|
1002
|
+
team_id: team?.team_id ?? "T000000001",
|
|
1003
|
+
name,
|
|
1004
|
+
is_channel: !isPrivate,
|
|
1005
|
+
is_private: isPrivate,
|
|
1006
|
+
is_archived: false,
|
|
1007
|
+
topic: { value: "", creator: "", last_set: 0 },
|
|
1008
|
+
purpose: { value: "", creator: authUserId, last_set: now },
|
|
1009
|
+
members: [authUserId],
|
|
1010
|
+
creator: authUserId,
|
|
1011
|
+
num_members: 1
|
|
1012
|
+
});
|
|
1013
|
+
return slackOk(c, { channel: formatChannel(ch, authUserId, authSlackUser?.name) });
|
|
1014
|
+
});
|
|
1015
|
+
app.post("/api/conversations.archive", async (c) => {
|
|
1016
|
+
const authUser = c.get("authUser");
|
|
1017
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1018
|
+
const body = await parseSlackBody(c);
|
|
1019
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1020
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1021
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1022
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1023
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1024
|
+
if (scopeError) return scopeError;
|
|
1025
|
+
if (isDirectConversation(ch)) return slackError(c, "method_not_supported_for_channel_type");
|
|
1026
|
+
if (isGeneralChannel(ch)) return slackError(c, "cant_archive_general");
|
|
1027
|
+
if (ch.is_archived) return slackError(c, "already_archived");
|
|
1028
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1029
|
+
const authUserId = getAuthUserId(authUser);
|
|
1030
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1031
|
+
const updated = ss().channels.update(ch.id, { is_archived: true });
|
|
1032
|
+
await dispatchConversationEvent(lifecycleEventType(updated, "archive"), {
|
|
1033
|
+
channel: updated.channel_id,
|
|
1034
|
+
user: authUserId
|
|
1035
|
+
});
|
|
1036
|
+
await insertAndDispatchMessageEvent(updated, authUserId, {
|
|
1037
|
+
subtype: lifecycleEventType(updated, "archive"),
|
|
1038
|
+
text: `<@${authUserId}> archived the ${conversationNoun(updated)}`
|
|
1039
|
+
});
|
|
1040
|
+
return slackOk(c, {});
|
|
1041
|
+
});
|
|
1042
|
+
app.post("/api/conversations.unarchive", async (c) => {
|
|
1043
|
+
const authUser = c.get("authUser");
|
|
1044
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1045
|
+
const body = await parseSlackBody(c);
|
|
1046
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1047
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1048
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1049
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1050
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1051
|
+
if (scopeError) return scopeError;
|
|
1052
|
+
if (isDirectConversation(ch)) return slackError(c, "method_not_supported_for_channel_type");
|
|
1053
|
+
if (!ch.is_archived) return slackError(c, "not_archived");
|
|
1054
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1055
|
+
const authUserId = getAuthUserId(authUser);
|
|
1056
|
+
const isMember = isChannelMember(ch, authSlackUser, authUserId);
|
|
1057
|
+
if (ch.is_private && !isMember) return slackError(c, "not_in_channel");
|
|
1058
|
+
const members = isMember ? ch.members : [...ch.members, authUserId];
|
|
1059
|
+
const updated = ss().channels.update(ch.id, {
|
|
1060
|
+
is_archived: false,
|
|
1061
|
+
members,
|
|
1062
|
+
num_members: members.length
|
|
1063
|
+
});
|
|
1064
|
+
await dispatchConversationEvent(lifecycleEventType(updated, "unarchive"), {
|
|
1065
|
+
channel: updated.channel_id,
|
|
1066
|
+
user: authUserId
|
|
1067
|
+
});
|
|
1068
|
+
await insertAndDispatchMessageEvent(updated, authUserId, {
|
|
1069
|
+
subtype: lifecycleEventType(updated, "unarchive"),
|
|
1070
|
+
text: `<@${authUserId}> unarchived the ${conversationNoun(updated)}`
|
|
1071
|
+
});
|
|
1072
|
+
return slackOk(c, {});
|
|
1073
|
+
});
|
|
1074
|
+
app.post("/api/conversations.rename", async (c) => {
|
|
1075
|
+
const authUser = c.get("authUser");
|
|
1076
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1077
|
+
const body = await parseSlackBody(c);
|
|
1078
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1079
|
+
const name = normalizeChannelName(typeof body.name === "string" ? body.name : "");
|
|
1080
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1081
|
+
const nameError = validateChannelName(name);
|
|
1082
|
+
if (nameError) return slackError(c, nameError);
|
|
1083
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1084
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1085
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1086
|
+
if (scopeError) return scopeError;
|
|
1087
|
+
if (isDirectConversation(ch)) return slackError(c, "method_not_supported_for_channel_type");
|
|
1088
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1089
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1090
|
+
const authUserId = getAuthUserId(authUser);
|
|
1091
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1092
|
+
if (ch.creator !== authUserId && ch.creator !== authUser.login && !authSlackUser?.is_admin) {
|
|
1093
|
+
return slackError(c, "not_authorized");
|
|
1094
|
+
}
|
|
1095
|
+
const existing = findNamedChannel(ss().channels.all(), name);
|
|
1096
|
+
if (existing && existing.id !== ch.id) return slackError(c, "name_taken");
|
|
1097
|
+
if (name === ch.name) return slackOk(c, { channel: formatChannel(ch, authUserId, authSlackUser?.name) });
|
|
1098
|
+
const oldName = ch.name;
|
|
1099
|
+
const updated = ss().channels.update(ch.id, { name });
|
|
1100
|
+
await dispatchConversationEvent(lifecycleEventType(updated, "rename"), {
|
|
1101
|
+
channel: {
|
|
1102
|
+
id: updated.channel_id,
|
|
1103
|
+
name: updated.name,
|
|
1104
|
+
created: createdSeconds(updated)
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
await insertAndDispatchMessageEvent(updated, authUserId, {
|
|
1108
|
+
subtype: lifecycleMessageSubtype(updated, "name"),
|
|
1109
|
+
text: `<@${authUserId}> renamed the ${conversationNoun(updated)} from "${oldName}" to "${updated.name}"`,
|
|
1110
|
+
old_name: oldName,
|
|
1111
|
+
name: updated.name
|
|
1112
|
+
});
|
|
1113
|
+
return slackOk(c, { channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
1114
|
+
});
|
|
1115
|
+
app.post("/api/conversations.setTopic", async (c) => {
|
|
1116
|
+
const authUser = c.get("authUser");
|
|
1117
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1118
|
+
const body = await parseSlackBody(c);
|
|
1119
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1120
|
+
const topic = typeof body.topic === "string" ? body.topic : void 0;
|
|
1121
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1122
|
+
if (topic === void 0) return slackError(c, "invalid_arguments");
|
|
1123
|
+
if (topic.length > 250) return slackError(c, "too_long");
|
|
1124
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1125
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1126
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1127
|
+
if (scopeError) return scopeError;
|
|
1128
|
+
if (isDirectConversation(ch)) return slackError(c, "method_not_supported_for_channel_type");
|
|
1129
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1130
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1131
|
+
const authUserId = getAuthUserId(authUser);
|
|
1132
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1133
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1134
|
+
const updated = ss().channels.update(ch.id, {
|
|
1135
|
+
topic: { value: topic, creator: authUserId, last_set: now }
|
|
1136
|
+
});
|
|
1137
|
+
await insertAndDispatchMessageEvent(updated, authUserId, {
|
|
1138
|
+
subtype: lifecycleMessageSubtype(updated, "topic"),
|
|
1139
|
+
text: `<@${authUserId}> set the ${conversationNoun(updated)} topic: ${topic}`,
|
|
1140
|
+
topic
|
|
1141
|
+
});
|
|
1142
|
+
return slackOk(c, { channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
1143
|
+
});
|
|
1144
|
+
app.post("/api/conversations.setPurpose", async (c) => {
|
|
1145
|
+
const authUser = c.get("authUser");
|
|
1146
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1147
|
+
const body = await parseSlackBody(c);
|
|
1148
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1149
|
+
const purpose = typeof body.purpose === "string" ? body.purpose : void 0;
|
|
1150
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1151
|
+
if (purpose === void 0) return slackError(c, "invalid_arguments");
|
|
1152
|
+
if (purpose.length > 250) return slackError(c, "too_long");
|
|
1153
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1154
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1155
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1156
|
+
if (scopeError) return scopeError;
|
|
1157
|
+
if (isDirectConversation(ch)) return slackError(c, "method_not_supported_for_channel_type");
|
|
1158
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1159
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1160
|
+
const authUserId = getAuthUserId(authUser);
|
|
1161
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1162
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1163
|
+
const updated = ss().channels.update(ch.id, {
|
|
1164
|
+
purpose: { value: purpose, creator: authUserId, last_set: now }
|
|
1165
|
+
});
|
|
1166
|
+
await insertAndDispatchMessageEvent(updated, authUserId, {
|
|
1167
|
+
subtype: lifecycleMessageSubtype(updated, "purpose"),
|
|
1168
|
+
text: `<@${authUserId}> set the ${conversationNoun(updated)} purpose: ${purpose}`,
|
|
1169
|
+
purpose
|
|
1170
|
+
});
|
|
1171
|
+
return slackOk(c, { purpose, channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
1172
|
+
});
|
|
1173
|
+
app.post("/api/conversations.history", async (c) => {
|
|
1174
|
+
const authUser = c.get("authUser");
|
|
1175
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1176
|
+
const body = await parseSlackBody(c);
|
|
1177
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1178
|
+
const limit = Math.min(Number(body.limit) || 100, 1e3);
|
|
1179
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
1180
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1181
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1182
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1183
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationHistoryScope(ch)]);
|
|
1184
|
+
if (scopeError) return scopeError;
|
|
1185
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1186
|
+
const authUserId = getAuthUserId(authUser);
|
|
1187
|
+
if (!canReadConversation(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1188
|
+
const allMessages = ss().messages.findBy("channel_id", channel).filter((m) => !m.thread_ts || m.thread_ts === m.ts).sort((a, b) => b.ts > a.ts ? 1 : -1);
|
|
1189
|
+
let startIndex = 0;
|
|
1190
|
+
if (cursor) {
|
|
1191
|
+
const idx = allMessages.findIndex((m) => m.ts === cursor);
|
|
1192
|
+
if (idx >= 0) startIndex = idx;
|
|
1193
|
+
}
|
|
1194
|
+
const page = allMessages.slice(startIndex, startIndex + limit);
|
|
1195
|
+
const hasMore = startIndex + limit < allMessages.length;
|
|
1196
|
+
const nextCursor = hasMore ? allMessages[startIndex + limit].ts : "";
|
|
1197
|
+
return slackOk(c, {
|
|
1198
|
+
messages: page.map((message) => formatSlackMessageForAuth(message, authUser)),
|
|
1199
|
+
has_more: hasMore,
|
|
1200
|
+
response_metadata: { next_cursor: nextCursor }
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
app.post("/api/conversations.replies", async (c) => {
|
|
1204
|
+
const authUser = c.get("authUser");
|
|
1205
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1206
|
+
const body = await parseSlackBody(c);
|
|
1207
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1208
|
+
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
1209
|
+
if (!channel || !ts) return slackError(c, "channel_not_found");
|
|
1210
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1211
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1212
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationHistoryScope(ch)]);
|
|
1213
|
+
if (scopeError) return scopeError;
|
|
1214
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1215
|
+
const authUserId = getAuthUserId(authUser);
|
|
1216
|
+
if (!canReadConversation(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1217
|
+
const allMessages = ss().messages.findBy("channel_id", channel).filter((m) => m.ts === ts || m.thread_ts === ts).sort((a, b) => a.ts > b.ts ? 1 : -1);
|
|
1218
|
+
return slackOk(c, {
|
|
1219
|
+
messages: allMessages.map((message) => formatSlackMessageForAuth(message, authUser)),
|
|
1220
|
+
has_more: false
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
app.post("/api/conversations.join", async (c) => {
|
|
1224
|
+
const authUser = c.get("authUser");
|
|
1225
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1226
|
+
const body = await parseSlackBody(c);
|
|
1227
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1228
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1229
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1230
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationJoinScope(ch)]);
|
|
1231
|
+
if (scopeError) return scopeError;
|
|
1232
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1233
|
+
if (ch.is_im || ch.is_mpim) return slackError(c, "method_not_supported_for_channel_type");
|
|
1234
|
+
const authUserId = getAuthUserId(authUser);
|
|
1235
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1236
|
+
if (ch.is_private && !isChannelMember(ch, authSlackUser, authUserId)) {
|
|
1237
|
+
return slackError(c, "not_in_channel");
|
|
1238
|
+
}
|
|
1239
|
+
const memberKey = getChannelMemberKey(ch, authSlackUser, authUserId);
|
|
1240
|
+
if (!memberKey) {
|
|
1241
|
+
const updated2 = ss().channels.update(ch.id, {
|
|
1242
|
+
members: [...ch.members, authUserId],
|
|
1243
|
+
num_members: ch.num_members + 1
|
|
1244
|
+
});
|
|
1245
|
+
await dispatchMemberJoined(updated2, authUserId);
|
|
1246
|
+
}
|
|
1247
|
+
const updated = ss().channels.findOneBy("channel_id", channel);
|
|
1248
|
+
return slackOk(c, { channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
1249
|
+
});
|
|
1250
|
+
app.post("/api/conversations.leave", async (c) => {
|
|
1251
|
+
const authUser = c.get("authUser");
|
|
1252
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1253
|
+
const body = await parseSlackBody(c);
|
|
1254
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1255
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1256
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1257
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1258
|
+
if (scopeError) return scopeError;
|
|
1259
|
+
if (ch.is_im) return slackError(c, "method_not_supported_for_channel_type");
|
|
1260
|
+
if (isGeneralChannel(ch)) return slackError(c, "cant_leave_general");
|
|
1261
|
+
const authUserId = getAuthUserId(authUser);
|
|
1262
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1263
|
+
const memberKey = getChannelMemberKey(ch, authSlackUser, authUserId);
|
|
1264
|
+
if (!memberKey) return c.json({ ok: false, not_in_channel: true });
|
|
1265
|
+
const aliases = memberAliases(authSlackUser, authUserId);
|
|
1266
|
+
const updatedMembers = ch.members.filter((m) => !aliases.has(m));
|
|
1267
|
+
if (updatedMembers.length === 0) return slackError(c, "last_member");
|
|
1268
|
+
const updated = ss().channels.update(ch.id, {
|
|
1269
|
+
members: updatedMembers,
|
|
1270
|
+
num_members: updatedMembers.length
|
|
1271
|
+
});
|
|
1272
|
+
await dispatchMemberLeft(updated, authUserId);
|
|
1273
|
+
return slackOk(c, {});
|
|
1274
|
+
});
|
|
1275
|
+
app.post("/api/conversations.invite", async (c) => {
|
|
1276
|
+
const authUser = c.get("authUser");
|
|
1277
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1278
|
+
const body = await parseSlackBody(c);
|
|
1279
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1280
|
+
const users = parseUserList(body.users);
|
|
1281
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1282
|
+
if (users.length === 0) return slackError(c, "user_not_found");
|
|
1283
|
+
if (users.length > 100) return slackError(c, "too_many_users");
|
|
1284
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1285
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1286
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1287
|
+
if (scopeError) return scopeError;
|
|
1288
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1289
|
+
if (ch.is_im) return slackError(c, "method_not_supported_for_channel_type");
|
|
1290
|
+
const authUserId = getAuthUserId(authUser);
|
|
1291
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1292
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1293
|
+
const errors = [];
|
|
1294
|
+
const validUsers = [];
|
|
1295
|
+
for (const userId of users) {
|
|
1296
|
+
const user = ss().users.findOneBy("user_id", userId);
|
|
1297
|
+
if (!user || user.deleted) {
|
|
1298
|
+
errors.push({ user: userId, ok: false, error: "user_not_found" });
|
|
1299
|
+
} else if (userId === authUserId) {
|
|
1300
|
+
errors.push({ user: userId, ok: false, error: "cant_invite_self" });
|
|
1301
|
+
} else if (isChannelMember(ch, user, userId)) {
|
|
1302
|
+
errors.push({ user: userId, ok: false, error: "already_in_channel" });
|
|
1303
|
+
} else {
|
|
1304
|
+
validUsers.push(userId);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (errors.length > 0) {
|
|
1308
|
+
return c.json({ ok: false, error: errors[0].error, errors });
|
|
1309
|
+
}
|
|
1310
|
+
const updatedMembers = [...ch.members, ...validUsers];
|
|
1311
|
+
const updated = ss().channels.update(ch.id, {
|
|
1312
|
+
members: updatedMembers,
|
|
1313
|
+
num_members: updatedMembers.length
|
|
1314
|
+
});
|
|
1315
|
+
for (const user of validUsers) {
|
|
1316
|
+
await dispatchMemberJoined(updated, user, authUserId);
|
|
1317
|
+
}
|
|
1318
|
+
return slackOk(c, { channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
1319
|
+
});
|
|
1320
|
+
app.post("/api/conversations.kick", async (c) => {
|
|
1321
|
+
const authUser = c.get("authUser");
|
|
1322
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1323
|
+
const body = await parseSlackBody(c);
|
|
1324
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1325
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1326
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1327
|
+
if (!user) return slackError(c, "user_not_found");
|
|
1328
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1329
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1330
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1331
|
+
if (scopeError) return scopeError;
|
|
1332
|
+
if (ch.is_archived) return slackError(c, "is_archived");
|
|
1333
|
+
if (isGeneralChannel(ch)) return slackError(c, "cant_kick_from_general");
|
|
1334
|
+
if (ch.is_im) return slackError(c, "method_not_supported_for_channel_type");
|
|
1335
|
+
const authUserId = getAuthUserId(authUser);
|
|
1336
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1337
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1338
|
+
if (user === authUserId) return slackError(c, "cant_kick_self");
|
|
1339
|
+
const targetUser = ss().users.findOneBy("user_id", user);
|
|
1340
|
+
if (!targetUser) return slackError(c, "user_not_found");
|
|
1341
|
+
const targetMemberKey = getChannelMemberKey(ch, targetUser, user);
|
|
1342
|
+
if (!targetMemberKey) return slackError(c, "user_not_in_channel");
|
|
1343
|
+
const targetAliases = memberAliases(targetUser, user);
|
|
1344
|
+
const updatedMembers = ch.members.filter((member) => !targetAliases.has(member));
|
|
1345
|
+
const updated = ss().channels.update(ch.id, {
|
|
1346
|
+
members: updatedMembers,
|
|
1347
|
+
num_members: updatedMembers.length
|
|
1348
|
+
});
|
|
1349
|
+
await dispatchMemberLeft(updated, user);
|
|
1350
|
+
return slackOk(c, { errors: {} });
|
|
1351
|
+
});
|
|
1352
|
+
app.post("/api/conversations.open", async (c) => {
|
|
1353
|
+
const authUser = c.get("authUser");
|
|
1354
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1355
|
+
const body = await parseSlackBody(c);
|
|
1356
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1357
|
+
const users = parseUserList(body.users);
|
|
1358
|
+
const returnIm = isTruthySlackBoolean(body.return_im);
|
|
1359
|
+
const preventCreation = isTruthySlackBoolean(body.prevent_creation);
|
|
1360
|
+
const authUserId = getAuthUserId(authUser);
|
|
1361
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1362
|
+
if (channel) {
|
|
1363
|
+
const existing2 = ss().channels.findOneBy("channel_id", channel);
|
|
1364
|
+
if (!existing2 || !existing2.is_im && !existing2.is_mpim) return slackError(c, "channel_not_found");
|
|
1365
|
+
const scopeError2 = requireSlackScopes(c, store, [slackConversationWriteScope(existing2)]);
|
|
1366
|
+
if (scopeError2) return scopeError2;
|
|
1367
|
+
if (!isChannelMember(existing2, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1368
|
+
const alreadyOpen = getSlackConversationOpenState(existing2, authUserId);
|
|
1369
|
+
const updated = alreadyOpen ? existing2 : ss().channels.update(existing2.id, setSlackConversationOpenState(existing2, authUserId, true));
|
|
1370
|
+
if (!alreadyOpen) await dispatchConversationEvent(openEventType(updated), { channel: updated.channel_id });
|
|
1371
|
+
return slackOk(c, {
|
|
1372
|
+
...alreadyOpen ? { no_op: true, already_open: true } : {},
|
|
1373
|
+
channel: returnIm ? formatChannel(updated, authUserId, authSlackUser?.name) : { id: updated.channel_id }
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
if (users.length === 0) return slackError(c, "users_list_not_supplied");
|
|
1377
|
+
if (users.length > 8) return slackError(c, "too_many_users");
|
|
1378
|
+
const targetUsers = [];
|
|
1379
|
+
for (const userId of users) {
|
|
1380
|
+
if (userId === authUserId) continue;
|
|
1381
|
+
const user = ss().users.findOneBy("user_id", userId);
|
|
1382
|
+
if (!user || user.deleted) return slackError(c, "user_not_found");
|
|
1383
|
+
targetUsers.push(user);
|
|
1384
|
+
}
|
|
1385
|
+
if (targetUsers.length === 0) return slackError(c, "users_list_not_supplied");
|
|
1386
|
+
const memberIds = [.../* @__PURE__ */ new Set([authUserId, ...targetUsers.map((user) => user.user_id)])];
|
|
1387
|
+
const isMpim = memberIds.length > 2;
|
|
1388
|
+
const scopeError = requireSlackScopes(c, store, [isMpim ? "mpim:write" : "im:write"]);
|
|
1389
|
+
if (scopeError) return scopeError;
|
|
1390
|
+
const existing = findConversationByMembers(ss().channels.all(), memberIds, isMpim);
|
|
1391
|
+
if (existing) {
|
|
1392
|
+
const alreadyOpen = getSlackConversationOpenState(existing, authUserId);
|
|
1393
|
+
const updated = alreadyOpen ? existing : ss().channels.update(existing.id, setSlackConversationOpenState(existing, authUserId, true));
|
|
1394
|
+
if (!alreadyOpen) await dispatchConversationEvent(openEventType(updated), { channel: updated.channel_id });
|
|
1395
|
+
return slackOk(c, {
|
|
1396
|
+
...alreadyOpen ? { no_op: true, already_open: true } : {},
|
|
1397
|
+
channel: returnIm ? formatChannel(updated, authUserId, authSlackUser?.name) : { id: updated.channel_id }
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
if (preventCreation) return slackError(c, "channel_not_found");
|
|
1401
|
+
const team = ss().teams.all()[0];
|
|
1402
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1403
|
+
const created = ss().channels.insert({
|
|
1404
|
+
channel_id: generateSlackId(isMpim ? "G" : "D"),
|
|
1405
|
+
team_id: team?.team_id ?? "T000000001",
|
|
1406
|
+
name: isMpim ? `mpdm-${targetUsers.map((user) => user.name).join("-")}` : targetUsers[0]?.name ?? "direct-message",
|
|
1407
|
+
is_channel: false,
|
|
1408
|
+
is_private: true,
|
|
1409
|
+
is_im: !isMpim,
|
|
1410
|
+
is_mpim: isMpim,
|
|
1411
|
+
is_open_by_user: { [authUserId]: true },
|
|
1412
|
+
user: isMpim ? void 0 : targetUsers[0]?.user_id,
|
|
1413
|
+
is_archived: false,
|
|
1414
|
+
topic: { value: "", creator: authUserId, last_set: now },
|
|
1415
|
+
purpose: { value: "", creator: authUserId, last_set: now },
|
|
1416
|
+
members: memberIds,
|
|
1417
|
+
creator: authUserId,
|
|
1418
|
+
num_members: memberIds.length,
|
|
1419
|
+
last_read: {}
|
|
1420
|
+
});
|
|
1421
|
+
await dispatchConversationEvent(created.is_im ? "im_created" : "group_joined", {
|
|
1422
|
+
channel: formatChannel(created, authUserId, authSlackUser?.name)
|
|
1423
|
+
});
|
|
1424
|
+
await dispatchConversationEvent(openEventType(created), { channel: created.channel_id });
|
|
1425
|
+
return slackOk(c, {
|
|
1426
|
+
channel: returnIm ? formatChannel(created, authUserId, authSlackUser?.name) : { id: created.channel_id }
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
app.post("/api/conversations.close", async (c) => {
|
|
1430
|
+
const authUser = c.get("authUser");
|
|
1431
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1432
|
+
const body = await parseSlackBody(c);
|
|
1433
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1434
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1435
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1436
|
+
if (!ch || !ch.is_im && !ch.is_mpim) return slackError(c, "channel_not_found");
|
|
1437
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1438
|
+
if (scopeError) return scopeError;
|
|
1439
|
+
const authUserId = getAuthUserId(authUser);
|
|
1440
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1441
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1442
|
+
if (!getSlackConversationOpenState(ch, authUserId)) {
|
|
1443
|
+
return slackOk(c, { no_op: true, already_closed: true });
|
|
1444
|
+
}
|
|
1445
|
+
const updated = ss().channels.update(ch.id, setSlackConversationOpenState(ch, authUserId, false));
|
|
1446
|
+
await dispatchConversationEvent(closeEventType(updated), { channel: updated.channel_id });
|
|
1447
|
+
return slackOk(c, {});
|
|
1448
|
+
});
|
|
1449
|
+
app.post("/api/conversations.mark", async (c) => {
|
|
1450
|
+
const authUser = c.get("authUser");
|
|
1451
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1452
|
+
const body = await parseSlackBody(c);
|
|
1453
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1454
|
+
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
1455
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1456
|
+
if (!ts) return slackError(c, "invalid_ts");
|
|
1457
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1458
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1459
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationWriteScope(ch)]);
|
|
1460
|
+
if (scopeError) return scopeError;
|
|
1461
|
+
const authUserId = getAuthUserId(authUser);
|
|
1462
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1463
|
+
if (!isChannelMember(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1464
|
+
ss().channels.update(ch.id, {
|
|
1465
|
+
last_read: { ...ch.last_read ?? {}, [authUserId]: ts }
|
|
1466
|
+
});
|
|
1467
|
+
await dispatchConversationEvent(markEventType(ch), { channel: ch.channel_id, ts });
|
|
1468
|
+
return slackOk(c, {});
|
|
1469
|
+
});
|
|
1470
|
+
app.post("/api/conversations.members", async (c) => {
|
|
1471
|
+
const authUser = c.get("authUser");
|
|
1472
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1473
|
+
const body = await parseSlackBody(c);
|
|
1474
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1475
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1476
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
1477
|
+
const scopeError = requireSlackScopes(c, store, [slackConversationReadScope(ch)]);
|
|
1478
|
+
if (scopeError) return scopeError;
|
|
1479
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1480
|
+
const authUserId = getAuthUserId(authUser);
|
|
1481
|
+
if (!canReadConversation(ch, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
1482
|
+
return slackOk(c, {
|
|
1483
|
+
members: ch.members,
|
|
1484
|
+
response_metadata: { next_cursor: "" }
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
function formatChannel(ch, viewer, viewerName) {
|
|
1489
|
+
const imUser = ch.is_im && viewer ? ch.members.find((member) => member !== viewer) : ch.user;
|
|
1490
|
+
const isMember = viewer ? ch.members.includes(viewer) || viewerName !== void 0 && ch.members.includes(viewerName) : void 0;
|
|
1491
|
+
return {
|
|
1492
|
+
id: ch.channel_id,
|
|
1493
|
+
name: ch.name,
|
|
1494
|
+
name_normalized: ch.name,
|
|
1495
|
+
is_channel: ch.is_channel,
|
|
1496
|
+
is_group: ch.is_private && !ch.is_im && !ch.is_mpim,
|
|
1497
|
+
is_im: ch.is_im ?? false,
|
|
1498
|
+
is_mpim: ch.is_mpim ?? false,
|
|
1499
|
+
is_private: ch.is_private,
|
|
1500
|
+
is_archived: ch.is_archived,
|
|
1501
|
+
is_open: getSlackConversationOpenState(ch, viewer),
|
|
1502
|
+
...imUser ? { user: imUser } : {},
|
|
1503
|
+
is_member: viewer ? isMember : void 0,
|
|
1504
|
+
last_read: viewer ? ch.last_read?.[viewer] ?? "0000000000.000000" : void 0,
|
|
1505
|
+
topic: ch.topic,
|
|
1506
|
+
purpose: ch.purpose,
|
|
1507
|
+
creator: ch.creator,
|
|
1508
|
+
num_members: ch.num_members,
|
|
1509
|
+
created: createdSeconds(ch)
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function createdSeconds(ch) {
|
|
1513
|
+
return Math.floor(new Date(ch.created_at).getTime() / 1e3);
|
|
1514
|
+
}
|
|
1515
|
+
function normalizeChannelName(name) {
|
|
1516
|
+
return name.trim().toLowerCase().replace(/\s+/g, "-");
|
|
1517
|
+
}
|
|
1518
|
+
function validateChannelName(name) {
|
|
1519
|
+
if (!name) return "invalid_name_required";
|
|
1520
|
+
if (name.length > 80) return "invalid_name_maxlength";
|
|
1521
|
+
if (!/[a-z0-9]/.test(name)) return "invalid_name_punctuation";
|
|
1522
|
+
if (!/^[a-z0-9_-]+$/.test(name)) return "invalid_name_specials";
|
|
1523
|
+
return void 0;
|
|
1524
|
+
}
|
|
1525
|
+
function isTruthySlackBoolean(value) {
|
|
1526
|
+
if (value === true || value === 1) return true;
|
|
1527
|
+
if (typeof value !== "string") return false;
|
|
1528
|
+
const normalized = value.toLowerCase();
|
|
1529
|
+
return normalized === "true" || normalized === "1";
|
|
1530
|
+
}
|
|
1531
|
+
function isGeneralChannel(ch) {
|
|
1532
|
+
return ch.channel_id === "C000000001" || ch.name === "general";
|
|
1533
|
+
}
|
|
1534
|
+
function isDirectConversation(ch) {
|
|
1535
|
+
return Boolean(ch.is_im || ch.is_mpim);
|
|
1536
|
+
}
|
|
1537
|
+
function lifecycleEventType(ch, action) {
|
|
1538
|
+
return `${conversationEventPrefix(ch)}_${action}`;
|
|
1539
|
+
}
|
|
1540
|
+
function lifecycleMessageSubtype(ch, action) {
|
|
1541
|
+
return `${conversationEventPrefix(ch)}_${action}`;
|
|
1542
|
+
}
|
|
1543
|
+
function conversationEventPrefix(ch) {
|
|
1544
|
+
return ch.is_private ? "group" : "channel";
|
|
1545
|
+
}
|
|
1546
|
+
function conversationNoun(ch) {
|
|
1547
|
+
return ch.is_private ? "group" : "channel";
|
|
1548
|
+
}
|
|
1549
|
+
function parseConversationTypes(value) {
|
|
1550
|
+
const raw = typeof value === "string" && value.length > 0 ? value : "public_channel";
|
|
1551
|
+
return new Set(
|
|
1552
|
+
raw.split(",").map((type) => type.trim()).filter(Boolean)
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
function readScopesForConversationTypes(types) {
|
|
1556
|
+
const scopes = [];
|
|
1557
|
+
if (types.has("public_channel")) scopes.push("channels:read");
|
|
1558
|
+
if (types.has("private_channel")) scopes.push("groups:read");
|
|
1559
|
+
if (types.has("im")) scopes.push("im:read");
|
|
1560
|
+
if (types.has("mpim")) scopes.push("mpim:read");
|
|
1561
|
+
return scopes.length > 0 ? scopes : ["channels:read"];
|
|
1562
|
+
}
|
|
1563
|
+
function matchesConversationTypes(ch, types) {
|
|
1564
|
+
if (types.has("public_channel") && !ch.is_private && !ch.is_im && !ch.is_mpim) return true;
|
|
1565
|
+
if (types.has("private_channel") && ch.is_private && !ch.is_im && !ch.is_mpim) return true;
|
|
1566
|
+
if (types.has("im") && ch.is_im) return true;
|
|
1567
|
+
if (types.has("mpim") && ch.is_mpim) return true;
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
function parseUserList(value) {
|
|
1571
|
+
const users = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
1572
|
+
return [...new Set(users.map((user) => String(user).trim()).filter(Boolean))];
|
|
1573
|
+
}
|
|
1574
|
+
function sameMembers(left, right) {
|
|
1575
|
+
if (left.length !== right.length) return false;
|
|
1576
|
+
const leftKey = [...left].sort().join(",");
|
|
1577
|
+
const rightKey = [...right].sort().join(",");
|
|
1578
|
+
return leftKey === rightKey;
|
|
1579
|
+
}
|
|
1580
|
+
function findConversationByMembers(channels, members, isMpim) {
|
|
1581
|
+
return channels.find(
|
|
1582
|
+
(ch) => Boolean(ch.is_mpim) === isMpim && Boolean(ch.is_im) === !isMpim && sameMembers(ch.members, members)
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
function findNamedChannel(channels, name) {
|
|
1586
|
+
return channels.find((ch) => !ch.is_im && !ch.is_mpim && ch.name === name);
|
|
1587
|
+
}
|
|
1588
|
+
function fileChannels(file) {
|
|
1589
|
+
return [...file.channels, ...file.groups, ...file.ims];
|
|
1590
|
+
}
|
|
1591
|
+
function filterVisibleShares(shares, visibleIds) {
|
|
1592
|
+
const entries = Object.entries(shares ?? {}).filter(([channelId]) => visibleIds.has(channelId));
|
|
1593
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
1594
|
+
}
|
|
1595
|
+
function channelTypeLetter(ch) {
|
|
1596
|
+
if (ch.is_im) return "D";
|
|
1597
|
+
if (ch.is_private || ch.is_mpim) return "G";
|
|
1598
|
+
return "C";
|
|
1599
|
+
}
|
|
1600
|
+
function openEventType(ch) {
|
|
1601
|
+
return ch.is_im ? "im_open" : "group_open";
|
|
1602
|
+
}
|
|
1603
|
+
function closeEventType(ch) {
|
|
1604
|
+
return ch.is_im ? "im_close" : "group_close";
|
|
1605
|
+
}
|
|
1606
|
+
function markEventType(ch) {
|
|
1607
|
+
if (ch.is_im) return "im_marked";
|
|
1608
|
+
if (ch.is_private || ch.is_mpim) return "group_marked";
|
|
1609
|
+
return "channel_marked";
|
|
1610
|
+
}
|
|
1611
|
+
function usersRoutes(ctx) {
|
|
1612
|
+
const { app, store, webhooks } = ctx;
|
|
1613
|
+
const ss = () => getSlackStore(store);
|
|
1614
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
1615
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
1616
|
+
app.post("/api/users.list", async (c) => {
|
|
1617
|
+
const authUser = c.get("authUser");
|
|
1618
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1619
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1620
|
+
if (scopeError) return scopeError;
|
|
1621
|
+
const body = await parseSlackBody(c);
|
|
1622
|
+
const limit = Math.min(Number(body.limit) || 100, 1e3);
|
|
1623
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
1624
|
+
const allUsers = ss().users.all().filter((u) => !u.deleted);
|
|
1625
|
+
let startIndex = 0;
|
|
1626
|
+
if (cursor) {
|
|
1627
|
+
const idx = allUsers.findIndex((u) => u.user_id === cursor);
|
|
1628
|
+
if (idx >= 0) startIndex = idx;
|
|
1629
|
+
}
|
|
1630
|
+
const page = allUsers.slice(startIndex, startIndex + limit);
|
|
1631
|
+
const nextCursor = startIndex + limit < allUsers.length ? allUsers[startIndex + limit].user_id : "";
|
|
1632
|
+
return slackOk(c, {
|
|
1633
|
+
members: page.map((user) => formatUser(user, canExposeEmail(c))),
|
|
1634
|
+
response_metadata: { next_cursor: nextCursor }
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
app.post("/api/users.info", async (c) => {
|
|
1638
|
+
const authUser = c.get("authUser");
|
|
1639
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1640
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1641
|
+
if (scopeError) return scopeError;
|
|
1642
|
+
const body = await parseSlackBody(c);
|
|
1643
|
+
const userId = typeof body.user === "string" ? body.user : "";
|
|
1644
|
+
const user = ss().users.findOneBy("user_id", userId);
|
|
1645
|
+
if (!user) return slackError(c, "user_not_found");
|
|
1646
|
+
return slackOk(c, { user: formatUser(user, canExposeEmail(c)) });
|
|
1647
|
+
});
|
|
1648
|
+
app.post("/api/users.lookupByEmail", async (c) => {
|
|
1649
|
+
const authUser = c.get("authUser");
|
|
1650
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1651
|
+
const scopeError = requireSlackScopes(c, store, ["users:read.email"]);
|
|
1652
|
+
if (scopeError) return scopeError;
|
|
1653
|
+
const body = await parseSlackBody(c);
|
|
1654
|
+
const email = typeof body.email === "string" ? body.email : "";
|
|
1655
|
+
if (!email) return slackError(c, "users_not_found");
|
|
1656
|
+
const user = ss().users.findOneBy("email", email);
|
|
1657
|
+
if (!user) return slackError(c, "users_not_found");
|
|
1658
|
+
return slackOk(c, { user: formatUser(user, true) });
|
|
1659
|
+
});
|
|
1660
|
+
async function profileGet(c) {
|
|
1661
|
+
const authUser = c.get("authUser");
|
|
1662
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1663
|
+
const scopeError = requireSlackScopes(c, store, ["users.profile:read"]);
|
|
1664
|
+
if (scopeError) return scopeError;
|
|
1665
|
+
const body = await parseSlackRequest(c);
|
|
1666
|
+
const requestedUserId = typeof body.user === "string" && body.user ? body.user : getAuthUserId(authUser);
|
|
1667
|
+
const user = ss().users.findOneBy("user_id", requestedUserId);
|
|
1668
|
+
if (!user || user.deleted) return slackError(c, "user_not_found");
|
|
1669
|
+
return slackOk(c, { profile: formatProfile(user.profile, canExposeEmail(c)) });
|
|
1670
|
+
}
|
|
1671
|
+
async function profileSet(c) {
|
|
1672
|
+
const authUser = c.get("authUser");
|
|
1673
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1674
|
+
const scopeError = requireSlackScopes(c, store, ["users.profile:write"]);
|
|
1675
|
+
if (scopeError) return scopeError;
|
|
1676
|
+
const body = await parseSlackBody(c);
|
|
1677
|
+
const requestedUserId = typeof body.user === "string" && body.user ? body.user : getAuthUserId(authUser);
|
|
1678
|
+
const user = ss().users.findOneBy("user_id", requestedUserId);
|
|
1679
|
+
if (!user || user.deleted) return slackError(c, "user_not_found");
|
|
1680
|
+
const updates = parseProfileUpdates(body);
|
|
1681
|
+
if (!updates) return slackError(c, "invalid_arguments");
|
|
1682
|
+
const nextProfile = mergeProfile(user.profile, updates);
|
|
1683
|
+
const userUpdates = { profile: nextProfile };
|
|
1684
|
+
if (updates.real_name !== void 0) userUpdates.real_name = nextProfile.real_name;
|
|
1685
|
+
if (updates.email !== void 0) userUpdates.email = nextProfile.email;
|
|
1686
|
+
const updated = ss().users.update(user.id, userUpdates);
|
|
1687
|
+
await webhooks.dispatch(
|
|
1688
|
+
"user_change",
|
|
1689
|
+
void 0,
|
|
1690
|
+
{
|
|
1691
|
+
type: "event_callback",
|
|
1692
|
+
event: {
|
|
1693
|
+
type: "user_change",
|
|
1694
|
+
user: formatUser(updated),
|
|
1695
|
+
cache_ts: Number(generateTs().replace(".", ""))
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
"slack"
|
|
1699
|
+
);
|
|
1700
|
+
return slackOk(c, { profile: formatProfile(updated.profile, canExposeEmail(c)) });
|
|
1701
|
+
}
|
|
1702
|
+
async function getPresence(c) {
|
|
1703
|
+
const authUser = c.get("authUser");
|
|
1704
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1705
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1706
|
+
if (scopeError) return scopeError;
|
|
1707
|
+
const body = await parseSlackRequest(c);
|
|
1708
|
+
const authUserId = getAuthUserId(authUser);
|
|
1709
|
+
const requestedUserId = typeof body.user === "string" && body.user ? body.user : authUserId;
|
|
1710
|
+
const user = ss().users.findOneBy("user_id", requestedUserId);
|
|
1711
|
+
if (!user || user.deleted) return slackError(c, "user_not_found");
|
|
1712
|
+
const presence = user.presence ?? "active";
|
|
1713
|
+
if (requestedUserId !== authUserId) {
|
|
1714
|
+
return slackOk(c, { presence });
|
|
1715
|
+
}
|
|
1716
|
+
const manualPresence = user.manual_presence ?? (presence === "away" ? "away" : "auto");
|
|
1717
|
+
return slackOk(c, {
|
|
1718
|
+
presence,
|
|
1719
|
+
online: presence === "active",
|
|
1720
|
+
auto_away: false,
|
|
1721
|
+
manual_away: manualPresence === "away",
|
|
1722
|
+
connection_count: user.connection_count ?? (presence === "active" ? 1 : 0),
|
|
1723
|
+
...user.last_activity ? { last_activity: user.last_activity } : {}
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
async function setPresence(c) {
|
|
1727
|
+
const authUser = c.get("authUser");
|
|
1728
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1729
|
+
const scopeError = requireSlackScopes(c, store, ["users:write"]);
|
|
1730
|
+
if (scopeError) return scopeError;
|
|
1731
|
+
const body = await parseSlackBody(c);
|
|
1732
|
+
const presence = typeof body.presence === "string" ? body.presence : "";
|
|
1733
|
+
if (presence !== "auto" && presence !== "away") return slackError(c, "invalid_presence");
|
|
1734
|
+
const authUserId = getAuthUserId(authUser);
|
|
1735
|
+
const user = ss().users.findOneBy("user_id", authUserId);
|
|
1736
|
+
if (!user || user.deleted) return slackError(c, "user_not_found");
|
|
1737
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1738
|
+
const nextPresence = presence === "away" ? "away" : "active";
|
|
1739
|
+
const manualPresence = presence === "away" ? "away" : "auto";
|
|
1740
|
+
const updated = ss().users.update(user.id, {
|
|
1741
|
+
presence: nextPresence,
|
|
1742
|
+
manual_presence: manualPresence,
|
|
1743
|
+
connection_count: nextPresence === "active" ? 1 : 0,
|
|
1744
|
+
last_activity: nextPresence === "active" ? now : user.last_activity
|
|
1745
|
+
});
|
|
1746
|
+
await webhooks.dispatch(
|
|
1747
|
+
"presence_change",
|
|
1748
|
+
void 0,
|
|
1749
|
+
{
|
|
1750
|
+
type: "event_callback",
|
|
1751
|
+
event: {
|
|
1752
|
+
type: "presence_change",
|
|
1753
|
+
user: updated.user_id,
|
|
1754
|
+
presence: nextPresence
|
|
1755
|
+
}
|
|
1756
|
+
},
|
|
1757
|
+
"slack"
|
|
1758
|
+
);
|
|
1759
|
+
return slackOk(c, {});
|
|
1760
|
+
}
|
|
1761
|
+
function canExposeEmail(c) {
|
|
1762
|
+
return !isSlackStrictScopes(store) || hasSlackScope(c, "users:read.email");
|
|
1763
|
+
}
|
|
1764
|
+
app.get("/api/users.profile.get", profileGet);
|
|
1765
|
+
app.post("/api/users.profile.get", profileGet);
|
|
1766
|
+
app.post("/api/users.profile.set", profileSet);
|
|
1767
|
+
app.get("/api/users.getPresence", getPresence);
|
|
1768
|
+
app.post("/api/users.getPresence", getPresence);
|
|
1769
|
+
app.post("/api/users.setPresence", setPresence);
|
|
1770
|
+
}
|
|
1771
|
+
function formatUser(u, includeEmail = true) {
|
|
1772
|
+
const profile = formatProfile(u.profile, includeEmail);
|
|
1773
|
+
return {
|
|
1774
|
+
id: u.user_id,
|
|
1775
|
+
team_id: u.team_id,
|
|
1776
|
+
name: u.name,
|
|
1777
|
+
real_name: u.real_name,
|
|
1778
|
+
is_admin: u.is_admin,
|
|
1779
|
+
is_bot: u.is_bot,
|
|
1780
|
+
deleted: u.deleted,
|
|
1781
|
+
profile
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
function formatProfile(profile, includeEmail = true) {
|
|
1785
|
+
const formatted = normalizeProfile(profile);
|
|
1786
|
+
return includeEmail ? formatted : omitEmail(formatted);
|
|
1787
|
+
}
|
|
1788
|
+
function normalizeProfile(profile) {
|
|
1789
|
+
return {
|
|
1790
|
+
title: "",
|
|
1791
|
+
phone: "",
|
|
1792
|
+
skype: "",
|
|
1793
|
+
...profile,
|
|
1794
|
+
real_name_normalized: profile.real_name_normalized ?? profile.real_name,
|
|
1795
|
+
display_name_normalized: profile.display_name_normalized ?? profile.display_name,
|
|
1796
|
+
status_text: profile.status_text ?? "",
|
|
1797
|
+
status_emoji: profile.status_emoji ?? "",
|
|
1798
|
+
status_emoji_display_info: profile.status_emoji_display_info ?? [],
|
|
1799
|
+
status_expiration: profile.status_expiration ?? 0,
|
|
1800
|
+
huddle_state: profile.huddle_state ?? "default_unset",
|
|
1801
|
+
huddle_state_expiration_ts: profile.huddle_state_expiration_ts ?? 0
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
function omitEmail(profile) {
|
|
1805
|
+
const { email: _email, ...rest } = profile;
|
|
1806
|
+
return rest;
|
|
1807
|
+
}
|
|
1808
|
+
async function parseSlackRequest(c) {
|
|
1809
|
+
if (c.req.method === "GET") {
|
|
1810
|
+
return Object.fromEntries(new URL(c.req.url).searchParams.entries());
|
|
1811
|
+
}
|
|
1812
|
+
return parseSlackBody(c);
|
|
1813
|
+
}
|
|
1814
|
+
function parseProfileUpdates(body) {
|
|
1815
|
+
const profile = parseProfileObject(body.profile);
|
|
1816
|
+
if (profile) return profile;
|
|
1817
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
1818
|
+
if (!name) return void 0;
|
|
1819
|
+
if (!Object.prototype.hasOwnProperty.call(body, "value")) return void 0;
|
|
1820
|
+
return { [name]: String(body.value ?? "") };
|
|
1821
|
+
}
|
|
1822
|
+
function parseProfileObject(value) {
|
|
1823
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
1824
|
+
if (typeof value === "string") {
|
|
1825
|
+
try {
|
|
1826
|
+
const parsed = JSON.parse(value);
|
|
1827
|
+
return isProfileObject(parsed) ? parsed : void 0;
|
|
1828
|
+
} catch {
|
|
1829
|
+
return void 0;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return isProfileObject(value) ? value : void 0;
|
|
1833
|
+
}
|
|
1834
|
+
function isProfileObject(value) {
|
|
1835
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1836
|
+
}
|
|
1837
|
+
function mergeProfile(profile, updates) {
|
|
1838
|
+
const next = normalizeProfile({ ...profile, ...updates });
|
|
1839
|
+
if (updates.real_name !== void 0) {
|
|
1840
|
+
next.real_name = String(updates.real_name);
|
|
1841
|
+
next.real_name_normalized = next.real_name;
|
|
1842
|
+
const [firstName = "", ...rest] = next.real_name.trim().split(/\s+/);
|
|
1843
|
+
next.first_name = firstName;
|
|
1844
|
+
next.last_name = rest.join(" ");
|
|
1845
|
+
}
|
|
1846
|
+
if (updates.display_name !== void 0) {
|
|
1847
|
+
next.display_name = String(updates.display_name);
|
|
1848
|
+
next.display_name_normalized = next.display_name;
|
|
1849
|
+
}
|
|
1850
|
+
if (updates.email !== void 0) {
|
|
1851
|
+
next.email = String(updates.email);
|
|
1852
|
+
}
|
|
1853
|
+
if (updates.fields !== void 0) {
|
|
1854
|
+
next.fields = updates.fields;
|
|
1855
|
+
}
|
|
1856
|
+
return next;
|
|
1857
|
+
}
|
|
1858
|
+
function reactionsRoutes(ctx) {
|
|
1859
|
+
const { app, store, webhooks } = ctx;
|
|
1860
|
+
const ss = () => getSlackStore(store);
|
|
1861
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
1862
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
1863
|
+
const getAuthUserAliases = (authUser) => {
|
|
1864
|
+
const user = getAuthSlackUser(authUser);
|
|
1865
|
+
return new Set([authUser.login, user?.user_id, user?.name].filter((value) => Boolean(value)));
|
|
1866
|
+
};
|
|
1867
|
+
const isAuthChannelMember = (channel, authUser) => {
|
|
1868
|
+
const user = getAuthSlackUser(authUser);
|
|
1869
|
+
const userId = user?.user_id ?? authUser.login;
|
|
1870
|
+
return channel.members.includes(userId) || (user ? channel.members.includes(user.name) : false);
|
|
1871
|
+
};
|
|
1872
|
+
const canAccessConversation = (channel, authUser) => !channel.is_private || isAuthChannelMember(channel, authUser);
|
|
1873
|
+
app.post("/api/reactions.add", async (c) => {
|
|
1874
|
+
const authUser = c.get("authUser");
|
|
1875
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1876
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:write"]);
|
|
1877
|
+
if (scopeError) return scopeError;
|
|
1878
|
+
const body = await parseSlackBody(c);
|
|
1879
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1880
|
+
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
1881
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
1882
|
+
if (!name) return slackError(c, "invalid_name");
|
|
1883
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1884
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
1885
|
+
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
1886
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
1887
|
+
const reactions = [...msg.reactions];
|
|
1888
|
+
const existing = reactions.find((r) => r.name === name);
|
|
1889
|
+
const authUserId = getAuthUserId(authUser);
|
|
1890
|
+
const aliases = getAuthUserAliases(authUser);
|
|
1891
|
+
if (existing) {
|
|
1892
|
+
if (existing.users.some((user) => aliases.has(user))) {
|
|
1893
|
+
return slackError(c, "already_reacted");
|
|
1894
|
+
}
|
|
1895
|
+
existing.users.push(authUserId);
|
|
1896
|
+
existing.count++;
|
|
1897
|
+
} else {
|
|
1898
|
+
reactions.push({ name, users: [authUserId], count: 1 });
|
|
1899
|
+
}
|
|
1900
|
+
ss().messages.update(msg.id, { reactions });
|
|
1901
|
+
await webhooks.dispatch(
|
|
1902
|
+
"reaction_added",
|
|
1903
|
+
void 0,
|
|
1904
|
+
{
|
|
1905
|
+
type: "event_callback",
|
|
1906
|
+
event: {
|
|
1907
|
+
type: "reaction_added",
|
|
1908
|
+
user: authUserId,
|
|
1909
|
+
reaction: name,
|
|
1910
|
+
item: { type: "message", channel, ts: timestamp }
|
|
1911
|
+
}
|
|
1912
|
+
},
|
|
1913
|
+
"slack"
|
|
1914
|
+
);
|
|
1915
|
+
return slackOk(c, {});
|
|
1916
|
+
});
|
|
1917
|
+
app.post("/api/reactions.remove", async (c) => {
|
|
1918
|
+
const authUser = c.get("authUser");
|
|
1919
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1920
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:write"]);
|
|
1921
|
+
if (scopeError) return scopeError;
|
|
1922
|
+
const body = await parseSlackBody(c);
|
|
1923
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1924
|
+
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
1925
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
1926
|
+
if (!name) return slackError(c, "invalid_name");
|
|
1927
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1928
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
1929
|
+
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
1930
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
1931
|
+
const reactions = [...msg.reactions];
|
|
1932
|
+
const existing = reactions.find((r) => r.name === name);
|
|
1933
|
+
const authUserId = getAuthUserId(authUser);
|
|
1934
|
+
const aliases = getAuthUserAliases(authUser);
|
|
1935
|
+
if (!existing || !existing.users.some((user) => aliases.has(user))) {
|
|
1936
|
+
return slackError(c, "no_reaction");
|
|
1937
|
+
}
|
|
1938
|
+
existing.users = existing.users.filter((u) => !aliases.has(u));
|
|
1939
|
+
existing.count = existing.users.length;
|
|
1940
|
+
const filtered = reactions.filter((r) => r.count > 0);
|
|
1941
|
+
ss().messages.update(msg.id, { reactions: filtered });
|
|
1942
|
+
await webhooks.dispatch(
|
|
1943
|
+
"reaction_removed",
|
|
1944
|
+
void 0,
|
|
1945
|
+
{
|
|
1946
|
+
type: "event_callback",
|
|
1947
|
+
event: {
|
|
1948
|
+
type: "reaction_removed",
|
|
1949
|
+
user: authUserId,
|
|
1950
|
+
reaction: name,
|
|
1951
|
+
item: { type: "message", channel, ts: timestamp }
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
"slack"
|
|
1955
|
+
);
|
|
1956
|
+
return slackOk(c, {});
|
|
1957
|
+
});
|
|
1958
|
+
app.post("/api/reactions.get", async (c) => {
|
|
1959
|
+
const authUser = c.get("authUser");
|
|
1960
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1961
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:read"]);
|
|
1962
|
+
if (scopeError) return scopeError;
|
|
1963
|
+
const body = await parseSlackBody(c);
|
|
1964
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1965
|
+
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
1966
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1967
|
+
if (ch && !canAccessConversation(ch, authUser)) return slackError(c, "not_in_channel");
|
|
1968
|
+
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
1969
|
+
if (!msg) return slackError(c, "message_not_found");
|
|
1970
|
+
return slackOk(c, {
|
|
1971
|
+
type: "message",
|
|
1972
|
+
message: { ...formatSlackMessage(msg), reactions: msg.reactions }
|
|
1973
|
+
});
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
function teamRoutes(ctx) {
|
|
1977
|
+
const { app, store } = ctx;
|
|
1978
|
+
const ss = () => getSlackStore(store);
|
|
1979
|
+
app.post("/api/team.info", (c) => {
|
|
1980
|
+
const authUser = c.get("authUser");
|
|
1981
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1982
|
+
const scopeError = requireSlackScopes(c, store, ["team:read"]);
|
|
1983
|
+
if (scopeError) return scopeError;
|
|
1984
|
+
const team = ss().teams.all()[0];
|
|
1985
|
+
if (!team) return slackError(c, "team_not_found");
|
|
1986
|
+
return slackOk(c, {
|
|
1987
|
+
team: {
|
|
1988
|
+
id: team.team_id,
|
|
1989
|
+
name: team.name,
|
|
1990
|
+
domain: team.domain
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
});
|
|
1994
|
+
app.post("/api/bots.info", async (c) => {
|
|
1995
|
+
const authUser = c.get("authUser");
|
|
1996
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1997
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1998
|
+
if (scopeError) return scopeError;
|
|
1999
|
+
const body = await parseSlackBody(c);
|
|
2000
|
+
const botId = typeof body.bot === "string" ? body.bot : "";
|
|
2001
|
+
const bot = ss().bots.findOneBy("bot_id", botId);
|
|
2002
|
+
if (!bot) return slackError(c, "bot_not_found");
|
|
2003
|
+
return slackOk(c, {
|
|
2004
|
+
bot: {
|
|
2005
|
+
id: bot.bot_id,
|
|
2006
|
+
name: bot.name,
|
|
2007
|
+
deleted: bot.deleted,
|
|
2008
|
+
icons: bot.icons
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
function createErrorHandler(documentationUrl) {
|
|
2014
|
+
return async (c, next) => {
|
|
2015
|
+
if (documentationUrl) {
|
|
2016
|
+
c.set("docsUrl", documentationUrl);
|
|
2017
|
+
}
|
|
2018
|
+
await next();
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
var errorHandler = createErrorHandler();
|
|
2022
|
+
var isDebug = typeof process !== "undefined" && (process.env.DEBUG === "1" || process.env.DEBUG === "true" || process.env.EMULATE_DEBUG === "1");
|
|
2023
|
+
function debug(label, ...args) {
|
|
2024
|
+
if (isDebug) {
|
|
2025
|
+
console.log(`[${label}]`, ...args);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2029
|
+
var FONTS = {
|
|
2030
|
+
"geist-sans.woff2": readFileSync(join(__dirname, "fonts", "geist-sans.woff2")),
|
|
2031
|
+
"GeistPixel-Square.woff2": readFileSync(join(__dirname, "fonts", "GeistPixel-Square.woff2"))
|
|
2032
|
+
};
|
|
2033
|
+
var FAVICON = readFileSync(join(__dirname, "fonts", "favicon.ico"));
|
|
2034
|
+
function escapeHtml(s) {
|
|
2035
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2036
|
+
}
|
|
2037
|
+
function escapeAttr(s) {
|
|
2038
|
+
return escapeHtml(s).replace(/'/g, "'");
|
|
2039
|
+
}
|
|
2040
|
+
var CSS = `
|
|
2041
|
+
@font-face{
|
|
2042
|
+
font-family:'Geist';font-style:normal;font-weight:100 900;font-display:swap;
|
|
2043
|
+
src:url('/_emulate/fonts/geist-sans.woff2') format('woff2');
|
|
2044
|
+
}
|
|
2045
|
+
@font-face{
|
|
2046
|
+
font-family:'Geist Pixel';font-style:normal;font-weight:400;font-display:swap;
|
|
2047
|
+
src:url('/_emulate/fonts/GeistPixel-Square.woff2') format('woff2');
|
|
2048
|
+
}
|
|
2049
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
2050
|
+
body{
|
|
2051
|
+
font-family:'Geist',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
2052
|
+
background:#000;color:#33ff00;min-height:100vh;
|
|
2053
|
+
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
|
|
2054
|
+
}
|
|
2055
|
+
.emu-bar{
|
|
2056
|
+
border-bottom:1px solid #0a3300;padding:10px 20px;
|
|
2057
|
+
display:flex;align-items:center;gap:10px;font-size:.8125rem;color:#1a8c00;
|
|
2058
|
+
}
|
|
2059
|
+
.emu-bar-title{font-weight:600;color:#33ff00;font-family:'Geist Pixel',monospace;}
|
|
2060
|
+
.emu-bar-links{margin-left:auto;display:flex;gap:16px;}
|
|
2061
|
+
.emu-bar-links a{
|
|
2062
|
+
color:#1a8c00;font-size:.75rem;text-decoration:none;transition:color .15s;
|
|
2063
|
+
}
|
|
2064
|
+
.emu-bar-links a:hover{color:#33ff00;}
|
|
2065
|
+
.emu-bar-links a .full{display:inline;}
|
|
2066
|
+
.emu-bar-links a .short{display:none;}
|
|
2067
|
+
@media(max-width:600px){
|
|
2068
|
+
.emu-bar-links a .full{display:none;}
|
|
2069
|
+
.emu-bar-links a .short{display:inline;}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
.content{
|
|
2073
|
+
display:flex;align-items:center;justify-content:center;
|
|
2074
|
+
min-height:calc(100vh - 42px);padding:24px 16px;
|
|
2075
|
+
}
|
|
2076
|
+
.content-inner{width:100%;max-width:420px;}
|
|
2077
|
+
.card-title{
|
|
2078
|
+
font-family:'Geist Pixel',monospace;
|
|
2079
|
+
font-size:1.125rem;font-weight:600;margin-bottom:4px;color:#33ff00;
|
|
2080
|
+
}
|
|
2081
|
+
.card-subtitle{color:#1a8c00;font-size:.8125rem;margin-bottom:18px;line-height:1.45;}
|
|
2082
|
+
.powered-by{
|
|
2083
|
+
position:fixed;bottom:0;left:0;right:0;
|
|
2084
|
+
text-align:center;padding:12px;font-size:.6875rem;color:#0a3300;
|
|
2085
|
+
font-family:'Geist Pixel',monospace;
|
|
2086
|
+
}
|
|
2087
|
+
.powered-by a{color:#1a8c00;text-decoration:none;transition:color .15s;}
|
|
2088
|
+
.powered-by a:hover{color:#33ff00;}
|
|
2089
|
+
|
|
2090
|
+
.error-title{
|
|
2091
|
+
font-family:'Geist Pixel',monospace;
|
|
2092
|
+
color:#ff4444;font-size:1.125rem;font-weight:600;margin-bottom:8px;
|
|
2093
|
+
}
|
|
2094
|
+
.error-msg{color:#1a8c00;font-size:.875rem;line-height:1.5;}
|
|
2095
|
+
.error-card{text-align:center;}
|
|
2096
|
+
|
|
2097
|
+
.user-form{margin-bottom:8px;}
|
|
2098
|
+
.user-form:last-of-type{margin-bottom:0;}
|
|
2099
|
+
.user-btn{
|
|
2100
|
+
width:100%;display:flex;align-items:center;gap:12px;
|
|
2101
|
+
padding:10px 12px;border:1px solid #0a3300;border-radius:8px;
|
|
2102
|
+
background:#000;color:inherit;cursor:pointer;text-align:left;
|
|
2103
|
+
font:inherit;transition:border-color .15s;
|
|
2104
|
+
}
|
|
2105
|
+
.user-btn:hover{border-color:#33ff00;}
|
|
2106
|
+
.avatar{
|
|
2107
|
+
width:36px;height:36px;border-radius:50%;
|
|
2108
|
+
background:#0a3300;color:#33ff00;font-weight:600;font-size:.875rem;
|
|
2109
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
2110
|
+
font-family:'Geist Pixel',monospace;
|
|
2111
|
+
}
|
|
2112
|
+
.user-text{min-width:0;}
|
|
2113
|
+
.user-login{font-weight:600;font-size:.875rem;display:block;color:#33ff00;}
|
|
2114
|
+
.user-meta{color:#1a8c00;font-size:.75rem;margin-top:1px;}
|
|
2115
|
+
.user-email{font-size:.6875rem;color:#116600;word-break:break-all;margin-top:1px;}
|
|
2116
|
+
|
|
2117
|
+
.settings-layout{
|
|
2118
|
+
max-width:920px;margin:0 auto;padding:28px 20px;
|
|
2119
|
+
display:flex;gap:28px;
|
|
2120
|
+
}
|
|
2121
|
+
.settings-sidebar{width:200px;flex-shrink:0;}
|
|
2122
|
+
.settings-sidebar a{
|
|
2123
|
+
display:block;padding:6px 10px;border-radius:6px;color:#1a8c00;
|
|
2124
|
+
text-decoration:none;font-size:.8125rem;transition:color .15s;
|
|
2125
|
+
}
|
|
2126
|
+
.settings-sidebar a:hover{color:#33ff00;}
|
|
2127
|
+
.settings-sidebar a.active{color:#33ff00;font-weight:600;}
|
|
2128
|
+
.settings-main{flex:1;min-width:0;}
|
|
2129
|
+
|
|
2130
|
+
.s-card{
|
|
2131
|
+
padding:18px 0;margin-bottom:14px;border-bottom:1px solid #0a3300;
|
|
2132
|
+
}
|
|
2133
|
+
.s-card:last-child{border-bottom:none;}
|
|
2134
|
+
.s-card-header{display:flex;align-items:center;gap:14px;margin-bottom:14px;}
|
|
2135
|
+
.s-icon{
|
|
2136
|
+
width:42px;height:42px;border-radius:8px;
|
|
2137
|
+
background:#0a3300;display:flex;align-items:center;justify-content:center;
|
|
2138
|
+
font-size:1.125rem;font-weight:700;color:#116600;flex-shrink:0;
|
|
2139
|
+
font-family:'Geist Pixel',monospace;
|
|
2140
|
+
}
|
|
2141
|
+
.s-title{
|
|
2142
|
+
font-family:'Geist Pixel',monospace;
|
|
2143
|
+
font-size:1.25rem;font-weight:600;color:#33ff00;
|
|
2144
|
+
}
|
|
2145
|
+
.s-subtitle{font-size:.75rem;color:#1a8c00;margin-top:2px;}
|
|
2146
|
+
.section-heading{
|
|
2147
|
+
font-size:.9375rem;font-weight:600;margin-bottom:10px;color:#33ff00;
|
|
2148
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
2149
|
+
}
|
|
2150
|
+
.perm-list{list-style:none;}
|
|
2151
|
+
.perm-list li{padding:5px 0;font-size:.8125rem;display:flex;align-items:center;gap:6px;color:#1a8c00;}
|
|
2152
|
+
.check{color:#33ff00;}
|
|
2153
|
+
.org-row{
|
|
2154
|
+
display:flex;align-items:center;gap:8px;padding:7px 0;
|
|
2155
|
+
border-bottom:1px solid #0a3300;font-size:.8125rem;
|
|
2156
|
+
}
|
|
2157
|
+
.org-row:last-child{border-bottom:none;}
|
|
2158
|
+
.org-icon{
|
|
2159
|
+
width:22px;height:22px;border-radius:4px;background:#0a3300;
|
|
2160
|
+
display:flex;align-items:center;justify-content:center;
|
|
2161
|
+
font-size:.625rem;font-weight:700;color:#116600;flex-shrink:0;
|
|
2162
|
+
font-family:'Geist Pixel',monospace;
|
|
2163
|
+
}
|
|
2164
|
+
.org-name{font-weight:600;color:#33ff00;}
|
|
2165
|
+
.badge{font-size:.6875rem;padding:1px 7px;border-radius:999px;font-weight:500;}
|
|
2166
|
+
.badge-granted{background:#0a3300;color:#33ff00;}
|
|
2167
|
+
.badge-denied{background:#1a0a0a;color:#ff4444;}
|
|
2168
|
+
.badge-requested{background:#0a3300;color:#1a8c00;}
|
|
2169
|
+
.btn-revoke{
|
|
2170
|
+
display:inline-block;padding:5px 14px;border-radius:6px;
|
|
2171
|
+
border:1px solid #0a3300;background:transparent;color:#ff4444;
|
|
2172
|
+
font-size:.75rem;font-weight:600;cursor:pointer;transition:border-color .15s;
|
|
2173
|
+
}
|
|
2174
|
+
.btn-revoke:hover{border-color:#ff4444;}
|
|
2175
|
+
.info-text{color:#1a8c00;font-size:.75rem;line-height:1.5;margin-top:10px;}
|
|
2176
|
+
.app-link{
|
|
2177
|
+
display:flex;align-items:center;gap:12px;padding:12px;
|
|
2178
|
+
border:1px solid #0a3300;border-radius:8px;background:#000;
|
|
2179
|
+
text-decoration:none;color:inherit;margin-bottom:8px;transition:border-color .15s;
|
|
2180
|
+
}
|
|
2181
|
+
.app-link:hover{border-color:#33ff00;}
|
|
2182
|
+
.app-link-name{font-weight:600;font-size:.875rem;color:#33ff00;}
|
|
2183
|
+
.app-link-scopes{font-size:.6875rem;color:#1a8c00;margin-top:1px;}
|
|
2184
|
+
.empty{color:#1a8c00;text-align:center;padding:28px 0;font-size:.875rem;}
|
|
2185
|
+
|
|
2186
|
+
.inspector-layout{max-width:960px;margin:0 auto;padding:28px 20px;}
|
|
2187
|
+
.inspector-tabs{display:flex;gap:4px;margin-bottom:20px;}
|
|
2188
|
+
.inspector-tabs a{
|
|
2189
|
+
padding:7px 16px;border-radius:6px;text-decoration:none;
|
|
2190
|
+
font-size:.8125rem;color:#1a8c00;border:1px solid transparent;
|
|
2191
|
+
transition:color .15s,border-color .15s;
|
|
2192
|
+
}
|
|
2193
|
+
.inspector-tabs a:hover{color:#33ff00;}
|
|
2194
|
+
.inspector-tabs a.active{color:#33ff00;font-weight:600;border-color:#0a3300;background:#0a3300;}
|
|
2195
|
+
.inspector-section{margin-bottom:24px;}
|
|
2196
|
+
.inspector-section h2{
|
|
2197
|
+
font-family:'Geist Pixel',monospace;
|
|
2198
|
+
font-size:1rem;font-weight:600;color:#33ff00;margin-bottom:10px;
|
|
2199
|
+
}
|
|
2200
|
+
.inspector-section h3{
|
|
2201
|
+
font-family:'Geist Pixel',monospace;
|
|
2202
|
+
font-size:.875rem;font-weight:600;color:#1a8c00;margin:16px 0 8px;
|
|
2203
|
+
}
|
|
2204
|
+
.inspector-table{width:100%;border-collapse:collapse;margin-bottom:12px;}
|
|
2205
|
+
.inspector-table th,.inspector-table td{
|
|
2206
|
+
text-align:left;padding:8px 12px;border-bottom:1px solid #0a3300;
|
|
2207
|
+
font-size:.8125rem;
|
|
2208
|
+
}
|
|
2209
|
+
.inspector-table th{color:#1a8c00;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;}
|
|
2210
|
+
.inspector-table td{color:#33ff00;}
|
|
2211
|
+
.inspector-table tbody tr{transition:background .1s;}
|
|
2212
|
+
.inspector-table tbody tr:hover{background:#0a3300;}
|
|
2213
|
+
.inspector-empty{color:#1a8c00;text-align:center;padding:20px 0;font-size:.8125rem;}
|
|
2214
|
+
|
|
2215
|
+
.checkout-layout{
|
|
2216
|
+
display:flex;min-height:calc(100vh - 42px);
|
|
2217
|
+
}
|
|
2218
|
+
.checkout-summary{
|
|
2219
|
+
flex:1;background:#020;padding:48px 40px 48px 10%;
|
|
2220
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
2221
|
+
border-right:1px solid #0a3300;
|
|
2222
|
+
}
|
|
2223
|
+
.checkout-form-side{
|
|
2224
|
+
flex:1;background:#000;padding:48px 10% 48px 40px;
|
|
2225
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
2226
|
+
}
|
|
2227
|
+
.checkout-merchant{
|
|
2228
|
+
display:flex;align-items:center;gap:10px;margin-bottom:6px;
|
|
2229
|
+
}
|
|
2230
|
+
.checkout-merchant-name{
|
|
2231
|
+
font-family:'Geist Pixel',monospace;
|
|
2232
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
2233
|
+
}
|
|
2234
|
+
.checkout-test-badge{
|
|
2235
|
+
font-size:.625rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
2236
|
+
background:#0a3300;color:#1a8c00;padding:2px 8px;border-radius:4px;
|
|
2237
|
+
}
|
|
2238
|
+
.checkout-total{
|
|
2239
|
+
font-family:'Geist Pixel',monospace;
|
|
2240
|
+
font-size:2rem;font-weight:700;color:#33ff00;margin:8px 0 28px;
|
|
2241
|
+
}
|
|
2242
|
+
.checkout-line-item{
|
|
2243
|
+
display:flex;align-items:center;gap:14px;padding:14px 0;
|
|
2244
|
+
border-bottom:1px solid #0a3300;
|
|
2245
|
+
}
|
|
2246
|
+
.checkout-line-item:first-child{border-top:1px solid #0a3300;}
|
|
2247
|
+
.checkout-item-icon{
|
|
2248
|
+
width:42px;height:42px;border-radius:6px;background:#0a3300;
|
|
2249
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
2250
|
+
font-family:'Geist Pixel',monospace;font-size:.875rem;font-weight:700;color:#116600;
|
|
2251
|
+
}
|
|
2252
|
+
.checkout-item-details{flex:1;min-width:0;}
|
|
2253
|
+
.checkout-item-name{font-size:.875rem;font-weight:600;color:#33ff00;}
|
|
2254
|
+
.checkout-item-qty{font-size:.75rem;color:#1a8c00;margin-top:2px;}
|
|
2255
|
+
.checkout-item-price{
|
|
2256
|
+
font-size:.875rem;font-weight:600;color:#33ff00;text-align:right;white-space:nowrap;
|
|
2257
|
+
}
|
|
2258
|
+
.checkout-item-unit{font-size:.6875rem;color:#1a8c00;text-align:right;margin-top:2px;}
|
|
2259
|
+
.checkout-totals{margin-top:20px;}
|
|
2260
|
+
.checkout-totals-row{
|
|
2261
|
+
display:flex;justify-content:space-between;padding:6px 0;
|
|
2262
|
+
font-size:.8125rem;color:#1a8c00;
|
|
2263
|
+
}
|
|
2264
|
+
.checkout-totals-row.total{
|
|
2265
|
+
border-top:1px solid #0a3300;margin-top:8px;padding-top:14px;
|
|
2266
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
2267
|
+
}
|
|
2268
|
+
.checkout-form-section{margin-bottom:24px;}
|
|
2269
|
+
.checkout-form-label{
|
|
2270
|
+
font-size:.8125rem;font-weight:600;color:#33ff00;margin-bottom:8px;display:block;
|
|
2271
|
+
}
|
|
2272
|
+
.checkout-input{
|
|
2273
|
+
width:100%;padding:10px 12px;border:1px solid #0a3300;border-radius:6px;
|
|
2274
|
+
background:#020;color:#33ff00;font:inherit;font-size:.875rem;
|
|
2275
|
+
transition:border-color .15s;outline:none;
|
|
2276
|
+
}
|
|
2277
|
+
.checkout-input:focus{border-color:#33ff00;}
|
|
2278
|
+
.checkout-input::placeholder{color:#116600;}
|
|
2279
|
+
.checkout-card-box{
|
|
2280
|
+
border:1px solid #0a3300;border-radius:6px;padding:14px;
|
|
2281
|
+
background:#020;
|
|
2282
|
+
}
|
|
2283
|
+
.checkout-card-row{
|
|
2284
|
+
display:flex;gap:12px;margin-top:10px;
|
|
2285
|
+
}
|
|
2286
|
+
.checkout-card-row .checkout-input{flex:1;}
|
|
2287
|
+
.checkout-sim-note{
|
|
2288
|
+
font-size:.6875rem;color:#1a8c00;margin-top:10px;text-align:center;
|
|
2289
|
+
font-style:italic;
|
|
2290
|
+
}
|
|
2291
|
+
.checkout-pay-btn{
|
|
2292
|
+
width:100%;padding:14px;border:none;border-radius:8px;
|
|
2293
|
+
background:#33ff00;color:#000;font:inherit;font-size:.9375rem;font-weight:700;
|
|
2294
|
+
cursor:pointer;transition:background .15s;
|
|
2295
|
+
font-family:'Geist Pixel',monospace;
|
|
2296
|
+
}
|
|
2297
|
+
.checkout-pay-btn:hover{background:#44ff22;}
|
|
2298
|
+
.checkout-cancel{
|
|
2299
|
+
text-align:center;margin-top:14px;
|
|
2300
|
+
}
|
|
2301
|
+
.checkout-cancel a{
|
|
2302
|
+
color:#1a8c00;text-decoration:none;font-size:.8125rem;
|
|
2303
|
+
transition:color .15s;
|
|
2304
|
+
}
|
|
2305
|
+
.checkout-cancel a:hover{color:#33ff00;}
|
|
2306
|
+
@media(max-width:768px){
|
|
2307
|
+
.checkout-layout{flex-direction:column;}
|
|
2308
|
+
.checkout-summary{padding:32px 20px;border-right:none;border-bottom:1px solid #0a3300;}
|
|
2309
|
+
.checkout-form-side{padding:32px 20px;}
|
|
2310
|
+
}
|
|
2311
|
+
`;
|
|
2312
|
+
var POWERED_BY = `<div class="powered-by">Powered by <a href="https://emulate.dev" target="_blank" rel="noopener">emulate</a></div>`;
|
|
2313
|
+
function emuBar(service) {
|
|
2314
|
+
const title = service ? `${escapeHtml(service)} Emulator` : "Emulator";
|
|
2315
|
+
return `<div class="emu-bar">
|
|
2316
|
+
<span class="emu-bar-title">${title}</span>
|
|
2317
|
+
<nav class="emu-bar-links">
|
|
2318
|
+
<a href="https://github.com/vercel-labs/emulate/issues" target="_blank" rel="noopener"><span class="full">Report Issue</span><span class="short">Report</span></a>
|
|
2319
|
+
<a href="https://github.com/vercel-labs/emulate" target="_blank" rel="noopener"><span class="full">Source Code</span><span class="short">Source</span></a>
|
|
2320
|
+
<a href="https://emulate.dev" target="_blank" rel="noopener"><span class="full">Learn More</span><span class="short">Learn</span></a>
|
|
2321
|
+
</nav>
|
|
2322
|
+
</div>`;
|
|
2323
|
+
}
|
|
2324
|
+
function head(title) {
|
|
2325
|
+
return `<!DOCTYPE html>
|
|
2326
|
+
<html lang="en">
|
|
2327
|
+
<head>
|
|
2328
|
+
<meta charset="utf-8"/>
|
|
2329
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
2330
|
+
<link rel="icon" href="/_emulate/favicon.ico"/>
|
|
2331
|
+
<title>${escapeHtml(title)} | emulate</title>
|
|
2332
|
+
<style>${CSS}</style>
|
|
2333
|
+
</head>`;
|
|
2334
|
+
}
|
|
2335
|
+
function renderCardPage(title, subtitle, body, service) {
|
|
2336
|
+
return `${head(title)}
|
|
2337
|
+
<body>
|
|
2338
|
+
${emuBar(service)}
|
|
2339
|
+
<div class="content">
|
|
2340
|
+
<div class="content-inner">
|
|
2341
|
+
<div class="card-title">${escapeHtml(title)}</div>
|
|
2342
|
+
<div class="card-subtitle">${subtitle}</div>
|
|
2343
|
+
${body}
|
|
2344
|
+
</div>
|
|
2345
|
+
</div>
|
|
2346
|
+
${POWERED_BY}
|
|
2347
|
+
</body></html>`;
|
|
2348
|
+
}
|
|
2349
|
+
function renderErrorPage(title, message, service) {
|
|
2350
|
+
return `${head(title)}
|
|
2351
|
+
<body>
|
|
2352
|
+
${emuBar(service)}
|
|
2353
|
+
<div class="content">
|
|
2354
|
+
<div class="content-inner error-card">
|
|
2355
|
+
<div class="error-title">${escapeHtml(title)}</div>
|
|
2356
|
+
<div class="error-msg">${escapeHtml(message)}</div>
|
|
2357
|
+
</div>
|
|
2358
|
+
</div>
|
|
2359
|
+
${POWERED_BY}
|
|
2360
|
+
</body></html>`;
|
|
2361
|
+
}
|
|
2362
|
+
function renderInspectorPage(title, tabs, activeTab, body, service) {
|
|
2363
|
+
const tabLinks = tabs.map(
|
|
2364
|
+
(t) => `<a href="${escapeAttr(t.href)}" class="${t.id === activeTab ? "active" : ""}">${escapeHtml(t.label)}</a>`
|
|
2365
|
+
).join("");
|
|
2366
|
+
return `${head(title)}
|
|
2367
|
+
<body>
|
|
2368
|
+
${emuBar(service)}
|
|
2369
|
+
<div class="inspector-layout">
|
|
2370
|
+
<nav class="inspector-tabs">${tabLinks}</nav>
|
|
2371
|
+
${body}
|
|
2372
|
+
</div>
|
|
2373
|
+
${POWERED_BY}
|
|
2374
|
+
</body></html>`;
|
|
2375
|
+
}
|
|
2376
|
+
function renderUserButton(opts) {
|
|
2377
|
+
const hiddens = Object.entries(opts.hiddenFields).map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}"/>`).join("");
|
|
2378
|
+
const nameLine = opts.name ? `<div class="user-meta">${escapeHtml(opts.name)}</div>` : "";
|
|
2379
|
+
const emailLine = opts.email ? `<div class="user-email">${escapeHtml(opts.email)}</div>` : "";
|
|
2380
|
+
return `<form class="user-form" method="post" action="${escapeAttr(opts.formAction)}">
|
|
2381
|
+
${hiddens}
|
|
2382
|
+
<button type="submit" class="user-btn">
|
|
2383
|
+
<span class="avatar">${escapeHtml(opts.letter)}</span>
|
|
2384
|
+
<span class="user-text">
|
|
2385
|
+
<span class="user-login">${escapeHtml(opts.login)}</span>
|
|
2386
|
+
${nameLine}${emailLine}
|
|
2387
|
+
</span>
|
|
2388
|
+
</button>
|
|
2389
|
+
</form>`;
|
|
2390
|
+
}
|
|
2391
|
+
function normalizeUri(uri) {
|
|
2392
|
+
try {
|
|
2393
|
+
const u = new URL(uri);
|
|
2394
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
|
|
2395
|
+
} catch {
|
|
2396
|
+
return uri.replace(/\/+$/, "").split("?")[0];
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
function matchesRedirectUri(incoming, registered) {
|
|
2400
|
+
const normalized = normalizeUri(incoming);
|
|
2401
|
+
return registered.some((r) => normalizeUri(r) === normalized);
|
|
2402
|
+
}
|
|
2403
|
+
function constantTimeSecretEqual(a, b) {
|
|
2404
|
+
const bufA = Buffer.from(a, "utf-8");
|
|
2405
|
+
const bufB = Buffer.from(b, "utf-8");
|
|
2406
|
+
if (bufA.length !== bufB.length) return false;
|
|
2407
|
+
return timingSafeEqual(bufA, bufB);
|
|
2408
|
+
}
|
|
2409
|
+
function bodyStr(v) {
|
|
2410
|
+
if (typeof v === "string") return v;
|
|
2411
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0];
|
|
2412
|
+
return "";
|
|
2413
|
+
}
|
|
2414
|
+
var PENDING_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
2415
|
+
var SERVICE_LABEL = "Slack";
|
|
2416
|
+
function getPendingCodes(store) {
|
|
2417
|
+
let map = store.getData("slack.oauth.pendingCodes");
|
|
2418
|
+
if (!map) {
|
|
2419
|
+
map = /* @__PURE__ */ new Map();
|
|
2420
|
+
store.setData("slack.oauth.pendingCodes", map);
|
|
2421
|
+
}
|
|
2422
|
+
return map;
|
|
2423
|
+
}
|
|
2424
|
+
function isPendingCodeExpired(p) {
|
|
2425
|
+
return Date.now() - p.created_at > PENDING_CODE_TTL_MS;
|
|
2426
|
+
}
|
|
2427
|
+
function oauthRoutes({ app, store, tokenMap }) {
|
|
2428
|
+
const ss = () => getSlackStore(store);
|
|
2429
|
+
app.get("/oauth/v2/authorize", (c) => {
|
|
2430
|
+
const client_id = c.req.query("client_id") ?? "";
|
|
2431
|
+
const redirect_uri = c.req.query("redirect_uri") ?? "";
|
|
2432
|
+
const scope = c.req.query("scope") ?? "";
|
|
2433
|
+
const user_scope = c.req.query("user_scope") ?? "";
|
|
2434
|
+
const state = c.req.query("state") ?? "";
|
|
2435
|
+
const appsConfigured = ss().oauthApps.all().length > 0;
|
|
2436
|
+
let appName = "";
|
|
2437
|
+
if (appsConfigured) {
|
|
2438
|
+
const oauthApp = ss().oauthApps.findOneBy("client_id", client_id);
|
|
2439
|
+
if (!oauthApp) {
|
|
2440
|
+
return c.html(
|
|
2441
|
+
renderErrorPage("Application not found", `The client_id '${client_id}' is not registered.`, SERVICE_LABEL),
|
|
2442
|
+
400
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
if (redirect_uri && !matchesRedirectUri(redirect_uri, oauthApp.redirect_uris)) {
|
|
2446
|
+
return c.html(
|
|
2447
|
+
renderErrorPage(
|
|
2448
|
+
"Redirect URI mismatch",
|
|
2449
|
+
"The redirect_uri is not registered for this application.",
|
|
2450
|
+
SERVICE_LABEL
|
|
2451
|
+
),
|
|
2452
|
+
400
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
appName = oauthApp.name;
|
|
2456
|
+
}
|
|
2457
|
+
const subtitleText = appName ? `Authorize <strong>${escapeHtml(appName)}</strong> to access your Slack workspace.` : "Choose a user to authorize.";
|
|
2458
|
+
const users = ss().users.all().filter((u) => !u.deleted && !u.is_bot);
|
|
2459
|
+
const userButtons = users.map((user) => {
|
|
2460
|
+
return renderUserButton({
|
|
2461
|
+
letter: (user.name[0] ?? "?").toUpperCase(),
|
|
2462
|
+
login: user.name,
|
|
2463
|
+
name: user.real_name,
|
|
2464
|
+
email: user.email,
|
|
2465
|
+
formAction: "/oauth/v2/authorize/callback",
|
|
2466
|
+
hiddenFields: {
|
|
2467
|
+
user_id: user.user_id,
|
|
2468
|
+
redirect_uri,
|
|
2469
|
+
scope,
|
|
2470
|
+
user_scope,
|
|
2471
|
+
state,
|
|
2472
|
+
client_id
|
|
2473
|
+
}
|
|
2474
|
+
});
|
|
2475
|
+
}).join("\n");
|
|
2476
|
+
const body = users.length === 0 ? '<p class="empty">No users in the emulator store.</p>' : userButtons;
|
|
2477
|
+
return c.html(renderCardPage("Sign in to Slack", subtitleText, body, SERVICE_LABEL));
|
|
2478
|
+
});
|
|
2479
|
+
app.post("/oauth/v2/authorize/callback", async (c) => {
|
|
2480
|
+
const body = await c.req.parseBody();
|
|
2481
|
+
const userId = bodyStr(body.user_id);
|
|
2482
|
+
const redirect_uri = bodyStr(body.redirect_uri);
|
|
2483
|
+
const scope = bodyStr(body.scope);
|
|
2484
|
+
const userScope = bodyStr(body.user_scope);
|
|
2485
|
+
const state = bodyStr(body.state);
|
|
2486
|
+
const client_id = bodyStr(body.client_id);
|
|
2487
|
+
const code = randomBytes2(20).toString("hex");
|
|
2488
|
+
getPendingCodes(store).set(code, {
|
|
2489
|
+
userId,
|
|
2490
|
+
scope,
|
|
2491
|
+
userScope,
|
|
2492
|
+
redirectUri: redirect_uri,
|
|
2493
|
+
clientId: client_id,
|
|
2494
|
+
created_at: Date.now()
|
|
2495
|
+
});
|
|
2496
|
+
debug("slack.oauth", `[Slack callback] code=${code.slice(0, 8)}... user=${userId}`);
|
|
2497
|
+
const url = new URL(redirect_uri);
|
|
2498
|
+
url.searchParams.set("code", code);
|
|
2499
|
+
if (state) url.searchParams.set("state", state);
|
|
2500
|
+
return c.redirect(url.toString(), 302);
|
|
2501
|
+
});
|
|
2502
|
+
app.post("/api/oauth.v2.access", async (c) => {
|
|
2503
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
2504
|
+
const rawText = await c.req.text();
|
|
2505
|
+
let body;
|
|
2506
|
+
if (contentType.includes("application/json")) {
|
|
2507
|
+
try {
|
|
2508
|
+
body = JSON.parse(rawText);
|
|
2509
|
+
} catch {
|
|
2510
|
+
body = {};
|
|
2511
|
+
}
|
|
2512
|
+
} else {
|
|
2513
|
+
body = Object.fromEntries(new URLSearchParams(rawText));
|
|
2514
|
+
}
|
|
2515
|
+
const code = typeof body.code === "string" ? body.code : "";
|
|
2516
|
+
const basicAuth = parseBasicAuth(c.req.header("Authorization"));
|
|
2517
|
+
const client_id = typeof body.client_id === "string" ? body.client_id : basicAuth?.clientId ?? "";
|
|
2518
|
+
const client_secret = typeof body.client_secret === "string" ? body.client_secret : basicAuth?.clientSecret ?? "";
|
|
2519
|
+
const redirect_uri = typeof body.redirect_uri === "string" ? body.redirect_uri : "";
|
|
2520
|
+
const appsConfigured = ss().oauthApps.all().length > 0;
|
|
2521
|
+
let oauthApp;
|
|
2522
|
+
if (appsConfigured) {
|
|
2523
|
+
oauthApp = ss().oauthApps.findOneBy("client_id", client_id);
|
|
2524
|
+
if (!oauthApp) {
|
|
2525
|
+
return c.json({ ok: false, error: "invalid_client_id" });
|
|
2526
|
+
}
|
|
2527
|
+
if (!constantTimeSecretEqual(client_secret, oauthApp.client_secret)) {
|
|
2528
|
+
return c.json({ ok: false, error: "invalid_client_id" });
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
const pendingMap = getPendingCodes(store);
|
|
2532
|
+
const pending = pendingMap.get(code);
|
|
2533
|
+
if (!pending) {
|
|
2534
|
+
return c.json({ ok: false, error: "invalid_code" });
|
|
2535
|
+
}
|
|
2536
|
+
if (isPendingCodeExpired(pending)) {
|
|
2537
|
+
pendingMap.delete(code);
|
|
2538
|
+
return c.json({ ok: false, error: "invalid_code" });
|
|
2539
|
+
}
|
|
2540
|
+
pendingMap.delete(code);
|
|
2541
|
+
if (client_id && pending.clientId && client_id !== pending.clientId) {
|
|
2542
|
+
return c.json({ ok: false, error: "invalid_client_id" });
|
|
2543
|
+
}
|
|
2544
|
+
if (redirect_uri && pending.redirectUri && redirect_uri !== pending.redirectUri) {
|
|
2545
|
+
return c.json({ ok: false, error: "bad_redirect_uri" });
|
|
2546
|
+
}
|
|
2547
|
+
const user = ss().users.findOneBy("user_id", pending.userId);
|
|
2548
|
+
if (!user) {
|
|
2549
|
+
return c.json({ ok: false, error: "invalid_code" });
|
|
2550
|
+
}
|
|
2551
|
+
const accessToken = "xoxb-" + randomBytes2(20).toString("base64url");
|
|
2552
|
+
const userAccessToken = "xoxp-" + randomBytes2(20).toString("base64url");
|
|
2553
|
+
const team = ss().teams.all()[0];
|
|
2554
|
+
const teamId = team?.team_id ?? "T000000001";
|
|
2555
|
+
const appId = ensureOAuthAppId(ss(), oauthApp, client_id || pending.clientId);
|
|
2556
|
+
const requestedScopes = normalizeScopes(pending.scope, oauthApp?.scopes ?? ["chat:write", "channels:read"]);
|
|
2557
|
+
const userScopes = pending.userScope ? normalizeScopes(pending.userScope, []) : [];
|
|
2558
|
+
const bot = ensureBotForApp(ss(), oauthApp, appId, teamId);
|
|
2559
|
+
const installation = upsertInstallation(ss(), {
|
|
2560
|
+
appId,
|
|
2561
|
+
clientId: client_id || pending.clientId,
|
|
2562
|
+
teamId,
|
|
2563
|
+
appName: oauthApp?.name ?? "Slack App",
|
|
2564
|
+
installerUserId: user.user_id,
|
|
2565
|
+
bot,
|
|
2566
|
+
scopes: requestedScopes,
|
|
2567
|
+
userScopes
|
|
2568
|
+
});
|
|
2569
|
+
ss().tokens.insert({
|
|
2570
|
+
token: accessToken,
|
|
2571
|
+
token_type: "bot",
|
|
2572
|
+
team_id: teamId,
|
|
2573
|
+
user_id: bot.user.user_id,
|
|
2574
|
+
scopes: requestedScopes,
|
|
2575
|
+
app_id: appId,
|
|
2576
|
+
client_id: client_id || pending.clientId,
|
|
2577
|
+
installation_id: installation.installation_id,
|
|
2578
|
+
bot_id: bot.bot.bot_id,
|
|
2579
|
+
bot_user_id: bot.user.user_id,
|
|
2580
|
+
authed_user_id: user.user_id
|
|
2581
|
+
});
|
|
2582
|
+
if (tokenMap) {
|
|
2583
|
+
tokenMap.set(accessToken, { login: bot.user.user_id, id: bot.user.id, scopes: requestedScopes });
|
|
2584
|
+
}
|
|
2585
|
+
if (userScopes.length > 0) {
|
|
2586
|
+
ss().tokens.insert({
|
|
2587
|
+
token: userAccessToken,
|
|
2588
|
+
token_type: "user",
|
|
2589
|
+
team_id: teamId,
|
|
2590
|
+
user_id: user.user_id,
|
|
2591
|
+
scopes: userScopes,
|
|
2592
|
+
app_id: appId,
|
|
2593
|
+
client_id: client_id || pending.clientId,
|
|
2594
|
+
installation_id: installation.installation_id,
|
|
2595
|
+
bot_id: bot.bot.bot_id,
|
|
2596
|
+
bot_user_id: bot.user.user_id,
|
|
2597
|
+
authed_user_id: user.user_id
|
|
2598
|
+
});
|
|
2599
|
+
tokenMap?.set(userAccessToken, { login: user.user_id, id: user.id, scopes: userScopes });
|
|
2600
|
+
}
|
|
2601
|
+
debug("slack.oauth", `[Slack token] issued token for ${oauthApp?.name ?? "Slack App"} as ${bot.user.name}`);
|
|
2602
|
+
return c.json({
|
|
2603
|
+
ok: true,
|
|
2604
|
+
access_token: accessToken,
|
|
2605
|
+
token_type: "bot",
|
|
2606
|
+
scope: requestedScopes.join(","),
|
|
2607
|
+
bot_user_id: bot.user.user_id,
|
|
2608
|
+
app_id: appId,
|
|
2609
|
+
team: {
|
|
2610
|
+
id: teamId,
|
|
2611
|
+
name: team?.name ?? "Emulate"
|
|
2612
|
+
},
|
|
2613
|
+
enterprise: null,
|
|
2614
|
+
is_enterprise_install: false,
|
|
2615
|
+
authed_user: {
|
|
2616
|
+
id: user.user_id,
|
|
2617
|
+
...userScopes.length > 0 ? { scope: userScopes.join(","), access_token: userAccessToken, token_type: "user" } : {}
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
function parseBasicAuth(value) {
|
|
2623
|
+
if (!value?.startsWith("Basic ")) return void 0;
|
|
2624
|
+
try {
|
|
2625
|
+
const decoded = Buffer.from(value.slice("Basic ".length), "base64").toString("utf8");
|
|
2626
|
+
const separator = decoded.indexOf(":");
|
|
2627
|
+
if (separator < 0) return void 0;
|
|
2628
|
+
return {
|
|
2629
|
+
clientId: decoded.slice(0, separator),
|
|
2630
|
+
clientSecret: decoded.slice(separator + 1)
|
|
2631
|
+
};
|
|
2632
|
+
} catch {
|
|
2633
|
+
return void 0;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
function ensureOAuthAppId(ss, oauthApp, fallback) {
|
|
2637
|
+
if (!oauthApp) return fallback || generateSlackId("A");
|
|
2638
|
+
if (oauthApp.app_id) return oauthApp.app_id;
|
|
2639
|
+
const appId = generateSlackId("A");
|
|
2640
|
+
ss.oauthApps.update(oauthApp.id, { app_id: appId });
|
|
2641
|
+
return appId;
|
|
2642
|
+
}
|
|
2643
|
+
function ensureBotForApp(ss, oauthApp, appId, teamId) {
|
|
2644
|
+
const botId = oauthApp?.bot_id ?? generateSlackId("B");
|
|
2645
|
+
const botUserId = oauthApp?.bot_user_id ?? generateSlackId("U");
|
|
2646
|
+
const botName = oauthApp?.bot_name ?? slugifyBotName(oauthApp?.name ?? "Slack App");
|
|
2647
|
+
if (oauthApp && (!oauthApp.bot_id || !oauthApp.bot_user_id || !oauthApp.bot_name)) {
|
|
2648
|
+
ss.oauthApps.update(oauthApp.id, {
|
|
2649
|
+
bot_id: botId,
|
|
2650
|
+
bot_user_id: botUserId,
|
|
2651
|
+
bot_name: botName
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
const existingBot = ss.bots.findOneBy("bot_id", botId);
|
|
2655
|
+
const bot = existingBot ?? ss.bots.insert({
|
|
2656
|
+
bot_id: botId,
|
|
2657
|
+
app_id: appId,
|
|
2658
|
+
user_id: botUserId,
|
|
2659
|
+
name: botName,
|
|
2660
|
+
deleted: false,
|
|
2661
|
+
icons: { image_48: "" }
|
|
2662
|
+
});
|
|
2663
|
+
if (existingBot && (existingBot.app_id !== appId || existingBot.user_id !== botUserId)) {
|
|
2664
|
+
ss.bots.update(existingBot.id, { app_id: appId, user_id: botUserId });
|
|
2665
|
+
}
|
|
2666
|
+
const existingUser = ss.users.findOneBy("user_id", botUserId);
|
|
2667
|
+
const user = existingUser ?? ss.users.insert({
|
|
2668
|
+
user_id: botUserId,
|
|
2669
|
+
team_id: teamId,
|
|
2670
|
+
name: botName,
|
|
2671
|
+
real_name: oauthApp?.name ?? botName,
|
|
2672
|
+
email: `${botName}@bots.emulate.dev`,
|
|
2673
|
+
is_admin: false,
|
|
2674
|
+
is_bot: true,
|
|
2675
|
+
deleted: false,
|
|
2676
|
+
profile: {
|
|
2677
|
+
display_name: botName,
|
|
2678
|
+
real_name: oauthApp?.name ?? botName,
|
|
2679
|
+
email: `${botName}@bots.emulate.dev`,
|
|
2680
|
+
image_48: "",
|
|
2681
|
+
image_192: "",
|
|
2682
|
+
real_name_normalized: oauthApp?.name ?? botName,
|
|
2683
|
+
display_name_normalized: botName,
|
|
2684
|
+
status_text: "",
|
|
2685
|
+
status_emoji: "",
|
|
2686
|
+
status_emoji_display_info: [],
|
|
2687
|
+
status_expiration: 0
|
|
2688
|
+
},
|
|
2689
|
+
presence: "active",
|
|
2690
|
+
manual_presence: "auto",
|
|
2691
|
+
connection_count: 1,
|
|
2692
|
+
last_activity: Math.floor(Date.now() / 1e3)
|
|
2693
|
+
});
|
|
2694
|
+
return {
|
|
2695
|
+
bot: ss.bots.findOneBy("bot_id", bot.bot_id) ?? bot,
|
|
2696
|
+
user
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
function upsertInstallation(ss, input) {
|
|
2700
|
+
const existing = ss.installations.all().find((item) => item.app_id === input.appId && item.team_id === input.teamId);
|
|
2701
|
+
const data = {
|
|
2702
|
+
app_id: input.appId,
|
|
2703
|
+
client_id: input.clientId,
|
|
2704
|
+
team_id: input.teamId,
|
|
2705
|
+
app_name: input.appName,
|
|
2706
|
+
installer_user_id: input.installerUserId,
|
|
2707
|
+
bot_id: input.bot.bot.bot_id,
|
|
2708
|
+
bot_user_id: input.bot.user.user_id,
|
|
2709
|
+
scopes: input.scopes,
|
|
2710
|
+
user_scopes: input.userScopes
|
|
2711
|
+
};
|
|
2712
|
+
if (existing) {
|
|
2713
|
+
return ss.installations.update(existing.id, data);
|
|
2714
|
+
}
|
|
2715
|
+
return ss.installations.insert({
|
|
2716
|
+
installation_id: generateSlackId("I"),
|
|
2717
|
+
...data
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
function normalizeScopes(value, fallback) {
|
|
2721
|
+
if (!value) return [...fallback];
|
|
2722
|
+
return value.split(/[,\s]+/).map((scope) => scope.trim()).filter(Boolean);
|
|
2723
|
+
}
|
|
2724
|
+
function slugifyBotName(value) {
|
|
2725
|
+
const slug = value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2726
|
+
return slug || "slack-app";
|
|
2727
|
+
}
|
|
2728
|
+
function webhookRoutes(ctx) {
|
|
2729
|
+
const { app, store, webhooks } = ctx;
|
|
2730
|
+
const ss = () => getSlackStore(store);
|
|
2731
|
+
const findChannel = (channel) => ss().channels.findOneBy("channel_id", channel) ?? ss().channels.all().find((ch) => !ch.is_im && !ch.is_mpim && ch.name === channel);
|
|
2732
|
+
app.post("/services/:teamId/:botId/:token", async (c) => {
|
|
2733
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
2734
|
+
const rawText = await c.req.text();
|
|
2735
|
+
let body;
|
|
2736
|
+
if (contentType.includes("application/json")) {
|
|
2737
|
+
try {
|
|
2738
|
+
body = JSON.parse(rawText);
|
|
2739
|
+
} catch {
|
|
2740
|
+
return c.text("invalid_payload", 400);
|
|
2741
|
+
}
|
|
2742
|
+
} else {
|
|
2743
|
+
const params = new URLSearchParams(rawText);
|
|
2744
|
+
const payload = params.get("payload");
|
|
2745
|
+
if (payload) {
|
|
2746
|
+
try {
|
|
2747
|
+
body = JSON.parse(payload);
|
|
2748
|
+
} catch {
|
|
2749
|
+
return c.text("invalid_payload", 400);
|
|
2750
|
+
}
|
|
2751
|
+
} else {
|
|
2752
|
+
body = {};
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
2756
|
+
const channelName = typeof body.channel === "string" ? body.channel : "";
|
|
2757
|
+
const threadTs = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
2758
|
+
const richMessage = parseSlackRichMessageFields(body);
|
|
2759
|
+
if (richMessage.error) {
|
|
2760
|
+
return c.text(richMessage.error, 400);
|
|
2761
|
+
}
|
|
2762
|
+
if (!hasSlackMessageContent(text, richMessage.fields)) {
|
|
2763
|
+
return c.text("no_text", 400);
|
|
2764
|
+
}
|
|
2765
|
+
const webhook = ss().incomingWebhooks.all().find((w) => w.token === c.req.param("token"));
|
|
2766
|
+
let targetChannel = channelName ? findChannel(channelName) : null;
|
|
2767
|
+
if (!targetChannel && webhook) {
|
|
2768
|
+
targetChannel = findChannel(webhook.default_channel);
|
|
2769
|
+
}
|
|
2770
|
+
if (!targetChannel) {
|
|
2771
|
+
targetChannel = findChannel("general");
|
|
2772
|
+
}
|
|
2773
|
+
if (!targetChannel) {
|
|
2774
|
+
return c.text("channel_not_found", 404);
|
|
2775
|
+
}
|
|
2776
|
+
const ts = generateTs();
|
|
2777
|
+
const botId = c.req.param("botId");
|
|
2778
|
+
const msg = ss().messages.insert({
|
|
2779
|
+
ts,
|
|
2780
|
+
channel_id: targetChannel.channel_id,
|
|
2781
|
+
user: botId,
|
|
2782
|
+
text,
|
|
2783
|
+
type: "message",
|
|
2784
|
+
subtype: "bot_message",
|
|
2785
|
+
thread_ts: threadTs,
|
|
2786
|
+
...richMessage.fields,
|
|
2787
|
+
bot_id: botId,
|
|
2788
|
+
reply_count: 0,
|
|
2789
|
+
reply_users: [],
|
|
2790
|
+
reactions: []
|
|
2791
|
+
});
|
|
2792
|
+
const { user: _user, ...eventMessage } = formatSlackMessage(msg);
|
|
2793
|
+
await webhooks.dispatch(
|
|
2794
|
+
"message",
|
|
2795
|
+
void 0,
|
|
2796
|
+
{
|
|
2797
|
+
type: "event_callback",
|
|
2798
|
+
event: {
|
|
2799
|
+
...eventMessage,
|
|
2800
|
+
type: "message",
|
|
2801
|
+
subtype: "bot_message",
|
|
2802
|
+
channel: targetChannel.channel_id,
|
|
2803
|
+
bot_id: botId
|
|
2804
|
+
}
|
|
2805
|
+
},
|
|
2806
|
+
"slack"
|
|
2807
|
+
);
|
|
2808
|
+
return c.text("ok");
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
function filesRoutes(ctx) {
|
|
2812
|
+
const { app, store, webhooks, baseUrl } = ctx;
|
|
2813
|
+
const ss = () => getSlackStore(store);
|
|
2814
|
+
const serviceBaseUrl = baseUrl.replace(/\/$/, "");
|
|
2815
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
2816
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
2817
|
+
const isChannelMember = (channel, user, userId) => channel.members.includes(userId) || (user ? channel.members.includes(user.name) : false);
|
|
2818
|
+
const canReadConversation = (channel, user, userId) => !channel.is_private || isChannelMember(channel, user, userId);
|
|
2819
|
+
const visibleFileChannelIds = (file, authUser) => {
|
|
2820
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
2821
|
+
const authUserId = authSlackUser?.user_id ?? authUser.login;
|
|
2822
|
+
return fileChannels2(file).filter((channelId) => {
|
|
2823
|
+
const channel = ss().channels.findOneBy("channel_id", channelId);
|
|
2824
|
+
return channel ? canReadConversation(channel, authSlackUser, authUserId) : false;
|
|
2825
|
+
});
|
|
2826
|
+
};
|
|
2827
|
+
const canAccessFile = (file, authUser) => {
|
|
2828
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
2829
|
+
const authUserId = authSlackUser?.user_id ?? authUser.login;
|
|
2830
|
+
if (file.user === authUserId || authSlackUser && file.user === authSlackUser.name) return true;
|
|
2831
|
+
return visibleFileChannelIds(file, authUser).length > 0;
|
|
2832
|
+
};
|
|
2833
|
+
const canAccessFileInChannel = (file, authUser, channelId) => {
|
|
2834
|
+
return visibleFileChannelIds(file, authUser).includes(channelId);
|
|
2835
|
+
};
|
|
2836
|
+
const formatSlackFileForAuth = (file, authUser) => {
|
|
2837
|
+
const visibleIds = new Set(visibleFileChannelIds(file, authUser));
|
|
2838
|
+
const publicShares = filterVisibleShares2(file.shares.public, visibleIds);
|
|
2839
|
+
const privateShares = filterVisibleShares2(file.shares.private, visibleIds);
|
|
2840
|
+
const shares = {};
|
|
2841
|
+
if (publicShares) shares.public = publicShares;
|
|
2842
|
+
if (privateShares) shares.private = privateShares;
|
|
2843
|
+
return formatSlackFile({
|
|
2844
|
+
...file,
|
|
2845
|
+
channels: file.channels.filter((channelId) => visibleIds.has(channelId)),
|
|
2846
|
+
groups: file.groups.filter((channelId) => visibleIds.has(channelId)),
|
|
2847
|
+
ims: file.ims.filter((channelId) => visibleIds.has(channelId)),
|
|
2848
|
+
shares
|
|
2849
|
+
});
|
|
2850
|
+
};
|
|
2851
|
+
const canDeleteFile = (file, authUser) => {
|
|
2852
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
2853
|
+
const authUserId = authSlackUser?.user_id ?? authUser.login;
|
|
2854
|
+
return file.user === authUserId || authSlackUser?.is_admin === true;
|
|
2855
|
+
};
|
|
2856
|
+
const findChannel = (channel) => ss().channels.findOneBy("channel_id", channel) ?? ss().channels.all().find((ch) => !ch.is_im && !ch.is_mpim && ch.name === channel);
|
|
2857
|
+
const findDirectMessage = (authUserId, userId) => {
|
|
2858
|
+
const members = [authUserId, userId].sort();
|
|
2859
|
+
return ss().channels.all().find(
|
|
2860
|
+
(ch) => ch.is_im && ch.members.length === members.length && [...ch.members].sort().join(",") === members.join(",")
|
|
2861
|
+
);
|
|
2862
|
+
};
|
|
2863
|
+
const findOrCreateDirectMessage = (authUser, userId) => {
|
|
2864
|
+
const targetUser = ss().users.findOneBy("user_id", userId);
|
|
2865
|
+
if (!targetUser || targetUser.deleted) return void 0;
|
|
2866
|
+
const authUserId = getAuthUserId(authUser);
|
|
2867
|
+
if (targetUser.user_id === authUserId) return void 0;
|
|
2868
|
+
const members = [authUserId, targetUser.user_id].sort();
|
|
2869
|
+
const existing = findDirectMessage(authUserId, targetUser.user_id);
|
|
2870
|
+
if (existing) return existing;
|
|
2871
|
+
const team = ss().teams.all()[0];
|
|
2872
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2873
|
+
return ss().channels.insert({
|
|
2874
|
+
channel_id: generateSlackId("D"),
|
|
2875
|
+
team_id: team?.team_id ?? "T000000001",
|
|
2876
|
+
name: targetUser.name,
|
|
2877
|
+
is_channel: false,
|
|
2878
|
+
is_private: true,
|
|
2879
|
+
is_im: true,
|
|
2880
|
+
is_mpim: false,
|
|
2881
|
+
is_open_by_user: { [authUserId]: true },
|
|
2882
|
+
user: targetUser.user_id,
|
|
2883
|
+
is_archived: false,
|
|
2884
|
+
topic: { value: "", creator: authUserId, last_set: now },
|
|
2885
|
+
purpose: { value: "", creator: authUserId, last_set: now },
|
|
2886
|
+
members,
|
|
2887
|
+
creator: authUserId,
|
|
2888
|
+
num_members: members.length,
|
|
2889
|
+
last_read: {}
|
|
2890
|
+
});
|
|
2891
|
+
};
|
|
2892
|
+
const resolveShareTarget = (authUser, channel) => {
|
|
2893
|
+
const existingChannel = findChannel(channel);
|
|
2894
|
+
if (existingChannel) return { key: existingChannel.channel_id, channel: existingChannel };
|
|
2895
|
+
if (!channel.startsWith("U")) return void 0;
|
|
2896
|
+
const targetUser = ss().users.findOneBy("user_id", channel);
|
|
2897
|
+
if (!targetUser || targetUser.deleted) return void 0;
|
|
2898
|
+
const authUserId = getAuthUserId(authUser);
|
|
2899
|
+
if (targetUser.user_id === authUserId) return void 0;
|
|
2900
|
+
const existingDirectMessage = findDirectMessage(authUserId, targetUser.user_id);
|
|
2901
|
+
if (existingDirectMessage) return { key: existingDirectMessage.channel_id, channel: existingDirectMessage };
|
|
2902
|
+
return { key: `user:${targetUser.user_id}`, directUserId: targetUser.user_id };
|
|
2903
|
+
};
|
|
2904
|
+
app.post("/api/files.getUploadURLExternal", async (c) => {
|
|
2905
|
+
const authUser = c.get("authUser");
|
|
2906
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
2907
|
+
const scopeError = requireSlackScopes(c, store, ["files:write"]);
|
|
2908
|
+
if (scopeError) return scopeError;
|
|
2909
|
+
const body = await parseSlackBody(c);
|
|
2910
|
+
const filename = typeof body.filename === "string" ? body.filename.trim() : "";
|
|
2911
|
+
const length = Number(body.length);
|
|
2912
|
+
const altTxt = typeof body.alt_text === "string" ? body.alt_text : void 0;
|
|
2913
|
+
const snippetType = typeof body.snippet_type === "string" ? body.snippet_type : void 0;
|
|
2914
|
+
if (!filename || !Number.isFinite(length) || length < 0) return slackError(c, "invalid_arguments");
|
|
2915
|
+
const team = ss().teams.all()[0];
|
|
2916
|
+
const fileId = generateSlackId("F");
|
|
2917
|
+
const uploadUrl = `${serviceBaseUrl}/upload/v1/${fileId}`;
|
|
2918
|
+
ss().fileUploadSessions.insert({
|
|
2919
|
+
file_id: fileId,
|
|
2920
|
+
team_id: team?.team_id ?? "T000000001",
|
|
2921
|
+
user: getAuthUserId(authUser),
|
|
2922
|
+
filename,
|
|
2923
|
+
title: filename,
|
|
2924
|
+
length: Math.floor(length),
|
|
2925
|
+
upload_url: uploadUrl,
|
|
2926
|
+
alt_txt: altTxt,
|
|
2927
|
+
snippet_type: snippetType,
|
|
2928
|
+
uploaded: false,
|
|
2929
|
+
completed: false
|
|
2930
|
+
});
|
|
2931
|
+
return slackOk(c, { upload_url: uploadUrl, file_id: fileId });
|
|
2932
|
+
});
|
|
2933
|
+
app.post("/upload/v1/:fileId", async (c) => {
|
|
2934
|
+
const session = ss().fileUploadSessions.findOneBy("file_id", c.req.param("fileId"));
|
|
2935
|
+
if (!session || session.completed) return c.text("file_not_found", 404);
|
|
2936
|
+
const data = await readUploadBytes(c);
|
|
2937
|
+
if (!data) return c.text("invalid_upload", 400);
|
|
2938
|
+
ss().fileUploadSessions.update(session.id, {
|
|
2939
|
+
uploaded: true,
|
|
2940
|
+
uploaded_size: data.byteLength,
|
|
2941
|
+
content_base64: data.toString("base64")
|
|
2942
|
+
});
|
|
2943
|
+
return c.text("OK");
|
|
2944
|
+
});
|
|
2945
|
+
app.post("/api/files.completeUploadExternal", async (c) => {
|
|
2946
|
+
const authUser = c.get("authUser");
|
|
2947
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
2948
|
+
const scopeError = requireSlackScopes(c, store, ["files:write"]);
|
|
2949
|
+
if (scopeError) return scopeError;
|
|
2950
|
+
const body = await parseSlackBody(c);
|
|
2951
|
+
const requestedFiles = parseCompleteFiles(body.files);
|
|
2952
|
+
if (!requestedFiles || requestedFiles.length === 0) return slackError(c, "invalid_arguments");
|
|
2953
|
+
if (new Set(requestedFiles.map((file) => file.id)).size !== requestedFiles.length) {
|
|
2954
|
+
return slackError(c, "invalid_arguments");
|
|
2955
|
+
}
|
|
2956
|
+
const authUserId = getAuthUserId(authUser);
|
|
2957
|
+
const initialComment = typeof body.initial_comment === "string" ? body.initial_comment : "";
|
|
2958
|
+
const threadTs = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
2959
|
+
const blocks = initialComment ? void 0 : parseBlocks(body.blocks);
|
|
2960
|
+
if (!initialComment && body.blocks !== void 0 && blocks === void 0) return slackError(c, "invalid_blocks");
|
|
2961
|
+
const requestedSessions = [];
|
|
2962
|
+
for (const requestedFile of requestedFiles) {
|
|
2963
|
+
const session = ss().fileUploadSessions.findOneBy("file_id", requestedFile.id);
|
|
2964
|
+
if (!session || !session.uploaded || session.completed || session.user !== authUserId) {
|
|
2965
|
+
return slackError(c, "file_not_found");
|
|
2966
|
+
}
|
|
2967
|
+
requestedSessions.push(session);
|
|
2968
|
+
}
|
|
2969
|
+
const rawChannelIds = parseDestinationChannels(body.channel_id, body.channels);
|
|
2970
|
+
const targetRefs = [];
|
|
2971
|
+
const targetKeys = /* @__PURE__ */ new Set();
|
|
2972
|
+
for (const channelId of rawChannelIds) {
|
|
2973
|
+
const target = resolveShareTarget(authUser, channelId);
|
|
2974
|
+
if (!target) return slackError(c, "channel_not_found");
|
|
2975
|
+
if (target.channel?.is_archived) return slackError(c, "is_archived");
|
|
2976
|
+
if (target.channel && !canReadConversation(target.channel, getAuthSlackUser(authUser), authUserId)) {
|
|
2977
|
+
return slackError(c, "not_in_channel");
|
|
2978
|
+
}
|
|
2979
|
+
if (!targetKeys.has(target.key)) {
|
|
2980
|
+
targetKeys.add(target.key);
|
|
2981
|
+
targetRefs.push(target);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
const targets = [];
|
|
2985
|
+
for (const target of targetRefs) {
|
|
2986
|
+
const channel = target.channel ?? findOrCreateDirectMessage(authUser, target.directUserId ?? "");
|
|
2987
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
2988
|
+
targets.push(channel);
|
|
2989
|
+
}
|
|
2990
|
+
const completedFiles = [];
|
|
2991
|
+
for (let index = 0; index < requestedFiles.length; index++) {
|
|
2992
|
+
const requestedFile = requestedFiles[index];
|
|
2993
|
+
const session = requestedSessions[index];
|
|
2994
|
+
const file = ss().files.insert(
|
|
2995
|
+
buildSlackFile(session, {
|
|
2996
|
+
title: requestedFile.title ?? session.title,
|
|
2997
|
+
user: authUserId,
|
|
2998
|
+
baseUrl: serviceBaseUrl,
|
|
2999
|
+
initialComment,
|
|
3000
|
+
threadTs
|
|
3001
|
+
})
|
|
3002
|
+
);
|
|
3003
|
+
ss().fileUploadSessions.update(session.id, { completed: true });
|
|
3004
|
+
await dispatchFileEvent(webhooks, "file_created", file);
|
|
3005
|
+
completedFiles.push(file);
|
|
3006
|
+
}
|
|
3007
|
+
const sharedFiles = targets.length > 0 ? await shareFiles(targets, completedFiles) : completedFiles;
|
|
3008
|
+
return slackOk(c, { files: sharedFiles.map((file) => formatSlackFileForAuth(file, authUser)) });
|
|
3009
|
+
async function shareFiles(channels, files) {
|
|
3010
|
+
const updatedFiles = [...files];
|
|
3011
|
+
for (const channel of channels) {
|
|
3012
|
+
const msg = ss().messages.insert({
|
|
3013
|
+
ts: generateTs(),
|
|
3014
|
+
channel_id: channel.channel_id,
|
|
3015
|
+
user: authUserId,
|
|
3016
|
+
text: initialComment,
|
|
3017
|
+
type: "message",
|
|
3018
|
+
subtype: "file_share",
|
|
3019
|
+
thread_ts: threadTs,
|
|
3020
|
+
blocks,
|
|
3021
|
+
files: updatedFiles,
|
|
3022
|
+
upload: true,
|
|
3023
|
+
reply_count: 0,
|
|
3024
|
+
reply_users: [],
|
|
3025
|
+
reactions: []
|
|
3026
|
+
});
|
|
3027
|
+
updateParentThread(channel.channel_id, threadTs, authUserId);
|
|
3028
|
+
const messageFiles = [];
|
|
3029
|
+
for (const file of updatedFiles) {
|
|
3030
|
+
const shared = updateFileShare(file, channel, msg, authUserId);
|
|
3031
|
+
messageFiles.push(shared);
|
|
3032
|
+
await dispatchFileEvent(webhooks, "file_shared", shared, { channel_id: channel.channel_id });
|
|
3033
|
+
}
|
|
3034
|
+
const updatedMessage = ss().messages.update(msg.id, { files: messageFiles });
|
|
3035
|
+
await webhooks.dispatch(
|
|
3036
|
+
"message",
|
|
3037
|
+
void 0,
|
|
3038
|
+
{
|
|
3039
|
+
type: "event_callback",
|
|
3040
|
+
event: {
|
|
3041
|
+
...formatSlackMessage(updatedMessage),
|
|
3042
|
+
type: "message",
|
|
3043
|
+
subtype: "file_share",
|
|
3044
|
+
channel: channel.channel_id
|
|
3045
|
+
}
|
|
3046
|
+
},
|
|
3047
|
+
"slack"
|
|
3048
|
+
);
|
|
3049
|
+
for (const shared of messageFiles) {
|
|
3050
|
+
const index = updatedFiles.findIndex((file) => file.file_id === shared.file_id);
|
|
3051
|
+
if (index >= 0) updatedFiles[index] = shared;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
return updatedFiles;
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
async function fileInfo(c) {
|
|
3058
|
+
const authUser = c.get("authUser");
|
|
3059
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3060
|
+
const scopeError = requireSlackScopes(c, store, ["files:read"]);
|
|
3061
|
+
if (scopeError) return scopeError;
|
|
3062
|
+
const body = await parseSlackRequest2(c);
|
|
3063
|
+
const fileId = typeof body.file === "string" ? body.file : "";
|
|
3064
|
+
const file = fileId ? ss().files.findOneBy("file_id", fileId) : void 0;
|
|
3065
|
+
if (!file || file.deleted || !canAccessFile(file, authUser)) return slackError(c, "file_not_found");
|
|
3066
|
+
return slackOk(c, {
|
|
3067
|
+
file: formatSlackFileForAuth(file, authUser),
|
|
3068
|
+
comments: [],
|
|
3069
|
+
paging: { count: 0, total: 0, page: 1, pages: 0 }
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
async function fileList(c) {
|
|
3073
|
+
const authUser = c.get("authUser");
|
|
3074
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3075
|
+
const scopeError = requireSlackScopes(c, store, ["files:read"]);
|
|
3076
|
+
if (scopeError) return scopeError;
|
|
3077
|
+
const body = await parseSlackRequest2(c);
|
|
3078
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
3079
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
3080
|
+
const types = typeof body.types === "string" ? body.types : "all";
|
|
3081
|
+
const tsFrom = body.ts_from === void 0 ? void 0 : Number(body.ts_from);
|
|
3082
|
+
const tsTo = body.ts_to === void 0 ? void 0 : Number(body.ts_to);
|
|
3083
|
+
const page = Math.max(1, Math.floor(Number(body.page) || 1));
|
|
3084
|
+
const count = Math.min(Math.max(1, Math.floor(Number(body.count) || 100)), 1e3);
|
|
3085
|
+
const files = ss().files.all().filter((file) => !file.deleted).filter((file) => canAccessFile(file, authUser)).filter((file) => !channel || canAccessFileInChannel(file, authUser, channel)).filter((file) => !user || file.user === user).filter((file) => tsFrom === void 0 || file.created >= tsFrom).filter((file) => tsTo === void 0 || file.created <= tsTo).filter((file) => matchesFileTypes(file, types)).sort((a, b) => b.created - a.created || b.file_id.localeCompare(a.file_id));
|
|
3086
|
+
const start = (page - 1) * count;
|
|
3087
|
+
const paged = files.slice(start, start + count);
|
|
3088
|
+
return slackOk(c, {
|
|
3089
|
+
files: paged.map((file) => formatSlackFileForAuth(file, authUser)),
|
|
3090
|
+
paging: {
|
|
3091
|
+
count,
|
|
3092
|
+
total: files.length,
|
|
3093
|
+
page,
|
|
3094
|
+
pages: Math.ceil(files.length / count)
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
app.get("/api/files.info", fileInfo);
|
|
3099
|
+
app.post("/api/files.info", fileInfo);
|
|
3100
|
+
app.get("/api/files.list", fileList);
|
|
3101
|
+
app.post("/api/files.list", fileList);
|
|
3102
|
+
app.get("/files-pri/:fileId/:filename", (c) => {
|
|
3103
|
+
const authUser = c.get("authUser");
|
|
3104
|
+
if (!authUser) return c.text("not_authed", 401);
|
|
3105
|
+
const scopeError = requireSlackScopes(c, store, ["files:read"]);
|
|
3106
|
+
if (scopeError) return scopeError;
|
|
3107
|
+
const file = ss().files.findOneBy("file_id", c.req.param("fileId"));
|
|
3108
|
+
if (!file || file.deleted) return c.text("file_not_found", 404);
|
|
3109
|
+
if (!canAccessFile(file, authUser)) return c.text("file_not_found", 404);
|
|
3110
|
+
const data = Buffer.from(file.content_base64 ?? "", "base64");
|
|
3111
|
+
return new Response(data, {
|
|
3112
|
+
status: 200,
|
|
3113
|
+
headers: {
|
|
3114
|
+
"Content-Type": file.mimetype,
|
|
3115
|
+
...c.req.query("download") ? { "Content-Disposition": `attachment; filename="${file.name}"` } : {}
|
|
3116
|
+
}
|
|
3117
|
+
});
|
|
3118
|
+
});
|
|
3119
|
+
app.post("/api/files.delete", async (c) => {
|
|
3120
|
+
const authUser = c.get("authUser");
|
|
3121
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3122
|
+
const scopeError = requireSlackScopes(c, store, ["files:write"]);
|
|
3123
|
+
if (scopeError) return scopeError;
|
|
3124
|
+
const body = await parseSlackBody(c);
|
|
3125
|
+
const fileId = typeof body.file === "string" ? body.file : "";
|
|
3126
|
+
const file = fileId ? ss().files.findOneBy("file_id", fileId) : void 0;
|
|
3127
|
+
if (!file || file.deleted || !canAccessFile(file, authUser)) return slackError(c, "file_not_found");
|
|
3128
|
+
if (!canDeleteFile(file, authUser)) return slackError(c, "cant_delete_file");
|
|
3129
|
+
const deleted = ss().files.update(file.id, { deleted: true });
|
|
3130
|
+
removeFileFromMessages(deleted.file_id);
|
|
3131
|
+
await dispatchFileEvent(webhooks, "file_deleted", deleted);
|
|
3132
|
+
return slackOk(c, {});
|
|
3133
|
+
});
|
|
3134
|
+
function removeFileFromMessages(fileId) {
|
|
3135
|
+
for (const message of ss().messages.all()) {
|
|
3136
|
+
if (!message.files?.some((file) => file.file_id === fileId)) continue;
|
|
3137
|
+
ss().messages.update(message.id, {
|
|
3138
|
+
files: message.files.filter((file) => file.file_id !== fileId)
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
function updateParentThread(channelId, threadTs, userId) {
|
|
3143
|
+
if (!threadTs) return;
|
|
3144
|
+
const parent = ss().messages.all().find((message) => message.channel_id === channelId && message.ts === threadTs);
|
|
3145
|
+
if (!parent) return;
|
|
3146
|
+
const replyUsers = parent.reply_users.includes(userId) ? parent.reply_users : [...parent.reply_users, userId];
|
|
3147
|
+
ss().messages.update(parent.id, {
|
|
3148
|
+
reply_count: parent.reply_count + 1,
|
|
3149
|
+
reply_users: replyUsers
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
3152
|
+
function updateFileShare(file, channel, msg, userId) {
|
|
3153
|
+
const share = {
|
|
3154
|
+
ts: msg.ts,
|
|
3155
|
+
channel_name: channel.name,
|
|
3156
|
+
team_id: channel.team_id,
|
|
3157
|
+
share_user_id: userId,
|
|
3158
|
+
source: "UPLOAD",
|
|
3159
|
+
thread_ts: msg.thread_ts,
|
|
3160
|
+
reply_count: 0,
|
|
3161
|
+
reply_users: [],
|
|
3162
|
+
reply_users_count: 0,
|
|
3163
|
+
is_silent_share: false
|
|
3164
|
+
};
|
|
3165
|
+
const shareBucket = channel.is_private ? "private" : "public";
|
|
3166
|
+
const shares = {
|
|
3167
|
+
...file.shares,
|
|
3168
|
+
[shareBucket]: {
|
|
3169
|
+
...file.shares[shareBucket] ?? {},
|
|
3170
|
+
[channel.channel_id]: [...file.shares[shareBucket]?.[channel.channel_id] ?? [], share]
|
|
3171
|
+
}
|
|
3172
|
+
};
|
|
3173
|
+
const channelFields = nextFileChannelFields(file, channel);
|
|
3174
|
+
return ss().files.update(file.id, {
|
|
3175
|
+
...channelFields,
|
|
3176
|
+
shares,
|
|
3177
|
+
is_public: channelFields.channels.length > 0
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
async function parseSlackRequest2(c) {
|
|
3182
|
+
if (c.req.method === "GET") {
|
|
3183
|
+
return Object.fromEntries(new URL(c.req.url).searchParams.entries());
|
|
3184
|
+
}
|
|
3185
|
+
return parseSlackBody(c);
|
|
3186
|
+
}
|
|
3187
|
+
async function readUploadBytes(c) {
|
|
3188
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
3189
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
3190
|
+
return Buffer.from(await c.req.arrayBuffer());
|
|
3191
|
+
}
|
|
3192
|
+
const body = await c.req.parseBody();
|
|
3193
|
+
const values = orderedUploadFormValues(body);
|
|
3194
|
+
for (const value of values) {
|
|
3195
|
+
const data = await formValueToBuffer(value, "file");
|
|
3196
|
+
if (data) return data;
|
|
3197
|
+
}
|
|
3198
|
+
for (const value of values) {
|
|
3199
|
+
const data = await formValueToBuffer(value, "string");
|
|
3200
|
+
if (data) return data;
|
|
3201
|
+
}
|
|
3202
|
+
return void 0;
|
|
3203
|
+
}
|
|
3204
|
+
function orderedUploadFormValues(body) {
|
|
3205
|
+
const preferredFields = /* @__PURE__ */ new Set(["filename", "file", "body"]);
|
|
3206
|
+
const values = [...preferredFields].flatMap((field) => formValues(body[field]));
|
|
3207
|
+
const fallbackValues = Object.entries(body).filter(([field]) => !preferredFields.has(field)).flatMap(([, value]) => formValues(value));
|
|
3208
|
+
return [...values, ...fallbackValues];
|
|
3209
|
+
}
|
|
3210
|
+
function formValues(value) {
|
|
3211
|
+
if (value === void 0) return [];
|
|
3212
|
+
return Array.isArray(value) ? value : [value];
|
|
3213
|
+
}
|
|
3214
|
+
async function formValueToBuffer(value, kind) {
|
|
3215
|
+
if (kind === "string" && typeof value === "string") return Buffer.from(value);
|
|
3216
|
+
if (kind === "file" && value && typeof value === "object" && "arrayBuffer" in value) {
|
|
3217
|
+
const arrayBuffer = value.arrayBuffer;
|
|
3218
|
+
if (typeof arrayBuffer === "function") return Buffer.from(await arrayBuffer.call(value));
|
|
3219
|
+
}
|
|
3220
|
+
return void 0;
|
|
3221
|
+
}
|
|
3222
|
+
function parseCompleteFiles(value) {
|
|
3223
|
+
const parsed = parseJsonMaybe(value);
|
|
3224
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
3225
|
+
const files = [];
|
|
3226
|
+
for (const entry of parsed) {
|
|
3227
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return void 0;
|
|
3228
|
+
const record = entry;
|
|
3229
|
+
if (typeof record.id !== "string" || !record.id) return void 0;
|
|
3230
|
+
if (record.title !== void 0 && typeof record.title !== "string") return void 0;
|
|
3231
|
+
if (record.highlight_type !== void 0 && typeof record.highlight_type !== "string") return void 0;
|
|
3232
|
+
files.push({
|
|
3233
|
+
id: record.id,
|
|
3234
|
+
title: record.title,
|
|
3235
|
+
highlight_type: record.highlight_type
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
return files;
|
|
3239
|
+
}
|
|
3240
|
+
function parseDestinationChannels(channelId, channels) {
|
|
3241
|
+
const values = [];
|
|
3242
|
+
if (typeof channelId === "string" && channelId.trim()) values.push(channelId.trim());
|
|
3243
|
+
if (typeof channels === "string" && channels.trim()) {
|
|
3244
|
+
values.push(...channels.split(",").map((channel) => channel.trim()));
|
|
3245
|
+
}
|
|
3246
|
+
return [...new Set(values.filter(Boolean))];
|
|
3247
|
+
}
|
|
3248
|
+
function parseBlocks(value) {
|
|
3249
|
+
const parsed = parseJsonMaybe(value);
|
|
3250
|
+
if (parsed === void 0 || parsed === "") return void 0;
|
|
3251
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
3252
|
+
if (!parsed.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) return void 0;
|
|
3253
|
+
return parsed;
|
|
3254
|
+
}
|
|
3255
|
+
function parseJsonMaybe(value) {
|
|
3256
|
+
if (typeof value !== "string") return value;
|
|
3257
|
+
if (!value.trim()) return void 0;
|
|
3258
|
+
try {
|
|
3259
|
+
return JSON.parse(value);
|
|
3260
|
+
} catch {
|
|
3261
|
+
return void 0;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
function buildSlackFile(session, options) {
|
|
3265
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
3266
|
+
const fileType = fileTypeFor(session.filename, session.snippet_type);
|
|
3267
|
+
const root = options.baseUrl.replace(/\/$/, "");
|
|
3268
|
+
return {
|
|
3269
|
+
file_id: session.file_id,
|
|
3270
|
+
team_id: session.team_id,
|
|
3271
|
+
user: options.user,
|
|
3272
|
+
name: session.filename,
|
|
3273
|
+
title: options.title || session.filename,
|
|
3274
|
+
mimetype: mimeTypeFor(session.filename, session.snippet_type),
|
|
3275
|
+
filetype: fileType,
|
|
3276
|
+
pretty_type: prettyTypeFor(fileType),
|
|
3277
|
+
mode: session.snippet_type ? "snippet" : "hosted",
|
|
3278
|
+
size: session.uploaded_size ?? session.length,
|
|
3279
|
+
created,
|
|
3280
|
+
timestamp: created,
|
|
3281
|
+
url_private: `${root}/files-pri/${session.file_id}/${encodeURIComponent(session.filename)}`,
|
|
3282
|
+
url_private_download: `${root}/files-pri/${session.file_id}/${encodeURIComponent(session.filename)}?download=1`,
|
|
3283
|
+
permalink: `${root}/files/${session.file_id}`,
|
|
3284
|
+
is_external: false,
|
|
3285
|
+
external_type: "",
|
|
3286
|
+
is_public: false,
|
|
3287
|
+
public_url_shared: false,
|
|
3288
|
+
display_as_bot: false,
|
|
3289
|
+
editable: session.snippet_type !== void 0,
|
|
3290
|
+
deleted: false,
|
|
3291
|
+
channels: [],
|
|
3292
|
+
groups: [],
|
|
3293
|
+
ims: [],
|
|
3294
|
+
shares: {},
|
|
3295
|
+
initial_comment: options.initialComment || void 0,
|
|
3296
|
+
thread_ts: options.threadTs,
|
|
3297
|
+
alt_txt: session.alt_txt,
|
|
3298
|
+
snippet_type: session.snippet_type,
|
|
3299
|
+
content_base64: session.content_base64
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
function nextFileChannelFields(file, channel) {
|
|
3303
|
+
const channels = new Set(file.channels);
|
|
3304
|
+
const groups = new Set(file.groups);
|
|
3305
|
+
const ims = new Set(file.ims);
|
|
3306
|
+
if (channel.is_im || channel.is_mpim) ims.add(channel.channel_id);
|
|
3307
|
+
else if (channel.is_private) groups.add(channel.channel_id);
|
|
3308
|
+
else channels.add(channel.channel_id);
|
|
3309
|
+
return { channels: [...channels], groups: [...groups], ims: [...ims] };
|
|
3310
|
+
}
|
|
3311
|
+
function fileChannels2(file) {
|
|
3312
|
+
return [...file.channels, ...file.groups, ...file.ims];
|
|
3313
|
+
}
|
|
3314
|
+
function filterVisibleShares2(shares, visibleIds) {
|
|
3315
|
+
const entries = Object.entries(shares ?? {}).filter(([channelId]) => visibleIds.has(channelId));
|
|
3316
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
3317
|
+
}
|
|
3318
|
+
function matchesFileTypes(file, types) {
|
|
3319
|
+
const requested = types.split(",").map((type) => type.trim()).filter(Boolean);
|
|
3320
|
+
if (requested.length === 0 || requested.includes("all")) return true;
|
|
3321
|
+
if (requested.includes(file.filetype)) return true;
|
|
3322
|
+
if (requested.includes("snippets") && file.mode === "snippet") return true;
|
|
3323
|
+
if (requested.includes("images") && file.mimetype.startsWith("image/")) return true;
|
|
3324
|
+
if (requested.includes("zips") && file.filetype === "zip") return true;
|
|
3325
|
+
if (requested.includes("pdfs") && file.filetype === "pdf") return true;
|
|
3326
|
+
return false;
|
|
3327
|
+
}
|
|
3328
|
+
function fileTypeFor(filename, snippetType) {
|
|
3329
|
+
if (snippetType) return snippetType;
|
|
3330
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
3331
|
+
if (!ext || ext === filename) return "auto";
|
|
3332
|
+
if (ext === "jpg" || ext === "jpeg") return "jpg";
|
|
3333
|
+
if (ext === "md" || ext === "markdown") return "markdown";
|
|
3334
|
+
return ext;
|
|
3335
|
+
}
|
|
3336
|
+
function mimeTypeFor(filename, snippetType) {
|
|
3337
|
+
if (snippetType) return "text/plain";
|
|
3338
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
3339
|
+
const byExt = {
|
|
3340
|
+
gif: "image/gif",
|
|
3341
|
+
jpg: "image/jpeg",
|
|
3342
|
+
jpeg: "image/jpeg",
|
|
3343
|
+
md: "text/markdown",
|
|
3344
|
+
pdf: "application/pdf",
|
|
3345
|
+
png: "image/png",
|
|
3346
|
+
txt: "text/plain",
|
|
3347
|
+
zip: "application/zip"
|
|
3348
|
+
};
|
|
3349
|
+
return byExt[ext] ?? "application/octet-stream";
|
|
3350
|
+
}
|
|
3351
|
+
function prettyTypeFor(filetype) {
|
|
3352
|
+
const byType = {
|
|
3353
|
+
auto: "File",
|
|
3354
|
+
gif: "GIF",
|
|
3355
|
+
jpg: "JPEG",
|
|
3356
|
+
markdown: "Markdown",
|
|
3357
|
+
pdf: "PDF",
|
|
3358
|
+
png: "PNG",
|
|
3359
|
+
txt: "Plain Text",
|
|
3360
|
+
zip: "Zip"
|
|
3361
|
+
};
|
|
3362
|
+
return byType[filetype] ?? filetype.toUpperCase();
|
|
3363
|
+
}
|
|
3364
|
+
async function dispatchFileEvent(webhooks, type, file, extra = {}) {
|
|
3365
|
+
await webhooks.dispatch(
|
|
3366
|
+
type,
|
|
3367
|
+
void 0,
|
|
3368
|
+
{
|
|
3369
|
+
type: "event_callback",
|
|
3370
|
+
event: {
|
|
3371
|
+
type,
|
|
3372
|
+
file_id: file.file_id,
|
|
3373
|
+
file: formatSlackFile(file),
|
|
3374
|
+
...extra
|
|
3375
|
+
}
|
|
3376
|
+
},
|
|
3377
|
+
"slack"
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
function pinsRoutes(ctx) {
|
|
3381
|
+
const { app, store, webhooks, baseUrl } = ctx;
|
|
3382
|
+
const ss = () => getSlackStore(store);
|
|
3383
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
3384
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
3385
|
+
const isChannelMember = (channel, user, userId) => channel.members.includes(userId) || (user ? channel.members.includes(user.name) : false);
|
|
3386
|
+
const canReadConversation = (channel, user, userId) => !channel.is_private || isChannelMember(channel, user, userId);
|
|
3387
|
+
const findPinnedMessage = (channelId, timestamp) => ss().messages.all().find((message) => message.channel_id === channelId && message.ts === timestamp);
|
|
3388
|
+
const findPin = (channelId, timestamp) => ss().pins.all().find((pin) => pin.channel_id === channelId && pin.message_ts === timestamp);
|
|
3389
|
+
app.post("/api/pins.add", async (c) => {
|
|
3390
|
+
const authUser = c.get("authUser");
|
|
3391
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3392
|
+
const scopeError = requireSlackScopes(c, store, ["pins:write"]);
|
|
3393
|
+
if (scopeError) return scopeError;
|
|
3394
|
+
const body = await parseSlackBody(c);
|
|
3395
|
+
const channelId = typeof body.channel === "string" ? body.channel : "";
|
|
3396
|
+
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
3397
|
+
if (!channelId) return slackError(c, "channel_not_found");
|
|
3398
|
+
if (!timestamp) return slackError(c, "no_item_specified");
|
|
3399
|
+
if (!isSlackTimestamp(timestamp)) return slackError(c, "bad_timestamp");
|
|
3400
|
+
const channel = ss().channels.findOneBy("channel_id", channelId);
|
|
3401
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3402
|
+
if (channel.is_archived) return slackError(c, "is_archived");
|
|
3403
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3404
|
+
const authUserId = getAuthUserId(authUser);
|
|
3405
|
+
if (!isChannelMember(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3406
|
+
const message = findPinnedMessage(channel.channel_id, timestamp);
|
|
3407
|
+
if (!message) return slackError(c, "message_not_found");
|
|
3408
|
+
if (findPin(channel.channel_id, timestamp)) return slackError(c, "already_pinned");
|
|
3409
|
+
const pin = ss().pins.insert({
|
|
3410
|
+
pin_id: generateSlackId("P"),
|
|
3411
|
+
team_id: channel.team_id,
|
|
3412
|
+
channel_id: channel.channel_id,
|
|
3413
|
+
message_ts: timestamp,
|
|
3414
|
+
created: Math.floor(Date.now() / 1e3),
|
|
3415
|
+
created_by: authUserId
|
|
3416
|
+
});
|
|
3417
|
+
await dispatchPinEvent("pin_added", {
|
|
3418
|
+
user: authUserId,
|
|
3419
|
+
channel_id: channel.channel_id,
|
|
3420
|
+
item: formatPinItem(pin, message),
|
|
3421
|
+
event_ts: generateTs()
|
|
3422
|
+
});
|
|
3423
|
+
return slackOk(c, {});
|
|
3424
|
+
});
|
|
3425
|
+
async function pinList(c) {
|
|
3426
|
+
const authUser = c.get("authUser");
|
|
3427
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3428
|
+
const scopeError = requireSlackScopes(c, store, ["pins:read"]);
|
|
3429
|
+
if (scopeError) return scopeError;
|
|
3430
|
+
const body = await parseSlackRequest3(c);
|
|
3431
|
+
const channelId = typeof body.channel === "string" ? body.channel : "";
|
|
3432
|
+
if (!channelId) return slackError(c, "channel_not_found");
|
|
3433
|
+
const channel = ss().channels.findOneBy("channel_id", channelId);
|
|
3434
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3435
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3436
|
+
const authUserId = getAuthUserId(authUser);
|
|
3437
|
+
if (!canReadConversation(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3438
|
+
const items = ss().pins.findBy("channel_id", channel.channel_id).sort((a, b) => b.created - a.created).flatMap((pin) => {
|
|
3439
|
+
const message = findPinnedMessage(pin.channel_id, pin.message_ts);
|
|
3440
|
+
return message ? [formatPinItem(pin, message)] : [];
|
|
3441
|
+
});
|
|
3442
|
+
return slackOk(c, { items });
|
|
3443
|
+
}
|
|
3444
|
+
app.get("/api/pins.list", pinList);
|
|
3445
|
+
app.post("/api/pins.list", pinList);
|
|
3446
|
+
app.post("/api/pins.remove", async (c) => {
|
|
3447
|
+
const authUser = c.get("authUser");
|
|
3448
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3449
|
+
const scopeError = requireSlackScopes(c, store, ["pins:write"]);
|
|
3450
|
+
if (scopeError) return scopeError;
|
|
3451
|
+
const body = await parseSlackBody(c);
|
|
3452
|
+
const channelId = typeof body.channel === "string" ? body.channel : "";
|
|
3453
|
+
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
3454
|
+
if (!channelId) return slackError(c, "channel_not_found");
|
|
3455
|
+
if (!timestamp) return slackError(c, "no_item_specified");
|
|
3456
|
+
if (!isSlackTimestamp(timestamp)) return slackError(c, "bad_timestamp");
|
|
3457
|
+
const channel = ss().channels.findOneBy("channel_id", channelId);
|
|
3458
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3459
|
+
if (channel.is_archived) return slackError(c, "is_archived");
|
|
3460
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3461
|
+
const authUserId = getAuthUserId(authUser);
|
|
3462
|
+
if (!isChannelMember(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3463
|
+
const pin = findPin(channel.channel_id, timestamp);
|
|
3464
|
+
const message = findPinnedMessage(channel.channel_id, timestamp);
|
|
3465
|
+
if (!pin) return slackError(c, "no_pin");
|
|
3466
|
+
ss().pins.delete(pin.id);
|
|
3467
|
+
if (!message) return slackOk(c, {});
|
|
3468
|
+
const hasPins = ss().pins.findBy("channel_id", channel.channel_id).length > 0;
|
|
3469
|
+
await dispatchPinEvent("pin_removed", {
|
|
3470
|
+
user: authUserId,
|
|
3471
|
+
channel_id: channel.channel_id,
|
|
3472
|
+
item: formatPinItem(pin, message),
|
|
3473
|
+
has_pins: hasPins,
|
|
3474
|
+
event_ts: generateTs()
|
|
3475
|
+
});
|
|
3476
|
+
return slackOk(c, {});
|
|
3477
|
+
});
|
|
3478
|
+
function formatPinItem(pin, message) {
|
|
3479
|
+
return {
|
|
3480
|
+
type: "message",
|
|
3481
|
+
channel: pin.channel_id,
|
|
3482
|
+
created: pin.created,
|
|
3483
|
+
created_by: pin.created_by,
|
|
3484
|
+
message: {
|
|
3485
|
+
...formatSlackMessage(message),
|
|
3486
|
+
pinned_to: [pin.channel_id],
|
|
3487
|
+
permalink: formatSlackPermalink(baseUrl, pin.channel_id, message)
|
|
3488
|
+
}
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
async function dispatchPinEvent(type, event) {
|
|
3492
|
+
await webhooks.dispatch(
|
|
3493
|
+
type,
|
|
3494
|
+
void 0,
|
|
3495
|
+
{
|
|
3496
|
+
type: "event_callback",
|
|
3497
|
+
event: { type, ...event }
|
|
3498
|
+
},
|
|
3499
|
+
"slack"
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
async function parseSlackRequest3(c) {
|
|
3504
|
+
if (c.req.method === "GET") {
|
|
3505
|
+
return Object.fromEntries(new URL(c.req.url).searchParams.entries());
|
|
3506
|
+
}
|
|
3507
|
+
return parseSlackBody(c);
|
|
3508
|
+
}
|
|
3509
|
+
function isSlackTimestamp(value) {
|
|
3510
|
+
return /^\d{1,16}\.\d{1,16}$/.test(value);
|
|
3511
|
+
}
|
|
3512
|
+
function bookmarksRoutes(ctx) {
|
|
3513
|
+
const { app, store } = ctx;
|
|
3514
|
+
const ss = () => getSlackStore(store);
|
|
3515
|
+
const getAuthSlackUser = (authUser) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login);
|
|
3516
|
+
const getAuthUserId = (authUser) => getAuthSlackUser(authUser)?.user_id ?? authUser.login;
|
|
3517
|
+
const isChannelMember = (channel, user, userId) => channel.members.includes(userId) || (user ? channel.members.includes(user.name) : false);
|
|
3518
|
+
const canReadConversation = (channel, user, userId) => !channel.is_private || isChannelMember(channel, user, userId);
|
|
3519
|
+
app.post("/api/bookmarks.add", async (c) => {
|
|
3520
|
+
const authUser = c.get("authUser");
|
|
3521
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3522
|
+
const scopeError = requireSlackScopes(c, store, ["bookmarks:write"]);
|
|
3523
|
+
if (scopeError) return scopeError;
|
|
3524
|
+
const body = await parseSlackBody(c);
|
|
3525
|
+
const channelId = stringField(body.channel_id) || stringField(body.channel);
|
|
3526
|
+
const channel = findBookmarkChannel(channelId);
|
|
3527
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3528
|
+
if (channel.is_archived) return slackError(c, "is_archived");
|
|
3529
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3530
|
+
const authUserId = getAuthUserId(authUser);
|
|
3531
|
+
if (!isChannelMember(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3532
|
+
const title = stringField(body.title).trim();
|
|
3533
|
+
const type = stringField(body.type);
|
|
3534
|
+
const link = stringField(body.link) || stringField(body.url);
|
|
3535
|
+
if (type !== "link") return slackError(c, "invalid_bookmark_type");
|
|
3536
|
+
if (!title || !link) return slackError(c, "invalid_arguments");
|
|
3537
|
+
if (!isValidBookmarkLink(link)) return slackError(c, "invalid_link");
|
|
3538
|
+
if (ss().bookmarks.findBy("channel_id", channel.channel_id).length >= 100) {
|
|
3539
|
+
return slackError(c, "too_many_bookmarks");
|
|
3540
|
+
}
|
|
3541
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3542
|
+
const team = ss().teams.all()[0];
|
|
3543
|
+
const bookmark = ss().bookmarks.insert({
|
|
3544
|
+
bookmark_id: generateSlackId("Bk"),
|
|
3545
|
+
team_id: team?.team_id ?? channel.team_id,
|
|
3546
|
+
channel_id: channel.channel_id,
|
|
3547
|
+
title,
|
|
3548
|
+
type: "link",
|
|
3549
|
+
link,
|
|
3550
|
+
emoji: stringField(body.emoji),
|
|
3551
|
+
icon_url: bookmarkIconUrl(link),
|
|
3552
|
+
entity_id: null,
|
|
3553
|
+
date_created: now,
|
|
3554
|
+
date_updated: 0,
|
|
3555
|
+
rank: bookmarkRank(channel.channel_id),
|
|
3556
|
+
last_updated_by_user_id: authUserId,
|
|
3557
|
+
last_updated_by_team_id: team?.team_id ?? channel.team_id,
|
|
3558
|
+
shortcut_id: null,
|
|
3559
|
+
app_id: null,
|
|
3560
|
+
...accessLevel(body.access_level) ? { access_level: accessLevel(body.access_level) } : {},
|
|
3561
|
+
...stringField(body.parent_id) ? { parent_id: stringField(body.parent_id) } : {}
|
|
3562
|
+
});
|
|
3563
|
+
return slackOk(c, { bookmark: formatBookmark(bookmark) });
|
|
3564
|
+
});
|
|
3565
|
+
app.post("/api/bookmarks.edit", async (c) => {
|
|
3566
|
+
const authUser = c.get("authUser");
|
|
3567
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3568
|
+
const scopeError = requireSlackScopes(c, store, ["bookmarks:write"]);
|
|
3569
|
+
if (scopeError) return scopeError;
|
|
3570
|
+
const body = await parseSlackBody(c);
|
|
3571
|
+
const channelId = stringField(body.channel_id) || stringField(body.channel);
|
|
3572
|
+
const bookmarkId = stringField(body.bookmark_id);
|
|
3573
|
+
const channel = findBookmarkChannel(channelId);
|
|
3574
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3575
|
+
if (channel.is_archived) return slackError(c, "is_archived");
|
|
3576
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3577
|
+
const authUserId = getAuthUserId(authUser);
|
|
3578
|
+
if (!isChannelMember(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3579
|
+
const bookmark = findBookmark(channel.channel_id, bookmarkId);
|
|
3580
|
+
if (!bookmark) return slackError(c, "not_found");
|
|
3581
|
+
const updates = {
|
|
3582
|
+
date_updated: Math.floor(Date.now() / 1e3),
|
|
3583
|
+
last_updated_by_user_id: authUserId
|
|
3584
|
+
};
|
|
3585
|
+
const title = stringField(body.title).trim();
|
|
3586
|
+
const link = stringField(body.link) || stringField(body.url);
|
|
3587
|
+
const emoji = stringField(body.emoji);
|
|
3588
|
+
if (title) updates.title = title;
|
|
3589
|
+
if (link) {
|
|
3590
|
+
if (!isValidBookmarkLink(link)) return slackError(c, "invalid_link");
|
|
3591
|
+
updates.link = link;
|
|
3592
|
+
updates.icon_url = bookmarkIconUrl(link);
|
|
3593
|
+
}
|
|
3594
|
+
if (Object.prototype.hasOwnProperty.call(body, "emoji")) updates.emoji = emoji;
|
|
3595
|
+
const updated = ss().bookmarks.update(bookmark.id, updates);
|
|
3596
|
+
return slackOk(c, { bookmark: formatBookmark(updated) });
|
|
3597
|
+
});
|
|
3598
|
+
app.post("/api/bookmarks.list", async (c) => {
|
|
3599
|
+
const authUser = c.get("authUser");
|
|
3600
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3601
|
+
const scopeError = requireSlackScopes(c, store, ["bookmarks:read"]);
|
|
3602
|
+
if (scopeError) return scopeError;
|
|
3603
|
+
const body = await parseSlackBody(c);
|
|
3604
|
+
const channelId = stringField(body.channel_id) || stringField(body.channel);
|
|
3605
|
+
const channel = findBookmarkChannel(channelId);
|
|
3606
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3607
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3608
|
+
const authUserId = getAuthUserId(authUser);
|
|
3609
|
+
if (!canReadConversation(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3610
|
+
const bookmarks = ss().bookmarks.findBy("channel_id", channel.channel_id).sort(compareSlackBookmarks).map(formatBookmark);
|
|
3611
|
+
return slackOk(c, { bookmarks });
|
|
3612
|
+
});
|
|
3613
|
+
app.post("/api/bookmarks.remove", async (c) => {
|
|
3614
|
+
const authUser = c.get("authUser");
|
|
3615
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3616
|
+
const scopeError = requireSlackScopes(c, store, ["bookmarks:write"]);
|
|
3617
|
+
if (scopeError) return scopeError;
|
|
3618
|
+
const body = await parseSlackBody(c);
|
|
3619
|
+
const channelId = stringField(body.channel_id) || stringField(body.channel);
|
|
3620
|
+
const bookmarkId = stringField(body.bookmark_id);
|
|
3621
|
+
const channel = findBookmarkChannel(channelId);
|
|
3622
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
3623
|
+
if (channel.is_archived) return slackError(c, "is_archived");
|
|
3624
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
3625
|
+
const authUserId = getAuthUserId(authUser);
|
|
3626
|
+
if (!isChannelMember(channel, authSlackUser, authUserId)) return slackError(c, "not_in_channel");
|
|
3627
|
+
const bookmark = findBookmark(channel.channel_id, bookmarkId);
|
|
3628
|
+
if (!bookmark) return slackError(c, "not_found");
|
|
3629
|
+
ss().bookmarks.delete(bookmark.id);
|
|
3630
|
+
return slackOk(c, {});
|
|
3631
|
+
});
|
|
3632
|
+
function findBookmarkChannel(channelId) {
|
|
3633
|
+
if (!channelId) return void 0;
|
|
3634
|
+
return ss().channels.findOneBy("channel_id", channelId);
|
|
3635
|
+
}
|
|
3636
|
+
function findBookmark(channelId, bookmarkId) {
|
|
3637
|
+
if (!bookmarkId) return void 0;
|
|
3638
|
+
return ss().bookmarks.all().find((bookmark) => bookmark.channel_id === channelId && bookmark.bookmark_id === bookmarkId);
|
|
3639
|
+
}
|
|
3640
|
+
function bookmarkRank(channelId) {
|
|
3641
|
+
const maxRank = ss().bookmarks.findBy("channel_id", channelId).reduce((max, bookmark) => Math.max(max, validBookmarkRankNumber(bookmark) ?? 0), 0);
|
|
3642
|
+
return (maxRank + 1).toString(36);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
function compareSlackBookmarks(a, b) {
|
|
3646
|
+
return bookmarkRankNumber(a) - bookmarkRankNumber(b) || a.date_created - b.date_created || a.id - b.id || a.bookmark_id.localeCompare(b.bookmark_id);
|
|
3647
|
+
}
|
|
3648
|
+
function formatBookmark(bookmark) {
|
|
3649
|
+
return {
|
|
3650
|
+
id: bookmark.bookmark_id,
|
|
3651
|
+
channel_id: bookmark.channel_id,
|
|
3652
|
+
title: bookmark.title,
|
|
3653
|
+
link: bookmark.link,
|
|
3654
|
+
emoji: bookmark.emoji,
|
|
3655
|
+
icon_url: bookmark.icon_url,
|
|
3656
|
+
type: bookmark.type,
|
|
3657
|
+
entity_id: bookmark.entity_id,
|
|
3658
|
+
date_created: bookmark.date_created,
|
|
3659
|
+
date_updated: bookmark.date_updated,
|
|
3660
|
+
rank: bookmark.rank,
|
|
3661
|
+
last_updated_by_user_id: bookmark.last_updated_by_user_id,
|
|
3662
|
+
last_updated_by_team_id: bookmark.last_updated_by_team_id,
|
|
3663
|
+
shortcut_id: bookmark.shortcut_id,
|
|
3664
|
+
app_id: bookmark.app_id
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
function stringField(value) {
|
|
3668
|
+
return typeof value === "string" ? value : "";
|
|
3669
|
+
}
|
|
3670
|
+
function accessLevel(value) {
|
|
3671
|
+
if (value === "read" || value === "write") return value;
|
|
3672
|
+
return void 0;
|
|
3673
|
+
}
|
|
3674
|
+
function bookmarkRankNumber(bookmark) {
|
|
3675
|
+
return validBookmarkRankNumber(bookmark) ?? Number.MAX_SAFE_INTEGER;
|
|
3676
|
+
}
|
|
3677
|
+
function validBookmarkRankNumber(bookmark) {
|
|
3678
|
+
if (!/^[0-9a-z]+$/i.test(bookmark.rank)) return void 0;
|
|
3679
|
+
const rank = parseInt(bookmark.rank, 36);
|
|
3680
|
+
return Number.isSafeInteger(rank) ? rank : void 0;
|
|
3681
|
+
}
|
|
3682
|
+
function bookmarkIconUrl(link) {
|
|
3683
|
+
try {
|
|
3684
|
+
const url = new URL(link);
|
|
3685
|
+
return `${url.origin}/favicon.ico`;
|
|
3686
|
+
} catch {
|
|
3687
|
+
return "";
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
function isValidBookmarkLink(link) {
|
|
3691
|
+
try {
|
|
3692
|
+
const url = new URL(link);
|
|
3693
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
3694
|
+
} catch {
|
|
3695
|
+
return false;
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
var VIEW_TRIGGER_TTL_SECONDS = 3;
|
|
3699
|
+
var MAX_MODAL_STACK_DEPTH = 3;
|
|
3700
|
+
function viewsRoutes(ctx) {
|
|
3701
|
+
const { app, store } = ctx;
|
|
3702
|
+
const ss = () => getSlackStore(store);
|
|
3703
|
+
const teamId = () => ss().teams.all()[0]?.team_id ?? "T000000001";
|
|
3704
|
+
app.post("/api/views.publish", async (c) => {
|
|
3705
|
+
const authUser = c.get("authUser");
|
|
3706
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3707
|
+
const body = await parseSlackBody(c);
|
|
3708
|
+
const userId = resolveUserId(stringField2(body.user_id));
|
|
3709
|
+
if (!userId) return slackError(c, "user_not_found");
|
|
3710
|
+
const parsed = parseViewPayload(body.view, "home");
|
|
3711
|
+
if (parsed.error || !parsed.view) return slackError(c, parsed.error ?? "invalid_view");
|
|
3712
|
+
const viewPayload = parsed.view;
|
|
3713
|
+
const actor = viewActor(c);
|
|
3714
|
+
const existing = ss().views.all().find((view2) => view2.type === "home" && view2.user_id === userId && view2.app_id === actor.app_id);
|
|
3715
|
+
const hash = stringField2(body.hash);
|
|
3716
|
+
if (existing && hash && hash !== existing.hash) return slackError(c, "hash_conflict");
|
|
3717
|
+
if (findDuplicateExternalId(viewPayload.external_id, existing?.view_id)) {
|
|
3718
|
+
return slackError(c, "duplicate_external_id");
|
|
3719
|
+
}
|
|
3720
|
+
const now = nowSeconds();
|
|
3721
|
+
const view = existing ?? ss().views.insert({
|
|
3722
|
+
...viewPayload,
|
|
3723
|
+
view_id: generateSlackId("V"),
|
|
3724
|
+
team_id: teamId(),
|
|
3725
|
+
user_id: userId,
|
|
3726
|
+
hash: generateTs(),
|
|
3727
|
+
root_view_id: "",
|
|
3728
|
+
app_id: actor.app_id,
|
|
3729
|
+
bot_id: actor.bot_id,
|
|
3730
|
+
created: now,
|
|
3731
|
+
updated: now
|
|
3732
|
+
});
|
|
3733
|
+
const updated = ss().views.update(view.id, {
|
|
3734
|
+
...viewPayload,
|
|
3735
|
+
root_view_id: view.root_view_id || view.view_id,
|
|
3736
|
+
hash: generateTs(),
|
|
3737
|
+
updated: now
|
|
3738
|
+
});
|
|
3739
|
+
return slackOk(c, { view: formatSlackView(updated) });
|
|
3740
|
+
});
|
|
3741
|
+
app.post("/api/views.open", async (c) => {
|
|
3742
|
+
const authUser = c.get("authUser");
|
|
3743
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3744
|
+
const body = await parseSlackBody(c);
|
|
3745
|
+
const parsed = parseViewPayload(body.view, "modal");
|
|
3746
|
+
if (parsed.error || !parsed.view) return slackError(c, parsed.error ?? "invalid_view");
|
|
3747
|
+
const viewPayload = parsed.view;
|
|
3748
|
+
const actor = viewActor(c);
|
|
3749
|
+
const trigger = consumeTrigger(viewExchangeId(body), actor.app_id);
|
|
3750
|
+
if (trigger.error) return slackError(c, trigger.error);
|
|
3751
|
+
const userId = trigger.value.user_id;
|
|
3752
|
+
if (!resolveUserId(userId)) return slackError(c, "user_not_found");
|
|
3753
|
+
if (findDuplicateExternalId(viewPayload.external_id)) return slackError(c, "duplicate_external_id");
|
|
3754
|
+
const view = createView(viewPayload, {
|
|
3755
|
+
user_id: userId,
|
|
3756
|
+
app_id: actor.app_id,
|
|
3757
|
+
bot_id: actor.bot_id
|
|
3758
|
+
});
|
|
3759
|
+
return slackOk(c, { view: formatSlackView(view) });
|
|
3760
|
+
});
|
|
3761
|
+
app.post("/api/views.update", async (c) => {
|
|
3762
|
+
const authUser = c.get("authUser");
|
|
3763
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3764
|
+
const body = await parseSlackBody(c);
|
|
3765
|
+
const view = findView(stringField2(body.view_id), stringField2(body.external_id));
|
|
3766
|
+
if (!view) return slackError(c, "not_found");
|
|
3767
|
+
const actor = viewActor(c);
|
|
3768
|
+
if (view.app_id !== actor.app_id) return slackError(c, "not_found");
|
|
3769
|
+
const hash = stringField2(body.hash);
|
|
3770
|
+
if (hash && hash !== view.hash) return slackError(c, "hash_conflict");
|
|
3771
|
+
const parsed = parseViewPayload(body.view, view.type, view.type);
|
|
3772
|
+
if (parsed.error || !parsed.view) return slackError(c, parsed.error ?? "invalid_view");
|
|
3773
|
+
const viewPayload = parsed.view;
|
|
3774
|
+
if (findDuplicateExternalId(viewPayload.external_id, view.view_id)) {
|
|
3775
|
+
return slackError(c, "duplicate_external_id");
|
|
3776
|
+
}
|
|
3777
|
+
const updated = ss().views.update(view.id, {
|
|
3778
|
+
...viewPayload,
|
|
3779
|
+
hash: generateTs(),
|
|
3780
|
+
updated: nowSeconds()
|
|
3781
|
+
});
|
|
3782
|
+
return slackOk(c, { view: formatSlackView(updated) });
|
|
3783
|
+
});
|
|
3784
|
+
app.post("/api/views.push", async (c) => {
|
|
3785
|
+
const authUser = c.get("authUser");
|
|
3786
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3787
|
+
const body = await parseSlackBody(c);
|
|
3788
|
+
const parsed = parseViewPayload(body.view, "modal");
|
|
3789
|
+
if (parsed.error || !parsed.view) return slackError(c, parsed.error ?? "invalid_view");
|
|
3790
|
+
const viewPayload = parsed.view;
|
|
3791
|
+
const actor = viewActor(c);
|
|
3792
|
+
const trigger = consumeTrigger(viewExchangeId(body), actor.app_id);
|
|
3793
|
+
if (trigger.error) return slackError(c, trigger.error);
|
|
3794
|
+
const userId = trigger.value.user_id;
|
|
3795
|
+
if (!resolveUserId(userId)) return slackError(c, "user_not_found");
|
|
3796
|
+
if (findDuplicateExternalId(viewPayload.external_id)) return slackError(c, "duplicate_external_id");
|
|
3797
|
+
const parent = trigger.value?.view_id ? ss().views.findOneBy("view_id", trigger.value.view_id) : void 0;
|
|
3798
|
+
if (!parent || parent.type !== "modal" || parent.user_id !== userId) return slackError(c, "view_not_found");
|
|
3799
|
+
if (modalStackDepth(parent) >= MAX_MODAL_STACK_DEPTH) return slackError(c, "push_limit_reached");
|
|
3800
|
+
const view = createView(viewPayload, {
|
|
3801
|
+
user_id: userId,
|
|
3802
|
+
app_id: actor.app_id,
|
|
3803
|
+
bot_id: actor.bot_id,
|
|
3804
|
+
previous_view_id: parent?.view_id,
|
|
3805
|
+
root_view_id: parent?.root_view_id ?? parent?.view_id
|
|
3806
|
+
});
|
|
3807
|
+
return slackOk(c, { view: formatSlackView(view) });
|
|
3808
|
+
});
|
|
3809
|
+
app.post("/api/views.generateTriggerId", async (c) => {
|
|
3810
|
+
const authUser = c.get("authUser");
|
|
3811
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
3812
|
+
const body = await parseSlackBody(c);
|
|
3813
|
+
const referencedView = stringField2(body.view_id) ? ss().views.findOneBy("view_id", stringField2(body.view_id)) : void 0;
|
|
3814
|
+
if (stringField2(body.view_id) && !referencedView) return slackError(c, "view_not_found");
|
|
3815
|
+
const actor = viewActor(c);
|
|
3816
|
+
if (referencedView && referencedView.app_id !== actor.app_id) return slackError(c, "view_not_found");
|
|
3817
|
+
const userId = resolveUserId(stringField2(body.user_id)) ?? referencedView?.user_id ?? resolveUserId(authUser.login);
|
|
3818
|
+
if (!userId || !resolveUserId(userId)) return slackError(c, "user_not_found");
|
|
3819
|
+
const triggerId = generateTriggerId();
|
|
3820
|
+
const expiresAt = nowSeconds() + VIEW_TRIGGER_TTL_SECONDS;
|
|
3821
|
+
ss().viewTriggers.insert({
|
|
3822
|
+
trigger_id: triggerId,
|
|
3823
|
+
team_id: teamId(),
|
|
3824
|
+
user_id: userId,
|
|
3825
|
+
app_id: actor.app_id,
|
|
3826
|
+
expires_at: expiresAt,
|
|
3827
|
+
used: false,
|
|
3828
|
+
...referencedView ? { view_id: referencedView.view_id } : {}
|
|
3829
|
+
});
|
|
3830
|
+
return slackOk(c, { trigger_id: triggerId, expires_at: expiresAt });
|
|
3831
|
+
});
|
|
3832
|
+
function createView(parsed, options) {
|
|
3833
|
+
const now = nowSeconds();
|
|
3834
|
+
const viewId = generateSlackId("V");
|
|
3835
|
+
return ss().views.insert({
|
|
3836
|
+
...parsed,
|
|
3837
|
+
view_id: viewId,
|
|
3838
|
+
team_id: teamId(),
|
|
3839
|
+
user_id: options.user_id,
|
|
3840
|
+
hash: generateTs(),
|
|
3841
|
+
root_view_id: options.root_view_id ?? viewId,
|
|
3842
|
+
...options.previous_view_id ? { previous_view_id: options.previous_view_id } : {},
|
|
3843
|
+
app_id: options.app_id,
|
|
3844
|
+
bot_id: options.bot_id,
|
|
3845
|
+
created: now,
|
|
3846
|
+
updated: now
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
function findView(viewId, externalId) {
|
|
3850
|
+
if (viewId) return ss().views.findOneBy("view_id", viewId);
|
|
3851
|
+
if (externalId) return ss().views.findOneBy("external_id", externalId);
|
|
3852
|
+
return void 0;
|
|
3853
|
+
}
|
|
3854
|
+
function findDuplicateExternalId(externalId, currentViewId) {
|
|
3855
|
+
if (!externalId) return void 0;
|
|
3856
|
+
return ss().views.all().find((view) => view.team_id === teamId() && view.external_id === externalId && view.view_id !== currentViewId);
|
|
3857
|
+
}
|
|
3858
|
+
function resolveUserId(value) {
|
|
3859
|
+
if (!value) return void 0;
|
|
3860
|
+
return ss().users.findOneBy("user_id", value)?.user_id ?? ss().users.findOneBy("name", value)?.user_id;
|
|
3861
|
+
}
|
|
3862
|
+
function modalStackDepth(view) {
|
|
3863
|
+
const rootViewId = view.root_view_id || view.view_id;
|
|
3864
|
+
return ss().views.all().filter((candidate) => candidate.type === "modal" && candidate.root_view_id === rootViewId).length;
|
|
3865
|
+
}
|
|
3866
|
+
function viewActor(c) {
|
|
3867
|
+
const token = authTokenRecord(c);
|
|
3868
|
+
const appId = token?.app_id ?? ss().oauthApps.all()[0]?.app_id ?? "A000000001";
|
|
3869
|
+
const botId = token?.bot_id ?? ss().bots.all()[0]?.bot_id ?? "B000000001";
|
|
3870
|
+
return { app_id: appId, bot_id: botId };
|
|
3871
|
+
}
|
|
3872
|
+
function authTokenRecord(c) {
|
|
3873
|
+
const token = c.get("authToken");
|
|
3874
|
+
return token ? ss().tokens.findOneBy("token", token) : void 0;
|
|
3875
|
+
}
|
|
3876
|
+
function consumeTrigger(triggerId, appId) {
|
|
3877
|
+
if (!triggerId) return { error: "invalid_trigger_id" };
|
|
3878
|
+
const trigger = ss().viewTriggers.findOneBy("trigger_id", triggerId);
|
|
3879
|
+
if (!trigger) return { error: "invalid_trigger_id" };
|
|
3880
|
+
if (trigger.app_id !== appId) return { error: "invalid_trigger_id" };
|
|
3881
|
+
if (trigger.used) return { error: "exchanged_trigger_id" };
|
|
3882
|
+
if (trigger.expires_at <= nowSeconds()) return { error: "expired_trigger_id" };
|
|
3883
|
+
const updated = ss().viewTriggers.update(trigger.id, { used: true }) ?? trigger;
|
|
3884
|
+
return { value: { user_id: updated.user_id, app_id: updated.app_id, view_id: updated.view_id } };
|
|
3885
|
+
}
|
|
3886
|
+
function parseViewPayload(value, expectedType, fallbackType) {
|
|
3887
|
+
const view = parseViewObject(value);
|
|
3888
|
+
if (!view) return { error: "invalid_view" };
|
|
3889
|
+
const type = typeof view.type === "string" ? view.type : fallbackType;
|
|
3890
|
+
if (type !== expectedType) return { error: "invalid_view" };
|
|
3891
|
+
const blocks = view.blocks;
|
|
3892
|
+
if (!Array.isArray(blocks) || !blocks.every(isSlackJsonObject2)) return { error: "invalid_view" };
|
|
3893
|
+
const title = optionalObject(view.title);
|
|
3894
|
+
const submit = optionalObject(view.submit);
|
|
3895
|
+
const close = optionalObject(view.close);
|
|
3896
|
+
const state = optionalObject(view.state) ?? { values: {} };
|
|
3897
|
+
if (title === false || submit === false || close === false || state === false) return { error: "invalid_view" };
|
|
3898
|
+
if (expectedType === "modal" && title === null) return { error: "invalid_view" };
|
|
3899
|
+
return {
|
|
3900
|
+
view: {
|
|
3901
|
+
type: expectedType,
|
|
3902
|
+
blocks,
|
|
3903
|
+
private_metadata: stringField2(view.private_metadata),
|
|
3904
|
+
callback_id: stringField2(view.callback_id),
|
|
3905
|
+
external_id: stringField2(view.external_id),
|
|
3906
|
+
title,
|
|
3907
|
+
submit,
|
|
3908
|
+
close,
|
|
3909
|
+
state,
|
|
3910
|
+
clear_on_close: booleanField(view.clear_on_close, false),
|
|
3911
|
+
notify_on_close: booleanField(view.notify_on_close, false)
|
|
3912
|
+
}
|
|
3913
|
+
};
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
function parseViewObject(value) {
|
|
3917
|
+
let parsed = value;
|
|
3918
|
+
if (typeof parsed === "string") {
|
|
3919
|
+
if (!parsed) return void 0;
|
|
3920
|
+
try {
|
|
3921
|
+
parsed = JSON.parse(parsed);
|
|
3922
|
+
} catch {
|
|
3923
|
+
return void 0;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
if (!isSlackJsonObject2(parsed)) return void 0;
|
|
3927
|
+
return parsed;
|
|
3928
|
+
}
|
|
3929
|
+
function optionalObject(value) {
|
|
3930
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
3931
|
+
return isSlackJsonObject2(value) ? value : false;
|
|
3932
|
+
}
|
|
3933
|
+
function isSlackJsonObject2(value) {
|
|
3934
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3935
|
+
}
|
|
3936
|
+
function stringField2(value) {
|
|
3937
|
+
return typeof value === "string" ? value : "";
|
|
3938
|
+
}
|
|
3939
|
+
function viewExchangeId(body) {
|
|
3940
|
+
return stringField2(body.trigger_id) || stringField2(body.interactivity_pointer);
|
|
3941
|
+
}
|
|
3942
|
+
function booleanField(value, fallback) {
|
|
3943
|
+
if (typeof value === "boolean") return value;
|
|
3944
|
+
if (value === 1 || value === "1" || value === "true") return true;
|
|
3945
|
+
if (value === 0 || value === "0" || value === "false") return false;
|
|
3946
|
+
return fallback;
|
|
3947
|
+
}
|
|
3948
|
+
function nowSeconds() {
|
|
3949
|
+
return Math.floor(Date.now() / 1e3);
|
|
3950
|
+
}
|
|
3951
|
+
function generateTriggerId() {
|
|
3952
|
+
const first = Math.floor(Date.now() / 1e3);
|
|
3953
|
+
const second = Math.floor(Math.random() * 1e6).toString().padStart(6, "0");
|
|
3954
|
+
return `${first}.${second}.${generateSlackId("trg").toLowerCase()}`;
|
|
3955
|
+
}
|
|
3956
|
+
var SERVICE_LABEL2 = "Slack";
|
|
3957
|
+
var INSPECTOR_TABS = [
|
|
3958
|
+
{ id: "messages", label: "Messages", href: "/?tab=messages" },
|
|
3959
|
+
{ id: "channels", label: "Channels", href: "/?tab=channels" },
|
|
3960
|
+
{ id: "files", label: "Files", href: "/?tab=files" },
|
|
3961
|
+
{ id: "views", label: "Views", href: "/?tab=views" },
|
|
3962
|
+
{ id: "auth", label: "Auth", href: "/?tab=auth" },
|
|
3963
|
+
{ id: "events", label: "Events", href: "/?tab=events" }
|
|
3964
|
+
];
|
|
3965
|
+
function timeAgo(isoDate) {
|
|
3966
|
+
const seconds = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1e3);
|
|
3967
|
+
if (seconds < 60) return "just now";
|
|
3968
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
3969
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
3970
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
3971
|
+
}
|
|
3972
|
+
function collectTextValues(value, output) {
|
|
3973
|
+
if (Array.isArray(value)) {
|
|
3974
|
+
for (const item of value) collectTextValues(item, output);
|
|
3975
|
+
return;
|
|
3976
|
+
}
|
|
3977
|
+
if (value === null || typeof value !== "object") return;
|
|
3978
|
+
const record = value;
|
|
3979
|
+
const text = record.text;
|
|
3980
|
+
if (typeof text === "string" && text.trim().length > 0) {
|
|
3981
|
+
output.push(text);
|
|
3982
|
+
} else {
|
|
3983
|
+
collectTextValues(text, output);
|
|
3984
|
+
}
|
|
3985
|
+
collectTextValues(record.fields, output);
|
|
3986
|
+
collectTextValues(record.elements, output);
|
|
3987
|
+
collectTextValues(record.accessory, output);
|
|
3988
|
+
}
|
|
3989
|
+
function richMessagePreview(msg) {
|
|
3990
|
+
if (msg.text.trim().length > 0) return msg.text;
|
|
3991
|
+
const blockText = [];
|
|
3992
|
+
collectTextValues(msg.blocks, blockText);
|
|
3993
|
+
if (blockText.length > 0) return blockText.join(" ");
|
|
3994
|
+
const attachmentText = msg.attachments?.flatMap((attachment) => [attachment.text, attachment.title]).filter((value) => typeof value === "string" && value.trim().length > 0) ?? [];
|
|
3995
|
+
if (attachmentText.length > 0) return attachmentText.join(" ");
|
|
3996
|
+
const files = "files" in msg ? msg.files : void 0;
|
|
3997
|
+
const fileText = files?.map((file) => file.title || file.name).filter((value) => value.trim().length > 0) ?? [];
|
|
3998
|
+
if (fileText.length > 0) return fileText.join(" ");
|
|
3999
|
+
if (msg.blocks?.length) return `${msg.blocks.length} ${msg.blocks.length === 1 ? "block" : "blocks"}`;
|
|
4000
|
+
if (msg.attachments?.length) {
|
|
4001
|
+
return `${msg.attachments.length} ${msg.attachments.length === 1 ? "attachment" : "attachments"}`;
|
|
4002
|
+
}
|
|
4003
|
+
if (files?.length) return `${files.length} ${files.length === 1 ? "file" : "files"}`;
|
|
4004
|
+
return msg.text;
|
|
4005
|
+
}
|
|
4006
|
+
function viewPreview(view) {
|
|
4007
|
+
const blockText = [];
|
|
4008
|
+
collectTextValues(view.blocks, blockText);
|
|
4009
|
+
if (blockText.length > 0) return blockText.join(" ");
|
|
4010
|
+
const title = view.title?.text;
|
|
4011
|
+
if (typeof title === "string" && title.trim().length > 0) return title;
|
|
4012
|
+
if (view.callback_id) return view.callback_id;
|
|
4013
|
+
if (view.external_id) return view.external_id;
|
|
4014
|
+
return `${view.blocks.length} ${view.blocks.length === 1 ? "block" : "blocks"}`;
|
|
4015
|
+
}
|
|
4016
|
+
function renderSection(title, body) {
|
|
4017
|
+
return `<section class="inspector-section">
|
|
4018
|
+
<h2>${escapeHtml(title)}</h2>
|
|
4019
|
+
${body}
|
|
4020
|
+
</section>`;
|
|
4021
|
+
}
|
|
4022
|
+
function renderTable(headers, rows, empty) {
|
|
4023
|
+
if (rows.length === 0) return `<p class="inspector-empty">${escapeHtml(empty)}</p>`;
|
|
4024
|
+
const headerHtml = headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("");
|
|
4025
|
+
const rowsHtml = rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("\n");
|
|
4026
|
+
return `<table class="inspector-table">
|
|
4027
|
+
<thead><tr>${headerHtml}</tr></thead>
|
|
4028
|
+
<tbody>
|
|
4029
|
+
${rowsHtml}
|
|
4030
|
+
</tbody>
|
|
4031
|
+
</table>`;
|
|
4032
|
+
}
|
|
4033
|
+
function badge(label, tone = "requested") {
|
|
4034
|
+
return `<span class="badge badge-${tone}">${escapeHtml(label)}</span>`;
|
|
4035
|
+
}
|
|
4036
|
+
function renderReactionBadges(reactions) {
|
|
4037
|
+
if (reactions.length === 0) return "";
|
|
4038
|
+
return reactions.map((reaction) => badge(`:${reaction.name}: ${reaction.count}`, "granted")).join(" ");
|
|
4039
|
+
}
|
|
4040
|
+
function linkCell(href, label) {
|
|
4041
|
+
return `<a href="${escapeAttr(href)}">${escapeHtml(label)}</a>`;
|
|
4042
|
+
}
|
|
4043
|
+
function scopePreview(scopes) {
|
|
4044
|
+
if (!scopes || scopes.length === 0) return "";
|
|
4045
|
+
return scopes.join(", ");
|
|
4046
|
+
}
|
|
4047
|
+
function userLabel(users, id) {
|
|
4048
|
+
return users.get(id) ?? id;
|
|
4049
|
+
}
|
|
4050
|
+
function channelLabel(ch) {
|
|
4051
|
+
if (ch.is_im) return `DM ${ch.name}`;
|
|
4052
|
+
if (ch.is_mpim) return `MPIM ${ch.name}`;
|
|
4053
|
+
if (ch.is_private) return `private ${ch.name}`;
|
|
4054
|
+
return `# ${ch.name}`;
|
|
4055
|
+
}
|
|
4056
|
+
function channelKind(ch) {
|
|
4057
|
+
if (ch.is_im) return "DM";
|
|
4058
|
+
if (ch.is_mpim) return "MPIM";
|
|
4059
|
+
if (ch.is_private) return "Private";
|
|
4060
|
+
return "Public";
|
|
4061
|
+
}
|
|
4062
|
+
function openStateLabel(ch, users) {
|
|
4063
|
+
if (ch.is_open_by_user) {
|
|
4064
|
+
const openUsers = Object.entries(ch.is_open_by_user).filter(([, isOpen]) => isOpen === true).map(([userId]) => userLabel(users, userId));
|
|
4065
|
+
return openUsers.length > 0 ? openUsers.join(", ") : "closed";
|
|
4066
|
+
}
|
|
4067
|
+
return ch.is_open ? "open" : "closed";
|
|
4068
|
+
}
|
|
4069
|
+
function maskToken(value) {
|
|
4070
|
+
if (value.length <= 10) return value;
|
|
4071
|
+
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
|
4072
|
+
}
|
|
4073
|
+
function sortedChannels(channels) {
|
|
4074
|
+
return [...channels].sort(
|
|
4075
|
+
(a, b) => Number(a.is_archived) - Number(b.is_archived) || channelKind(a).localeCompare(channelKind(b)) || a.name.localeCompare(b.name)
|
|
4076
|
+
);
|
|
4077
|
+
}
|
|
4078
|
+
function inspectorRoutes(ctx) {
|
|
4079
|
+
const { app, store, webhooks } = ctx;
|
|
4080
|
+
const ss = () => getSlackStore(store);
|
|
4081
|
+
app.get("/", (c) => {
|
|
4082
|
+
const team = ss().teams.all()[0];
|
|
4083
|
+
const requestedTab = c.req.query("tab") ?? "messages";
|
|
4084
|
+
const activeTab = INSPECTOR_TABS.some((tab) => tab.id === requestedTab) ? requestedTab : "messages";
|
|
4085
|
+
const users = buildUserMap();
|
|
4086
|
+
const body = activeTab === "channels" ? renderChannelsView(users) : activeTab === "files" ? renderFilesView(users) : activeTab === "views" ? renderViewsView(users) : activeTab === "auth" ? renderAuthView() : activeTab === "events" ? renderEventsView() : renderMessagesView(c.req.query("channel") ?? "", users);
|
|
4087
|
+
return c.html(
|
|
4088
|
+
renderInspectorPage(
|
|
4089
|
+
`${team?.name ?? "Slack"} - Message Inspector`,
|
|
4090
|
+
INSPECTOR_TABS,
|
|
4091
|
+
activeTab,
|
|
4092
|
+
body,
|
|
4093
|
+
SERVICE_LABEL2
|
|
4094
|
+
)
|
|
4095
|
+
);
|
|
4096
|
+
});
|
|
4097
|
+
function buildUserMap() {
|
|
4098
|
+
const userMap = /* @__PURE__ */ new Map();
|
|
4099
|
+
for (const u of ss().users.all()) {
|
|
4100
|
+
userMap.set(u.user_id, u.name);
|
|
4101
|
+
userMap.set(u.name, u.name);
|
|
4102
|
+
}
|
|
4103
|
+
for (const b of ss().bots.all()) {
|
|
4104
|
+
userMap.set(b.bot_id, b.name);
|
|
4105
|
+
if (b.user_id) userMap.set(b.user_id, b.name);
|
|
4106
|
+
}
|
|
4107
|
+
return userMap;
|
|
4108
|
+
}
|
|
4109
|
+
function renderMessagesView(requestedChannel, users) {
|
|
4110
|
+
const channels = sortedChannels(ss().channels.all());
|
|
4111
|
+
const visibleChannels = channels.filter((ch) => !ch.is_archived);
|
|
4112
|
+
const activeChannel = channels.find((ch) => ch.channel_id === requestedChannel) ?? visibleChannels[0] ?? channels[0];
|
|
4113
|
+
if (!activeChannel) {
|
|
4114
|
+
return renderSection("Messages", '<p class="inspector-empty">No conversations in the emulator store.</p>');
|
|
4115
|
+
}
|
|
4116
|
+
const channelMessages = ss().messages.findBy("channel_id", activeChannel.channel_id).sort((a, b) => b.ts > a.ts ? 1 : -1);
|
|
4117
|
+
const messages = channelMessages.slice(0, 50);
|
|
4118
|
+
const threads = channelMessages.filter((message) => message.thread_ts && message.thread_ts !== message.ts).slice(0, 20);
|
|
4119
|
+
const ephemeralMessages = ss().ephemeralMessages.findBy("channel_id", activeChannel.channel_id).sort((a, b) => b.ts > a.ts ? 1 : -1).slice(0, 20);
|
|
4120
|
+
const scheduledMessages = ss().scheduledMessages.findBy("channel_id", activeChannel.channel_id).sort((a, b) => a.post_at - b.post_at).slice(0, 20);
|
|
4121
|
+
const pins = ss().pins.findBy("channel_id", activeChannel.channel_id).filter((pin) => channelMessages.some((message) => message.ts === pin.message_ts)).sort((a, b) => b.created - a.created).slice(0, 20);
|
|
4122
|
+
const bookmarks = ss().bookmarks.findBy("channel_id", activeChannel.channel_id).sort(compareSlackBookmarks).slice(0, 20);
|
|
4123
|
+
const views = ss().views.all().sort((a, b) => b.updated - a.updated || b.id - a.id);
|
|
4124
|
+
const homeViews = views.filter((view) => view.type === "home").slice(0, 20);
|
|
4125
|
+
const modalViews = views.filter((view) => view.type === "modal").slice(0, 20);
|
|
4126
|
+
const stats = `${ss().users.all().length} users, ${channels.length} conversations, ${ss().messages.all().length} messages, ${ss().views.all().length} views`;
|
|
4127
|
+
const body = [
|
|
4128
|
+
renderConversationSelector(channels, activeChannel.channel_id),
|
|
4129
|
+
renderSection(
|
|
4130
|
+
`Messages In ${channelLabel(activeChannel)}`,
|
|
4131
|
+
`<p class="info-text">${escapeHtml(activeChannel.topic.value || "No topic set")} - ${escapeHtml(stats)}</p>` + renderMessagesTable(
|
|
4132
|
+
messages,
|
|
4133
|
+
users,
|
|
4134
|
+
"No messages yet. Post one with chat.postMessage or an incoming webhook."
|
|
4135
|
+
)
|
|
4136
|
+
),
|
|
4137
|
+
renderSection("Threads", renderMessagesTable(threads, users, "No thread replies for this conversation.")),
|
|
4138
|
+
renderSection(
|
|
4139
|
+
"Ephemeral",
|
|
4140
|
+
renderEphemeralTable(ephemeralMessages, users, "No ephemeral messages for this conversation.")
|
|
4141
|
+
),
|
|
4142
|
+
renderSection(
|
|
4143
|
+
"Scheduled",
|
|
4144
|
+
renderScheduledTable(scheduledMessages, users, "No scheduled messages for this conversation.")
|
|
4145
|
+
),
|
|
4146
|
+
renderSection("Pins", renderPinsTable(pins, channelMessages, users, "No pins for this conversation.")),
|
|
4147
|
+
renderSection("Bookmarks", renderBookmarksTable(bookmarks, "No bookmarks for this conversation.")),
|
|
4148
|
+
renderSection("App Home", renderViewsTable(homeViews, users, "No App Home views have been published.")),
|
|
4149
|
+
renderSection("Modals", renderViewsTable(modalViews, users, "No modal views have been opened."))
|
|
4150
|
+
];
|
|
4151
|
+
return body.join("\n");
|
|
4152
|
+
}
|
|
4153
|
+
function renderConversationSelector(channels, activeChannelId) {
|
|
4154
|
+
const rows = channels.map((ch) => [
|
|
4155
|
+
ch.channel_id === activeChannelId ? badge("active", "granted") : "",
|
|
4156
|
+
linkCell(`/?tab=messages&channel=${encodeURIComponent(ch.channel_id)}`, channelLabel(ch)),
|
|
4157
|
+
escapeHtml(channelKind(ch)),
|
|
4158
|
+
escapeHtml(String(ch.num_members)),
|
|
4159
|
+
ch.is_archived ? badge("archived", "denied") : badge("open", "granted")
|
|
4160
|
+
]);
|
|
4161
|
+
return renderSection(
|
|
4162
|
+
"Conversations",
|
|
4163
|
+
renderTable(["", "Name", "Type", "Members", "State"], rows, "No conversations in the emulator store.")
|
|
4164
|
+
);
|
|
4165
|
+
}
|
|
4166
|
+
function renderChannelsView(users) {
|
|
4167
|
+
const channels = sortedChannels(ss().channels.all());
|
|
4168
|
+
const conversations = channels.filter((channel) => !channel.is_im && !channel.is_mpim);
|
|
4169
|
+
const dms = channels.filter((channel) => channel.is_im || channel.is_mpim);
|
|
4170
|
+
return [
|
|
4171
|
+
renderSection(
|
|
4172
|
+
"Channels",
|
|
4173
|
+
renderTable(
|
|
4174
|
+
["ID", "Name", "Type", "Members", "Topic", "Purpose", "State"],
|
|
4175
|
+
conversations.map((ch) => [
|
|
4176
|
+
escapeHtml(ch.channel_id),
|
|
4177
|
+
linkCell(`/?tab=messages&channel=${encodeURIComponent(ch.channel_id)}`, channelLabel(ch)),
|
|
4178
|
+
escapeHtml(channelKind(ch)),
|
|
4179
|
+
escapeHtml(ch.members.map((member) => userLabel(users, member)).join(", ")),
|
|
4180
|
+
escapeHtml(ch.topic.value),
|
|
4181
|
+
escapeHtml(ch.purpose.value),
|
|
4182
|
+
ch.is_archived ? badge("archived", "denied") : badge("open", "granted")
|
|
4183
|
+
]),
|
|
4184
|
+
"No channels in the emulator store."
|
|
4185
|
+
)
|
|
4186
|
+
),
|
|
4187
|
+
renderSection(
|
|
4188
|
+
"Direct Messages",
|
|
4189
|
+
renderTable(
|
|
4190
|
+
["ID", "Name", "Type", "Members", "Open State"],
|
|
4191
|
+
dms.map((ch) => [
|
|
4192
|
+
escapeHtml(ch.channel_id),
|
|
4193
|
+
linkCell(`/?tab=messages&channel=${encodeURIComponent(ch.channel_id)}`, channelLabel(ch)),
|
|
4194
|
+
escapeHtml(channelKind(ch)),
|
|
4195
|
+
escapeHtml(ch.members.map((member) => userLabel(users, member)).join(", ")),
|
|
4196
|
+
escapeHtml(openStateLabel(ch, users))
|
|
4197
|
+
]),
|
|
4198
|
+
"No DMs or MPIMs in the emulator store."
|
|
4199
|
+
)
|
|
4200
|
+
)
|
|
4201
|
+
].join("\n");
|
|
4202
|
+
}
|
|
4203
|
+
function renderFilesView(users) {
|
|
4204
|
+
const files = ss().files.all().sort((a, b) => b.created - a.created || b.id - a.id);
|
|
4205
|
+
const sessions = ss().fileUploadSessions.all().filter((session) => !session.completed).sort((a, b) => b.id - a.id);
|
|
4206
|
+
return [
|
|
4207
|
+
renderSection(
|
|
4208
|
+
"Files",
|
|
4209
|
+
renderTable(
|
|
4210
|
+
["ID", "Title", "User", "Channels", "Size", "State", "Created"],
|
|
4211
|
+
files.map((file) => [
|
|
4212
|
+
escapeHtml(file.file_id),
|
|
4213
|
+
escapeHtml(file.title || file.name),
|
|
4214
|
+
escapeHtml(userLabel(users, file.user)),
|
|
4215
|
+
escapeHtml([...file.channels, ...file.groups, ...file.ims].join(", ")),
|
|
4216
|
+
escapeHtml(String(file.size)),
|
|
4217
|
+
file.deleted ? badge("deleted", "denied") : badge("available", "granted"),
|
|
4218
|
+
escapeHtml(new Date(file.created * 1e3).toISOString())
|
|
4219
|
+
]),
|
|
4220
|
+
"No completed files in the emulator store."
|
|
4221
|
+
)
|
|
4222
|
+
),
|
|
4223
|
+
renderSection(
|
|
4224
|
+
"Pending Uploads",
|
|
4225
|
+
renderTable(
|
|
4226
|
+
["File ID", "Filename", "Title", "Length", "Uploaded", "Completed"],
|
|
4227
|
+
sessions.map((session) => [
|
|
4228
|
+
escapeHtml(session.file_id),
|
|
4229
|
+
escapeHtml(session.filename),
|
|
4230
|
+
escapeHtml(session.title),
|
|
4231
|
+
escapeHtml(String(session.length)),
|
|
4232
|
+
session.uploaded ? badge("uploaded", "granted") : badge("pending"),
|
|
4233
|
+
session.completed ? badge("complete", "granted") : badge("pending")
|
|
4234
|
+
]),
|
|
4235
|
+
"No pending external upload sessions."
|
|
4236
|
+
)
|
|
4237
|
+
)
|
|
4238
|
+
].join("\n");
|
|
4239
|
+
}
|
|
4240
|
+
function renderViewsView(users) {
|
|
4241
|
+
const views = ss().views.all().sort((a, b) => b.updated - a.updated || b.id - a.id);
|
|
4242
|
+
const homeViews = views.filter((view) => view.type === "home");
|
|
4243
|
+
const modalViews = views.filter((view) => view.type === "modal");
|
|
4244
|
+
const triggers = ss().viewTriggers.all().sort((a, b) => b.expires_at - a.expires_at || b.id - a.id);
|
|
4245
|
+
return [
|
|
4246
|
+
renderSection("App Home", renderViewsTable(homeViews, users, "No App Home views have been published.")),
|
|
4247
|
+
renderSection("Modals", renderViewsTable(modalViews, users, "No modal views have been opened.")),
|
|
4248
|
+
renderSection("Trigger IDs", renderTriggerTable(triggers, users))
|
|
4249
|
+
].join("\n");
|
|
4250
|
+
}
|
|
4251
|
+
function renderAuthView() {
|
|
4252
|
+
const subscriptions = webhooks.getSubscriptions("slack");
|
|
4253
|
+
return [
|
|
4254
|
+
renderSection("OAuth Apps", renderOAuthAppsTable(ss().oauthApps.all())),
|
|
4255
|
+
renderSection("Installations", renderInstallationsTable(ss().installations.all())),
|
|
4256
|
+
renderSection("Tokens", renderTokensTable(ss().tokens.all())),
|
|
4257
|
+
renderSection("Incoming Webhooks", renderIncomingWebhooksTable(ss().incomingWebhooks.all())),
|
|
4258
|
+
renderSection("Event Subscriptions", renderSubscriptionsTable(subscriptions))
|
|
4259
|
+
].join("\n");
|
|
4260
|
+
}
|
|
4261
|
+
function renderEventsView() {
|
|
4262
|
+
const subscriptions = webhooks.getSubscriptions("slack");
|
|
4263
|
+
const slackHookIds = new Set(subscriptions.map((subscription) => subscription.id));
|
|
4264
|
+
const allDeliveries = webhooks.getDeliveries().filter((delivery) => slackHookIds.has(delivery.hook_id)).sort((a, b) => b.id - a.id);
|
|
4265
|
+
const deliveries = allDeliveries.slice(0, 100);
|
|
4266
|
+
const failed = allDeliveries.filter((delivery) => !delivery.success).slice(0, 100);
|
|
4267
|
+
return [
|
|
4268
|
+
renderSection("Event Subscriptions", renderSubscriptionsTable(subscriptions)),
|
|
4269
|
+
renderSection(
|
|
4270
|
+
"Event Deliveries",
|
|
4271
|
+
renderDeliveriesTable(deliveries, subscriptions, "No Slack event deliveries yet.")
|
|
4272
|
+
),
|
|
4273
|
+
renderSection("Last Errors", renderDeliveriesTable(failed, subscriptions, "No failed Slack event deliveries."))
|
|
4274
|
+
].join("\n");
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
function renderMessagesTable(messages, users, empty) {
|
|
4278
|
+
return renderTable(
|
|
4279
|
+
["Time", "User", "Message", "Reactions", "TS"],
|
|
4280
|
+
messages.map((msg) => {
|
|
4281
|
+
const isBot = msg.subtype === "bot_message";
|
|
4282
|
+
const richBadge = msg.text.length === 0 && ((msg.blocks?.length ?? 0) > 0 || (msg.attachments?.length ?? 0) > 0) ? ` ${badge("rich", "granted")}` : "";
|
|
4283
|
+
const threadBadge = msg.reply_count > 0 ? ` ${badge(`${msg.reply_count} ${msg.reply_count === 1 ? "reply" : "replies"}`, "requested")}` : "";
|
|
4284
|
+
const fileBadge = msg.files?.length ? ` ${badge(`${msg.files.length} ${msg.files.length === 1 ? "file" : "files"}`, "granted")}` : "";
|
|
4285
|
+
const threadIndicator = msg.thread_ts && msg.thread_ts !== msg.ts ? `${badge("thread", "denied")} ` : "";
|
|
4286
|
+
return [
|
|
4287
|
+
escapeHtml(timeAgo(msg.created_at)),
|
|
4288
|
+
`${escapeHtml(userLabel(users, msg.user))}${isBot ? ` ${badge("bot", "granted")}` : ""}`,
|
|
4289
|
+
`${threadIndicator}${escapeHtml(richMessagePreview(msg))}${richBadge}${fileBadge}${threadBadge}`,
|
|
4290
|
+
renderReactionBadges(msg.reactions),
|
|
4291
|
+
escapeHtml(msg.ts)
|
|
4292
|
+
];
|
|
4293
|
+
}),
|
|
4294
|
+
empty
|
|
4295
|
+
);
|
|
4296
|
+
}
|
|
4297
|
+
function renderEphemeralTable(messages, users, empty) {
|
|
4298
|
+
return renderTable(
|
|
4299
|
+
["Time", "Target", "Message", "TS"],
|
|
4300
|
+
messages.map((msg) => [
|
|
4301
|
+
escapeHtml(timeAgo(msg.created_at)),
|
|
4302
|
+
`${escapeHtml(userLabel(users, msg.target_user))} ${badge("ephemeral", "requested")}`,
|
|
4303
|
+
escapeHtml(richMessagePreview(msg)),
|
|
4304
|
+
escapeHtml(msg.ts)
|
|
4305
|
+
]),
|
|
4306
|
+
empty
|
|
4307
|
+
);
|
|
4308
|
+
}
|
|
4309
|
+
function renderScheduledTable(messages, users, empty) {
|
|
4310
|
+
return renderTable(
|
|
4311
|
+
["Post At", "User", "Message", "ID"],
|
|
4312
|
+
messages.map((msg) => [
|
|
4313
|
+
escapeHtml(new Date(msg.post_at * 1e3).toISOString()),
|
|
4314
|
+
escapeHtml(userLabel(users, msg.user)),
|
|
4315
|
+
escapeHtml(richMessagePreview(msg)),
|
|
4316
|
+
escapeHtml(msg.scheduled_message_id)
|
|
4317
|
+
]),
|
|
4318
|
+
empty
|
|
4319
|
+
);
|
|
4320
|
+
}
|
|
4321
|
+
function renderPinsTable(pins, channelMessages, users, empty) {
|
|
4322
|
+
return renderTable(
|
|
4323
|
+
["Created", "Creator", "Message", "TS"],
|
|
4324
|
+
pins.map((pin) => {
|
|
4325
|
+
const message = channelMessages.find((candidate) => candidate.ts === pin.message_ts);
|
|
4326
|
+
return [
|
|
4327
|
+
escapeHtml(new Date(pin.created * 1e3).toISOString()),
|
|
4328
|
+
escapeHtml(userLabel(users, pin.created_by)),
|
|
4329
|
+
escapeHtml(message ? richMessagePreview(message) : pin.message_ts),
|
|
4330
|
+
escapeHtml(pin.message_ts)
|
|
4331
|
+
];
|
|
4332
|
+
}),
|
|
4333
|
+
empty
|
|
4334
|
+
);
|
|
4335
|
+
}
|
|
4336
|
+
function renderBookmarksTable(bookmarks, empty) {
|
|
4337
|
+
return renderTable(
|
|
4338
|
+
["Title", "Type", "Link", "Rank"],
|
|
4339
|
+
bookmarks.map((bookmark) => [
|
|
4340
|
+
escapeHtml(bookmark.title),
|
|
4341
|
+
escapeHtml(bookmark.type),
|
|
4342
|
+
escapeHtml(bookmark.link),
|
|
4343
|
+
escapeHtml(bookmark.rank)
|
|
4344
|
+
]),
|
|
4345
|
+
empty
|
|
4346
|
+
);
|
|
4347
|
+
}
|
|
4348
|
+
function renderViewsTable(views, users, empty) {
|
|
4349
|
+
return renderTable(
|
|
4350
|
+
["ID", "Type", "User", "App", "Preview", "Hash", "Root", "Previous"],
|
|
4351
|
+
views.map((view) => [
|
|
4352
|
+
escapeHtml(view.view_id),
|
|
4353
|
+
view.type === "home" ? badge("app home", "granted") : badge("modal", "requested"),
|
|
4354
|
+
escapeHtml(userLabel(users, view.user_id)),
|
|
4355
|
+
escapeHtml(view.app_id),
|
|
4356
|
+
escapeHtml(viewPreview(view)),
|
|
4357
|
+
escapeHtml(view.hash),
|
|
4358
|
+
escapeHtml(view.root_view_id),
|
|
4359
|
+
escapeHtml(view.previous_view_id ?? "")
|
|
4360
|
+
]),
|
|
4361
|
+
empty
|
|
4362
|
+
);
|
|
4363
|
+
}
|
|
4364
|
+
function renderTriggerTable(triggers, users) {
|
|
4365
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
4366
|
+
return renderTable(
|
|
4367
|
+
["Trigger ID", "User", "App", "View", "Expires", "State"],
|
|
4368
|
+
triggers.map((trigger) => [
|
|
4369
|
+
escapeHtml(trigger.trigger_id),
|
|
4370
|
+
escapeHtml(userLabel(users, trigger.user_id)),
|
|
4371
|
+
escapeHtml(trigger.app_id),
|
|
4372
|
+
escapeHtml(trigger.view_id ?? ""),
|
|
4373
|
+
escapeHtml(new Date(trigger.expires_at * 1e3).toISOString()),
|
|
4374
|
+
trigger.used ? badge("used", "denied") : trigger.expires_at <= now ? badge("expired", "denied") : badge("active", "granted")
|
|
4375
|
+
]),
|
|
4376
|
+
"No local trigger ids have been generated."
|
|
4377
|
+
);
|
|
4378
|
+
}
|
|
4379
|
+
function renderOAuthAppsTable(apps) {
|
|
4380
|
+
return renderTable(
|
|
4381
|
+
["App ID", "Client ID", "Name", "Bot", "Scopes", "User Scopes"],
|
|
4382
|
+
apps.map((app) => [
|
|
4383
|
+
escapeHtml(app.app_id ?? ""),
|
|
4384
|
+
escapeHtml(app.client_id),
|
|
4385
|
+
escapeHtml(app.name),
|
|
4386
|
+
escapeHtml(app.bot_id ?? app.bot_name ?? ""),
|
|
4387
|
+
escapeHtml(scopePreview(app.scopes)),
|
|
4388
|
+
escapeHtml(scopePreview(app.user_scopes))
|
|
4389
|
+
]),
|
|
4390
|
+
"No OAuth apps are configured."
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
function renderInstallationsTable(installations) {
|
|
4394
|
+
return renderTable(
|
|
4395
|
+
["Installation", "App", "Team", "Bot User", "Installer", "Scopes"],
|
|
4396
|
+
installations.map((installation) => [
|
|
4397
|
+
escapeHtml(installation.installation_id),
|
|
4398
|
+
escapeHtml(installation.app_id),
|
|
4399
|
+
escapeHtml(installation.team_id),
|
|
4400
|
+
escapeHtml(installation.bot_user_id),
|
|
4401
|
+
escapeHtml(installation.installer_user_id),
|
|
4402
|
+
escapeHtml(scopePreview(installation.scopes))
|
|
4403
|
+
]),
|
|
4404
|
+
"No OAuth installations have been recorded."
|
|
4405
|
+
);
|
|
4406
|
+
}
|
|
4407
|
+
function renderTokensTable(tokens) {
|
|
4408
|
+
return renderTable(
|
|
4409
|
+
["Token", "Type", "Team", "User", "App", "Bot", "Scopes"],
|
|
4410
|
+
tokens.map((token) => [
|
|
4411
|
+
escapeHtml(maskToken(token.token)),
|
|
4412
|
+
escapeHtml(token.token_type),
|
|
4413
|
+
escapeHtml(token.team_id),
|
|
4414
|
+
escapeHtml(token.user_id),
|
|
4415
|
+
escapeHtml(token.app_id ?? ""),
|
|
4416
|
+
escapeHtml(token.bot_id ?? token.bot_user_id ?? ""),
|
|
4417
|
+
escapeHtml(scopePreview(token.scopes))
|
|
4418
|
+
]),
|
|
4419
|
+
"No Slack token records have been seeded or exchanged."
|
|
4420
|
+
);
|
|
4421
|
+
}
|
|
4422
|
+
function renderIncomingWebhooksTable(webhooks) {
|
|
4423
|
+
return renderTable(
|
|
4424
|
+
["Token", "Team", "Bot", "Default Channel", "Label", "URL"],
|
|
4425
|
+
webhooks.map((webhook) => [
|
|
4426
|
+
escapeHtml(maskToken(webhook.token)),
|
|
4427
|
+
escapeHtml(webhook.team_id),
|
|
4428
|
+
escapeHtml(webhook.bot_id),
|
|
4429
|
+
escapeHtml(webhook.default_channel),
|
|
4430
|
+
escapeHtml(webhook.label),
|
|
4431
|
+
escapeHtml(webhook.url)
|
|
4432
|
+
]),
|
|
4433
|
+
"No incoming webhooks are configured."
|
|
4434
|
+
);
|
|
4435
|
+
}
|
|
4436
|
+
function renderSubscriptionsTable(subscriptions) {
|
|
4437
|
+
return renderTable(
|
|
4438
|
+
["ID", "URL", "Events", "State"],
|
|
4439
|
+
subscriptions.map((subscription) => [
|
|
4440
|
+
escapeHtml(String(subscription.id)),
|
|
4441
|
+
escapeHtml(subscription.url),
|
|
4442
|
+
escapeHtml(subscription.events.join(", ")),
|
|
4443
|
+
subscription.active ? badge("active", "granted") : badge("inactive", "denied")
|
|
4444
|
+
]),
|
|
4445
|
+
"No Slack event subscriptions are registered."
|
|
4446
|
+
);
|
|
4447
|
+
}
|
|
4448
|
+
function renderDeliveriesTable(deliveries, subscriptions, empty) {
|
|
4449
|
+
const subscriptionsById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
|
|
4450
|
+
return renderTable(
|
|
4451
|
+
["ID", "Event", "Hook", "URL", "Status", "Duration", "Delivered"],
|
|
4452
|
+
deliveries.map((delivery) => {
|
|
4453
|
+
const subscription = subscriptionsById.get(delivery.hook_id);
|
|
4454
|
+
return [
|
|
4455
|
+
escapeHtml(String(delivery.id)),
|
|
4456
|
+
escapeHtml(delivery.event),
|
|
4457
|
+
escapeHtml(String(delivery.hook_id)),
|
|
4458
|
+
escapeHtml(subscription?.url ?? ""),
|
|
4459
|
+
delivery.success ? badge(String(delivery.status_code ?? "ok"), "granted") : badge(String(delivery.status_code ?? "failed"), "denied"),
|
|
4460
|
+
escapeHtml(delivery.duration === null ? "" : `${delivery.duration}ms`),
|
|
4461
|
+
escapeHtml(delivery.delivered_at)
|
|
4462
|
+
];
|
|
4463
|
+
}),
|
|
4464
|
+
empty
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
var DEFAULT_SLACK_SCOPES = [
|
|
4468
|
+
"chat:write",
|
|
4469
|
+
"channels:read",
|
|
4470
|
+
"channels:history",
|
|
4471
|
+
"channels:join",
|
|
4472
|
+
"channels:manage",
|
|
4473
|
+
"channels:write",
|
|
4474
|
+
"groups:read",
|
|
4475
|
+
"groups:history",
|
|
4476
|
+
"groups:write",
|
|
4477
|
+
"im:read",
|
|
4478
|
+
"im:history",
|
|
4479
|
+
"im:write",
|
|
4480
|
+
"mpim:read",
|
|
4481
|
+
"mpim:history",
|
|
4482
|
+
"mpim:write",
|
|
4483
|
+
"users:read",
|
|
4484
|
+
"users:read.email",
|
|
4485
|
+
"users.profile:read",
|
|
4486
|
+
"users.profile:write",
|
|
4487
|
+
"users:write",
|
|
4488
|
+
"files:read",
|
|
4489
|
+
"files:write",
|
|
4490
|
+
"pins:read",
|
|
4491
|
+
"pins:write",
|
|
4492
|
+
"bookmarks:read",
|
|
4493
|
+
"bookmarks:write",
|
|
4494
|
+
"reactions:read",
|
|
4495
|
+
"reactions:write",
|
|
4496
|
+
"team:read"
|
|
4497
|
+
];
|
|
4498
|
+
function seedDefaults(store, _baseUrl) {
|
|
4499
|
+
const ss = getSlackStore(store);
|
|
4500
|
+
const teamId = "T000000001";
|
|
4501
|
+
ss.teams.insert({
|
|
4502
|
+
team_id: teamId,
|
|
4503
|
+
name: "Emulate",
|
|
4504
|
+
domain: "emulate"
|
|
4505
|
+
});
|
|
4506
|
+
const userId = "U000000001";
|
|
4507
|
+
ss.users.insert({
|
|
4508
|
+
user_id: userId,
|
|
4509
|
+
team_id: teamId,
|
|
4510
|
+
name: "admin",
|
|
4511
|
+
real_name: "Admin User",
|
|
4512
|
+
email: "admin@emulate.dev",
|
|
4513
|
+
is_admin: true,
|
|
4514
|
+
is_bot: false,
|
|
4515
|
+
deleted: false,
|
|
4516
|
+
profile: {
|
|
4517
|
+
display_name: "admin",
|
|
4518
|
+
real_name: "Admin User",
|
|
4519
|
+
email: "admin@emulate.dev",
|
|
4520
|
+
image_48: "",
|
|
4521
|
+
image_192: "",
|
|
4522
|
+
real_name_normalized: "Admin User",
|
|
4523
|
+
display_name_normalized: "admin",
|
|
4524
|
+
status_text: "",
|
|
4525
|
+
status_emoji: "",
|
|
4526
|
+
status_emoji_display_info: [],
|
|
4527
|
+
status_expiration: 0
|
|
4528
|
+
},
|
|
4529
|
+
presence: "active",
|
|
4530
|
+
manual_presence: "auto",
|
|
4531
|
+
connection_count: 1,
|
|
4532
|
+
last_activity: Math.floor(Date.now() / 1e3)
|
|
4533
|
+
});
|
|
4534
|
+
ss.channels.insert({
|
|
4535
|
+
channel_id: "C000000001",
|
|
4536
|
+
team_id: teamId,
|
|
4537
|
+
name: "general",
|
|
4538
|
+
is_channel: true,
|
|
4539
|
+
is_private: false,
|
|
4540
|
+
is_archived: false,
|
|
4541
|
+
topic: { value: "General discussion", creator: userId, last_set: Math.floor(Date.now() / 1e3) },
|
|
4542
|
+
purpose: { value: "A place for general discussion", creator: userId, last_set: Math.floor(Date.now() / 1e3) },
|
|
4543
|
+
members: [userId],
|
|
4544
|
+
creator: userId,
|
|
4545
|
+
num_members: 1
|
|
4546
|
+
});
|
|
4547
|
+
ss.channels.insert({
|
|
4548
|
+
channel_id: "C000000002",
|
|
4549
|
+
team_id: teamId,
|
|
4550
|
+
name: "random",
|
|
4551
|
+
is_channel: true,
|
|
4552
|
+
is_private: false,
|
|
4553
|
+
is_archived: false,
|
|
4554
|
+
topic: { value: "Random stuff", creator: userId, last_set: Math.floor(Date.now() / 1e3) },
|
|
4555
|
+
purpose: {
|
|
4556
|
+
value: "A place for non-work-related chatter",
|
|
4557
|
+
creator: userId,
|
|
4558
|
+
last_set: Math.floor(Date.now() / 1e3)
|
|
4559
|
+
},
|
|
4560
|
+
members: [userId],
|
|
4561
|
+
creator: userId,
|
|
4562
|
+
num_members: 1
|
|
4563
|
+
});
|
|
4564
|
+
ss.incomingWebhooks.insert({
|
|
4565
|
+
token: "X000000001",
|
|
4566
|
+
team_id: teamId,
|
|
4567
|
+
bot_id: "B000000001",
|
|
4568
|
+
default_channel: "general",
|
|
4569
|
+
label: "Default Webhook",
|
|
4570
|
+
url: `/services/${teamId}/B000000001/X000000001`
|
|
4571
|
+
});
|
|
4572
|
+
}
|
|
4573
|
+
function seedFromConfig(store, _baseUrl, config) {
|
|
4574
|
+
const ss = getSlackStore(store);
|
|
4575
|
+
if (config.team) {
|
|
4576
|
+
const existing = ss.teams.all()[0];
|
|
4577
|
+
if (existing) {
|
|
4578
|
+
ss.teams.update(existing.id, {
|
|
4579
|
+
name: config.team.name ?? existing.name,
|
|
4580
|
+
domain: config.team.domain ?? existing.domain
|
|
4581
|
+
});
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
const team = ss.teams.all()[0];
|
|
4585
|
+
const teamId = team?.team_id ?? "T000000001";
|
|
4586
|
+
if (config.users) {
|
|
4587
|
+
for (const u of config.users) {
|
|
4588
|
+
const existing = ss.users.all().find((eu) => eu.name === u.name);
|
|
4589
|
+
if (existing) continue;
|
|
4590
|
+
const userId = generateSlackId("U");
|
|
4591
|
+
const email = u.profile?.email ?? u.email ?? `${u.name}@emulate.dev`;
|
|
4592
|
+
const realName = u.real_name ?? u.name;
|
|
4593
|
+
const profile = normalizeSeedProfile({
|
|
4594
|
+
display_name: u.name,
|
|
4595
|
+
real_name: realName,
|
|
4596
|
+
email,
|
|
4597
|
+
image_48: "",
|
|
4598
|
+
image_192: "",
|
|
4599
|
+
...u.profile
|
|
4600
|
+
});
|
|
4601
|
+
ss.users.insert({
|
|
4602
|
+
user_id: userId,
|
|
4603
|
+
team_id: teamId,
|
|
4604
|
+
name: u.name,
|
|
4605
|
+
real_name: profile.real_name,
|
|
4606
|
+
email: profile.email,
|
|
4607
|
+
is_admin: u.is_admin ?? false,
|
|
4608
|
+
is_bot: false,
|
|
4609
|
+
deleted: false,
|
|
4610
|
+
profile,
|
|
4611
|
+
presence: u.presence ?? "active",
|
|
4612
|
+
manual_presence: u.presence === "away" ? "away" : "auto",
|
|
4613
|
+
connection_count: u.presence === "away" ? 0 : 1,
|
|
4614
|
+
last_activity: u.presence === "away" ? void 0 : Math.floor(Date.now() / 1e3)
|
|
4615
|
+
});
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
if (config.channels) {
|
|
4619
|
+
for (const ch of config.channels) {
|
|
4620
|
+
const existing = ss.channels.findOneBy("name", ch.name);
|
|
4621
|
+
if (existing) continue;
|
|
4622
|
+
const creator = ss.users.all()[0]?.user_id ?? "U000000001";
|
|
4623
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
4624
|
+
const isPrivate = ch.is_private ?? false;
|
|
4625
|
+
ss.channels.insert({
|
|
4626
|
+
channel_id: generateSlackId("C"),
|
|
4627
|
+
team_id: teamId,
|
|
4628
|
+
name: ch.name,
|
|
4629
|
+
is_channel: !isPrivate,
|
|
4630
|
+
is_private: isPrivate,
|
|
4631
|
+
is_archived: false,
|
|
4632
|
+
topic: { value: ch.topic ?? "", creator, last_set: now },
|
|
4633
|
+
purpose: { value: ch.purpose ?? "", creator, last_set: now },
|
|
4634
|
+
members: ss.users.all().map((u) => u.user_id),
|
|
4635
|
+
creator,
|
|
4636
|
+
num_members: ss.users.all().length
|
|
4637
|
+
});
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
if (config.bots) {
|
|
4641
|
+
for (const b of config.bots) {
|
|
4642
|
+
const existing = ss.bots.all().find((eb) => eb.name === b.name);
|
|
4643
|
+
if (existing) continue;
|
|
4644
|
+
ss.bots.insert({
|
|
4645
|
+
bot_id: generateSlackId("B"),
|
|
4646
|
+
name: b.name,
|
|
4647
|
+
deleted: false,
|
|
4648
|
+
icons: { image_48: "" }
|
|
4649
|
+
});
|
|
4650
|
+
}
|
|
4651
|
+
}
|
|
4652
|
+
if (config.oauth_apps) {
|
|
4653
|
+
for (const oa of config.oauth_apps) {
|
|
4654
|
+
const existing = ss.oauthApps.findOneBy("client_id", oa.client_id);
|
|
4655
|
+
if (existing) {
|
|
4656
|
+
if (!existing.app_id) {
|
|
4657
|
+
ss.oauthApps.update(existing.id, { app_id: oa.app_id ?? generateSlackId("A") });
|
|
4658
|
+
}
|
|
4659
|
+
continue;
|
|
4660
|
+
}
|
|
4661
|
+
ss.oauthApps.insert({
|
|
4662
|
+
app_id: oa.app_id ?? generateSlackId("A"),
|
|
4663
|
+
client_id: oa.client_id,
|
|
4664
|
+
client_secret: oa.client_secret,
|
|
4665
|
+
name: oa.name,
|
|
4666
|
+
redirect_uris: oa.redirect_uris,
|
|
4667
|
+
scopes: normalizeScopes2(oa.scopes),
|
|
4668
|
+
user_scopes: normalizeScopes2(oa.user_scopes),
|
|
4669
|
+
bot_id: oa.bot_id,
|
|
4670
|
+
bot_user_id: oa.bot_user_id,
|
|
4671
|
+
bot_name: oa.bot_name
|
|
4672
|
+
});
|
|
4673
|
+
}
|
|
4674
|
+
const installer = ss.users.all().find((user) => !user.deleted && !user.is_bot) ?? ss.users.all()[0];
|
|
4675
|
+
for (const appRecord of ss.oauthApps.all()) {
|
|
4676
|
+
seedOAuthInstallation(ss, teamId, installer?.user_id ?? "U000000001", appRecord);
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
if (config.tokens) {
|
|
4680
|
+
for (const token of config.tokens) {
|
|
4681
|
+
const value = token.token.trim();
|
|
4682
|
+
if (!value || ss.tokens.findOneBy("token", value)) continue;
|
|
4683
|
+
const userId = resolveSeedTokenUserId(ss, token.user_id ?? token.user) ?? ss.users.all()[0]?.user_id ?? "U000000001";
|
|
4684
|
+
ss.tokens.insert({
|
|
4685
|
+
token: value,
|
|
4686
|
+
token_type: token.type ?? "test",
|
|
4687
|
+
team_id: token.team_id ?? teamId,
|
|
4688
|
+
user_id: userId,
|
|
4689
|
+
scopes: normalizeScopes2(token.scopes, DEFAULT_SLACK_SCOPES),
|
|
4690
|
+
app_id: token.app_id,
|
|
4691
|
+
client_id: token.client_id,
|
|
4692
|
+
bot_id: token.bot_id,
|
|
4693
|
+
bot_user_id: token.bot_user_id,
|
|
4694
|
+
authed_user_id: token.authed_user_id
|
|
4695
|
+
});
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
if (config.incoming_webhooks) {
|
|
4699
|
+
const firstBot = ss.bots.all()[0];
|
|
4700
|
+
const botId = firstBot?.bot_id ?? "B000000001";
|
|
4701
|
+
for (const wh of config.incoming_webhooks) {
|
|
4702
|
+
const token = generateSlackId("X");
|
|
4703
|
+
ss.incomingWebhooks.insert({
|
|
4704
|
+
token,
|
|
4705
|
+
team_id: teamId,
|
|
4706
|
+
bot_id: botId,
|
|
4707
|
+
default_channel: wh.channel,
|
|
4708
|
+
label: wh.label ?? wh.channel,
|
|
4709
|
+
url: `/services/${teamId}/${botId}/${token}`
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
if (config.signing_secret) {
|
|
4714
|
+
store.setData("slack.signing_secret", config.signing_secret);
|
|
4715
|
+
}
|
|
4716
|
+
if (config.strict_scopes !== void 0) {
|
|
4717
|
+
store.setData("slack.strict_scopes", config.strict_scopes);
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
var slackPlugin = {
|
|
4721
|
+
name: "slack",
|
|
4722
|
+
register(app, store, webhooks, baseUrl, tokenMap) {
|
|
4723
|
+
app.use("*", async (c, next) => {
|
|
4724
|
+
applySlackTokenAuth(c, store);
|
|
4725
|
+
await next();
|
|
4726
|
+
});
|
|
4727
|
+
const ctx = { app, store, webhooks, baseUrl, tokenMap };
|
|
4728
|
+
authRoutes(ctx);
|
|
4729
|
+
chatRoutes(ctx);
|
|
4730
|
+
conversationsRoutes(ctx);
|
|
4731
|
+
usersRoutes(ctx);
|
|
4732
|
+
reactionsRoutes(ctx);
|
|
4733
|
+
teamRoutes(ctx);
|
|
4734
|
+
oauthRoutes(ctx);
|
|
4735
|
+
webhookRoutes(ctx);
|
|
4736
|
+
filesRoutes(ctx);
|
|
4737
|
+
pinsRoutes(ctx);
|
|
4738
|
+
bookmarksRoutes(ctx);
|
|
4739
|
+
viewsRoutes(ctx);
|
|
4740
|
+
inspectorRoutes(ctx);
|
|
4741
|
+
},
|
|
4742
|
+
seed(store, baseUrl) {
|
|
4743
|
+
seedDefaults(store, baseUrl);
|
|
4744
|
+
}
|
|
4745
|
+
};
|
|
4746
|
+
var index_default = slackPlugin;
|
|
4747
|
+
function normalizeScopes2(value, fallback = []) {
|
|
4748
|
+
if (Array.isArray(value)) return value.map((scope) => scope.trim()).filter(Boolean);
|
|
4749
|
+
if (typeof value === "string") {
|
|
4750
|
+
return value.split(/[,\s]+/).map((scope) => scope.trim()).filter(Boolean);
|
|
4751
|
+
}
|
|
4752
|
+
return [...fallback];
|
|
4753
|
+
}
|
|
4754
|
+
function applySlackTokenAuth(c, store) {
|
|
4755
|
+
const token = slackRequestToken(c);
|
|
4756
|
+
if (!token) return;
|
|
4757
|
+
const record = getSlackStore(store).tokens.findOneBy("token", token);
|
|
4758
|
+
if (!record) return;
|
|
4759
|
+
c.set("authToken", record.token);
|
|
4760
|
+
c.set("authScopes", record.scopes);
|
|
4761
|
+
c.set("authUser", {
|
|
4762
|
+
login: record.user_id,
|
|
4763
|
+
id: record.id,
|
|
4764
|
+
scopes: record.scopes
|
|
4765
|
+
});
|
|
4766
|
+
}
|
|
4767
|
+
function slackRequestToken(c) {
|
|
4768
|
+
const authHeader = c.req.header("Authorization");
|
|
4769
|
+
if (!authHeader) return void 0;
|
|
4770
|
+
const token = authHeader.replace(/^(Bearer|token)\s+/i, "").trim();
|
|
4771
|
+
return token || void 0;
|
|
4772
|
+
}
|
|
4773
|
+
function seedOAuthInstallation(ss, teamId, installerUserId, app) {
|
|
4774
|
+
const appId = app.app_id ?? generateSlackId("A");
|
|
4775
|
+
if (!app.app_id) ss.oauthApps.update(app.id, { app_id: appId });
|
|
4776
|
+
const botName = app.bot_name ?? slugifySlackBotName(app.name);
|
|
4777
|
+
const existingBot = (app.bot_id ? ss.bots.findOneBy("bot_id", app.bot_id) : void 0) ?? ss.bots.all().find((bot2) => bot2.name === botName);
|
|
4778
|
+
const botId = app.bot_id ?? existingBot?.bot_id ?? generateSlackId("B");
|
|
4779
|
+
const botUserId = app.bot_user_id ?? existingBot?.user_id ?? generateSlackId("U");
|
|
4780
|
+
const bot = existingBot ?? ss.bots.insert({
|
|
4781
|
+
bot_id: botId,
|
|
4782
|
+
app_id: appId,
|
|
4783
|
+
user_id: botUserId,
|
|
4784
|
+
name: botName,
|
|
4785
|
+
deleted: false,
|
|
4786
|
+
icons: { image_48: "" }
|
|
4787
|
+
});
|
|
4788
|
+
if (bot.app_id !== appId || bot.user_id !== botUserId) {
|
|
4789
|
+
ss.bots.update(bot.id, { app_id: appId, user_id: botUserId });
|
|
4790
|
+
}
|
|
4791
|
+
if (!app.bot_id || !app.bot_user_id || !app.bot_name) {
|
|
4792
|
+
ss.oauthApps.update(app.id, {
|
|
4793
|
+
bot_id: botId,
|
|
4794
|
+
bot_user_id: botUserId,
|
|
4795
|
+
bot_name: botName
|
|
4796
|
+
});
|
|
4797
|
+
}
|
|
4798
|
+
if (!ss.users.findOneBy("user_id", botUserId)) {
|
|
4799
|
+
ss.users.insert({
|
|
4800
|
+
user_id: botUserId,
|
|
4801
|
+
team_id: teamId,
|
|
4802
|
+
name: botName,
|
|
4803
|
+
real_name: app.name,
|
|
4804
|
+
email: `${botName}@bots.emulate.dev`,
|
|
4805
|
+
is_admin: false,
|
|
4806
|
+
is_bot: true,
|
|
4807
|
+
deleted: false,
|
|
4808
|
+
profile: {
|
|
4809
|
+
display_name: botName,
|
|
4810
|
+
real_name: app.name,
|
|
4811
|
+
email: `${botName}@bots.emulate.dev`,
|
|
4812
|
+
image_48: "",
|
|
4813
|
+
image_192: "",
|
|
4814
|
+
real_name_normalized: app.name,
|
|
4815
|
+
display_name_normalized: botName,
|
|
4816
|
+
status_text: "",
|
|
4817
|
+
status_emoji: "",
|
|
4818
|
+
status_emoji_display_info: [],
|
|
4819
|
+
status_expiration: 0
|
|
4820
|
+
},
|
|
4821
|
+
presence: "active",
|
|
4822
|
+
manual_presence: "auto",
|
|
4823
|
+
connection_count: 1,
|
|
4824
|
+
last_activity: Math.floor(Date.now() / 1e3)
|
|
4825
|
+
});
|
|
4826
|
+
}
|
|
4827
|
+
const existingInstallation = ss.installations.all().find((installation) => installation.app_id === appId && installation.team_id === teamId);
|
|
4828
|
+
const data = {
|
|
4829
|
+
app_id: appId,
|
|
4830
|
+
client_id: app.client_id,
|
|
4831
|
+
team_id: teamId,
|
|
4832
|
+
app_name: app.name,
|
|
4833
|
+
installer_user_id: installerUserId,
|
|
4834
|
+
bot_id: botId,
|
|
4835
|
+
bot_user_id: botUserId,
|
|
4836
|
+
scopes: app.scopes ?? [],
|
|
4837
|
+
user_scopes: app.user_scopes ?? []
|
|
4838
|
+
};
|
|
4839
|
+
if (existingInstallation) {
|
|
4840
|
+
ss.installations.update(existingInstallation.id, data);
|
|
4841
|
+
} else {
|
|
4842
|
+
ss.installations.insert({
|
|
4843
|
+
installation_id: generateSlackId("I"),
|
|
4844
|
+
...data
|
|
4845
|
+
});
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
function resolveSeedTokenUserId(ss, userRef) {
|
|
4849
|
+
if (!userRef) return void 0;
|
|
4850
|
+
return ss.users.findOneBy("user_id", userRef)?.user_id ?? ss.users.findOneBy("name", userRef)?.user_id ?? userRef;
|
|
4851
|
+
}
|
|
4852
|
+
function slugifySlackBotName(value) {
|
|
4853
|
+
const slug = value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4854
|
+
return slug || "slack-app";
|
|
4855
|
+
}
|
|
4856
|
+
function normalizeSeedProfile(profile) {
|
|
4857
|
+
return {
|
|
4858
|
+
...profile,
|
|
4859
|
+
real_name_normalized: profile.real_name_normalized ?? profile.real_name,
|
|
4860
|
+
display_name_normalized: profile.display_name_normalized ?? profile.display_name,
|
|
4861
|
+
status_text: profile.status_text ?? "",
|
|
4862
|
+
status_emoji: profile.status_emoji ?? "",
|
|
4863
|
+
status_emoji_display_info: profile.status_emoji_display_info ?? [],
|
|
4864
|
+
status_expiration: profile.status_expiration ?? 0
|
|
4865
|
+
};
|
|
4866
|
+
}
|
|
4867
|
+
export {
|
|
4868
|
+
index_default as default,
|
|
4869
|
+
getSlackStore,
|
|
4870
|
+
normalizeScopes2 as normalizeScopes,
|
|
4871
|
+
seedFromConfig,
|
|
4872
|
+
slackPlugin
|
|
4873
|
+
};
|
|
4874
|
+
//# sourceMappingURL=dist-OGSAVJ25.js.map
|