@emulators/slack 0.4.1 → 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/fonts/favicon.ico +0 -0
- package/dist/index.d.ts +294 -8
- package/dist/index.js +3885 -290
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
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,79 +510,308 @@ 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
|
|
107
517
|
});
|
|
108
518
|
}
|
|
109
519
|
}
|
|
110
|
-
await webhooks.dispatch(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
520
|
+
await webhooks.dispatch(
|
|
521
|
+
"message",
|
|
522
|
+
void 0,
|
|
523
|
+
{
|
|
524
|
+
type: "event_callback",
|
|
525
|
+
event: {
|
|
526
|
+
...formatSlackMessage(msg),
|
|
527
|
+
type: "message",
|
|
528
|
+
channel: ch.channel_id
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
"slack"
|
|
532
|
+
);
|
|
121
533
|
return slackOk(c, {
|
|
122
534
|
channel: ch.channel_id,
|
|
123
535
|
ts,
|
|
124
|
-
message:
|
|
125
|
-
text: msg.text,
|
|
126
|
-
user: msg.user,
|
|
127
|
-
type: msg.type,
|
|
128
|
-
ts: msg.ts,
|
|
129
|
-
thread_ts: msg.thread_ts
|
|
130
|
-
}
|
|
536
|
+
message: formatSlackMessage(msg)
|
|
131
537
|
});
|
|
132
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: []
|
|
575
|
+
});
|
|
576
|
+
return slackOk(c, { message_ts: ts });
|
|
577
|
+
});
|
|
133
578
|
app.post("/api/chat.update", async (c) => {
|
|
134
579
|
const authUser = c.get("authUser");
|
|
135
580
|
if (!authUser) return slackError(c, "not_authed");
|
|
581
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
582
|
+
if (scopeError) return scopeError;
|
|
136
583
|
const body = await parseSlackBody(c);
|
|
137
584
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
138
585
|
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
139
|
-
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);
|
|
140
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");
|
|
141
593
|
const msg = ss().messages.all().find((m) => m.ts === ts && m.channel_id === channel);
|
|
142
594
|
if (!msg) return slackError(c, "message_not_found");
|
|
143
|
-
|
|
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
|
+
);
|
|
144
629
|
return slackOk(c, {
|
|
145
630
|
channel,
|
|
146
631
|
ts,
|
|
147
|
-
text
|
|
632
|
+
text: updated.text,
|
|
633
|
+
message: formatSlackMessage(updated)
|
|
148
634
|
});
|
|
149
635
|
});
|
|
150
636
|
app.post("/api/chat.delete", async (c) => {
|
|
151
637
|
const authUser = c.get("authUser");
|
|
152
638
|
if (!authUser) return slackError(c, "not_authed");
|
|
639
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
640
|
+
if (scopeError) return scopeError;
|
|
153
641
|
const body = await parseSlackBody(c);
|
|
154
642
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
155
643
|
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
156
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");
|
|
157
647
|
const msg = ss().messages.all().find((m) => m.ts === ts && m.channel_id === channel);
|
|
158
648
|
if (!msg) return slackError(c, "message_not_found");
|
|
649
|
+
if (!isAuthoredByUser(msg, authUser)) return slackError(c, "cant_delete_message");
|
|
159
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
|
+
);
|
|
160
671
|
return slackOk(c, { channel, ts });
|
|
161
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
|
+
});
|
|
162
796
|
app.post("/api/chat.meMessage", async (c) => {
|
|
163
797
|
const authUser = c.get("authUser");
|
|
164
798
|
if (!authUser) return slackError(c, "not_authed");
|
|
799
|
+
const scopeError = requireSlackScopes(c, store, ["chat:write"]);
|
|
800
|
+
if (scopeError) return scopeError;
|
|
165
801
|
const body = await parseSlackBody(c);
|
|
166
802
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
167
803
|
const text = typeof body.text === "string" ? body.text : "";
|
|
168
804
|
if (!channel) return slackError(c, "channel_not_found");
|
|
169
|
-
const ch =
|
|
805
|
+
const ch = findChannel(channel);
|
|
170
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);
|
|
171
810
|
const ts = generateTs();
|
|
172
811
|
ss().messages.insert({
|
|
173
812
|
ts,
|
|
174
813
|
channel_id: ch.channel_id,
|
|
175
|
-
user:
|
|
814
|
+
user: authUserId,
|
|
176
815
|
text,
|
|
177
816
|
type: "message",
|
|
178
817
|
subtype: "me_message",
|
|
@@ -183,18 +822,138 @@ function chatRoutes(ctx) {
|
|
|
183
822
|
return slackOk(c, { channel: ch.channel_id, ts });
|
|
184
823
|
});
|
|
185
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
|
+
}
|
|
186
847
|
|
|
187
848
|
// src/routes/conversations.ts
|
|
188
849
|
function conversationsRoutes(ctx) {
|
|
189
|
-
const { app, store } = ctx;
|
|
850
|
+
const { app, store, webhooks } = ctx;
|
|
190
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
|
+
};
|
|
191
944
|
app.post("/api/conversations.list", async (c) => {
|
|
192
945
|
const authUser = c.get("authUser");
|
|
193
946
|
if (!authUser) return slackError(c, "not_authed");
|
|
194
947
|
const body = await parseSlackBody(c);
|
|
195
948
|
const limit = Math.min(Number(body.limit) || 100, 1e3);
|
|
196
949
|
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
197
|
-
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);
|
|
198
957
|
let startIndex = 0;
|
|
199
958
|
if (cursor) {
|
|
200
959
|
const idx = allChannels.findIndex((ch) => ch.channel_id === cursor);
|
|
@@ -203,7 +962,7 @@ function conversationsRoutes(ctx) {
|
|
|
203
962
|
const page = allChannels.slice(startIndex, startIndex + limit);
|
|
204
963
|
const nextCursor = startIndex + limit < allChannels.length ? allChannels[startIndex + limit].channel_id : "";
|
|
205
964
|
return slackOk(c, {
|
|
206
|
-
channels: page.map(formatChannel),
|
|
965
|
+
channels: page.map((ch) => formatChannel(ch, authUserId, authSlackUser?.name)),
|
|
207
966
|
response_metadata: { next_cursor: nextCursor }
|
|
208
967
|
});
|
|
209
968
|
});
|
|
@@ -214,20 +973,33 @@ function conversationsRoutes(ctx) {
|
|
|
214
973
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
215
974
|
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
216
975
|
if (!ch) return slackError(c, "channel_not_found");
|
|
217
|
-
|
|
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) });
|
|
218
982
|
});
|
|
219
983
|
app.post("/api/conversations.create", async (c) => {
|
|
220
984
|
const authUser = c.get("authUser");
|
|
221
985
|
if (!authUser) return slackError(c, "not_authed");
|
|
222
986
|
const body = await parseSlackBody(c);
|
|
223
|
-
const name = typeof body.name === "string" ? body.name : "";
|
|
987
|
+
const name = normalizeChannelName(typeof body.name === "string" ? body.name : "");
|
|
224
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;
|
|
225
993
|
if (!name) return slackError(c, "invalid_name_specials");
|
|
226
|
-
const
|
|
994
|
+
const nameError = validateChannelName(name);
|
|
995
|
+
if (nameError) return slackError(c, nameError);
|
|
996
|
+
const existing = findNamedChannel(ss().channels.all(), name);
|
|
227
997
|
if (existing) return slackError(c, "name_taken");
|
|
228
998
|
const team = ss().teams.all()[0];
|
|
229
999
|
const channelId = generateSlackId("C");
|
|
230
1000
|
const now = Math.floor(Date.now() / 1e3);
|
|
1001
|
+
const authSlackUser = getAuthSlackUser(authUser);
|
|
1002
|
+
const authUserId = getAuthUserId(authUser);
|
|
231
1003
|
const ch = ss().channels.insert({
|
|
232
1004
|
channel_id: channelId,
|
|
233
1005
|
team_id: team?.team_id ?? "T000000001",
|
|
@@ -236,12 +1008,170 @@ function conversationsRoutes(ctx) {
|
|
|
236
1008
|
is_private: isPrivate,
|
|
237
1009
|
is_archived: false,
|
|
238
1010
|
topic: { value: "", creator: "", last_set: 0 },
|
|
239
|
-
purpose: { value: "", creator:
|
|
240
|
-
members: [
|
|
241
|
-
creator:
|
|
1011
|
+
purpose: { value: "", creator: authUserId, last_set: now },
|
|
1012
|
+
members: [authUserId],
|
|
1013
|
+
creator: authUserId,
|
|
242
1014
|
num_members: 1
|
|
243
1015
|
});
|
|
244
|
-
return slackOk(c, { channel: formatChannel(ch) });
|
|
1016
|
+
return slackOk(c, { channel: formatChannel(ch, authUserId, authSlackUser?.name) });
|
|
1017
|
+
});
|
|
1018
|
+
app.post("/api/conversations.archive", async (c) => {
|
|
1019
|
+
const authUser = c.get("authUser");
|
|
1020
|
+
if (!authUser) return slackError(c, "not_authed");
|
|
1021
|
+
const body = await parseSlackBody(c);
|
|
1022
|
+
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
1023
|
+
if (!channel) return slackError(c, "channel_not_found");
|
|
1024
|
+
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
1025
|
+
if (!ch) return slackError(c, "channel_not_found");
|
|
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) });
|
|
245
1175
|
});
|
|
246
1176
|
app.post("/api/conversations.history", async (c) => {
|
|
247
1177
|
const authUser = c.get("authUser");
|
|
@@ -253,6 +1183,11 @@ function conversationsRoutes(ctx) {
|
|
|
253
1183
|
if (!channel) return slackError(c, "channel_not_found");
|
|
254
1184
|
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
255
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");
|
|
256
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);
|
|
257
1192
|
let startIndex = 0;
|
|
258
1193
|
if (cursor) {
|
|
@@ -263,7 +1198,7 @@ function conversationsRoutes(ctx) {
|
|
|
263
1198
|
const hasMore = startIndex + limit < allMessages.length;
|
|
264
1199
|
const nextCursor = hasMore ? allMessages[startIndex + limit].ts : "";
|
|
265
1200
|
return slackOk(c, {
|
|
266
|
-
messages: page.map(
|
|
1201
|
+
messages: page.map((message) => formatSlackMessageForAuth(message, authUser)),
|
|
267
1202
|
has_more: hasMore,
|
|
268
1203
|
response_metadata: { next_cursor: nextCursor }
|
|
269
1204
|
});
|
|
@@ -275,9 +1210,16 @@ function conversationsRoutes(ctx) {
|
|
|
275
1210
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
276
1211
|
const ts = typeof body.ts === "string" ? body.ts : "";
|
|
277
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");
|
|
278
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);
|
|
279
1221
|
return slackOk(c, {
|
|
280
|
-
messages: allMessages.map(
|
|
1222
|
+
messages: allMessages.map((message) => formatSlackMessageForAuth(message, authUser)),
|
|
281
1223
|
has_more: false
|
|
282
1224
|
});
|
|
283
1225
|
});
|
|
@@ -288,14 +1230,25 @@ function conversationsRoutes(ctx) {
|
|
|
288
1230
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
289
1231
|
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
290
1232
|
if (!ch) return slackError(c, "channel_not_found");
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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],
|
|
294
1246
|
num_members: ch.num_members + 1
|
|
295
1247
|
});
|
|
1248
|
+
await dispatchMemberJoined(updated2, authUserId);
|
|
296
1249
|
}
|
|
297
1250
|
const updated = ss().channels.findOneBy("channel_id", channel);
|
|
298
|
-
return slackOk(c, { channel: formatChannel(updated) });
|
|
1251
|
+
return slackOk(c, { channel: formatChannel(updated, authUserId, authSlackUser?.name) });
|
|
299
1252
|
});
|
|
300
1253
|
app.post("/api/conversations.leave", async (c) => {
|
|
301
1254
|
const authUser = c.get("authUser");
|
|
@@ -304,12 +1257,217 @@ function conversationsRoutes(ctx) {
|
|
|
304
1257
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
305
1258
|
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
306
1259
|
if (!ch) return slackError(c, "channel_not_found");
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 }
|
|
311
1377
|
});
|
|
312
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 }
|
|
1401
|
+
});
|
|
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 });
|
|
313
1471
|
return slackOk(c, {});
|
|
314
1472
|
});
|
|
315
1473
|
app.post("/api/conversations.members", async (c) => {
|
|
@@ -319,46 +1477,152 @@ function conversationsRoutes(ctx) {
|
|
|
319
1477
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
320
1478
|
const ch = ss().channels.findOneBy("channel_id", channel);
|
|
321
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");
|
|
322
1485
|
return slackOk(c, {
|
|
323
1486
|
members: ch.members,
|
|
324
1487
|
response_metadata: { next_cursor: "" }
|
|
325
1488
|
});
|
|
326
1489
|
});
|
|
327
1490
|
}
|
|
328
|
-
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;
|
|
329
1494
|
return {
|
|
330
1495
|
id: ch.channel_id,
|
|
331
1496
|
name: ch.name,
|
|
1497
|
+
name_normalized: ch.name,
|
|
332
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,
|
|
333
1502
|
is_private: ch.is_private,
|
|
334
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,
|
|
335
1508
|
topic: ch.topic,
|
|
336
1509
|
purpose: ch.purpose,
|
|
337
1510
|
creator: ch.creator,
|
|
338
1511
|
num_members: ch.num_members,
|
|
339
|
-
created:
|
|
1512
|
+
created: createdSeconds(ch)
|
|
340
1513
|
};
|
|
341
1514
|
}
|
|
342
|
-
function
|
|
343
|
-
return
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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";
|
|
353
1613
|
}
|
|
354
1614
|
|
|
355
1615
|
// src/routes/users.ts
|
|
356
1616
|
function usersRoutes(ctx) {
|
|
357
|
-
const { app, store } = ctx;
|
|
1617
|
+
const { app, store, webhooks } = ctx;
|
|
358
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;
|
|
359
1621
|
app.post("/api/users.list", async (c) => {
|
|
360
1622
|
const authUser = c.get("authUser");
|
|
361
1623
|
if (!authUser) return slackError(c, "not_authed");
|
|
1624
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1625
|
+
if (scopeError) return scopeError;
|
|
362
1626
|
const body = await parseSlackBody(c);
|
|
363
1627
|
const limit = Math.min(Number(body.limit) || 100, 1e3);
|
|
364
1628
|
const cursor = typeof body.cursor === "string" ? body.cursor : "";
|
|
@@ -371,31 +1635,146 @@ function usersRoutes(ctx) {
|
|
|
371
1635
|
const page = allUsers.slice(startIndex, startIndex + limit);
|
|
372
1636
|
const nextCursor = startIndex + limit < allUsers.length ? allUsers[startIndex + limit].user_id : "";
|
|
373
1637
|
return slackOk(c, {
|
|
374
|
-
members: page.map(formatUser),
|
|
1638
|
+
members: page.map((user) => formatUser(user, canExposeEmail(c))),
|
|
375
1639
|
response_metadata: { next_cursor: nextCursor }
|
|
376
1640
|
});
|
|
377
1641
|
});
|
|
378
1642
|
app.post("/api/users.info", async (c) => {
|
|
379
1643
|
const authUser = c.get("authUser");
|
|
380
1644
|
if (!authUser) return slackError(c, "not_authed");
|
|
1645
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
1646
|
+
if (scopeError) return scopeError;
|
|
381
1647
|
const body = await parseSlackBody(c);
|
|
382
1648
|
const userId = typeof body.user === "string" ? body.user : "";
|
|
383
1649
|
const user = ss().users.findOneBy("user_id", userId);
|
|
384
1650
|
if (!user) return slackError(c, "user_not_found");
|
|
385
|
-
return slackOk(c, { user: formatUser(user) });
|
|
1651
|
+
return slackOk(c, { user: formatUser(user, canExposeEmail(c)) });
|
|
386
1652
|
});
|
|
387
1653
|
app.post("/api/users.lookupByEmail", async (c) => {
|
|
388
1654
|
const authUser = c.get("authUser");
|
|
389
1655
|
if (!authUser) return slackError(c, "not_authed");
|
|
1656
|
+
const scopeError = requireSlackScopes(c, store, ["users:read.email"]);
|
|
1657
|
+
if (scopeError) return scopeError;
|
|
390
1658
|
const body = await parseSlackBody(c);
|
|
391
1659
|
const email = typeof body.email === "string" ? body.email : "";
|
|
392
1660
|
if (!email) return slackError(c, "users_not_found");
|
|
393
1661
|
const user = ss().users.findOneBy("email", email);
|
|
394
1662
|
if (!user) return slackError(c, "users_not_found");
|
|
395
|
-
return slackOk(c, { user: formatUser(user) });
|
|
1663
|
+
return slackOk(c, { user: formatUser(user, true) });
|
|
396
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);
|
|
397
1775
|
}
|
|
398
|
-
function formatUser(u) {
|
|
1776
|
+
function formatUser(u, includeEmail = true) {
|
|
1777
|
+
const profile = formatProfile(u.profile, includeEmail);
|
|
399
1778
|
return {
|
|
400
1779
|
id: u.user_id,
|
|
401
1780
|
team_id: u.team_id,
|
|
@@ -404,93 +1783,200 @@ function formatUser(u) {
|
|
|
404
1783
|
is_admin: u.is_admin,
|
|
405
1784
|
is_bot: u.is_bot,
|
|
406
1785
|
deleted: u.deleted,
|
|
407
|
-
profile
|
|
1786
|
+
profile
|
|
408
1787
|
};
|
|
409
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
|
|
1807
|
+
};
|
|
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
|
+
}
|
|
410
1863
|
|
|
411
1864
|
// src/routes/reactions.ts
|
|
412
1865
|
function reactionsRoutes(ctx) {
|
|
413
1866
|
const { app, store, webhooks } = ctx;
|
|
414
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);
|
|
415
1880
|
app.post("/api/reactions.add", async (c) => {
|
|
416
1881
|
const authUser = c.get("authUser");
|
|
417
1882
|
if (!authUser) return slackError(c, "not_authed");
|
|
1883
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:write"]);
|
|
1884
|
+
if (scopeError) return scopeError;
|
|
418
1885
|
const body = await parseSlackBody(c);
|
|
419
1886
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
420
1887
|
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
421
1888
|
const name = typeof body.name === "string" ? body.name : "";
|
|
422
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");
|
|
423
1892
|
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
424
1893
|
if (!msg) return slackError(c, "message_not_found");
|
|
425
1894
|
const reactions = [...msg.reactions];
|
|
426
1895
|
const existing = reactions.find((r) => r.name === name);
|
|
1896
|
+
const authUserId = getAuthUserId(authUser);
|
|
1897
|
+
const aliases = getAuthUserAliases(authUser);
|
|
427
1898
|
if (existing) {
|
|
428
|
-
if (existing.users.
|
|
1899
|
+
if (existing.users.some((user) => aliases.has(user))) {
|
|
429
1900
|
return slackError(c, "already_reacted");
|
|
430
1901
|
}
|
|
431
|
-
existing.users.push(
|
|
1902
|
+
existing.users.push(authUserId);
|
|
432
1903
|
existing.count++;
|
|
433
1904
|
} else {
|
|
434
|
-
reactions.push({ name, users: [
|
|
1905
|
+
reactions.push({ name, users: [authUserId], count: 1 });
|
|
435
1906
|
}
|
|
436
1907
|
ss().messages.update(msg.id, { reactions });
|
|
437
|
-
await webhooks.dispatch(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1908
|
+
await webhooks.dispatch(
|
|
1909
|
+
"reaction_added",
|
|
1910
|
+
void 0,
|
|
1911
|
+
{
|
|
1912
|
+
type: "event_callback",
|
|
1913
|
+
event: {
|
|
1914
|
+
type: "reaction_added",
|
|
1915
|
+
user: authUserId,
|
|
1916
|
+
reaction: name,
|
|
1917
|
+
item: { type: "message", channel, ts: timestamp }
|
|
1918
|
+
}
|
|
1919
|
+
},
|
|
1920
|
+
"slack"
|
|
1921
|
+
);
|
|
446
1922
|
return slackOk(c, {});
|
|
447
1923
|
});
|
|
448
1924
|
app.post("/api/reactions.remove", async (c) => {
|
|
449
1925
|
const authUser = c.get("authUser");
|
|
450
1926
|
if (!authUser) return slackError(c, "not_authed");
|
|
1927
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:write"]);
|
|
1928
|
+
if (scopeError) return scopeError;
|
|
451
1929
|
const body = await parseSlackBody(c);
|
|
452
1930
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
453
1931
|
const timestamp = typeof body.timestamp === "string" ? body.timestamp : "";
|
|
454
1932
|
const name = typeof body.name === "string" ? body.name : "";
|
|
455
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");
|
|
456
1936
|
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
457
1937
|
if (!msg) return slackError(c, "message_not_found");
|
|
458
1938
|
const reactions = [...msg.reactions];
|
|
459
1939
|
const existing = reactions.find((r) => r.name === name);
|
|
460
|
-
|
|
1940
|
+
const authUserId = getAuthUserId(authUser);
|
|
1941
|
+
const aliases = getAuthUserAliases(authUser);
|
|
1942
|
+
if (!existing || !existing.users.some((user) => aliases.has(user))) {
|
|
461
1943
|
return slackError(c, "no_reaction");
|
|
462
1944
|
}
|
|
463
|
-
existing.users = existing.users.filter((u) => u
|
|
464
|
-
existing.count
|
|
1945
|
+
existing.users = existing.users.filter((u) => !aliases.has(u));
|
|
1946
|
+
existing.count = existing.users.length;
|
|
465
1947
|
const filtered = reactions.filter((r) => r.count > 0);
|
|
466
1948
|
ss().messages.update(msg.id, { reactions: filtered });
|
|
467
|
-
await webhooks.dispatch(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1949
|
+
await webhooks.dispatch(
|
|
1950
|
+
"reaction_removed",
|
|
1951
|
+
void 0,
|
|
1952
|
+
{
|
|
1953
|
+
type: "event_callback",
|
|
1954
|
+
event: {
|
|
1955
|
+
type: "reaction_removed",
|
|
1956
|
+
user: authUserId,
|
|
1957
|
+
reaction: name,
|
|
1958
|
+
item: { type: "message", channel, ts: timestamp }
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
"slack"
|
|
1962
|
+
);
|
|
476
1963
|
return slackOk(c, {});
|
|
477
1964
|
});
|
|
478
1965
|
app.post("/api/reactions.get", async (c) => {
|
|
479
1966
|
const authUser = c.get("authUser");
|
|
480
1967
|
if (!authUser) return slackError(c, "not_authed");
|
|
1968
|
+
const scopeError = requireSlackScopes(c, store, ["reactions:read"]);
|
|
1969
|
+
if (scopeError) return scopeError;
|
|
481
1970
|
const body = await parseSlackBody(c);
|
|
482
1971
|
const channel = typeof body.channel === "string" ? body.channel : "";
|
|
483
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");
|
|
484
1975
|
const msg = ss().messages.all().find((m) => m.ts === timestamp && m.channel_id === channel);
|
|
485
1976
|
if (!msg) return slackError(c, "message_not_found");
|
|
486
1977
|
return slackOk(c, {
|
|
487
1978
|
type: "message",
|
|
488
|
-
message: {
|
|
489
|
-
type: msg.type,
|
|
490
|
-
text: msg.text,
|
|
491
|
-
ts: msg.ts,
|
|
492
|
-
reactions: msg.reactions
|
|
493
|
-
}
|
|
1979
|
+
message: { ...formatSlackMessage(msg), reactions: msg.reactions }
|
|
494
1980
|
});
|
|
495
1981
|
});
|
|
496
1982
|
}
|
|
@@ -502,6 +1988,8 @@ function teamRoutes(ctx) {
|
|
|
502
1988
|
app.post("/api/team.info", (c) => {
|
|
503
1989
|
const authUser = c.get("authUser");
|
|
504
1990
|
if (!authUser) return slackError(c, "not_authed");
|
|
1991
|
+
const scopeError = requireSlackScopes(c, store, ["team:read"]);
|
|
1992
|
+
if (scopeError) return scopeError;
|
|
505
1993
|
const team = ss().teams.all()[0];
|
|
506
1994
|
if (!team) return slackError(c, "team_not_found");
|
|
507
1995
|
return slackOk(c, {
|
|
@@ -515,6 +2003,8 @@ function teamRoutes(ctx) {
|
|
|
515
2003
|
app.post("/api/bots.info", async (c) => {
|
|
516
2004
|
const authUser = c.get("authUser");
|
|
517
2005
|
if (!authUser) return slackError(c, "not_authed");
|
|
2006
|
+
const scopeError = requireSlackScopes(c, store, ["users:read"]);
|
|
2007
|
+
if (scopeError) return scopeError;
|
|
518
2008
|
const body = await parseSlackBody(c);
|
|
519
2009
|
const botId = typeof body.bot === "string" ? body.bot : "";
|
|
520
2010
|
const bot = ss().bots.findOneBy("bot_id", botId);
|
|
@@ -534,8 +2024,6 @@ function teamRoutes(ctx) {
|
|
|
534
2024
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
535
2025
|
|
|
536
2026
|
// ../core/dist/index.js
|
|
537
|
-
import { Hono } from "hono";
|
|
538
|
-
import { cors } from "hono/cors";
|
|
539
2027
|
import { readFileSync } from "fs";
|
|
540
2028
|
import { fileURLToPath } from "url";
|
|
541
2029
|
import { dirname, join } from "path";
|
|
@@ -560,6 +2048,7 @@ var FONTS = {
|
|
|
560
2048
|
"geist-sans.woff2": readFileSync(join(__dirname, "fonts", "geist-sans.woff2")),
|
|
561
2049
|
"GeistPixel-Square.woff2": readFileSync(join(__dirname, "fonts", "GeistPixel-Square.woff2"))
|
|
562
2050
|
};
|
|
2051
|
+
var FAVICON = readFileSync(join(__dirname, "fonts", "favicon.ico"));
|
|
563
2052
|
function escapeHtml(s) {
|
|
564
2053
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
565
2054
|
}
|
|
@@ -676,41 +2165,167 @@ body{
|
|
|
676
2165
|
font-size:.9375rem;font-weight:600;margin-bottom:10px;color:#33ff00;
|
|
677
2166
|
display:flex;align-items:center;justify-content:space-between;
|
|
678
2167
|
}
|
|
679
|
-
.perm-list{list-style:none;}
|
|
680
|
-
.perm-list li{padding:5px 0;font-size:.8125rem;display:flex;align-items:center;gap:6px;color:#1a8c00;}
|
|
681
|
-
.check{color:#33ff00;}
|
|
682
|
-
.org-row{
|
|
683
|
-
display:flex;align-items:center;gap:8px;padding:7px 0;
|
|
684
|
-
border-bottom:1px solid #0a3300;font-size:.8125rem;
|
|
2168
|
+
.perm-list{list-style:none;}
|
|
2169
|
+
.perm-list li{padding:5px 0;font-size:.8125rem;display:flex;align-items:center;gap:6px;color:#1a8c00;}
|
|
2170
|
+
.check{color:#33ff00;}
|
|
2171
|
+
.org-row{
|
|
2172
|
+
display:flex;align-items:center;gap:8px;padding:7px 0;
|
|
2173
|
+
border-bottom:1px solid #0a3300;font-size:.8125rem;
|
|
2174
|
+
}
|
|
2175
|
+
.org-row:last-child{border-bottom:none;}
|
|
2176
|
+
.org-icon{
|
|
2177
|
+
width:22px;height:22px;border-radius:4px;background:#0a3300;
|
|
2178
|
+
display:flex;align-items:center;justify-content:center;
|
|
2179
|
+
font-size:.625rem;font-weight:700;color:#116600;flex-shrink:0;
|
|
2180
|
+
font-family:'Geist Pixel',monospace;
|
|
2181
|
+
}
|
|
2182
|
+
.org-name{font-weight:600;color:#33ff00;}
|
|
2183
|
+
.badge{font-size:.6875rem;padding:1px 7px;border-radius:999px;font-weight:500;}
|
|
2184
|
+
.badge-granted{background:#0a3300;color:#33ff00;}
|
|
2185
|
+
.badge-denied{background:#1a0a0a;color:#ff4444;}
|
|
2186
|
+
.badge-requested{background:#0a3300;color:#1a8c00;}
|
|
2187
|
+
.btn-revoke{
|
|
2188
|
+
display:inline-block;padding:5px 14px;border-radius:6px;
|
|
2189
|
+
border:1px solid #0a3300;background:transparent;color:#ff4444;
|
|
2190
|
+
font-size:.75rem;font-weight:600;cursor:pointer;transition:border-color .15s;
|
|
2191
|
+
}
|
|
2192
|
+
.btn-revoke:hover{border-color:#ff4444;}
|
|
2193
|
+
.info-text{color:#1a8c00;font-size:.75rem;line-height:1.5;margin-top:10px;}
|
|
2194
|
+
.app-link{
|
|
2195
|
+
display:flex;align-items:center;gap:12px;padding:12px;
|
|
2196
|
+
border:1px solid #0a3300;border-radius:8px;background:#000;
|
|
2197
|
+
text-decoration:none;color:inherit;margin-bottom:8px;transition:border-color .15s;
|
|
2198
|
+
}
|
|
2199
|
+
.app-link:hover{border-color:#33ff00;}
|
|
2200
|
+
.app-link-name{font-weight:600;font-size:.875rem;color:#33ff00;}
|
|
2201
|
+
.app-link-scopes{font-size:.6875rem;color:#1a8c00;margin-top:1px;}
|
|
2202
|
+
.empty{color:#1a8c00;text-align:center;padding:28px 0;font-size:.875rem;}
|
|
2203
|
+
|
|
2204
|
+
.inspector-layout{max-width:960px;margin:0 auto;padding:28px 20px;}
|
|
2205
|
+
.inspector-tabs{display:flex;gap:4px;margin-bottom:20px;}
|
|
2206
|
+
.inspector-tabs a{
|
|
2207
|
+
padding:7px 16px;border-radius:6px;text-decoration:none;
|
|
2208
|
+
font-size:.8125rem;color:#1a8c00;border:1px solid transparent;
|
|
2209
|
+
transition:color .15s,border-color .15s;
|
|
2210
|
+
}
|
|
2211
|
+
.inspector-tabs a:hover{color:#33ff00;}
|
|
2212
|
+
.inspector-tabs a.active{color:#33ff00;font-weight:600;border-color:#0a3300;background:#0a3300;}
|
|
2213
|
+
.inspector-section{margin-bottom:24px;}
|
|
2214
|
+
.inspector-section h2{
|
|
2215
|
+
font-family:'Geist Pixel',monospace;
|
|
2216
|
+
font-size:1rem;font-weight:600;color:#33ff00;margin-bottom:10px;
|
|
2217
|
+
}
|
|
2218
|
+
.inspector-section h3{
|
|
2219
|
+
font-family:'Geist Pixel',monospace;
|
|
2220
|
+
font-size:.875rem;font-weight:600;color:#1a8c00;margin:16px 0 8px;
|
|
2221
|
+
}
|
|
2222
|
+
.inspector-table{width:100%;border-collapse:collapse;margin-bottom:12px;}
|
|
2223
|
+
.inspector-table th,.inspector-table td{
|
|
2224
|
+
text-align:left;padding:8px 12px;border-bottom:1px solid #0a3300;
|
|
2225
|
+
font-size:.8125rem;
|
|
2226
|
+
}
|
|
2227
|
+
.inspector-table th{color:#1a8c00;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;}
|
|
2228
|
+
.inspector-table td{color:#33ff00;}
|
|
2229
|
+
.inspector-table tbody tr{transition:background .1s;}
|
|
2230
|
+
.inspector-table tbody tr:hover{background:#0a3300;}
|
|
2231
|
+
.inspector-empty{color:#1a8c00;text-align:center;padding:20px 0;font-size:.8125rem;}
|
|
2232
|
+
|
|
2233
|
+
.checkout-layout{
|
|
2234
|
+
display:flex;min-height:calc(100vh - 42px);
|
|
2235
|
+
}
|
|
2236
|
+
.checkout-summary{
|
|
2237
|
+
flex:1;background:#020;padding:48px 40px 48px 10%;
|
|
2238
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
2239
|
+
border-right:1px solid #0a3300;
|
|
2240
|
+
}
|
|
2241
|
+
.checkout-form-side{
|
|
2242
|
+
flex:1;background:#000;padding:48px 10% 48px 40px;
|
|
2243
|
+
display:flex;flex-direction:column;justify-content:center;
|
|
2244
|
+
}
|
|
2245
|
+
.checkout-merchant{
|
|
2246
|
+
display:flex;align-items:center;gap:10px;margin-bottom:6px;
|
|
2247
|
+
}
|
|
2248
|
+
.checkout-merchant-name{
|
|
2249
|
+
font-family:'Geist Pixel',monospace;
|
|
2250
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
2251
|
+
}
|
|
2252
|
+
.checkout-test-badge{
|
|
2253
|
+
font-size:.625rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
2254
|
+
background:#0a3300;color:#1a8c00;padding:2px 8px;border-radius:4px;
|
|
2255
|
+
}
|
|
2256
|
+
.checkout-total{
|
|
2257
|
+
font-family:'Geist Pixel',monospace;
|
|
2258
|
+
font-size:2rem;font-weight:700;color:#33ff00;margin:8px 0 28px;
|
|
2259
|
+
}
|
|
2260
|
+
.checkout-line-item{
|
|
2261
|
+
display:flex;align-items:center;gap:14px;padding:14px 0;
|
|
2262
|
+
border-bottom:1px solid #0a3300;
|
|
2263
|
+
}
|
|
2264
|
+
.checkout-line-item:first-child{border-top:1px solid #0a3300;}
|
|
2265
|
+
.checkout-item-icon{
|
|
2266
|
+
width:42px;height:42px;border-radius:6px;background:#0a3300;
|
|
2267
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
2268
|
+
font-family:'Geist Pixel',monospace;font-size:.875rem;font-weight:700;color:#116600;
|
|
2269
|
+
}
|
|
2270
|
+
.checkout-item-details{flex:1;min-width:0;}
|
|
2271
|
+
.checkout-item-name{font-size:.875rem;font-weight:600;color:#33ff00;}
|
|
2272
|
+
.checkout-item-qty{font-size:.75rem;color:#1a8c00;margin-top:2px;}
|
|
2273
|
+
.checkout-item-price{
|
|
2274
|
+
font-size:.875rem;font-weight:600;color:#33ff00;text-align:right;white-space:nowrap;
|
|
2275
|
+
}
|
|
2276
|
+
.checkout-item-unit{font-size:.6875rem;color:#1a8c00;text-align:right;margin-top:2px;}
|
|
2277
|
+
.checkout-totals{margin-top:20px;}
|
|
2278
|
+
.checkout-totals-row{
|
|
2279
|
+
display:flex;justify-content:space-between;padding:6px 0;
|
|
2280
|
+
font-size:.8125rem;color:#1a8c00;
|
|
2281
|
+
}
|
|
2282
|
+
.checkout-totals-row.total{
|
|
2283
|
+
border-top:1px solid #0a3300;margin-top:8px;padding-top:14px;
|
|
2284
|
+
font-size:.9375rem;font-weight:600;color:#33ff00;
|
|
2285
|
+
}
|
|
2286
|
+
.checkout-form-section{margin-bottom:24px;}
|
|
2287
|
+
.checkout-form-label{
|
|
2288
|
+
font-size:.8125rem;font-weight:600;color:#33ff00;margin-bottom:8px;display:block;
|
|
2289
|
+
}
|
|
2290
|
+
.checkout-input{
|
|
2291
|
+
width:100%;padding:10px 12px;border:1px solid #0a3300;border-radius:6px;
|
|
2292
|
+
background:#020;color:#33ff00;font:inherit;font-size:.875rem;
|
|
2293
|
+
transition:border-color .15s;outline:none;
|
|
2294
|
+
}
|
|
2295
|
+
.checkout-input:focus{border-color:#33ff00;}
|
|
2296
|
+
.checkout-input::placeholder{color:#116600;}
|
|
2297
|
+
.checkout-card-box{
|
|
2298
|
+
border:1px solid #0a3300;border-radius:6px;padding:14px;
|
|
2299
|
+
background:#020;
|
|
2300
|
+
}
|
|
2301
|
+
.checkout-card-row{
|
|
2302
|
+
display:flex;gap:12px;margin-top:10px;
|
|
685
2303
|
}
|
|
686
|
-
.
|
|
687
|
-
.
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
2304
|
+
.checkout-card-row .checkout-input{flex:1;}
|
|
2305
|
+
.checkout-sim-note{
|
|
2306
|
+
font-size:.6875rem;color:#1a8c00;margin-top:10px;text-align:center;
|
|
2307
|
+
font-style:italic;
|
|
2308
|
+
}
|
|
2309
|
+
.checkout-pay-btn{
|
|
2310
|
+
width:100%;padding:14px;border:none;border-radius:8px;
|
|
2311
|
+
background:#33ff00;color:#000;font:inherit;font-size:.9375rem;font-weight:700;
|
|
2312
|
+
cursor:pointer;transition:background .15s;
|
|
691
2313
|
font-family:'Geist Pixel',monospace;
|
|
692
2314
|
}
|
|
693
|
-
.
|
|
694
|
-
.
|
|
695
|
-
|
|
696
|
-
.badge-denied{background:#1a0a0a;color:#ff4444;}
|
|
697
|
-
.badge-requested{background:#0a3300;color:#1a8c00;}
|
|
698
|
-
.btn-revoke{
|
|
699
|
-
display:inline-block;padding:5px 14px;border-radius:6px;
|
|
700
|
-
border:1px solid #0a3300;background:transparent;color:#ff4444;
|
|
701
|
-
font-size:.75rem;font-weight:600;cursor:pointer;transition:border-color .15s;
|
|
2315
|
+
.checkout-pay-btn:hover{background:#44ff22;}
|
|
2316
|
+
.checkout-cancel{
|
|
2317
|
+
text-align:center;margin-top:14px;
|
|
702
2318
|
}
|
|
703
|
-
.
|
|
704
|
-
|
|
705
|
-
.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
2319
|
+
.checkout-cancel a{
|
|
2320
|
+
color:#1a8c00;text-decoration:none;font-size:.8125rem;
|
|
2321
|
+
transition:color .15s;
|
|
2322
|
+
}
|
|
2323
|
+
.checkout-cancel a:hover{color:#33ff00;}
|
|
2324
|
+
@media(max-width:768px){
|
|
2325
|
+
.checkout-layout{flex-direction:column;}
|
|
2326
|
+
.checkout-summary{padding:32px 20px;border-right:none;border-bottom:1px solid #0a3300;}
|
|
2327
|
+
.checkout-form-side{padding:32px 20px;}
|
|
709
2328
|
}
|
|
710
|
-
.app-link:hover{border-color:#33ff00;}
|
|
711
|
-
.app-link-name{font-weight:600;font-size:.875rem;color:#33ff00;}
|
|
712
|
-
.app-link-scopes{font-size:.6875rem;color:#1a8c00;margin-top:1px;}
|
|
713
|
-
.empty{color:#1a8c00;text-align:center;padding:28px 0;font-size:.875rem;}
|
|
714
2329
|
`;
|
|
715
2330
|
var POWERED_BY = `<div class="powered-by">Powered by <a href="https://emulate.dev" target="_blank" rel="noopener">emulate</a></div>`;
|
|
716
2331
|
function emuBar(service) {
|
|
@@ -730,6 +2345,7 @@ function head(title) {
|
|
|
730
2345
|
<head>
|
|
731
2346
|
<meta charset="utf-8"/>
|
|
732
2347
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
2348
|
+
<link rel="icon" href="/_emulate/favicon.ico"/>
|
|
733
2349
|
<title>${escapeHtml(title)} | emulate</title>
|
|
734
2350
|
<style>${CSS}</style>
|
|
735
2351
|
</head>`;
|
|
@@ -761,13 +2377,16 @@ ${emuBar(service)}
|
|
|
761
2377
|
${POWERED_BY}
|
|
762
2378
|
</body></html>`;
|
|
763
2379
|
}
|
|
764
|
-
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("");
|
|
765
2384
|
return `${head(title)}
|
|
766
2385
|
<body>
|
|
767
2386
|
${emuBar(service)}
|
|
768
|
-
<div class="
|
|
769
|
-
<nav class="
|
|
770
|
-
|
|
2387
|
+
<div class="inspector-layout">
|
|
2388
|
+
<nav class="inspector-tabs">${tabLinks}</nav>
|
|
2389
|
+
${body}
|
|
771
2390
|
</div>
|
|
772
2391
|
${POWERED_BY}
|
|
773
2392
|
</body></html>`;
|
|
@@ -825,12 +2444,13 @@ function getPendingCodes(store) {
|
|
|
825
2444
|
function isPendingCodeExpired(p) {
|
|
826
2445
|
return Date.now() - p.created_at > PENDING_CODE_TTL_MS;
|
|
827
2446
|
}
|
|
828
|
-
function oauthRoutes({ app, store,
|
|
2447
|
+
function oauthRoutes({ app, store, tokenMap }) {
|
|
829
2448
|
const ss = () => getSlackStore(store);
|
|
830
2449
|
app.get("/oauth/v2/authorize", (c) => {
|
|
831
2450
|
const client_id = c.req.query("client_id") ?? "";
|
|
832
2451
|
const redirect_uri = c.req.query("redirect_uri") ?? "";
|
|
833
2452
|
const scope = c.req.query("scope") ?? "";
|
|
2453
|
+
const user_scope = c.req.query("user_scope") ?? "";
|
|
834
2454
|
const state = c.req.query("state") ?? "";
|
|
835
2455
|
const appsConfigured = ss().oauthApps.all().length > 0;
|
|
836
2456
|
let appName = "";
|
|
@@ -844,7 +2464,11 @@ function oauthRoutes({ app, store, baseUrl, tokenMap }) {
|
|
|
844
2464
|
}
|
|
845
2465
|
if (redirect_uri && !matchesRedirectUri(redirect_uri, oauthApp.redirect_uris)) {
|
|
846
2466
|
return c.html(
|
|
847
|
-
renderErrorPage(
|
|
2467
|
+
renderErrorPage(
|
|
2468
|
+
"Redirect URI mismatch",
|
|
2469
|
+
"The redirect_uri is not registered for this application.",
|
|
2470
|
+
SERVICE_LABEL
|
|
2471
|
+
),
|
|
848
2472
|
400
|
|
849
2473
|
);
|
|
850
2474
|
}
|
|
@@ -863,6 +2487,7 @@ function oauthRoutes({ app, store, baseUrl, tokenMap }) {
|
|
|
863
2487
|
user_id: user.user_id,
|
|
864
2488
|
redirect_uri,
|
|
865
2489
|
scope,
|
|
2490
|
+
user_scope,
|
|
866
2491
|
state,
|
|
867
2492
|
client_id
|
|
868
2493
|
}
|
|
@@ -876,12 +2501,14 @@ function oauthRoutes({ app, store, baseUrl, tokenMap }) {
|
|
|
876
2501
|
const userId = bodyStr(body.user_id);
|
|
877
2502
|
const redirect_uri = bodyStr(body.redirect_uri);
|
|
878
2503
|
const scope = bodyStr(body.scope);
|
|
2504
|
+
const userScope = bodyStr(body.user_scope);
|
|
879
2505
|
const state = bodyStr(body.state);
|
|
880
2506
|
const client_id = bodyStr(body.client_id);
|
|
881
2507
|
const code = randomBytes2(20).toString("hex");
|
|
882
2508
|
getPendingCodes(store).set(code, {
|
|
883
2509
|
userId,
|
|
884
2510
|
scope,
|
|
2511
|
+
userScope,
|
|
885
2512
|
redirectUri: redirect_uri,
|
|
886
2513
|
clientId: client_id,
|
|
887
2514
|
created_at: Date.now()
|
|
@@ -906,11 +2533,14 @@ function oauthRoutes({ app, store, baseUrl, tokenMap }) {
|
|
|
906
2533
|
body = Object.fromEntries(new URLSearchParams(rawText));
|
|
907
2534
|
}
|
|
908
2535
|
const code = typeof body.code === "string" ? body.code : "";
|
|
909
|
-
const
|
|
910
|
-
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 : "";
|
|
911
2540
|
const appsConfigured = ss().oauthApps.all().length > 0;
|
|
2541
|
+
let oauthApp;
|
|
912
2542
|
if (appsConfigured) {
|
|
913
|
-
|
|
2543
|
+
oauthApp = ss().oauthApps.findOneBy("client_id", client_id);
|
|
914
2544
|
if (!oauthApp) {
|
|
915
2545
|
return c.json({ ok: false, error: "invalid_client_id" });
|
|
916
2546
|
}
|
|
@@ -928,113 +2558,1442 @@ function oauthRoutes({ app, store, baseUrl, tokenMap }) {
|
|
|
928
2558
|
return c.json({ ok: false, error: "invalid_code" });
|
|
929
2559
|
}
|
|
930
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
|
+
}
|
|
931
2567
|
const user = ss().users.findOneBy("user_id", pending.userId);
|
|
932
2568
|
if (!user) {
|
|
933
2569
|
return c.json({ ok: false, error: "invalid_code" });
|
|
934
2570
|
}
|
|
935
2571
|
const accessToken = "xoxb-" + randomBytes2(20).toString("base64url");
|
|
2572
|
+
const userAccessToken = "xoxp-" + randomBytes2(20).toString("base64url");
|
|
936
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
|
+
});
|
|
937
2602
|
if (tokenMap) {
|
|
938
|
-
|
|
939
|
-
|
|
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 });
|
|
940
2620
|
}
|
|
941
|
-
debug("slack.oauth", `[Slack token] issued token for ${user.name}`);
|
|
2621
|
+
debug("slack.oauth", `[Slack token] issued token for ${oauthApp?.name ?? "Slack App"} as ${bot.user.name}`);
|
|
942
2622
|
return c.json({
|
|
943
2623
|
ok: true,
|
|
944
2624
|
access_token: accessToken,
|
|
945
2625
|
token_type: "bot",
|
|
946
|
-
scope:
|
|
947
|
-
bot_user_id: user.user_id,
|
|
948
|
-
app_id:
|
|
2626
|
+
scope: requestedScopes.join(","),
|
|
2627
|
+
bot_user_id: bot.user.user_id,
|
|
2628
|
+
app_id: appId,
|
|
949
2629
|
team: {
|
|
950
|
-
id:
|
|
2630
|
+
id: teamId,
|
|
951
2631
|
name: team?.name ?? "Emulate"
|
|
952
2632
|
},
|
|
2633
|
+
enterprise: null,
|
|
2634
|
+
is_enterprise_install: false,
|
|
953
2635
|
authed_user: {
|
|
954
|
-
id: user.user_id
|
|
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
|
|
955
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()
|
|
956
3501
|
});
|
|
3502
|
+
return slackOk(c, {});
|
|
957
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);
|
|
958
3537
|
}
|
|
959
3538
|
|
|
960
|
-
// src/routes/
|
|
961
|
-
function
|
|
962
|
-
const { app, store
|
|
3539
|
+
// src/routes/bookmarks.ts
|
|
3540
|
+
function bookmarksRoutes(ctx) {
|
|
3541
|
+
const { app, store } = ctx;
|
|
963
3542
|
const ss = () => getSlackStore(store);
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
const threadTs = typeof body.thread_ts === "string" ? body.thread_ts : void 0;
|
|
990
|
-
if (!text && !body.blocks && !body.attachments) {
|
|
991
|
-
return c.text("no_text", 400);
|
|
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");
|
|
992
3568
|
}
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
)
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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) } : {}
|
|
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);
|
|
999
3621
|
}
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
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, {});
|
|
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
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
|
|
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;
|
|
3732
|
+
const ss = () => getSlackStore(store);
|
|
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");
|
|
1002
3749
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
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");
|
|
1005
3806
|
}
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
channel_id: targetChannel.channel_id,
|
|
1011
|
-
user: botId,
|
|
1012
|
-
text: text || "(rich message)",
|
|
1013
|
-
type: "message",
|
|
1014
|
-
subtype: "bot_message",
|
|
1015
|
-
thread_ts: threadTs,
|
|
1016
|
-
reply_count: 0,
|
|
1017
|
-
reply_users: [],
|
|
1018
|
-
reactions: []
|
|
3807
|
+
const updated = ss().views.update(view.id, {
|
|
3808
|
+
...viewPayload,
|
|
3809
|
+
hash: generateTs(),
|
|
3810
|
+
updated: nowSeconds()
|
|
1019
3811
|
});
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
1031
3836
|
});
|
|
1032
|
-
return c
|
|
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 });
|
|
1033
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()}`;
|
|
1034
3985
|
}
|
|
1035
3986
|
|
|
1036
3987
|
// src/routes/inspector.ts
|
|
1037
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
|
+
];
|
|
1038
3997
|
function timeAgo(isoDate) {
|
|
1039
3998
|
const seconds = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1e3);
|
|
1040
3999
|
if (seconds < 60) return "just now";
|
|
@@ -1042,48 +4001,132 @@ function timeAgo(isoDate) {
|
|
|
1042
4001
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
1043
4002
|
return `${Math.floor(seconds / 86400)}d ago`;
|
|
1044
4003
|
}
|
|
1045
|
-
function
|
|
1046
|
-
if (
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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>`;
|
|
1063
4067
|
}
|
|
1064
|
-
function
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
}
|
|
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";
|
|
4100
|
+
}
|
|
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
|
+
);
|
|
1070
4109
|
}
|
|
1071
4110
|
function inspectorRoutes(ctx) {
|
|
1072
|
-
const { app, store } = ctx;
|
|
4111
|
+
const { app, store, webhooks } = ctx;
|
|
1073
4112
|
const ss = () => getSlackStore(store);
|
|
1074
4113
|
app.get("/", (c) => {
|
|
1075
|
-
const channels = ss().channels.all().filter((ch) => !ch.is_archived);
|
|
1076
4114
|
const team = ss().teams.all()[0];
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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,
|
|
1082
4125
|
SERVICE_LABEL2
|
|
1083
|
-
)
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
4126
|
+
)
|
|
4127
|
+
);
|
|
4128
|
+
});
|
|
4129
|
+
function buildUserMap() {
|
|
1087
4130
|
const userMap = /* @__PURE__ */ new Map();
|
|
1088
4131
|
for (const u of ss().users.all()) {
|
|
1089
4132
|
userMap.set(u.user_id, u.name);
|
|
@@ -1091,36 +4134,401 @@ function inspectorRoutes(ctx) {
|
|
|
1091
4134
|
}
|
|
1092
4135
|
for (const b of ss().bots.all()) {
|
|
1093
4136
|
userMap.set(b.bot_id, b.name);
|
|
4137
|
+
if (b.user_id) userMap.set(b.user_id, b.name);
|
|
1094
4138
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
+
);
|
|
1121
4498
|
}
|
|
1122
4499
|
|
|
1123
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
|
+
];
|
|
1124
4532
|
function seedDefaults(store, _baseUrl) {
|
|
1125
4533
|
const ss = getSlackStore(store);
|
|
1126
4534
|
const teamId = "T000000001";
|
|
@@ -1144,8 +4552,18 @@ function seedDefaults(store, _baseUrl) {
|
|
|
1144
4552
|
real_name: "Admin User",
|
|
1145
4553
|
email: "admin@emulate.dev",
|
|
1146
4554
|
image_48: "",
|
|
1147
|
-
image_192: ""
|
|
1148
|
-
|
|
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)
|
|
1149
4567
|
});
|
|
1150
4568
|
ss.channels.insert({
|
|
1151
4569
|
channel_id: "C000000001",
|
|
@@ -1168,7 +4586,11 @@ function seedDefaults(store, _baseUrl) {
|
|
|
1168
4586
|
is_private: false,
|
|
1169
4587
|
is_archived: false,
|
|
1170
4588
|
topic: { value: "Random stuff", creator: userId, last_set: Math.floor(Date.now() / 1e3) },
|
|
1171
|
-
purpose: {
|
|
4589
|
+
purpose: {
|
|
4590
|
+
value: "A place for non-work-related chatter",
|
|
4591
|
+
creator: userId,
|
|
4592
|
+
last_set: Math.floor(Date.now() / 1e3)
|
|
4593
|
+
},
|
|
1172
4594
|
members: [userId],
|
|
1173
4595
|
creator: userId,
|
|
1174
4596
|
num_members: 1
|
|
@@ -1200,23 +4622,30 @@ function seedFromConfig(store, _baseUrl, config) {
|
|
|
1200
4622
|
const existing = ss.users.all().find((eu) => eu.name === u.name);
|
|
1201
4623
|
if (existing) continue;
|
|
1202
4624
|
const userId = generateSlackId("U");
|
|
1203
|
-
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
|
+
});
|
|
1204
4635
|
ss.users.insert({
|
|
1205
4636
|
user_id: userId,
|
|
1206
4637
|
team_id: teamId,
|
|
1207
4638
|
name: u.name,
|
|
1208
|
-
real_name:
|
|
1209
|
-
email,
|
|
4639
|
+
real_name: profile.real_name,
|
|
4640
|
+
email: profile.email,
|
|
1210
4641
|
is_admin: u.is_admin ?? false,
|
|
1211
4642
|
is_bot: false,
|
|
1212
4643
|
deleted: false,
|
|
1213
|
-
profile
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
image_192: ""
|
|
1219
|
-
}
|
|
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)
|
|
1220
4649
|
});
|
|
1221
4650
|
}
|
|
1222
4651
|
}
|
|
@@ -1257,12 +4686,46 @@ function seedFromConfig(store, _baseUrl, config) {
|
|
|
1257
4686
|
if (config.oauth_apps) {
|
|
1258
4687
|
for (const oa of config.oauth_apps) {
|
|
1259
4688
|
const existing = ss.oauthApps.findOneBy("client_id", oa.client_id);
|
|
1260
|
-
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
|
+
}
|
|
1261
4695
|
ss.oauthApps.insert({
|
|
4696
|
+
app_id: oa.app_id ?? generateSlackId("A"),
|
|
1262
4697
|
client_id: oa.client_id,
|
|
1263
4698
|
client_secret: oa.client_secret,
|
|
1264
4699
|
name: oa.name,
|
|
1265
|
-
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
|
|
1266
4729
|
});
|
|
1267
4730
|
}
|
|
1268
4731
|
}
|
|
@@ -1284,10 +4747,17 @@ function seedFromConfig(store, _baseUrl, config) {
|
|
|
1284
4747
|
if (config.signing_secret) {
|
|
1285
4748
|
store.setData("slack.signing_secret", config.signing_secret);
|
|
1286
4749
|
}
|
|
4750
|
+
if (config.strict_scopes !== void 0) {
|
|
4751
|
+
store.setData("slack.strict_scopes", config.strict_scopes);
|
|
4752
|
+
}
|
|
1287
4753
|
}
|
|
1288
4754
|
var slackPlugin = {
|
|
1289
4755
|
name: "slack",
|
|
1290
4756
|
register(app, store, webhooks, baseUrl, tokenMap) {
|
|
4757
|
+
app.use("*", async (c, next) => {
|
|
4758
|
+
applySlackTokenAuth(c, store);
|
|
4759
|
+
await next();
|
|
4760
|
+
});
|
|
1291
4761
|
const ctx = { app, store, webhooks, baseUrl, tokenMap };
|
|
1292
4762
|
authRoutes(ctx);
|
|
1293
4763
|
chatRoutes(ctx);
|
|
@@ -1297,6 +4767,10 @@ var slackPlugin = {
|
|
|
1297
4767
|
teamRoutes(ctx);
|
|
1298
4768
|
oauthRoutes(ctx);
|
|
1299
4769
|
webhookRoutes(ctx);
|
|
4770
|
+
filesRoutes(ctx);
|
|
4771
|
+
pinsRoutes(ctx);
|
|
4772
|
+
bookmarksRoutes(ctx);
|
|
4773
|
+
viewsRoutes(ctx);
|
|
1300
4774
|
inspectorRoutes(ctx);
|
|
1301
4775
|
},
|
|
1302
4776
|
seed(store, baseUrl) {
|
|
@@ -1304,9 +4778,130 @@ var slackPlugin = {
|
|
|
1304
4778
|
}
|
|
1305
4779
|
};
|
|
1306
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
|
+
}
|
|
1307
4901
|
export {
|
|
1308
4902
|
index_default as default,
|
|
1309
4903
|
getSlackStore,
|
|
4904
|
+
normalizeScopes2 as normalizeScopes,
|
|
1310
4905
|
seedFromConfig,
|
|
1311
4906
|
slackPlugin
|
|
1312
4907
|
};
|