@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-FGNCK7HE.js → chunk-MWBF2GXL.js} +1849 -58
- package/dist/chunk-MWBF2GXL.js.map +1 -0
- package/dist/codex-realtime.d.ts +43 -0
- package/dist/gateway-realtime.d.ts +24 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-bot.d.ts +122 -0
- package/dist/integrations/slack-interactions.d.ts +5 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/session-realtime-context.d.ts +19 -0
- package/package.json +12 -12
- package/src/codex-realtime.ts +367 -0
- package/src/gateway-realtime.ts +143 -0
- package/src/integrations/google-drive.ts +22 -12
- package/src/integrations/slack-bot.ts +536 -0
- package/src/integrations/slack-interactions.ts +467 -2
- package/src/routes/connections.ts +2 -2
- package/src/routes/sessions.ts +622 -11
- package/src/routes/workspaces.ts +56 -1
- package/src/session-realtime-context.ts +134 -0
- package/dist/chunk-FGNCK7HE.js.map +0 -1
|
@@ -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 {
|
|
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
|
-
|
|
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",
|
|
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(
|