@open-everywhere/provider-slack 0.2.1 → 0.3.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.
Files changed (32) hide show
  1. package/agent/channels/slack-events.ts +3 -0
  2. package/agent/channels/slack-front.ts +2 -78
  3. package/agent/channels/slack-post-arm.ts +1 -42
  4. package/agent/docs-generated.ts +1 -1
  5. package/agent/setup/claim.ts +16 -1
  6. package/agent/setup/page.ts +68 -6
  7. package/agent/surface-parity.ts +0 -6
  8. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  9. package/dist/agent/channels/slack-events.js +3 -0
  10. package/dist/agent/channels/slack-events.js.map +1 -1
  11. package/dist/agent/channels/slack-front.d.ts +0 -14
  12. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  13. package/dist/agent/channels/slack-front.js +3 -61
  14. package/dist/agent/channels/slack-front.js.map +1 -1
  15. package/dist/agent/channels/slack-post-arm.d.ts +1 -8
  16. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  17. package/dist/agent/channels/slack-post-arm.js +1 -40
  18. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  19. package/dist/agent/docs-generated.js +1 -1
  20. package/dist/agent/docs-generated.js.map +1 -1
  21. package/dist/agent/setup/claim.d.ts.map +1 -1
  22. package/dist/agent/setup/claim.js +15 -2
  23. package/dist/agent/setup/claim.js.map +1 -1
  24. package/dist/agent/setup/page.d.ts +2 -0
  25. package/dist/agent/setup/page.d.ts.map +1 -1
  26. package/dist/agent/setup/page.js +60 -6
  27. package/dist/agent/setup/page.js.map +1 -1
  28. package/dist/agent/surface-parity.d.ts.map +1 -1
  29. package/dist/agent/surface-parity.js +0 -6
  30. package/dist/agent/surface-parity.js.map +1 -1
  31. package/package.json +2 -7
  32. package/agent/channels/slack-approval-card.ts +0 -275
