@opengeni/api-router 0.16.5 → 0.17.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.
@@ -1,10 +1,15 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import {
3
3
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
4
+ hasOpenGeniSlackReactionScope,
5
+ resolveWorkspaceSlackReactionSummonSettings,
6
+ SlackReactionChannelListResponse,
4
7
  type AccessGrant,
5
8
  type FirstPartyMcpToolName,
6
9
  type HumanInputQuestion,
7
10
  type SessionEvent,
11
+ type WorkspaceSlackReactionSummonSettings,
12
+ workspaceSlackReactionChannelAllowed,
8
13
  } from "@opengeni/contracts";
9
14
  import {
10
15
  acceptSessionHumanInputResponse,
@@ -17,10 +22,14 @@ import {
17
22
  deferSlackInteractionDelivery,
18
23
  deleteSlackBotUserLink,
19
24
  enqueueSlackInteractionInbox,
25
+ getConnectionMetadata,
20
26
  getOrCreateSlackInteraction,
21
27
  getLatestSessionModelForSubject,
22
28
  getSlackBotUserLink,
29
+ getSlackInteractionByClientEventId,
23
30
  getSlackInteractionByRoute,
31
+ getSessionEventByClientEventId,
32
+ getWorkspace,
24
33
  getWorkspaceGrant,
25
34
  listSessionEventPage,
26
35
  listSessionHumanInputRequests,
@@ -30,6 +39,7 @@ import {
30
39
  releaseSlackInteractionInbox,
31
40
  resolveSlackInstallationRoute,
32
41
  saveSlackBotUserLink,
42
+ saveSlackInteractionInboxReactionCheckpoint,
33
43
  settleSlackInteractionInbox,
34
44
  type SlackInstallationRoute,
35
45
  type SlackInteraction,
@@ -47,7 +57,11 @@ import {
47
57
  import { publishDurableSessionEvents } from "@opengeni/events";
48
58
  import type { Context, Hono } from "hono";
49
59
  import { HTTPException } from "hono/http-exception";
50
- import { createOpenGeniSlackBotInteractionClient, SlackBotProviderError } from "./slack-bot";
60
+ import {
61
+ createOpenGeniSlackBotInteractionClient,
62
+ type OpenGeniSlackBotClient,
63
+ SlackBotProviderError,
64
+ } from "./slack-bot";
51
65
 
52
66
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
53
67
  export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
@@ -62,6 +76,8 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
62
76
 
63
77
  const MAX_SLACK_TEXT_CHARS = 3_500;
64
78
  const MAX_SLACK_INPUT_CHARS = 8_000;
79
+ const MAX_SLACK_REACTION_CONTEXT_MESSAGES = 15;
80
+ const MAX_SLACK_REACTION_FILE_SUMMARY_CHARS = 1_500;
65
81
  const MAX_PROGRESS_MESSAGES = 3;
66
82
  const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
67
83
  const INBOX_LEASE_MS = 30_000;
@@ -176,6 +192,56 @@ export function slackEventInboxEntry(
176
192
  };
177
193
  }
178
194
 
195
+ export function slackReactionInboxEntry(
196
+ payload: unknown,
197
+ bot: Pick<SlackInstallationRoute, "botUserId">,
198
+ settings: WorkspaceSlackReactionSummonSettings,
199
+ ): NormalizedSlackInteraction | null {
200
+ const envelope = record(payload);
201
+ if (!envelope || envelope.type !== "event_callback") return null;
202
+ const event = record(envelope.event);
203
+ const item = record(event?.item);
204
+ const teamId = boundedString(envelope.team_id, 64);
205
+ const eventId = boundedString(envelope.event_id, 256);
206
+ const userId = boundedString(event?.user, 64);
207
+ const reaction = boundedString(event?.reaction, 64);
208
+ const channelId = boundedString(item?.channel, 64);
209
+ const timestamp = boundedString(item?.ts, 64);
210
+ if (
211
+ !settings.enabled ||
212
+ !event ||
213
+ event.type !== "reaction_added" ||
214
+ item?.type !== "message" ||
215
+ !teamId ||
216
+ !eventId ||
217
+ !userId ||
218
+ userId === bot.botUserId ||
219
+ !reaction ||
220
+ reaction !== settings.emoji ||
221
+ !channelId ||
222
+ !workspaceSlackReactionChannelAllowed(settings, channelId) ||
223
+ !timestamp
224
+ ) {
225
+ return null;
226
+ }
227
+ const stableReactionIdentity = createHash("sha256")
228
+ .update([teamId, userId, channelId, timestamp, reaction].join("\n"))
229
+ .digest("hex");
230
+ return {
231
+ providerEventId: eventId,
232
+ providerMessageId: `reaction:${stableReactionIdentity}`,
233
+ slackTeamId: teamId,
234
+ slackUserId: userId,
235
+ slackChannelId: channelId,
236
+ slackMessageTs: timestamp,
237
+ slackThreadTs: null,
238
+ triggerKind: "reaction",
239
+ // Store only the exact emoji name before authorization/content fetch. The
240
+ // provider message and bounded thread are projected at durable claim time.
241
+ text: reaction,
242
+ };
243
+ }
244
+
179
245
  export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
180
246
  app.post("/v1/integrations/slack/events", async (c) => {
181
247
  const signed = await readSignedSlackRequest(c, deps);
@@ -192,6 +258,25 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
192
258
  throw new HTTPException(403, {
193
259
  message: "Slack installation unavailable",
194
260
  });
261
+ const event = record(payload.event);
262
+ if (event?.type === "reaction_added") {
263
+ const [workspace, connection] = await Promise.all([
264
+ getWorkspace(deps.db, installation.workspaceId),
265
+ getConnectionMetadata(deps.db, installation.workspaceId, installation.connectionId, null),
266
+ ]);
267
+ if (!workspace || !connection || !hasOpenGeniSlackReactionScope(connection.grantedScopes)) {
268
+ return c.json({ ok: true });
269
+ }
270
+ const reactionEntry = slackReactionInboxEntry(
271
+ payload,
272
+ installation,
273
+ resolveWorkspaceSlackReactionSummonSettings(workspace.settings),
274
+ );
275
+ if (reactionEntry) {
276
+ await enqueueNormalizedSlackInteraction(deps, installation, reactionEntry);
277
+ }
278
+ return c.json({ ok: true });
279
+ }
195
280
  const entry = slackEventInboxEntry(payload, installation);
196
281
  if (entry) await enqueueNormalizedSlackInteraction(deps, installation, entry);
197
282
  return c.json({ ok: true });
@@ -330,6 +415,43 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
330
415
  });
331
416
  },
332
417
  );
418
+
419
+ app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
420
+ const workspaceId = c.req.param("workspaceId");
421
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
422
+ const connectionId = boundedString(c.req.query("connectionId"), 64);
423
+ if (!connectionId) throw new HTTPException(400, { message: "connectionId is required" });
424
+ const cursor = boundedString(c.req.query("cursor"), 1_024);
425
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
426
+ accountId: grant.accountId,
427
+ workspaceId,
428
+ connectionId,
429
+ subjectId: grant.subjectId,
430
+ });
431
+ const result = await client.listChannels({
432
+ limit: 200,
433
+ ...(cursor ? { cursor } : {}),
434
+ });
435
+ return c.json(
436
+ SlackReactionChannelListResponse.parse({
437
+ channels: result.channels
438
+ .filter(
439
+ (channel) =>
440
+ channel.isMember &&
441
+ !channel.isArchived &&
442
+ !channel.isShared &&
443
+ !channel.isExternallyShared &&
444
+ !channel.isOrgShared,
445
+ )
446
+ .map((channel) => ({
447
+ id: channel.id,
448
+ name: channel.name,
449
+ isPrivate: channel.isPrivate,
450
+ })),
451
+ nextCursor: result.nextCursor || null,
452
+ }),
453
+ );
454
+ });
333
455
  }
