@emulators/slack 0.4.1 → 0.6.0

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