@@ -11,12 +11,6 @@ export const SLACK_SURFACE_PARITY = {
11
11
  "capability-manifest": "supported",
12
12
  "post-arm": "supported",
13
13
  "wake-origin-codec": "supported",
14
- // The arm renders the buttons (`slack-post-arm.ts`), the front routes the tap back
15
- // (`slack-front.ts` classifies `oe_input:`, `slack-approval-card.ts` settles it), and the settle
16
- // DELIVERS through core's `approvalCardRespondVia` — all three halves, as the row requires. The
17
- // third is not a formality: this front bound its own `args.from(bareToken)` until OPE-1863, and
18
- // that address is channel-local, so every tap threw and the approval stayed parked.
19
- "unified-action-plane": "supported",
20
14
  // `replyAnchorTs` is placement only and sits outside `threadRef`, so two top-level wakes in one
21
15
  // channel are ONE conversation carrying two different anchors. Both members are implemented.
22
16
  "origin-placement-carry": "supported",
@@ -1 +1 @@
1
- {"version":3,"file":"surface-parity.js","sourceRoot":"","sources":["../../agent/surface-parity.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,gGAAgG;AAChG,iFAAiF;AACjF,EAAE;AACF,+FAA+F;AAC/F,iGAAiG;AACjG,wEAAwE;AAIxE,MAAM,CAAC,MAAM,oBAAoB,GAA6B;IAC5D,gGAAgG;IAChG,iBAAiB,EAAE,WAAW;IAC9B,qBAAqB,EAAE,WAAW;IAClC,UAAU,EAAE,WAAW;IACvB,mBAAmB,EAAE,WAAW;IAChC,mFAAmF;IACnF,iGAAiG;IACjG,gGAAgG;IAChG,gGAAgG;IAChG,oFAAoF;IACpF,sBAAsB,EAAE,WAAW;IACnC,gGAAgG;IAChG,6FAA6F;IAC7F,wBAAwB,EAAE,WAAW;IACrC,cAAc,EAAE,WAAW;IAC3B,0BAA0B,EAAE,WAAW;IACvC,8FAA8F;IAC9F,+FAA+F;IAC/F,8FAA8F;IAC9F,8DAA8D;IAC9D,eAAe,EAAE,EAAE,SAAS,EAAE,kEAAkE,EAAE;IAClG,yBAAyB,EAAE,WAAW;IACtC,kGAAkG;IAClG,gGAAgG;IAChG,kGAAkG;IAClG,oDAAoD;IACpD,uBAAuB,EAAE,EAAE,WAAW,EAAE,2GAA2G,EAAE;IACrJ,kGAAkG;IAClG,mGAAmG;IACnG,oFAAoF;IACpF,mGAAmG;IACnG,kGAAkG;IAClG,wDAAwD;IACxD,sBAAsB,EAAE,WAAW;IACnC,kGAAkG;IAClG,6FAA6F;IAC7F,2FAA2F;IAC3F,eAAe,EAAE,WAAW;IAC5B,kGAAkG;IAClG,kGAAkG;IAClG,mGAAmG;IACnG,wBAAwB,EAAE,WAAW;IACrC,8FAA8F;IAC9F,8EAA8E;IAC9E,kGAAkG;IAClG,8FAA8F;IAC9F,eAAe,EAAE,WAAW;IAC5B,gGAAgG;IAChG,+FAA+F;IAC/F,uCAAuC;IACvC,mBAAmB,EAAE,WAAW;IAEhC,gGAAgG;IAChG,eAAe,EAAE,WAAW;IAC5B,aAAa,EAAE,WAAW;IAC1B,mBAAmB,EAAE,WAAW;IAChC,iGAAiG;IACjG,iGAAiG;IACjG,8FAA8F;IAC9F,sEAAsE;IACtE,mBAAmB,EAAE,WAAW;IAChC,OAAO,EAAE,WAAW;IACpB,KAAK,EAAE,WAAW;IAClB,EAAE,EAAE,WAAW;IACf,IAAI,EAAE,WAAW;IAEjB,gGAAgG;IAChG,KAAK,EAAE,WAAW;IAClB,UAAU,EAAE,WAAW;IACvB,KAAK,EAAE,WAAW;IAClB,QAAQ,EAAE,WAAW;IACrB,YAAY,EAAE,WAAW;IACzB,WAAW,EAAE,WAAW;IACxB,0FAA0F;IAC1F,8FAA8F;IAC9F,4FAA4F;IAC5F,MAAM,EAAE,EAAE,WAAW,EAAE,4EAA4E,EAAE;IACrG,YAAY,EAAE,WAAW;IACzB,SAAS,EAAE,WAAW;IACtB,eAAe,EAAE,WAAW;IAC5B,IAAI,EAAE,EAAE,SAAS,EAAE,yCAAyC,EAAE;IAC9D,cAAc,EAAE,WAAW;IAC3B,eAAe,EAAE,WAAW;IAC5B,iGAAiG;IACjG,kFAAkF;IAClF,UAAU,EAAE,EAAE,SAAS,EAAE,yEAAyE,EAAE;IACpG,SAAS,EAAE,WAAW;CACvB,CAAC"}
1
+ {"version":3,"file":"surface-parity.js","sourceRoot":"","sources":["../../agent/surface-parity.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,gGAAgG;AAChG,iFAAiF;AACjF,EAAE;AACF,+FAA+F;AAC/F,iGAAiG;AACjG,wEAAwE;AAIxE,MAAM,CAAC,MAAM,oBAAoB,GAA6B;IAC5D,gGAAgG;IAChG,iBAAiB,EAAE,WAAW;IAC9B,qBAAqB,EAAE,WAAW;IAClC,UAAU,EAAE,WAAW;IACvB,mBAAmB,EAAE,WAAW;IAChC,gGAAgG;IAChG,6FAA6F;IAC7F,wBAAwB,EAAE,WAAW;IACrC,cAAc,EAAE,WAAW;IAC3B,0BAA0B,EAAE,WAAW;IACvC,8FAA8F;IAC9F,+FAA+F;IAC/F,8FAA8F;IAC9F,8DAA8D;IAC9D,eAAe,EAAE,EAAE,SAAS,EAAE,kEAAkE,EAAE;IAClG,yBAAyB,EAAE,WAAW;IACtC,kGAAkG;IAClG,gGAAgG;IAChG,kGAAkG;IAClG,oDAAoD;IACpD,uBAAuB,EAAE,EAAE,WAAW,EAAE,2GAA2G,EAAE;IACrJ,kGAAkG;IAClG,mGAAmG;IACnG,oFAAoF;IACpF,mGAAmG;IACnG,kGAAkG;IAClG,wDAAwD;IACxD,sBAAsB,EAAE,WAAW;IACnC,kGAAkG;IAClG,6FAA6F;IAC7F,2FAA2F;IAC3F,eAAe,EAAE,WAAW;IAC5B,kGAAkG;IAClG,kGAAkG;IAClG,mGAAmG;IACnG,wBAAwB,EAAE,WAAW;IACrC,8FAA8F;IAC9F,8EAA8E;IAC9E,kGAAkG;IAClG,8FAA8F;IAC9F,eAAe,EAAE,WAAW;IAC5B,gGAAgG;IAChG,+FAA+F;IAC/F,uCAAuC;IACvC,mBAAmB,EAAE,WAAW;IAEhC,gGAAgG;IAChG,eAAe,EAAE,WAAW;IAC5B,aAAa,EAAE,WAAW;IAC1B,mBAAmB,EAAE,WAAW;IAChC,iGAAiG;IACjG,iGAAiG;IACjG,8FAA8F;IAC9F,sEAAsE;IACtE,mBAAmB,EAAE,WAAW;IAChC,OAAO,EAAE,WAAW;IACpB,KAAK,EAAE,WAAW;IAClB,EAAE,EAAE,WAAW;IACf,IAAI,EAAE,WAAW;IAEjB,gGAAgG;IAChG,KAAK,EAAE,WAAW;IAClB,UAAU,EAAE,WAAW;IACvB,KAAK,EAAE,WAAW;IAClB,QAAQ,EAAE,WAAW;IACrB,YAAY,EAAE,WAAW;IACzB,WAAW,EAAE,WAAW;IACxB,0FAA0F;IAC1F,8FAA8F;IAC9F,4FAA4F;IAC5F,MAAM,EAAE,EAAE,WAAW,EAAE,4EAA4E,EAAE;IACrG,YAAY,EAAE,WAAW;IACzB,SAAS,EAAE,WAAW;IACtB,eAAe,EAAE,WAAW;IAC5B,IAAI,EAAE,EAAE,SAAS,EAAE,yCAAyC,EAAE;IAC9D,cAAc,EAAE,WAAW;IAC3B,eAAe,EAAE,WAAW;IAC5B,iGAAiG;IACjG,kFAAkF;IAClF,UAAU,EAAE,EAAE,SAAS,EAAE,yEAAyE,EAAE;IACpG,SAAS,EAAE,WAAW;CACvB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-everywhere/provider-slack",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Open Everywhere Slack provider — Slack channel and DM surfaces, identity mapping, approval cards and slash commands.",
5
5
  "keywords": [
6
6
  "agent",
@@ -44,10 +44,6 @@
44
44
  "types": "./dist/agent/app-manifest.d.ts",
45
45
  "default": "./dist/agent/app-manifest.js"
46
46
  },
47
- "./agent/channels/slack-approval-card.js": {
48
- "types": "./dist/agent/channels/slack-approval-card.d.ts",
49
- "default": "./dist/agent/channels/slack-approval-card.js"
50
- },
51
47
  "./agent/channels/slack-attachments.js": {
52
48
  "types": "./dist/agent/channels/slack-attachments.d.ts",
53
49
  "default": "./dist/agent/channels/slack-attachments.js"
@@ -226,7 +222,6 @@
226
222
  "test:slack-attachments": "NODE_NO_WARNINGS=1 tsx scripts/slack-attachments-test.ts",
227
223
  "test:slack-governance-roster": "NODE_NO_WARNINGS=1 tsx scripts/slack-governance-roster-test.ts",
228
224
  "test:slack-input-response": "NODE_NO_WARNINGS=1 tsx scripts/slack-input-response-test.ts",
229
- "test:slack-approval-card": "NODE_NO_WARNINGS=1 tsx scripts/slack-approval-card-test.ts",
230
225
  "test:slack-task-card": "NODE_NO_WARNINGS=1 tsx scripts/slack-task-card-test.ts",
231
226
  "test:slack-mrkdwn": "NODE_NO_WARNINGS=1 tsx scripts/slack-mrkdwn-test.ts",
232
227
  "test:slack-rate-limit": "NODE_NO_WARNINGS=1 tsx scripts/slack-rate-limit-test.ts",
@@ -236,7 +231,7 @@
236
231
  "test:slack-surface-evidence": "NODE_NO_WARNINGS=1 tsx scripts/slack-surface-evidence-test.ts"
237
232
  },
238
233
  "dependencies": {
239
- "@open-everywhere/core": "0.2.1",
234
+ "@open-everywhere/core": "0.3.0",
240
235
  "undici": "^7.28.0",
241
236
  "zod": "4.5.4"
242
237
  },
@@ -1,275 +0,0 @@
1
- // THE SLACK RETURN LEG FOR A BRIDGED APPROVAL CARD (OPE-1720) — what makes the button the unified
2
- // post arm now renders actually settle the approval it names.
3
- //
4
- // WHY THIS EXISTS RATHER THAN EVE'S OWN HITL ROUTE. eve's Slack channel has an interactivity seam for
5
- // its own `eve_input:` cards, and `slack-input-response.ts` is OE's admission on it. That seam cannot
6
- // serve this card, and the reason is structural rather than a not-yet: eve's own handler stores the
7
- // card it minted in the SLACK channel's state (`defaults.js` writes `state.pendingApprovalCards`) and
8
- // resolves the click against that channel's session address. A DM seat's parked request lives on the
9
- // UNIFIED window — another channel, another session, another state bag — so a click on a card OE
10
- // posted there has nothing for eve's route to resolve. It is OE's card, so it is OE's click.
11
- //
12
- // 🔴 THE SAME ADMISSION, THOUGH. `admitInputResponse` (core approvals/input-response-floor.ts) is the
13
- // ONE decision both HITL answer doors take, and `ingressSpendGate` with door `"answer"` is the same
14
- // spend floor `admitSlackInputResponse` consults FIRST for the same reason: a tap RESUMES a parked
15
- // turn, which is a decision to spend more on a scope nobody asked whether it could afford. This
16
- // module adds no floor of its own — it resolves the click, asks those two, and delivers.
17
- //
18
- // 🔴 A REFUSAL LEAVES THE CARD LIVE. Nothing here consumes a row on any outcome but a delivered
19
- // answer: the approval stays parked, the card stays tappable for somebody who may answer it, and the
20
- // question stays answerable IN WORDS through the text leg (`question-bridge.ts`), which is untouched.
21
- // That is the same "a refusal is not a drop" posture the text leg holds.
22
-
23
- import {
24
- admitInputResponse,
25
- APPROVAL_BUTTON_LABELS,
26
- APPROVAL_CARD_REFUSED_AUDIT_ACTION as CORE_APPROVAL_CARD_REFUSED_AUDIT_ACTION,
27
- APPROVAL_CARD_SETTLED_AUDIT_ACTION as CORE_APPROVAL_CARD_SETTLED_AUDIT_ACTION,
28
- auditApprovalCardTap,
29
- deploymentOrg,
30
- forgetApprovalCardTicket,
31
- forgetBridgedQuestion,
32
- getOperationalStore,
33
- ingressSpendGate,
34
- parseApprovalClickId,
35
- readBridgedQuestion,
36
- readApprovalCardTicket,
37
- resolveApprovalCardClick,
38
- resolvePartition,
39
- unifiedThreadRef,
40
- SLACK_SPEND_BADGES,
41
- type ApprovalCardRespondFn,
42
- type BindableContext,
43
- } from "@open-everywhere/core/agent/adapter-api.js";
44
- import { slackClickerIsGuest, slackForm } from "./slack-events.js";
45
-
46
- const log = (m: string): void => console.log(`[open-everywhere:slack] approval card: ${m}`);
47
-
48
- /** The `input_response.routed` sibling for a tap: an approval settled through a BUTTON rather than a
49
- * typed reply. RE-EXPORTED FROM CORE, never re-spelled here — one action name for every surface, so
50
- * a scorer that joins on the row cannot under-count one of them (OPE-1856). */
51
- export const APPROVAL_CARD_SETTLED_AUDIT_ACTION = CORE_APPROVAL_CARD_SETTLED_AUDIT_ACTION;
52
- /** A verified tap that settled nothing, carrying WHICH refusal it was. Core-owned, as above. */
53
- export const APPROVAL_CARD_REFUSED_AUDIT_ACTION = CORE_APPROVAL_CARD_REFUSED_AUDIT_ACTION;
54
-
55
- /** The settle delivery this leg needs. CORE-OWNED and bound by the front route through
56
- * `approvalCardRespondVia(args.attachSession)` — re-exported here, never re-spelled, so the address
57
- * the answer takes cannot drift per surface (OPE-1863). */
58
- export type ApprovalRespondFn = ApprovalCardRespondFn;
59
-
60
- /** What one verified tap did, for the front route's log line. Never thrown — this endpoint answers a
61
- * person who is waiting, so every path returns. */
62
- export interface ApprovalCardClickOutcome {
63
- readonly handled: boolean;
64
- readonly reason: string;
65
- /** A line to show the tapper alone, or undefined when the outcome needs no note. */
66
- readonly notice?: string;
67
- }
68
-
69
- /** The `block_actions` fields this leg reads, narrowed so nothing here needs Slack's own types. */
70
- interface ParsedApprovalClick {
71
- readonly actionId: string;
72
- readonly channelId: string;
73
- readonly userId: string;
74
- readonly teamId?: string;
75
- }
76
-
77
- /**
78
- * Read the one `oe_input:` action out of a verified `block_actions` payload. `undefined` when the
79
- * payload is not one of ours or is missing a field the decision needs — the front route then leaves
80
- * it alone rather than half-handling it.
81
- *
82
- * THE FIRST `oe_input:` ACTION WINS and the rest are ignored: Slack sends one action per click, and a
83
- * payload carrying two of ours is not a shape this host mints.
84
- */
85
- export function parseSlackApprovalClick(payload: unknown): ParsedApprovalClick | undefined {
86
- if (typeof payload !== "object" || payload === null) return undefined;
87
- const p = payload as Record<string, unknown>;
88
- if (!Array.isArray(p.actions)) return undefined;
89
- let actionId: string | undefined;
90
- for (const a of p.actions as readonly unknown[]) {
91
- const id = typeof a === "object" && a !== null ? (a as Record<string, unknown>).action_id : undefined;
92
- if (typeof id === "string" && parseApprovalClickId(id) !== undefined) {
93
- actionId = id;
94
- break;
95
- }
96
- }
97
- if (actionId === undefined) return undefined;
98
- const channel = typeof p.channel === "object" && p.channel !== null ? (p.channel as Record<string, unknown>).id : undefined;
99
- const user = typeof p.user === "object" && p.user !== null ? (p.user as Record<string, unknown>).id : undefined;
100
- const team = typeof p.team === "object" && p.team !== null ? (p.team as Record<string, unknown>).id : undefined;
101
- if (typeof channel !== "string" || channel.length === 0) return undefined;
102
- if (typeof user !== "string" || user.length === 0) return undefined;
103
- return { actionId, channelId: channel, userId: user, ...(typeof team === "string" && team.length > 0 ? { teamId: team } : {}) };
104
- }
105
-
106
- /** The bindable a partition resolve needs from a raw interactivity payload. The team id is the ONE
107
- * attribute that matters here, and it is read off the wire rather than through a placeholder — the
108
- * same reading, and the same reason, as `slackAuthTeamId` in slack-input-response.ts. */
109
- function bindableForClick(click: ParsedApprovalClick): BindableContext {
110
- return {
111
- channel: { kind: "slack" },
112
- session: { auth: { current: { attributes: { ...(click.teamId ? { team_id: click.teamId } : {}), channel_id: click.channelId } } } },
113
- } as unknown as BindableContext;
114
- }
115
-
116
- /**
117
- * The host copy that rides the delivered answer's `text` — the LABEL THIS DEPLOYMENT PUT ON THE
118
- * BUTTON, read back out of the same allowlist that minted it, so the transcript says what the person
119
- * actually chose. A tap carries no words, and writing the person's prose for them is not an option.
120
- * The fallback capitalises the eve-minted option id, which can only be reached by a row written
121
- * before an id left the allowlist; the DECISION is never this value, because eve settles on
122
- * `optionId` alone.
123
- */
124
- function approvalAnswerText(optionId: string): string {
125
- return APPROVAL_BUTTON_LABELS.get(optionId.toLowerCase())?.label ?? optionId.charAt(0).toUpperCase() + optionId.slice(1);
126
- }
127
-
128
- /** Show the tapper alone why nothing happened. EPHEMERAL, never a message in the room: the card is
129
- * still live for somebody who may answer it, and a public "you may not answer" note tells the room
130
- * nothing it needs. Best-effort — a failed notice never changes the verdict. */
131
- async function notifyTapper(channelId: string, userId: string, text: string): Promise<void> {
132
- try {
133
- await slackForm("chat.postEphemeral", new URLSearchParams({ channel: channelId, user: userId, text }).toString());
134
- } catch (err) {
135
- log(`refusal notice failed to post: ${String(err)}`);
136
- }
137
- }
138
-
139
- /**
140
- * Handle one verified `oe_input:` click: resolve it, admit it, deliver the answer to eve, consume the
141
- * rows. NEVER THROWS — the front route has already 200'd the interaction, and an unhandled throw here
142
- * would only lose the log line.
143
- *
144
- * 🔴 THE ANSWER TEXT IS HOST COPY, and it must be. A tap carries no words, so a transcript that read
145
- * the button as the person's own prose would be inventing their words. What rides is the host label
146
- * this deployment put on that button (`Approve` / `Cancel`), which is what the person actually chose,
147
- * and the `optionId` beside it is what eve settles on — `resolveApprovalOutcome` reads `optionId`
148
- * ALONE, so the text is transcript only and never the decision.
149
- *
150
- * 🔴 CONSUME-ONCE, AND BOTH ROWS. The ticket goes so a second tap on the same card cannot answer a
151
- * request eve has already settled; the outstanding-question row goes so the room's NEXT message is an
152
- * ordinary wake again rather than a second answer to a settled request. Both happen only after eve
153
- * accepted the delivery, exactly as the text leg orders them.
154
- *
155
- * THE LIMIT OF THAT, stated rather than implied: the read and the delete are not one atomic step, and
156
- * the delete runs only AFTER `respond` returns, so anything arriving while the first tap is still in
157
- * flight resolves against rows that are both still present. Two such deliveries each spend an
158
- * admission, each call `respond`, and each can write a settled audit row — so one approval can read
159
- * as decided by two taps. It is not a second DECISION: both had to be admitted and both had to name
160
- * an option the request offered, and eve's own resolution refuses the second delivery for a request
161
- * it already settled. A delivery that arrives after the first settle RETURNED is the covered case —
162
- * the ticket is gone and it resolves `no-ticket`.
163
- *
164
- * 🔴 A SECOND DELIVERY IS MOST LIKELY TO BE SLACK'S OWN, NOT A SECOND PERSON, and this handler does
165
- * not ack until the settle returns. Slack expects an interactivity endpoint to answer within about
166
- * three seconds; what it does past that — show the tapper a connection error, redeliver, or both —
167
- * is VENDOR BEHAVIOUR THIS TREE HAS NOT MEASURED, so do not read the bound above as covering it.
168
- * Acking first and settling in the background would remove the delay and widen exactly the window
169
- * this paragraph describes; the ordering here takes the narrower window instead. Whoever measures
170
- * the real behaviour should revisit that trade, with the measurement written down.
171
- */
172
- export async function handleSlackApprovalCardClick(payload: unknown, respond: ApprovalRespondFn): Promise<ApprovalCardClickOutcome> {
173
- // 🔴 HOISTED OUT OF THE `try` SO THE `catch` CAN STAMP ITS ROW (OPE-1856). A throw anywhere below
174
- // — including from the partition resolve itself — must still leave a row saying this deployment
175
- // took a tap and settled nothing; core's auditor accepts an absent partition for that case.
176
- let tapper = "";
177
- let auditPartition: ReturnType<typeof resolvePartition> | undefined;
178
- try {
179
- const click = parseSlackApprovalClick(payload);
180
- if (click === undefined) return { handled: false, reason: "not an oe_input: click" };
181
- const parsed = parseApprovalClickId(click.actionId);
182
- if (parsed === undefined) return { handled: false, reason: "oe_input: action id did not parse" };
183
- tapper = click.userId;
184
- const org = deploymentOrg();
185
- const store = getOperationalStore();
186
- const partition = resolvePartition(bindableForClick(click));
187
- auditPartition = partition;
188
- // THE SPEND GATE FIRST, ahead of the identity admission — the same order and the same reason
189
- // `admitSlackInputResponse` takes: over budget outranks identity, and a decline must not spend a
190
- // roster lookup. Its refusal copy is its own; a decline here leaves the request PENDING.
191
- const spend = await ingressSpendGate({
192
- bindable: bindableForClick(click),
193
- door: "answer",
194
- principalId: click.userId,
195
- workspaceRef: click.teamId ?? null,
196
- badges: SLACK_SPEND_BADGES,
197
- post: async (text) => {
198
- await notifyTapper(click.channelId, click.userId, text);
199
- },
200
- });
201
- if (spend === "declined") {
202
- // AUDITED, not only logged (OPE-1856): a decline is this deployment refusing a person's tap,
203
- // and a refusal nobody can count is indistinguishable from a tap that never arrived.
204
- await auditApprovalCardTap({ platform: "slack", principalId: click.userId, partition, outcome: { kind: "refused", reason: "spend-declined" } });
205
- log(`tap declined by the spend gate — the approval stays parked`);
206
- return { handled: true, reason: "spend-declined" };
207
- }
208
- // The room the tap came from, in the SAME vocabulary the card's ticket stored — derived through
209
- // core's own codec rather than spelled here, so the two sides cannot drift on the key's shape.
210
- const containerKey = unifiedThreadRef({ platform: "slack", channelId: click.channelId }).channelKey;
211
- const routing = await resolveApprovalCardClick(
212
- { ticket: parsed.ticket, optionId: parsed.optionId, containerKey },
213
- {
214
- read: (ticket) => readApprovalCardTicket(store, org, ticket),
215
- // The join with the TEXT leg — see the dep's own note. Fail-CLOSED on an unreadable row: a
216
- // store this leg cannot read cannot say the request is still parked, and settling on that
217
- // guess is the one direction that spends a governed decision twice.
218
- stillOutstanding: async (bareToken, requestId) => {
219
- const outstanding = await readBridgedQuestion(store, org, bareToken).catch(() => undefined);
220
- return outstanding?.requestId === requestId;
221
- },
222
- admit: async () => {
223
- // Leg B, the same positive guest verdict both other clicker floors use, and inert on an
224
- // unreadable probe (`undefined` never refuses).
225
- const answererIsGuest = await slackClickerIsGuest(click.userId, click.teamId).catch(() => undefined);
226
- const verdict = await admitInputResponse({
227
- org: partition.org,
228
- platform: partition.platform,
229
- workspace: partition.workspace,
230
- surface: partition.surface,
231
- surfaceKey: partition.surfaceKey,
232
- principalId: click.userId,
233
- responses: 1,
234
- ...(answererIsGuest === undefined ? {} : { answererIsGuest }),
235
- });
236
- return { ok: verdict.ok, floor: verdict.ok ? verdict.floor : "", notice: verdict.ok ? "" : verdict.notice };
237
- },
238
- },
239
- );
240
- if (routing.kind === "refuse") {
241
- await auditApprovalCardTap({ platform: "slack", principalId: click.userId, partition, outcome: { kind: "refused", reason: routing.reason } });
242
- if (routing.notice !== undefined) await notifyTapper(click.channelId, click.userId, routing.notice);
243
- log(`tap settled nothing (${routing.reason}) — the approval stays parked and answerable in words`);
244
- return { handled: true, reason: routing.reason, ...(routing.notice ? { notice: routing.notice } : {}) };
245
- }
246
- const label = approvalAnswerText(routing.optionId);
247
- // 🔴 DELIVERED BY SESSION ID, never by the window token (OPE-1863): a continuation token is
248
- // channel-local, and this route is the SLACK channel's, so `from(bareToken)` here named a
249
- // session that never existed and the settle threw into the catch below. The token's job is the
250
- // outstanding-question row on the two lines that follow.
251
- await respond(routing.sessionId, { requestId: routing.requestId, optionId: routing.optionId, text: label });
252
- // CONSUME-ONCE, and only now that eve accepted the delivery. A throw above skips both, which
253
- // leaves the card tappable and the question answerable — the safe direction.
254
- await forgetApprovalCardTicket(store, org, parsed.ticket);
255
- await forgetBridgedQuestion(store, org, routing.bareToken);
256
- await auditApprovalCardTap({
257
- platform: "slack",
258
- principalId: click.userId,
259
- partition,
260
- outcome: { kind: "settled", requestId: routing.requestId, optionId: routing.optionId, floor: routing.floor },
261
- });
262
- log(`tap settled ${routing.requestId} as "${routing.optionId}" (floor ${routing.floor})`);
263
- return { handled: true, reason: "settled" };
264
- } catch (err) {
265
- // The approval is still parked and the card is still tappable, so the honest outcome is to say so
266
- // and leave both rows in place. Never a throw: this runs after the interaction was acknowledged.
267
- // AUDITED, not only logged (OPE-1856): ship #212's S-EVE-CARD leg could not say whether its tap
268
- // had been refused or had thrown, because neither arm left a row. `auditApprovalCardTap` never
269
- // throws, so this cannot turn a failed tap into a failed response.
270
- await auditApprovalCardTap({ platform: "slack", principalId: tapper, ...(auditPartition === undefined ? {} : { partition: auditPartition }), outcome: { kind: "refused", reason: "error" } });
271
- log(`tap failed (${(err as Error)?.message ?? String(err)}) — nothing was settled, the approval stays parked`);
272
- return { handled: true, reason: "error" };
273
- }
274
- }
275
-