@opengeni/api-router 0.15.1 → 0.15.4
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.d.ts +1 -0
- package/dist/app.js +3 -1
- package/dist/{chunk-UVL2KH56.js → chunk-ICRZC3JH.js} +10233 -8946
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-bot.d.ts +9 -1
- package/dist/integrations/slack-interactions.d.ts +18 -1
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/package.json +10 -10
- package/src/app.ts +56 -0
- package/src/http/auth.ts +5 -0
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +20 -3
- package/src/integrations/slack-interactions.ts +204 -27
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +174 -0
- package/src/routes/social.ts +67 -2
- package/dist/chunk-UVL2KH56.js.map +0 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
4
|
+
type AccessGrant,
|
|
5
|
+
type FirstPartyMcpToolName,
|
|
6
|
+
type HumanInputQuestion,
|
|
7
|
+
type SessionEvent,
|
|
8
|
+
} from "@opengeni/contracts";
|
|
3
9
|
import {
|
|
4
10
|
acceptSessionHumanInputResponse,
|
|
5
11
|
advanceSlackInteractionDelivery,
|
|
@@ -8,6 +14,7 @@ import {
|
|
|
8
14
|
claimSlackInteractionProgressDelivery,
|
|
9
15
|
claimSlackInteractionInbox,
|
|
10
16
|
closeSlackInteractionDelivery,
|
|
17
|
+
deferSlackInteractionDelivery,
|
|
11
18
|
deleteSlackBotUserLink,
|
|
12
19
|
enqueueSlackInteractionInbox,
|
|
13
20
|
getOrCreateSlackInteraction,
|
|
@@ -39,7 +46,7 @@ import {
|
|
|
39
46
|
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
40
47
|
import type { Context, Hono } from "hono";
|
|
41
48
|
import { HTTPException } from "hono/http-exception";
|
|
42
|
-
import { createOpenGeniSlackBotInteractionClient } from "./slack-bot";
|
|
49
|
+
import { createOpenGeniSlackBotInteractionClient, SlackBotProviderError } from "./slack-bot";
|
|
43
50
|
|
|
44
51
|
export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
|
|
45
52
|
export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
|
|
@@ -55,8 +62,11 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
|
55
62
|
const MAX_SLACK_TEXT_CHARS = 3_500;
|
|
56
63
|
const MAX_SLACK_INPUT_CHARS = 8_000;
|
|
57
64
|
const MAX_PROGRESS_MESSAGES = 3;
|
|
65
|
+
const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
|
|
58
66
|
const INBOX_LEASE_MS = 30_000;
|
|
59
67
|
const DELIVERY_LEASE_MS = 30_000;
|
|
68
|
+
const MAX_DELIVERY_ATTEMPTS = 8;
|
|
69
|
+
const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
|
|
60
70
|
export const SLACK_TASK_INSTRUCTIONS = [
|
|
61
71
|
"This turn originated from Slack. Slack message and thread context is task-local only.",
|
|
62
72
|
"Do not write Slack context to Documents, Knowledge, Memory, preferences, Workspace Charter, instructions, or policy unless a separate explicit authorized user action requests it.",
|
|
@@ -64,6 +74,23 @@ export const SLACK_TASK_INSTRUCTIONS = [
|
|
|
64
74
|
"Keep user-visible output concise, bounded, and safe to send back to Slack.",
|
|
65
75
|
].join(" ");
|
|
66
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Slack-originated tasks may retrieve the workspace bot's bounded read surface
|
|
79
|
+
* on demand. Connector tools are explicit-only, so freeze that narrow context
|
|
80
|
+
* selection at session creation while keeping Slack mutations out of the model
|
|
81
|
+
* surface; interaction delivery remains owned by the durable delivery pump.
|
|
82
|
+
*/
|
|
83
|
+
export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
|
|
84
|
+
...DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
85
|
+
"slack_bot_list_channels",
|
|
86
|
+
"slack_bot_channel_history",
|
|
87
|
+
"slack_bot_thread_replies",
|
|
88
|
+
"slack_bot_list_users",
|
|
89
|
+
"slack_bot_list_files",
|
|
90
|
+
"slack_bot_file_info",
|
|
91
|
+
"slack_bot_file_content",
|
|
92
|
+
] satisfies readonly FirstPartyMcpToolName[];
|
|
93
|
+
|
|
67
94
|
export type NormalizedSlackInteraction = {
|
|
68
95
|
providerEventId: string;
|
|
69
96
|
providerMessageId: string;
|
|
@@ -181,6 +208,29 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
181
208
|
throw new HTTPException(403, {
|
|
182
209
|
message: "Slack installation unavailable",
|
|
183
210
|
});
|
|
211
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
212
|
+
accountId: installation.accountId,
|
|
213
|
+
workspaceId: installation.workspaceId,
|
|
214
|
+
connectionId: installation.connectionId,
|
|
215
|
+
subjectId: "service:slack-interaction",
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
await client.verifyChannelAccess(entry.slackChannelId);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof SlackBotProviderError && error.code === "not_in_channel") {
|
|
221
|
+
return c.text(
|
|
222
|
+
"OpenGeni is not a member of this channel. Add @OpenGeni, then run /opengeni again.",
|
|
223
|
+
200,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
// Slash commands are not replayed through the Events API. A transient
|
|
227
|
+
// membership preflight failure must therefore fall through to the
|
|
228
|
+
// durable inbox, whose normal claim/backoff path retries the same task.
|
|
229
|
+
// Permanent provider/local failures remain an honest request failure.
|
|
230
|
+
if (!(error instanceof SlackBotProviderError) || permanentSlackDeliveryError(error)) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
184
234
|
await enqueueNormalizedSlackInteraction(deps, installation, entry);
|
|
185
235
|
return c.text("OpenGeni accepted this task and will reply in a thread.", 200);
|
|
186
236
|
});
|
|
@@ -231,18 +281,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
231
281
|
|
|
232
282
|
app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
|
|
233
283
|
const workspaceId = c.req.param("workspaceId");
|
|
234
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
284
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
235
285
|
const body = record(await c.req.json().catch(() => null));
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
286
|
+
const linkToken = boundedString(body?.linkToken, 2_048);
|
|
287
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
288
|
+
const link =
|
|
289
|
+
linkToken && signingSecret ? verifySlackUserLinkToken(signingSecret, linkToken) : null;
|
|
290
|
+
if (!link || link.workspaceId !== workspaceId) {
|
|
240
291
|
throw new HTTPException(400, {
|
|
241
|
-
message: "invalid Slack identity link
|
|
292
|
+
message: "invalid or expired Slack identity link",
|
|
242
293
|
});
|
|
243
294
|
}
|
|
244
|
-
const route = await resolveSlackInstallationRoute(deps.db, slackTeamId);
|
|
245
|
-
if (!route || route.workspaceId !== workspaceId || route.connectionId !== connectionId) {
|
|
295
|
+
const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
|
|
296
|
+
if (!route || route.workspaceId !== workspaceId || route.connectionId !== link.connectionId) {
|
|
246
297
|
throw new HTTPException(404, {
|
|
247
298
|
message: "Slack installation not found",
|
|
248
299
|
});
|
|
@@ -251,9 +302,9 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
251
302
|
await saveSlackBotUserLink(deps.db, {
|
|
252
303
|
accountId: grant.accountId,
|
|
253
304
|
workspaceId,
|
|
254
|
-
connectionId,
|
|
255
|
-
slackTeamId,
|
|
256
|
-
slackUserId,
|
|
305
|
+
connectionId: link.connectionId,
|
|
306
|
+
slackTeamId: link.slackTeamId,
|
|
307
|
+
slackUserId: link.slackUserId,
|
|
257
308
|
subjectId: grant.subjectId,
|
|
258
309
|
linkedBySubjectId: grant.subjectId,
|
|
259
310
|
}),
|
|
@@ -293,7 +344,11 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
|
|
|
293
344
|
});
|
|
294
345
|
} catch (error) {
|
|
295
346
|
const code = safeErrorCode(error);
|
|
296
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
entry.attemptCount >= 5 ||
|
|
349
|
+
permanentSlackInteractionError(error) ||
|
|
350
|
+
permanentSlackDeliveryError(error)
|
|
351
|
+
) {
|
|
297
352
|
await settleSlackInteractionInbox(deps.db, {
|
|
298
353
|
entry,
|
|
299
354
|
claimHolderId: holder,
|
|
@@ -305,6 +360,7 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
|
|
|
305
360
|
entry,
|
|
306
361
|
claimHolderId: holder,
|
|
307
362
|
errorCode: code,
|
|
363
|
+
retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, entry.attemptCount)),
|
|
308
364
|
});
|
|
309
365
|
}
|
|
310
366
|
}
|
|
@@ -320,11 +376,29 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
|
|
|
320
376
|
if (!interaction) return false;
|
|
321
377
|
try {
|
|
322
378
|
await deliverSlackSessionEvents(deps, interaction, deliveryHolder);
|
|
323
|
-
} catch {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
379
|
+
} catch (error) {
|
|
380
|
+
const errorCode = slackDeliveryErrorCode(error);
|
|
381
|
+
if (
|
|
382
|
+
interaction.deliveryAttemptCount >= MAX_DELIVERY_ATTEMPTS ||
|
|
383
|
+
permanentSlackDeliveryError(error)
|
|
384
|
+
) {
|
|
385
|
+
await closeSlackInteractionDelivery(deps.db, {
|
|
386
|
+
...interaction,
|
|
387
|
+
claimHolderId: deliveryHolder,
|
|
388
|
+
sequence: interaction.lastDeliveredSessionEventSequence,
|
|
389
|
+
state: "failed",
|
|
390
|
+
errorCode,
|
|
391
|
+
}).catch(() => undefined);
|
|
392
|
+
} else {
|
|
393
|
+
await deferSlackInteractionDelivery(deps.db, {
|
|
394
|
+
...interaction,
|
|
395
|
+
claimHolderId: deliveryHolder,
|
|
396
|
+
retryAt: new Date(
|
|
397
|
+
Date.now() + slackDeliveryRetryMs(error, interaction.deliveryAttemptCount),
|
|
398
|
+
),
|
|
399
|
+
errorCode,
|
|
400
|
+
}).catch(() => undefined);
|
|
401
|
+
}
|
|
328
402
|
}
|
|
329
403
|
return true;
|
|
330
404
|
}
|
|
@@ -383,8 +457,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
383
457
|
if (!link) {
|
|
384
458
|
await client.postMessage({
|
|
385
459
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
386
|
-
|
|
387
|
-
...(entry.slackThreadTs ? { threadTimestamp: entry.slackThreadTs } : {}),
|
|
460
|
+
userId: entry.slackUserId,
|
|
388
461
|
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
|
|
389
462
|
});
|
|
390
463
|
return;
|
|
@@ -423,6 +496,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
423
496
|
requestedSessionId: interaction.sessionReservationId,
|
|
424
497
|
initialMessage: entry.text,
|
|
425
498
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
499
|
+
firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
|
|
426
500
|
idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
|
|
427
501
|
clientEventId: `slack:${entry.providerEventId}`,
|
|
428
502
|
});
|
|
@@ -777,15 +851,82 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
|
|
|
777
851
|
|
|
778
852
|
function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
779
853
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
url
|
|
783
|
-
url.searchParams.set("
|
|
784
|
-
url.searchParams.set("teamId", entry.slackTeamId);
|
|
785
|
-
url.searchParams.set("userId", entry.slackUserId);
|
|
854
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
855
|
+
if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
|
|
856
|
+
const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
|
|
857
|
+
url.searchParams.set("slack_link", createSlackUserLinkToken(signingSecret, entry));
|
|
786
858
|
return url.toString();
|
|
787
859
|
}
|
|
788
860
|
|
|
861
|
+
type SlackUserLinkToken = {
|
|
862
|
+
workspaceId: string;
|
|
863
|
+
connectionId: string;
|
|
864
|
+
slackTeamId: string;
|
|
865
|
+
slackUserId: string;
|
|
866
|
+
expiresAt: number;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
export function createSlackUserLinkToken(
|
|
870
|
+
signingSecret: string,
|
|
871
|
+
entry: Pick<
|
|
872
|
+
SlackInteractionInboxEntry,
|
|
873
|
+
"workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
|
|
874
|
+
>,
|
|
875
|
+
nowMs = Date.now(),
|
|
876
|
+
) {
|
|
877
|
+
const payload = Buffer.from(
|
|
878
|
+
JSON.stringify({
|
|
879
|
+
workspaceId: entry.workspaceId,
|
|
880
|
+
connectionId: entry.connectionId,
|
|
881
|
+
slackTeamId: entry.slackTeamId,
|
|
882
|
+
slackUserId: entry.slackUserId,
|
|
883
|
+
expiresAt: nowMs + SLACK_USER_LINK_TTL_MS,
|
|
884
|
+
} satisfies SlackUserLinkToken),
|
|
885
|
+
"utf8",
|
|
886
|
+
).toString("base64url");
|
|
887
|
+
const signature = createHmac("sha256", signingSecret).update(payload).digest("base64url");
|
|
888
|
+
return `${payload}.${signature}`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function verifySlackUserLinkToken(
|
|
892
|
+
signingSecret: string,
|
|
893
|
+
token: string,
|
|
894
|
+
nowMs = Date.now(),
|
|
895
|
+
): SlackUserLinkToken | null {
|
|
896
|
+
const [payload, signature, extra] = token.split(".");
|
|
897
|
+
if (!payload || !signature || extra || payload.length > 1_500 || signature.length > 128)
|
|
898
|
+
return null;
|
|
899
|
+
const expected = createHmac("sha256", signingSecret).update(payload).digest("base64url");
|
|
900
|
+
const actualBytes = Buffer.from(signature, "utf8");
|
|
901
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
902
|
+
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const value = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
907
|
+
const workspaceId = boundedString(value?.workspaceId, 64);
|
|
908
|
+
const connectionId = boundedString(value?.connectionId, 64);
|
|
909
|
+
const slackTeamId = boundedString(value?.slackTeamId, 64);
|
|
910
|
+
const slackUserId = boundedString(value?.slackUserId, 64);
|
|
911
|
+
const expiresAt = value?.expiresAt;
|
|
912
|
+
if (
|
|
913
|
+
!workspaceId ||
|
|
914
|
+
!connectionId ||
|
|
915
|
+
!slackTeamId ||
|
|
916
|
+
!slackUserId ||
|
|
917
|
+
typeof expiresAt !== "number" ||
|
|
918
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
919
|
+
expiresAt < nowMs ||
|
|
920
|
+
expiresAt > nowMs + SLACK_USER_LINK_TTL_MS
|
|
921
|
+
) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
return { workspaceId, connectionId, slackTeamId, slackUserId, expiresAt };
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
789
930
|
function slackRouteKey(channelId: string, threadTs: string) {
|
|
790
931
|
return `${channelId}:${threadTs}`;
|
|
791
932
|
}
|
|
@@ -840,6 +981,7 @@ function safePayloadText(payload: unknown, field: string) {
|
|
|
840
981
|
}
|
|
841
982
|
|
|
842
983
|
function safeErrorCode(error: unknown) {
|
|
984
|
+
if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
|
|
843
985
|
const raw = error instanceof Error ? error.name : "slack_interaction_error";
|
|
844
986
|
return (
|
|
845
987
|
raw
|
|
@@ -854,3 +996,38 @@ class SlackInteractionPermanentError extends Error {}
|
|
|
854
996
|
function permanentSlackInteractionError(error: unknown) {
|
|
855
997
|
return error instanceof SlackInteractionPermanentError || error instanceof HTTPException;
|
|
856
998
|
}
|
|
999
|
+
|
|
1000
|
+
const PERMANENT_SLACK_DELIVERY_CODES = new Set([
|
|
1001
|
+
"account_inactive",
|
|
1002
|
+
"cannot_reply_to_message",
|
|
1003
|
+
"channel_not_found",
|
|
1004
|
+
"invalid_auth",
|
|
1005
|
+
"invalid_ts",
|
|
1006
|
+
"is_archived",
|
|
1007
|
+
"message_not_found",
|
|
1008
|
+
"not_authed",
|
|
1009
|
+
"not_in_channel",
|
|
1010
|
+
"token_expired",
|
|
1011
|
+
"token_revoked",
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
function permanentSlackDeliveryError(error: unknown) {
|
|
1015
|
+
if (!(error instanceof SlackBotProviderError)) return false;
|
|
1016
|
+
if (PERMANENT_SLACK_DELIVERY_CODES.has(error.code)) return true;
|
|
1017
|
+
const status = /^http_(\d{3})$/.exec(error.code)?.[1];
|
|
1018
|
+
return status
|
|
1019
|
+
? Number(status) >= 400 && Number(status) < 500 && status !== "408" && status !== "429"
|
|
1020
|
+
: false;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function slackDeliveryRetryMs(error: unknown, attemptCount: number) {
|
|
1024
|
+
if (error instanceof SlackBotProviderError && error.retryAfterMs) {
|
|
1025
|
+
return error.retryAfterMs;
|
|
1026
|
+
}
|
|
1027
|
+
return Math.min(1_000 * 2 ** Math.max(0, attemptCount - 1), MAX_DELIVERY_RETRY_MS);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function slackDeliveryErrorCode(error: unknown) {
|
|
1031
|
+
if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
|
|
1032
|
+
return safeErrorCode(error);
|
|
1033
|
+
}
|