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