@open-everywhere/provider-slack 0.4.0 → 0.6.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 (76) hide show
  1. package/agent/app-manifest.ts +85 -8
  2. package/agent/channels/slack-events.ts +25 -0
  3. package/agent/channels/slack-front.ts +275 -2
  4. package/agent/channels/slack-home.ts +546 -0
  5. package/agent/channels/slack-memory-canvas.ts +382 -0
  6. package/agent/channels/slack-offboarding.ts +89 -0
  7. package/agent/channels/slack-origin-codec.ts +8 -0
  8. package/agent/channels/slack-side-panel.ts +104 -0
  9. package/agent/channels/slack-step-label.ts +177 -0
  10. package/agent/channels/slack.ts +29 -16
  11. package/agent/docs-generated.ts +1 -1
  12. package/agent/ops.ts +8 -0
  13. package/agent/provider-events.ts +1 -1
  14. package/agent/register.ts +15 -0
  15. package/agent/setup/page.ts +3 -1
  16. package/agent/slack-as-you.ts +107 -0
  17. package/agent/surface-parity.ts +3 -0
  18. package/dist/agent/app-manifest.d.ts +23 -0
  19. package/dist/agent/app-manifest.d.ts.map +1 -1
  20. package/dist/agent/app-manifest.js +64 -8
  21. package/dist/agent/app-manifest.js.map +1 -1
  22. package/dist/agent/channels/slack-events.d.ts +17 -0
  23. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-events.js +16 -0
  25. package/dist/agent/channels/slack-events.js.map +1 -1
  26. package/dist/agent/channels/slack-front.d.ts +66 -1
  27. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-front.js +188 -3
  29. package/dist/agent/channels/slack-front.js.map +1 -1
  30. package/dist/agent/channels/slack-home.d.ts +103 -0
  31. package/dist/agent/channels/slack-home.d.ts.map +1 -0
  32. package/dist/agent/channels/slack-home.js +473 -0
  33. package/dist/agent/channels/slack-home.js.map +1 -0
  34. package/dist/agent/channels/slack-memory-canvas.d.ts +65 -0
  35. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -0
  36. package/dist/agent/channels/slack-memory-canvas.js +325 -0
  37. package/dist/agent/channels/slack-memory-canvas.js.map +1 -0
  38. package/dist/agent/channels/slack-offboarding.d.ts +31 -0
  39. package/dist/agent/channels/slack-offboarding.d.ts.map +1 -0
  40. package/dist/agent/channels/slack-offboarding.js +75 -0
  41. package/dist/agent/channels/slack-offboarding.js.map +1 -0
  42. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-origin-codec.js +8 -0
  44. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  45. package/dist/agent/channels/slack-side-panel.d.ts +41 -0
  46. package/dist/agent/channels/slack-side-panel.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-side-panel.js +78 -0
  48. package/dist/agent/channels/slack-side-panel.js.map +1 -0
  49. package/dist/agent/channels/slack-step-label.d.ts +17 -0
  50. package/dist/agent/channels/slack-step-label.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-step-label.js +170 -0
  52. package/dist/agent/channels/slack-step-label.js.map +1 -0
  53. package/dist/agent/channels/slack.d.ts.map +1 -1
  54. package/dist/agent/channels/slack.js +29 -16
  55. package/dist/agent/channels/slack.js.map +1 -1
  56. package/dist/agent/docs-generated.js +1 -1
  57. package/dist/agent/docs-generated.js.map +1 -1
  58. package/dist/agent/ops.d.ts.map +1 -1
  59. package/dist/agent/ops.js +7 -1
  60. package/dist/agent/ops.js.map +1 -1
  61. package/dist/agent/provider-events.js +1 -1
  62. package/dist/agent/provider-events.js.map +1 -1
  63. package/dist/agent/register.d.ts.map +1 -1
  64. package/dist/agent/register.js +14 -1
  65. package/dist/agent/register.js.map +1 -1
  66. package/dist/agent/setup/page.d.ts.map +1 -1
  67. package/dist/agent/setup/page.js +3 -1
  68. package/dist/agent/setup/page.js.map +1 -1
  69. package/dist/agent/slack-as-you.d.ts +38 -0
  70. package/dist/agent/slack-as-you.d.ts.map +1 -0
  71. package/dist/agent/slack-as-you.js +82 -0
  72. package/dist/agent/slack-as-you.js.map +1 -0
  73. package/dist/agent/surface-parity.d.ts.map +1 -1
  74. package/dist/agent/surface-parity.js +3 -0
  75. package/dist/agent/surface-parity.js.map +1 -1
  76. package/package.json +33 -3
