@open-everywhere/provider-slack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/slack-attachment-fetch.ts +168 -0
- package/agent/app-manifest.ts +149 -0
- package/agent/channels/slack-approval-card.ts +275 -0
- package/agent/channels/slack-attachments.ts +195 -0
- package/agent/channels/slack-commands.ts +227 -0
- package/agent/channels/slack-context.ts +185 -0
- package/agent/channels/slack-events.ts +5406 -0
- package/agent/channels/slack-front.ts +2349 -0
- package/agent/channels/slack-identity.ts +520 -0
- package/agent/channels/slack-input-response.ts +162 -0
- package/agent/channels/slack-mrkdwn.ts +61 -0
- package/agent/channels/slack-origin-codec.ts +62 -0
- package/agent/channels/slack-post-arm.ts +314 -0
- package/agent/channels/slack-read.ts +248 -0
- package/agent/channels/slack-route-hold.ts +107 -0
- package/agent/channels/slack-shortcut.ts +336 -0
- package/agent/channels/slack-signature.ts +53 -0
- package/agent/channels/slack-spend-gate.ts +62 -0
- package/agent/channels/slack-tenant-fence.ts +99 -0
- package/agent/channels/slack-wake-backscroll.ts +604 -0
- package/agent/channels/slack-web-api.ts +178 -0
- package/agent/channels/slack-webhook-verifier.ts +116 -0
- package/agent/channels/slack.ts +1266 -0
- package/agent/contributions.ts +32 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +50 -0
- package/agent/provider-events.ts +91 -0
- package/agent/register.ts +144 -0
- package/agent/surface-parity.ts +99 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
- package/dist/agent/access/slack-attachment-fetch.js +151 -0
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
- package/dist/agent/app-manifest.d.ts +15 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +141 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/slack-approval-card.d.ts +71 -0
- package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-approval-card.js +232 -0
- package/dist/agent/channels/slack-approval-card.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +66 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
- package/dist/agent/channels/slack-attachments.js +174 -0
- package/dist/agent/channels/slack-attachments.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +27 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -0
- package/dist/agent/channels/slack-commands.js +208 -0
- package/dist/agent/channels/slack-commands.js.map +1 -0
- package/dist/agent/channels/slack-context.d.ts +26 -0
- package/dist/agent/channels/slack-context.d.ts.map +1 -0
- package/dist/agent/channels/slack-context.js +178 -0
- package/dist/agent/channels/slack-context.js.map +1 -0
- package/dist/agent/channels/slack-events.d.ts +1654 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -0
- package/dist/agent/channels/slack-events.js +4240 -0
- package/dist/agent/channels/slack-events.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +387 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -0
- package/dist/agent/channels/slack-front.js +1958 -0
- package/dist/agent/channels/slack-front.js.map +1 -0
- package/dist/agent/channels/slack-identity.d.ts +245 -0
- package/dist/agent/channels/slack-identity.d.ts.map +1 -0
- package/dist/agent/channels/slack-identity.js +405 -0
- package/dist/agent/channels/slack-identity.js.map +1 -0
- package/dist/agent/channels/slack-input-response.d.ts +26 -0
- package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
- package/dist/agent/channels/slack-input-response.js +127 -0
- package/dist/agent/channels/slack-input-response.js.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.js +53 -0
- package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/slack-origin-codec.js +55 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -0
- package/dist/agent/channels/slack-post-arm.d.ts +54 -0
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/slack-post-arm.js +268 -0
- package/dist/agent/channels/slack-post-arm.js.map +1 -0
- package/dist/agent/channels/slack-read.d.ts +84 -0
- package/dist/agent/channels/slack-read.d.ts.map +1 -0
- package/dist/agent/channels/slack-read.js +205 -0
- package/dist/agent/channels/slack-read.js.map +1 -0
- package/dist/agent/channels/slack-route-hold.d.ts +34 -0
- package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
- package/dist/agent/channels/slack-route-hold.js +97 -0
- package/dist/agent/channels/slack-route-hold.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +162 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
- package/dist/agent/channels/slack-shortcut.js +262 -0
- package/dist/agent/channels/slack-shortcut.js.map +1 -0
- package/dist/agent/channels/slack-signature.d.ts +19 -0
- package/dist/agent/channels/slack-signature.d.ts.map +1 -0
- package/dist/agent/channels/slack-signature.js +50 -0
- package/dist/agent/channels/slack-signature.js.map +1 -0
- package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
- package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
- package/dist/agent/channels/slack-spend-gate.js +48 -0
- package/dist/agent/channels/slack-spend-gate.js.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.js +88 -0
- package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.js +473 -0
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +46 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
- package/dist/agent/channels/slack-web-api.js +147 -0
- package/dist/agent/channels/slack-web-api.js.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.js +112 -0
- package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/slack.d.ts +37 -0
- package/dist/agent/channels/slack.d.ts.map +1 -0
- package/dist/agent/channels/slack.js +1169 -0
- package/dist/agent/channels/slack.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +32 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +38 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +84 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +118 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +95 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +209 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,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
|
+
}
|