@open-everywhere/provider-slack 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/slack-attachment-fetch.ts +168 -0
- package/agent/app-manifest.ts +149 -0
- package/agent/channels/slack-approval-card.ts +275 -0
- package/agent/channels/slack-attachments.ts +195 -0
- package/agent/channels/slack-commands.ts +227 -0
- package/agent/channels/slack-context.ts +185 -0
- package/agent/channels/slack-events.ts +5406 -0
- package/agent/channels/slack-front.ts +2349 -0
- package/agent/channels/slack-identity.ts +520 -0
- package/agent/channels/slack-input-response.ts +162 -0
- package/agent/channels/slack-mrkdwn.ts +61 -0
- package/agent/channels/slack-origin-codec.ts +62 -0
- package/agent/channels/slack-post-arm.ts +314 -0
- package/agent/channels/slack-read.ts +248 -0
- package/agent/channels/slack-route-hold.ts +107 -0
- package/agent/channels/slack-shortcut.ts +336 -0
- package/agent/channels/slack-signature.ts +53 -0
- package/agent/channels/slack-spend-gate.ts +62 -0
- package/agent/channels/slack-tenant-fence.ts +99 -0
- package/agent/channels/slack-wake-backscroll.ts +604 -0
- package/agent/channels/slack-web-api.ts +178 -0
- package/agent/channels/slack-webhook-verifier.ts +116 -0
- package/agent/channels/slack.ts +1266 -0
- package/agent/contributions.ts +32 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +50 -0
- package/agent/provider-events.ts +91 -0
- package/agent/register.ts +144 -0
- package/agent/surface-parity.ts +99 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
- package/dist/agent/access/slack-attachment-fetch.js +151 -0
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
- package/dist/agent/app-manifest.d.ts +15 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +141 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/slack-approval-card.d.ts +71 -0
- package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-approval-card.js +232 -0
- package/dist/agent/channels/slack-approval-card.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +66 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
- package/dist/agent/channels/slack-attachments.js +174 -0
- package/dist/agent/channels/slack-attachments.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +27 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -0
- package/dist/agent/channels/slack-commands.js +208 -0
- package/dist/agent/channels/slack-commands.js.map +1 -0
- package/dist/agent/channels/slack-context.d.ts +26 -0
- package/dist/agent/channels/slack-context.d.ts.map +1 -0
- package/dist/agent/channels/slack-context.js +178 -0
- package/dist/agent/channels/slack-context.js.map +1 -0
- package/dist/agent/channels/slack-events.d.ts +1654 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -0
- package/dist/agent/channels/slack-events.js +4240 -0
- package/dist/agent/channels/slack-events.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +387 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -0
- package/dist/agent/channels/slack-front.js +1958 -0
- package/dist/agent/channels/slack-front.js.map +1 -0
- package/dist/agent/channels/slack-identity.d.ts +245 -0
- package/dist/agent/channels/slack-identity.d.ts.map +1 -0
- package/dist/agent/channels/slack-identity.js +405 -0
- package/dist/agent/channels/slack-identity.js.map +1 -0
- package/dist/agent/channels/slack-input-response.d.ts +26 -0
- package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
- package/dist/agent/channels/slack-input-response.js +127 -0
- package/dist/agent/channels/slack-input-response.js.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.js +53 -0
- package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/slack-origin-codec.js +55 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -0
- package/dist/agent/channels/slack-post-arm.d.ts +54 -0
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/slack-post-arm.js +268 -0
- package/dist/agent/channels/slack-post-arm.js.map +1 -0
- package/dist/agent/channels/slack-read.d.ts +84 -0
- package/dist/agent/channels/slack-read.d.ts.map +1 -0
- package/dist/agent/channels/slack-read.js +205 -0
- package/dist/agent/channels/slack-read.js.map +1 -0
- package/dist/agent/channels/slack-route-hold.d.ts +34 -0
- package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
- package/dist/agent/channels/slack-route-hold.js +97 -0
- package/dist/agent/channels/slack-route-hold.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +162 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
- package/dist/agent/channels/slack-shortcut.js +262 -0
- package/dist/agent/channels/slack-shortcut.js.map +1 -0
- package/dist/agent/channels/slack-signature.d.ts +19 -0
- package/dist/agent/channels/slack-signature.d.ts.map +1 -0
- package/dist/agent/channels/slack-signature.js +50 -0
- package/dist/agent/channels/slack-signature.js.map +1 -0
- package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
- package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
- package/dist/agent/channels/slack-spend-gate.js +48 -0
- package/dist/agent/channels/slack-spend-gate.js.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.js +88 -0
- package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.js +473 -0
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +46 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
- package/dist/agent/channels/slack-web-api.js +147 -0
- package/dist/agent/channels/slack-web-api.js.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.js +112 -0
- package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/slack.d.ts +37 -0
- package/dist/agent/channels/slack.d.ts.map +1 -0
- package/dist/agent/channels/slack.js +1169 -0
- package/dist/agent/channels/slack.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +32 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +38 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +84 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +118 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +95 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +209 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,1958 @@
|
|
|
1
|
+
// Component 2 wake path — MOUNTED).
|
|
2
|
+
//
|
|
3
|
+
// Slack delivers EVERY subscribed event to ONE Request URL. Eve's `slackChannel` owns that
|
|
4
|
+
// URL and only acts on `app_mention` + `message.im` (DM); a plain `message.channels` reply is
|
|
5
|
+
// silently dropped, and Eve exposes no `onMessage` hook. So to get "reply without re-tagging"
|
|
6
|
+
// we FRONT the public URL with our own route and forward the events slackChannel handles to
|
|
7
|
+
// its own handler — no dispatch fork (option (b)), no upstream dependency (option (a)).
|
|
8
|
+
//
|
|
9
|
+
// The design (why it's clean):
|
|
10
|
+
// • The wrapped `slackChannel` is pointed at an INTERNAL route (`/eve/v1/slack/mention`).
|
|
11
|
+
// • OUR route sits at the PUBLIC `/eve/v1/slack` (the URL Slack POSTs to).
|
|
12
|
+
// • Both routes live on the SAME channel object (one file → one channel name → ONE session
|
|
13
|
+
// namespace and ONE `send`). So forwarding `args` straight through gives slackChannel the
|
|
14
|
+
// exact `send`/continuation machinery it would get if mounted directly, AND our wake uses
|
|
15
|
+
// the same `send` → `slackContinuationToken(channel, thread)` continues the very session
|
|
16
|
+
// the mention started. That shared namespace is the whole reason (c) works.
|
|
17
|
+
//
|
|
18
|
+
// The front route:
|
|
19
|
+
// 1. Buffers the RAW body ONCE (needed for BOTH signature verification AND forwarding).
|
|
20
|
+
// 2. Verifies the Slack signature over that raw body (fail-closed 401). We re-implement the
|
|
21
|
+
// HMAC here because Eve's `verifySlackRequest` isn't a public export; slackChannel still
|
|
22
|
+
// re-verifies on the forward path (a fresh Request re-reads the body), so the mention
|
|
23
|
+
// path's auth is unchanged — we only ADD a gate, never weaken one.
|
|
24
|
+
// 3. Classifies the envelope: url_verification / block_actions interaction → FORWARD to
|
|
25
|
+
// slackChannel's handler; message.im and message.mpim (the DM FAMILY) → divert into the
|
|
26
|
+
// surface's own standing DM window (OPE-351 stages 2 + 4), never forwarded; app_mention →
|
|
27
|
+
// FORWARD too, UNLESS either the single-window mention policy (unified-window.ts seam) says
|
|
28
|
+
// the unified window owns it — then the app_mention EVENT ITSELF is diverted to the unified
|
|
29
|
+
// supervisor as an addressed direct-mention wake (single-leg: ONE decision on ONE webhook),
|
|
30
|
+
// and its message.channels sibling is dropped exactly as always — or the mention landed in a
|
|
31
|
+
// GROUP DM, which the envelope does not say and a conversations.info read has to answer, in
|
|
32
|
+
// which case it diverts into that mpim's DM window and its message.mpim sibling is dropped;
|
|
33
|
+
// plain message.channels/groups → run the engagement wake (routeMessageEvent);
|
|
34
|
+
// member_joined_channel (the bot's own join) → the channel-join intro
|
|
35
|
+
// (routeChannelJoinEvent); a urlencoded `block_actions` payload whose actions carry an
|
|
36
|
+
// `oe_approval:` action id → OURS,
|
|
37
|
+
// handled locally via `handleApprovalPayload` and NEVER forwarded — every other
|
|
38
|
+
// urlencoded body (Eve's own `eve_input:` HITL clicks, view_submission, slash
|
|
39
|
+
// commands) still forwards exactly as before.
|
|
40
|
+
// 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
|
|
41
|
+
import { POST } from "eve/channels";
|
|
42
|
+
import { SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER, slackSignature, slackSignatureMatches } from "./slack-signature.js";
|
|
43
|
+
import { slackContinuationToken } from "eve/channels/slack";
|
|
44
|
+
import { callSlackWebApi } from "./slack-web-api.js";
|
|
45
|
+
import { auditDmBotAuthorDrop, auditDmDriverAdmitted, dmDoorAdmitsDriver, parseMessageEvent, routeMessageEvent, liveDeps, routeNameDropEvent, liveNameDropDeps, parseChannelJoinEvent, routeChannelJoinEvent, liveChannelJoinDeps, parseSlackReactionEvent, routeReactionEvent, liveReactionDeps, routeSupervisorEvent, liveSupervisorDeps, supervisorFifoKey, bindableFor, bindableForJoin, bindableForReaction, slackSingleWindowMentionPolicy, slackDomainUsesUnifiedWindow, slackFrontTenantFenceDeclined, slackInteractivityTenantFenceDeclined, } from "./slack-events.js";
|
|
46
|
+
import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
|
|
47
|
+
import { EventDedupe, ThreadFifo, getOrCreateDmSeat as liveGetOrCreateDmSeat, touchDmSeat as liveTouchDmSeat, resolvePartition, domainKeyOf, isThreadEngaged, divertInboundToUnifiedWindow, isPrunedDeploymentWakeError, markUndeliveredAfterHeal, isUndeliveredAfterHeal, healPrunedDeploymentStrand, mintWakeId, slackVendorEventTime, spreadVendorTs, resolveWakeTrust, ambientSupervisorEnabled, getConfig, getOperationalStore, OE_APPROVAL_PREFIX, handleApprovalPayload as liveHandleApprovalPayload, OE_SETKEY_ACTION_PREFIX, OE_SETKEY_MODAL_CALLBACK, handleSetKeyClick as liveHandleSetKeyClick, handleSetKeyViewSubmission as liveHandleSetKeyViewSubmission, OE_CONNECT_ACTION_PREFIX, handleConnectClick as liveHandleConnectClick, OE_GRANT_ACTION_PREFIX, OE_INPUT_CLICK_PREFIX, handleGrantCardClick as liveHandleGrantCardClick, buildRecord, enqueue as enqueueLogRecord, flushLogShip, stampNonMemberChatAuth, externalPolicyIsInert, recordWakeTail, tailAuthorGrain, dmWindowRegistryKey, approvalCardRespondVia, } from "@open-everywhere/core/agent/adapter-api.js";
|
|
48
|
+
import { renderWakeMessage, slackChannelFactsFor, slackAuthorIsMember, slackClickerIsGuest, slackWakeAuthorFacts, slackExternalPolicyForRoom, applySlackExternalPolicy, stripUnfencedSlackAttachments, slackEnterpriseIdFromEnvelope, _resetAuthorMemberCache, } from "./slack-events.js";
|
|
49
|
+
import { buildSlackAuth, withForwardedSlackEnvelope } from "./slack-identity.js";
|
|
50
|
+
import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
|
|
51
|
+
import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
|
|
52
|
+
import { handleSlackApprovalCardClick as liveHandleApprovalCardClick, } from "./slack-approval-card.js";
|
|
53
|
+
import { parseSlackShortcutPayload, shortcutMessageEvent, shortcutWakeKey, } from "./slack-shortcut.js";
|
|
54
|
+
/** The public URL Slack POSTs every subscribed event to (Event Subscriptions Request URL). */
|
|
55
|
+
export const PUBLIC_SLACK_ROUTE = "/eve/v1/slack";
|
|
56
|
+
/** The internal path the wrapped `slackChannel` is mounted at (never hit by Slack directly). */
|
|
57
|
+
export const INTERNAL_SLACK_ROUTE = "/eve/v1/slack/mention";
|
|
58
|
+
/**
|
|
59
|
+
* Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
|
|
60
|
+
* payload — `undefined` for anything that isn't one (no `payload` field, unparseable JSON, a
|
|
61
|
+
* non-`block_actions` type, or a `block_actions` whose action ids are all someone else's, e.g.
|
|
62
|
+
* Eve's own `eve_input:` HITL buttons). Never throws: any parse failure just means "not ours".
|
|
63
|
+
*/
|
|
64
|
+
function parseOeApprovalPayload(rawBody) {
|
|
65
|
+
const raw = new URLSearchParams(rawBody).get("payload");
|
|
66
|
+
if (!raw)
|
|
67
|
+
return undefined;
|
|
68
|
+
let payload;
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (typeof payload !== "object" || payload === null)
|
|
76
|
+
return undefined;
|
|
77
|
+
const p = payload;
|
|
78
|
+
if (p.type !== "block_actions" || !Array.isArray(p.actions))
|
|
79
|
+
return undefined;
|
|
80
|
+
const isOurs = p.actions.some((a) => typeof a === "object" &&
|
|
81
|
+
a !== null &&
|
|
82
|
+
typeof a.action_id === "string" &&
|
|
83
|
+
a.action_id.startsWith(OE_APPROVAL_PREFIX));
|
|
84
|
+
return isOurs ? payload : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Decode the interactivity `payload` field into a plain object, or undefined if it isn't parseable
|
|
87
|
+
* JSON-in-urlencoded. Shared by the setkey click + view_submission classifiers below. */
|
|
88
|
+
function decodeInteractivityPayload(rawBody) {
|
|
89
|
+
const raw = new URLSearchParams(rawBody).get("payload");
|
|
90
|
+
if (!raw)
|
|
91
|
+
return undefined;
|
|
92
|
+
let payload;
|
|
93
|
+
try {
|
|
94
|
+
payload = JSON.parse(raw);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return typeof payload === "object" && payload !== null ? payload : undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Best-effort decode of a urlencoded body into an `oe_setkey:` "Set API key" `block_actions`
|
|
103
|
+
* payload (credentials/intake.ts) — undefined for anything that isn't one. Never throws.
|
|
104
|
+
*/
|
|
105
|
+
function parseOeSetKeyClickPayload(rawBody) {
|
|
106
|
+
const p = decodeInteractivityPayload(rawBody);
|
|
107
|
+
if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
|
|
108
|
+
return undefined;
|
|
109
|
+
const isOurs = p.actions.some((a) => typeof a === "object" &&
|
|
110
|
+
a !== null &&
|
|
111
|
+
typeof a.action_id === "string" &&
|
|
112
|
+
a.action_id.startsWith(OE_SETKEY_ACTION_PREFIX));
|
|
113
|
+
return isOurs ? p : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Best-effort decode of a urlencoded body into an `oe_connect:` connector-card `block_actions` payload
|
|
117
|
+
* (connections/connector-card.ts) — undefined for anything that isn't one. Never throws.
|
|
118
|
+
*/
|
|
119
|
+
function parseOeConnectClickPayload(rawBody) {
|
|
120
|
+
const p = decodeInteractivityPayload(rawBody);
|
|
121
|
+
if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
|
|
122
|
+
return undefined;
|
|
123
|
+
const isOurs = p.actions.some((a) => typeof a === "object" &&
|
|
124
|
+
a !== null &&
|
|
125
|
+
typeof a.action_id === "string" &&
|
|
126
|
+
a.action_id.startsWith(OE_CONNECT_ACTION_PREFIX));
|
|
127
|
+
return isOurs ? p : undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Best-effort decode of a urlencoded body into an `oe_grant:` credential-grant card `block_actions`
|
|
131
|
+
* payload (access/credential-grant-card.ts) — undefined for anything that isn't one. Never throws.
|
|
132
|
+
*/
|
|
133
|
+
function parseOeGrantClickPayload(rawBody) {
|
|
134
|
+
const p = decodeInteractivityPayload(rawBody);
|
|
135
|
+
if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
|
|
136
|
+
return undefined;
|
|
137
|
+
const isOurs = p.actions.some((a) => typeof a === "object" &&
|
|
138
|
+
a !== null &&
|
|
139
|
+
typeof a.action_id === "string" &&
|
|
140
|
+
a.action_id.startsWith(OE_GRANT_ACTION_PREFIX));
|
|
141
|
+
return isOurs ? p : undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Best-effort decode of a urlencoded body into an `oe_input:` bridged-approval-card `block_actions`
|
|
145
|
+
* payload (core channels/approval-card.ts) — undefined for anything that isn't one. Never throws.
|
|
146
|
+
*
|
|
147
|
+
* 🔴 NOT eve's `eve_input:`, which still FORWARDS. The two prefixes are one character apart on
|
|
148
|
+
* purpose — both name an input request — and only one of them is ours. `startsWith` distinguishes
|
|
149
|
+
* them cleanly because neither is a prefix of the other, and the order of the two branches never
|
|
150
|
+
* matters: eve's clicks reach no branch here at all and fall to the urlencoded forward.
|
|
151
|
+
*/
|
|
152
|
+
function parseOeInputClickPayload(rawBody) {
|
|
153
|
+
const p = decodeInteractivityPayload(rawBody);
|
|
154
|
+
if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
|
|
155
|
+
return undefined;
|
|
156
|
+
const isOurs = p.actions.some((a) => typeof a === "object" &&
|
|
157
|
+
a !== null &&
|
|
158
|
+
typeof a.action_id === "string" &&
|
|
159
|
+
a.action_id.startsWith(OE_INPUT_CLICK_PREFIX));
|
|
160
|
+
return isOurs ? p : undefined;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Best-effort decode of a urlencoded body into OUR credential-intake `view_submission` (its
|
|
164
|
+
* `view.callback_id` is our modal's) — undefined for any other view_submission (Eve's own modals
|
|
165
|
+
* still forward). Never throws.
|
|
166
|
+
*/
|
|
167
|
+
function parseOeSetKeyViewSubmission(rawBody) {
|
|
168
|
+
const p = decodeInteractivityPayload(rawBody);
|
|
169
|
+
if (!p || p.type !== "view_submission")
|
|
170
|
+
return undefined;
|
|
171
|
+
const view = p.view;
|
|
172
|
+
return view?.callback_id === OE_SETKEY_MODAL_CALLBACK ? p : undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The ids one already-decoded INTERACTIVITY payload names, for the tenant fence at the six click
|
|
176
|
+
* branches (OPE-1680). The tenant half comes from {@link slackInteractivityTenantIds}, which is the
|
|
177
|
+
* one reader of Slack's three Grid spellings; the room half is read here because only the fence's
|
|
178
|
+
* AUDIT ROW wants it — the admission decision never turns on a channel.
|
|
179
|
+
*
|
|
180
|
+
* A `view_submission` names no `channel` at all (it names a view), which is exactly why the row
|
|
181
|
+
* writer takes an optional channel rather than demanding one.
|
|
182
|
+
*/
|
|
183
|
+
function interactivityFenceIds(payload) {
|
|
184
|
+
const p = payload;
|
|
185
|
+
const obj = (v) => (typeof v === "object" && v !== null ? v : undefined);
|
|
186
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
187
|
+
const channelId = str(obj(p?.channel)?.id);
|
|
188
|
+
const userId = str(obj(p?.user)?.id);
|
|
189
|
+
return {
|
|
190
|
+
...slackInteractivityTenantIds(payload),
|
|
191
|
+
...(channelId === undefined ? {} : { channelId }),
|
|
192
|
+
...(userId === undefined ? {} : { userId }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Decide how the front route handles a raw inbound body. FORWARD is the fail-safe default:
|
|
197
|
+
* anything slackChannel owns (the challenge handshake, `app_mention`, a `block_actions`/slash
|
|
198
|
+
* interaction) or anything unrecognized goes to slackChannel unchanged. The DM FAMILY is ours:
|
|
199
|
+
* `message.im` (OPE-351 stage 2) and `message.mpim` (stage 4) classify `dm-wake` and are diverted
|
|
200
|
+
* into the surface's own standing window. The other events slackChannel drops route to us too: a
|
|
201
|
+
* plain channel/group `message` → the engagement wake, `member_joined_channel` → the channel-join
|
|
202
|
+
* intro, and a urlencoded `block_actions` payload carrying an `oe_approval:` action id → the
|
|
203
|
+
* approval-click handler (ours alone). A urlencoded SHORTCUT payload (`message_action` /
|
|
204
|
+
* `shortcut`, OPE-1376) is ours too — every OTHER urlencoded body, including Eve's `eve_input:`
|
|
205
|
+
* clicks, its view_submissions and slash commands, still forwards. No network I/O and NO side
|
|
206
|
+
* effects, so every branch is unit-tested. Classification decides what a request IS; nothing here
|
|
207
|
+
* may write process state, because every gate that decides whether the request WAKES anything runs
|
|
208
|
+
* later in the handler (OPE-1442). The absence of a lookup is also why the
|
|
209
|
+
* GROUP-DM app_mention leg is decided in the HANDLER and not here: it needs a Slack lookup, because
|
|
210
|
+
* an app_mention envelope carries no `channel_type` to classify on.
|
|
211
|
+
*/
|
|
212
|
+
export function classifyEnvelope(rawBody, contentType) {
|
|
213
|
+
// Interactions (button clicks, slash commands) arrive urlencoded (`payload=...`).
|
|
214
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
215
|
+
const approvalPayload = parseOeApprovalPayload(rawBody);
|
|
216
|
+
if (approvalPayload !== undefined) {
|
|
217
|
+
return { kind: "approval-click", reason: "block_actions oe_approval: click — ours, never forwarded", payload: approvalPayload };
|
|
218
|
+
}
|
|
219
|
+
const setKeyClick = parseOeSetKeyClickPayload(rawBody);
|
|
220
|
+
if (setKeyClick !== undefined) {
|
|
221
|
+
return { kind: "setkey-click", reason: "block_actions oe_setkey: click — ours, never forwarded", payload: setKeyClick };
|
|
222
|
+
}
|
|
223
|
+
const setKeySubmit = parseOeSetKeyViewSubmission(rawBody);
|
|
224
|
+
if (setKeySubmit !== undefined) {
|
|
225
|
+
return { kind: "setkey-submit", reason: "view_submission for our credential-intake modal — ours, never forwarded", payload: setKeySubmit };
|
|
226
|
+
}
|
|
227
|
+
const connectClick = parseOeConnectClickPayload(rawBody);
|
|
228
|
+
if (connectClick !== undefined) {
|
|
229
|
+
return { kind: "connect-click", reason: "block_actions oe_connect: click — ours, never forwarded", payload: connectClick };
|
|
230
|
+
}
|
|
231
|
+
const grantClick = parseOeGrantClickPayload(rawBody);
|
|
232
|
+
if (grantClick !== undefined) {
|
|
233
|
+
return { kind: "grant-click", reason: "block_actions oe_grant: click — ours, never forwarded", payload: grantClick };
|
|
234
|
+
}
|
|
235
|
+
const inputClick = parseOeInputClickPayload(rawBody);
|
|
236
|
+
if (inputClick !== undefined) {
|
|
237
|
+
return { kind: "input-click", reason: "block_actions oe_input: click — ours, never forwarded", payload: inputClick };
|
|
238
|
+
}
|
|
239
|
+
// A SHORTCUT (OPE-1376) — a `message_action` or `shortcut` payload. Ours, never forwarded; see
|
|
240
|
+
// the `shortcut` arm of FrontDecision for why a forward could not do anything with it. It is
|
|
241
|
+
// matched LAST among the urlencoded branches and cannot race any of them: every branch above
|
|
242
|
+
// reads a `block_actions` or a `view_submission`, and a shortcut is neither.
|
|
243
|
+
const shortcut = parseSlackShortcutPayload(decodeInteractivityPayload(rawBody));
|
|
244
|
+
if (shortcut) {
|
|
245
|
+
return { kind: "shortcut", reason: `${shortcut.kind} shortcut "${shortcut.callbackId}" — ours, never forwarded`, shortcut };
|
|
246
|
+
}
|
|
247
|
+
return { kind: "forward", reason: "urlencoded interaction/slash — slackChannel owns it" };
|
|
248
|
+
}
|
|
249
|
+
let env;
|
|
250
|
+
try {
|
|
251
|
+
env = JSON.parse(rawBody);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return { kind: "forward", reason: "unparseable body — let slackChannel ack it" };
|
|
255
|
+
}
|
|
256
|
+
if (env?.type === "url_verification") {
|
|
257
|
+
return { kind: "forward", reason: "url_verification challenge — slackChannel answers it" };
|
|
258
|
+
}
|
|
259
|
+
if (env?.type !== "event_callback") {
|
|
260
|
+
return { kind: "forward", reason: `outer type "${String(env?.type)}" — not an event we wake on` };
|
|
261
|
+
}
|
|
262
|
+
const eventType = typeof env.event?.type === "string" ? env.event.type : undefined;
|
|
263
|
+
if (eventType === "app_mention") {
|
|
264
|
+
// Not a bare "forward": the handler must first ask the single-window policy whether the
|
|
265
|
+
// unified window owns this mention (suppress the native forward) — the default answer is
|
|
266
|
+
// "forward to the mention path", byte-identical to before the branch existed.
|
|
267
|
+
return { kind: "mention", reason: "app_mention — the mention path owns it unless single-window suppresses", env };
|
|
268
|
+
}
|
|
269
|
+
if (eventType === "member_joined_channel") {
|
|
270
|
+
return { kind: "join", reason: "member_joined_channel — channel-join intro candidate", env };
|
|
271
|
+
}
|
|
272
|
+
if (eventType === "reaction_added" || eventType === "reaction_removed") {
|
|
273
|
+
// Inbound reactions: slackChannel drops them (no handler), so they're ours to ingest
|
|
274
|
+
// (reaction-ingest.ts — a feedback signal on the bot's own replies + volume telemetry).
|
|
275
|
+
return { kind: "reaction", reason: `${eventType} — reaction ingestion candidate`, env };
|
|
276
|
+
}
|
|
277
|
+
if (eventType !== "message") {
|
|
278
|
+
return { kind: "forward", reason: `event "${String(eventType)}" — not a message` };
|
|
279
|
+
}
|
|
280
|
+
const channelType = typeof env.event.channel_type === "string"
|
|
281
|
+
? env.event.channel_type
|
|
282
|
+
: undefined;
|
|
283
|
+
if (channelType === "im") {
|
|
284
|
+
// OPE-351 stage 2: a 1:1 DM is no longer forwarded to eve's native `onDirectMessage`. Its seat is
|
|
285
|
+
// a first-class standing WINDOW (channels/dm-seat.ts), so the DM is diverted onto the `unified`
|
|
286
|
+
// channel exactly like the single-window app_mention above — one decision, one webhook.
|
|
287
|
+
return { kind: "dm-wake", reason: "message.im (DM) — the DM window seat owns it", env, dmKind: "im" };
|
|
288
|
+
}
|
|
289
|
+
if (channelType === "mpim") {
|
|
290
|
+
// OPE-351 stage 4: a GROUP DM joins the family. Its previous route was the engagement wake below,
|
|
291
|
+
// where `shouldNameDropWake` refused it outright on channel_type — so a non-mention message in a
|
|
292
|
+
// group DM woke NOTHING, and the room's only live path was an `app_mention` forwarded to eve's
|
|
293
|
+
// native per-thread session. Both halves move into the mpim's own standing window (the mention
|
|
294
|
+
// half in the `mention` branch of the handler, which has to ASK Slack what surface it landed on —
|
|
295
|
+
// an app_mention envelope carries no channel_type).
|
|
296
|
+
return { kind: "dm-wake", reason: "message.mpim (group DM) — the DM window seat owns it", env, dmKind: "mpim" };
|
|
297
|
+
}
|
|
298
|
+
// channels / groups — the plain reply slackChannel drops. Ours to wake.
|
|
299
|
+
return { kind: "wake", reason: `message.${channelType ?? "?"} — engagement wake candidate`, env };
|
|
300
|
+
}
|
|
301
|
+
/** The FIFO key a DM's wakes serialize on. Generation-INDEPENDENT (the same reasoning as
|
|
302
|
+
* `supervisorFifoKey`): the FIFO's job is to serialize this DM's wakes, which must hold ACROSS a
|
|
303
|
+
* rotation boundary too, so it keys on the channel and never on the generation-bearing token. Its
|
|
304
|
+
* own namespace, so a DM's queue is never shared with a channel supervisor's or a thread's. */
|
|
305
|
+
export function dmFifoKey(dmChannelId) {
|
|
306
|
+
return `${dmChannelId}:oe-dm`;
|
|
307
|
+
}
|
|
308
|
+
// --- signature verification (Slack HMAC) ------------------------------------
|
|
309
|
+
const SLACK_MAX_SKEW_SECONDS = 60 * 5; // Slack's own replay window.
|
|
310
|
+
/**
|
|
311
|
+
* Verify a Slack request signature over the RAW body (the exact bytes Slack signed).
|
|
312
|
+
* `v0=` HMAC-SHA256 of `v0:{timestamp}:{body}` keyed by the signing secret, constant-time
|
|
313
|
+
* compared, with a 5-minute replay window. Fail-closed: any missing header / secret, a stale
|
|
314
|
+
* timestamp, or a mismatch returns false. `nowMs` is injectable so the replay window is tested.
|
|
315
|
+
*
|
|
316
|
+
* The BASESTRING AND DIGEST are `channels/slack-signature.ts`'s (OPE-197), shared verbatim with the
|
|
317
|
+
* webhook-registry entry for this scheme — a difference there would be a signature that verifies on
|
|
318
|
+
* one route and 401s on the other. What stays HERE is what the two routes legitimately differ on:
|
|
319
|
+
* this one reads one env-homed secret and returns a bare boolean, because a Slack Request URL has no
|
|
320
|
+
* failure vocabulary to report into.
|
|
321
|
+
*/
|
|
322
|
+
export function verifySlackSignature(rawBody, headers, signingSecret, nowMs = Date.now()) {
|
|
323
|
+
const timestamp = headers.get(SLACK_TIMESTAMP_HEADER);
|
|
324
|
+
const signature = headers.get(SLACK_SIGNATURE_HEADER);
|
|
325
|
+
if (!signingSecret || !timestamp || !signature)
|
|
326
|
+
return false;
|
|
327
|
+
const ts = Number(timestamp);
|
|
328
|
+
if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > SLACK_MAX_SKEW_SECONDS)
|
|
329
|
+
return false;
|
|
330
|
+
return slackSignatureMatches(signingSecret, timestamp, rawBody, signature);
|
|
331
|
+
}
|
|
332
|
+
// --- retry de-dupe + per-thread FIFO: LIFTED to core (OPE-114 stage 4) ----------------------
|
|
333
|
+
// EventDedupe and ThreadFifo are provider-neutral front-door plumbing (teams-front rides them
|
|
334
|
+
// too) — they now live in core channels/front-queue.ts, imported through the barrel below and
|
|
335
|
+
// re-exported here so bundle-internal import sites (slack-commands, tests) are unchanged.
|
|
336
|
+
export { EventDedupe, ThreadFifo };
|
|
337
|
+
// --- bot identity (mention suppression) -------------------------------------
|
|
338
|
+
/**
|
|
339
|
+
* The bot's own Slack user id, resolved once via `auth.test` (cached). Needed because Slack
|
|
340
|
+
* fires BOTH `app_mention` AND `message.channels` for a message that @mentions the app; the
|
|
341
|
+
* mention path already owns the `app_mention`, so on the wake path we DROP a message whose
|
|
342
|
+
* AUTHORED text still carries `<@bot>` to avoid a double turn. Authored, not the parsed `text`:
|
|
343
|
+
* that now carries forwarded content too, and a forward quoting a bot mention must still wake
|
|
344
|
+
* (OPE-1374). Best-effort: if it can't be resolved
|
|
345
|
+
* (no token / API error) we don't suppress — an occasional double is better than dropping a
|
|
346
|
+
* genuine engaged reply. `SLACK_BOT_TOKEN` is already required for the bot to post at all, so
|
|
347
|
+
* in practice this resolves.
|
|
348
|
+
*/
|
|
349
|
+
let botIdentityPromise;
|
|
350
|
+
export function resolveBotIdentity() {
|
|
351
|
+
if (!botIdentityPromise) {
|
|
352
|
+
botIdentityPromise = (async () => {
|
|
353
|
+
try {
|
|
354
|
+
// Via OE's `callSlackWebApi` (the 429-backoff seam); a missing token makes it throw, and any
|
|
355
|
+
// throw → {} (best-effort — we simply don't suppress on failure).
|
|
356
|
+
const json = (await callSlackWebApi({ botToken: undefined, operation: "auth.test", body: {} }));
|
|
357
|
+
return json.ok ? { userId: json.user_id, botId: json.bot_id } : {};
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
}
|
|
364
|
+
return botIdentityPromise;
|
|
365
|
+
}
|
|
366
|
+
/** Reset the cached identity (tests only). */
|
|
367
|
+
export function _resetBotIdentity() {
|
|
368
|
+
botIdentityPromise = undefined;
|
|
369
|
+
}
|
|
370
|
+
const OK = () => new Response("ok");
|
|
371
|
+
/**
|
|
372
|
+
* The Enterprise Grid rung a forwarded delivery carries, paired with the message it describes
|
|
373
|
+
* (OPE-1559). `undefined` for an ordinary single-workspace install, for an interactivity body, and
|
|
374
|
+
* for anything this route could not parse — the native forward then stows nothing and `slack.ts`
|
|
375
|
+
* stamps no rung, exactly as before.
|
|
376
|
+
*
|
|
377
|
+
* The rung itself comes from {@link slackEnterpriseIdFromEnvelope}, the ONE reader of
|
|
378
|
+
* `enterprise_id`; this function only pairs it with the inner event's `(channel, ts)`, which is what
|
|
379
|
+
* lets `forwardedSlackEnterpriseId` (slack-identity.ts) answer for that message alone. A Grid
|
|
380
|
+
* envelope whose inner event names neither returns `undefined`: an unpairable rung is a rung this
|
|
381
|
+
* route will not carry.
|
|
382
|
+
*/
|
|
383
|
+
function forwardedEnvelopeOf(rawBody) {
|
|
384
|
+
let parsed;
|
|
385
|
+
try {
|
|
386
|
+
parsed = JSON.parse(rawBody);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
// `JSON.parse` answers `null` for the body `null`, and a scalar for `1` or `"x"` — none of which
|
|
392
|
+
// throws, and all of which would make the field reads below throw INSTEAD OF returning undefined.
|
|
393
|
+
// A throw here escapes `forwardNative` and turns a forward that used to ack 200 into a 500 that
|
|
394
|
+
// Slack retries, so the shape is checked rather than assumed.
|
|
395
|
+
if (parsed === null || typeof parsed !== "object")
|
|
396
|
+
return undefined;
|
|
397
|
+
const env = parsed;
|
|
398
|
+
const enterpriseId = slackEnterpriseIdFromEnvelope(env);
|
|
399
|
+
if (enterpriseId === undefined)
|
|
400
|
+
return undefined;
|
|
401
|
+
const event = env.event;
|
|
402
|
+
const channelId = typeof event?.channel === "string" ? event.channel : undefined;
|
|
403
|
+
const ts = typeof event?.ts === "string" ? event.ts : undefined;
|
|
404
|
+
if (!channelId || !ts)
|
|
405
|
+
return undefined;
|
|
406
|
+
return { enterpriseId, channelId, ts };
|
|
407
|
+
}
|
|
408
|
+
/** The event_id (retry-stable) or `(channel, ts)` key we de-dupe a wake candidate on.
|
|
409
|
+
* Prefixed with the RECEIVING bot identity (constant "default" today): with N Slack apps
|
|
410
|
+
* in one channel (item 27 tier 2) each app receives its own copy of every message, and
|
|
411
|
+
* an un-prefixed key would swallow one app's copy as the other's retry — silently, per
|
|
412
|
+
* message, maddening to debug. The TTL'd dedupe store self-heals across the change. */
|
|
413
|
+
function wakeKey(env, channelId, ts, botIdentity = "default") {
|
|
414
|
+
const eventId = typeof env.event_id === "string" ? env.event_id : undefined;
|
|
415
|
+
return `${botIdentity}:${eventId ?? `${channelId}:${ts}`}`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Resolve the 1:1 DM channel with one Slack member (OPE-1376's global-shortcut landing place).
|
|
419
|
+
*
|
|
420
|
+
* `conversations.open` is idempotent — repeated calls return the SAME `im` channel id — so there is
|
|
421
|
+
* no "already open" branch, and this opens no conversation a global shortcut's invoker did not
|
|
422
|
+
* themselves ask for by pressing the shortcut.
|
|
423
|
+
*
|
|
424
|
+
* FAIL-CLOSED and QUIET-ON-THE-WIRE: any failure (no token, a workspace without `im:write`, a user
|
|
425
|
+
* who cannot be DM'd) returns `undefined`, which the caller turns into "wake nothing, log why".
|
|
426
|
+
* Returning a guess would put a person's shortcut into a room they did not choose.
|
|
427
|
+
*/
|
|
428
|
+
async function liveOpenDirectMessageChannel(userId) {
|
|
429
|
+
try {
|
|
430
|
+
const opened = await callSlackWebApi({ botToken: undefined, operation: "conversations.open", body: { users: userId } });
|
|
431
|
+
if (opened?.ok !== true)
|
|
432
|
+
return undefined;
|
|
433
|
+
const channel = opened.channel;
|
|
434
|
+
return typeof channel?.id === "string" && channel.id.length > 0 ? channel.id : undefined;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Durable half of the retry dedupe: the in-memory seen-set only catches a retry landing
|
|
441
|
+
// on the SAME instance; on serverless the redelivery often lands on a fresh one. FAIL-OPEN
|
|
442
|
+
// by design — dedupe prevents an annoyance (a double reply), not a security hole, so a
|
|
443
|
+
// store outage must never silence genuine wakes. TTL matches the in-memory set; Slack
|
|
444
|
+
// stops retrying long before it expires.
|
|
445
|
+
const DEDUPE_TTL_MS = 10 * 60_000;
|
|
446
|
+
let warnedDedupeStore = false;
|
|
447
|
+
async function liveMarkEventSeen(org, key, atIso) {
|
|
448
|
+
try {
|
|
449
|
+
return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
if (!warnedDedupeStore) {
|
|
453
|
+
warnedDedupeStore = true;
|
|
454
|
+
console.warn(`[open-everywhere:slack-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Route decisions used to be silently discarded, which made a gate decline, a judged
|
|
460
|
+
// NO_REPLY, and a broken path indistinguishable from the outside (bit us live: "the
|
|
461
|
+
// name-drop didn't reply" had no way to tell WHY). One line per candidate event; the
|
|
462
|
+
// posted reply itself is the positive signal, this line is the story when there isn't one.
|
|
463
|
+
const logOutcome = (path, channelId, ts) => (outcome) => {
|
|
464
|
+
console.log(`[open-everywhere:slack-front] ${path} ${channelId}/${ts}: ${outcome.woke ? "woke" : "no wake"} — ${outcome.reason}`);
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* Turn a swallowed FIFO wake failure into a DURABLE, queryable signal (resilience S-1 / #551
|
|
468
|
+
* §3.4): a log-ship "error" record (the primary win — the failure survives the ~1h console
|
|
469
|
+
* retention) plus a best-effort `surface.wake_failed` audit row (mirrors recordQuietFailure's
|
|
470
|
+
* posture). The FIFO key encodes the channel (`${channelId}:${threadTs}` for a thread wake,
|
|
471
|
+
* `${channelId}:oe-sup` for a supervisor wake); team is unknown at this layer, so the audit
|
|
472
|
+
* partition falls back to the empty-team default exactly like recordQuietFailure's own
|
|
473
|
+
* `teamId ?? ""` — best-effort, never blocks. Never throws.
|
|
474
|
+
*/
|
|
475
|
+
/**
|
|
476
|
+
* Deliver a wake, and RE-DELIVER IT ONCE when the first attempt died on a pruned deployment
|
|
477
|
+
* (OPE-963, channels/stranded-seat.ts).
|
|
478
|
+
*
|
|
479
|
+
* The first attempt's failure is not wasted: `divertToUnifiedWindow` classified it and MARKED the
|
|
480
|
+
* seat's `channel_session` row for rotation before it rethrew. So the second attempt re-resolves the
|
|
481
|
+
* seat, `rotationTrigger` answers `operator-marked`, the seat takes its own rotate leg (fresh
|
|
482
|
+
* generation, carried tail), and eve opens a NEW run under the CURRENT deployment. Without this the
|
|
483
|
+
* member loses the message that hit the strand, even though the seat itself is healed.
|
|
484
|
+
*
|
|
485
|
+
* EXACTLY ONE retry, and only for this class. Every other failure rethrows untouched, straight to
|
|
486
|
+
* the FIFO's loud handler ({@link recordWakeFailure}). A second failure of the same class rethrows
|
|
487
|
+
* too — the seat could not be marked, so a third attempt would resume the same dead run.
|
|
488
|
+
*
|
|
489
|
+
* THE ROUTE FUNCTION MUST BE RE-RUNNABLE, and both wrapped routes are. Everything they do before
|
|
490
|
+
* the divert is a read or an idempotent write: TTL-cached `conversations.info` / `users.info`
|
|
491
|
+
* probes, a deterministic external-policy verdict (its private notice posts only on a verdict that
|
|
492
|
+
* RETURNS rather than throws, so a retry never reaches it), a read-only spend gate, and
|
|
493
|
+
* `markEngaged`, which upserts. `recordWakeTail` and `touchDmSeat`/`touchSession` all run AFTER the
|
|
494
|
+
* divert, so a failed attempt records nothing to duplicate. The wake id is minted fresh, which is
|
|
495
|
+
* correct — the retry is a new delivery.
|
|
496
|
+
*
|
|
497
|
+
* Applied only to the WINDOW-bearing routes — a thread seat has no `channel_session` row to mark,
|
|
498
|
+
* so a retry there could only resume the same dead run.
|
|
499
|
+
*
|
|
500
|
+
* OPE-970: when the RETRY ALSO throws, the seat is still healed for its NEXT wake — but THIS
|
|
501
|
+
* member's message got no answer, and that fact must not collapse into an ordinary send-failure
|
|
502
|
+
* row indistinguishable from a transient blip. The retry's error is tagged
|
|
503
|
+
* ({@link markUndeliveredAfterHeal}) before it rethrows, so {@link recordWakeFailure} can give the
|
|
504
|
+
* row its own reason.
|
|
505
|
+
*/
|
|
506
|
+
export async function deliverWithStrandRetry(deliver) {
|
|
507
|
+
try {
|
|
508
|
+
return await deliver();
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
if (!isPrunedDeploymentWakeError(err))
|
|
512
|
+
throw err;
|
|
513
|
+
console.warn(`[open-everywhere:slack-front] wake hit a pruned deployment — seat marked for rotation, re-delivering once: ${String(err)}`);
|
|
514
|
+
try {
|
|
515
|
+
return await deliver();
|
|
516
|
+
}
|
|
517
|
+
catch (retryErr) {
|
|
518
|
+
markUndeliveredAfterHeal(retryErr);
|
|
519
|
+
throw retryErr;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
export function recordWakeFailure(key, err) {
|
|
524
|
+
const channelId = key.split(":")[0] ?? key;
|
|
525
|
+
try {
|
|
526
|
+
enqueueLogRecord(buildRecord("error", "wake", "supervisor/thread wake delivery failed", { fields: { fifoKey: key, channelId }, err }));
|
|
527
|
+
void flushLogShip();
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
/* log-ship best-effort */
|
|
531
|
+
}
|
|
532
|
+
// Held on the route (OPE-1648): the FIFO promise this runs under is itself in `waitUntil`, but a
|
|
533
|
+
// `void`-ed write sits OUTSIDE that promise and so races the freeze after the 200 all the same.
|
|
534
|
+
void holdRouteWork((async () => {
|
|
535
|
+
try {
|
|
536
|
+
const p = resolvePartition({
|
|
537
|
+
channel: { kind: "slack" },
|
|
538
|
+
session: { auth: { current: { attributes: { team_id: "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
|
|
539
|
+
});
|
|
540
|
+
// OPE-970: a retry that ALSO failed after the pruned-deployment heal gets its own reason —
|
|
541
|
+
// otherwise it is indistinguishable from any other send failure, and the only way to tell
|
|
542
|
+
// "the seat is fine now but this message was lost" from "a transient blip" was to read the
|
|
543
|
+
// raw message text and recognise the run-key sentence by eye.
|
|
544
|
+
const undeliveredAfterHeal = isUndeliveredAfterHeal(err);
|
|
545
|
+
await getOperationalStore().appendAudit({
|
|
546
|
+
ts: new Date().toISOString(),
|
|
547
|
+
org: p.org,
|
|
548
|
+
platform: p.platform,
|
|
549
|
+
workspace: p.workspace,
|
|
550
|
+
surface: p.surface,
|
|
551
|
+
action: "surface.wake_failed",
|
|
552
|
+
detail: {
|
|
553
|
+
fifoKey: key,
|
|
554
|
+
message: String(err instanceof Error ? err.message : err).slice(0, 500),
|
|
555
|
+
...(undeliveredAfterHeal
|
|
556
|
+
? { reason: "pruned-deployment-heal-retry-failed", remedy: "seat healed for its next wake; this message got no answer" }
|
|
557
|
+
: {}),
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
/* audit is best-effort — the log-ship record above is the durable signal */
|
|
563
|
+
}
|
|
564
|
+
})());
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Build the front route handler. Buffers the body once, verifies the signature, then forwards
|
|
568
|
+
* or wakes per {@link classifyEnvelope}. `innerHandler` is slackChannel's own POST handler,
|
|
569
|
+
* invoked with a FRESH Request (re-readable body) + the same {@link RouteHandlerArgs}, so
|
|
570
|
+
* slackChannel re-verifies and dispatches exactly as if it owned the URL.
|
|
571
|
+
*/
|
|
572
|
+
/**
|
|
573
|
+
* SINGLE-WINDOW diversion (channels/unified-window.ts): layer the cross-channel hop onto the
|
|
574
|
+
* supervisor deps. When routeSupervisorEvent minted a `window` for a wake (the domain's
|
|
575
|
+
* crossSurfaceWindow gates hold — the wakeId is already rendered into the envelope), deliver it
|
|
576
|
+
* through eve's cross-channel `to(...).send(...)` into the synthetic `unified` channel instead of the
|
|
577
|
+
* Slack send: the effective token becomes `unified:<raw>`, shared with every surface in the
|
|
578
|
+
* domain — ONE context window. The unified channel's `receive` registers the wakeId→origin map
|
|
579
|
+
* entry (durably, BEFORE the send) and re-checks the gates fail-closed. This wrapper lives HERE
|
|
580
|
+
* because only the route handler holds `args.to` (and slack-events.ts must not import
|
|
581
|
+
* unified.ts — unified.ts imports it). A window-less wake passes through untouched, so flag-off
|
|
582
|
+
* is byte-identical. A diversion failure PROPAGATES to the FIFO's loud catch (fail-closed: a
|
|
583
|
+
* wake whose reply map couldn't be written is not delivered into the shared window).
|
|
584
|
+
*
|
|
585
|
+
* THREAD-OWNERSHIP ingress routing (§S2): the divert goes through the INBOUND variant
|
|
586
|
+
* (unified.ts divertInboundToUnifiedWindow), which — only under delegation.oneshotAgent — consults the
|
|
587
|
+
* durable ownership index and dispatches a wake landing in an OWNED thread to the owning task/
|
|
588
|
+
* thread-seat session's token instead of the standing seat's. Flag-off, and on every unowned
|
|
589
|
+
* thread, the inbound variant is a byte-identical passthrough to divertToUnifiedWindow.
|
|
590
|
+
*
|
|
591
|
+
* OPE-971: the `!input.window` (NATIVE, flag-off) branch below is Slack's own un-windowed
|
|
592
|
+
* supervisor session — the send this domain takes when it never opted into windowing at all. That
|
|
593
|
+
* send was completely unwrapped: a pruned-deployment strand there stranded the seat silently,
|
|
594
|
+
* forever (see channels/stranded-seat.ts). `deps.wake`'s own registryKey for this branch IS
|
|
595
|
+
* `surfaceKey` (supervisorGrainFor returns `{unified:false, registryKey:surfaceKey}` flag-off — the
|
|
596
|
+
* exact row `liveSupervisorDeps`'s `getOrCreateSession` already rotates), so the heal is told that
|
|
597
|
+
* key directly rather than trying to parse it back out of the per-channel token.
|
|
598
|
+
*/
|
|
599
|
+
export function withSingleWindowDiversion(deps, to, surfaceKey) {
|
|
600
|
+
return {
|
|
601
|
+
...deps,
|
|
602
|
+
async wake(input) {
|
|
603
|
+
if (!input.window) {
|
|
604
|
+
try {
|
|
605
|
+
await deps.wake(input);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
await healPrunedDeploymentStrand({
|
|
609
|
+
token: input.token,
|
|
610
|
+
registryKey: surfaceKey,
|
|
611
|
+
surfaceKey,
|
|
612
|
+
workspace: surfaceKey.split("/")[1] ?? "unknown",
|
|
613
|
+
platform: "slack",
|
|
614
|
+
atIso: new Date().toISOString(),
|
|
615
|
+
err,
|
|
616
|
+
});
|
|
617
|
+
throw err;
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
await divertInboundToUnifiedWindow(to, {
|
|
622
|
+
message: input.message,
|
|
623
|
+
// The seat wake's untrusted-wrapped side-channel context (the name-drop hydration,
|
|
624
|
+
// OPE-1725). Omitted on every other wake, so a context-less divert is byte-identical.
|
|
625
|
+
...(input.context && input.context.length > 0 ? { context: input.context } : {}),
|
|
626
|
+
token: input.token,
|
|
627
|
+
window: input.window,
|
|
628
|
+
surfaceKey,
|
|
629
|
+
auth: input.auth,
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// DM-PARTNER TRUST, front-route copy (OPE-351 stage 2). `dm_partner_trust` gates the DM's SHARED read
|
|
635
|
+
// layer (internal partner ⇒ `dm` trust, external ⇒ `public`), so the diverted DM's auth must carry it
|
|
636
|
+
// or every DM silently degrades to the external tier — a regression the native path would not have had.
|
|
637
|
+
// The native path resolves it in slack.ts (`enrichDmPartner`) off `ctx.slack.request`, which does not
|
|
638
|
+
// exist here (the front route holds no eve slack context, and slack.ts imports THIS module, so the
|
|
639
|
+
// helper cannot simply be shared without a cycle).
|
|
640
|
+
//
|
|
641
|
+
// OPE-466: the PROBE ITSELF now lives in slack-events.ts (`slackAuthorIsMember`), because the same
|
|
642
|
+
// question — is this person a full member of this workspace? — is what a CHANNEL author's wake tier
|
|
643
|
+
// needs, and two caches of one answer drift. This function is the DM leg's reading of that verdict,
|
|
644
|
+
// and it keeps the DM leg's own FAIL-CLOSED direction: an unresolvable probe leaves the attribute
|
|
645
|
+
// unset and the binding fails closed to the external tier, unchanged from before.
|
|
646
|
+
async function liveDmPartnerTrust(userId, teamId) {
|
|
647
|
+
const member = await slackAuthorIsMember(userId, teamId);
|
|
648
|
+
return member === undefined ? undefined : member ? "internal" : "external";
|
|
649
|
+
}
|
|
650
|
+
/** Reset the cached DM-partner trust (tests only). */
|
|
651
|
+
export function _resetDmTrustCache() {
|
|
652
|
+
_resetAuthorMemberCache();
|
|
653
|
+
}
|
|
654
|
+
// `wrapDmSeatFrom` (OPE-90) is DELETED at OPE-351 stage 2. It existed to remap the address of eve's
|
|
655
|
+
// NATIVE DM send onto a rotatable seat token — a bolt-on to a dispatch path a DM no longer takes. The
|
|
656
|
+
// DM is now diverted onto the `unified` channel before the forward ever happens (`scheduleDmWake`
|
|
657
|
+
// below), so the seat token is chosen at DISPATCH time by the same code every other standing seat
|
|
658
|
+
// uses, and there is no native send left to intercept. What it carried forward, and where each part
|
|
659
|
+
// lives now: the seat resolve → `resolveDmSeat` in `scheduleDmWake`; the fresh-generation tail
|
|
660
|
+
// prepended as `context` → the divert's own `context` argument; the off-critical-path wake touch →
|
|
661
|
+
// the same `waitUntil(touch…)` there. What it does NOT carry forward is its FAIL-OPEN fallback to the
|
|
662
|
+
// native send: there is nothing honest to fall back TO, since eve's native DM dispatch would open a
|
|
663
|
+
// session on an address the seat has no window row for. A DM whose seat cannot be resolved now fails
|
|
664
|
+
// LOUDLY into the FIFO's catch (audited via recordWakeFailure) instead of silently answering from a
|
|
665
|
+
// second, un-rotatable session — the same fail-closed posture every other divert has.
|
|
666
|
+
/**
|
|
667
|
+
* Does this `message` event DUPLICATE an `app_mention` Slack also fires, so the wake path must drop it?
|
|
668
|
+
*
|
|
669
|
+
* Slack sends TWO events for a new message that @mentions the bot: the `app_mention`, which the
|
|
670
|
+
* front's own `mention` branch owns, and a sibling carrying the same text — `message.channels` in a
|
|
671
|
+
* channel, `message.mpim` in a group DM. Acting on both is a double turn, so both the channel wake
|
|
672
|
+
* path and the group-DM leg drop the sibling through this one rule.
|
|
673
|
+
*
|
|
674
|
+
* 🔴 AN EDIT IS NEVER A SIBLING, AND SUPPRESSING ONE IS A LOST WAKE (OPE-1673). Slack delivers an
|
|
675
|
+
* edit ONLY as `message` with `subtype: "message_changed"`; no `app_mention` accompanies it, and
|
|
676
|
+
* `parseMessageEvent` reads the POST-EDIT text — so a person who edits a message in an engaged
|
|
677
|
+
* thread to add `<@bot>`, or who corrects the wording of a mention that is already there, matched
|
|
678
|
+
* this rule and was answered by nothing at all. There is no second event to prefer. The exemption is
|
|
679
|
+
* narrow by construction: an edit cannot reach the `app_mention` branch, because the front classifies
|
|
680
|
+
* the envelope on its event TYPE and a `message_changed` always arrives as a `message`.
|
|
681
|
+
*
|
|
682
|
+
* UNVERIFIED, and stated rather than assumed: Slack's docs do not say whether an edit that ADDS a
|
|
683
|
+
* mention also raises a fresh `app_mention`. If it does, the cost of this exemption is an occasional
|
|
684
|
+
* double turn — the same trade the next paragraph already takes, for the same reason.
|
|
685
|
+
*
|
|
686
|
+
* MATCH THE AUTHOR'S OWN TEXT, NEVER THE MERGED ONE (OPE-1374). `ev.text` carries forwarded and
|
|
687
|
+
* quoted content, so testing it would suppress a forward whose QUOTED message mentions the bot.
|
|
688
|
+
* UNVERIFIED for the same reason as above: whether Slack fires an `app_mention` for a mention that
|
|
689
|
+
* lives inside an attachment. If it does NOT — the shape we expect — testing the merged text loses
|
|
690
|
+
* the wake outright, because no sibling ever arrives to deliver it. If it DOES, the cost is a double
|
|
691
|
+
* turn. We take the double: an occasional double beats a drop, and a deaf agent on a forward is the
|
|
692
|
+
* failure OPE-1374 exists to prevent.
|
|
693
|
+
*
|
|
694
|
+
* Pure, so both the suppression and its exemption are unit-testable without Slack or a store. An
|
|
695
|
+
* unresolved bot user id suppresses nothing — there is no needle to match, and inventing one would
|
|
696
|
+
* drop every message.
|
|
697
|
+
*/
|
|
698
|
+
export function mentionSiblingSuppressed(input) {
|
|
699
|
+
if (input.botUserId === undefined || input.botUserId.length === 0)
|
|
700
|
+
return false;
|
|
701
|
+
if (input.subtype === "message_changed")
|
|
702
|
+
return false;
|
|
703
|
+
return input.authoredText.includes(`<@${input.botUserId}>`);
|
|
704
|
+
}
|
|
705
|
+
export function buildFrontHandler(innerHandler, opts = {}) {
|
|
706
|
+
const dedupe = opts.dedupe ?? new EventDedupe();
|
|
707
|
+
const fifo = opts.fifo ?? new ThreadFifo(recordWakeFailure);
|
|
708
|
+
const getBotIdentity = opts.getBotIdentity ?? resolveBotIdentity;
|
|
709
|
+
const now = opts.now ?? (() => Date.now());
|
|
710
|
+
const signingSecret = () => opts.signingSecret ?? process.env.SLACK_SIGNING_SECRET;
|
|
711
|
+
const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
|
|
712
|
+
// OPE-589 leg D: reuse the taint-marking member probe (slack-events.ts `slackClickerIsGuest`,
|
|
713
|
+
// itself `slackAuthorIsMember` inverted). Same `users.info` call and cache the channel-taint leg
|
|
714
|
+
// already pays for, so a clicker who recently posted a message in the same workspace is often a
|
|
715
|
+
// free lookup, not a new one.
|
|
716
|
+
const handleApproval = opts.handleApprovalPayload ??
|
|
717
|
+
((payload, deps) => liveHandleApprovalPayload(payload, { clickerIsGuest: slackClickerIsGuest, ...(deps?.waitUntil ? { waitUntil: deps.waitUntil } : {}) }));
|
|
718
|
+
const handleSetKeyClick = opts.handleSetKeyClick ?? liveHandleSetKeyClick;
|
|
719
|
+
const handleSetKeyViewSubmission = opts.handleSetKeyViewSubmission ?? liveHandleSetKeyViewSubmission;
|
|
720
|
+
const handleConnectClick = opts.handleConnectClick ?? liveHandleConnectClick;
|
|
721
|
+
const handleGrantCardClick = opts.handleGrantCardClick ?? liveHandleGrantCardClick;
|
|
722
|
+
const handleApprovalCardClick = opts.handleApprovalCardClick ?? liveHandleApprovalCardClick;
|
|
723
|
+
const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
|
|
724
|
+
const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
|
|
725
|
+
const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
|
|
726
|
+
const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
|
|
727
|
+
const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
|
|
728
|
+
const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
|
|
729
|
+
const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
|
|
730
|
+
const channelFactsFor = opts.channelFactsFor ?? slackChannelFactsFor;
|
|
731
|
+
const authorFactsFor = opts.authorFactsFor ?? ((internal, userId, teamId) => slackWakeAuthorFacts(internal, userId, teamId));
|
|
732
|
+
// THE one single-window mention decision, shared by BOTH mention legs (the `app_mention`
|
|
733
|
+
// native-forward below and its `message.channels` sibling on the wake path) so they stay
|
|
734
|
+
// exactly complementary — never a double turn (native + unified), never a gap (neither). True ⇔
|
|
735
|
+
// the channel has a STANDING SEAT and the policy's privacy floor holds (channel POSITIVELY
|
|
736
|
+
// confirmed internal, fail-closed). Any error ⇒ false ⇒ the native split.
|
|
737
|
+
// 🔴 THE CHATTINESS PRECONDITION IS GONE (OPE-1695). Requiring `ambientSupervisor` here is what
|
|
738
|
+
// made a mention in an addressed-only room open eve's native per-thread session — the fallback
|
|
739
|
+
// the ruling deletes. Its complement moved with it: `scheduleUnownedWake` now takes the seat road
|
|
740
|
+
// for every addressed wake, so suppressing the native leg can no longer leave the mention with
|
|
741
|
+
// nowhere to land.
|
|
742
|
+
const singleWindowOwnsMention = async (channelId, surfaceKey) => (await mentionPolicyFor(channelId, surfaceKey)).suppressNative;
|
|
743
|
+
// 🔴 THE WHOLE HANDLER RUNS INSIDE THE ROUTE HOLD (OPE-1648). Every door below answers its 200 at
|
|
744
|
+
// once and finishes its work afterwards — this route through `args.waitUntil`, eve's native
|
|
745
|
+
// handler through the same `args` on the forward — so a `void`-ed audit write anywhere under here
|
|
746
|
+
// races the serverless freeze that follows the response and loses the row.
|
|
747
|
+
//
|
|
748
|
+
// 🔴 EVERY `waitUntil` THE DELIVERY REGISTERS IS QUIESCED FIRST, and that is what makes the hold
|
|
749
|
+
// reach the wake routes. eve snapshots its background-task array the instant this handler
|
|
750
|
+
// resolves, so a `waitUntil` called later — from an engagement route running inside one of the
|
|
751
|
+
// FIFO promises below, long after the 200 — lands in a list nobody awaits. Every such route runs
|
|
752
|
+
// INSIDE a promise registered here while the handler was still going, so handing the platform a
|
|
753
|
+
// promise that outlives that route's own writes is enough, and nothing extra is registered: a
|
|
754
|
+
// delivery that queues no background work still hands the platform nothing at all.
|
|
755
|
+
return (req, args) => withSlackRouteHold(() => {
|
|
756
|
+
// `Object.create`, not a spread: the route args are eve's object, and a spread would keep only
|
|
757
|
+
// its OWN enumerable properties. Shadowing one method on a prototype-linked copy leaves
|
|
758
|
+
// everything else — `from`, `to`, `attachSession`, `params` — resolving to eve's own.
|
|
759
|
+
const held = Object.assign(Object.create(args), {
|
|
760
|
+
waitUntil: (p) => args.waitUntil(routeHoldQuiesced(p)),
|
|
761
|
+
});
|
|
762
|
+
return handleFrontRequest(req, held);
|
|
763
|
+
});
|
|
764
|
+
async function handleFrontRequest(req, args) {
|
|
765
|
+
const rawBody = await req.text();
|
|
766
|
+
if (!verifySlackSignature(rawBody, req.headers, signingSecret(), now())) {
|
|
767
|
+
return new Response("unauthorized", { status: 401 });
|
|
768
|
+
}
|
|
769
|
+
const decision = classifyEnvelope(rawBody, req.headers.get("content-type") ?? "");
|
|
770
|
+
/**
|
|
771
|
+
* Hand this request to eve's native `slackChannel`, with the OPE-1374 attachment boundary
|
|
772
|
+
* applied to what eve is given (OPE-1380).
|
|
773
|
+
*
|
|
774
|
+
* 🔴 EVERY NATIVE FORWARD GOES THROUGH HERE, not just the `@mention` one. A fence on the mention
|
|
775
|
+
* path alone would be no fence at all — the same reasoning `slackWakeSpendGate` states for its
|
|
776
|
+
* own placement: an `im` message forwards natively too, and eve merges its attachments the same
|
|
777
|
+
* way. The click and view_submission fall-throughs carry no `event`, so for them this is the
|
|
778
|
+
* identity below and their forward stays byte-identical.
|
|
779
|
+
*
|
|
780
|
+
* 🔴 THE RE-SIGN IS WHAT MAKES A REWRITE POSSIBLE. The inner handler re-verifies the Slack
|
|
781
|
+
* signature over the body it receives, so a changed body needs a signature over those bytes. The
|
|
782
|
+
* secret is the one this route just VERIFIED the inbound signature with, so a deployment where
|
|
783
|
+
* the two secrets disagree would have 401'd above rather than reaching here.
|
|
784
|
+
*
|
|
785
|
+
* FAIL-SOFT TO TODAY'S BEHAVIOUR. If the body carried nothing to strip (the common path), or the
|
|
786
|
+
* signature cannot be reminted (no secret, no timestamp header), the ORIGINAL bytes and headers
|
|
787
|
+
* are forwarded. Losing the fence on a body we cannot re-sign is recoverable; dropping the
|
|
788
|
+
* message on a signature the inner handler then refuses is not.
|
|
789
|
+
*
|
|
790
|
+
* 🔴 IT ALSO CARRIES THE ENTERPRISE RUNG ACROSS THE FORWARD (OPE-1559). `enterprise_id` lives on
|
|
791
|
+
* the envelope, and eve hands its handler the INNER event only, so the rung would be lost here
|
|
792
|
+
* otherwise — and a Grid deployment that declared only the enterprise would resolve every native
|
|
793
|
+
* mention as an undeclared room. The stow wraps the WHOLE forward, including the fenced-body
|
|
794
|
+
* branch, because both branches end in the same inner handler. See
|
|
795
|
+
* {@link withForwardedSlackEnvelope} for why the carrier is async-scoped and message-matched.
|
|
796
|
+
* EVERY native forward is wrapped for the same reason the attachment fence is: an `im` message
|
|
797
|
+
* forwards natively too, so a rung carried on the mention path alone would be half a rung.
|
|
798
|
+
*/
|
|
799
|
+
const forwardNative = () => withForwardedSlackEnvelope(forwardedEnvelopeOf(rawBody), () => {
|
|
800
|
+
const fenced = stripUnfencedSlackAttachments(rawBody);
|
|
801
|
+
const ts = req.headers.get(SLACK_TIMESTAMP_HEADER);
|
|
802
|
+
const secret = signingSecret();
|
|
803
|
+
if (fenced === rawBody || !ts || !secret) {
|
|
804
|
+
return innerHandler(new Request(req.url, { method: "POST", headers: req.headers, body: rawBody }), args);
|
|
805
|
+
}
|
|
806
|
+
// A fresh Headers, because the inbound `content-length` describes the ORIGINAL bytes and the
|
|
807
|
+
// fenced body is shorter. Dropped rather than recomputed: `Request` sets it from the body.
|
|
808
|
+
const headers = new Headers(req.headers);
|
|
809
|
+
headers.set(SLACK_SIGNATURE_HEADER, slackSignature(secret, ts, fenced));
|
|
810
|
+
headers.delete("content-length");
|
|
811
|
+
console.log("[open-everywhere:slack-front] stripped an unfenced link-unfurl attachment before the native forward (OPE-1380)");
|
|
812
|
+
return innerHandler(new Request(req.url, { method: "POST", headers, body: fenced }), args);
|
|
813
|
+
});
|
|
814
|
+
// `liveDeps` types `send`'s auth as `unknown` on purpose (it doesn't import Eve's auth type);
|
|
815
|
+
// the cast bridges that gap — at runtime the payload/token/auth pass straight through. Hoisted
|
|
816
|
+
// because BOTH the single-window app_mention divert (mention branch) and the unowned-message
|
|
817
|
+
// wake path feed the same supervisor/name-drop dispatcher.
|
|
818
|
+
//
|
|
819
|
+
// eve 0.31 replaced the route's flat `send(payload, { continuationToken, … })` with
|
|
820
|
+
// `from(address).send(message, { … })` — the address is bound at `from()` time instead of
|
|
821
|
+
// riding in the options, and `context` moved out of the payload into the options. OE's own
|
|
822
|
+
// `WakeSend` (slack-events.ts) is UNCHANGED by design: it is an OE-authored adapter interface,
|
|
823
|
+
// not eve's, so the whole wake/name-drop/supervisor/join layer below stays byte-identical and
|
|
824
|
+
// the version-shaped difference is confined to this one adapter.
|
|
825
|
+
const send = ((input, opts) => args.from(opts.continuationToken).send(input.message, {
|
|
826
|
+
auth: opts.auth,
|
|
827
|
+
...(opts.state === undefined ? {} : { state: opts.state }),
|
|
828
|
+
...(input.context && input.context.length > 0 ? { context: input.context } : {}),
|
|
829
|
+
}));
|
|
830
|
+
// Two-layer idempotency (in-memory first, then the durable cross-instance store) shared by the
|
|
831
|
+
// wake path AND the single-window mention divert: a Slack RETRY (same event_id) must never
|
|
832
|
+
// double-wake. Returns false when the event was already handled (caller 200s and stops).
|
|
833
|
+
// Fail-open on an unresolvable partition — dedupe prevents a double reply, not a security hole.
|
|
834
|
+
/** The two-layer dedupe, keyed EXPLICITLY. Split out from {@link notYetSeen} for the shortcut
|
|
835
|
+
* branch (OPE-1376), whose idempotency key is Slack's per-click `trigger_id` rather than an
|
|
836
|
+
* Events API `event_id` — see `shortcutWakeKey` for why the message's own ts is wrong there. */
|
|
837
|
+
const notYetSeenKey = async (key, ev) => {
|
|
838
|
+
if (dedupe.seen(key, now()))
|
|
839
|
+
return false;
|
|
840
|
+
dedupe.mark(key, now());
|
|
841
|
+
try {
|
|
842
|
+
const org = resolvePartition(bindableFor(ev)).org;
|
|
843
|
+
if (!(await markEventSeen(org, key, new Date(now()).toISOString())))
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
/* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
|
|
848
|
+
}
|
|
849
|
+
return true;
|
|
850
|
+
};
|
|
851
|
+
/** The Events API form: key one `event_id` (falling back to `channel:ts`). Every existing wake
|
|
852
|
+
* route reads this one; only the shortcut branch keys itself. */
|
|
853
|
+
const notYetSeen = (env, ev) => notYetSeenKey(wakeKey(env, ev.channelId, ev.ts), ev);
|
|
854
|
+
// UNOWNED message dispatch (top-level, or in-thread but not engaged): today's default is the
|
|
855
|
+
// name-drop path (per-domain opt-in, fail-closed). When the channel's domain opts into
|
|
856
|
+
// ambientSupervisor, the SAME unowned traffic routes to the channel-supervisor session instead
|
|
857
|
+
// (slack-events.ts routeSupervisorEvent) — the "always-on teammate" leg. `directMention` is the
|
|
858
|
+
// SINGLE-WINDOW addressed-mention divert (an app_mention event routed here from the mention
|
|
859
|
+
// branch): it forces the supervisor path (the only road into the unified window) and renders an
|
|
860
|
+
// addressed `direct-mention` wake. The supervisor wake serializes on its OWN FIFO key (the
|
|
861
|
+
// supervisor token), never shared with a thread FIFO. Fail-closed: any partition/config error,
|
|
862
|
+
// or the flag off, falls through to the UNCHANGED name-drop dispatch — so ambientSupervisor-off
|
|
863
|
+
// is byte-identical to today. Parametrised on `ev` so both the app_mention divert and a plain
|
|
864
|
+
// message.channels wake feed one dispatcher.
|
|
865
|
+
// `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled — and never on the
|
|
866
|
+
// addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
|
|
867
|
+
// registry only for a click that actually wakes something; every other caller omits it.
|
|
868
|
+
const scheduleUnownedWake = (ev, label, directMention = false, onWake, seatEngaged = false) => {
|
|
869
|
+
let ambient = false;
|
|
870
|
+
let surfaceKey;
|
|
871
|
+
try {
|
|
872
|
+
surfaceKey = resolvePartition(bindableFor(ev)).surfaceKey;
|
|
873
|
+
ambient = ambientEnabledFor(surfaceKey);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
ambient = false; // unresolvable partition / config load → today's name-drop path
|
|
877
|
+
}
|
|
878
|
+
// 🔴 AN ADDRESSED WAKE TAKES THE SEAT ROAD, WHATEVER THE CHATTINESS FLAG SAYS (OPE-1695). The
|
|
879
|
+
// drop that stood here refused an addressed wake on an ambient-off channel, because the seat
|
|
880
|
+
// itself only existed where ambient was on — so a mention and a channel-message shortcut both
|
|
881
|
+
// died there. Seats are universal now: the flag decides whether UNADDRESSED talk wakes the
|
|
882
|
+
// seat, and addressing decides nothing about the session's shape. A mention in an
|
|
883
|
+
// addressed-only room therefore lands in that room's own seat, with the earlier exchange in
|
|
884
|
+
// context, instead of opening eve's native per-thread session (the fallback this ruling
|
|
885
|
+
// deletes) or being dropped.
|
|
886
|
+
// 🔴 THE DROP SURVIVES WHERE THERE IS NO SEAT, AND THAT KEEPS FLAG-OFF BYTE-IDENTICAL. With the
|
|
887
|
+
// standing-seat capability unarmed there is no seat road at all, so an addressed wake that fell
|
|
888
|
+
// through here would take the NAME-DROP path — a silently mis-judged wake, which is exactly what
|
|
889
|
+
// the old drop existed to prevent. The mention route never reaches it (its own policy leaves the
|
|
890
|
+
// native leg in charge when nothing windows); the SHORTCUT route does, and on such a deployment
|
|
891
|
+
// it is dropped loudly, exactly as before.
|
|
892
|
+
// An unresolvable partition leaves no surface key, so there is no seat to ask about: that
|
|
893
|
+
// reads as UNSEATED, which keeps the drop — the fail-closed direction the old branch had.
|
|
894
|
+
const seated = surfaceKey !== undefined && seatedSurface(surfaceKey);
|
|
895
|
+
if (directMention && !ambient && !seated) {
|
|
896
|
+
console.warn(`[open-everywhere:slack-front] ${label} ${ev.channelId}/${ev.ts}: addressed wake with no standing seat and ambient off — dropped (no road to a seat)`);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
// 🔴 `seatEngaged` IS NOT `directMention`, and the distinction is load-bearing. An ENGAGED
|
|
900
|
+
// thread's follow-up must reach the seat that is holding the conversation even in an
|
|
901
|
+
// addressed-only room — otherwise the seat answers a mention once and goes deaf on the very
|
|
902
|
+
// thread it opened. But it must NOT be treated as an address: `directMention` also lifts the
|
|
903
|
+
// app_mention/channel_type disqualifiers AND bypasses the stood-down suppression
|
|
904
|
+
// (slack-events.ts routeSupervisorEvent, `ev.threadTs && !directMention && engRow?.stopped`),
|
|
905
|
+
// so passing it here would make `stand_down` stop working in threads. It only says "there is a
|
|
906
|
+
// seat road for this message", which is what the chattiness flag would otherwise have said.
|
|
907
|
+
const addressedOrAmbient = directMention || ambient || seatEngaged;
|
|
908
|
+
onWake?.();
|
|
909
|
+
if (addressedOrAmbient) {
|
|
910
|
+
// Generation-INDEPENDENT FIFO key (T1.5): the FIFO's job is to serialize supervisor
|
|
911
|
+
// wakes for this CHANNEL, which must hold ACROSS a rotation boundary too — keying on
|
|
912
|
+
// the generation-bearing token (the old hardcoded gen-1 assumption) would split one
|
|
913
|
+
// channel's serialized queue in two the instant a rotation minted a new generation.
|
|
914
|
+
// supervisorFifoKey needs no store read either, so it stays a stable, synchronous key
|
|
915
|
+
// on the hot path exactly like the token-based key did.
|
|
916
|
+
const supKey = supervisorFifoKey(ev.channelId);
|
|
917
|
+
args.waitUntil(fifo.enqueue(supKey, () => deliverWithStrandRetry(() => routeSupervisorEvent(ev, withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey), directMention ? { directMention: true } : undefined).then(logOutcome(`${label}-supervisor`, ev.channelId, ev.ts)))));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// 🔴 A NAME-DROP WAKES THE ROOM'S STANDING SEAT (OPE-1725). `routeNameDropEvent` stays the
|
|
921
|
+
// JUDGE — the name match, the domain's `nameDropWake` opt-in, the internal confirmation, the
|
|
922
|
+
// wake arm — and on a SEATED surface its `seatRoute` hands the judged wake to that seat instead
|
|
923
|
+
// of opening eve's native per-thread session. Addressing decides what the seat is owed; it
|
|
924
|
+
// never decides the shape of the session, so the same seat an @mention reaches answers a
|
|
925
|
+
// name-drop, with the room's earlier exchange already in its context.
|
|
926
|
+
// UNSEATED ⇒ no `seatRoute`, and the native per-thread wake is byte-identical to today. That
|
|
927
|
+
// arm is the whole pre-seat deployment, so the fallback is not a corner case.
|
|
928
|
+
//
|
|
929
|
+
// 🔴 THE JUDGE RUNS OUTSIDE THE SEAT'S FIFO, and only a message it ADMITS enters one. Putting
|
|
930
|
+
// the judge inside would make every unaddressed message in a seated room occupy the channel's
|
|
931
|
+
// serialized queue for the length of a decision that almost always declines — a real cost on a
|
|
932
|
+
// busy room, and it would hold a genuine seat wake behind it. `fifo.enqueue` resolves void and
|
|
933
|
+
// SWALLOWS a failure, so the outcome is captured in the closure and the default stays the
|
|
934
|
+
// honest "did not wake" for a delivery that threw.
|
|
935
|
+
// 🔴 THE HYDRATOR RIDES ACROSS THE HAND-OFF (OPE-1725). The judge fetches the room's
|
|
936
|
+
// surrounding conversation for the wake it admits, and the SEAT wake must carry that same
|
|
937
|
+
// block — withheld by the same transform — or a seated deployment quietly stops hydrating
|
|
938
|
+
// name-drops at all. It is passed as a thunk, so the seat road pays for the history page only
|
|
939
|
+
// after its own tenant fence, spend gate and external-participant policy admit the wake.
|
|
940
|
+
const seatRoute = seated
|
|
941
|
+
? async (hydrateContext) => {
|
|
942
|
+
let outcome = { deliver: false, reason: "seat wake delivery failed", woke: false };
|
|
943
|
+
await fifo.enqueue(supervisorFifoKey(ev.channelId), async () => {
|
|
944
|
+
outcome = await deliverWithStrandRetry(() => routeSupervisorEvent(ev, withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey), {
|
|
945
|
+
nameDrop: true,
|
|
946
|
+
nameDropContext: hydrateContext,
|
|
947
|
+
}));
|
|
948
|
+
});
|
|
949
|
+
return outcome;
|
|
950
|
+
}
|
|
951
|
+
: undefined;
|
|
952
|
+
args.waitUntil(routeNameDropEvent(ev, { ...liveNameDropDeps(send), ...(seatRoute ? { seatRoute } : {}) }).then(logOutcome(seatRoute ? `${label}-name-drop-seat` : label, ev.channelId, ev.ts)));
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* THE DM WAKE (OPE-351 stage 2; group DMs at stage 4) — a `message.im` or `message.mpim` diverted
|
|
956
|
+
* into its own first-class standing window, the DM analogue of the single-window app_mention
|
|
957
|
+
* divert above. `dmKind` names which family member it is; everything the two differ in is
|
|
958
|
+
* called out below, and nothing else does.
|
|
959
|
+
*
|
|
960
|
+
* 🔴 IT DELIBERATELY BYPASSES routeSupervisorEvent, engagementTierFor AND THE NAME-DROP JUDGE.
|
|
961
|
+
* Every one of those exists to answer "was the agent addressed, and is this worth replying to?"
|
|
962
|
+
* — a question a 1:1 DM has already answered. A DM IS ALWAYS ANSWERED; routing it through a
|
|
963
|
+
* silence-biased judge would let the agent ignore someone talking to it directly, which is the
|
|
964
|
+
* one place it must never be quiet. So this path renders an ADDRESSED wake (`slackDmWake`,
|
|
965
|
+
* `addressing:"direct-mention"`) and dispatches it, full stop. The spend gate is NOT bypassed:
|
|
966
|
+
* `divertToUnifiedWindow`'s own attended-arrival gate (windowSpendGate) runs on this wake exactly
|
|
967
|
+
* as it does on a mention, which is what replaces the native door's gate.
|
|
968
|
+
*
|
|
969
|
+
* A GROUP DM WAKES ON THE SAME UNCONDITIONAL RULE, AND THAT IS A REAL WIDENING — its previous
|
|
970
|
+
* route (`shouldNameDropWake`) refused every non-mention message on channel_type, so the room woke
|
|
971
|
+
* only on an @mention. The justification is not that every message in a group DM is addressed to
|
|
972
|
+
* the agent (it plainly is not), but that a closed room the agent was added to person-by-person is
|
|
973
|
+
* a conversation it is IN, and the judgement of whether to speak belongs to the model, which has
|
|
974
|
+
* the text, rather than to a front-door gate keyed on a channel type. What carries that is the
|
|
975
|
+
* wake's own silence escape plus the GROUP opening of the `[direct]` preamble
|
|
976
|
+
* (channels/normalized-wake.ts), which says the room holds other people and tells the seat to stay
|
|
977
|
+
* out of what is not asked of it. The COST of the widening lands on the spend gate above, not on
|
|
978
|
+
* a judge: a chatty group DM is a budget question, and it is already answered by one.
|
|
979
|
+
*
|
|
980
|
+
* THE ENVELOPE IS MINIMAL, ON PURPOSE — no backscroll. The native DM path deliberately skipped
|
|
981
|
+
* `loadConversationContext` (slack.ts: "DMs are skipped — the 1:1 history IS the conversation and
|
|
982
|
+
* the durable session already carries it"), and that reasoning survives the re-seat unchanged: the
|
|
983
|
+
* seat is one long-lived session over the whole DM, so injecting history would duplicate what the
|
|
984
|
+
* session already holds. The one thing a FRESH generation carries is the recorded tail of the
|
|
985
|
+
* generation it replaced (OPE-794) — the case that argument does not cover, since a rotation is
|
|
986
|
+
* exactly when the session stops holding it.
|
|
987
|
+
*
|
|
988
|
+
* A STRICT IMPROVEMENT IN FENCING: eve's native DM dispatch delivered the human's text as the raw
|
|
989
|
+
* turn body. Going through the shared wake renderer means a DM body is now `wrapUntrusted`-fenced
|
|
990
|
+
* like every other surface's — a DM was the last inbound text that was not.
|
|
991
|
+
*
|
|
992
|
+
* FAIL-CLOSED, unlike the OPE-90 remap it replaces: no seat, no partition or no dispatch means the
|
|
993
|
+
* wake FAILS into the FIFO's loud catch (audited by recordWakeFailure) rather than falling back to
|
|
994
|
+
* a native send, which would answer the human from a second session with no window row behind it.
|
|
995
|
+
*/
|
|
996
|
+
const scheduleDmWake = async (ev, dmKind,
|
|
997
|
+
/** The DM door admitted this bot author as the deployment's named e2e driver (OPE-1023).
|
|
998
|
+
* Defaults FALSE, so every existing call site is unchanged. */
|
|
999
|
+
driverAdmitted = false) => {
|
|
1000
|
+
// 🔴 THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
|
|
1001
|
+
// this line `slack-front.ts` consulted the fence NOWHERE, while the mention door, the four
|
|
1002
|
+
// dispatching engagement routes, the channel-join door and the `/oe` slash door all did — so a
|
|
1003
|
+
// DM from a workspace this deployment never declared woke the DM seat. That seat is the
|
|
1004
|
+
// LARGEST grant the deployment makes: it holds a window token, delegation and memory, and a
|
|
1005
|
+
// DM is exactly where a colliding `U…` id resolves somebody's PERSONAL credentials.
|
|
1006
|
+
//
|
|
1007
|
+
// EVERY road into a DM window passes through here — the `message.im`/`message.mpim` branch,
|
|
1008
|
+
// the DM-shaped shortcut and the group-DM mention divert — so the fence is placed here rather
|
|
1009
|
+
// than at each of the three, and no road can be added that skips it. The two callers that CAN
|
|
1010
|
+
// decide earlier do (the DM branch and the shortcut branch below), because a refusal there
|
|
1011
|
+
// also spares the deployment a foreign workspace's attachment fetch and dedupe write; this
|
|
1012
|
+
// check is what makes those an optimisation rather than the fence itself.
|
|
1013
|
+
//
|
|
1014
|
+
// SILENT, like every other door: nothing is posted back into the DM. We cannot safely identify
|
|
1015
|
+
// anybody in an unadmitted workspace, and a foreign tenant learning it reached a live
|
|
1016
|
+
// deployment is more than it needs to know. The audit row and the operator log line are the
|
|
1017
|
+
// whole signal.
|
|
1018
|
+
if (slackFrontTenantFenceDeclined(ev, `dm-${dmKind}`, "scheduleDmWake"))
|
|
1019
|
+
return;
|
|
1020
|
+
const p = resolvePartition(bindableFor(ev));
|
|
1021
|
+
const atIso = new Date(now()).toISOString();
|
|
1022
|
+
const isGroup = dmKind === "mpim";
|
|
1023
|
+
// 🔴 WHERE THE SURFACE'S TRUST COMES FROM, and why the two families differ.
|
|
1024
|
+
// • 1:1 — the DM PARTNER, via users.info. It rides BOTH the delivery auth (`dm_partner_trust`
|
|
1025
|
+
// gates the DM's shared read layer) and the envelope's trust tier, so one lookup answers
|
|
1026
|
+
// both. Fail-closed to `external`/`unauth`.
|
|
1027
|
+
// • GROUP — there is no partner. `dm_partner_trust` is a singular fact about "the other person
|
|
1028
|
+
// in this DM", a question an mpim does not have an answer to, and `bindSlack` ignores the
|
|
1029
|
+
// attribute for `mode:"mpim"` anyway (its trust comes from `slackTrust`). Stamping one would
|
|
1030
|
+
// be inventing a fact. What stands in for the WAKE tier is the surface's own confirmed-
|
|
1031
|
+
// internal verdict — the same substitution routeSupervisorEvent makes for a channel — read
|
|
1032
|
+
// from the TTL-cached conversations.info the mention leg already needs. Fail-closed: an
|
|
1033
|
+
// unresolvable lookup (a workspace without `mpim:read`) leaves every author `unauth`, which
|
|
1034
|
+
// renders the reference-only system note rather than dropping the wake. The group's AUTHOR
|
|
1035
|
+
// is a separate probe, resolved just below — the room says nothing about who wrote here.
|
|
1036
|
+
const partnerTrust = !isGroup && ev.userId ? await dmPartnerTrustFor(ev.userId, ev.teamId) : undefined;
|
|
1037
|
+
const roomFacts = isGroup ? await channelFactsFor(ev.channelId) : undefined;
|
|
1038
|
+
const surfaceInternal = isGroup ? roomFacts?.internal === true : partnerTrust === "internal";
|
|
1039
|
+
// ── THE AUTHOR LEG (OPE-1486, Tommy's 2026-09-07 ruling) ────────────────────────────────────
|
|
1040
|
+
//
|
|
1041
|
+
// 🔴 A GROUP DM IS A ROOM, SO ITS AUTHOR IS PROBED LIKE A CHANNEL'S. `partnerTrust` answers a
|
|
1042
|
+
// 1:1's author (`dmPartnerTrustFor` probes `ev.userId` — the writer, not some third party), so
|
|
1043
|
+
// the 1:1 leg has always carried a real refutation. An mpim resolved none at all, which left
|
|
1044
|
+
// `oe_chat_nonmember` ABSENT on a turn a guest's own bytes opened. That is an INGESTION-grain
|
|
1045
|
+
// hole under the 2026-08-13 ruling: taint keys on whose words entered the context.
|
|
1046
|
+
//
|
|
1047
|
+
// The probe is `slackWakeAuthorFacts` — the SAME `users.info` verdict every channel route
|
|
1048
|
+
// reads, TTL-cached, so a person who wrote in this workspace recently costs no new call. It
|
|
1049
|
+
// fails OPEN: an unresolvable probe refutes nobody.
|
|
1050
|
+
//
|
|
1051
|
+
// 🔴 PRESENCE MARKS NOTHING. This asks who WROTE the trigger, never who sits in the room. No
|
|
1052
|
+
// roster is read here and none should be: a guest who has not spoken has put no bytes in the
|
|
1053
|
+
// context, and marking the turn for them would refuse the member plane on a room's occupancy.
|
|
1054
|
+
//
|
|
1055
|
+
// 🔴 THE WAKE TIER IS DELIBERATELY NOT NARROWED, AND THE FENCE IS CARRIED SEPARATELY. This is
|
|
1056
|
+
// the explicit decision the ticket asked for rather than an inherited one, and the two halves
|
|
1057
|
+
// of what the tier controls are what make it safe:
|
|
1058
|
+
// • the tier decides the RENDER — a `peer`/`principal` body is delivered unfenced, and only
|
|
1059
|
+
// `unauth` adds the reference-only system note. It does NOT decide whether the wake fires:
|
|
1060
|
+
// an mpim whose room cannot be confirmed already wakes at `unauth` rather than dropping;
|
|
1061
|
+
// • so the refutation rides to the render on its own field (`triggerRefuted` →
|
|
1062
|
+
// `WakeMessageInput.refuted`), which is a ONE-WAY FENCE BACKSTOP: it fences a refuted
|
|
1063
|
+
// author's body without touching the tier or the note.
|
|
1064
|
+
// What that buys, against folding the refutation into `surfaceInternal` as a channel does: a
|
|
1065
|
+
// guest the operator's own people invited into an addressed room still gets the addressed
|
|
1066
|
+
// envelope and an answer, while their bytes still reach the model INSIDE the fence and the
|
|
1067
|
+
// member plane still refuses a personal credential on the turn. Narrowing the tier as well
|
|
1068
|
+
// would add only the demotion of the envelope itself, which no part of this ruling asks for.
|
|
1069
|
+
const authorRefuted = isGroup ? (await authorFactsFor(surfaceInternal, ev.userId, ev.teamId)).refuted : partnerTrust === "external";
|
|
1070
|
+
const wakeTrust = resolveWakeTrust({
|
|
1071
|
+
// 🔴 THE ADMITTED DRIVER IS NOT A BOT AUTHOR HERE (OPE-1023) — the same expression the
|
|
1072
|
+
// engagement and mention doors already use (`Boolean(ev.botId) && !isAllowedSlackDriver(…)`),
|
|
1073
|
+
// applied to the DM door's own narrower lever. Left as a plain `Boolean(ev.botId)`, an
|
|
1074
|
+
// admitted driver would wake at the `relay` floor and the DM legs would exercise a trust tier
|
|
1075
|
+
// no human DM ever produces — which is not the real front door the carve-out exists to open.
|
|
1076
|
+
//
|
|
1077
|
+
// NO TRUST IS INVENTED. The driver posts through a human `xoxp` token, so `ev.userId` is a
|
|
1078
|
+
// REAL member id and the tier below is resolved from that human by the ordinary rules (an
|
|
1079
|
+
// unvouched surface still lands `unauth`, the admin roster still decides `principal`). What
|
|
1080
|
+
// the flag removes is only the machine floor that Slack's `bot_id` stamp would otherwise
|
|
1081
|
+
// impose on a message a person actually wrote.
|
|
1082
|
+
authorIsBot: Boolean(ev.botId) && !driverAdmitted,
|
|
1083
|
+
authorId: ev.userId,
|
|
1084
|
+
surfaceInternal,
|
|
1085
|
+
channelKey: p.surfaceKey,
|
|
1086
|
+
});
|
|
1087
|
+
// ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain) ──────────────────────────────────
|
|
1088
|
+
//
|
|
1089
|
+
// A DM is ALWAYS addressed, so an excluded sender here is owed BEHAVIOUR 1: the private notice
|
|
1090
|
+
// and nothing else. The notice posts back into this very DM — it is already private, so there
|
|
1091
|
+
// is no `conversations.open` to make and no room to disclose anything to.
|
|
1092
|
+
//
|
|
1093
|
+
// 🔴 IT SITS ABOVE `resolveDmSeat`, WHICH IS WHY THE TRUST BLOCK MOVED UP. A suppressed DM must
|
|
1094
|
+
// mint no seat, rotate no generation and consume no tail. The probe above is the same
|
|
1095
|
+
// one the seat path needed anyway, so the reorder costs nothing.
|
|
1096
|
+
//
|
|
1097
|
+
// A DM contributes NO ROOM LEG — on a DM the trust plane asks about the PARTNER, not about the
|
|
1098
|
+
// room — so only the AUTHOR grain can suppress here.
|
|
1099
|
+
//
|
|
1100
|
+
// 🔴 A GROUP DM IS SUPPRESSIBLE ON THE AUTHOR GRAIN TOO (OPE-1486). It was not before, because
|
|
1101
|
+
// an mpim resolved no refutation at all — the limit the old note here recorded. The author leg
|
|
1102
|
+
// above now resolves one from the same probe a channel uses, so an operator who set
|
|
1103
|
+
// `externalPolicy.authorGrain: "withhold"` gets that rule in a group DM as well. Nothing moves
|
|
1104
|
+
// on the SUPPRESSION PATH for a default deployment: `withholdsBody` is inert under the shipped
|
|
1105
|
+
// `taint` grain, so only a deployment that explicitly excluded refuted authors is suppressed
|
|
1106
|
+
// here — which is the rule it asked for. That is a claim about SUPPRESSION alone: on `taint`
|
|
1107
|
+
// the same refutation still newly stamps `oe_chat_nonmember` and still newly fences the body,
|
|
1108
|
+
// which is the fix. The room leg stays off for the whole DM family by the note above: on a DM
|
|
1109
|
+
// the trust plane asks about the PARTNER, not about the room.
|
|
1110
|
+
{
|
|
1111
|
+
// `authorRefuted` is already resolved above, from ONE probe per family — the 1:1's partner
|
|
1112
|
+
// lookup or the group's author lookup — so this gate makes no second call.
|
|
1113
|
+
const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorRefuted }, "scheduleDmWake");
|
|
1114
|
+
if (verdict !== "proceed") {
|
|
1115
|
+
console.log(`[open-everywhere:slack-front] dm ${p.surfaceKey}: suppressed — externalPolicy verdict "${verdict}"`);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// `channelId` is what the ROTATION distiller fetches history with (always supplied);
|
|
1120
|
+
// `legacyChannelId` is the one-time adopt-from-OPE-90 probe and is 1:1-ONLY. OPE-90 never minted
|
|
1121
|
+
// an `oe-dm-seat::<id>` row for a group DM, so probing for one could only ever miss — and a miss
|
|
1122
|
+
// that cannot be distinguished from a store outage is worse than no probe, because the OPE-346
|
|
1123
|
+
// note-loss audit row exists precisely to tell those two apart.
|
|
1124
|
+
const seat = await resolveDmSeat(p.org, { surfaceKey: p.surfaceKey, channelId: ev.channelId, ...(isGroup ? {} : { legacyChannelId: ev.channelId }) }, atIso);
|
|
1125
|
+
const wakeId = mintWakeId();
|
|
1126
|
+
// buildSlackAuth's DM attributes VERBATIM — including the rootDmGrainAttrs `oe_grain:"standing"`
|
|
1127
|
+
// stamp, which is what narrows a DM-family root's hands (window-tool-gating §D-DM, which already
|
|
1128
|
+
// covered mpim). `isPrivate:true` holds for both: a 1:1 is unambiguous, and a group DM has no
|
|
1129
|
+
// public form. `channelType` is the dmKind verbatim, so `slackMode` binds `dm` / `mpim` and every
|
|
1130
|
+
// mode-keyed carve-out (the lean-seat identity trio, the memory route, the skills refusal) sees
|
|
1131
|
+
// the SAME mode it saw on the native token.
|
|
1132
|
+
//
|
|
1133
|
+
// OPE-466 CARRIAGE: a DM/group-DM wake ingests exactly one message, so the join runs over the
|
|
1134
|
+
// trigger's own tier. The 1:1 leg already resolves `unauth` for a guest or Connect stranger
|
|
1135
|
+
// (the partner probe above IS the membership probe); the group leg keeps the ROOM's tier and
|
|
1136
|
+
// carries its author's refutation separately — see the author-leg note above for why the two
|
|
1137
|
+
// are not folded together on an mpim.
|
|
1138
|
+
const auth = stampNonMemberChatAuth(buildSlackAuth({
|
|
1139
|
+
userId: ev.userId ?? "",
|
|
1140
|
+
teamId: ev.teamId,
|
|
1141
|
+
// The Grid rung (OPE-1508), the same conditional spread `authForEvent` uses: a group DM
|
|
1142
|
+
// in an Enterprise Grid carries the enterprise the operator's declaration is written at,
|
|
1143
|
+
// and a non-Grid event stamps nothing.
|
|
1144
|
+
...(ev.enterpriseId === undefined ? {} : { enterpriseId: ev.enterpriseId }),
|
|
1145
|
+
channelId: ev.channelId,
|
|
1146
|
+
threadTs: ev.threadTs,
|
|
1147
|
+
channelType: dmKind,
|
|
1148
|
+
isPrivate: true,
|
|
1149
|
+
...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
|
|
1150
|
+
// 🔴 THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
|
|
1151
|
+
// channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
|
|
1152
|
+
// reads the pair through `slackTrust`, so this is the binding's only route to the fact
|
|
1153
|
+
// that a group DM spans another organisation — and `binding.trust` is the DEMOTION half of
|
|
1154
|
+
// the member plane's G4b vouch. Without it a Slack Connect group DM was vouched internal
|
|
1155
|
+
// under a workspace-wide declaration, because `slackTrust` graded every mpim `internal`.
|
|
1156
|
+
//
|
|
1157
|
+
// POSITIVE-ONLY, and both halves are spread conditionally for it: a workspace without
|
|
1158
|
+
// `mpim:read` resolves neither fact, stamps neither attribute, and keeps today's verdict.
|
|
1159
|
+
// Absence of an answer must never demote a room — only the platform SAYING so does.
|
|
1160
|
+
...(roomFacts?.extShared === undefined ? {} : { isExternal: roomFacts.extShared }),
|
|
1161
|
+
...(roomFacts?.trustChecked === undefined ? {} : { trustChecked: roomFacts.trustChecked }),
|
|
1162
|
+
}),
|
|
1163
|
+
// OPE-489: the author leg reads the POSITIVE refutation. On a 1:1 that is exactly the
|
|
1164
|
+
// partner probe's `external` verdict — the same users.info answer, so no second lookup.
|
|
1165
|
+
// OPE-1486: a GROUP DM resolves the same refutation from the same probe a channel's author
|
|
1166
|
+
// leg uses, so a guest or Connect stranger who WRITES here marks the turn. The ROOM leg is
|
|
1167
|
+
// still off for the whole DM family — `dm: true` puts it out of scope for the `public` tier,
|
|
1168
|
+
// because on a DM `trust` asks about the PARTNER, not about the room.
|
|
1169
|
+
[{ trust: wakeTrust, refuted: authorRefuted }], { dm: true });
|
|
1170
|
+
// OPE-794 — a fresh DM generation opens with the OUTGOING generation's RECORDED TAIL as its
|
|
1171
|
+
// backscroll, in place of the deleted handoff note. The DM envelope is otherwise still minimal
|
|
1172
|
+
// by design (the seat is one long-lived session over the whole DM, so replaying history a live
|
|
1173
|
+
// session already holds would duplicate it) — the tail is exactly the case that reasoning does
|
|
1174
|
+
// not cover, because a rotation is when the session stops holding it.
|
|
1175
|
+
const message = renderWakeMessage(ev, () => atIso, {
|
|
1176
|
+
dm: true,
|
|
1177
|
+
trust: wakeTrust,
|
|
1178
|
+
// The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
|
|
1179
|
+
// and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
|
|
1180
|
+
// no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
|
|
1181
|
+
// on a GROUP DM it is what fences a refuted author whose tier the room keeps at member grade.
|
|
1182
|
+
triggerRefuted: authorRefuted || undefined,
|
|
1183
|
+
wakeId,
|
|
1184
|
+
...(seat.freshGeneration && seat.tail.length > 0 ? { backscroll: seat.tail } : {}),
|
|
1185
|
+
});
|
|
1186
|
+
// 🔴 NO `replyAnchorTs` ON A TOP-LEVEL DM — the reply stays FLAT in the conversation. The
|
|
1187
|
+
// CHANNEL divert DOES set one (slack-events.ts), because threading an answer under its trigger
|
|
1188
|
+
// keeps a busy shared channel tidy; the slack post arm reads `threadTs ?? replyAnchorTs`
|
|
1189
|
+
// as its thread target, so setting it here would thread EVERY DM answer under the message that
|
|
1190
|
+
// asked — shattering a 1:1 conversation into a pile of one-reply threads. That is also not what
|
|
1191
|
+
// the native path did: eve replied to the wake's own `thread_ts` (OPE-98's per-wake anchor), which
|
|
1192
|
+
// is absent on a top-level DM message, so the answer posted flat. Behaviour preserved deliberately.
|
|
1193
|
+
// A DM message that genuinely IS in a thread still carries its threadTs and is answered in it.
|
|
1194
|
+
//
|
|
1195
|
+
// 🔴 A GROUP DM IS FLAT TOO — and here that DEPARTS from the native behaviour rather than
|
|
1196
|
+
// preserving it, so the reasoning has to stand on its own. Today an mpim @mention is answered by
|
|
1197
|
+
// eve's native path, which anchors on the wake's own `thread_ts` and therefore THREADS under the
|
|
1198
|
+
// mention. Keeping that would put two reply geometries in one room served by one seat: the
|
|
1199
|
+
// mention-carried wake threaded, every other message in the same conversation flat — the
|
|
1200
|
+
// placement flipping on whether the human happened to type `<@bot>`. Between "match what mpim did
|
|
1201
|
+
// yesterday" and "one seat, one room, one placement", the second wins: the seat is a single
|
|
1202
|
+
// long-lived session over the WHOLE group DM (the same property that makes a 1:1 flat), and a
|
|
1203
|
+
// Slack group DM is capped at nine people, so it has none of the tidiness problem threading
|
|
1204
|
+
// solves in a busy channel. A genuinely threaded mpim message still carries its threadTs and is
|
|
1205
|
+
// still answered inside that thread, exactly as in a 1:1.
|
|
1206
|
+
const origin = { platform: "slack", channelId: ev.channelId, ...(ev.threadTs ? { threadTs: ev.threadTs } : {}) };
|
|
1207
|
+
// THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719), on the DM leg too — and the reason is the
|
|
1208
|
+
// `threadTs` two comment blocks above. A Slack DM CAN hold a thread, so a DM thread can be
|
|
1209
|
+
// owned: the DM seat runs on a standing window token, `spawn_thread_session` mounts on it, and
|
|
1210
|
+
// a seat spawned inside a DM thread records a real ownership row. On the raw divert that row
|
|
1211
|
+
// was never read, so the seat was orphaned until its TTL — the exact defect this ticket closes,
|
|
1212
|
+
// on the one surface whose DM carries a thread signal at all. Discord's and Teams' DM legs stay
|
|
1213
|
+
// on the raw divert because THEIR DM origins carry none, not because a DM is special.
|
|
1214
|
+
// A top-level DM message has no `threadTs`, so it is not thread-scoped and goes standing with
|
|
1215
|
+
// no store read — flat DM behaviour is byte-unchanged.
|
|
1216
|
+
await divertInboundToUnifiedWindow(args.to, {
|
|
1217
|
+
message,
|
|
1218
|
+
token: seat.token,
|
|
1219
|
+
// `vendorTs` (OPE-1716): the trigger's OWN ts, which on Slack is its send time — the first
|
|
1220
|
+
// latency anchor, recorded on the ingress row and acted on by nothing.
|
|
1221
|
+
window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(slackVendorEventTime(ev.ts)) },
|
|
1222
|
+
surfaceKey: p.surfaceKey,
|
|
1223
|
+
auth,
|
|
1224
|
+
});
|
|
1225
|
+
// Record the wake off the critical path (best-effort — a touch failure never affects the reply).
|
|
1226
|
+
args.waitUntil(touchDmSeatFn(p.org, p.surfaceKey, atIso).catch(() => { }));
|
|
1227
|
+
// OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
|
|
1228
|
+
// the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
|
|
1229
|
+
args.waitUntil(recordWakeTail(p.org, dmWindowRegistryKey(p.surfaceKey), seat.token, {
|
|
1230
|
+
kind: "wake",
|
|
1231
|
+
at: atIso,
|
|
1232
|
+
from: "human",
|
|
1233
|
+
trust: wakeTrust,
|
|
1234
|
+
// THE AUTHOR GRAIN (OPE-1747) — the same `authorRefuted` the DM's own taint join and
|
|
1235
|
+
// external-policy verdict above read, through the one core road.
|
|
1236
|
+
...tailAuthorGrain({ trust: wakeTrust, refuted: authorRefuted }),
|
|
1237
|
+
author: ev.userId ?? "unknown",
|
|
1238
|
+
...(ev.userId ? { authorId: ev.userId } : {}),
|
|
1239
|
+
...(ev.ts ? { id: ev.ts } : {}),
|
|
1240
|
+
platform: "slack",
|
|
1241
|
+
container: p.surfaceKey,
|
|
1242
|
+
text: ev.text ?? "",
|
|
1243
|
+
}));
|
|
1244
|
+
};
|
|
1245
|
+
// THE IN-MEMORY ATTACHMENT REGISTRY, seeded from an event whose files the front door has already
|
|
1246
|
+
// ADMITTED. Every Events API path gets this inside `parseMessageEvent`, beside its own
|
|
1247
|
+
// `parseSlackFiles` call. The SHORTCUT route cannot (OPE-1442): its files must clear the
|
|
1248
|
+
// readability gate and the wake gates first, and both of those live here rather than in the
|
|
1249
|
+
// parser, so that route seeds through this helper instead. Image-only, matching what
|
|
1250
|
+
// `describe_image` resolves; the durable half for another process is `persistAttachments` below.
|
|
1251
|
+
const registerAttachments = (ev) => {
|
|
1252
|
+
for (const a of ev.attachments ?? []) {
|
|
1253
|
+
if (a.kind === "image" && a.id && a.url) {
|
|
1254
|
+
registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, ...(ev.teamId ? { teamId: ev.teamId } : {}) });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
// DURABLE ATTACHMENT REGISTRY (task #107): mirror this message's uploads into the durable
|
|
1259
|
+
// operational store so a DELEGATED worker — a one-shot task in a SEPARATE process from this
|
|
1260
|
+
// seat — can resolve a file id the seat saw (the in-memory registry is process-local). Trusted
|
|
1261
|
+
// provenance: the url_private is the EVENT-supplied string (never a model string), org resolved
|
|
1262
|
+
// from the same event. Off the hot path (waitUntil); fail-open on an unresolvable partition (the
|
|
1263
|
+
// in-memory registry still serves the same-process seat). Persists NON-IMAGE files too, which the
|
|
1264
|
+
// image-only in-memory registry never records — the raw-bytes read path (read_file_upload) needs them.
|
|
1265
|
+
const persistAttachments = (ev) => {
|
|
1266
|
+
const atts = ev.attachments;
|
|
1267
|
+
if (!atts || atts.length === 0)
|
|
1268
|
+
return;
|
|
1269
|
+
let org;
|
|
1270
|
+
try {
|
|
1271
|
+
org = resolvePartition(bindableFor(ev)).org;
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
return; // unresolvable partition → in-memory registry only (fail-open)
|
|
1275
|
+
}
|
|
1276
|
+
for (const a of atts) {
|
|
1277
|
+
if (a.id && a.url) {
|
|
1278
|
+
args.waitUntil(persistSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId: ev.teamId }, org));
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
// 🔴 A TENANT REFUSAL STILL HAS TO HAND THE PLATFORM ITS WRITES (cold review, 2026-09-10).
|
|
1283
|
+
// The fence tracks its `surface.wake_dropped` row with `holdRouteWork`, which only pushes the
|
|
1284
|
+
// promise into THIS delivery's pending bag (slack-route-hold.ts). The bag is drained by
|
|
1285
|
+
// `routeHoldQuiesced`, and the one place that runs it is the wrapped `waitUntil` this handler was
|
|
1286
|
+
// given (see `held` in the route builder above). So a branch that refuses and returns 200 WITHOUT
|
|
1287
|
+
// calling `args.waitUntil` hands the platform nothing: the serverless invocation freezes the
|
|
1288
|
+
// moment the response is written and the row never lands. The refusal itself is unaffected — it
|
|
1289
|
+
// is decided before any of this — but the audit row is the drop's ONLY durable signal, because a
|
|
1290
|
+
// webhook function's console output does not reach the operator. One empty `waitUntil` is enough:
|
|
1291
|
+
// the wrapper quiesces the whole tracked bag, not just the promise handed to it.
|
|
1292
|
+
const fencedAck = (res) => {
|
|
1293
|
+
args.waitUntil(Promise.resolve());
|
|
1294
|
+
return res;
|
|
1295
|
+
};
|
|
1296
|
+
if (decision.kind === "forward") {
|
|
1297
|
+
// A fresh Request so slackChannel can re-read the body and re-verify the signature — and, on a
|
|
1298
|
+
// body carrying an unfenced attachment, one built over the fenced bytes. See `forwardNative`.
|
|
1299
|
+
return forwardNative();
|
|
1300
|
+
}
|
|
1301
|
+
if (decision.kind === "dm-wake") {
|
|
1302
|
+
// A DM-FAMILY message — a 1:1 (OPE-351 stage 2) or a group DM (stage 4). OURS, UNCONDITIONALLY:
|
|
1303
|
+
// diverted into the surface's own standing window rather than forwarded to eve's native
|
|
1304
|
+
// `onDirectMessage` (1:1) or handed to the engagement gate that refused it (group DM). The
|
|
1305
|
+
// `OE_DM_SEAT_ROTATION=0` escape hatch that used to short-circuit to a native forward here is
|
|
1306
|
+
// gone (OPE-368) — it was the last supported way to leave a DM un-windowed, i.e. un-wakeable by
|
|
1307
|
+
// a delegated worker's completion, which is precisely the seat class the retired OPE-306
|
|
1308
|
+
// delegation anchor existed to serve.
|
|
1309
|
+
const dev = parseMessageEvent(decision.env);
|
|
1310
|
+
// An unparseable envelope has nothing to wake on — a no-op 200. It carries no channel and no
|
|
1311
|
+
// ts, so it cannot be keyed to a surface and gets a log line rather than an audit row.
|
|
1312
|
+
if (!dev) {
|
|
1313
|
+
console.log(`[open-everywhere:slack-front] ${decision.dmKind} wake dropped — the envelope parsed to no channel/ts`);
|
|
1314
|
+
return OK();
|
|
1315
|
+
}
|
|
1316
|
+
// 🔴 THE TENANT FENCE, AS EARLY AS THIS BRANCH CAN ASK IT (OPE-1644). `scheduleDmWake` below
|
|
1317
|
+
// is the chokepoint and refuses the same delivery on its own, so this is not the fence — it is
|
|
1318
|
+
// what keeps an unadmitted workspace from spending THESE on the way there: the bot-author
|
|
1319
|
+
// probe's audit rows, `persistAttachments` (which registers a FOREIGN workspace's
|
|
1320
|
+
// `url_private` files for a delegated worker to fetch) and a durable dedupe write all sit
|
|
1321
|
+
// between here and it. The parse above has already seeded the in-memory attachment LRU, which
|
|
1322
|
+
// is the one cost no placement in this branch avoids.
|
|
1323
|
+
if (slackFrontTenantFenceDeclined(dev, `dm-${decision.dmKind}`, "front-dm-branch"))
|
|
1324
|
+
return fencedAck(OK());
|
|
1325
|
+
// A bot-authored DM is never a wake — a no-op 200. NOT a forward: forwarding a bot_message
|
|
1326
|
+
// would hand eve's native door a turn the DM window is meant to own.
|
|
1327
|
+
//
|
|
1328
|
+
// 🔴 THE DROP IS COUNTED (OPE-1022). This used to be a bare `return OK()`, which made the DM
|
|
1329
|
+
// door the one wake path in the deployment that could swallow a real, well-formed event with
|
|
1330
|
+
// no audit row and no log line. Slack stamps `bot_id` on every Web-API post, a user token
|
|
1331
|
+
// included, so any script-driven probe lands here — and on 2026-08-23 that silence read as a
|
|
1332
|
+
// dead Slack surface for a drop that was working exactly as designed. Our own reply echo is
|
|
1333
|
+
// excluded inside the helper, so the row means "somebody else's bot spoke here", not chatter.
|
|
1334
|
+
//
|
|
1335
|
+
// 🔴 NAMED EXCEPTIONS ONLY (OPE-1023, OPE-1739, OPE-1754). `wake.e2eDrivers` lists the
|
|
1336
|
+
// deployment's e2e driver identities across every surface, and this door reads its `slack`
|
|
1337
|
+
// entries alone — each a `teamId:botId` pair. A LISTED identity's DM is admitted and woken
|
|
1338
|
+
// like a human's. Nothing but a whole-pair match is admitted. Empty is the
|
|
1339
|
+
// shipped value, and an empty list matches nothing in `isE2eDriver`, so an unconfigured
|
|
1340
|
+
// deployment reaches the drop below with the same verdict and the same row it always had.
|
|
1341
|
+
// `getBotIdentity()` is only awaited AFTER a pair matches, which is why the hot echo leg
|
|
1342
|
+
// gains no lookup.
|
|
1343
|
+
//
|
|
1344
|
+
// The self-check is the loop guard: an operator who wrote OUR OWN bot record into the setting
|
|
1345
|
+
// would otherwise have every reply re-wake the seat forever. Refusing the match costs nothing
|
|
1346
|
+
// (the identity read is a cached promise) and removes the only configuration that could loop.
|
|
1347
|
+
//
|
|
1348
|
+
// The leading `dev.botId` test is what keeps the HUMAN path free: without it every human DM
|
|
1349
|
+
// would resolve the settings object to ask a question only a bot author can answer.
|
|
1350
|
+
const driverAdmitted = Boolean(dev.botId) && dmDoorAdmitsDriver(dev) && dev.botId !== (await getBotIdentity()).botId;
|
|
1351
|
+
if (driverAdmitted) {
|
|
1352
|
+
// LOUD, not silent: the row is what lets a soak review tell an admitted driver DM from a
|
|
1353
|
+
// human DM. It rides `waitUntil` for the same reason the drop's row does — the route 200s
|
|
1354
|
+
// and wakes before the write would land.
|
|
1355
|
+
args.waitUntil(auditDmDriverAdmitted(dev, decision.dmKind).catch(() => {
|
|
1356
|
+
/* observability only — never block or change the decision */
|
|
1357
|
+
}));
|
|
1358
|
+
console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM ADMITTED — the named e2e driver identity (bot ${dev.botId} in team ${dev.teamId ?? "?"})`);
|
|
1359
|
+
// …and then FALLS THROUGH to the ordinary DM path below. No separate wake: the whole point
|
|
1360
|
+
// is that the driver's DM takes the same route a human's does.
|
|
1361
|
+
}
|
|
1362
|
+
else if (dev.botId) {
|
|
1363
|
+
// The identity read and the audit write both ride `waitUntil`, so neither delays the 200.
|
|
1364
|
+
// That matters most on the echo leg: Slack re-delivers every reply the bot posts, so this
|
|
1365
|
+
// is the hottest path through the DM door, and it must stay as fast as the bare drop was.
|
|
1366
|
+
// `.catch` because an unhandled rejection inside `waitUntil` can fail the invocation, and
|
|
1367
|
+
// an observability write must never change what the route does.
|
|
1368
|
+
args.waitUntil((async () => {
|
|
1369
|
+
const { botId: selfBotId } = await getBotIdentity();
|
|
1370
|
+
if (await auditDmBotAuthorDrop(dev, decision.dmKind, selfBotId)) {
|
|
1371
|
+
console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM dropped — no turn (bot ${dev.botId} in team ${dev.teamId ?? "?"}; the DM door admits no bot author)`);
|
|
1372
|
+
}
|
|
1373
|
+
})().catch(() => {
|
|
1374
|
+
/* observability only — never block or change the decision */
|
|
1375
|
+
}));
|
|
1376
|
+
return OK();
|
|
1377
|
+
}
|
|
1378
|
+
// 🔴 MENTION-SIBLING SUPPRESSION, GROUP DM ONLY. Slack fires BOTH an `app_mention` and a
|
|
1379
|
+
// `message.mpim` for a group-DM message that @mentions the bot, and the mention branch below
|
|
1380
|
+
// diverts the app_mention into this same seat — so without this drop the room would answer
|
|
1381
|
+
// twice. It is the exact rule the channel wake path applies to a `message.channels` sibling,
|
|
1382
|
+
// applied here because the DM branch returns before ever reaching that code.
|
|
1383
|
+
//
|
|
1384
|
+
// …AND DELIBERATELY NOT FOR A 1:1, where it would be the more dangerous mistake. The mention
|
|
1385
|
+
// branch does not divert an `im` (see there), so nothing else owns a 1:1's mention-bearing
|
|
1386
|
+
// message; suppressing it would turn "@bot are you there?" in a DM into total silence. Whether
|
|
1387
|
+
// Slack even fires app_mention inside an `im` is unmeasured here, and a rule whose failure mode
|
|
1388
|
+
// is a DEAF DM is not the place to bet on a vendor behaviour nobody has checked. Fail-open on an
|
|
1389
|
+
// unresolvable bot identity (same posture as the channel path): an occasional double beats a drop.
|
|
1390
|
+
//
|
|
1391
|
+
// 🔴 AND NOT FOR AN EDIT, for the reason {@link mentionSiblingSuppressed} states in full: Slack
|
|
1392
|
+
// raises no `app_mention` for a `message_changed`, so there is no second event to prefer and
|
|
1393
|
+
// the drop is total silence. The rule is the SHARED helper rather than a copy — this leg and
|
|
1394
|
+
// the channel leg refused the same edit for the same wrong reason, and a second copy is how
|
|
1395
|
+
// the next exemption reaches only one of them.
|
|
1396
|
+
if (decision.dmKind === "mpim") {
|
|
1397
|
+
const { userId: dmBotUserId } = await getBotIdentity();
|
|
1398
|
+
if (mentionSiblingSuppressed({
|
|
1399
|
+
authoredText: dev.authoredText,
|
|
1400
|
+
...(dev.subtype === undefined ? {} : { subtype: dev.subtype }),
|
|
1401
|
+
...(dmBotUserId === undefined ? {} : { botUserId: dmBotUserId }),
|
|
1402
|
+
})) {
|
|
1403
|
+
return OK();
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
persistAttachments(dev); // durable upload registry (task #107) — so a delegated worker can read this DM's files
|
|
1407
|
+
// Same two-layer idempotency as every other wake candidate: a Slack RETRY must not double-wake.
|
|
1408
|
+
if (!(await notYetSeen(decision.env, dev)))
|
|
1409
|
+
return OK();
|
|
1410
|
+
// Serialized on the surface's OWN FIFO key — a DM's bursts must not interleave, and its queue is
|
|
1411
|
+
// never shared with a channel supervisor's or a thread's. The mention-carried divert below
|
|
1412
|
+
// enqueues on the SAME key, so a group DM's two legs can never run concurrently on one seat.
|
|
1413
|
+
args.waitUntil(fifo.enqueue(dmFifoKey(dev.channelId), async () => {
|
|
1414
|
+
await deliverWithStrandRetry(() => scheduleDmWake(dev, decision.dmKind, driverAdmitted));
|
|
1415
|
+
console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke — diverted to the DM window seat`);
|
|
1416
|
+
}));
|
|
1417
|
+
return OK();
|
|
1418
|
+
}
|
|
1419
|
+
if (decision.kind === "shortcut") {
|
|
1420
|
+
// 🔴 A SHORTCUT IS AN ADDRESSED WAKE. Somebody pressed a named button aimed at this app, so
|
|
1421
|
+
// there is nothing to judge: it takes the `direct-mention` road, exactly like a single-window
|
|
1422
|
+
// @mention and a DM, and never the engagement or name-drop judge that exists to answer "was
|
|
1423
|
+
// the agent addressed?". What it does NOT bypass is any gate: the shortcut is mapped onto the
|
|
1424
|
+
// ordinary ParsedMessageEvent and handed to the SAME dispatchers, so the partition, seat,
|
|
1425
|
+
// window, spend gate, external-participant policy and wake-trust tier are all derived by the
|
|
1426
|
+
// code a mention already goes through. This branch adds a wake ORIGIN, not a plane.
|
|
1427
|
+
const sc = decision.shortcut;
|
|
1428
|
+
// A GLOBAL shortcut names no conversation, so the invoker's own DM window is where it lands —
|
|
1429
|
+
// the one room the person unambiguously chose by pressing a button with no message in scope.
|
|
1430
|
+
// A message shortcut lands in the conversation the message lives in.
|
|
1431
|
+
const shortcutChannelId = sc.kind === "message" ? sc.channelId : await openDirectMessageChannel(sc.userId);
|
|
1432
|
+
if (!shortcutChannelId) {
|
|
1433
|
+
// Fail-closed, and NAMED: this is the one degradation an operator can act on (grant
|
|
1434
|
+
// `im:write` and reinstall). Slack still gets its fast ack — a shortcut that wakes nothing
|
|
1435
|
+
// must not also look like a broken endpoint.
|
|
1436
|
+
console.log(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" from ${sc.userId}: no channel to wake in ` +
|
|
1437
|
+
`(a global shortcut opens the invoker's DM, which needs the im:write bot scope) — nothing woken`);
|
|
1438
|
+
return new Response(null, { status: 200 });
|
|
1439
|
+
}
|
|
1440
|
+
// A message shortcut can be invoked anywhere the app can see a message, so two facts have to
|
|
1441
|
+
// be ASKED for — a `message_action` payload carries neither. A global shortcut skips the
|
|
1442
|
+
// lookup: its channel is the `im` we just opened, so both answers are already known.
|
|
1443
|
+
// • `channelType` picks the road (DM window vs channel supervisor). It is the same gap an
|
|
1444
|
+
// `app_mention` envelope has;
|
|
1445
|
+
// • `appCanRead` says whether this app is in the room at all, which gates the target
|
|
1446
|
+
// message's FILES below.
|
|
1447
|
+
const shortcutFacts = sc.kind === "global"
|
|
1448
|
+
? { channelType: "im", appCanRead: true }
|
|
1449
|
+
: await channelFactsFor(shortcutChannelId)
|
|
1450
|
+
.then((f) => ({ channelType: f.channelType, appCanRead: f.appCanRead === true }))
|
|
1451
|
+
.catch(() => ({ channelType: undefined, appCanRead: false }));
|
|
1452
|
+
const shortcutChannelType = shortcutFacts.channelType;
|
|
1453
|
+
if (!shortcutChannelType) {
|
|
1454
|
+
// 🔴 AN UNRESOLVED CHANNEL TYPE DROPS (OPE-1442). This used to fall through to the channel
|
|
1455
|
+
// road, which is wrong for the case that actually produces an unresolved type: a DM or a
|
|
1456
|
+
// group DM whose `conversations.info` blipped. A shortcut is ADDRESSED, so the channel road
|
|
1457
|
+
// carries `directMention` into the CHANNEL-SUPERVISOR session for this id — and a DM's
|
|
1458
|
+
// content would land in a channel-shaped seat on a transient API failure. There is no
|
|
1459
|
+
// reading of "I could not tell what room this is" that justifies routing it anyway.
|
|
1460
|
+
console.warn(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
|
|
1461
|
+
`conversations.info resolved no channel type — dropped (a shortcut never routes on an unresolved room)`);
|
|
1462
|
+
return new Response(null, { status: 200 });
|
|
1463
|
+
}
|
|
1464
|
+
// 🔴 THE FILE GATE (OPE-1442). Slack delivers a `message_action` for a room the app is NOT in,
|
|
1465
|
+
// and the payload carries that message's `url_private`. Registering it would let the bot token
|
|
1466
|
+
// download bytes out of a room the agent's own channel reads refuse, through a door the
|
|
1467
|
+
// channel-read allowlist never sees. So the files ride only on a POSITIVE readability answer;
|
|
1468
|
+
// otherwise the references are dropped from the event as well, and the wake carries the
|
|
1469
|
+
// message's text alone. The wake itself still happens — the invoker is the principal and the
|
|
1470
|
+
// body is fenced, so the TEXT crossing is the ruled Slack-forward precedent. The BYTES are not.
|
|
1471
|
+
if (sc.kind === "message" && !shortcutFacts.appCanRead && (sc.message?.attachments?.length ?? 0) > 0) {
|
|
1472
|
+
console.warn(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
|
|
1473
|
+
`withheld ${sc.message?.attachments?.length} file reference(s) — the app cannot read that conversation`);
|
|
1474
|
+
}
|
|
1475
|
+
const sev = shortcutMessageEvent(sc, shortcutChannelId, shortcutChannelType, now(), { admitAttachments: shortcutFacts.appCanRead });
|
|
1476
|
+
// 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1644 / OPE-1645). Both roads out of
|
|
1477
|
+
// this branch are fenced further down — a DM-shaped shortcut by `scheduleDmWake`, a channel
|
|
1478
|
+
// shortcut by `routeSupervisorEvent` — so this refuses the delivery before either, and before
|
|
1479
|
+
// the attachment registry is seeded from it. It is also the door the ORG-WIDE Grid payload
|
|
1480
|
+
// arrives at: such a payload names an enterprise and NO workspace, which `sev` carries as
|
|
1481
|
+
// `enterpriseId` with `teamId` absent, and which the fence now judges on the enterprise
|
|
1482
|
+
// instead of admitting as "names no tenant".
|
|
1483
|
+
//
|
|
1484
|
+
// IT SITS BELOW THE TWO LOOKUPS ABOVE, not above them, because it needs `sev` to write a row
|
|
1485
|
+
// keyed to a surface. Those lookups cost an unadmitted workspace one `conversations.open` or
|
|
1486
|
+
// `conversations.info` on OUR OWN bot token, which answers `user_not_found` /
|
|
1487
|
+
// `channel_not_found` for a foreign id and discloses nothing.
|
|
1488
|
+
if (slackFrontTenantFenceDeclined(sev, `${sc.kind}-shortcut`, "front-shortcut-branch"))
|
|
1489
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1490
|
+
// Keyed on the click, not the message — a person may invoke the same shortcut on the same
|
|
1491
|
+
// message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
|
|
1492
|
+
if (!(await notYetSeenKey(shortcutWakeKey(sc), sev)))
|
|
1493
|
+
return new Response(null, { status: 200 });
|
|
1494
|
+
// BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
|
|
1495
|
+
// the in-memory one for this process's seat, the durable one for a delegated worker in
|
|
1496
|
+
// another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
|
|
1497
|
+
// fail-closed internal confirmation does; that residual click touches the LRU.)
|
|
1498
|
+
// They sit behind every gate above for the reason `parseSlackShortcutPayload`'s header states:
|
|
1499
|
+
// the in-memory map is a 1000-entry LRU shared with every real upload, so seeding it from
|
|
1500
|
+
// clicks that wake nothing evicts records `describe_image` needs.
|
|
1501
|
+
const seedAttachments = () => {
|
|
1502
|
+
registerAttachments(sev);
|
|
1503
|
+
persistAttachments(sev);
|
|
1504
|
+
};
|
|
1505
|
+
if (shortcutChannelType === "im" || shortcutChannelType === "mpim") {
|
|
1506
|
+
seedAttachments();
|
|
1507
|
+
args.waitUntil(fifo.enqueue(dmFifoKey(shortcutChannelId), async () => {
|
|
1508
|
+
await deliverWithStrandRetry(() => scheduleDmWake(sev, shortcutChannelType));
|
|
1509
|
+
console.log(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}": woke the ${shortcutChannelType} window ${shortcutChannelId}`);
|
|
1510
|
+
}));
|
|
1511
|
+
return new Response(null, { status: 200 });
|
|
1512
|
+
}
|
|
1513
|
+
// A CHANNEL shortcut takes the addressed single-window road. On a deployment whose domain does
|
|
1514
|
+
// NOT run the unified window there is no road into a channel seat for anything but a native
|
|
1515
|
+
// @mention, which a shortcut cannot become — `scheduleUnownedWake` refuses a `directMention`
|
|
1516
|
+
// it cannot deliver with a loud warn rather than falling through to the silence-biased
|
|
1517
|
+
// name-drop judge, and that refusal is the honest outcome here too. See the runbook.
|
|
1518
|
+
scheduleUnownedWake(sev, `${sc.kind}-shortcut`, true, seedAttachments);
|
|
1519
|
+
return new Response(null, { status: 200 });
|
|
1520
|
+
}
|
|
1521
|
+
if (decision.kind === "mention") {
|
|
1522
|
+
// An `app_mention`. Default: forward to eve's native slackChannel exactly as before —
|
|
1523
|
+
// byte-identical for every non-single-window domain. SINGLE-WINDOW (SINGLE-LEG divert): when
|
|
1524
|
+
// the shared seam says the unified window owns mentions on this POSITIVELY-internal channel,
|
|
1525
|
+
// we divert the app_mention EVENT ITSELF into the supervisor as an addressed direct-mention
|
|
1526
|
+
// wake and NEVER forward. ONE decision on ONE webhook: the `message.channels` sibling is
|
|
1527
|
+
// ALWAYS dropped (wake path below, unchanged from today), so nothing depends on it — there is
|
|
1528
|
+
// no separate suppress-leg / deliver-leg pair that could disagree across a webhook, TTL
|
|
1529
|
+
// eviction, or a cold instance, so neither a lost-mention nor a double-answer is possible.
|
|
1530
|
+
// It also handles a public-channel app_mention that fires NO sibling at all. Fail-closed: an
|
|
1531
|
+
// unparseable event, unresolvable partition, or any policy error ⇒ forward (today's path).
|
|
1532
|
+
const mev = parseMessageEvent(decision.env);
|
|
1533
|
+
if (mev)
|
|
1534
|
+
persistAttachments(mev); // durable upload registry (task #107) — before the owned/forward branch
|
|
1535
|
+
let owned = false;
|
|
1536
|
+
if (mev) {
|
|
1537
|
+
try {
|
|
1538
|
+
owned = await singleWindowOwnsMention(mev.channelId, resolvePartition(bindableFor(mev)).surfaceKey);
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1541
|
+
owned = false; // fail-closed → the native mention path keeps ownership
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (owned && mev) {
|
|
1545
|
+
// Idempotency on the app_mention's own event_id (a Slack retry must not double-wake).
|
|
1546
|
+
if (!(await notYetSeen(decision.env, mev)))
|
|
1547
|
+
return OK();
|
|
1548
|
+
console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: single-window — diverting to the unified supervisor (native forward suppressed)`);
|
|
1549
|
+
// The supervisor route lifts the app_mention/channel_type disqualifiers for this
|
|
1550
|
+
// directMention path (slack-events.ts routeSupervisorEvent) and re-checks its own
|
|
1551
|
+
// fail-closed internal confirmation — the SAME process-local TTL-cached conversations.info
|
|
1552
|
+
// the policy just populated, so the two reads on this one webhook are cache-coherent.
|
|
1553
|
+
scheduleUnownedWake(mev, "single-window-mention", true);
|
|
1554
|
+
return OK();
|
|
1555
|
+
}
|
|
1556
|
+
// 🔴 THE GROUP-DM MENTION LEG (OPE-351 stage 4). An @mention is how a human addresses the bot in
|
|
1557
|
+
// a multi-party room, so it is the MOST common way an mpim wakes — and left forwarding it would
|
|
1558
|
+
// have split the group DM in two: mentions answered by a native, un-wakeable, per-thread eve
|
|
1559
|
+
// session, everything else by the standing window this stage just gave the room. That split is
|
|
1560
|
+
// not a cosmetic inconsistency; the native session is exactly the seat OPE-351 exists to
|
|
1561
|
+
// abolish, so shipping the message leg alone would leave the headline defect (a seat that
|
|
1562
|
+
// delegates and can never be told the answer arrived) unfixed for the room's main entrance.
|
|
1563
|
+
//
|
|
1564
|
+
// IT COSTS A LOOKUP, because a raw `app_mention` envelope carries NO `channel_type` — the front
|
|
1565
|
+
// route genuinely cannot tell a group DM's mention from a channel's without asking Slack. The
|
|
1566
|
+
// read is the shared TTL-cached `conversations.info` (see slackChannelFactsFor for why that is
|
|
1567
|
+
// cheaper than the uncached read the forward it replaces triggers inside eve).
|
|
1568
|
+
//
|
|
1569
|
+
// SCOPED TO `mpim`, NOT TO "DM-FAMILY": an `im` mention still forwards, byte-identically to
|
|
1570
|
+
// stage 2. The 1:1 message leg does not suppress its mention-bearing sibling (see the dm-wake
|
|
1571
|
+
// branch), so diverting an `im` app_mention as well would double-answer every DM in which
|
|
1572
|
+
// somebody typed the bot's name. Fail-closed everywhere: an unresolved channelType (a workspace
|
|
1573
|
+
// without `mpim:read`) or any error ⇒ the native forward below, which is precisely pre-stage-4
|
|
1574
|
+
// behaviour. 🔴 THAT RESIDUAL FORWARD IS THE ONE REMAINING WAY A DM-FAMILY MESSAGE REACHES A
|
|
1575
|
+
// NATIVE-TOKEN SEAT (OPE-368 removed the deliberate `OE_DM_SEAT_ROTATION=0` route). Such a seat
|
|
1576
|
+
// has no window token, so `canDelegateFromSeat` is false, `agent` does not mount, and the memory
|
|
1577
|
+
// index says `"none"` — the honest pre-OPE-306 posture, not a dead affordance.
|
|
1578
|
+
if (mev) {
|
|
1579
|
+
const mentionChannelType = await channelFactsFor(mev.channelId)
|
|
1580
|
+
.then((f) => f.channelType)
|
|
1581
|
+
.catch(() => undefined);
|
|
1582
|
+
if (mentionChannelType === "mpim") {
|
|
1583
|
+
if (!(await notYetSeen(decision.env, mev)))
|
|
1584
|
+
return OK();
|
|
1585
|
+
console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: group DM — diverting to the mpim window seat (native forward suppressed)`);
|
|
1586
|
+
// The event's own `channel_type` is absent, so the divert is TOLD it is an mpim rather than
|
|
1587
|
+
// re-reading a field that was never there; `scheduleDmWake` stamps it onto the wake auth,
|
|
1588
|
+
// which is what makes `bindSlack` bind mode `mpim` on this leg exactly as on the other.
|
|
1589
|
+
args.waitUntil(fifo.enqueue(dmFifoKey(mev.channelId), async () => {
|
|
1590
|
+
await deliverWithStrandRetry(() => scheduleDmWake({ ...mev, channelType: "mpim" }, "mpim"));
|
|
1591
|
+
}));
|
|
1592
|
+
return OK();
|
|
1593
|
+
}
|
|
1594
|
+
if (mentionChannelType === undefined) {
|
|
1595
|
+
// NAMED, not silent: this is the one degradation an operator can act on (grant `mpim:read`
|
|
1596
|
+
// and reinstall). Everything else about the mention is unchanged, so it is a log line, not
|
|
1597
|
+
// a failure — a channel mention reaches here too whenever conversations.info is unavailable.
|
|
1598
|
+
console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: channel type unresolved (conversations.info unavailable; a group-DM mention needs the mpim:read scope) — forwarding to the native mention path`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
// ── THE EXTERNAL-PARTICIPANT POLICY, ON THE NATIVE FORWARD (OPE-507 / OPE-504) ──────────────
|
|
1602
|
+
//
|
|
1603
|
+
// 🔴 THIS IS THE LAST UNGATED SLACK WAKE, AND IT IS THE MOST LIKELY ONE. The three wake routes
|
|
1604
|
+
// in slack-events.ts and the DM seat above all read the policy — but an `app_mention` in an
|
|
1605
|
+
// ordinary NON-single-window channel takes none of them: it is handed to eve's native
|
|
1606
|
+
// `slackChannel`, which owns the turn end to end and has no seam the policy could reach. So the
|
|
1607
|
+
// gate has to sit HERE, above the forward, exactly where teams-front.ts puts its own.
|
|
1608
|
+
//
|
|
1609
|
+
// 🔴 IT COSTS A PROBE, SO IT IS SKIPPED WHEN IT CANNOT MATTER. Unlike the three routes, this
|
|
1610
|
+
// leg resolves no author facts of its own, so gating it means one `users.info` call. The inert
|
|
1611
|
+
// check runs first: on the shipped `taint` / `engage` pair no evidence is resolved at all and
|
|
1612
|
+
// this whole block is a single config read. The probe is TTL-cached per author on top of that.
|
|
1613
|
+
//
|
|
1614
|
+
// FAIL-OPEN on anything unreadable — an unparseable event, an unresolvable partition, a probe
|
|
1615
|
+
// that cannot answer. Every carriage leg in this plane fails open, and a policy error must not
|
|
1616
|
+
// be the thing that takes a workspace's mentions off the air.
|
|
1617
|
+
if (mev) {
|
|
1618
|
+
try {
|
|
1619
|
+
const p = resolvePartition(bindableFor(mev));
|
|
1620
|
+
const roomKey = domainKeyOf(p);
|
|
1621
|
+
if (!externalPolicyIsInert(slackExternalPolicyForRoom(roomKey))) {
|
|
1622
|
+
const internal = (await channelFactsFor(mev.channelId).catch(() => ({ internal: false }))).internal;
|
|
1623
|
+
const facts = await authorFactsFor(internal, mev.userId, mev.teamId);
|
|
1624
|
+
const wakeTrust = resolveWakeTrust({
|
|
1625
|
+
authorIsBot: Boolean(mev.botId),
|
|
1626
|
+
authorId: mev.userId,
|
|
1627
|
+
surfaceInternal: facts.surfaceInternal,
|
|
1628
|
+
channelKey: roomKey,
|
|
1629
|
+
});
|
|
1630
|
+
const verdict = await applySlackExternalPolicy(mev, p, { trust: wakeTrust, refuted: facts.refuted }, "native-mention-forward");
|
|
1631
|
+
if (verdict !== "proceed") {
|
|
1632
|
+
console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: suppressed — externalPolicy verdict "${verdict}" (native forward withheld)`);
|
|
1633
|
+
return OK();
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
catch {
|
|
1638
|
+
// fail-open → the native forward below keeps today's behaviour
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
return forwardNative();
|
|
1642
|
+
}
|
|
1643
|
+
if (decision.kind === "approval-click") {
|
|
1644
|
+
// 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1680). ABOVE the handler, because the
|
|
1645
|
+
// handler is the whole consequence: it loads the pending row, CASes a decision, tests the
|
|
1646
|
+
// clicker against the config-as-code admin roster and EXECUTES the approved act. A foreign
|
|
1647
|
+
// workspace's `U…` reaching that is the aliasing the fence exists for — most sharply on a
|
|
1648
|
+
// payload naming NO workspace (an org-wide Grid install), where `handleApprovalPayload` passes
|
|
1649
|
+
// no `clickContext` and `resolveApprovalClick` then reads the clicker's tenant off the CARD's
|
|
1650
|
+
// own room, i.e. off the ADMITTED workspace. See `slackInteractivityTenantFenceDeclined`.
|
|
1651
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "approval-click", "front-interactivity-branch")) {
|
|
1652
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1653
|
+
}
|
|
1654
|
+
// Ours: handled entirely by our code (load → CAS decide → approver check → execute →
|
|
1655
|
+
// chat.update), never Eve's. Fast empty 200 — Slack interactivity wants a quick ack,
|
|
1656
|
+
// not a rendered reply.
|
|
1657
|
+
//
|
|
1658
|
+
// 🔴 `args.waitUntil` RIDES ALONG (OPE-1709), and it is the HELD one (see the route hold
|
|
1659
|
+
// above), so the deferred work is quiesced before the delivery's own promise resolves. What
|
|
1660
|
+
// it moves off this path is the session-grant release: an "Approve for session" click on a
|
|
1661
|
+
// fan-out decides and EXECUTES the sibling cards the grant already covers, and that loop has
|
|
1662
|
+
// no business inside Slack's three-second interactivity budget.
|
|
1663
|
+
if (await handleApproval(decision.payload, { waitUntil: args.waitUntil })) {
|
|
1664
|
+
return new Response(null, { status: 200 });
|
|
1665
|
+
}
|
|
1666
|
+
// Defensive: classification said "ours" but the handler disagreed (a mismatch, e.g. a
|
|
1667
|
+
// stale/foreign action_id shape) — fall through to forwarding so the click is never
|
|
1668
|
+
// silently swallowed.
|
|
1669
|
+
return forwardNative();
|
|
1670
|
+
}
|
|
1671
|
+
if (decision.kind === "setkey-click") {
|
|
1672
|
+
// THE TENANT FENCE (OPE-1680), above the handler for the same reason the approval branch states.
|
|
1673
|
+
// The intake's owner check is over a bare per-workspace member id, so an unadmitted workspace's
|
|
1674
|
+
// click is a claim on somebody else's credential intake.
|
|
1675
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-click", "front-interactivity-branch")) {
|
|
1676
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1677
|
+
}
|
|
1678
|
+
// Ours (credentials/intake.ts): verify the clicker owns the intake, then views.open the modal —
|
|
1679
|
+
// the trigger_id lives in the payload we already classified. Fast empty 200 (Slack wants a quick
|
|
1680
|
+
// interactivity ack; the modal opens via the views.open API call, not this response body).
|
|
1681
|
+
if (await handleSetKeyClick(decision.payload)) {
|
|
1682
|
+
return new Response(null, { status: 200 });
|
|
1683
|
+
}
|
|
1684
|
+
return forwardNative();
|
|
1685
|
+
}
|
|
1686
|
+
if (decision.kind === "connect-click") {
|
|
1687
|
+
// THE TENANT FENCE (OPE-1680), above the handler. This branch MINTS AN OAUTH URL against a
|
|
1688
|
+
// connector, so an unadmitted workspace reaching it is a foreign tenant driving this
|
|
1689
|
+
// deployment's connector plane on our own credentials.
|
|
1690
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "connect-click", "front-interactivity-branch")) {
|
|
1691
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1692
|
+
}
|
|
1693
|
+
// Ours (connections/connector-card.ts): re-verify standing host-side, then mint an ephemeral
|
|
1694
|
+
// OAuth URL / open the api-key intake. Fast empty 200 (Slack wants a quick interactivity ack;
|
|
1695
|
+
// the ephemeral URL / intake button is posted via a separate API call, not this response body).
|
|
1696
|
+
if (await handleConnectClick(decision.payload)) {
|
|
1697
|
+
return new Response(null, { status: 200 });
|
|
1698
|
+
}
|
|
1699
|
+
return forwardNative();
|
|
1700
|
+
}
|
|
1701
|
+
if (decision.kind === "grant-click") {
|
|
1702
|
+
// THE TENANT FENCE (OPE-1680), above the handler — and above the vendor round trip below, which
|
|
1703
|
+
// is the most expensive thing an unadmitted workspace could make this deployment spend: the
|
|
1704
|
+
// approve leg opens a session under the OWNER's account (a consent probe, a token mint, an MCP
|
|
1705
|
+
// handshake and a `tools/list`) before it CASes.
|
|
1706
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "grant-click", "front-interactivity-branch")) {
|
|
1707
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1708
|
+
}
|
|
1709
|
+
// Ours (access/credential-grant-card.ts): the OWNER answered a credential-grant ask in their
|
|
1710
|
+
// own DM. Core verifies the signed payload's `user.id` IS that ask's owner, then approves or
|
|
1711
|
+
// declines and answers the owner ephemerally through a separate API call.
|
|
1712
|
+
//
|
|
1713
|
+
// 🔴 ACK BEFORE THE VENDOR READ. An approval opens the owner's MCP connection before its
|
|
1714
|
+
// CAS — consent probe, token mint, handshake and `tools/list` — and a healthy cold catalogue
|
|
1715
|
+
// can cross Slack's three-second interactivity deadline. Classification and the tenant fence
|
|
1716
|
+
// have already proved this is our signed `oe_grant:` action, so the empty 200 goes out now and
|
|
1717
|
+
// the route's held `waitUntil` keeps exactly one resolution alive afterwards. The approve CAS
|
|
1718
|
+
// remains the exactly-once decider if Slack redelivers a click whose first ACK was lost.
|
|
1719
|
+
//
|
|
1720
|
+
// A handler disagreement is an invariant violation after classification, not a reason to
|
|
1721
|
+
// forward after acknowledging: eve does not own `oe_grant:` and cannot answer it. Keep it loud
|
|
1722
|
+
// without rejecting the host's background promise or changing the already-sent response.
|
|
1723
|
+
args.waitUntil(Promise.resolve()
|
|
1724
|
+
.then(async () => {
|
|
1725
|
+
if (!(await handleGrantCardClick(decision.payload))) {
|
|
1726
|
+
console.warn("[open-everywhere:slack-front] classified grant click was not claimed by the grant handler");
|
|
1727
|
+
}
|
|
1728
|
+
})
|
|
1729
|
+
.catch(() => {
|
|
1730
|
+
// The handler is total in production. Keep an impossible injected/runtime rejection
|
|
1731
|
+
// visible without putting vendor or credential-bearing error text into the route log.
|
|
1732
|
+
console.warn("[open-everywhere:slack-front] grant click failed after acknowledgement");
|
|
1733
|
+
}));
|
|
1734
|
+
return new Response(null, { status: 200 });
|
|
1735
|
+
}
|
|
1736
|
+
if (decision.kind === "input-click") {
|
|
1737
|
+
// THE TENANT FENCE (OPE-1680), above the handler, for the same reason every other interactivity
|
|
1738
|
+
// branch carries it: a tap RESUMES a parked turn, which is this deployment spending on a scope
|
|
1739
|
+
// an unadmitted workspace never had standing to decide.
|
|
1740
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "input-click", "front-interactivity-branch")) {
|
|
1741
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1742
|
+
}
|
|
1743
|
+
// Ours (core channels/approval-card.ts): somebody tapped a bridged eve approval this
|
|
1744
|
+
// deployment posted onto a unified origin. The handler resolves the ticket, admits the tap
|
|
1745
|
+
// through the SAME floor the typed answer takes, and delivers `respond([{requestId, optionId}])`
|
|
1746
|
+
// to the parked WINDOW session.
|
|
1747
|
+
//
|
|
1748
|
+
// 🔴 THE DELIVERY GOES BY SESSION ID (OPE-1863). `args.from` is CHANNEL-LOCAL — on this route
|
|
1749
|
+
// it addresses `slack:<token>`, while the parked session lives at `unified:<token>` — so the
|
|
1750
|
+
// settle threw "the target session was not found" and every tap left the approval parked.
|
|
1751
|
+
// `args.attachSession` dispatches by durable session id from any channel, and
|
|
1752
|
+
// `approvalCardRespondVia` is the one road every surface binds. Bound HERE and injected because
|
|
1753
|
+
// the route handler is the only place eve hands out either primitive.
|
|
1754
|
+
//
|
|
1755
|
+
// The 200 goes out AFTER the settle, deliberately, and the cost is stated rather than hidden:
|
|
1756
|
+
// Slack expects this endpoint to answer in about three seconds, and a settle that runs longer
|
|
1757
|
+
// leaves the tapper waiting on whatever Slack does then. Acking first and settling in the
|
|
1758
|
+
// background would fix the delay and WIDEN the handler's own non-atomic window — see the
|
|
1759
|
+
// "THE LIMIT OF THAT" paragraph in `slack-approval-card.ts`, which also records that the
|
|
1760
|
+
// past-deadline behaviour is unmeasured here.
|
|
1761
|
+
const respondToApproval = approvalCardRespondVia(args.attachSession);
|
|
1762
|
+
const tap = await handleApprovalCardClick(decision.payload, respondToApproval);
|
|
1763
|
+
console.log(`[open-everywhere:slack] oe_input: tap — ${tap.reason}`);
|
|
1764
|
+
// `handled:false` means the payload carried our prefix but not a shape the handler could read.
|
|
1765
|
+
// Forward it rather than swallow it, the same defensive shape the grant and setkey branches
|
|
1766
|
+
// take: a click this deployment cannot act on is better handed on than silently dropped.
|
|
1767
|
+
if (!tap.handled)
|
|
1768
|
+
return forwardNative();
|
|
1769
|
+
return new Response(null, { status: 200 });
|
|
1770
|
+
}
|
|
1771
|
+
if (decision.kind === "setkey-submit") {
|
|
1772
|
+
// THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
|
|
1773
|
+
// this deployment's store, keyed to the intake the payload names. A `view_submission` carries
|
|
1774
|
+
// no `channel`, which is the case the fence's row writer keys to `SLACK_INTERACTIVITY_SURFACE`.
|
|
1775
|
+
// The refusal answers with the SAME empty 200 a successful close does, never the `errors`
|
|
1776
|
+
// object: an inline validation message would tell a foreign tenant it reached a live modal.
|
|
1777
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-submit", "front-interactivity-branch")) {
|
|
1778
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
1779
|
+
}
|
|
1780
|
+
// Ours: encrypt + store the pasted value host-side (the model never sees it). A view_submission
|
|
1781
|
+
// must answer with a JSON body — empty ⇒ Slack closes the modal (success), an `errors` object ⇒
|
|
1782
|
+
// inline validation (expired/used/blank). `handled:false` (a callback_id we don't own slipped
|
|
1783
|
+
// through) falls through to forwarding.
|
|
1784
|
+
const result = await handleSetKeyViewSubmission(decision.payload);
|
|
1785
|
+
if (result.handled) {
|
|
1786
|
+
return result.response
|
|
1787
|
+
? new Response(JSON.stringify(result.response), { status: 200, headers: { "content-type": "application/json" } })
|
|
1788
|
+
: new Response(null, { status: 200 });
|
|
1789
|
+
}
|
|
1790
|
+
return forwardNative();
|
|
1791
|
+
}
|
|
1792
|
+
if (decision.kind === "join") {
|
|
1793
|
+
// Channel-join intro. Only the BOT's own join is an invitation — a human joining a
|
|
1794
|
+
// channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
|
|
1795
|
+
// opposite polarity from the mention suppression below: unresolved identity means we
|
|
1796
|
+
// can't prove the joiner is us, so nobody wakes (vs. "an occasional double is better
|
|
1797
|
+
// than a dropped reply" for suppression).
|
|
1798
|
+
const jev = parseChannelJoinEvent(decision.env);
|
|
1799
|
+
if (!jev)
|
|
1800
|
+
return OK();
|
|
1801
|
+
const { userId: joinBotUserId } = await getBotIdentity();
|
|
1802
|
+
if (!joinBotUserId || jev.userId !== joinBotUserId)
|
|
1803
|
+
return OK();
|
|
1804
|
+
// Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
|
|
1805
|
+
// double intro is exactly the annoyance the durable half exists to prevent.
|
|
1806
|
+
const joinKey = wakeKey(decision.env, jev.channelId, jev.ts);
|
|
1807
|
+
if (dedupe.seen(joinKey, now()))
|
|
1808
|
+
return OK();
|
|
1809
|
+
dedupe.mark(joinKey, now());
|
|
1810
|
+
try {
|
|
1811
|
+
const org = resolvePartition(bindableForJoin(jev)).org;
|
|
1812
|
+
if (!(await markEventSeen(org, joinKey, new Date(now()).toISOString())))
|
|
1813
|
+
return OK();
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
/* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
|
|
1817
|
+
}
|
|
1818
|
+
const joinSend = send; // the same 0.31 `from(address).send(…)` adapter built above
|
|
1819
|
+
// A fresh join can't contend with an existing thread FIFO (its thread root is the
|
|
1820
|
+
// join notice itself), so no queueing — same reasoning as the name-drop wake.
|
|
1821
|
+
args.waitUntil(routeChannelJoinEvent(jev, liveChannelJoinDeps(joinSend)).then(logOutcome("channel-join", jev.channelId, jev.ts)));
|
|
1822
|
+
return OK();
|
|
1823
|
+
}
|
|
1824
|
+
if (decision.kind === "reaction") {
|
|
1825
|
+
// Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
|
|
1826
|
+
const rev = parseSlackReactionEvent(decision.env);
|
|
1827
|
+
if (!rev)
|
|
1828
|
+
return OK();
|
|
1829
|
+
// 🔴 THE TENANT FENCE (OPE-1680), and this branch is on the fence's list for a reason the other
|
|
1830
|
+
// five do not share: the ✋ STOP arm reaches `args.attachSession`, eve's I/O-free CANCEL door,
|
|
1831
|
+
// so an unadmitted workspace's reaction can HALT a running session in the admitted one. The
|
|
1832
|
+
// rest of the branch also writes a durable dedupe row and records feedback against our own
|
|
1833
|
+
// replies. A reaction arrives on the Events API rather than the interactivity URL, so its
|
|
1834
|
+
// workspace is the one the PARSER already read off the envelope, and its Grid rung comes from
|
|
1835
|
+
// `slackEnterpriseIdFromEnvelope` — the same two fields `fenceDeclined` reads for a message.
|
|
1836
|
+
//
|
|
1837
|
+
// IT SITS BELOW THE PARSE, not above it: the parse is pure and free, and it is what lets the
|
|
1838
|
+
// refusal row name the channel the reaction happened in rather than a placeholder.
|
|
1839
|
+
const reactionEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
|
|
1840
|
+
if (slackInteractivityTenantFenceDeclined({
|
|
1841
|
+
...(rev.teamId === undefined ? {} : { teamId: rev.teamId }),
|
|
1842
|
+
...(reactionEnterpriseId === undefined ? {} : { enterpriseId: reactionEnterpriseId }),
|
|
1843
|
+
channelId: rev.channelId,
|
|
1844
|
+
userId: rev.reactorId,
|
|
1845
|
+
}, "reaction", "front-reaction-branch")) {
|
|
1846
|
+
return fencedAck(OK());
|
|
1847
|
+
}
|
|
1848
|
+
// Same two-layer idempotency as every other wake candidate: a Slack RETRY (same
|
|
1849
|
+
// event_id) must not double-record a reaction. In-memory first, then the durable store
|
|
1850
|
+
// (a retry usually lands on a fresh serverless instance). Fail-open on an unresolvable
|
|
1851
|
+
// partition — dedupe prevents a double row, not a security hole.
|
|
1852
|
+
const rkey = wakeKey(decision.env, rev.channelId, rev.messageTs);
|
|
1853
|
+
if (dedupe.seen(rkey, now()))
|
|
1854
|
+
return OK();
|
|
1855
|
+
dedupe.mark(rkey, now());
|
|
1856
|
+
try {
|
|
1857
|
+
const org = resolvePartition(bindableForReaction(rev)).org;
|
|
1858
|
+
if (!(await markEventSeen(org, rkey, new Date(now()).toISOString())))
|
|
1859
|
+
return OK();
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
/* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
|
|
1863
|
+
}
|
|
1864
|
+
// Resolve the bot identity once (cached) so the route can tell the bot's own reaction
|
|
1865
|
+
// from a human's and whether the target is the bot's own reply. A reaction can't
|
|
1866
|
+
// contend with any thread FIFO (it starts no turn), so no queueing — just fire it.
|
|
1867
|
+
// `args.attachSession` rides in for the ✋ STOP arm (OPE-418): it is eve's I/O-free cancel
|
|
1868
|
+
// door, and handing it in here is what lets a stop happen PRE-DISPATCH — no session mint,
|
|
1869
|
+
// no model call, and therefore no spend and no cap to be over.
|
|
1870
|
+
const identity = await getBotIdentity();
|
|
1871
|
+
args.waitUntil(routeReactionEvent(rev, liveReactionDeps(identity, args.attachSession)).then((o) => console.log(`[open-everywhere:slack-front] reaction ${rev.channelId}/${rev.messageTs}: ${o.recorded ? "recorded" : "not recorded"} — ${o.reason}`)));
|
|
1872
|
+
return OK();
|
|
1873
|
+
}
|
|
1874
|
+
// Wake path. Parse the message; a non-message or id-less envelope is a no-op 200.
|
|
1875
|
+
const ev = parseMessageEvent(decision.env);
|
|
1876
|
+
if (!ev)
|
|
1877
|
+
return OK();
|
|
1878
|
+
persistAttachments(ev); // durable upload registry (task #107) — so a delegated worker can read this message's files
|
|
1879
|
+
// Mention-sibling suppression — see {@link mentionSiblingSuppressed} for the whole rule and for
|
|
1880
|
+
// why an EDIT is exempt from it.
|
|
1881
|
+
const { userId: botUserId } = await getBotIdentity();
|
|
1882
|
+
if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
|
|
1883
|
+
return OK();
|
|
1884
|
+
}
|
|
1885
|
+
// Idempotency: a retried (or duplicated) event must not double-wake. In-memory first (free,
|
|
1886
|
+
// same-instance), then the durable store (cross-instance — a Slack retry usually lands on a
|
|
1887
|
+
// fresh serverless instance). One small statement, well inside Slack's 3s ack window.
|
|
1888
|
+
if (!(await notYetSeen(decision.env, ev)))
|
|
1889
|
+
return OK();
|
|
1890
|
+
if (!ev.threadTs) {
|
|
1891
|
+
// TOP-LEVEL message: never an engagement wake — but possibly a name-drop candidate
|
|
1892
|
+
// (per-domain opt-in, fail-closed; slack-events.ts routeNameDropEvent), or — when
|
|
1893
|
+
// ambientSupervisor is on for this domain — a supervisor wake. The reply thread it may
|
|
1894
|
+
// start is rooted at this message's ts, so a name-drop can't contend with any existing
|
|
1895
|
+
// FIFO chain; the supervisor wake serializes on the supervisor token instead.
|
|
1896
|
+
scheduleUnownedWake(ev, "name-drop");
|
|
1897
|
+
return OK();
|
|
1898
|
+
}
|
|
1899
|
+
// SEATED-CHANNEL short-circuit (task #100): where the channel has a standing seat, that seat owns
|
|
1900
|
+
// ALL in-thread traffic. A mention was diverted into it (no native per-thread session exists), so
|
|
1901
|
+
// an engaged thread's follow-up must ride the seat, NEVER the native per-thread token below
|
|
1902
|
+
// (routeMessageEvent), or it would wake a fresh empty session off the wrong token. The seat's own
|
|
1903
|
+
// deterministic stop leg (slack-events.ts routeSupervisorEvent) honours the engagement row a
|
|
1904
|
+
// diverted mention enrolls.
|
|
1905
|
+
//
|
|
1906
|
+
// 🔴 THE ENGAGEMENT READ IS BACK, AND OPE-1695 IS WHY. This branch used to fire only on a WINDOW
|
|
1907
|
+
// domain, which implied `ambientSupervisor`, so reaching the seat road was guaranteed. Every
|
|
1908
|
+
// channel is seated now, so in an ADDRESSED-ONLY room the same call would fall through to the
|
|
1909
|
+
// name-drop judge and answer nothing — the seat would reply to a mention and then go deaf on its
|
|
1910
|
+
// own thread. The read says which it is: an ENGAGED thread (the seat is holding this
|
|
1911
|
+
// conversation) takes the seat road whatever the chattiness flag says; a thread nobody engaged is
|
|
1912
|
+
// ordinary room traffic and still obeys the flag. Fail-closed: an unreadable row is NOT engaged.
|
|
1913
|
+
const p = resolvePartition(bindableFor(ev));
|
|
1914
|
+
if (seatedSurface(p.surfaceKey)) {
|
|
1915
|
+
const seatEngaged = await isThreadEngaged(p, ev.threadTs).catch(() => false);
|
|
1916
|
+
scheduleUnownedWake(ev, "in-thread-window", false, undefined, seatEngaged);
|
|
1917
|
+
return OK();
|
|
1918
|
+
}
|
|
1919
|
+
// In-thread branch (one engagement read decides it): an ENGAGED thread takes the
|
|
1920
|
+
// delivery path (rolled-in reply); a NON-engaged in-thread message runs the trigger
|
|
1921
|
+
// check instead — a name-drop can now pull the bot into a live sub-thread it wasn't in
|
|
1922
|
+
// (slack-events.ts shouldNameDropWake no longer disqualifies inThread).
|
|
1923
|
+
// Fail-closed isThreadEngaged reads an error as NOT engaged → the hard-gated trigger
|
|
1924
|
+
// path, never a spurious delivery.
|
|
1925
|
+
const engaged = await isThreadEngaged(p, ev.threadTs);
|
|
1926
|
+
if (!engaged) {
|
|
1927
|
+
// Trigger-check path: like the top-level name-drop it roots on the existing thread
|
|
1928
|
+
// and can't contend with a FIFO chain, so it follows the same non-queued waitUntil
|
|
1929
|
+
// shape rather than the per-thread queue (the supervisor branch enqueues on its own key).
|
|
1930
|
+
scheduleUnownedWake(ev, "name-drop-thread");
|
|
1931
|
+
return OK();
|
|
1932
|
+
}
|
|
1933
|
+
// Per-thread FIFO: serialize this thread's bursts; hand the promise to waitUntil so the
|
|
1934
|
+
// route returns 200 now while the wake continues in the background. (The FIFO is
|
|
1935
|
+
// deliberately per-instance: cross-instance ORDERING would need a lease/queue, and the
|
|
1936
|
+
// failure mode — two burst replies racing — is cosmetic, unlike the duplicate-delivery
|
|
1937
|
+
// problem the durable dedupe above closes.)
|
|
1938
|
+
const threadKey = slackContinuationToken(ev.channelId, ev.threadTs);
|
|
1939
|
+
args.waitUntil(fifo.enqueue(threadKey, () => routeMessageEvent(ev, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs))));
|
|
1940
|
+
return OK();
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Compose the engagement front route with a wrapped `slackChannel`. `inner` must have been
|
|
1945
|
+
* built with `route: INTERNAL_SLACK_ROUTE`. Returns the SAME channel value (preserving Eve's
|
|
1946
|
+
* internal `__kind`/adapter/receive) with the public front route prepended — so Slack's URL
|
|
1947
|
+
* hits us, and both routes share one channel name → one session namespace.
|
|
1948
|
+
*/
|
|
1949
|
+
export function frontSlackChannel(inner, opts = {}) {
|
|
1950
|
+
const innerPost = inner.routes.find((r) => r.method === "POST" && r.path === INTERNAL_SLACK_ROUTE);
|
|
1951
|
+
if (!innerPost) {
|
|
1952
|
+
throw new Error(`frontSlackChannel: wrapped slackChannel must be built with route: "${INTERNAL_SLACK_ROUTE}" ` +
|
|
1953
|
+
`(found paths: ${inner.routes.map((r) => r.path).join(", ")})`);
|
|
1954
|
+
}
|
|
1955
|
+
const front = buildFrontHandler(innerPost.handler, opts);
|
|
1956
|
+
return { ...inner, routes: [POST(PUBLIC_SLACK_ROUTE, front), ...inner.routes] };
|
|
1957
|
+
}
|
|
1958
|
+
//# sourceMappingURL=slack-front.js.map
|