@@ -0,0 +1,382 @@
1
+ // THE DOMAIN MEMORY CANVAS (OPE-2451) — a domain's channels show what the domain knows, as the
2
+ // channel's own canvas (the tab Slack pins at the top of a channel), and the canvas is rewritten
3
+ // whenever a silo the domain names changes.
4
+ //
5
+ // HOW IT STAYS CURRENT. Every durable silo write passes the one write road (core
6
+ // memory/write-road.ts), which notifies the observers a surface registers. This module is Slack's
7
+ // observer: for each domain whose `memory` list names the written silo, it re-reads the domain's
8
+ // silos and rewrites the canvas of each of the domain's channels the app sits in.
9
+ //
10
+ // 🔴 THE TRUST GATE HOLDS FOR A CANVAS, AND IS NARROWER THAN THE ROOM GATE. A canvas is shown to
11
+ // everyone in the channel, so it is written ONLY into a channel `conversations.info` confirms
12
+ // internal (never a Slack Connect channel, never one whose facts could not be read) AND whose every
13
+ // member `users.info` confirms a full workspace member (never a channel with a guest, a stranger, an
14
+ // unresolvable member, or more members than one page lists). Both checks fail closed.
15
+ //
16
+ // 🔴 THE GATE ALSO TAKES A CANVAS DOWN. A canvas written while the channel passed would outlive the
17
+ // room's trust: share the channel, or add a guest, and the old canvas stays on screen. So a refresh
18
+ // that fails the gate REPLACES the app's own canvas with a notice that holds no memory, and the two
19
+ // trust events re-run the gate at once (a person joins — `member_joined_channel`; the channel is
20
+ // shared — `channel_shared`, slack-front.ts). The room verdict is read FRESH for every canvas
21
+ // decision, never from the wake path's cache, which would say "internal" for up to its TTL after a
22
+ // share. A channel whose domain stops showing a canvas gets the same take-down. Every lookup the
23
+ // take-down needs fails CLOSED to `clear-failed`. What the gate cannot do is notice a change no event
24
+ // reports, until the next memory write or trust event re-runs it (docs/capabilities/memory.md lists
25
+ // every limit).
26
+ //
27
+ // 🔴 A CANVAS THE APP DID NOT CREATE IS NEVER TOUCHED. A channel has at most one channel canvas, and
28
+ // people write their own. When the channel already has one, the app rewrites it only when
29
+ // `files.info` names the app's own bot user as its creator. A person's canvas stays theirs, and the
30
+ // refusal is logged and audited so an operator can see why a channel shows no memory.
31
+ //
32
+ // 🔴 WHAT A CANVAS DOES NOT DO. It is a READ view. Members correct or forget a line by asking the
33
+ // agent in the channel, which goes through the tools and the approval card like any other change.
34
+ // An edit made directly in the canvas is overwritten on the next memory write — the canvas says so.
35
+
36
+ import {
37
+ agentDisplayName,
38
+ appendAudit,
39
+ domainForChannel,
40
+ domainsNamingSilo,
41
+ getConfig,
42
+ readDomainMemory,
43
+ renderMemoryMarkdown,
44
+ type Domain,
45
+ type MemoryWriteEvent,
46
+ } from "@open-everywhere/core/agent/adapter-api.js";
47
+ import { confirmChannelInternalFresh, slackAuthorIsMember, slackForm } from "./slack-events.js";
48
+ import { encodeSlackForm } from "./slack-web-api.js";
49
+
50
+ /** The audit action every refresh attempt writes, one row per channel. Declared in core's registry. */
51
+ const MEMORY_CANVAS_ACTION = "memory.canvas_refresh";
52
+
53
+ /** The most channels one refresh rewrites. A domain wider than this is refreshed in its first N. */
54
+ const MEMORY_CANVAS_CHANNELS_MAX = 20;
55
+ /** How many pages of the bot's channel list a refresh walks (200 per page). */
56
+ const BOT_CHANNEL_PAGES_MAX = 5;
57
+ /** Slack caps a canvas document; the rendered memory stops well below it and says so. */
58
+ const CANVAS_MARKDOWN_MAX = 90_000;
59
+
60
+ export type CanvasOutcome =
61
+ | "created"
62
+ | "updated"
63
+ | "unchanged"
64
+ | "skipped-external"
65
+ | "skipped-guest"
66
+ | "skipped-foreign"
67
+ | "skipped-disabled"
68
+ | "cleared-external"
69
+ | "cleared-guest"
70
+ | "cleared-disabled"
71
+ | "clear-failed"
72
+ | "failed";
73
+
74
+ /** The most members a canvas channel may have and still be checked person by person (one page of
75
+ * `conversations.members`). A larger channel is refused rather than half-checked. */
76
+ const MEMBER_CHECK_MAX = 200;
77
+
78
+ export interface MemoryCanvasDeps {
79
+ /** One Slack Web API call; undefined on any failure. */
80
+ readonly call: (method: string, body: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
81
+ /** Is this channel confirmed internal? false on any doubt. 🔴 The live dep reads it FRESH (it drops
82
+ * the cached verdict first): a cached `internal` from before a share must never clear the gate. */
83
+ readonly channelInternal: (channelId: string) => Promise<boolean>;
84
+ /** Is this person a full member of the workspace? undefined when the lookup failed. The shared
85
+ * `users.info` probe (slack-events.ts `slackAuthorIsMember`). */
86
+ readonly authorIsMember: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
87
+ readonly now?: () => Date;
88
+ }
89
+
90
+ /** The live deps: every call through `slackForm` (the one Web API seam, fail-soft), the room verdict
91
+ * read FRESH, and the shared per-person membership probe. */
92
+ export function liveMemoryCanvasDeps(): MemoryCanvasDeps {
93
+ return {
94
+ call: (method, body) => slackForm(method, encodeSlackForm(body)),
95
+ channelInternal: confirmChannelInternalFresh,
96
+ authorIsMember: slackAuthorIsMember,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Make memory text safe for a canvas a whole channel reads. A remembered line is member-authored
102
+ * text, and canvas markdown turns `![](@U…)` into a mention and `<!channel>`-shaped text into a ping
103
+ * on some clients. A memory view must never notify anyone, so both forms are defused. PURE.
104
+ */
105
+ export function defuseCanvasText(markdown: string): string {
106
+ return markdown.replace(/!\[/g, "!\\[").replace(/<([@!#])/g, "‹$1");
107
+ }
108
+
109
+ /** The canvas document for one domain. PURE apart from the read the caller already did. */
110
+ function memoryCanvasMarkdown(domain: Domain, groups: Parameters<typeof renderMemoryMarkdown>[1], nowIso: string): string {
111
+ const name = agentDisplayName();
112
+ const body = renderMemoryMarkdown(`What ${name} knows — ${domain.title ?? domain.name}`, groups, nowIso);
113
+ const footer =
114
+ `\n---\n${name} keeps this canvas up to date from this domain's memory. ` +
115
+ `To correct or forget a line, ask ${name} in this channel. A direct edit to this canvas is replaced on the next memory change.\n`;
116
+ const text = defuseCanvasText(body);
117
+ const cut = text.length > CANVAS_MARKDOWN_MAX ? text.slice(0, CANVAS_MARKDOWN_MAX) + "\n\n_This canvas is full. Ask in this channel for the rest._\n" : text;
118
+ return cut + footer;
119
+ }
120
+
121
+ /** The channels the app is in that a domain owns, as `{ channelId, surfaceKey }`, capped. */
122
+ async function domainChannels(teamId: string, domains: readonly Domain[], deps: MemoryCanvasDeps): Promise<Map<string, { channelId: string; surfaceKey: string }[]>> {
123
+ const out = new Map<string, { channelId: string; surfaceKey: string }[]>();
124
+ const all = getConfig().loadDomains();
125
+ const wanted = new Set(domains.map((d) => d.name));
126
+ let cursor: string | undefined;
127
+ let total = 0;
128
+ for (let page = 0; page < BOT_CHANNEL_PAGES_MAX && total < MEMORY_CANVAS_CHANNELS_MAX; page++) {
129
+ const res = await deps.call("users.conversations", {
130
+ types: "public_channel,private_channel",
131
+ exclude_archived: true,
132
+ limit: 200,
133
+ ...(cursor ? { cursor } : {}),
134
+ });
135
+ if (res?.ok !== true || !Array.isArray(res.channels)) break;
136
+ for (const c of res.channels) {
137
+ const channelId = typeof (c as { id?: unknown })?.id === "string" ? (c as { id: string }).id : undefined;
138
+ if (!channelId) continue;
139
+ const surfaceKey = `slack/${teamId}/${channelId}`;
140
+ const owner = domainForChannel(surfaceKey, all);
141
+ if (!owner || !wanted.has(owner.name)) continue;
142
+ if (total >= MEMORY_CANVAS_CHANNELS_MAX) break;
143
+ out.set(owner.name, [...(out.get(owner.name) ?? []), { channelId, surfaceKey }]);
144
+ total++;
145
+ }
146
+ cursor = typeof (res.response_metadata as { next_cursor?: unknown })?.next_cursor === "string" ? (res.response_metadata as { next_cursor: string }).next_cursor : undefined;
147
+ if (!cursor) break;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Does every member of the channel resolve as a full workspace member? FAIL-CLOSED: a failed list, a
154
+ * channel larger than one page, or any member the probe cannot confirm answers false.
155
+ *
156
+ * 🔴 WHY THE ROOM GATE IS NOT ENOUGH HERE. `conversations.info` answers whether a channel is SHARED.
157
+ * It says nothing about who sits in it, and Slack lets a multi-channel or single-channel guest join an
158
+ * ordinary private channel (see slack-events.ts, the per-author membership probe, OPE-466). A wake in
159
+ * such a channel still reads the domain's memory — that is the room gate as it stands — but a canvas
160
+ * puts every line in front of every member at once, unasked. So the canvas asks the per-person
161
+ * question the room gate does not.
162
+ */
163
+ async function everyMemberIsFull(channelId: string, teamId: string | undefined, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<boolean> {
164
+ const res = await deps.call("conversations.members", { channel: channelId, limit: MEMBER_CHECK_MAX });
165
+ if (res?.ok !== true || !Array.isArray(res.members)) return false;
166
+ const next = (res.response_metadata as { next_cursor?: unknown } | undefined)?.next_cursor;
167
+ if (typeof next === "string" && next.length > 0) return false;
168
+ for (const m of res.members) {
169
+ if (typeof m !== "string" || m.length === 0) return false;
170
+ if (m === botUserId) continue;
171
+ if ((await deps.authorIsMember(m, teamId)) !== true) return false;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ /**
177
+ * The document that REPLACES the app's own canvas when its channel stops passing the gate. It holds no
178
+ * memory at all, by construction: it is built from the agent's name alone.
179
+ */
180
+ function withdrawnCanvasMarkdown(): string {
181
+ const name = agentDisplayName();
182
+ return (
183
+ `# ${name}'s memory canvas was withdrawn\n\n` +
184
+ `This channel is shared outside the workspace, or one of its members is not a full member, so ${name} no longer shows a domain's memory here. ` +
185
+ `The memory itself is unchanged. Members can still ask ${name} about it where the domain's memory is readable.\n`
186
+ );
187
+ }
188
+
189
+ /** Why a canvas decision withdrew (or must withdraw) the app's canvas. `disabled`: the channel no
190
+ * longer belongs to a domain that shows one (`memoryCanvas: false`, no `memory`, or no domain). */
191
+ type WithdrawReason = "external" | "guest" | "disabled";
192
+
193
+ /**
194
+ * Take the app's OWN canvas down in one channel, if it has one. FAIL-CLOSED ON EVERY LOOKUP: a failed
195
+ * `conversations.info`, a failed `files.info`, or an unknown bot identity answers `clear-failed`, never
196
+ * a skip — the caller cannot rule out that the app's canvas is still on screen, and the audit row must
197
+ * say so. `skipped-*` is kept for the two answers that PROVE nothing of ours is there: no channel
198
+ * canvas at all, or a canvas whose creator is somebody else.
199
+ */
200
+ async function withdrawIfOurs(channelId: string, reason: WithdrawReason, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<CanvasOutcome> {
201
+ const info = await deps.call("conversations.info", { channel: channelId });
202
+ if (info?.ok !== true) return "clear-failed";
203
+ const existing = canvasIdOf(info);
204
+ if (!existing) return `skipped-${reason}`;
205
+ const file = await deps.call("files.info", { file: existing });
206
+ if (file?.ok !== true) return "clear-failed";
207
+ const creator = (file.file as { user?: unknown } | undefined)?.user;
208
+ if (botUserId === undefined) return "clear-failed";
209
+ if (creator !== botUserId) return `skipped-${reason}`;
210
+ const cleared = await deps.call("canvases.edit", {
211
+ canvas_id: existing,
212
+ changes: [{ operation: "replace", document_content: { type: "markdown", markdown: withdrawnCanvasMarkdown() } }],
213
+ });
214
+ return cleared?.ok === true ? `cleared-${reason}` : "clear-failed";
215
+ }
216
+
217
+ function canvasIdOf(info: Record<string, unknown>): string | undefined {
218
+ const props = (info.channel as { properties?: { canvas?: { file_id?: unknown } } } | undefined)?.properties;
219
+ return typeof props?.canvas?.file_id === "string" ? props.canvas.file_id : undefined;
220
+ }
221
+
222
+ /**
223
+ * Write one channel's canvas — or, with `markdown: null`, only RECHECK it. Never throws.
224
+ *
225
+ * 🔴 THE GATE RUNS BEFORE EVERY DECISION, AND A FAILED GATE TAKES THE APP'S OWN CANVAS DOWN (OPE-2451,
226
+ * the brain's security review). A canvas the app wrote while the channel passed outlives the room's
227
+ * trust otherwise: once the channel is shared, or a guest joins, a refresh that merely SKIPS leaves
228
+ * every line of the domain's memory on screen for the new audience. So on either refusal the app
229
+ * replaces its own canvas with {@link withdrawnCanvasMarkdown} ({@link withdrawIfOurs}). A canvas a
230
+ * PERSON made is never touched, in either direction.
231
+ *
232
+ * `null` markdown is the recheck the trust EVENTS run: it takes the canvas down on a failed gate and
233
+ * writes nothing on a passing one (`unchanged`). `joinedUserId` is the person a join event named: they
234
+ * are probed DIRECTLY, because Slack's member list may not carry them yet when the event arrives.
235
+ */
236
+ export async function publishChannelCanvas(
237
+ channelId: string,
238
+ markdown: string | null,
239
+ botUserId: string | undefined,
240
+ deps: MemoryCanvasDeps,
241
+ teamId?: string,
242
+ joinedUserId?: string,
243
+ ): Promise<CanvasOutcome> {
244
+ const blocked: "external" | "guest" | undefined = !(await deps.channelInternal(channelId))
245
+ ? "external"
246
+ : (joinedUserId !== undefined && joinedUserId !== botUserId && (await deps.authorIsMember(joinedUserId, teamId)) !== true) ||
247
+ !(await everyMemberIsFull(channelId, teamId, botUserId, deps))
248
+ ? "guest"
249
+ : undefined;
250
+ if (blocked) return withdrawIfOurs(channelId, blocked, botUserId, deps);
251
+ if (markdown === null) return "unchanged";
252
+ const info = await deps.call("conversations.info", { channel: channelId });
253
+ if (info?.ok !== true) return "failed";
254
+ const existing = canvasIdOf(info);
255
+ if (!existing) {
256
+ const created = await deps.call("conversations.canvases.create", { channel_id: channelId, document_content: { type: "markdown", markdown } });
257
+ return created?.ok === true ? "created" : "failed";
258
+ }
259
+ // Ownership before any write: only a canvas the app's own bot user created is the app's to replace.
260
+ const file = await deps.call("files.info", { file: existing });
261
+ const creator = file?.ok === true ? (file.file as { user?: unknown } | undefined)?.user : undefined;
262
+ if (botUserId === undefined || creator !== botUserId) return "skipped-foreign";
263
+ const edited = await deps.call("canvases.edit", { canvas_id: existing, changes: [{ operation: "replace", document_content: { type: "markdown", markdown } }] });
264
+ return edited?.ok === true ? "updated" : "failed";
265
+ }
266
+
267
+ /** What woke a canvas decision: a memory write, or one of the two trust events. */
268
+ export type CanvasTrigger = "memory-write" | "member-joined" | "channel-shared";
269
+
270
+ /**
271
+ * Recheck ONE channel's canvas after a trust event (a person joined it, or it was shared). The front
272
+ * route schedules it, and it writes one `memory.canvas_refresh` row.
273
+ *
274
+ * 🔴 IT RUNS IN EVERY CHANNEL, NOT ONLY A CANVAS-SHOWING DOMAIN'S. A channel whose domain no longer
275
+ * shows a canvas — `memoryCanvas: false`, no `memory`, or a channel moved out of every domain — may
276
+ * still hold the canvas the app wrote while it did. Skipping it would leave that canvas up with no gate
277
+ * ever running on it again, so such a channel gets the clear-only path (`cleared-disabled` when the
278
+ * app's canvas was there). The cost is two Slack reads per person joining a channel with no canvas.
279
+ */
280
+ export async function recheckChannelCanvas(
281
+ input: {
282
+ readonly org: string;
283
+ readonly teamId: string;
284
+ readonly channelId: string;
285
+ readonly trigger: Exclude<CanvasTrigger, "memory-write">;
286
+ readonly joinedUserId?: string;
287
+ },
288
+ deps: MemoryCanvasDeps,
289
+ ): Promise<CanvasOutcome> {
290
+ const surfaceKey = `slack/${input.teamId}/${input.channelId}`;
291
+ const domain = domainForChannel(surfaceKey, getConfig().loadDomains());
292
+ const shows = domain !== undefined && domain.memory !== undefined && domain.memoryCanvas !== false;
293
+ const auth = await deps.call("auth.test", {});
294
+ const botUserId = auth?.ok === true && typeof auth.user_id === "string" ? auth.user_id : undefined;
295
+ const outcome = await (shows
296
+ ? publishChannelCanvas(input.channelId, null, botUserId, deps, input.teamId, input.joinedUserId)
297
+ : withdrawIfOurs(input.channelId, "disabled", botUserId, deps)
298
+ ).catch((): CanvasOutcome => "clear-failed");
299
+ if (outcome !== "unchanged" && outcome !== "skipped-disabled") {
300
+ console.log(`[open-everywhere:slack-canvas] ${input.trigger} recheck, channel ${input.channelId}: ${outcome}`);
301
+ }
302
+ await appendAudit({
303
+ org: input.org,
304
+ platform: "slack",
305
+ workspace: input.teamId,
306
+ surface: input.channelId,
307
+ action: MEMORY_CANVAS_ACTION,
308
+ detail: { domain: domain?.name ?? null, trigger: input.trigger, outcome },
309
+ }).catch(() => undefined);
310
+ return outcome;
311
+ }
312
+
313
+ /**
314
+ * Refresh every canvas a write to `event.silo` made stale. The observer the provider registers.
315
+ * A domain with `memoryCanvas: false` gets no new canvas, and the app takes its own old canvas down.
316
+ * When no domain names the silo — the common case — the refresh costs no Slack call at all.
317
+ */
318
+ export async function refreshMemoryCanvases(event: MemoryWriteEvent, deps: MemoryCanvasDeps): Promise<void> {
319
+ // Every domain naming the silo, the ones that turned their canvas OFF included: a write to such a
320
+ // domain's silo is the moment its old canvas would otherwise go stale with the new lines still
321
+ // missing — and stay up ungated. Those get the clear-only path below.
322
+ const domains = domainsNamingSilo(event.silo, getConfig().loadDomains());
323
+ if (domains.length === 0) return;
324
+ const auth = await deps.call("auth.test", {});
325
+ const teamId = auth?.ok === true && typeof auth.team_id === "string" ? auth.team_id : undefined;
326
+ const botUserId = auth?.ok === true && typeof auth.user_id === "string" ? auth.user_id : undefined;
327
+ if (!teamId) return;
328
+ const channels = await domainChannels(teamId, domains, deps);
329
+ if (channels.size === 0) return;
330
+ const nowIso = (deps.now?.() ?? new Date()).toISOString();
331
+ for (const domain of domains) {
332
+ const rooms = channels.get(domain.name);
333
+ if (!rooms || rooms.length === 0) continue;
334
+ const shows = domain.memoryCanvas !== false;
335
+ const markdown = shows ? memoryCanvasMarkdown(domain, await readDomainMemory(event.org, domain), nowIso) : "";
336
+ for (const room of rooms) {
337
+ // A throw on the clear-only path cannot rule out that the old canvas is still up, so it says so.
338
+ const outcome = await (shows ? publishChannelCanvas(room.channelId, markdown, botUserId, deps, teamId) : withdrawIfOurs(room.channelId, "disabled", botUserId, deps)).catch(
339
+ (): CanvasOutcome => (shows ? "failed" : "clear-failed"),
340
+ );
341
+ if (outcome !== "created" && outcome !== "updated") {
342
+ console.log(`[open-everywhere:slack-canvas] domain "${domain.name}" channel ${room.channelId}: ${outcome}`);
343
+ }
344
+ const [platform, workspace, surface] = room.surfaceKey.split("/");
345
+ await appendAudit({
346
+ org: event.org,
347
+ platform: platform!,
348
+ workspace: workspace!,
349
+ surface: surface!,
350
+ action: MEMORY_CANVAS_ACTION,
351
+ detail: { domain: domain.name, trigger: "memory-write", silo: event.silo, outcome, chars: markdown.length },
352
+ }).catch(() => undefined);
353
+ }
354
+ }
355
+ }
356
+
357
+ // One refresh per domain set at a time. A burst of writes (a session persisting three silos) runs
358
+ // the refresh once more after the current one, never once per write.
359
+ const running = new Map<string, { again: boolean; done: Promise<void> }>();
360
+
361
+ /** Coalesce refreshes for one org and silo: a call during a run schedules exactly one more run. */
362
+ export function coalescedRefresh(event: MemoryWriteEvent, deps: MemoryCanvasDeps): Promise<void> {
363
+ const key = `${event.org}\u0000${event.silo}`;
364
+ const current = running.get(key);
365
+ if (current) {
366
+ current.again = true;
367
+ return current.done;
368
+ }
369
+ const state: { again: boolean; done: Promise<void> } = { again: false, done: Promise.resolve() };
370
+ state.done = (async () => {
371
+ try {
372
+ do {
373
+ state.again = false;
374
+ await refreshMemoryCanvases(event, deps);
375
+ } while (state.again);
376
+ } finally {
377
+ running.delete(key);
378
+ }
379
+ })();
380
+ running.set(key, state);
381
+ return state.done;
382
+ }
@@ -0,0 +1,89 @@
1
+ // SLACK'S HALF OF AUTOMATIC OFFBOARDING (OPE-2423) — a `user_change` event whose user is `deleted`
2
+ // is a deactivation report, and this module hands it to core's gates and lever
3
+ // (identity/offboarding.ts `offboardDeactivatedAccount`).
4
+ //
5
+ // WHAT THIS MODULE OWNS, AND WHAT IT DOES NOT. It owns reading the Slack envelope and the Slack
6
+ // re-read. Every DECISION — the same-workspace gate, the tenant fence, the terminal revoke and its
7
+ // audit row — is core's, so a second surface that reports deactivations gets the same gates.
8
+ //
9
+ // THE THREE SLACK FACTS, AND WHERE EACH COMES FROM:
10
+ // • the DELIVERING workspace — the envelope's `team_id`, which the front route has already
11
+ // authenticated with the signing-secret HMAC. Never the event body's `user.team_id`.
12
+ // • the ACCOUNT's workspace — the event body's `user.team_id`. A Slack-Connect partner's user
13
+ // arrives with the partner's workspace here, and core refuses it (gate 1).
14
+ // • the CONFIRMATION — a fresh `users.info` under this deployment's own bot token. The account
15
+ // must come back `deleted: true` AND in the delivering workspace. The event body's own `deleted`
16
+ // flag only decides whether to ASK; it is never the evidence.
17
+
18
+ import { deploymentOrg, offboardDeactivatedAccount, type DeactivationConfirmation, type OffboardOutcome } from "@open-everywhere/core/agent/adapter-api.js";
19
+ import type { SlackEventEnvelope } from "./slack-events.js";
20
+
21
+ /** The facts one `user_change` envelope names. */
22
+ export interface SlackUserDeactivation {
23
+ /** The envelope's `team_id` — the workspace that delivered the event. */
24
+ readonly deliveringTeamId: string;
25
+ readonly enterpriseId?: string;
26
+ readonly userId: string;
27
+ /** The account's own workspace (`user.team_id`), as the event body names it. */
28
+ readonly userTeamId: string;
29
+ }
30
+
31
+ function str(v: unknown): string | undefined {
32
+ return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
33
+ }
34
+
35
+ /**
36
+ * PURE: the deactivation a `user_change` envelope reports, or undefined when it reports none. Only a
37
+ * `user` with `deleted: true` is a deactivation; every other profile edit (a new title, a status
38
+ * emoji) is undefined, and the front route acks it with no further work.
39
+ */
40
+ export function parseSlackUserDeactivation(env: SlackEventEnvelope): SlackUserDeactivation | undefined {
41
+ const event = env.event;
42
+ if (!event || event.type !== "user_change") return undefined;
43
+ const user = event["user"] as Record<string, unknown> | undefined;
44
+ if (!user || user["deleted"] !== true) return undefined;
45
+ const userId = str(user["id"]);
46
+ const deliveringTeamId = str(env.team_id);
47
+ if (!userId || !deliveringTeamId) return undefined;
48
+ const enterpriseId = str(env.enterprise_id);
49
+ return { deliveringTeamId, userId, userTeamId: str(user["team_id"]) ?? "", ...(enterpriseId ? { enterpriseId } : {}) };
50
+ }
51
+
52
+ /** The `users.info` call, as the bundle's Web API seam answers it. */
53
+ export type SlackUsersInfo = (userId: string) => Promise<Record<string, unknown> | undefined>;
54
+
55
+ /**
56
+ * Gate 3, the Slack way: re-read the account under this deployment's own token. Confirmed only when
57
+ * Slack says the account is `deleted` AND in `teamId`. Every other answer is a refusal with a
58
+ * token-free reason, and `missing_scope` names the scope to grant.
59
+ */
60
+ export async function confirmSlackDeactivation(userId: string, teamId: string, usersInfo: SlackUsersInfo): Promise<DeactivationConfirmation> {
61
+ const info = await usersInfo(userId);
62
+ if (info?.["ok"] !== true) {
63
+ const error = str(info?.["error"]) ?? "no response";
64
+ return {
65
+ deactivated: false,
66
+ reason: `Slack users.info refused (${error})${error === "missing_scope" ? " — grant the app the users:read bot scope" : ""}`,
67
+ };
68
+ }
69
+ const user = info["user"] as Record<string, unknown> | undefined;
70
+ if (user?.["deleted"] !== true) return { deactivated: false, reason: "Slack users.info says the account is active (a reactivation, or a report that was never true)" };
71
+ if (str(user["team_id"]) !== teamId) {
72
+ return { deactivated: false, reason: `Slack users.info places the account in workspace "${str(user["team_id"]) ?? "(none)"}", not "${teamId}"` };
73
+ }
74
+ return { deactivated: true };
75
+ }
76
+
77
+ /** Hand one deactivation to core. Never throws (core's own contract). */
78
+ export async function routeSlackUserDeactivation(d: SlackUserDeactivation, deps: { readonly usersInfo: SlackUsersInfo; readonly org?: string }): Promise<OffboardOutcome> {
79
+ return await offboardDeactivatedAccount({
80
+ org: deps.org ?? deploymentOrg(),
81
+ platform: "slack",
82
+ deliveringTenant: d.deliveringTeamId,
83
+ ...(d.enterpriseId ? { deliveringEnterprise: d.enterpriseId } : {}),
84
+ tenant: d.userTeamId,
85
+ nativeId: d.userId,
86
+ source: "slack:user_change",
87
+ confirm: () => confirmSlackDeactivation(d.userId, d.deliveringTeamId, deps.usersInfo),
88
+ });
89
+ }
@@ -55,6 +55,14 @@ export const slackWakeOriginCodec: WakeOriginCodec<SlackOrigin> = {
55
55
  // unequal on an axis that cannot differ. EMPTINESS IS ABSENCE, the third of the three reads this
56
56
  // file takes that way.
57
57
  placementRef: (origin) => (typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined),
58
+ // THE THREAD'S ROOT (OPE-2430): the parent message's ts. A threaded origin names it in `threadTs`;
59
+ // a top-level trigger that a seat was spawned for names it in `replyAnchorTs`, because the seat's
60
+ // thread hangs under that very message. A DM-family or routine origin names neither, so no done
61
+ // mark is placed there.
62
+ threadRootMessage: (origin) => {
63
+ const ts = typeof origin.threadTs === "string" && origin.threadTs.length > 0 ? origin.threadTs : typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined;
64
+ return ts === undefined ? undefined : { channelId: origin.channelId, messageId: ts };
65
+ },
58
66
  // A `dmOrigin` arm lived here (OPE-306) to mint a DM delegation anchor's reply origin from the
59
67
  // binding. OPE-368 deleted the anchor and the interface member with it — a Slack 1:1 or group DM
60
68
  // now diverts into its own first-class window (OPE-351 stages 2 + 4), so its origin is minted and
@@ -0,0 +1,104 @@
1
+ // THE SLACK SIDE PANEL (OPE-2457) — the person's own DM seat, opened beside whatever they are reading.
2
+ //
3
+ // Slack's agent experience (the `agent_view` manifest feature, docs.slack.dev/ai) shows the app's
4
+ // Messages tab in a panel the person can open from anywhere in Slack. A message the person sends there
5
+ // arrives as an ordinary `message.im`, so it wakes the SAME DM seat a DM wakes: no new seat type, no
6
+ // new session, no new grant. What the panel adds is one fact — the room the person is looking at —
7
+ // which Slack attaches to the `message.im` as `app_context` once the app subscribes to
8
+ // `app_context_changed`.
9
+ //
10
+ // 🔴 THE VIEWED ROOM IS CONTEXT, NEVER ACCESS. The seat learns the room's id in a trusted host note
11
+ // and nothing else: no history, no name, no member list. A read of that room still goes through the
12
+ // rules that already apply to the DM, so opening the panel beside a channel gives the seat none of
13
+ // that channel's reads. The note says so to the model in the same words.
14
+ //
15
+ // 🔴 THE STRICTEST DEFAULT FOR GUESTS AND EXTERNAL ROOMS. The id rides the note only when ALL hold:
16
+ // • the person is a full member of the workspace (the DM-partner probe answered `internal`);
17
+ // • the entity is a channel id of THIS workspace (its `team_id` equals the event's team);
18
+ // • Slack confirms the channel internal (`conversations.info`, the same TTL-cached, fail-closed
19
+ // verdict every wake route reads) — a Slack Connect channel, or one the app cannot see, fails it.
20
+ // Anything else passes nothing, silently: the person still gets an answer, just without the room.
21
+
22
+ /** Slack's entity type for a conversation id inside `app_context.entities`. */
23
+ export const SLACK_CHANNEL_ENTITY_TYPE = "slack#/types/channel_id";
24
+
25
+ /** A public (`C…`) or private (`G…`) channel id. A DM (`D…`) is not a room the panel adds. */
26
+ const CHANNEL_ID = /^[CG][A-Z0-9]{2,}$/u;
27
+
28
+ /** The room the person was viewing when they wrote, as Slack reported it. Unverified until
29
+ * {@link sidePanelViewedRoom} checks it. */
30
+ export interface SlackAppContextRoom {
31
+ readonly channelId: string;
32
+ /** The workspace the entity belongs to. */
33
+ readonly teamId?: string;
34
+ }
35
+
36
+ /**
37
+ * The most relevant CHANNEL entity of a `message.im`'s `app_context`, or undefined. Slack orders
38
+ * entities by relevance, so the first channel entity wins. Pure and tolerant: any other shape answers
39
+ * undefined, which is the no-context default.
40
+ */
41
+ export function parseSlackAppContext(event: Readonly<Record<string, unknown>>): SlackAppContextRoom | undefined {
42
+ const ctx = event.app_context;
43
+ if (ctx === null || typeof ctx !== "object") return undefined;
44
+ const entities = (ctx as { entities?: unknown }).entities;
45
+ if (!Array.isArray(entities)) return undefined;
46
+ for (const raw of entities) {
47
+ if (raw === null || typeof raw !== "object") continue;
48
+ const e = raw as Record<string, unknown>;
49
+ if (e.type !== SLACK_CHANNEL_ENTITY_TYPE || typeof e.value !== "string" || !CHANNEL_ID.test(e.value)) continue;
50
+ return { channelId: e.value, ...(typeof e.team_id === "string" && e.team_id.length > 0 ? { teamId: e.team_id } : {}) };
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /** What {@link sidePanelViewedRoom} needs from the wake. */
56
+ export interface SidePanelGate {
57
+ readonly room: SlackAppContextRoom | undefined;
58
+ /** The DM's own workspace. */
59
+ readonly teamId: string | undefined;
60
+ /** The DM-partner probe's verdict on the person. Only `internal` admits a room. */
61
+ readonly partnerTrust: "internal" | "external" | undefined;
62
+ /** Slack's fail-closed internal verdict for one channel. */
63
+ readonly channelInternal: (channelId: string) => Promise<boolean>;
64
+ }
65
+
66
+ /** The viewed room's id, or undefined when any gate in the module header refuses it. Never throws. */
67
+ export async function sidePanelViewedRoom(gate: SidePanelGate): Promise<string | undefined> {
68
+ const { room } = gate;
69
+ if (!room || gate.partnerTrust !== "internal") return undefined;
70
+ if (!gate.teamId || room.teamId !== gate.teamId) return undefined;
71
+ const internal = await gate.channelInternal(room.channelId).catch(() => false);
72
+ return internal ? room.channelId : undefined;
73
+ }
74
+
75
+ /**
76
+ * The trusted host note for a DM-seat wake: the viewed room, and the person's opt-out (OPE-2436), in
77
+ * FIXED words around host-checked ids — the only text the envelope's `<system-note>` may carry (see
78
+ * `WakeEnvelopeInput.systemNote`). Undefined when there is nothing to say.
79
+ */
80
+ export function slackDmSystemNote(input: {
81
+ readonly viewedRoom?: string;
82
+ /** The person's opt-out answer. `readable: false` is the fail-closed guess, and gets its own line. */
83
+ readonly personOptOut?: { readonly optedOut: boolean; readonly readable: boolean };
84
+ }): string | undefined {
85
+ const lines: string[] = [];
86
+ if (input.viewedRoom) {
87
+ lines.push(
88
+ `The person wrote this from the side panel while viewing the room ${input.viewedRoom} (Slack shows <#${input.viewedRoom}> as its link). ` +
89
+ "That is context only. It gives this conversation none of that room's access: a read of that room follows the rules that already apply here.",
90
+ );
91
+ }
92
+ if (input.personOptOut?.optedOut && input.personOptOut.readable) {
93
+ lines.push(
94
+ "This person has opted out of agent actions on their own content. Nothing from this conversation is saved to memory. " +
95
+ "Do not promise to remember anything. If they ask, tell them their opt-out is on.",
96
+ );
97
+ } else if (input.personOptOut?.optedOut) {
98
+ lines.push(
99
+ "The person's opt-out setting could not be read just now, so this turn may not be saved to memory. " +
100
+ "Do not promise to remember anything, and do not tell them their opt-out is on.",
101
+ );
102
+ }
103
+ return lines.length > 0 ? lines.join(" ") : undefined;
104
+ }