@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,227 @@
1
+ // The Slack SLASH-COMMAND ingestion surface for "/oe" — the zero-inference control-plane
2
+ // escape hatch (backlog item 23): pure logic, instant code-rendered responses, NO model turn,
3
+ // so it keeps answering regardless of spend caps, engagement state, or a model/gateway
4
+ // outage. See agent/commands/core.ts for the full command grammar and rationale.
5
+ //
6
+ // MOUNTING CHOICE mirrors channels/webhooks.ts / channels/discord-gateway.ts: a plain
7
+ // `defineChannel` with ONE brand-new POST route, not a front over slack.ts's existing route
8
+ // (the slack-front.ts pattern). Slack's Slash Command Request URL is a SEPARATE, independently
9
+ // configurable field from the Event Subscriptions URL slack-front.ts already fronts — unlike
10
+ // Discord, which delivers everything (events, components, commands) to ONE fixed Interactions
11
+ // Endpoint URL and therefore has to intercept "/oe" inside its existing front route
12
+ // (channels/discord-front.ts). A brand-new public surface with no existing owner is exactly
13
+ // the case webhooks.ts's own header argues for the plain-route mount over forwarding.
14
+ //
15
+ // Slack signs every slash-command POST the SAME way it signs Events API deliveries (`v0=`
16
+ // HMAC-SHA256 of `v0:{timestamp}:{raw body}`, keyed by SLACK_SIGNING_SECRET, 5-minute skew) —
17
+ // `verifySlackSignature` is REUSED verbatim from channels/slack-front.ts rather than
18
+ // re-implemented. Fail-closed: no secret, a missing/malformed header, a stale timestamp, or a
19
+ // mismatch all return false → 401.
20
+ //
21
+ // Response: Slack needs an ack within 3 seconds. Every branch here is a fast, code-only path
22
+ // (parse + a couple of small store reads, or requestApproval's card post) — well inside that
23
+ // budget — so the route always answers SYNCHRONOUSLY with `response_type: "ephemeral"` JSON;
24
+ // only the invoker sees the text reply. `/oe budget raise|clear` ALSO post a SEPARATE, PUBLIC
25
+ // approval card via requestApproval (the same card the model-driven set_spend_limit/
26
+ // clear_spend_limit tools post) — the ephemeral response here is only the "card posted" ack.
27
+ //
28
+ // SPEND CAPS DELIBERATELY DO NOT GATE THIS ROUTE — see core.ts's header for why.
29
+ //
30
+ // THE TENANT FENCE DOES GATE IT (OPE-1633). The signature proves the POST came from Slack, never
31
+ // that it came from a workspace this deployment serves, so an unadmitted workspace's `/oe` is
32
+ // refused with an empty 200 and an audit row before any command runs. See the fence block in the
33
+ // route below for what such a command reached before.
34
+
35
+ import { defineChannel, POST } from "eve/channels";
36
+ import { verifySlackSignature } from "./slack-front.js";
37
+ import {
38
+ bindFrom,
39
+ runOeCommand,
40
+ parseSlackCommandText,
41
+ resolvePartition,
42
+ getOperationalStore,
43
+ admittedTenantsFromConfig,
44
+ unadmittedTenantLog,
45
+ UNADMITTED_TENANT_DROP_REASON,
46
+ type BindableContext,
47
+ } from "@open-everywhere/core/agent/adapter-api.js";
48
+ import { slackTenantAdmission } from "./slack-tenant-fence.js";
49
+
50
+ function log(msg: string): void {
51
+ console.log(`[open-everywhere:slack-commands] ${msg}`);
52
+ }
53
+
54
+ /** The public URL Slack POSTs the "/oe" slash command to (the app's Slash Command Request URL). */
55
+ export const SLACK_COMMANDS_ROUTE = "/eve/v1/slack/commands";
56
+
57
+ /** One non-empty form field, or undefined — this is untrusted form-encoded wire data (Slack's
58
+ * slash-command payload), so every field is probed rather than assumed present. */
59
+ function formField(params: URLSearchParams, name: string): string | undefined {
60
+ const v = params.get(name);
61
+ return v && v.length > 0 ? v : undefined;
62
+ }
63
+
64
+ /** Build the BindableContext the identity seam (bindFrom/bindSlack) expects from Slack's
65
+ * slash-command form fields. Slack's slash-command payload carries no `channel_type` /
66
+ * `is_private` bit (unlike an app_mention/message event) — `bindSlack`'s existing fallbacks
67
+ * already handle that (DM detection via the `channel_id`'s "D" prefix; visibility fails
68
+ * closed to private), same posture as every other under-specified Slack event.
69
+ *
70
+ * 🔴 IT DOES CARRY THE ENTERPRISE GRID RUNG (OPE-1559). Slack sends `enterprise_id` as a form field
71
+ * on a Grid, beside `team_id`, and it reaches `RawIdentity.trustEnterprise` → the declaration walk →
72
+ * `binding.declaredTrust`. That is the OPERATOR-DECLARATION half — the half `roomVouchedInternalFrom`
73
+ * reads — so a Grid declared once now answers here the same way it answers at every other door,
74
+ * instead of resolving as though the line were absent.
75
+ *
76
+ * 🔴 STATE ITS REACH EXACTLY, because this door is narrower than the wake doors. For a CHANNEL
77
+ * command it does NOT change `binding.trust`: this payload carries no `conversations.info` verdict,
78
+ * so `slackTrust(undefined, undefined, "channel")` fails closed to `external`, and `bindFrom` takes
79
+ * that raw verdict ahead of any vouch. So a channel `/oe` stays `trust: "external"` with the rung
80
+ * and without it, and the budget scope `domainNameForScope` picks is unchanged. The rung reaches
81
+ * `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), where the mapper leaves trust
82
+ * undefined and the vouch is what answers. Spread conditionally, like every other door: a
83
+ * single-workspace install stamps nothing. */
84
+ export function bindableForSlashCommand(params: URLSearchParams): BindableContext {
85
+ const enterpriseId = formField(params, "enterprise_id");
86
+ return {
87
+ channel: { kind: "slack" },
88
+ session: {
89
+ auth: {
90
+ current: {
91
+ attributes: {
92
+ team_id: formField(params, "team_id") ?? "",
93
+ channel_id: formField(params, "channel_id") ?? "",
94
+ ...(enterpriseId ? { enterprise_id: enterpriseId } : {}),
95
+ },
96
+ principalId: formField(params, "user_id") ?? "",
97
+ principalType: "slack-user",
98
+ },
99
+ },
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Make a refused slash command OBSERVABLE, the same way `slack.ts`'s `auditWakeDropped` makes a
106
+ * refused wake observable: the webhook function's console output never reaches `vercel logs`, so
107
+ * one `surface.wake_dropped` row per refusal is the only durable signal. Never throws, never
108
+ * changes the decision.
109
+ *
110
+ * 🔴 IT RETURNS ITS PROMISE, AND THE ROUTE HANDS THAT PROMISE TO `waitUntil`. This route answers
111
+ * IMMEDIATELY — Slack needs an ack inside three seconds — so a fire-and-forget write races the
112
+ * serverless freeze that follows the response, and the row this file calls "the only durable
113
+ * signal" would sometimes never land. `channels/webhooks.ts` takes the same `waitUntil` from the
114
+ * same route args for the same reason.
115
+ *
116
+ * The action is `surface.wake_dropped` rather than a new one, because the operator reads ONE
117
+ * greppable action for "a delivery reached a door and ran nothing", and `door` says which door it
118
+ * was. This row's `door` is `slash-command`; no wake was scheduled here, and none would have been —
119
+ * the `/oe` route runs no model turn at all.
120
+ */
121
+ async function auditCommandDropped(teamId: string | undefined, channelId: string | undefined, reason: string, enterpriseId?: string): Promise<void> {
122
+ try {
123
+ const p = resolvePartition({
124
+ channel: { kind: "slack" },
125
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
126
+ } as BindableContext);
127
+ await getOperationalStore().appendAudit({
128
+ ts: new Date().toISOString(),
129
+ org: p.org,
130
+ platform: p.platform,
131
+ workspace: p.workspace,
132
+ surface: p.surface,
133
+ action: "surface.wake_dropped",
134
+ detail: { reason, door: "slash-command", channel: channelId ?? null, team: teamId ?? null, enterprise: enterpriseId ?? null },
135
+ });
136
+ } catch {
137
+ /* observability only */
138
+ }
139
+ }
140
+
141
+ function ephemeralResponse(text: string): Response {
142
+ return new Response(JSON.stringify({ response_type: "ephemeral", text }), {
143
+ status: 200,
144
+ headers: { "content-type": "application/json" },
145
+ });
146
+ }
147
+
148
+ export default defineChannel({
149
+ routes: [
150
+ // `attachSession` is eve's I/O-free cancel door — taken here purely so "/oe stop" (OPE-418)
151
+ // can cancel an in-flight turn WITHOUT minting a session or calling a model. That is what
152
+ // keeps stop free and working while the scope is over its spend cap.
153
+ POST(SLACK_COMMANDS_ROUTE, async (req, { attachSession, waitUntil }) => {
154
+ const rawBody = await req.text();
155
+ if (!verifySlackSignature(rawBody, req.headers, process.env.SLACK_SIGNING_SECRET)) {
156
+ return new Response("unauthorized", { status: 401 });
157
+ }
158
+
159
+ const params = new URLSearchParams(rawBody);
160
+ const teamId = formField(params, "team_id");
161
+
162
+ // 🔴 THE TENANT FENCE (OPE-309 phase 0c / OPE-1633), and it sits ABOVE everything this route
163
+ // does. Slack signs a slash command with the APP-LEVEL signing secret, which every workspace
164
+ // that installed the app shares — so the verification above proves the POST came from Slack,
165
+ // never that it came from a workspace this deployment serves. Before this check the `/oe` door
166
+ // carried no tenant fence at all, while the mention door, the four dispatching engagement
167
+ // routes and the channel-join door all did.
168
+ //
169
+ // The DM door carries the fence too (OPE-1644): `slack-front.ts`'s `scheduleDmWake` is the
170
+ // chokepoint for a `message.im`/`message.mpim`, a DM-shaped shortcut and the mpim mention
171
+ // divert, and it consults `slackFrontTenantFenceDeclined` above the seat resolve. That path is
172
+ // WIDER than this one — it mints a DM seat holding a window token, delegation and memory.
173
+ //
174
+ // WHAT AN UNADMITTED WORKSPACE GOT BEFORE. `/oe` runs no model turn, so the exposure is not a
175
+ // turn — it is the ALIASING the fence exists for. `bindFrom` keys the invoker as
176
+ // `slack/<user id>`, and on Slack that id is issued PER WORKSPACE: a foreign `U…` that
177
+ // collides with a home member's resolves that member's standing. `/oe budget` then reports
178
+ // this deployment's spend for the scope that principal picks, and `/oe budget raise|clear`
179
+ // posts a real approval card against it. Refusing here is the same decision the wake doors
180
+ // make, one plane earlier.
181
+ //
182
+ // IT SITS ABOVE THE `/oe`-ONLY CHECK BELOW for the reason the mention door sits above the
183
+ // unmapped-channel nudge: the copy below names our own misconfiguration, and a foreign
184
+ // workspace must not be handed it.
185
+ //
186
+ // THE ANSWER IS AN EMPTY 200 — no ephemeral text. `unadmittedTenantLog`'s rule is that the
187
+ // operator gets the signal and the originating room gets nothing: we cannot safely identify
188
+ // anybody there, and a foreign tenant learning it reached a live deployment is more than it
189
+ // needs to know. The status keeps Slack from retrying; the empty body is what we choose to
190
+ // send, and what Slack renders for it is Slack's business.
191
+ //
192
+ // The audit write rides `waitUntil`, not a bare `void` — the response goes back at once and
193
+ // the invocation can freeze behind it, which would lose the row this refusal's only durable
194
+ // signal is. See {@link auditCommandDropped}.
195
+ const enterpriseId = formField(params, "enterprise_id");
196
+ if (slackTenantAdmission(teamId, enterpriseId) === "unadmitted") {
197
+ console.log(unadmittedTenantLog("slack", teamId, admittedTenantsFromConfig("slack"), enterpriseId));
198
+ waitUntil(auditCommandDropped(teamId, formField(params, "channel_id"), UNADMITTED_TENANT_DROP_REASON, enterpriseId));
199
+ return new Response(null, { status: 200 });
200
+ }
201
+
202
+ const command = formField(params, "command");
203
+ if (command !== "/oe") {
204
+ // Defensive, not a security gate: this route is dedicated to "/oe" — the Slack app's
205
+ // Slash Command config should only ever point ONE command at it. Anything else is a
206
+ // misconfiguration (a second command aimed at the same URL); answer politely instead
207
+ // of a bare 404/500 so it's obvious in Slack what to fix.
208
+ log(`unexpected slash command "${command ?? "?"}" hit the /oe route — check the Slack app's Slash Command config`);
209
+ return ephemeralResponse('This endpoint only handles "/oe" — check your Slack app\'s Slash Command configuration.');
210
+ }
211
+
212
+ const cmd = parseSlackCommandText(formField(params, "text"));
213
+ const binding = bindFrom(bindableForSlashCommand(params));
214
+ try {
215
+ const reply = await runOeCommand(cmd, binding, { attachSession });
216
+ return ephemeralResponse(reply);
217
+ } catch (err) {
218
+ // runOeCommand's budget-raise/clear branches can throw a user-facing validation error
219
+ // (e.g. proposing from a DM with no explicit scope) — surface it verbatim. Never let a
220
+ // thrown error escape as a 500; Slack's slash-command UX for that is an ugly
221
+ // "operation_timeout"/dispatch_failed toast.
222
+ log(`"/oe" command failed softly: ${String(err)}`);
223
+ return ephemeralResponse(`Couldn't complete that: ${err instanceof Error ? err.message : String(err)}`);
224
+ }
225
+ }),
226
+ ],
227
+ });
@@ -0,0 +1,185 @@
1
+ // Component 1 of the Slack engagement model:
2
+ // give the agent the SURROUNDING CONVERSATION when it's triggered, not just the bare
3
+ // @mention. Without this the agent sees only the tagged message + a `<slack_context>`
4
+ // identity block — it can't "understand what's happening in Slack" the way Claude Tag
5
+ // does. This module assembles that context and hands it back as `context` strings, which
6
+ // the Slack channel appends to session history as user messages before the delivery turn.
7
+ //
8
+ // Two shapes, chosen by where the mention landed (SlackMessage.threadTs vs .ts):
9
+ // • thread reply (threadTs ≠ ts) → prior messages IN THAT THREAD via Eve's native
10
+ // `loadThreadContextMessages`, bounded `since: "last-agent-reply"`. That boundary is
11
+ // self-correcting: when the agent has never spoken in the thread it returns ALL prior
12
+ // messages (the "tagged 10 deep, give me 1–9" case); once the agent has replied it
13
+ // returns only what's new since (the "re-tagged at 15, give me 11–14" case) — so it
14
+ // never re-injects what a durable resumed session already holds. One call, both cases.
15
+ // • channel-root mention (threadTs = ts) → a bounded backscroll of the channel via
16
+ // `conversations.history` (the "fetch 50 on join" load Claude Tag does), so a fresh
17
+ // mention in a busy channel still arrives with recent context.
18
+ //
19
+ // UNTRUSTED BY PROVENANCE: every line here was written by arbitrary workspace members, so
20
+ // the whole block is wrapped with wrapUntrusted (access/untrusted.ts) — the model must
21
+ // treat it as DATA, not instructions. This is the surface use-case that boundary exists
22
+ // for (Claude Tag wraps fetched Slack content the same way).
23
+ //
24
+ // Best-effort throughout: any Slack API failure yields [] (no context) rather than
25
+ // failing the turn — context is an enrichment, never a precondition.
26
+ //
27
+ // REUSE: the wake path (slack-wake-backscroll.ts) attaches the SAME kind of backscroll to a
28
+ // name-drop wake, but it can't use Eve's `SlackContext` handle (the wake fires from a raw
29
+ // route, not a live session) — it hits the Slack Web API directly with the bot token. So the
30
+ // message→line mapping (`linesFromMessages`) is exported here and the render+untrusted wrap
31
+ // (`renderContext`) + the `ContextLine` shape come from the SHARED context-render.ts, keeping
32
+ // BOTH Slack paths' — and the Discord fetcher's — attribution + untrusted framing byte-identical
33
+ // instead of forking a second renderer. The Slack-only bit (the read-more hint's TOOL NAMES)
34
+ // stays here and is fed into renderContext; a surface with no read tools passes no hint.
35
+
36
+ import type { SlackContext } from "eve/channels/slack";
37
+ import { loadThreadContextMessages } from "eve/channels/slack";
38
+ import type { SlackMessage } from "eve/channels/slack";
39
+ import { MAX_MESSAGES, renderContext, type ContextLine } from "@open-everywhere/core/agent/adapter-api.js";
40
+
41
+ // Re-export the shared render + line shape so slack-wake-backscroll.ts (which imports them from
42
+ // here) keeps compiling unchanged after the extraction, and any future Slack consumer has one
43
+ // import site. The mapping (`linesFromMessages`) is defined below — it's Slack-message-shaped.
44
+ export { renderContext, type ContextLine };
45
+
46
+ // When the rendered slice fills MAX_MESSAGES exactly, there's very likely more history above
47
+ // it (both fetchers below request MAX_MESSAGES-worth from Slack, so filling the cap usually
48
+ // means older messages were left out) — tell the model this is a bounded slice, not the whole
49
+ // conversation, and name the tools that can pull more. This is OUR instruction (trusted), so
50
+ // renderContext appends it OUTSIDE the untrusted-wrapped block, never inside where channel
51
+ // content lives. Passed into renderContext so the shared renderer stays platform-neutral (a surface
52
+ // with no read tools passes no hint).
53
+ //
54
+ // It names `read_channel` / `read_thread` — since OPE-1145 no per-surface reader stands beside them
55
+ // on any deployment, so the hint has nothing to branch on and names one fixed pair.
56
+ //
57
+ // 🔴 THE OPE-780 DEFECT CLASS IS NOT REACHABLE TODAY, AND NO TEST PROVES THIS FUNCTION'S SHAPE.
58
+ // OPE-780 was a `const` hint that named the tools of a deployment the seat was not on. That needed a
59
+ // CONDITIONAL toolset, and there is no longer one, so the old flip-the-flag-and-re-call test is gone
60
+ // with the flag. It stays a function as a CONVENTION — if a future change makes the mounted reader
61
+ // conditional again, a module-load `const` would reintroduce OPE-780 silently. Nothing enforces
62
+ // that; a refactor to a `const` would be green today. Say so rather than imply coverage.
63
+ export function readMoreHint(): string {
64
+ return (
65
+ "Note: the block above is the most recent messages only, capped for context size — there may " +
66
+ "be older history not shown. If you need it, fetch more with the read_channel or read_thread tool."
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Map raw Slack message objects (from `conversations.history` / `conversations.replies` — raw
72
+ * Web-API JSON, probe defensively) into {@link ContextLine}s, dropping system/subtype noise and
73
+ * empty-text messages. `ids` is the bot's resolved (userId, botId): a message authored by either
74
+ * is labelled `agent` (conversations.history/replies don't flag our own posts, so we compare
75
+ * ids). Order is PRESERVED — the caller reverses to oldest-first as needed before rendering.
76
+ * Pure, so both the mention path and the wake path share ONE mapping (no duplicated attribution).
77
+ */
78
+ export function linesFromMessages(
79
+ raw: ReadonlyArray<Record<string, unknown>>,
80
+ ids: { readonly userId?: string; readonly botId?: string },
81
+ ): ContextLine[] {
82
+ const lines: ContextLine[] = [];
83
+ for (const m of raw) {
84
+ // Skip system/subtype noise (joins, channel renames, pins, etc.) — keep plain posts.
85
+ if (typeof m.subtype === "string" && m.subtype.length > 0) continue;
86
+ const text = typeof m.text === "string" ? m.text : "";
87
+ if (!text) continue;
88
+ const user = typeof m.user === "string" ? m.user : undefined;
89
+ const botId = typeof m.bot_id === "string" ? m.bot_id : undefined;
90
+ const ts = (m.ts as string) ?? "";
91
+ const isMe = (ids.userId !== undefined && user === ids.userId) || (ids.botId !== undefined && botId === ids.botId);
92
+ if (isMe) lines.push({ senderType: "agent", ts, text });
93
+ else if (botId) lines.push({ senderType: "bot", senderId: botId, ts, text });
94
+ else if (user) lines.push({ senderType: "user", senderId: user, ts, text });
95
+ else lines.push({ senderType: "unknown", ts, text });
96
+ }
97
+ return lines;
98
+ }
99
+
100
+ // The bot's own (user_id, bot_id), resolved once via auth.test (cached). Lets the channel
101
+ // backscroll label the agent's own prior posts as `sender_type=agent` — conversations.history
102
+ // doesn't flag them, so we compare ids. Best-effort: an unresolved identity just means our own
103
+ // posts read as `bot` (still attributed), never a crash.
104
+ let botIdentityCache: Promise<{ userId?: string; botId?: string }> | undefined;
105
+
106
+ async function botIdentity(ctx: SlackContext): Promise<{ userId?: string; botId?: string }> {
107
+ if (!botIdentityCache) {
108
+ botIdentityCache = (async () => {
109
+ try {
110
+ const res = await ctx.slack.request("auth.test", {});
111
+ if (res.ok) {
112
+ const r = res as unknown as { user_id?: string; bot_id?: string };
113
+ return { userId: r.user_id, botId: r.bot_id };
114
+ }
115
+ } catch {
116
+ /* fall through to empty */
117
+ }
118
+ return {};
119
+ })();
120
+ }
121
+ return botIdentityCache;
122
+ }
123
+
124
+ /** Reset the cached bot identity (tests only). */
125
+ export function _resetBotIdentityCache(): void {
126
+ botIdentityCache = undefined;
127
+ }
128
+
129
+ // Prior messages in the triggering THREAD, bounded since the agent's last reply.
130
+ async function threadContext(ctx: SlackContext, message: SlackMessage): Promise<string[]> {
131
+ try {
132
+ const msgs = await loadThreadContextMessages(
133
+ ctx.thread,
134
+ { ts: message.ts, threadTs: message.threadTs },
135
+ { since: "last-agent-reply" },
136
+ );
137
+ const lines: ContextLine[] = msgs.map((m) => {
138
+ // Eve's thread handle already flags the agent's own posts via `isMe`.
139
+ if (m.isMe) return { senderType: "agent" as const, ts: m.ts, text: m.markdown || m.text };
140
+ if (m.botId) return { senderType: "bot" as const, senderId: m.botId, ts: m.ts, text: m.markdown || m.text };
141
+ if (m.user) return { senderType: "user" as const, senderId: m.user, ts: m.ts, text: m.markdown || m.text };
142
+ return { senderType: "unknown" as const, ts: m.ts, text: m.markdown || m.text };
143
+ });
144
+ const entry = renderContext(lines, `slack thread ${message.channelId}/${message.threadTs}`, { readMoreHint: readMoreHint() });
145
+ return entry ? [entry] : [];
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
150
+
151
+ // A bounded backscroll of the CHANNEL (top-level mention, no thread). Uses the raw Web API
152
+ // since Eve's thread handle only covers replies. We read strictly older than the mention.
153
+ async function channelBackscroll(ctx: SlackContext, message: SlackMessage): Promise<string[]> {
154
+ try {
155
+ const res = await ctx.slack.request("conversations.history", {
156
+ channel: message.channelId,
157
+ latest: message.ts,
158
+ inclusive: false,
159
+ limit: MAX_MESSAGES,
160
+ });
161
+ if (!res.ok || !Array.isArray((res as { messages?: unknown }).messages)) return [];
162
+ const raw = (res as unknown as { messages: Array<Record<string, unknown>> }).messages;
163
+ // conversations.history doesn't flag our own posts, so resolve the bot's ids and compare.
164
+ const { userId: meUser, botId: meBot } = await botIdentity(ctx);
165
+ const lines = linesFromMessages(raw, { userId: meUser, botId: meBot });
166
+ // conversations.history returns newest-first; renderContext re-orders to oldest-first.
167
+ lines.reverse();
168
+ const entry = renderContext(lines, `slack channel ${message.channelId}`, { readMoreHint: readMoreHint() });
169
+ return entry ? [entry] : [];
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The surrounding-conversation context for an inbound Slack mention, as `context` strings
177
+ * for the channel's `SlackMentionResult`. Empty array = nothing to add (or a lookup failed —
178
+ * always non-fatal). Threaded mention → thread context; channel-root mention → backscroll.
179
+ */
180
+ export async function loadConversationContext(ctx: SlackContext, message: SlackMessage): Promise<string[]> {
181
+ // threadTs === ts ⇒ top-level channel message; otherwise a reply inside a thread.
182
+ return message.threadTs && message.threadTs !== message.ts
183
+ ? threadContext(ctx, message)
184
+ : channelBackscroll(ctx, message);
185
+ }