334
456
 
335
457
  export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
@@ -440,6 +562,10 @@ export function startSlackInteractionPump(
440
562
  }
441
563
 
442
564
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
565
+ if (entry.triggerKind === "reaction") {
566
+ await processSlackReactionInboxEntry(deps, entry);
567
+ return;
568
+ }
443
569
  const routeKey = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
444
570
  const existing = await getSlackInteractionByRoute(
445
571
  deps.db,
@@ -554,6 +680,326 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
554
680
  }
555
681
  }
556
682
 
683
+ async function processSlackReactionInboxEntry(
684
+ deps: ApiRouteDeps,
685
+ entry: SlackInteractionInboxEntry,
686
+ ) {
687
+ const [workspace, connection, link] = await Promise.all([
688
+ getWorkspace(deps.db, entry.workspaceId),
689
+ getConnectionMetadata(deps.db, entry.workspaceId, entry.connectionId, null),
690
+ getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
691
+ ]);
692
+ const settings = resolveWorkspaceSlackReactionSummonSettings(workspace?.settings);
693
+ if (
694
+ !workspace ||
695
+ !connection ||
696
+ !settings.enabled ||
697
+ entry.text !== settings.emoji ||
698
+ !workspaceSlackReactionChannelAllowed(settings, entry.slackChannelId) ||
699
+ !hasOpenGeniSlackReactionScope(connection.grantedScopes) ||
700
+ !link
701
+ ) {
702
+ return;
703
+ }
704
+ const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
705
+ principalKind: "human_session",
706
+ });
707
+ if (!grant || grant.accountId !== entry.accountId) {
708
+ throw new SlackInteractionPermanentError("identity_access_revoked");
709
+ }
710
+ if (
711
+ !hasPermission(grant.permissions, "sessions:create") ||
712
+ !hasPermission(grant.permissions, "sessions:control")
713
+ ) {
714
+ throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
715
+ }
716
+
717
+ const clientEventId = `slack:${entry.providerEventId}`;
718
+ const durableInteraction = await getSlackInteractionByClientEventId(
719
+ deps.db,
720
+ entry.workspaceId,
721
+ entry.connectionId,
722
+ clientEventId,
723
+ );
724
+ if (durableInteraction) {
725
+ const { interaction, eventSessionId } = durableInteraction;
726
+ const shouldRepairAcknowledgement =
727
+ interaction.sessionId === null ||
728
+ interaction.triggeringProviderEventId === entry.providerEventId;
729
+ if (interaction.sessionId !== null && interaction.sessionId !== eventSessionId) {
730
+ throw new SlackInteractionPermanentError("slack_reaction_event_conflict");
731
+ }
732
+ if (interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId) {
733
+ throw new SlackInteractionPermanentError("session_owner_mismatch");
734
+ }
735
+ if (interaction.sessionId === null && interaction.owningSubjectId !== grant.subjectId) {
736
+ throw new SlackInteractionRetryableError("slack_route_creation_pending");
737
+ }
738
+ const boundInteraction =
739
+ interaction.sessionId !== null
740
+ ? interaction
741
+ : await bindSlackInteractionSession(deps.db, {
742
+ ...interaction,
743
+ owningSubjectId: grant.subjectId,
744
+ sessionId: eventSessionId,
745
+ });
746
+ if (!boundInteraction) {
747
+ throw new Error("Durable Slack reaction route could not bind its reserved session");
748
+ }
749
+ await reopenSlackInteractionDelivery(deps.db, boundInteraction);
750
+ if (shouldRepairAcknowledgement) {
751
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
752
+ accountId: entry.accountId,
753
+ workspaceId: entry.workspaceId,
754
+ connectionId: entry.connectionId,
755
+ subjectId: grant.subjectId,
756
+ sessionId: eventSessionId,
757
+ });
758
+ await acknowledgeSlackReactionSession(deps, client, boundInteraction, settings.emoji);
759
+ }
760
+ return;
761
+ }
762
+
763
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
764
+ accountId: entry.accountId,
765
+ workspaceId: entry.workspaceId,
766
+ connectionId: entry.connectionId,
767
+ subjectId: grant.subjectId,
768
+ });
769
+ const context = await client.reactionMessageContext({
770
+ channelId: entry.slackChannelId,
771
+ messageTimestamp: entry.slackMessageTs,
772
+ checkpoint: entry.reactionContextCheckpoint,
773
+ checkpointBinding: {
774
+ inboxId: entry.id,
775
+ accountId: entry.accountId,
776
+ workspaceId: entry.workspaceId,
777
+ connectionId: entry.connectionId,
778
+ providerEventId: entry.providerEventId,
779
+ providerMessageId: entry.providerMessageId,
780
+ slackTeamId: entry.slackTeamId,
781
+ slackChannelId: entry.slackChannelId,
782
+ slackMessageTs: entry.slackMessageTs,
783
+ },
784
+ saveCheckpoint: async (checkpoint) => {
785
+ if (!entry.claimHolderId) {
786
+ throw new Error("Slack reaction inbox checkpoint requires an active claim");
787
+ }
788
+ const saved = await saveSlackInteractionInboxReactionCheckpoint(deps.db, {
789
+ entry,
790
+ claimHolderId: entry.claimHolderId,
791
+ checkpoint,
792
+ });
793
+ if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
794
+ },
795
+ });
796
+ const preparedEntry: SlackInteractionInboxEntry = {
797
+ ...entry,
798
+ slackThreadTs: context.threadTimestamp,
799
+ text: slackReactionTaskText(context),
800
+ };
801
+ const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
802
+ const existing = await getSlackInteractionByRoute(
803
+ deps.db,
804
+ entry.workspaceId,
805
+ entry.connectionId,
806
+ routeKey,
807
+ );
808
+ if (existing?.sessionId) {
809
+ await continueSlackReactionSession(deps, grant, existing, preparedEntry);
810
+ return;
811
+ }
812
+ const { interaction } = await getOrCreateSlackInteraction(deps.db, {
813
+ accountId: entry.accountId,
814
+ workspaceId: entry.workspaceId,
815
+ connectionId: entry.connectionId,
816
+ slackTeamId: entry.slackTeamId,
817
+ slackChannelId: entry.slackChannelId,
818
+ slackThreadTs: context.threadTimestamp,
819
+ routeKey,
820
+ triggeringProviderEventId: entry.providerEventId,
821
+ owningSubjectId: grant.subjectId,
822
+ visibility: "workspace",
823
+ });
824
+ if (interaction.sessionId) {
825
+ await continueSlackReactionSession(deps, grant, interaction, preparedEntry);
826
+ return;
827
+ }
828
+ if (interaction.owningSubjectId !== grant.subjectId) {
829
+ // The route insert freezes the causal owner. Another linked user may race
830
+ // the same previously-unmapped Slack thread on a different API replica,
831
+ // but must not create the reserved session under their own authority.
832
+ // Retry until the owner binds the session; the later user can then
833
+ // continue the workspace-visible interaction through the normal path.
834
+ throw new SlackInteractionRetryableError("slack_route_creation_pending");
835
+ }
836
+ const preferredModel = await getLatestSessionModelForSubject(
837
+ deps.db,
838
+ entry.workspaceId,
839
+ grant.subjectId,
840
+ );
841
+ let session: Awaited<ReturnType<typeof createSessionForRequest>>;
842
+ try {
843
+ session = await createSessionForRequest(deps, grant, entry.workspaceId, {
844
+ requestedSessionId: interaction.sessionReservationId,
845
+ initialMessage: preparedEntry.text,
846
+ turnInstructions: SLACK_TASK_INSTRUCTIONS,
847
+ // The exact reacted message and bounded containing thread are already in
848
+ // the prompt; do not expose general Slack history tools for this trigger.
849
+ firstPartyMcpTools: [...DEFAULT_FIRST_PARTY_MCP_TOOLS],
850
+ ...(preferredModel ? { model: preferredModel } : {}),
851
+ // Every reaction entry converging on this route must use the same create
852
+ // key. This closes the same-owner multi-event race while the owner check
853
+ // above prevents a different subject from winning creation authority.
854
+ idempotencyKey: `slack-interaction:${interaction.id}`,
855
+ clientEventId: `slack:${entry.providerEventId}`,
856
+ });
857
+ // The route-wide create key converges every replica on one reserved
858
+ // session, but its first writer's initial message is the only event created
859
+ // by that operation. Replay this exact Slack event through the normal
860
+ // per-message idempotency boundary: the create winner is recognized as the
861
+ // initial event, while every distinct loser appends one durable task.
862
+ await acceptSlackReactionTask(deps, grant, session.id, preparedEntry);
863
+ } catch (error) {
864
+ if (error instanceof HTTPException) {
865
+ await client.postMessage({
866
+ operationId: deterministicUuid(`slack-reaction-admission-failed:${interaction.id}`),
867
+ channelId: entry.slackChannelId,
868
+ threadTimestamp: context.threadTimestamp,
869
+ text: slackAdmissionFailureText(error),
870
+ });
871
+ }
872
+ throw error;
873
+ }
874
+ const bound = await bindSlackInteractionSession(deps.db, {
875
+ ...interaction,
876
+ owningSubjectId: grant.subjectId,
877
+ sessionId: session.id,
878
+ });
879
+ if (!bound) throw new Error("Slack reaction route could not bind its durable session");
880
+ await acknowledgeSlackReactionSession(deps, client, bound, settings.emoji);
881
+ }
882
+
883
+ async function acknowledgeSlackReactionSession(
884
+ deps: ApiRouteDeps,
885
+ client: OpenGeniSlackBotClient,
886
+ interaction: SlackInteraction,
887
+ emoji: string,
888
+ ) {
889
+ if (!interaction.sessionId) {
890
+ throw new Error("Slack reaction acknowledgement requires a bound session");
891
+ }
892
+ await client.postMessage({
893
+ operationId: deterministicUuid(`slack-reaction-ack:${interaction.id}`),
894
+ channelId: interaction.slackChannelId,
895
+ threadTimestamp: interaction.slackThreadTs,
896
+ text: `OpenGeni started from the :${emoji}: reaction. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} If the intended action is unclear, OpenGeni will ask in this thread. Reply here to continue, or reply \`stop\` to stop.`,
897
+ });
898
+ }
899
+
900
+ type SlackReactionMessageContext = Awaited<
901
+ ReturnType<OpenGeniSlackBotClient["reactionMessageContext"]>
902
+ >;
903
+
904
+ export function slackReactionTaskText(context: SlackReactionMessageContext) {
905
+ const reactedLine = slackReactionMessageLine(context.reactedMessage, true);
906
+ const surroundingLines = context.messages
907
+ .slice(0, MAX_SLACK_REACTION_CONTEXT_MESSAGES)
908
+ .filter((message) => message.timestamp !== context.reactedMessage.timestamp)
909
+ .map((message) => slackReactionMessageLine(message, false));
910
+ const truncationNotice =
911
+ "The containing thread was truncated at the bounded Slack context limit.";
912
+ let prompt = [
913
+ "A linked, authorized Slack user explicitly summoned OpenGeni by reacting to one message.",
914
+ "Use only the exact reacted message and bounded containing-thread context below.",
915
+ "If the intended action is ambiguous, ask a concise clarifying question in the originating thread before taking action.",
916
+ "Do not infer permission to ingest or persist this Slack content into Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter.",
917
+ "",
918
+ "Exact reacted message:",
919
+ reactedLine,
920
+ "",
921
+ "Bounded surrounding thread context:",
922
+ ].join("\n");
923
+ let truncated = context.truncated;
924
+ for (const line of surroundingLines) {
925
+ const candidate = `${prompt}\n${line}`;
926
+ if (candidate.length + 1 + truncationNotice.length > MAX_SLACK_INPUT_CHARS) {
927
+ truncated = true;
928
+ break;
929
+ }
930
+ prompt = candidate;
931
+ }
932
+ return truncated ? `${prompt}\n${truncationNotice}` : prompt;
933
+ }
934
+
935
+ function slackReactionMessageLine(
936
+ message: SlackReactionMessageContext["reactedMessage"],
937
+ reacted: boolean,
938
+ ) {
939
+ const actor = message.userId || (message.botId ? `bot:${message.botId}` : "unknown");
940
+ const text = message.text.trim() || "(no text)";
941
+ const fileLabels: string[] = [];
942
+ let fileChars = 0;
943
+ let filesTruncated = false;
944
+ for (const file of message.files) {
945
+ const label = file.title || file.name || file.id;
946
+ if (!label) continue;
947
+ const addedChars = label.length + (fileLabels.length > 0 ? 2 : 0);
948
+ if (fileChars + addedChars > MAX_SLACK_REACTION_FILE_SUMMARY_CHARS) {
949
+ filesTruncated = true;
950
+ break;
951
+ }
952
+ fileLabels.push(label);
953
+ fileChars += addedChars;
954
+ }
955
+ const fileSummary = fileLabels.length
956
+ ? ` Files: ${fileLabels.join(", ")}${filesTruncated ? ", …" : ""}.`
957
+ : "";
958
+ return `- ${message.timestamp || "unknown"} ${actor}${reacted ? " [reacted message]" : ""}: ${text}${fileSummary}`;
959
+ }
960
+
961
+ async function continueSlackReactionSession(
962
+ deps: ApiRouteDeps,
963
+ grant: AccessGrant,
964
+ interaction: SlackInteraction,
965
+ entry: SlackInteractionInboxEntry,
966
+ ) {
967
+ if (
968
+ !interaction.sessionId ||
969
+ (interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId)
970
+ ) {
971
+ throw new SlackInteractionPermanentError("session_owner_mismatch");
972
+ }
973
+ await reopenSlackInteractionDelivery(deps.db, interaction);
974
+ await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry);
975
+ }
976
+
977
+ async function acceptSlackReactionTask(
978
+ deps: ApiRouteDeps,
979
+ grant: AccessGrant,
980
+ sessionId: string,
981
+ entry: SlackInteractionInboxEntry,
982
+ ) {
983
+ const clientEventId = `slack:${entry.providerEventId}`;
984
+ const existing = await getSessionEventByClientEventId(
985
+ deps.db,
986
+ entry.workspaceId,
987
+ sessionId,
988
+ clientEventId,
989
+ );
990
+ if (existing) {
991
+ if (existing.type !== "user.message") {
992
+ throw new SlackInteractionPermanentError("slack_reaction_event_conflict");
993
+ }
994
+ return;
995
+ }
996
+ await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
997
+ text: entry.text,
998
+ turnInstructions: SLACK_TASK_INSTRUCTIONS,
999
+ clientEventId,
1000
+ });
1001
+ }
1002
+
557
1003
  async function continueSlackSession(
558
1004
  deps: ApiRouteDeps,
559
1005
  grant: AccessGrant,
@@ -1012,6 +1458,8 @@ function safePayloadText(payload: unknown, field: string) {
1012
1458
 
1013
1459
  function safeErrorCode(error: unknown) {
1014
1460
  if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
1461
+ if (error instanceof SlackInteractionPermanentError) return error.code.slice(0, 128);
1462
+ if (error instanceof SlackInteractionRetryableError) return error.code.slice(0, 128);
1015
1463
  if (error instanceof HTTPException) return `http_${error.status}`;
1016
1464
  const raw = error instanceof Error ? error.name : "slack_interaction_error";
1017
1465
  return (
@@ -1032,7 +1480,19 @@ function slackAdmissionFailureText(error: HTTPException) {
1032
1480
  return "OpenGeni could not start this task because the workspace rejected the session settings. Open OpenGeni, select an available model, and try again.";
1033
1481
  }
1034
1482
 
1035
- class SlackInteractionPermanentError extends Error {}
1483
+ class SlackInteractionPermanentError extends Error {
1484
+ constructor(readonly code: string) {
1485
+ super(code);
1486
+ this.name = "SlackInteractionPermanentError";
1487
+ }
1488
+ }
1489
+
1490
+ class SlackInteractionRetryableError extends Error {
1491
+ constructor(readonly code: string) {
1492
+ super(code);
1493
+ this.name = "SlackInteractionRetryableError";
1494
+ }
1495
+ }
1036
1496
 
1037
1497
  function permanentSlackInteractionError(error: unknown) {
1038
1498
  return error instanceof SlackInteractionPermanentError || error instanceof HTTPException;
@@ -1048,6 +1508,11 @@ const PERMANENT_SLACK_DELIVERY_CODES = new Set([
1048
1508
  "message_not_found",
1049
1509
  "not_authed",
1050
1510
  "not_in_channel",
1511
+ "reaction_checkpoint_invalid",
1512
+ "reaction_checkpoint_too_large",
1513
+ "reaction_pagination_exhausted",
1514
+ "reaction_pagination_invalid",
1515
+ "slack_connect_unsupported",
1051
1516
  "token_expired",
1052
1517
  "token_revoked",
1053
1518
  ]);
@@ -64,7 +64,7 @@ import {
64
64
  import {
65
65
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
66
66
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
67
- OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
67
+ OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
68
68
  } from "@opengeni/contracts";
69
69
  import { createSignedState, readSignedState } from "@opengeni/github";
70
70
  import { oauthStateTtlMs, requireIntegrationsStateSecret } from "../integrations/oauth-client";
@@ -152,7 +152,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
152
152
  });
153
153
  const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
154
154
  authorizationUrl.searchParams.set("client_id", slack.clientId);
155
- authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUIRED_SCOPES.join(","));
155
+ authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
156
156
  authorizationUrl.searchParams.set("redirect_uri", redirectUri);
157
157
  authorizationUrl.searchParams.set("state", state);
158
158
  return c.json(