@emulators/slack 0.5.0 → 0.6.0

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