@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.
Files changed (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,162 @@
1
+ // THE SLACK ARM OF THE HITL-ANSWER ADMISSION (OPE-574).
2
+ //
3
+ // eve's Slack channel accepts an answer to any parked `ask_question` / session-limit prompt from
4
+ // whoever pressed the widget, with no check at all: `defaultOnInputResponse(ctx){return{auth:
5
+ // ctx.defaultAuth}}`. Authoring `onInputResponse` replaces that default with OE code. The decision
6
+ // itself lives in core (approvals/input-response-floor.ts) so the Teams arm reaches the same
7
+ // verdict; this file is the Slack-shaped edge — resolve the partition, hand the facts over, render
8
+ // the refusal.
9
+ //
10
+ // 🔴 FAIL-OPEN ON A THROW, deliberately. eve logs a thrown hook and treats it as REJECTION
11
+ // (`slack/input-response.js` catches and returns `null`), so a transient store or roster blip
12
+ // inside our code would silently make every question unanswerable — the exact outcome the
13
+ // clicker floor's own catch exists to prevent. Anything unexpected therefore returns eve's own
14
+ // default result and says so in the log; only a real verdict refuses.
15
+ //
16
+ // The refusal is EPHEMERAL, not a thread post. The card is still live for someone who may answer,
17
+ // and a public "you may not answer" note in the room adds nothing the answerer does not already
18
+ // need to know. This mirrors the Discord approval refusal (an ephemeral), not the Slack approval
19
+ // refusal (a thread note, which exists there because the CARD is public and its state matters to
20
+ // the room).
21
+ //
22
+ // LEG B HAS TEETH HERE (OPE-589) — the core header's "Slack cannot [answer B]" is now stale. This
23
+ // module reuses `slackClickerIsGuest` (channels/slack-events.ts) — `slackAuthorIsMember`, the SAME
24
+ // `users.info` call OPE-466 already makes to mark a non-member's chat message, inverted to a guest
25
+ // verdict. The clicker floor's own leg D (approvals/flow.ts's `handleApprovalPayload`) reuses the
26
+ // SAME function, so the two planes cannot drift on what "positively a guest" means. An unresolvable
27
+ // probe returns `undefined` and the leg stays INERT, exactly as it did before.
28
+ //
29
+ // 🔴 SPEND GATE (OPE-588), FIRST — ahead of the room/guest admission below. This click RESUMES a
30
+ // parked turn: a decision to spend more, taken through a button, on a scope nobody asked whether it
31
+ // could afford. Every ordinary ingress door consults the ONE shared spend gate before it dispatches
32
+ // a turn (spend/ingress-gate.ts); eve's own interactivity route calls `onInputResponse` and then
33
+ // `.respond()` directly, with no call site of that gate anywhere on the seam — verified against the
34
+ // installed eve/dist. `ingressSpendGate` fails open on a broken check and fails closed (visible
35
+ // refusal, door `"answer"`) on a definite over-cap, the same posture the DM/channel doors already
36
+ // hold. A decline returns eve's own reject-the-answer `null`, so the request stays PENDING — the
37
+ // same outcome a room/guest refusal already leaves.
38
+
39
+ import type {
40
+ SlackInputResponseContext,
41
+ SlackInputResponseResult,
42
+ SlackInputResponseSubmission,
43
+ } from "eve/channels/slack";
44
+ import {
45
+ admitInputResponse,
46
+ ingressSpendGate,
47
+ resolvePartition,
48
+ SLACK_SPEND_BADGES,
49
+ type BindableContext,
50
+ type IngressSpendGateInput,
51
+ type InputResponseAdmission,
52
+ type InputResponseScope,
53
+ type SpendGateDecision,
54
+ } from "@open-everywhere/core/agent/adapter-api.js";
55
+ import { slackClickerIsGuest } from "./slack-events.js";
56
+
57
+ /**
58
+ * The RAW `team_id` off the interaction's own auth attributes — never `resolvePartition`'s
59
+ * `workspace` field (OPE-589 review fix). `resolvePartition` degrades an unnamed workspace to the
60
+ * `UNNAMED_TENANT` placeholder ("dev"), which is harmless where `workspace` only ever fed a
61
+ * surfaceKey lookup, but the guest-verdict probe below compares it against Slack's REAL `team_id`
62
+ * (`partnerFactsFromInfo`'s `ownWorkspace` check) — a placeholder there does not mean "no team", it
63
+ * means "a team literally named dev", and would falsely fail that comparison and refuse a genuine
64
+ * member. Reading the wire attribute directly keeps "no team known" as `undefined`. The probe then
65
+ * answers that absence from the operator's register alone (OPE-1508): a declared workspace or Grid
66
+ * still clears leg B, and an undeclared one is a GUEST, because absence infers non-member. Only an
67
+ * unreadable `users.info` leaves leg B inert. This is
68
+ * the same shape `flow.ts`'s `handleApprovalPayload` already reads off the raw Slack
69
+ * payload for the SAME probe on the approval-click side. LOCAL COPY of the attribute read (the
70
+ * `slack-identity.ts` header states the precedent): a three-line structural read over the public
71
+ * auth shape, not worth a barrel export.
72
+ */
73
+ function slackAuthTeamId(auth: SlackInputResponseContext["defaultAuth"]): string | undefined {
74
+ const v = auth.attributes?.["team_id"];
75
+ if (typeof v === "string" && v.length > 0) return v;
76
+ if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
77
+ return undefined;
78
+ }
79
+
80
+ export interface SlackInputResponseDeps {
81
+ /** The core admission. Injected in tests; the live default is the shared decision. */
82
+ readonly admit?: (scope: InputResponseScope) => Promise<InputResponseAdmission>;
83
+ /** Render the refusal to the answerer alone. Live default is `chat.postEphemeral` in the thread. */
84
+ readonly notify?: (userId: string, text: string) => Promise<void>;
85
+ /**
86
+ * Positive per-answerer guest verdict for leg B (OPE-589). Live default is `slackClickerIsGuest`
87
+ * (channels/slack-events.ts). `undefined` is ALWAYS inert.
88
+ */
89
+ readonly guestVerdict?: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
90
+ /** The shared spend-admission decision (OPE-588). Injected in tests; the live default is
91
+ * `ingressSpendGate` (spend/ingress-gate.ts) — the SAME function every ordinary ingress door
92
+ * consults. */
93
+ readonly spendGate?: (input: IngressSpendGateInput) => Promise<SpendGateDecision>;
94
+ }
95
+
96
+ /**
97
+ * Admit or refuse one eve-owned HITL answer submitted through Slack interactivity.
98
+ *
99
+ * Returns eve's own default result (`{ auth: ctx.defaultAuth }`) on an allow, so an admitted answer
100
+ * is byte-identical to today's behaviour. Returns `null` on a refusal, which leaves the request
101
+ * PENDING — eve has no cancel on this seam (see the core module's residual note).
102
+ */
103
+ export async function admitSlackInputResponse(
104
+ ctx: SlackInputResponseContext,
105
+ submission: SlackInputResponseSubmission,
106
+ deps: SlackInputResponseDeps = {},
107
+ ): Promise<SlackInputResponseResult> {
108
+ const allow: SlackInputResponseResult = { auth: ctx.defaultAuth };
109
+ try {
110
+ const bindable = {
111
+ channel: { kind: "slack" },
112
+ session: { auth: { current: ctx.defaultAuth } },
113
+ } as BindableContext;
114
+ const p = resolvePartition(bindable);
115
+ const teamId = slackAuthTeamId(ctx.defaultAuth);
116
+
117
+ // SPEND GATE (OPE-588) — FIRST, ahead of the room/guest admission below (see the header note).
118
+ // A decline skips the guest probe entirely (over budget outranks identity) and returns eve's
119
+ // reject-the-answer `null`, leaving the request PENDING exactly like a room/guest refusal.
120
+ const spendGate = deps.spendGate ?? ingressSpendGate;
121
+ const spendVerdict = await spendGate({
122
+ bindable,
123
+ door: "answer",
124
+ principalId: submission.user.id,
125
+ workspaceRef: teamId ?? null,
126
+ badges: SLACK_SPEND_BADGES,
127
+ post: async (text) => {
128
+ await ctx.thread.postEphemeral(submission.user.id, text);
129
+ },
130
+ });
131
+ if (spendVerdict === "declined") return null;
132
+
133
+ const guestVerdict = deps.guestVerdict ?? slackClickerIsGuest;
134
+ const answererIsGuest = await guestVerdict(submission.user.id, teamId).catch(() => undefined);
135
+ const verdict = await (deps.admit ?? admitInputResponse)({
136
+ org: p.org,
137
+ platform: p.platform,
138
+ workspace: p.workspace,
139
+ surface: p.surface,
140
+ surfaceKey: p.surfaceKey,
141
+ principalId: submission.user.id,
142
+ responses: submission.inputResponses.length,
143
+ // Leg B (OPE-589): a POSITIVE per-answerer guest verdict from `slackAuthorIsMember`, or
144
+ // omitted when the probe could not answer — `undefined` never refuses.
145
+ ...(answererIsGuest === undefined ? {} : { answererIsGuest }),
146
+ });
147
+ if (verdict.ok) return allow;
148
+ const notify =
149
+ deps.notify ??
150
+ (async (userId: string, text: string): Promise<void> => {
151
+ await ctx.thread.postEphemeral(userId, text);
152
+ });
153
+ await notify(submission.user.id, verdict.notice).catch((err: unknown) => {
154
+ // The refusal still stands — the human just does not see why. One log line, never a throw.
155
+ console.error(`[open-everywhere:slack] HITL refusal notice failed to post: ${String(err)}`);
156
+ });
157
+ return null;
158
+ } catch (err) {
159
+ console.error(`[open-everywhere:slack] HITL answer admission failed softly — accepting the answer: ${String(err)}`);
160
+ return allow;
161
+ }
162
+ }
@@ -0,0 +1,61 @@
1
+ // slack-mrkdwn — markdown (what the model writes) → Slack's mrkdwn dialect (OPE-870).
2
+ //
3
+ // Slack renders mrkdwn, not GitHub markdown: `*bold*` (not `**bold**`), `_italic_`, `~strike~`,
4
+ // `<url|text>` links, no heading syntax. Every Slack post shipped the model's raw markdown
5
+ // verbatim until this file existed, so `**bold**` and `[text](url)` rendered as literal
6
+ // characters — observed live 2026-08-20, #oe-soak-ops, the channel-join intro's
7
+ // `**Track soak health**`.
8
+ //
9
+ // PURE — no I/O. Applied at the Slack provider's delivery seam (slack-post-arm.ts): the reply
10
+ // tool, edit_reply, and the join intro all post through dispatchSlackReply / liveSlackPost /
11
+ // editSlackMessage, so converting there covers all three without touching core or any other
12
+ // provider (Linear/GitHub take real markdown; Notion has its own block model).
13
+ //
14
+ // Code fences and inline code are protected FIRST and restored LAST — their contents must never
15
+ // change, even when they look like markdown. Text that is already valid Slack mrkdwn (a single
16
+ // `*bold*`, an existing `_italic_`, a `>` blockquote) is left untouched: Slack has no `**`/`__`
17
+ // bold marker, so those are unambiguous GFM syntax safe to convert, but a single `*`/`_` is
18
+ // ALREADY Slack's own syntax, and converting it again would corrupt it. That is also why this
19
+ // function is idempotent — running it twice on its own output is a no-op.
20
+
21
+ const CODE_PLACEHOLDER = "";
22
+
23
+ /** Convert model-authored markdown to Slack mrkdwn. */
24
+ export function markdownToSlackMrkdwn(text: string): string {
25
+ if (!text) return text;
26
+
27
+ // 1. Protect fenced code blocks, then inline code spans — restored verbatim at the end. Fences
28
+ // first: an inline-code regex would otherwise match the single backticks INSIDE a fence.
29
+ const blocks: string[] = [];
30
+ const stash = (s: string): string => {
31
+ const token = `${CODE_PLACEHOLDER}${blocks.length}${CODE_PLACEHOLDER}`;
32
+ blocks.push(s);
33
+ return token;
34
+ };
35
+ let out = text.replace(/```[\s\S]*?```/g, (m) => stash(m));
36
+ out = out.replace(/`[^`\n]+`/g, (m) => stash(m));
37
+
38
+ // 2. Headers → a bold line (Slack has no heading syntax).
39
+ out = out.replace(/^ {0,3}#{1,6}[ \t]+(.+)$/gm, (_m, body: string) => `*${body.trim()}*`);
40
+
41
+ // 3. Links: [text](url) → <url|text>.
42
+ out = out.replace(/\[([^\]\n]+)\]\((\S+?)\)/g, (_m, label: string, url: string) => `<${url}|${label}>`);
43
+
44
+ // 4. Bold: **text** / __text__ → *text* — unambiguous, Slack has no ** or __ marker so this
45
+ // never fires on text that is already Slack mrkdwn.
46
+ out = out.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => `*${body}*`);
47
+ out = out.replace(/__([^_\n]+)__/g, (_m, body: string) => `*${body}*`);
48
+
49
+ // 5. Strikethrough: ~~text~~ → ~text~.
50
+ out = out.replace(/~~([^~\n]+)~~/g, (_m, body: string) => `~${body}~`);
51
+
52
+ // 6. Bullet lists: "- item" / "* item" → "• item". The dash/star must be followed by
53
+ // whitespace to match — a line opening on **bold** or *bold* never qualifies (no space
54
+ // right after the marker), so this never collides with rule 4's untouched single-star text.
55
+ out = out.replace(/^(\s*)[-*][ \t]+/gm, (_m, indent: string) => `${indent}• `);
56
+
57
+ // 7. Restore code spans/fences untouched — no rule above ever saw their contents.
58
+ out = out.replace(new RegExp(`${CODE_PLACEHOLDER}(\\d+)${CODE_PLACEHOLDER}`, "g"), (_m, i: string) => blocks[Number(i)] ?? "");
59
+
60
+ return out;
61
+ }
@@ -0,0 +1,62 @@
1
+ // The Slack UnifiedWakeOrigin codec — MOVED VERBATIM out of core's
2
+ // channels/wake-origin-codecs.ts (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE
3
+ // CONTRACT: a Slack origin is JSON-serialised into the durable operational store and
4
+ // fail-closed re-validated on read, so every behaviour here is frozen by core's
5
+ // scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's boot
6
+ // wiring — scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only
7
+ // optional checks, extra unknown fields) are LOAD-BEARING for rows already on disk — never
8
+ // tighten them here.
9
+ //
10
+ // The `UnifiedWakeOrigin` TYPE union (including its slack arm) stays in core — it is the
11
+ // wire schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
12
+ // registerSlackProvider() (../register.ts).
13
+
14
+ import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
15
+
16
+ type SlackOrigin = Extract<UnifiedWakeOrigin, { platform: "slack" }>;
17
+
18
+ /** COLON-FREE by construction (a task windowId is `[^:]+` — core channels/window-key.ts): any colon
19
+ * in a raw surface id is folded to `_`, deterministically, so spawn + ingress agree on identity. */
20
+ const noColon = (s: string): string => s.replace(/:/g, "_");
21
+
22
+ export const slackWakeOriginCodec: WakeOriginCodec<SlackOrigin> = {
23
+ validate: (o) =>
24
+ typeof o.channelId === "string" &&
25
+ o.channelId.length > 0 &&
26
+ (o.threadTs === undefined || typeof o.threadTs === "string") &&
27
+ (o.replyAnchorTs === undefined || typeof o.replyAnchorTs === "string"),
28
+ threadRef: (origin) => ({ channelKey: noColon(`slack/${origin.channelId}`), threadKey: noColon(origin.threadTs ?? origin.channelId) }),
29
+ // threadTs present ⇔ the trigger was itself in a thread (the exact same presence threadRef keys
30
+ // on). Absent = a top-level trigger, which stays top-level for IDENTITY even though its reply
31
+ // opens a thread on it — that placement rides `replyAnchorTs`, which nothing here reads (OPE-759).
32
+ isThreadScoped: (origin) => typeof origin.threadTs === "string" && origin.threadTs.length > 0,
33
+ containerId: (origin) => origin.channelId,
34
+ // THE PLACEMENT CARRY (OPE-1824) — Slack is the one surface that needs it, and the line above says
35
+ // why: `replyAnchorTs` is placement ONLY and stays outside `threadRef`, so two top-level wakes in
36
+ // one channel are ONE conversation holding two different anchors, and a wake minted for a message
37
+ // that answers nothing (a routine fire, a webhook post-back) holds none. The reply fence admits a
38
+ // sibling wake on the conversation test alone; without this carry it would hand the post arm an
39
+ // anchorless origin and the answer would land FLAT on the channel.
40
+ // NARROWING-ONLY, and both guards are load-bearing. A `threadTs` target is already placed — its
41
+ // answer goes in that thread and must never re-anchor on a trigger message (the rule
42
+ // slackThreadTarget states) — and a target that already holds an anchor keeps it, because the model
43
+ // named THAT wake. So this fills exactly one gap: no placement at all, where the source has one.
44
+ // EMPTINESS IS ABSENCE ON ALL THREE READS, matching `slackThreadTarget` and `isThreadScoped`: a
45
+ // stored `""` is not a thread, not an anchor, and not an anchor worth carrying. No mint site writes
46
+ // one today, and reading it three ways in one file is how that stops being true quietly.
47
+ carryPlacement: (target, source) =>
48
+ !(target.threadTs && target.threadTs.length > 0) && !(target.replyAnchorTs && target.replyAnchorTs.length > 0) && typeof source.replyAnchorTs === "string" && source.replyAnchorTs.length > 0
49
+ ? { ...target, replyAnchorTs: source.replyAnchorTs }
50
+ : target,
51
+ // THE PLACEMENT AXIS ITSELF (OPE-1826) — the declaration `carryPlacement` above always implied and
52
+ // never stated. `replyAnchorTs` is the whole of it: `threadTs` is IDENTITY (it is what `threadRef`
53
+ // keys on and what `isThreadScoped` answers from), so a threaded origin's placement is already
54
+ // settled by the conversation test and naming it here would make two origins of one thread compare
55
+ // unequal on an axis that cannot differ. EMPTINESS IS ABSENCE, the third of the three reads this
56
+ // file takes that way.
57
+ placementRef: (origin) => (typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined),
58
+ // A `dmOrigin` arm lived here (OPE-306) to mint a DM delegation anchor's reply origin from the
59
+ // binding. OPE-368 deleted the anchor and the interface member with it — a Slack 1:1 or group DM
60
+ // now diverts into its own first-class window (OPE-351 stages 2 + 4), so its origin is minted and
61
+ // STORED at dispatch time by `scheduleDmWake` like every other wake.
62
+ };
@@ -0,0 +1,314 @@
1
+ // slack-post-arm — Slack's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs, VERBATIM,
2
+ // Slack's arms out of the three reply/delivery spines: reply-post.ts (resolveReplyTarget's slack
3
+ // case + dispatchReply's chat.postMessage branch), unified-reply.ts (liveSlackPost), and
4
+ // unified-redrive.ts (the slack describe/container cases). Slack's ONE bot-token write client is
5
+ // slackForm (slack-events.ts) — every Slack post here funnels through it.
6
+
7
+ import { slackForm, botDisplayName } from "./slack-events.js";
8
+ import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
9
+ import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
10
+ import type {
11
+ PostArm,
12
+ TargetResolveBag,
13
+ ReplyTarget,
14
+ ReplierLabel,
15
+ EditLocus,
16
+ UnifiedWakeOrigin,
17
+ UnifiedReplyDeps,
18
+ DirectMessageRequest,
19
+ DirectMessageDelivery,
20
+ DirectMessageAction,
21
+ } from "@open-everywhere/core/agent/adapter-api.js";
22
+
23
+ type SlackTarget = Extract<ReplyTarget, { platform: "slack" }>;
24
+ type SlackOrigin = Extract<UnifiedWakeOrigin, { platform: "slack" }>;
25
+
26
+ // --- OPE-508: the reply TIER signal, native on Slack ------------------------------------------
27
+ //
28
+ // Slack is the one surface OE's send path can vary the display NAME on, per message: chat.postMessage
29
+ // takes an optional `username` override on a bot-token post — needs the `chat:write.customize` scope
30
+ // (see docs/deploy/surfaces/slack.md). Every other wired surface (Discord's bot-token REST message
31
+ // create, Teams' Bot-Framework activity, Notion's/Linear's comment APIs) has no equivalent per-message
32
+ // override in what OE's send path already uses, so they get the shared bracket-BODY fallback instead
33
+ // (reply-post.ts / unified-reply.ts, gated on `displayNameCapable` being false/absent for them).
34
+ //
35
+ // 🔴 SCOPE-OPTIONAL, GRACEFUL FALLBACK — BY CONSTRUCTION, NOT BY AN ASSUMPTION ABOUT SLACK'S OWN
36
+ // BEHAVIOUR. Whether an app WITHOUT `chat:write.customize` gets `username` silently ignored or the
37
+ // WHOLE post refused (`missing_scope`) is a Slack-side behaviour this codebase has not measured
38
+ // against a real unscoped app — asserting one over the other would be an unmeasured vendor claim
39
+ // baked into an availability-critical path. {@link postSlackMessage} makes the answer not matter: a
40
+ // username-bearing post that comes back non-`ok` is retried EXACTLY ONCE with the tier-signal params
41
+ // stripped, so a missing scope costs only the bracket, never the reply. A genuine failure (a
42
+ // non-scope error, or a failure on the retry too) still throws — the fallback only ever widens what
43
+ // counts as "the reply landed", never swallows a real one.
44
+ //
45
+ // PURE core: {@link slackReplierParams} decides WHAT to send; {@link postSlackMessage} is the thin
46
+ // impure shell (resolve the bot's own name, dispatch, retry-on-reject). The `post` seam is injected
47
+ // so the retry behaviour is unit-testable against a SCRIPTED bridge, without a live Slack token.
48
+
49
+ /** Pure: the extra `chat.postMessage` params for a worker-turn reply. `username` is set to
50
+ * "<agentName> [<taskLabel>]" only when BOTH a resolvable agent name and a replier label are
51
+ * present; `{}` (no override — the bot's own default name renders, bare) for a supervisor/ambient
52
+ * reply OR when the agent name can't be resolved — fail-open to today's behaviour, never blocks
53
+ * the post over a cosmetic name. */
54
+ export function slackReplierParams(agentName: string | undefined, replier: ReplierLabel | undefined): Record<string, string> {
55
+ if (!replier || !agentName || agentName.trim().length === 0) return {};
56
+ return { username: `${agentName} [${replier.taskLabel}]` };
57
+ }
58
+
59
+ /** The minimal shape {@link postSlackMessage} needs from a `chat.postMessage` response — the same
60
+ * fields `slackForm` already returns, narrowed so a test double doesn't need the real client. */
61
+ export interface SlackPostResult {
62
+ readonly ok?: boolean;
63
+ readonly ts?: unknown;
64
+ readonly error?: unknown;
65
+ }
66
+
67
+ /** The injectable `chat.postMessage` dispatch {@link postSlackMessage} calls — defaults to the live
68
+ * `slackForm` client; a test supplies a scripted one. */
69
+ export type SlackPostFn = (body: URLSearchParams) => Promise<SlackPostResult | undefined>;
70
+
71
+ const livePost: SlackPostFn = (body) => slackForm("chat.postMessage", body.toString());
72
+
73
+ /**
74
+ * POST `chat.postMessage` with the OPE-508 replier-tier fallback: `base` (channel/thread/text, no
75
+ * tier params) is the request that must ALWAYS have a chance to land. When `replier` resolves to
76
+ * non-empty params (a worker turn with a resolvable agent name), the FIRST attempt adds them; on
77
+ * any non-`ok` response to THAT attempt, retries ONCE with `base` alone. A response that was never
78
+ * tier-bearing (no replier, or an unresolvable agent name) gets no retry — a real failure there is
79
+ * real, not scope-shaped. `post` defaults to the live Slack client; `resolveDisplayName` defaults
80
+ * to the live `botDisplayName()`; a test injects scripted versions of both (the live
81
+ * `botDisplayName` reaches its own module-level memoized Slack call, which a `post`-only test
82
+ * double can't reach — see slack-replier-label-test.ts).
83
+ */
84
+ export async function postSlackMessage(
85
+ base: URLSearchParams,
86
+ replier: ReplierLabel | undefined,
87
+ errPrefix: string,
88
+ post: SlackPostFn = livePost,
89
+ resolveDisplayName: () => Promise<string | undefined> = botDisplayName,
90
+ ): Promise<string> {
91
+ let usedReplierParams = false;
92
+ let attempt = base;
93
+ if (replier) {
94
+ const name = await resolveDisplayName().catch(() => undefined);
95
+ const params = slackReplierParams(name, replier);
96
+ if (Object.keys(params).length > 0) {
97
+ attempt = new URLSearchParams(base);
98
+ for (const [k, v] of Object.entries(params)) attempt.set(k, v);
99
+ usedReplierParams = true;
100
+ }
101
+ }
102
+ const res = await post(attempt);
103
+ const ts = typeof res?.ts === "string" ? res.ts : undefined;
104
+ if (res?.ok === true && ts) return ts;
105
+ if (usedReplierParams) {
106
+ // A rejected tier-signal param must never cost the reply itself — retry bare, exactly once.
107
+ const retry = await post(base);
108
+ const retryTs = typeof retry?.ts === "string" ? retry.ts : undefined;
109
+ if (retry?.ok === true && retryTs) return retryTs;
110
+ throw classifiedPostError(retry, `${errPrefix} — ${(retry?.error as string) ?? (res?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
111
+ }
112
+ throw classifiedPostError(res, `${errPrefix} — ${(res?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
113
+ }
114
+
115
+ /**
116
+ * The failure error for a `chat.postMessage` that did not return a usable ts, CLASSIFIED (OPE-821).
117
+ *
118
+ * `ok:false` IS DEFINITIVE, AND ONLY THAT. Slack answers HTTP 200 with `ok:false` when it read the
119
+ * request and refused it (`channel_not_found`, `not_in_channel`, `invalid_auth`) — nothing reached
120
+ * the room, so the post spine may release its dedupe claim and let an identical retry through. There
121
+ * is no HTTP status to carry that fact, so the arm states it (channels/platform-failure.ts).
122
+ *
123
+ * 🔴 `ok:true` WITH NO `ts` IS NOT DEFINITIVE, and the distinction is the whole point. Slack saying
124
+ * "ok" means the message DID post; a missing `ts` then means this host cannot name it, not that the
125
+ * room is empty. Marking that case would tell the spine to let a retry through and put the message
126
+ * in the room twice. A transport throw from `post()` never reaches here at all and stays AMBIGUOUS,
127
+ * which is the honest answer: a request that got no reply may still have applied.
128
+ */
129
+ function classifiedPostError(res: SlackPostResult | undefined, message: string): Error {
130
+ const err = new Error(message);
131
+ return res?.ok === false ? markNotApplied(err) : err;
132
+ }
133
+
134
+ /** MID-TURN: post via chat.postMessage (returns `ts`), thread-anchored when the trigger was threaded. */
135
+ async function dispatchSlackReply(target: SlackTarget, text: string, replier?: ReplierLabel): Promise<string> {
136
+ const body = new URLSearchParams({ channel: target.channelId, text: markdownToSlackMrkdwn(text) });
137
+ if (target.threadTs) body.set("thread_ts", target.threadTs);
138
+ return postSlackMessage(body, replier, "reply couldn't post to Slack");
139
+ }
140
+
141
+ /**
142
+ * The `thread_ts` a unified reply posts under, or undefined for a top-level post. PURE, and the ONE
143
+ * statement of Slack's reply placement:
144
+ * • `threadTs` — the trigger was already in a thread, so the answer goes in that thread.
145
+ * • `replyAnchorTs` — the trigger was a top-level channel message, so the answer opens a thread on
146
+ * it (OPE-759; slack-events.ts mints the anchor for an addressed mention AND an ambient wake).
147
+ * • neither — a post with no triggering message (a routine fire, a webhook post-back). It lands
148
+ * top-level, which is the right placement for a message that answers nothing.
149
+ * Exported so the placement is pinned by a test rather than by reading one `??` inside an
150
+ * un-injectable network call (scripts/slack-replier-label-test.ts).
151
+ */
152
+ export function slackThreadTarget(origin: Pick<SlackOrigin, "threadTs" | "replyAnchorTs">): string | undefined {
153
+ const anchor = origin.threadTs ?? origin.replyAnchorTs;
154
+ return anchor && anchor.length > 0 ? anchor : undefined;
155
+ }
156
+
157
+ /** UNIFIED: post into the wake origin's channel/thread (planSupervisorReply's exact semantics). */
158
+ async function liveSlackPost(origin: SlackOrigin, text: string, replier?: ReplierLabel): Promise<string> {
159
+ const body = new URLSearchParams({ channel: origin.channelId, text: markdownToSlackMrkdwn(text) });
160
+ const threadTarget = slackThreadTarget(origin);
161
+ if (threadTarget) body.set("thread_ts", threadTarget);
162
+ return postSlackMessage(body, replier, "unified reply couldn't post to Slack");
163
+ }
164
+
165
+ /**
166
+ * UNIFIED, WITH BUTTONS (OPE-1720) — `liveSlackPost` plus one `actions` block, so an interactive card
167
+ * can reach a unified origin (a DM seat, a single-window channel) instead of only a DM.
168
+ *
169
+ * IDENTICAL GEOMETRY TO THE PLAIN POST: same channel, same `thread_ts` resolution, same replier
170
+ * fallback through {@link postSlackMessage} — the ONLY difference is the `blocks` field, so a card
171
+ * lands exactly where the same reply would have. An `actions` list that renders to NOTHING (every
172
+ * entry over Slack's caps) posts the plain message rather than a `blocks` array with an empty row.
173
+ */
174
+ async function liveSlackPostWithActions(origin: SlackOrigin, text: string, actions: readonly DirectMessageAction[], replier?: ReplierLabel): Promise<string> {
175
+ return postSlackMessage(slackUnifiedActionForm(origin, text, actions), replier, "unified reply couldn't post to Slack");
176
+ }
177
+
178
+ /**
179
+ * PURE: the exact `chat.postMessage` form a unified card post sends. Split out from the dispatch so
180
+ * the BLOCK PAYLOAD itself is pinned by a contract test with no Slack token and no transport — the
181
+ * shape is the contract, and a shape Slack rejects costs the room the whole message, not just the
182
+ * buttons.
183
+ */
184
+ export function slackUnifiedActionForm(origin: Pick<SlackOrigin, "channelId" | "threadTs" | "replyAnchorTs">, text: string, actions: readonly DirectMessageAction[]): URLSearchParams {
185
+ const rendered = markdownToSlackMrkdwn(text);
186
+ const body = new URLSearchParams({ channel: origin.channelId, text: rendered });
187
+ const threadTarget = slackThreadTarget(origin);
188
+ if (threadTarget) body.set("thread_ts", threadTarget);
189
+ const actionBlock = slackDirectMessageActionBlock(actions);
190
+ if (actionBlock !== undefined) {
191
+ // The text block rides ALONGSIDE the buttons, for the reason `deliverSlackDirectMessage` states:
192
+ // once `blocks` is present `text` is only the notification fallback, so without the section the
193
+ // room would see a row of buttons over an empty message.
194
+ body.set("blocks", JSON.stringify([{ text: { text: rendered, type: "mrkdwn" }, type: "section" }, actionBlock]));
195
+ }
196
+ return body;
197
+ }
198
+
199
+ /**
200
+ * THE COMPONENT PLANE (OPE-1060 S3) — the `actions` a {@link DirectMessageRequest} carries, rendered
201
+ * as ONE Slack `actions` block of buttons. Shared since OPE-1720 with the unified post above, so a
202
+ * bridged approval card and a DM card cannot drift in how a button is built or capped.
203
+ *
204
+ * 🔴 THE TEXT STILL STANDS ON ITS OWN. `post-arms.ts` states the contract: an arm with no component
205
+ * plane delivers the text alone and never fakes the buttons, so every caller's copy already says what
206
+ * the answers ARE in words. These buttons are a shortcut over that copy, never a replacement for it —
207
+ * which is also why the `text` field below stays the full message rather than a placeholder.
208
+ *
209
+ * 🔴 THE ACTION ID IS THE WHOLE DECISION AND THE LABEL IS HOST COPY. Neither is model-authored: the
210
+ * caller composes both (`access/credential-grant-card.ts`), and this arm only transports them. Slack
211
+ * caps an `action_id` at 255 characters and a button label at 75; an id or a label past its cap makes
212
+ * Slack reject the WHOLE `chat.postMessage`, which would cost the person the message as well as the
213
+ * buttons. So an over-long action drops out and the text still goes.
214
+ *
215
+ * `intent` maps to Slack's own button styles: `reject` renders `danger`, `affirm` renders default
216
+ * (never `primary` — four buttons of which three are primary reads as no emphasis at all, and the
217
+ * grant card's three affirms are genuinely equal choices).
218
+ */
219
+ function slackDirectMessageActionBlock(actions: readonly DirectMessageAction[]): Record<string, unknown> | undefined {
220
+ const elements = actions
221
+ .filter((a) => a.id.length > 0 && a.id.length <= 255 && a.label.length > 0 && a.label.length <= 75)
222
+ .map((a) => ({
223
+ action_id: a.id,
224
+ text: { emoji: true, text: a.label, type: "plain_text" },
225
+ type: "button",
226
+ ...(a.intent === "reject" ? { style: "danger" } : {}),
227
+ }));
228
+ return elements.length > 0 ? { elements, type: "actions" } : undefined;
229
+ }
230
+
231
+ /**
232
+ * DM: open (or reuse) the 1:1 IM with a Slack member and post there (OPE-44's DM-delivery arm).
233
+ * `conversations.open` is idempotent — repeat calls return the SAME `im` channel id — so there is no
234
+ * "already open" branch to write. Needs the `im:write` bot scope on top of `chat:write`; without it
235
+ * Slack answers `missing_scope`, which this surfaces as a loud token-free throw so the governance
236
+ * arm refuses (and never falls back to rendering pane substance in the room).
237
+ *
238
+ * A request carrying `actions` also gets a row of buttons (OPE-1060 S3). The clicks come back on the
239
+ * app's interactivity URL, which the front route classifies by the action-id prefix.
240
+ */
241
+ async function deliverSlackDirectMessage(input: DirectMessageRequest): Promise<DirectMessageDelivery> {
242
+ const opened = await slackForm("conversations.open", new URLSearchParams({ users: input.principalId }).toString());
243
+ const channel = opened?.channel as { id?: unknown } | undefined;
244
+ const dmChannelId = typeof channel?.id === "string" ? channel.id : undefined;
245
+ if (opened?.ok !== true || !dmChannelId) {
246
+ throw new Error(
247
+ `couldn't open a Slack DM with the requester — ${(opened?.error as string) ?? "no channel returned (check SLACK_BOT_TOKEN and the im:write bot scope)"}`,
248
+ );
249
+ }
250
+ const rendered = markdownToSlackMrkdwn(input.text);
251
+ const actionBlock = input.actions === undefined ? undefined : slackDirectMessageActionBlock(input.actions);
252
+ const form = new URLSearchParams({ channel: dmChannelId, text: rendered });
253
+ if (actionBlock !== undefined) {
254
+ // The text block rides ALONGSIDE the buttons. `text` alone is only the notification fallback once
255
+ // `blocks` is present, so without this section the person's Slack client would show a row of
256
+ // buttons over an empty message.
257
+ form.set("blocks", JSON.stringify([{ text: { text: rendered, type: "mrkdwn" }, type: "section" }, actionBlock]));
258
+ }
259
+ const posted = await slackForm("chat.postMessage", form.toString());
260
+ const ts = typeof posted?.ts === "string" ? posted.ts : undefined;
261
+ if (posted?.ok !== true || !ts) {
262
+ throw new Error(`couldn't post the Slack DM — ${(posted?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
263
+ }
264
+ return { surfaceId: dmChannelId, messageId: ts };
265
+ }
266
+
267
+ /**
268
+ * EDIT (OPE-793): rewrite one of our own posted messages via `chat.update` (channel + ts + text).
269
+ * Slack renders an "(edited)" marker on the message, so the room sees that it changed.
270
+ *
271
+ * Ownership is NOT decided here — channels/reply-edit.ts already refused anything outside the
272
+ * own-post ledger before this runs. Slack's own rule (an app may only update a message its own token
273
+ * posted) is the backstop under that, and its refusal arrives as a token-free throw the model reads.
274
+ * `chat.update` needs the same `chat:write` scope the post path already has.
275
+ */
276
+ async function editSlackMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
277
+ const body = new URLSearchParams({ channel: locus.channelId, ts: messageId, text: markdownToSlackMrkdwn(text) });
278
+ const res = await slackForm("chat.update", body.toString());
279
+ if (res?.ok !== true) {
280
+ // DEFINITIVE (OPE-802): `chat.update` answers HTTP 200 with `ok:false`, so Slack read the request
281
+ // and decided against it — the message did not change. There is no status to carry that, so the
282
+ // arm says it. A transport throw from `slackForm` carries neither and stays AMBIGUOUS, which is
283
+ // the honest answer: a request that never got a reply may still have applied.
284
+ throw markNotApplied(
285
+ new Error(`edit_reply couldn't update the Slack message — ${(res?.error as string) ?? "chat.update returned no ok (check SLACK_BOT_TOKEN)"}`),
286
+ );
287
+ }
288
+ }
289
+
290
+ export const slackPostArm: PostArm = {
291
+ platform: "slack",
292
+ resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
293
+ const channelId = bag.str("channelId");
294
+ // Slack threads its replies; bag.threadTs is the anchor (undefined ⇒ top-level).
295
+ return channelId ? { platform: "slack", channelId, threadTs: bag.threadTs } : null;
296
+ },
297
+ displayNameCapable: true,
298
+ dispatchReply: (target, text, replier) => dispatchSlackReply(target as SlackTarget, text, replier),
299
+ dispatchUnified: (origin, text, deps: UnifiedReplyDeps, replier) =>
300
+ deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPost(origin as SlackOrigin, text, replier),
301
+ // OPE-1720: Slack declares the unified component plane because the tap route exists on it —
302
+ // `slack-front.ts` classifies the `oe_input:` click and `slack-approval-card.ts` settles it. The
303
+ // injected `postSlack` override is honoured VERBATIM and drops the buttons: a test that scripted a
304
+ // text-only post must not start receiving a card it never asked for.
305
+ dispatchUnifiedActions: (origin, text, actions, deps: UnifiedReplyDeps, replier) =>
306
+ deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPostWithActions(origin as SlackOrigin, text, actions, replier),
307
+ describeRedriveTarget: (origin) => {
308
+ const o = origin as SlackOrigin;
309
+ return `slack ${o.threadTs ? `thread in ${o.channelId}` : `channel ${o.channelId}`}`;
310
+ },
311
+ redriveContainerId: (origin) => (origin as SlackOrigin).channelId,
312
+ deliverDirectMessage: deliverSlackDirectMessage,
313
+ editMessage: editSlackMessage,
314
+ };