@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,248 @@
|
|
|
1
|
+
// Component 3 of the Slack engagement model (slack-engagement-spec.md): domain-scoped
|
|
2
|
+
// channel reads. "If a domain has 3 channels, the agent knows all 3 and can read them โ
|
|
3
|
+
// and NOTHING else." The Slack bot token is workspace-wide (it can read any channel the
|
|
4
|
+
// bot is in), so the boundary is OURS to enforce at the tool layer โ the same shape as the
|
|
5
|
+
// connector hard-gate (access/connector-scope.ts): broad credential underneath, narrow
|
|
6
|
+
// allowlist on top, FAIL CLOSED. The domain's channel list IS the allowlist.
|
|
7
|
+
//
|
|
8
|
+
// This module is the shared spine for the Slack read path (channels/channel-read.ts's SLACK
|
|
9
|
+
// adapter, behind the generic read verbs): resolve the allowed channel ids for the session, assert a
|
|
10
|
+
// requested channel is in them (or throw), and make the actual Slack Web API call. The call
|
|
11
|
+
// goes through OE's `callSlackWebApi` (channels/slack-web-api.ts) โ the ONE Slack Web API seam,
|
|
12
|
+
// which carries the 429/`Retry-After` backoff โ form-encoded with the same `SLACK_BOT_TOKEN` env
|
|
13
|
+
// fallback the Slack channel itself uses.
|
|
14
|
+
|
|
15
|
+
import { callSlackWebApi } from "./slack-web-api.js";
|
|
16
|
+
import {
|
|
17
|
+
auditChannelReadRefusal,
|
|
18
|
+
bindFrom,
|
|
19
|
+
getConfig,
|
|
20
|
+
domainForChannel,
|
|
21
|
+
resolvePartition,
|
|
22
|
+
refutedReadAuthorIds,
|
|
23
|
+
ChannelScopeError,
|
|
24
|
+
markNotApplied,
|
|
25
|
+
type BindableContext,
|
|
26
|
+
type ReadScopeAuditDeps,
|
|
27
|
+
type RenderedLine,
|
|
28
|
+
type WakeReadAddress,
|
|
29
|
+
domainRoster,
|
|
30
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
31
|
+
// The SAME per-author membership probe the wake leg uses (OPE-474) โ see the adapter's own note on
|
|
32
|
+
// why this is reused rather than re-derived. The direction is safe: slack-events.ts does not import
|
|
33
|
+
// this module, so nothing here closes a cycle.
|
|
34
|
+
import { slackAuthorIsMember, type SlackMemberProbe } from "./slack-events.js";
|
|
35
|
+
|
|
36
|
+
// ChannelScopeError now LIVES in core (channels/channel-read.ts) โ it predates the generic read
|
|
37
|
+
// verbs and the platform-parameterised assertSurfaceAllowed throws the same class, so the ONE
|
|
38
|
+
// class identity must sit on the core side of the boundary. Re-exported here so the bundle's own
|
|
39
|
+
// callers/tests keep their import site.
|
|
40
|
+
export { ChannelScopeError };
|
|
41
|
+
|
|
42
|
+
// A fully-qualified surface key is `platform/workspace/surface`; for Slack the surface
|
|
43
|
+
// segment IS the channel id. Returns null for non-Slack keys (defensive โ domains can span
|
|
44
|
+
// platforms in principle, and we only gate Slack here).
|
|
45
|
+
function channelIdFromKey(key: string): string | null {
|
|
46
|
+
const parts = key.split("/");
|
|
47
|
+
if (parts.length !== 3 || parts[0] !== "slack") return null;
|
|
48
|
+
return parts[2] || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Slack channel ids this session may read: every Slack channel in its explicit domain,
|
|
53
|
+
* PLUS its own current channel (always readable โ it's where the agent was engaged). A DM,
|
|
54
|
+
* an unassigned channel, or a criteria pseudo-domain (org/public/dm โ no channel list)
|
|
55
|
+
* yields just the current channel. Empty only when the surface isn't a Slack channel.
|
|
56
|
+
*/
|
|
57
|
+
export function allowedChannelIds(ctx: BindableContext): { ids: Set<string>; domain?: string } {
|
|
58
|
+
const p = resolvePartition(ctx);
|
|
59
|
+
const ids = new Set<string>();
|
|
60
|
+
const own = channelIdFromKey(p.surfaceKey);
|
|
61
|
+
if (own) ids.add(own); // the agent can always read its own channel
|
|
62
|
+
const domain = domainForChannel(p.surfaceKey, getConfig().loadDomains());
|
|
63
|
+
if (domain) {
|
|
64
|
+
for (const key of domainRoster(domain)) {
|
|
65
|
+
const id = channelIdFromKey(key);
|
|
66
|
+
if (id) ids.add(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { ids, domain: domain?.name };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Assert a requested channel id is readable by this session โ else AUDIT the refusal and throw
|
|
74
|
+
* (fail closed). The error names the domain so the model gets an actionable message, not a bare
|
|
75
|
+
* denial. The decision is unchanged from the silent version; only the row is new
|
|
76
|
+
* (core channels/read-scope-audit.ts).
|
|
77
|
+
*/
|
|
78
|
+
export async function assertChannelAllowed(ctx: BindableContext, channelId: string, deps?: ReadScopeAuditDeps): Promise<void> {
|
|
79
|
+
const { ids, domain } = allowedChannelIds(ctx);
|
|
80
|
+
if (ids.has(channelId)) return;
|
|
81
|
+
const p = resolvePartition(ctx);
|
|
82
|
+
await auditChannelReadRefusal(
|
|
83
|
+
ctx,
|
|
84
|
+
{ platform: "slack", surfaceId: channelId, callerKey: p.surfaceKey, callerDomain: domain, domains: getConfig().loadDomains() },
|
|
85
|
+
deps,
|
|
86
|
+
);
|
|
87
|
+
const scope = domain ? `domain "${domain}"` : "this channel's (no multi-channel domain)";
|
|
88
|
+
throw new ChannelScopeError(
|
|
89
|
+
`channel "${channelId}" is not in ${scope} read scope. The agent may only read channels in its ` +
|
|
90
|
+
`memory domain (memory/config/domains.ts). Add the channel to the domain's \`channels\` to grant it.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface SlackReadResponse {
|
|
95
|
+
readonly ok: boolean;
|
|
96
|
+
readonly error?: string;
|
|
97
|
+
readonly [key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Low-level POST to a (read-only) Slack Web API method with the bot token, via OE's
|
|
102
|
+
* `callSlackWebApi` (form-encoded โ Slack's JSON support is partial: `conversations.replies`/
|
|
103
|
+
* `history` want form). Throws on a missing token or a non-ok Slack response so the tool
|
|
104
|
+
* surfaces a real error. The explicit env pre-check keeps the actionable "not set" message
|
|
105
|
+
* (the seam itself throws a terser "SLACK_BOT_TOKEN is required.").
|
|
106
|
+
*/
|
|
107
|
+
export async function callSlackRead(operation: string, params: Record<string, string | number | boolean>): Promise<SlackReadResponse> {
|
|
108
|
+
if (!process.env.SLACK_BOT_TOKEN) {
|
|
109
|
+
throw new Error("SLACK_BOT_TOKEN is not set โ the Slack read tools need the bot token to call the Web API.");
|
|
110
|
+
}
|
|
111
|
+
// botToken omitted โ the seam resolves it from SLACK_BOT_TOKEN (same fallback as before).
|
|
112
|
+
const json = (await callSlackWebApi({ botToken: undefined, operation, body: params })) as SlackReadResponse;
|
|
113
|
+
// DEFINITIVE (OPE-821): Slack answers HTTP 200 with `ok:false` when it READ the request and
|
|
114
|
+
// refused it, so the call had no effect. `react` is the caller that acts on this โ a refused
|
|
115
|
+
// `reactions.add` proves no reaction landed. A throw from the seam (transport, or a non-2xx)
|
|
116
|
+
// never reaches this line and stays AMBIGUOUS, which is the honest answer for a call that got no
|
|
117
|
+
// reply. Marking it here rather than in `react` keeps ONE statement of what Slack's `ok:false`
|
|
118
|
+
// means, for every method that goes through this transport.
|
|
119
|
+
if (!json.ok) throw markNotApplied(new Error(`Slack ${operation} failed: ${json.error ?? "unknown error"}`));
|
|
120
|
+
return json;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One Slack message rendered to a compact, model-friendly line. */
|
|
124
|
+
export interface RenderedMessage {
|
|
125
|
+
readonly author: string;
|
|
126
|
+
readonly ts: string;
|
|
127
|
+
readonly text: string;
|
|
128
|
+
/** The author's Slack USER id, for the OPE-474 non-member probe. Absent on a machine-authored or
|
|
129
|
+
* unattributable line. It never reaches the model โ the read verbs project author/ts/text only. */
|
|
130
|
+
readonly authorId?: string;
|
|
131
|
+
/** A machine author (`bot_id` present). Excluded from the probe: a bot is below a member but is
|
|
132
|
+
* not a person outside the org โ the same exclusion access/taint-signal.ts makes for `relay`. */
|
|
133
|
+
readonly bot?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize a raw Slack messages array (history/replies) to rendered lines, oldest-first.
|
|
138
|
+
*
|
|
139
|
+
* `author` IS UNCHANGED and stays the display string the model reads (`user`, else `bot_id`, else
|
|
140
|
+
* `(unknown)`) โ every render golden depends on it. OPE-474 adds the two GRADING fields beside it
|
|
141
|
+
* rather than reinterpreting it: `author` is chosen for legibility and cannot be parsed back into an
|
|
142
|
+
* identity, which is exactly why the read leg had nothing to probe before.
|
|
143
|
+
*
|
|
144
|
+
* `bot_id` WINS over `user` for the machine classification, even on the messages that carry both (an
|
|
145
|
+
* app posting as a bot user). That direction is fail-OPEN โ a machine is dropped from the probe and
|
|
146
|
+
* marks nothing โ which is the posture the whole author leg takes.
|
|
147
|
+
*/
|
|
148
|
+
export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {}): RenderedMessage[] {
|
|
149
|
+
if (!Array.isArray(raw)) return [];
|
|
150
|
+
const out: RenderedMessage[] = [];
|
|
151
|
+
for (const m of raw as Array<Record<string, unknown>>) {
|
|
152
|
+
if (typeof m.subtype === "string" && m.subtype.length > 0) continue; // skip joins/edits/system
|
|
153
|
+
const text = typeof m.text === "string" ? m.text : "";
|
|
154
|
+
if (!text) continue;
|
|
155
|
+
const user = typeof m.user === "string" && m.user.length > 0 ? m.user : undefined;
|
|
156
|
+
const botId = typeof m.bot_id === "string" && m.bot_id.length > 0 ? m.bot_id : undefined;
|
|
157
|
+
out.push({
|
|
158
|
+
author: (m.user as string) ?? (m.bot_id as string) ?? "(unknown)",
|
|
159
|
+
ts: (m.ts as string) ?? "",
|
|
160
|
+
text,
|
|
161
|
+
...(botId !== undefined ? { bot: true } : user !== undefined ? { authorId: user } : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// conversations.history returns newest-first; replies returns oldest-first.
|
|
165
|
+
if (opts.oldestFirst === false) out.reverse();
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- the SLACK read adapter (moved from core channels/channel-read.ts in OPE-114 stage 4) ---
|
|
170
|
+
// Registered by registerSlackProvider() via registerReadAdapter โ the generic read verbs
|
|
171
|
+
// (read_channel / read_thread) resolve it by platform string; behaviour is byte-identical to the
|
|
172
|
+
// in-core adapter (the EXACT calls the retired per-surface Slack read tools made).
|
|
173
|
+
|
|
174
|
+
const SLACK_CHANNEL_DEFAULT_LIMIT = 50;
|
|
175
|
+
const SLACK_THREAD_DEFAULT_LIMIT = 100;
|
|
176
|
+
|
|
177
|
+
/** The Slack read transport โ injectable for offline tests (defaults to the live callSlackRead). */
|
|
178
|
+
export type SlackReadCall = typeof callSlackRead;
|
|
179
|
+
|
|
180
|
+
// --- the OPE-474 non-member author leg ---------------------------------------------------------
|
|
181
|
+
//
|
|
182
|
+
// Slack is the one chat surface that can refute an author BY ITSELF: `users.info` reports
|
|
183
|
+
// `is_restricted` / `is_ultra_restricted` / `is_stranger`, which slack-events.ts's
|
|
184
|
+
// {@link slackAuthorIsMember} already folds into one TTL-cached boolean for the wake leg. The read
|
|
185
|
+
// leg reuses THAT probe verbatim โ a second implementation of "is this person a member?" is how a
|
|
186
|
+
// wake and a read come to disagree about the same author.
|
|
187
|
+
//
|
|
188
|
+
// ๐ด THE COST IS BOUNDED BY PEOPLE, NOT BY MESSAGES (Tommy, 2026-08-14: no up-front member list).
|
|
189
|
+
// `refutedReadAuthorIds` probes the page's DISTINCT authors, and the probe is TTL-cached across
|
|
190
|
+
// calls, so a 200-message read of a busy channel is a handful of authors and mostly cache hits. An
|
|
191
|
+
// up-front roster would instead size with the WORKSPACE and go stale between reads.
|
|
192
|
+
//
|
|
193
|
+
// THE THREE-WAY VERDICT IS KEPT THREE-WAY. `slackAuthorIsMember` answers `true` (member), `false`
|
|
194
|
+
// (positively refuted) or `undefined` (lookup unavailable), and only `false` refutes. Collapsing
|
|
195
|
+
// `undefined` into a refusal would mark every read on a deployment whose bot token cannot call
|
|
196
|
+
// `users.info` โ an always-on bit, arriving through this door.
|
|
197
|
+
|
|
198
|
+
/** The Slack team id + the register's arming key for this session's own workspace. Both come from
|
|
199
|
+
* the BINDING, never from inspecting an author id: the platform and the tenant are surface facts. */
|
|
200
|
+
function slackReadTenant(ctx: BindableContext): { teamId: string; tenantKey: string } {
|
|
201
|
+
const p = resolvePartition(ctx);
|
|
202
|
+
return { teamId: p.workspace, tenantKey: p.workspaceKey };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Slack read via OE's callSlackWebApi โ domain-allowlist-gated, rendered oldest-first. */
|
|
206
|
+
export const slackReadAdapter = {
|
|
207
|
+
platform: "slack" as const,
|
|
208
|
+
/**
|
|
209
|
+
* OPE-929 โ Slack's own spelling of "which room and which thread is this turn in".
|
|
210
|
+
*
|
|
211
|
+
* The CONTAINER is the channel the post-back target names. The THREAD ROOT is `threadTs`, which
|
|
212
|
+
* eve's Slack adapter falls back to the triggering message's own ts โ so a bare channel mention
|
|
213
|
+
* carries one too, and reading "this thread" on such a turn reads the one-message thread the
|
|
214
|
+
* mention started, which is what a person means by it.
|
|
215
|
+
*/
|
|
216
|
+
wakeAddress(ctx: BindableContext): WakeReadAddress | undefined {
|
|
217
|
+
const b = bindFrom(ctx);
|
|
218
|
+
const container = typeof b.target?.["channelId"] === "string" ? (b.target["channelId"] as string) : undefined;
|
|
219
|
+
if (!container) return undefined;
|
|
220
|
+
return { container, ...(b.threadTs ? { threadRoot: b.threadTs } : {}) };
|
|
221
|
+
},
|
|
222
|
+
async readChannel(ctx: BindableContext, channel: string, limit?: number, call: SlackReadCall = callSlackRead): Promise<readonly RenderedLine[]> {
|
|
223
|
+
await assertChannelAllowed(ctx, channel);
|
|
224
|
+
const res = await call("conversations.history", { channel, limit: limit ?? SLACK_CHANNEL_DEFAULT_LIMIT, inclusive: true });
|
|
225
|
+
return renderMessages(res.messages, { oldestFirst: false });
|
|
226
|
+
},
|
|
227
|
+
async readThread(ctx: BindableContext, channel: string, threadTs: string, limit?: number, call: SlackReadCall = callSlackRead): Promise<readonly RenderedLine[]> {
|
|
228
|
+
await assertChannelAllowed(ctx, channel);
|
|
229
|
+
const res = await call("conversations.replies", { channel, ts: threadTs, limit: limit ?? SLACK_THREAD_DEFAULT_LIMIT });
|
|
230
|
+
return renderMessages(res.messages, { oldestFirst: true });
|
|
231
|
+
},
|
|
232
|
+
/** OPE-474 / OPE-534. Both legs run: Slack's own `users.info` refutation, and the operator's
|
|
233
|
+
* author-trust register (which most Slack deployments leave unarmed, so it costs nothing there).
|
|
234
|
+
* It answers with the refuted AUTHOR IDS, because core reads the one answer twice โ the turn mark
|
|
235
|
+
* and the `withhold` body replacement. `probe` is injected ONLY so a test can drive the grading
|
|
236
|
+
* without a live Slack โ every live call defaults. */
|
|
237
|
+
async refutedAuthorIds(ctx: BindableContext, lines: readonly RenderedLine[], probe: SlackMemberProbe = slackAuthorIsMember): Promise<readonly string[]> {
|
|
238
|
+
const { teamId, tenantKey } = slackReadTenant(ctx);
|
|
239
|
+
return await refutedReadAuthorIds(lines, tenantKey, {
|
|
240
|
+
surfaceRefuted: async (authorId) => {
|
|
241
|
+
const member = await probe(authorId, teamId);
|
|
242
|
+
return member === undefined ? undefined : !member; // undefined stays "cannot check"
|
|
243
|
+
},
|
|
244
|
+
// No `memberships` leg: Slack has no group-rung key a message carries, so an armed register
|
|
245
|
+
// matches this surface on principal keys alone (`slack/<team>/<user>`).
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// THE ROUTE HOLD (OPE-1648) โ "keep this best-effort write alive until it lands".
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT CLOSES. Every Slack door answers its 200 IMMEDIATELY: Slack retries a delivery it does
|
|
4
|
+
// not see acked inside three seconds, and eve's own dispatcher returns the response and then
|
|
5
|
+
// finishes the turn in the route's `waitUntil`. On Vercel the serverless invocation can freeze the
|
|
6
|
+
// moment nothing is holding it, so a `void`-ed audit write races that freeze and the row never
|
|
7
|
+
// lands. Those rows are not decoration: `slack.ts`'s `auditWakeDropped` header states that the
|
|
8
|
+
// webhook function's console output never reaches `vercel logs`, so the row is the ONLY durable
|
|
9
|
+
// signal that a wake was refused.
|
|
10
|
+
//
|
|
11
|
+
// ๐ด A LATE `waitUntil` CALL IS NOT A HOLD, WHICH IS WHY THIS FILE EXISTS AT ALL. eve collects the
|
|
12
|
+
// route's background tasks in an array and, the moment the handler resolves, hands the platform
|
|
13
|
+
// `Promise.allSettled(tasks)` (`internal/nitro/routes/channel-dispatch.js`, `flushBackgroundTasks`).
|
|
14
|
+
// `allSettled` reads that array ONCE, synchronously โ so a promise pushed after the handler returned
|
|
15
|
+
// is in a list nobody is awaiting, and calling `waitUntil` from a wake route that runs after the 200
|
|
16
|
+
// buys exactly nothing. Almost every write this module holds is in that position.
|
|
17
|
+
//
|
|
18
|
+
// SO THE HOLD RIDES THE WORK THE ROUTE ALREADY REGISTERED. Every one of these writes happens inside
|
|
19
|
+
// a promise the front route handed to `waitUntil` while it was still running โ eve's own turn
|
|
20
|
+
// dispatch on the native forward, a FIFO wake, a name-drop. `slack-front.ts` wraps each of those in
|
|
21
|
+
// {@link routeHoldQuiesced} before passing it on, so the promise the platform waits on settles only
|
|
22
|
+
// after the delivery's tracked writes have. Nothing extra is registered: a delivery that queues no
|
|
23
|
+
// background work (a 401, a dropped sibling event) hands the platform nothing, exactly as before.
|
|
24
|
+
//
|
|
25
|
+
// WHY AsyncLocalStorage AND NOT A PARAMETER. The writers do not hold the route args: the mention
|
|
26
|
+
// door's row is written inside eve's `onAppMention` hook, several frames below a hand-off that
|
|
27
|
+
// discards them, and the engagement doors' rows are written from route functions that take deps.
|
|
28
|
+
// Threading a serverless-runtime concern through every wake route's signature to reach them would
|
|
29
|
+
// put it in the type of each one. The front route stows it once instead, for the extent of ONE
|
|
30
|
+
// delivery. One process serves many Slack deliveries at once, and a module global would mix
|
|
31
|
+
// delivery A's writes into delivery B's hold. Same primitive and same reasoning as
|
|
32
|
+
// slack-identity.ts's forwarded-envelope carrier, which is proven to survive the hand-off into eve's
|
|
33
|
+
// handler (slack-enterprise-rung-test.ts ยง2).
|
|
34
|
+
//
|
|
35
|
+
// ๐ด IT NEVER CHANGES A DECISION, AND IT NEVER THROWS. A hold is observability plumbing: every
|
|
36
|
+
// tracked write already swallows its own failures, and the quiesced promise settles rather than
|
|
37
|
+
// rejects, so a broken sink can never turn a drop into a turn.
|
|
38
|
+
|
|
39
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
40
|
+
|
|
41
|
+
/** What one delivery is still waiting on. */
|
|
42
|
+
interface RouteHold {
|
|
43
|
+
/** Work registered since the last drain round. Swapped out wholesale each round. */
|
|
44
|
+
pending: Promise<unknown>[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many drain rounds one quiesce gets. Each round awaits everything registered so far, so a round
|
|
49
|
+
* only follows work that another round's work started. Ten is far past any real chain (a wake queues
|
|
50
|
+
* one audit write, which queues nothing) and it bounds a writer that re-registers itself forever โ
|
|
51
|
+
* which would otherwise hold the invocation open until the platform killed it.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_DRAIN_ROUNDS = 10;
|
|
54
|
+
|
|
55
|
+
const routeHold = new AsyncLocalStorage<RouteHold>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn` as ONE delivery, so the writes below it can be tracked.
|
|
59
|
+
*
|
|
60
|
+
* ONE caller: `slack-front.ts`'s public route handler, wrapping its whole body. Do not wrap anything
|
|
61
|
+
* else; the value means "the delivery in flight right now", and a second writer would make that
|
|
62
|
+
* false.
|
|
63
|
+
*/
|
|
64
|
+
export function withSlackRouteHold<T>(fn: () => T): T {
|
|
65
|
+
return routeHold.run({ pending: [] }, fn);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Track a best-effort write on the delivery in flight, and hand it back so a caller that can also
|
|
70
|
+
* await it may.
|
|
71
|
+
*
|
|
72
|
+
* `work` MUST already swallow its own failures โ every caller here is an audit write whose contract
|
|
73
|
+
* is "never throws, never changes the decision". Nothing is caught for it: swallowing a rejection
|
|
74
|
+
* here would let a genuinely broken writer look held.
|
|
75
|
+
*
|
|
76
|
+
* Outside a delivery (a unit test calling a door directly, a future non-route caller) there is
|
|
77
|
+
* nothing to track and the work comes straight back โ so `await holdRouteWork(...)` is correct in
|
|
78
|
+
* both worlds and a caller never has to ask which one it is in.
|
|
79
|
+
*/
|
|
80
|
+
export function holdRouteWork<T>(work: Promise<T>): Promise<T> {
|
|
81
|
+
routeHold.getStore()?.pending.push(work);
|
|
82
|
+
return work;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The promise to hand the platform in place of `work`: it settles when `work` has settled AND every
|
|
87
|
+
* write {@link holdRouteWork} tracked has settled too.
|
|
88
|
+
*
|
|
89
|
+
* ๐ด CALL IT SYNCHRONOUSLY, INSIDE THE DELIVERY โ it reads the delivery's tracked set at call time
|
|
90
|
+
* and closes over it, so a call from outside one silently degrades to "just `work`". `slack-front.ts`
|
|
91
|
+
* calls it from the `waitUntil` it hands down, which is exactly that position.
|
|
92
|
+
*
|
|
93
|
+
* It never rejects: a rejected `work` is the caller's own business (eve already logs its background
|
|
94
|
+
* task failures), and a hold that rejected would turn an observability write into a route error.
|
|
95
|
+
*/
|
|
96
|
+
export function routeHoldQuiesced(work: Promise<unknown>): Promise<void> {
|
|
97
|
+
const hold = routeHold.getStore();
|
|
98
|
+
return (async () => {
|
|
99
|
+
await Promise.allSettled([work]);
|
|
100
|
+
if (!hold) return;
|
|
101
|
+
for (let round = 0; round < MAX_DRAIN_ROUNDS && hold.pending.length > 0; round++) {
|
|
102
|
+
const batch = hold.pending;
|
|
103
|
+
hold.pending = [];
|
|
104
|
+
await Promise.allSettled(batch);
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
}
|