@opengeni/api-router 2.1.0-canary.0 → 2.3.2-canary.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/auth/managed-auth.d.ts +5 -2
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/organization-user-setup.d.ts +57 -0
- package/dist/{chunk-QKDFBBUE.js → chunk-IBV7Z6F4.js} +3872 -1845
- package/dist/chunk-IBV7Z6F4.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-app-home.d.ts +1 -1
- package/dist/integrations/slack-bot.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +35 -2
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/routes/automations.d.ts +13 -0
- package/dist/routes/insights.d.ts +2 -1
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/package.json +18 -18
- package/src/app.ts +64 -5
- package/src/auth/managed-auth.ts +29 -34
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/organization-user-setup.ts +217 -0
- package/src/http/auth.ts +15 -0
- package/src/http/sse.ts +62 -13
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/integrations/slack-bot.ts +5 -0
- package/src/integrations/slack-interactions.ts +653 -71
- package/src/integrations/slack-routing.ts +25 -12
- package/src/mcp/company-brain-governed-writes.ts +4 -4
- package/src/mcp/company-profile-agent-admin.ts +11 -18
- package/src/mcp/remember.ts +4 -4
- package/src/mcp/server.ts +50 -4
- package/src/mcp/session-view.ts +8 -2
- package/src/routes/automations.ts +3 -3
- package/src/routes/documents.ts +136 -3
- package/src/routes/insights.ts +61 -19
- package/src/routes/managed-onboarding.ts +317 -0
- package/src/routes/organization-memberships.ts +212 -155
- package/src/routes/pr-review-github.ts +844 -0
- package/src/routes/pr-review.ts +20 -0
- package/src/routes/rigs.ts +37 -4
- package/src/routes/sessions.ts +101 -0
- package/src/sandbox/channel-a.ts +34 -7
- package/src/sandbox/viewer.ts +47 -11
- package/dist/chunk-QKDFBBUE.js.map +0 -1
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
evaluateSlackTaskPolicy,
|
|
10
10
|
resolveWorkspaceSlackOrchestrationNoticeSettings,
|
|
11
11
|
resolveWorkspaceSlackReactionSummonSettings,
|
|
12
|
+
SlackChannelRouteListResponse,
|
|
12
13
|
SlackReactionChannelListResponse,
|
|
14
|
+
UpdateSlackChannelRoutesRequest,
|
|
13
15
|
SlackUserLinkAccessMutationRequest,
|
|
14
16
|
SlackUserLinkAccessRequest,
|
|
15
17
|
type AccessGrant,
|
|
@@ -61,7 +63,15 @@ import {
|
|
|
61
63
|
getSlackInteractionById,
|
|
62
64
|
getSlackInteractionByConnectionRoute,
|
|
63
65
|
probeSlackActionHandleTenancy,
|
|
66
|
+
openSlackRoutePrompt,
|
|
67
|
+
getSlackRoutePromptByOption,
|
|
68
|
+
settleSlackRoutePrompt,
|
|
69
|
+
answerSlackRoutePrompt,
|
|
70
|
+
type SlackRoutableWorkspace,
|
|
64
71
|
getSlackChannelRoute,
|
|
72
|
+
listSlackChannelRoutes,
|
|
73
|
+
upsertSlackChannelRoute,
|
|
74
|
+
deleteSlackChannelRoute,
|
|
65
75
|
getSlackUserDmRoute,
|
|
66
76
|
listNamedSubjectSlackRoutableWorkspaces,
|
|
67
77
|
getActiveSlackTaskPolicy,
|
|
@@ -69,11 +79,14 @@ import {
|
|
|
69
79
|
getSessionEventByClientEventId,
|
|
70
80
|
getWorkspace,
|
|
71
81
|
getWorkspaceGrant,
|
|
82
|
+
namedSubjectPersonalWorkspaceId,
|
|
72
83
|
resolveSlackTargetAuthority,
|
|
73
84
|
listSlackInteractionProgressDeliveryEvidence,
|
|
74
85
|
listSessionEventPage,
|
|
75
86
|
listSessionHumanInputRequests,
|
|
76
87
|
listPendingSlackUserLinkAccessRequests,
|
|
88
|
+
listSlackInstallationBindings,
|
|
89
|
+
slackUserLinkAccessRequestTeam,
|
|
77
90
|
rekeySlackInteractionRoute,
|
|
78
91
|
reopenSlackInteractionDelivery,
|
|
79
92
|
reserveSlackInteractionActionHandles,
|
|
@@ -401,6 +414,46 @@ export function slackReactionInboxEntry(
|
|
|
401
414
|
};
|
|
402
415
|
}
|
|
403
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Prove a Slack link token may name this workspace.
|
|
419
|
+
*
|
|
420
|
+
* Before routing, a token could only ever name the installation's own
|
|
421
|
+
* workspace, and both intent routes asserted exactly that. A routed
|
|
422
|
+
* conversation asks for access to the workspace the work would actually land
|
|
423
|
+
* in, which is a different one, so the assertion becomes: the installation
|
|
424
|
+
* binding resolves for the token's team, it is the same connection the token
|
|
425
|
+
* was minted against, and the named workspace belongs to that installation's
|
|
426
|
+
* organization.
|
|
427
|
+
*
|
|
428
|
+
* That is the same fence the route tables carry. It does not widen access by
|
|
429
|
+
* itself: this only decides which workspace an access REQUEST may be raised
|
|
430
|
+
* for, and the request still goes through the ordinary approval path.
|
|
431
|
+
*/
|
|
432
|
+
async function resolveSlackLinkTargetInstallation(
|
|
433
|
+
deps: ApiRouteDeps,
|
|
434
|
+
link: { slackTeamId: string; connectionId: string },
|
|
435
|
+
workspaceId: string,
|
|
436
|
+
): Promise<{
|
|
437
|
+
accountId: string;
|
|
438
|
+
connectionId: string;
|
|
439
|
+
workspaceName: string;
|
|
440
|
+
/** Where the identity link lives, which is not the routed workspace. */
|
|
441
|
+
identityHome: { accountId: string; workspaceId: string };
|
|
442
|
+
} | null> {
|
|
443
|
+
const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
|
|
444
|
+
if (!route || route.connectionId !== link.connectionId || route.accountId.length === 0) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
448
|
+
if (!workspace || workspace.accountId !== route.accountId) return null;
|
|
449
|
+
return {
|
|
450
|
+
accountId: route.accountId,
|
|
451
|
+
connectionId: route.connectionId,
|
|
452
|
+
workspaceName: workspace.name,
|
|
453
|
+
identityHome: { accountId: route.accountId, workspaceId: route.workspaceId },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
404
457
|
export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
405
458
|
app.post("/v1/integrations/slack/events", async (c) => {
|
|
406
459
|
const signed = await readSignedSlackRequest(c, deps);
|
|
@@ -563,22 +616,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
563
616
|
if (!link || link.workspaceId !== workspaceId) {
|
|
564
617
|
throw freshSlackLinkRequired();
|
|
565
618
|
}
|
|
566
|
-
const
|
|
567
|
-
if (
|
|
568
|
-
!route ||
|
|
569
|
-
route.workspaceId !== workspaceId ||
|
|
570
|
-
route.connectionId !== link.connectionId ||
|
|
571
|
-
route.accountId.length === 0
|
|
572
|
-
) {
|
|
573
|
-
throw freshSlackLinkRequired();
|
|
574
|
-
}
|
|
575
|
-
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
576
|
-
if (!workspace || workspace.accountId !== route.accountId) {
|
|
619
|
+
const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
|
|
620
|
+
if (!installation) {
|
|
577
621
|
throw freshSlackLinkRequired();
|
|
578
622
|
}
|
|
579
623
|
try {
|
|
580
624
|
const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
|
|
581
|
-
accountId:
|
|
625
|
+
accountId: installation.accountId,
|
|
582
626
|
workspaceId,
|
|
583
627
|
tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
|
|
584
628
|
connectionId: link.connectionId,
|
|
@@ -592,12 +636,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
592
636
|
workspaceId,
|
|
593
637
|
requestId: prepared.id,
|
|
594
638
|
subjectId: context.subjectId,
|
|
639
|
+
identityHome: installation.identityHome,
|
|
595
640
|
});
|
|
596
641
|
if (!completed) throw freshSlackLinkRequired();
|
|
597
642
|
return c.json(
|
|
598
643
|
SlackUserLinkAccessRequest.parse({
|
|
599
644
|
...completed,
|
|
600
|
-
workspaceDisplayName:
|
|
645
|
+
workspaceDisplayName: installation.workspaceName,
|
|
601
646
|
}),
|
|
602
647
|
201,
|
|
603
648
|
);
|
|
@@ -705,14 +750,33 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
705
750
|
const workspaceId = c.req.param("workspaceId");
|
|
706
751
|
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
707
752
|
const payload = ApproveSlackUserLinkAccessRequest.parse(await c.req.json());
|
|
753
|
+
// The identity link belongs to the installation, which for a routed
|
|
754
|
+
// request is a different workspace from the one being granted. The
|
|
755
|
+
// request row names its Slack team, so the binding resolves from it.
|
|
756
|
+
const requestId = c.req.param("requestId");
|
|
757
|
+
const requestTeam = await slackUserLinkAccessRequestTeam(deps.db, {
|
|
758
|
+
workspaceId,
|
|
759
|
+
requestId,
|
|
760
|
+
});
|
|
761
|
+
const installation = requestTeam
|
|
762
|
+
? await resolveSlackInstallationRoute(deps.db, requestTeam.slackTeamId)
|
|
763
|
+
: null;
|
|
708
764
|
try {
|
|
709
765
|
const request = await approveSlackUserLinkAccessRequest(deps.db, {
|
|
710
766
|
workspaceId,
|
|
711
|
-
requestId
|
|
767
|
+
requestId,
|
|
712
768
|
actorSubjectId: grant.subjectId,
|
|
713
769
|
expectedVersion: payload.expectedVersion,
|
|
714
770
|
idempotencyKey: payload.idempotencyKey,
|
|
715
771
|
permissions: payload.permissions,
|
|
772
|
+
...(installation
|
|
773
|
+
? {
|
|
774
|
+
identityHome: {
|
|
775
|
+
accountId: installation.accountId,
|
|
776
|
+
workspaceId: installation.workspaceId,
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
: {}),
|
|
716
780
|
...(payload.role !== undefined ? { role: payload.role } : {}),
|
|
717
781
|
});
|
|
718
782
|
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
@@ -771,8 +835,8 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
771
835
|
message: "invalid or expired Slack identity link",
|
|
772
836
|
});
|
|
773
837
|
}
|
|
774
|
-
const
|
|
775
|
-
if (!
|
|
838
|
+
const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
|
|
839
|
+
if (!installation) {
|
|
776
840
|
throw new HTTPException(404, {
|
|
777
841
|
message: "Slack installation not found",
|
|
778
842
|
});
|
|
@@ -793,6 +857,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
793
857
|
workspaceId,
|
|
794
858
|
requestId: prepared.id,
|
|
795
859
|
subjectId: grant.subjectId,
|
|
860
|
+
identityHome: installation.identityHome,
|
|
796
861
|
});
|
|
797
862
|
if (completed?.status !== "completed") throw freshSlackLinkRequired();
|
|
798
863
|
const saved = await getSlackBotUserLink(
|
|
@@ -826,6 +891,135 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
826
891
|
},
|
|
827
892
|
);
|
|
828
893
|
|
|
894
|
+
app.get("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
|
|
895
|
+
const workspaceId = c.req.param("workspaceId");
|
|
896
|
+
// Same gate as the reaction-channel allowlist beside it. The response names
|
|
897
|
+
// workspaces across the organization, including ones the reader may hold no
|
|
898
|
+
// grant in, so it is not an ordinary connections read.
|
|
899
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
900
|
+
const connectionId = boundedString(c.req.query("connectionId"), 64);
|
|
901
|
+
if (!connectionId) throw new HTTPException(400, { message: "connectionId is required" });
|
|
902
|
+
const routes = await listSlackChannelRoutes(
|
|
903
|
+
deps.db,
|
|
904
|
+
{ accountId: grant.accountId, workspaceId },
|
|
905
|
+
{ connectionId },
|
|
906
|
+
);
|
|
907
|
+
// Names are resolved once per distinct target rather than per route.
|
|
908
|
+
const names = new Map<string, string | null>();
|
|
909
|
+
for (const route of routes) {
|
|
910
|
+
if (names.has(route.targetWorkspaceId)) continue;
|
|
911
|
+
names.set(
|
|
912
|
+
route.targetWorkspaceId,
|
|
913
|
+
(await getWorkspace(deps.db, route.targetWorkspaceId))?.name ?? null,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
return c.json(
|
|
917
|
+
SlackChannelRouteListResponse.parse({
|
|
918
|
+
routingEnabled: deps.settings.slackWorkspaceRoutingEnabled,
|
|
919
|
+
routes: routes.map((route) => ({
|
|
920
|
+
slackChannelId: route.slackChannelId,
|
|
921
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
922
|
+
targetWorkspaceName: names.get(route.targetWorkspaceId) ?? null,
|
|
923
|
+
source: route.source,
|
|
924
|
+
updatedAt: route.updatedAt.toISOString(),
|
|
925
|
+
})),
|
|
926
|
+
}),
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
app.put("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
|
|
931
|
+
const workspaceId = c.req.param("workspaceId");
|
|
932
|
+
// Same gate as the reaction-channel allowlist this sits beside: pointing a
|
|
933
|
+
// channel somewhere is an installation-administration act.
|
|
934
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
935
|
+
const payload = UpdateSlackChannelRoutesRequest.parse(await c.req.json());
|
|
936
|
+
const home = { accountId: grant.accountId, workspaceId };
|
|
937
|
+
const installation = await listSlackInstallationBindings(deps.db, home);
|
|
938
|
+
const binding = installation.find(
|
|
939
|
+
(candidate) => candidate.connectionId === payload.connectionId,
|
|
940
|
+
);
|
|
941
|
+
// The connection must be this workspace's own installation, and the route
|
|
942
|
+
// rows carry its team id, so a missing binding is a refusal rather than a
|
|
943
|
+
// row written with an empty team the column's own bound would reject.
|
|
944
|
+
if (!binding) {
|
|
945
|
+
throw new HTTPException(404, { message: "Slack installation not found" });
|
|
946
|
+
}
|
|
947
|
+
// Authorize EVERY target before writing anything. Applying routes one at a
|
|
948
|
+
// time means a refusal partway through would otherwise leave some channels
|
|
949
|
+
// moved and the rest not, behind an error that says the save failed.
|
|
950
|
+
//
|
|
951
|
+
// An admin may only point a channel at a workspace they could start work in
|
|
952
|
+
// themselves, and only inside this organization. The table's own CHECK
|
|
953
|
+
// enforces the second half; this refuses before writing rather than
|
|
954
|
+
// surfacing a constraint error.
|
|
955
|
+
// A channel may never be pointed at a personal workspace. The routing
|
|
956
|
+
// resolver already refuses to treat one as a candidate in a channel, and
|
|
957
|
+
// this is the same rule at the write boundary, where a durable route would
|
|
958
|
+
// otherwise smuggle it back in: every message in that channel would land
|
|
959
|
+
// somewhere nobody else in the channel can see.
|
|
960
|
+
//
|
|
961
|
+
// Only the caller's OWN personal workspace has to be excluded, and that is
|
|
962
|
+
// the whole rule rather than a partial one, because no other person's can
|
|
963
|
+
// reach this loop. `resolveSlackTargetAuthority` admits exactly two shapes:
|
|
964
|
+
// a `workspace_memberships` row this subject holds, or this subject's own
|
|
965
|
+
// personal-workspace pointer. There is no account-admin union, so an
|
|
966
|
+
// administrator does not thereby reach anyone else's personal workspace.
|
|
967
|
+
//
|
|
968
|
+
// Deliberately NOT justified by "the schema forbids a membership row on a
|
|
969
|
+
// personal workspace". It does not. Migration 0219's `42501` is a
|
|
970
|
+
// precondition inside the provisioning function, checked at provisioning
|
|
971
|
+
// time only, and `personal_workspace_has_no_membership_row` is recorded as
|
|
972
|
+
// `basis: "runtime"` - the parity report's own term for a property nothing
|
|
973
|
+
// in the schema prevents. It holds because every membership writer either
|
|
974
|
+
// requires `members:manage` on the target (which a personal-workspace grant
|
|
975
|
+
// deliberately omits) or excludes personal workspaces outright.
|
|
976
|
+
const callerPersonalWorkspaceId = await namedSubjectPersonalWorkspaceId(deps.db, {
|
|
977
|
+
accountId: grant.accountId,
|
|
978
|
+
subjectId: grant.subjectId,
|
|
979
|
+
});
|
|
980
|
+
for (const route of payload.routes) {
|
|
981
|
+
if (route.targetWorkspaceId === null) continue;
|
|
982
|
+
const authorized = await resolveSlackTargetAuthority(deps.db, {
|
|
983
|
+
subjectId: grant.subjectId,
|
|
984
|
+
targetAccountId: grant.accountId,
|
|
985
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
986
|
+
});
|
|
987
|
+
if (!authorized || !hasPermission(authorized.permissions, "sessions:create")) {
|
|
988
|
+
throw new HTTPException(403, {
|
|
989
|
+
message: `you cannot start work in the workspace chosen for ${route.slackChannelId}`,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (
|
|
993
|
+
callerPersonalWorkspaceId !== null &&
|
|
994
|
+
route.targetWorkspaceId === callerPersonalWorkspaceId
|
|
995
|
+
) {
|
|
996
|
+
throw new HTTPException(422, {
|
|
997
|
+
message: `a channel cannot be routed to a personal workspace: work started in ${route.slackChannelId} would be invisible to everyone else in it`,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const route of payload.routes) {
|
|
1002
|
+
if (route.targetWorkspaceId === null) {
|
|
1003
|
+
await deleteSlackChannelRoute(deps.db, home, {
|
|
1004
|
+
connectionId: payload.connectionId,
|
|
1005
|
+
slackChannelId: route.slackChannelId,
|
|
1006
|
+
});
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
await upsertSlackChannelRoute(deps.db, home, {
|
|
1010
|
+
connectionId: payload.connectionId,
|
|
1011
|
+
slackTeamId: binding.slackTeamId,
|
|
1012
|
+
slackChannelId: route.slackChannelId,
|
|
1013
|
+
targetAccountId: grant.accountId,
|
|
1014
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
1015
|
+
decidedBySubjectId: grant.subjectId,
|
|
1016
|
+
decidedBySlackUserId: "admin",
|
|
1017
|
+
source: "admin",
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return c.json({ ok: true });
|
|
1021
|
+
});
|
|
1022
|
+
|
|
829
1023
|
app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
|
|
830
1024
|
const workspaceId = c.req.param("workspaceId");
|
|
831
1025
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
@@ -1339,11 +1533,12 @@ async function resolveSlackRouteForEntry(
|
|
|
1339
1533
|
* Every branch ends with "No session was created." because the one thing a
|
|
1340
1534
|
* refusal must never do is leave someone believing work is under way.
|
|
1341
1535
|
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
* at an
|
|
1536
|
+
* Only `no_access_to_route` mints an access-request link, and it mints one for
|
|
1537
|
+
* the workspace they actually need rather than the installation's. The other
|
|
1538
|
+
* two branches deliberately do not: a prefix naming something they cannot
|
|
1539
|
+
* reach, and an empty candidate list, both describe a workspace OpenGeni has
|
|
1540
|
+
* not established it will confirm to this person, so they point at an
|
|
1541
|
+
* administrator instead.
|
|
1347
1542
|
*/
|
|
1348
1543
|
async function postSlackRouteRefusal(
|
|
1349
1544
|
deps: ApiRouteDeps,
|
|
@@ -1354,14 +1549,30 @@ async function postSlackRouteRefusal(
|
|
|
1354
1549
|
const available = refusal.candidates.map((candidate) => candidate.label);
|
|
1355
1550
|
const text =
|
|
1356
1551
|
refusal.reason === "no_access_to_named"
|
|
1357
|
-
? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in.${
|
|
1552
|
+
? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in from here.${
|
|
1358
1553
|
available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
|
|
1359
1554
|
} No session was created.`
|
|
1360
1555
|
: refusal.reason === "no_access_to_route"
|
|
1361
1556
|
? `OpenGeni starts work from this conversation in ${
|
|
1362
1557
|
refusal.requested ?? "another workspace"
|
|
1363
|
-
}, and you do not have access to it.
|
|
1364
|
-
|
|
1558
|
+
}, and you do not have access to it. Request access: ${linkUrl(
|
|
1559
|
+
deps,
|
|
1560
|
+
{
|
|
1561
|
+
// Minted for the workspace they actually need, not the
|
|
1562
|
+
// installation's. The token only decides which workspace an
|
|
1563
|
+
// access request may be raised for; approval is unchanged.
|
|
1564
|
+
workspaceId: refusal.workspaceId ?? entry.workspaceId,
|
|
1565
|
+
connectionId: entry.connectionId,
|
|
1566
|
+
slackTeamId: entry.slackTeamId,
|
|
1567
|
+
slackUserId: entry.slackUserId,
|
|
1568
|
+
},
|
|
1569
|
+
entry.createdAt.getTime(),
|
|
1570
|
+
)}. No session was created.`
|
|
1571
|
+
: // Deliberately "from this conversation" rather than "for you": in a
|
|
1572
|
+
// channel this also fires for someone whose only workspace is their
|
|
1573
|
+
// own personal one, and telling that person they have no workspace
|
|
1574
|
+
// at all would be false. Their bot DM still works.
|
|
1575
|
+
"OpenGeni has no workspace it can start this task in from this conversation. Ask an OpenGeni administrator to give you access to one, or message OpenGeni directly to work in your own workspace. No session was created.";
|
|
1365
1576
|
await client.postMessage({
|
|
1366
1577
|
operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
|
|
1367
1578
|
userId: entry.slackUserId,
|
|
@@ -1369,6 +1580,330 @@ async function postSlackRouteRefusal(
|
|
|
1369
1580
|
});
|
|
1370
1581
|
}
|
|
1371
1582
|
|
|
1583
|
+
/**
|
|
1584
|
+
* A workspace name as a Slack button label.
|
|
1585
|
+
*
|
|
1586
|
+
* Slack renders a button label as plain text and the stored option snapshot caps
|
|
1587
|
+
* it at 128 bytes, so bound it once at the boundary: the button, the row and
|
|
1588
|
+
* every outcome card then agree. Counted in UTF-8 bytes, because that is what
|
|
1589
|
+
* the CHECK counts.
|
|
1590
|
+
*/
|
|
1591
|
+
function boundedSlackRouteLabel(label: string): string {
|
|
1592
|
+
const encoder = new TextEncoder();
|
|
1593
|
+
if (encoder.encode(label).length <= 72) return label;
|
|
1594
|
+
let bounded = label;
|
|
1595
|
+
while (bounded.length > 0 && encoder.encode(`${bounded}...`).length > 72) {
|
|
1596
|
+
bounded = bounded.slice(0, -1);
|
|
1597
|
+
}
|
|
1598
|
+
return `${bounded}...`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Say where the work went, quietly.
|
|
1603
|
+
*
|
|
1604
|
+
* The line exists to stop work landing somewhere surprising, so it appears only
|
|
1605
|
+
* when the routing decision was a genuine choice: a null label means routing is
|
|
1606
|
+
* off, or the person had exactly one workspace anyway, and a constant footer on
|
|
1607
|
+
* every message would be noise rather than information.
|
|
1608
|
+
*
|
|
1609
|
+
* The label is frozen on the interaction rather than looked up here, because a
|
|
1610
|
+
* workspace rename between the original post and a reconciliation would make the
|
|
1611
|
+
* post ledger's byte comparison raise.
|
|
1612
|
+
*/
|
|
1613
|
+
export function withWorkspaceLine(
|
|
1614
|
+
label: string | null,
|
|
1615
|
+
text: string,
|
|
1616
|
+
blocks?: SlackMessageBlock[],
|
|
1617
|
+
): { text: string; blocks?: SlackMessageBlock[] } {
|
|
1618
|
+
if (!label) return blocks ? { text, blocks } : { text };
|
|
1619
|
+
const suffix = `\n-> ${label}`;
|
|
1620
|
+
// Reserve the suffix rather than truncating after the fact, so the bounded
|
|
1621
|
+
// body plus the line still fits.
|
|
1622
|
+
const body = boundedOutput(text, MAX_SLACK_TEXT_CHARS - suffix.length);
|
|
1623
|
+
const line: SlackMessageBlock = {
|
|
1624
|
+
type: "context",
|
|
1625
|
+
elements: [{ type: "mrkdwn", text: `-> ${label}` }],
|
|
1626
|
+
};
|
|
1627
|
+
return {
|
|
1628
|
+
text: `${body}${suffix}`,
|
|
1629
|
+
...(blocks ? { blocks: [...blocks, line] } : {}),
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The post-ledger seed for one interaction's messages.
|
|
1635
|
+
*
|
|
1636
|
+
* The ledger binds one operation id to a digest over the rendered bytes, so
|
|
1637
|
+
* adding the workspace line under an unchanged id would wedge every interaction
|
|
1638
|
+
* with a claimed-but-unposted row. But bumping the id for interactions whose
|
|
1639
|
+
* bytes did NOT change is not free either: their ledger rows become unreachable,
|
|
1640
|
+
* so a repair or a cross-deploy retry posts a duplicate instead of finding the
|
|
1641
|
+
* completed original, and an action handle still points at the old operation.
|
|
1642
|
+
*
|
|
1643
|
+
* Only a labelled interaction renders different bytes, and the label is written
|
|
1644
|
+
* once at insert and never updated, so it is a stable discriminator. Version the
|
|
1645
|
+
* id on the label rather than on the release: a labelled interaction can only
|
|
1646
|
+
* have been created by an image that already renders the line, and every
|
|
1647
|
+
* pre-existing and unlabelled interaction keeps its exact ledger identity.
|
|
1648
|
+
*/
|
|
1649
|
+
export function slackPostSeed(
|
|
1650
|
+
interaction: Pick<SlackInteraction, "routedWorkspaceLabel">,
|
|
1651
|
+
seed: string,
|
|
1652
|
+
): string {
|
|
1653
|
+
return interaction.routedWorkspaceLabel ? `${seed}:v2` : seed;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Sources that mean somebody, or something, actually chose this workspace. */
|
|
1657
|
+
const SLACK_ROUTE_SOURCES_WORTH_NAMING: ReadonlySet<string> = new Set([
|
|
1658
|
+
// Not "thread": a mapped thread reuses the interaction that already exists,
|
|
1659
|
+
// so it inherits the label frozen when that interaction was created and never
|
|
1660
|
+
// reaches the writer below.
|
|
1661
|
+
"prefix",
|
|
1662
|
+
"channel",
|
|
1663
|
+
"dm_route",
|
|
1664
|
+
"dm_personal",
|
|
1665
|
+
]);
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* The label to freeze on a new interaction, or null to say nothing.
|
|
1669
|
+
*
|
|
1670
|
+
* `installation` and `sole_candidate` both mean there was only ever one place
|
|
1671
|
+
* this could go, so naming it on every message would be noise.
|
|
1672
|
+
*
|
|
1673
|
+
* Bounded with the picker's own button cap so the frozen label and the button
|
|
1674
|
+
* that produced it agree; the column allows 128 bytes, which is headroom.
|
|
1675
|
+
*/
|
|
1676
|
+
async function routedWorkspaceLabelFor(
|
|
1677
|
+
deps: ApiRouteDeps,
|
|
1678
|
+
resolution: Extract<SlackRouteResolution, { kind: "resolved" }>,
|
|
1679
|
+
): Promise<string | null> {
|
|
1680
|
+
if (!SLACK_ROUTE_SOURCES_WORTH_NAMING.has(resolution.source)) return null;
|
|
1681
|
+
const label = resolution.label ?? (await getWorkspace(deps.db, resolution.workspaceId))?.name;
|
|
1682
|
+
if (!label) return null;
|
|
1683
|
+
const bounded = boundedSlackRouteLabel(label);
|
|
1684
|
+
return bounded.length > 0 ? bounded : null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** How long a first-use picker stays answerable. */
|
|
1688
|
+
const SLACK_ROUTE_PROMPT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1689
|
+
|
|
1690
|
+
/** Buttons a card can carry before it stops being readable. */
|
|
1691
|
+
const MAX_SLACK_ROUTE_PROMPT_BUTTONS = 5;
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Ask, once, where this conversation should work.
|
|
1695
|
+
*
|
|
1696
|
+
* The card is a bot DM rather than an ephemeral message: `chat.postEphemeral`
|
|
1697
|
+
* returns no durable `(channel, ts)` and no `client_msg_id` echo, and the whole
|
|
1698
|
+
* post ledger and its reconciliation are built on both.
|
|
1699
|
+
*
|
|
1700
|
+
* Nothing is created in any workspace here. The request is parked on the prompt
|
|
1701
|
+
* row and re-queued only once the person answers, so a picker that is never
|
|
1702
|
+
* answered leaves no session, no interaction and no half-started work.
|
|
1703
|
+
*/
|
|
1704
|
+
async function askSlackRouteChoice(
|
|
1705
|
+
deps: ApiRouteDeps,
|
|
1706
|
+
client: OpenGeniSlackBotClient,
|
|
1707
|
+
home: SlackRouteTenancy,
|
|
1708
|
+
entry: SlackInteractionInboxEntry,
|
|
1709
|
+
candidates: readonly SlackRoutableWorkspace[],
|
|
1710
|
+
): Promise<void> {
|
|
1711
|
+
const offered = candidates.slice(0, MAX_SLACK_ROUTE_PROMPT_BUTTONS);
|
|
1712
|
+
// Slack renders a button label as plain text and the stored snapshot caps it
|
|
1713
|
+
// at 128 bytes, so bound it once here: the button, the stored row and every
|
|
1714
|
+
// outcome card then agree, and the full name still shows in OpenGeni.
|
|
1715
|
+
const bounded = offered.map((candidate) => ({
|
|
1716
|
+
...candidate,
|
|
1717
|
+
label: boundedSlackRouteLabel(candidate.label),
|
|
1718
|
+
}));
|
|
1719
|
+
const opened = await openSlackRoutePrompt(deps.db, home, {
|
|
1720
|
+
connectionId: entry.connectionId,
|
|
1721
|
+
inboxId: entry.id,
|
|
1722
|
+
slackTeamId: entry.slackTeamId,
|
|
1723
|
+
slackUserId: entry.slackUserId,
|
|
1724
|
+
slackChannelId: entry.slackChannelId,
|
|
1725
|
+
slackMessageTs: entry.slackMessageTs,
|
|
1726
|
+
providerEventId: entry.providerEventId,
|
|
1727
|
+
triggerKind: entry.triggerKind,
|
|
1728
|
+
requestText: entry.text,
|
|
1729
|
+
hasFiles: entry.hasFiles,
|
|
1730
|
+
slackThreadTs: entry.slackThreadTs,
|
|
1731
|
+
messageOperationId: deterministicUuid(`slack-route-prompt:${entry.id}`),
|
|
1732
|
+
expiresAt: new Date(Date.now() + SLACK_ROUTE_PROMPT_TTL_MS),
|
|
1733
|
+
candidates: bounded,
|
|
1734
|
+
});
|
|
1735
|
+
if (!opened) return;
|
|
1736
|
+
// The prompt commits before the card is posted, so a transient Slack failure
|
|
1737
|
+
// would otherwise leave a pending row with no card in front of anybody and
|
|
1738
|
+
// silently deaden the conversation. Post unconditionally instead: the
|
|
1739
|
+
// operation id is persisted on the prompt and the card is rendered from the
|
|
1740
|
+
// prompt's own stored options, so a replay reproduces identical bytes and the
|
|
1741
|
+
// post ledger returns the completed original rather than posting twice.
|
|
1742
|
+
const where = opened.prompt.slackChannelId.startsWith("D")
|
|
1743
|
+
? "your messages with OpenGeni"
|
|
1744
|
+
: `<#${opened.prompt.slackChannelId}>`;
|
|
1745
|
+
const blocks: SlackMessageBlock[] = [
|
|
1746
|
+
{
|
|
1747
|
+
type: "section",
|
|
1748
|
+
text: {
|
|
1749
|
+
type: "mrkdwn",
|
|
1750
|
+
text: `OpenGeni can start this task in more than one workspace. Pick where ${where} should work. This choice is remembered.`,
|
|
1751
|
+
},
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
type: "actions",
|
|
1755
|
+
block_id: "opengeni-route-choice",
|
|
1756
|
+
elements: [
|
|
1757
|
+
...opened.prompt.options.map((option) => ({
|
|
1758
|
+
type: "button" as const,
|
|
1759
|
+
action_id: "opengeni.route.select",
|
|
1760
|
+
value: option.id,
|
|
1761
|
+
text: { type: "plain_text" as const, text: option.candidateLabel },
|
|
1762
|
+
})),
|
|
1763
|
+
{
|
|
1764
|
+
type: "button" as const,
|
|
1765
|
+
action_id: "opengeni.route.cancel",
|
|
1766
|
+
value: opened.prompt.options[0]?.id ?? opened.prompt.id,
|
|
1767
|
+
text: { type: "plain_text" as const, text: "Not now" },
|
|
1768
|
+
},
|
|
1769
|
+
],
|
|
1770
|
+
},
|
|
1771
|
+
];
|
|
1772
|
+
// Deliberately unconditional rather than derived from the live candidate
|
|
1773
|
+
// count: a replay must render identical bytes, and membership can change
|
|
1774
|
+
// between attempts.
|
|
1775
|
+
const url = slackWorkspaceUrl(deps, home.workspaceId);
|
|
1776
|
+
blocks.push({
|
|
1777
|
+
type: "section",
|
|
1778
|
+
text: {
|
|
1779
|
+
type: "mrkdwn",
|
|
1780
|
+
text: url
|
|
1781
|
+
? `You can change this later in <${url}|OpenGeni>, under Capabilities, then Slack.`
|
|
1782
|
+
: "You can change this later in OpenGeni, under Capabilities, then Slack.",
|
|
1783
|
+
},
|
|
1784
|
+
});
|
|
1785
|
+
await client.postMessage({
|
|
1786
|
+
operationId: opened.prompt.messageOperationId,
|
|
1787
|
+
userId: opened.prompt.slackUserId,
|
|
1788
|
+
text: boundedOutput(
|
|
1789
|
+
`OpenGeni can start this task in more than one workspace. Pick where ${where} should work.`,
|
|
1790
|
+
),
|
|
1791
|
+
blocks,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Process one click on a picker.
|
|
1797
|
+
*
|
|
1798
|
+
* The stored option row is a prompt-time snapshot and is NEVER authority: the
|
|
1799
|
+
* chosen workspace is re-authorized live, because access can have been revoked
|
|
1800
|
+
* between the question and the answer.
|
|
1801
|
+
*/
|
|
1802
|
+
async function handleSlackRouteChoice(
|
|
1803
|
+
deps: ApiRouteDeps,
|
|
1804
|
+
home: SlackRouteTenancy,
|
|
1805
|
+
entry: SlackInteractionInboxEntry,
|
|
1806
|
+
actionId: string,
|
|
1807
|
+
optionId: string,
|
|
1808
|
+
): Promise<void> {
|
|
1809
|
+
if (!deps.settings.slackWorkspaceRoutingEnabled) {
|
|
1810
|
+
// A card can outlive the flag being turned back off. Honouring the click
|
|
1811
|
+
// would write a route row and start work through a path the operator has
|
|
1812
|
+
// disabled.
|
|
1813
|
+
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1814
|
+
}
|
|
1815
|
+
const found = await getSlackRoutePromptByOption(deps.db, home, optionId);
|
|
1816
|
+
if (!found) throw new SlackInteractionPermanentError("slack_route_prompt_invalid");
|
|
1817
|
+
const { prompt, chosen } = found;
|
|
1818
|
+
// The same authorization pinning ordinary action handles use: a different
|
|
1819
|
+
// Slack human clicking someone else's card is ignored, not obeyed.
|
|
1820
|
+
if (prompt.slackUserId !== entry.slackUserId) {
|
|
1821
|
+
throw new SlackInteractionPermanentError("slack_route_prompt_user_mismatch");
|
|
1822
|
+
}
|
|
1823
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1824
|
+
...home,
|
|
1825
|
+
connectionId: entry.connectionId,
|
|
1826
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1827
|
+
});
|
|
1828
|
+
// The post ledger binds one operation id to a digest over the text, so the
|
|
1829
|
+
// outcome has to be part of the seed. A second click on a settled card
|
|
1830
|
+
// renders different bytes than the first, and reusing one id would wedge the
|
|
1831
|
+
// ledger rather than saying so.
|
|
1832
|
+
const replaceCard = async (outcome: string, text: string) => {
|
|
1833
|
+
await client.updateMessage({
|
|
1834
|
+
operationId: deterministicUuid(
|
|
1835
|
+
`slack-route-answered:${prompt.id}:${actionId}:${chosen.id}:${outcome}`,
|
|
1836
|
+
),
|
|
1837
|
+
channelId: entry.slackChannelId,
|
|
1838
|
+
timestamp: entry.slackMessageTs,
|
|
1839
|
+
text,
|
|
1840
|
+
// Every button goes with the card, so a sibling option cannot be pressed
|
|
1841
|
+
// after the question is settled.
|
|
1842
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
if (actionId === "opengeni.route.cancel") {
|
|
1847
|
+
const cancelled = await settleSlackRoutePrompt(deps.db, home, {
|
|
1848
|
+
promptId: prompt.id,
|
|
1849
|
+
status: "cancelled",
|
|
1850
|
+
});
|
|
1851
|
+
await replaceCard(
|
|
1852
|
+
cancelled ? "cancelled" : "already_settled",
|
|
1853
|
+
cancelled
|
|
1854
|
+
? "OpenGeni did not start this task. No session was created."
|
|
1855
|
+
: "OpenGeni already settled this choice.",
|
|
1856
|
+
);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const link = await getSlackBotUserLink(
|
|
1861
|
+
deps.db,
|
|
1862
|
+
home.workspaceId,
|
|
1863
|
+
entry.connectionId,
|
|
1864
|
+
entry.slackUserId,
|
|
1865
|
+
);
|
|
1866
|
+
const grant = link
|
|
1867
|
+
? await resolveSlackTargetAuthority(deps.db, {
|
|
1868
|
+
subjectId: link.subjectId,
|
|
1869
|
+
targetAccountId: chosen.candidateAccountId,
|
|
1870
|
+
targetWorkspaceId: chosen.candidateWorkspaceId,
|
|
1871
|
+
})
|
|
1872
|
+
: null;
|
|
1873
|
+
if (!grant || !hasPermission(grant.permissions, "sessions:create")) {
|
|
1874
|
+
// Fail closed WITHOUT touching the card: the person may still have another
|
|
1875
|
+
// workspace on it that they can use, and replacing the card would take the
|
|
1876
|
+
// remaining buttons away and leave a pending prompt nobody can answer.
|
|
1877
|
+
await client.postMessage({
|
|
1878
|
+
operationId: deterministicUuid(`slack-route-denied:${prompt.id}:${chosen.id}`),
|
|
1879
|
+
userId: entry.slackUserId,
|
|
1880
|
+
text: boundedOutput(
|
|
1881
|
+
`OpenGeni cannot start work in ${chosen.candidateLabel}: you do not have access to it. No session was created. Pick another workspace on the card, or ask an OpenGeni administrator for access.`,
|
|
1882
|
+
),
|
|
1883
|
+
});
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const answered = await answerSlackRoutePrompt(deps.db, home, {
|
|
1888
|
+
promptId: prompt.id,
|
|
1889
|
+
targetAccountId: chosen.candidateAccountId,
|
|
1890
|
+
targetWorkspaceId: chosen.candidateWorkspaceId,
|
|
1891
|
+
answeredBySubjectId: grant.subjectId,
|
|
1892
|
+
decidedBySlackUserId: entry.slackUserId,
|
|
1893
|
+
});
|
|
1894
|
+
if (!answered.answered) {
|
|
1895
|
+
await replaceCard(
|
|
1896
|
+
"settled",
|
|
1897
|
+
"OpenGeni already settled this choice. No new session was created.",
|
|
1898
|
+
);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
await replaceCard(
|
|
1902
|
+
"answered",
|
|
1903
|
+
`OpenGeni will use *${chosen.candidateLabel}* here. You can change this in OpenGeni under Capabilities, then Slack.`,
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1372
1907
|
async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
1373
1908
|
if (entry.triggerKind === "block_action") {
|
|
1374
1909
|
await processSlackBlockAction(deps, entry);
|
|
@@ -1442,7 +1977,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1442
1977
|
await client.postMessage({
|
|
1443
1978
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
1444
1979
|
userId: entry.slackUserId,
|
|
1445
|
-
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
|
|
1980
|
+
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry, entry.createdAt.getTime())}. No session was created.`,
|
|
1446
1981
|
});
|
|
1447
1982
|
return;
|
|
1448
1983
|
}
|
|
@@ -1451,14 +1986,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1451
1986
|
threadTenancy: existing
|
|
1452
1987
|
? { accountId: existing.accountId, workspaceId: existing.workspaceId }
|
|
1453
1988
|
: null,
|
|
1454
|
-
|
|
1455
|
-
// installation's workspace rather than inventing an answer.
|
|
1456
|
-
askEnabled: false,
|
|
1989
|
+
askEnabled: true,
|
|
1457
1990
|
});
|
|
1458
1991
|
if (resolution.kind === "ask") {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1992
|
+
await askSlackRouteChoice(deps, client, home, entry, resolution.candidates);
|
|
1993
|
+
return;
|
|
1462
1994
|
}
|
|
1463
1995
|
if (resolution.kind === "denied") {
|
|
1464
1996
|
await postSlackRouteRefusal(deps, client, entry, resolution);
|
|
@@ -1579,6 +2111,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1579
2111
|
}
|
|
1580
2112
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
1581
2113
|
...target,
|
|
2114
|
+
// Frozen here, after authorization, so a workspace name is never shown to
|
|
2115
|
+
// someone who could not reach it. Null when the routing decision was not a
|
|
2116
|
+
// genuine choice, which is what keeps the line off every message in an
|
|
2117
|
+
// install that only ever had one workspace.
|
|
2118
|
+
routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, resolution),
|
|
1582
2119
|
connectionId: entry.connectionId,
|
|
1583
2120
|
slackTeamId: entry.slackTeamId,
|
|
1584
2121
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1894,7 +2431,7 @@ async function acknowledgeSlackSession(
|
|
|
1894
2431
|
const privateHandoff =
|
|
1895
2432
|
!directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
|
|
1896
2433
|
const privateBotDm = directMessageShortcut || privateHandoff;
|
|
1897
|
-
const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
|
|
2434
|
+
const operationId = deterministicUuid(slackPostSeed(interaction, `slack-ack:${interaction.id}`));
|
|
1898
2435
|
// The acknowledgement carries exactly one session link plus the Status/Stop
|
|
1899
2436
|
// buttons. The how-to prose it used to repeat forever now rides along once,
|
|
1900
2437
|
// on this Slack identity's first accepted task in this installation.
|
|
@@ -1922,6 +2459,15 @@ async function acknowledgeSlackSession(
|
|
|
1922
2459
|
sessionEventSequence: 0,
|
|
1923
2460
|
state: "active",
|
|
1924
2461
|
});
|
|
2462
|
+
// The approval and control cards let `text` and `blocks[0]` diverge, so the
|
|
2463
|
+
// line is appended to each independently rather than assuming they match.
|
|
2464
|
+
const acknowledgement = withWorkspaceLine(
|
|
2465
|
+
interaction.routedWorkspaceLabel,
|
|
2466
|
+
text,
|
|
2467
|
+
controls.length > 0
|
|
2468
|
+
? ([{ type: "section", text: { type: "mrkdwn", text } }, ...controls] as SlackMessageBlock[])
|
|
2469
|
+
: undefined,
|
|
2470
|
+
);
|
|
1925
2471
|
const ack = await client.postMessage({
|
|
1926
2472
|
operationId,
|
|
1927
2473
|
...(privateBotDm
|
|
@@ -1932,15 +2478,8 @@ async function acknowledgeSlackSession(
|
|
|
1932
2478
|
? {}
|
|
1933
2479
|
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
1934
2480
|
}),
|
|
1935
|
-
text,
|
|
1936
|
-
...(
|
|
1937
|
-
? {
|
|
1938
|
-
blocks: [
|
|
1939
|
-
{ type: "section", text: { type: "mrkdwn", text } },
|
|
1940
|
-
...controls,
|
|
1941
|
-
] as SlackMessageBlock[],
|
|
1942
|
-
}
|
|
1943
|
-
: {}),
|
|
2481
|
+
text: acknowledgement.text,
|
|
2482
|
+
...(acknowledgement.blocks ? { blocks: acknowledgement.blocks } : {}),
|
|
1944
2483
|
});
|
|
1945
2484
|
if (entry.triggerKind === "slash_command" || privateBotDm) {
|
|
1946
2485
|
const rekeyed = await rekeySlackInteractionRoute(deps.db, {
|
|
@@ -2155,6 +2694,10 @@ async function processSlackReactionInboxEntry(
|
|
|
2155
2694
|
}
|
|
2156
2695
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
2157
2696
|
...target,
|
|
2697
|
+
// A reaction routes exactly like a mention, so it names its workspace the
|
|
2698
|
+
// same way. Resolved after `authorizeTarget`, so a name never reaches
|
|
2699
|
+
// somebody who could not reach the workspace.
|
|
2700
|
+
routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, routeResolution),
|
|
2158
2701
|
connectionId: entry.connectionId,
|
|
2159
2702
|
slackTeamId: entry.slackTeamId,
|
|
2160
2703
|
slackChannelId: entry.slackChannelId,
|
|
@@ -2248,11 +2791,15 @@ async function acknowledgeSlackReactionSession(
|
|
|
2248
2791
|
if (!interaction.sessionId) {
|
|
2249
2792
|
throw new Error("Slack reaction acknowledgement requires a bound session");
|
|
2250
2793
|
}
|
|
2794
|
+
const started = `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.`;
|
|
2795
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, started);
|
|
2251
2796
|
await client.postMessage({
|
|
2252
|
-
operationId: deterministicUuid(
|
|
2797
|
+
operationId: deterministicUuid(
|
|
2798
|
+
slackPostSeed(interaction, `slack-reaction-ack:${interaction.id}`),
|
|
2799
|
+
),
|
|
2253
2800
|
channelId: interaction.slackChannelId,
|
|
2254
2801
|
threadTimestamp: interaction.slackThreadTs,
|
|
2255
|
-
text:
|
|
2802
|
+
text: rendered.text,
|
|
2256
2803
|
});
|
|
2257
2804
|
}
|
|
2258
2805
|
|
|
@@ -2715,6 +3262,13 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2715
3262
|
) {
|
|
2716
3263
|
throw new SlackInteractionPermanentError("slack_action_installation_changed");
|
|
2717
3264
|
}
|
|
3265
|
+
// A route choice is not an action handle: the picker exists BEFORE any
|
|
3266
|
+
// session, so there is no handle row to load and the ordinary path below
|
|
3267
|
+
// would refuse it. Branch first.
|
|
3268
|
+
if (actionId === "opengeni.route.select" || actionId === "opengeni.route.cancel") {
|
|
3269
|
+
await handleSlackRouteChoice(deps, home, entry, actionId, handleId);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
2718
3272
|
// A handle lives in the workspace that owns its session, which is not the
|
|
2719
3273
|
// installation's workspace once routing is on. Learn that scope from the
|
|
2720
3274
|
// content-free probe, then read the full handle under it: every authorization
|
|
@@ -2803,17 +3357,26 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2803
3357
|
// ever shown. The handle's message operation identifies the acknowledgement
|
|
2804
3358
|
// exactly, independent of route rekeying. Later control cards have their own
|
|
2805
3359
|
// operation ids, so the hint still appears on exactly one message.
|
|
2806
|
-
const acknowledgementOperationId = deterministicUuid(
|
|
3360
|
+
const acknowledgementOperationId = deterministicUuid(
|
|
3361
|
+
slackPostSeed(interaction, `slack-ack:${interaction.id}`),
|
|
3362
|
+
);
|
|
2807
3363
|
const updateText =
|
|
2808
3364
|
interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
|
|
2809
3365
|
? `${outcome.text}${slackFirstTaskHintText(deps)}`
|
|
2810
3366
|
: outcome.text;
|
|
3367
|
+
// A control click replaces a message that already carried the line, so the
|
|
3368
|
+
// replacement carries it too rather than quietly stripping it.
|
|
3369
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, updateText, [
|
|
3370
|
+
{ type: "section", text: { type: "mrkdwn", text: updateText } },
|
|
3371
|
+
]);
|
|
2811
3372
|
await client.updateMessage({
|
|
2812
|
-
operationId: deterministicUuid(
|
|
3373
|
+
operationId: deterministicUuid(
|
|
3374
|
+
slackPostSeed(interaction, `slack-action-update:${handle.id}:${outcome.result}`),
|
|
3375
|
+
),
|
|
2813
3376
|
channelId: entry.slackChannelId,
|
|
2814
3377
|
timestamp: entry.slackMessageTs,
|
|
2815
|
-
text:
|
|
2816
|
-
blocks
|
|
3378
|
+
text: rendered.text,
|
|
3379
|
+
...(rendered.blocks ? { blocks: rendered.blocks } : {}),
|
|
2817
3380
|
});
|
|
2818
3381
|
await settleSlackInteractionActionHandles(deps.db, {
|
|
2819
3382
|
...target,
|
|
@@ -3261,7 +3824,7 @@ async function slackApprovalCard(
|
|
|
3261
3824
|
requesterAuthorized: boolean,
|
|
3262
3825
|
): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
|
|
3263
3826
|
const operationId = deterministicUuid(
|
|
3264
|
-
`slack-delivery:${interaction.id}:${event.sequence}:approval
|
|
3827
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:approval`),
|
|
3265
3828
|
);
|
|
3266
3829
|
const approvals = slackApprovalSummaries(event.payload);
|
|
3267
3830
|
const fallback = approvals.length
|
|
@@ -3414,7 +3977,7 @@ async function slackHumanInputCard(
|
|
|
3414
3977
|
);
|
|
3415
3978
|
if (!request || request.status !== "pending") return null;
|
|
3416
3979
|
const operationId = deterministicUuid(
|
|
3417
|
-
`slack-delivery:${interaction.id}:${event.sequence}:human-input
|
|
3980
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:human-input`),
|
|
3418
3981
|
);
|
|
3419
3982
|
const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
|
|
3420
3983
|
if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
|
|
@@ -3890,32 +4453,38 @@ async function deliverSlackSessionEvents(
|
|
|
3890
4453
|
// The result is the result. Continuation prose and the recurring
|
|
3891
4454
|
// action live on the control/Status card and `<command> info`.
|
|
3892
4455
|
const text = boundedOutput(`${requester.mention}${existingProgress.text}`);
|
|
4456
|
+
// This rewrites a message that already carried the line, so it has to
|
|
4457
|
+
// carry it too, or the update would quietly strip it.
|
|
4458
|
+
const rendered = withWorkspaceLine(
|
|
4459
|
+
interaction.routedWorkspaceLabel,
|
|
4460
|
+
text,
|
|
4461
|
+
publicationBlocks.length > 0
|
|
4462
|
+
? ([
|
|
4463
|
+
{ type: "section", text: { type: "mrkdwn", text } },
|
|
4464
|
+
...publicationBlocks,
|
|
4465
|
+
] as SlackMessageBlock[])
|
|
4466
|
+
: undefined,
|
|
4467
|
+
);
|
|
3893
4468
|
await client.updateMessage({
|
|
3894
4469
|
operationId: deterministicUuid(
|
|
3895
|
-
|
|
4470
|
+
slackPostSeed(
|
|
4471
|
+
interaction,
|
|
4472
|
+
`slack-terminal-update:${interaction.id}:${event.sequence}`,
|
|
4473
|
+
),
|
|
3896
4474
|
),
|
|
3897
4475
|
channelId: posted.slackChannelId,
|
|
3898
4476
|
timestamp: posted.slackMessageTimestamp,
|
|
3899
|
-
text,
|
|
3900
|
-
...(
|
|
4477
|
+
text: rendered.text,
|
|
4478
|
+
...(rendered.blocks
|
|
3901
4479
|
? {
|
|
3902
|
-
blocks:
|
|
3903
|
-
{
|
|
3904
|
-
type: "section",
|
|
3905
|
-
text: {
|
|
3906
|
-
type: "mrkdwn",
|
|
3907
|
-
text,
|
|
3908
|
-
},
|
|
3909
|
-
},
|
|
3910
|
-
...publicationBlocks,
|
|
3911
|
-
],
|
|
4480
|
+
blocks: rendered.blocks,
|
|
3912
4481
|
}
|
|
3913
4482
|
: {}),
|
|
3914
4483
|
});
|
|
3915
4484
|
}
|
|
3916
4485
|
} else {
|
|
3917
4486
|
const operationId = deterministicUuid(
|
|
3918
|
-
`slack-delivery:${interaction.id}:${event.sequence}:final
|
|
4487
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:final`),
|
|
3919
4488
|
);
|
|
3920
4489
|
const text = boundedOutput(
|
|
3921
4490
|
`${requester.mention}${output || "OpenGeni finished this task."}`,
|
|
@@ -4016,15 +4585,18 @@ async function postDelivery(
|
|
|
4016
4585
|
event: SessionEvent,
|
|
4017
4586
|
text: string,
|
|
4018
4587
|
kind: string,
|
|
4019
|
-
operationId = deterministicUuid(
|
|
4588
|
+
operationId = deterministicUuid(
|
|
4589
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
|
|
4590
|
+
),
|
|
4020
4591
|
blocks?: SlackMessageBlock[],
|
|
4021
4592
|
) {
|
|
4593
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, boundedOutput(text), blocks);
|
|
4022
4594
|
await client.postMessage({
|
|
4023
4595
|
operationId,
|
|
4024
4596
|
channelId: interaction.slackChannelId,
|
|
4025
4597
|
threadTimestamp: interaction.slackThreadTs,
|
|
4026
|
-
text:
|
|
4027
|
-
...(blocks ? { blocks } : {}),
|
|
4598
|
+
text: rendered.text,
|
|
4599
|
+
...(rendered.blocks ? { blocks: rendered.blocks } : {}),
|
|
4028
4600
|
});
|
|
4029
4601
|
}
|
|
4030
4602
|
|
|
@@ -4104,6 +4676,8 @@ const SLACK_BLOCK_ACTION_IDS = new Set([
|
|
|
4104
4676
|
"opengeni.session.pause",
|
|
4105
4677
|
"opengeni.session.resume",
|
|
4106
4678
|
"opengeni.shared_result.publish",
|
|
4679
|
+
"opengeni.route.select",
|
|
4680
|
+
"opengeni.route.cancel",
|
|
4107
4681
|
]);
|
|
4108
4682
|
|
|
4109
4683
|
export function normalizedBlockActionInteraction(
|
|
@@ -4402,6 +4976,16 @@ function linkUrl(
|
|
|
4402
4976
|
SlackInteractionInboxEntry,
|
|
4403
4977
|
"workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
|
|
4404
4978
|
>,
|
|
4979
|
+
/**
|
|
4980
|
+
* When this URL goes into a message posted under a deterministic operation
|
|
4981
|
+
* id, the token has to be deterministic too. The post ledger binds that id to
|
|
4982
|
+
* a digest over the text, so a retry that mints a fresh token renders
|
|
4983
|
+
* different bytes and is refused as a conflicting request, permanently. Pass
|
|
4984
|
+
* the originating inbox row's creation time: the token is then stable for that
|
|
4985
|
+
* row and still expires fifteen minutes after the message could first have
|
|
4986
|
+
* been read.
|
|
4987
|
+
*/
|
|
4988
|
+
mintedAtMs?: number,
|
|
4405
4989
|
) {
|
|
4406
4990
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
4407
4991
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
@@ -4410,7 +4994,7 @@ function linkUrl(
|
|
|
4410
4994
|
// Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
|
|
4411
4995
|
// headers, and managed-auth callback URLs. Query-form bearers are rejected.
|
|
4412
4996
|
url.hash = new URLSearchParams({
|
|
4413
|
-
slack_link: createSlackUserLinkToken(signingSecret, entry),
|
|
4997
|
+
slack_link: createSlackUserLinkToken(signingSecret, entry, mintedAtMs),
|
|
4414
4998
|
}).toString();
|
|
4415
4999
|
return url.toString();
|
|
4416
5000
|
}
|
|
@@ -4583,10 +5167,8 @@ function boundedSlackChildDetail(value: string) {
|
|
|
4583
5167
|
: `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
|
|
4584
5168
|
}
|
|
4585
5169
|
|
|
4586
|
-
function boundedOutput(value: string) {
|
|
4587
|
-
return value.length <=
|
|
4588
|
-
? value
|
|
4589
|
-
: `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
|
|
5170
|
+
function boundedOutput(value: string, limit = MAX_SLACK_TEXT_CHARS) {
|
|
5171
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 20)}\n… output truncated`;
|
|
4590
5172
|
}
|
|
4591
5173
|
|
|
4592
5174
|
function safePayloadText(payload: unknown, field: string) {
|