@open-everywhere/provider-slack 0.4.0 → 0.5.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 (71) hide show
  1. package/agent/app-manifest.ts +48 -7
  2. package/agent/channels/slack-events.ts +12 -0
  3. package/agent/channels/slack-front.ts +201 -2
  4. package/agent/channels/slack-home.ts +511 -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-step-label.ts +177 -0
  9. package/agent/channels/slack.ts +29 -16
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/ops.ts +5 -0
  12. package/agent/provider-events.ts +1 -1
  13. package/agent/register.ts +9 -0
  14. package/agent/setup/page.ts +3 -1
  15. package/agent/slack-as-you.ts +107 -0
  16. package/agent/surface-parity.ts +3 -0
  17. package/dist/agent/app-manifest.d.ts +11 -0
  18. package/dist/agent/app-manifest.d.ts.map +1 -1
  19. package/dist/agent/app-manifest.js +39 -7
  20. package/dist/agent/app-manifest.js.map +1 -1
  21. package/dist/agent/channels/slack-events.d.ts +8 -0
  22. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-events.js +11 -0
  24. package/dist/agent/channels/slack-events.js.map +1 -1
  25. package/dist/agent/channels/slack-front.d.ts +52 -0
  26. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  27. package/dist/agent/channels/slack-front.js +134 -2
  28. package/dist/agent/channels/slack-front.js.map +1 -1
  29. package/dist/agent/channels/slack-home.d.ts +95 -0
  30. package/dist/agent/channels/slack-home.d.ts.map +1 -0
  31. package/dist/agent/channels/slack-home.js +445 -0
  32. package/dist/agent/channels/slack-home.js.map +1 -0
  33. package/dist/agent/channels/slack-memory-canvas.d.ts +65 -0
  34. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-memory-canvas.js +325 -0
  36. package/dist/agent/channels/slack-memory-canvas.js.map +1 -0
  37. package/dist/agent/channels/slack-offboarding.d.ts +31 -0
  38. package/dist/agent/channels/slack-offboarding.d.ts.map +1 -0
  39. package/dist/agent/channels/slack-offboarding.js +75 -0
  40. package/dist/agent/channels/slack-offboarding.js.map +1 -0
  41. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  42. package/dist/agent/channels/slack-origin-codec.js +8 -0
  43. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  44. package/dist/agent/channels/slack-step-label.d.ts +17 -0
  45. package/dist/agent/channels/slack-step-label.d.ts.map +1 -0
  46. package/dist/agent/channels/slack-step-label.js +170 -0
  47. package/dist/agent/channels/slack-step-label.js.map +1 -0
  48. package/dist/agent/channels/slack.d.ts.map +1 -1
  49. package/dist/agent/channels/slack.js +29 -16
  50. package/dist/agent/channels/slack.js.map +1 -1
  51. package/dist/agent/docs-generated.js +1 -1
  52. package/dist/agent/docs-generated.js.map +1 -1
  53. package/dist/agent/ops.d.ts.map +1 -1
  54. package/dist/agent/ops.js +5 -1
  55. package/dist/agent/ops.js.map +1 -1
  56. package/dist/agent/provider-events.js +1 -1
  57. package/dist/agent/provider-events.js.map +1 -1
  58. package/dist/agent/register.d.ts.map +1 -1
  59. package/dist/agent/register.js +9 -1
  60. package/dist/agent/register.js.map +1 -1
  61. package/dist/agent/setup/page.d.ts.map +1 -1
  62. package/dist/agent/setup/page.js +3 -1
  63. package/dist/agent/setup/page.js.map +1 -1
  64. package/dist/agent/slack-as-you.d.ts +38 -0
  65. package/dist/agent/slack-as-you.d.ts.map +1 -0
  66. package/dist/agent/slack-as-you.js +82 -0
  67. package/dist/agent/slack-as-you.js.map +1 -0
  68. package/dist/agent/surface-parity.d.ts.map +1 -1
  69. package/dist/agent/surface-parity.js +3 -0
  70. package/dist/agent/surface-parity.js.map +1 -1
  71. package/package.json +27 -3
@@ -0,0 +1,511 @@
1
+ // THE SLACK APP HOME TAB (OPE-2451) — the person's own page: what the agent remembers about them,
2
+ // what their domains know, and (OPE-2456, later) their rules. Core owns the page model
3
+ // (`home/home-view.ts`); this module is the Slack half: it parses the three Slack deliveries the
4
+ // page needs, builds the VERIFIED viewer, renders Block Kit, and drives the dialogs.
5
+ //
6
+ // THE THREE DELIVERIES, all classified by the front route (slack-front.ts) and never forwarded:
7
+ // • `app_home_opened` (Events API, `tab: "home"`) → build the viewer, `views.publish` the page.
8
+ // • a `block_actions` whose action id starts `oe_home:` → a button (Export, Refresh) runs at once;
9
+ // an item's overflow choice (Edit, Forget) opens a modal.
10
+ // • a `view_submission` whose `callback_id` is {@link HOME_MODAL_CALLBACK_ID} → run the action the
11
+ // modal named, then publish the page again with a one-line notice.
12
+ //
13
+ // 🔴 THE VIEWER COMES FROM THE SIGNED ENVELOPE, NEVER FROM A FIELD WE WROTE. The page is private to
14
+ // one person because Slack shows each person their own Home tab, and that only holds if every read
15
+ // and every write is keyed to the person Slack says is looking. So the viewer is built from the
16
+ // event's `user` / the interaction's `user.id` and `team`, every time. A modal's `private_metadata`
17
+ // carries ONLY the action id and the line digest — never a user, a channel, a partition or a silo —
18
+ // and core looks the digest up inside the viewer's own memory, so a forged digest names nothing the
19
+ // clicker does not already own.
20
+ //
21
+ // 🔴 THE TRUST GATE, APPLIED TO A PERSON. A domain's memory is internal knowledge. The page lists a
22
+ // domain only when the viewer is a full member of the workspace (`users.info`, fail-closed) AND sits
23
+ // in one of the domain's channels that Slack confirms is internal (`conversations.info`, fail-closed).
24
+ // A guest, or a person whose only route into a domain is a Slack Connect channel, sees their own
25
+ // memory and no domain's.
26
+
27
+ import {
28
+ appendAudit,
29
+ bindFrom,
30
+ buildHomeView,
31
+ domainForChannel,
32
+ getConfig,
33
+ hasHomeAction,
34
+ homeDomainsFor,
35
+ resolvePartition,
36
+ runHomeAction,
37
+ SLACK_HOME_WIRE,
38
+ type BindableContext,
39
+ type HomeBlock,
40
+ type HomeDomainMembership,
41
+ type HomeViewer,
42
+ } from "@open-everywhere/core/agent/adapter-api.js";
43
+ import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
44
+ import { confirmChannelInternal, slackAuthorIsMember, slackForm } from "./slack-events.js";
45
+ import { encodeSlackForm } from "./slack-web-api.js";
46
+
47
+ // ── WIRE CONSTANTS ──────────────────────────────────────────────────────────────────────────────
48
+
49
+ /** Every Home-tab action id starts with this, so the front route can claim it without reading on. */
50
+ export const HOME_ACTION_PREFIX = SLACK_HOME_WIRE.actionPrefix;
51
+ /** The one modal the Home tab opens. Its `callback_id` is how the front route claims a submission. */
52
+ export const HOME_MODAL_CALLBACK_ID = SLACK_HOME_WIRE.modalCallbackId;
53
+ /** The block and element ids of the modal's text input (the Edit dialog). */
54
+ const EDIT_BLOCK_ID = "oe_home_edit";
55
+ const EDIT_ELEMENT_ID = "text";
56
+ /** Slack refuses a view with more than 100 blocks. The renderer stops below it and says so. */
57
+ export const SLACK_HOME_MAX_BLOCKS = 100;
58
+ /** A section block's text is capped at 3000 characters by Slack. An item is cut well below that. */
59
+ const ITEM_TEXT_MAX = 600;
60
+ /** How many of the viewer's channels the domain lookup pages through (200 per page). */
61
+ const MEMBERSHIP_PAGES_MAX = 5;
62
+
63
+ // ── PARSING (pure) ──────────────────────────────────────────────────────────────────────────────
64
+
65
+ export interface ParsedHomeOpen {
66
+ readonly teamId?: string;
67
+ readonly enterpriseId?: string;
68
+ readonly userId: string;
69
+ }
70
+
71
+ /**
72
+ * An `app_home_opened` event for the HOME tab, or undefined. The same event fires when a person
73
+ * opens the Messages tab (`tab: "messages"`), and that one is not ours to answer — publishing the
74
+ * page on every DM open would cost a full build for a tab the person is not looking at.
75
+ */
76
+ export function parseHomeOpened(env: unknown): ParsedHomeOpen | undefined {
77
+ const e = asObject(env);
78
+ const ev = asObject(e?.event);
79
+ if (e?.type !== "event_callback" || ev?.type !== "app_home_opened" || ev.tab !== "home") return undefined;
80
+ const userId = str(ev.user);
81
+ if (!userId) return undefined;
82
+ const teamId = str(e.team_id);
83
+ const enterpriseId = str(e.enterprise_id);
84
+ return { userId, ...(teamId ? { teamId } : {}), ...(enterpriseId ? { enterpriseId } : {}) };
85
+ }
86
+
87
+ export interface ParsedHomeInteraction {
88
+ readonly userId: string;
89
+ readonly teamId?: string;
90
+ readonly enterpriseId?: string;
91
+ readonly triggerId?: string;
92
+ }
93
+
94
+ export interface ParsedHomeClick extends ParsedHomeInteraction {
95
+ /** The core action id (`memory.forget`), with the wire prefix removed. */
96
+ readonly action: string;
97
+ /** The item digest an overflow choice carries. Absent on a button. */
98
+ readonly ref?: string;
99
+ }
100
+
101
+ export interface ParsedHomeSubmission extends ParsedHomeInteraction {
102
+ readonly action: string;
103
+ readonly ref?: string;
104
+ /** The Edit dialog's new text. Absent on a confirmation. */
105
+ readonly input?: string;
106
+ }
107
+
108
+ function interactionIds(p: Record<string, unknown>): ParsedHomeInteraction | undefined {
109
+ const userId = str(asObject(p.user)?.id);
110
+ if (!userId) return undefined;
111
+ const { teamId, enterpriseId } = slackInteractivityTenantIds(p);
112
+ const triggerId = str(p.trigger_id);
113
+ return { userId, ...(teamId ? { teamId } : {}), ...(enterpriseId ? { enterpriseId } : {}), ...(triggerId ? { triggerId } : {}) };
114
+ }
115
+
116
+ /**
117
+ * A Home-tab `block_actions` click, or undefined. A BUTTON names its action in the action id
118
+ * (`oe_home:memory.export`). An item's OVERFLOW names it in the chosen option's value
119
+ * (`memory.forget|<digest>`), because one overflow carries both of an item's actions.
120
+ */
121
+ export function parseHomeClick(raw: unknown): ParsedHomeClick | undefined {
122
+ const p = asObject(raw);
123
+ if (p?.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
124
+ const ids = interactionIds(p);
125
+ if (!ids) return undefined;
126
+ for (const a of p.actions) {
127
+ const act = asObject(a);
128
+ const actionId = str(act?.action_id);
129
+ if (!actionId?.startsWith(HOME_ACTION_PREFIX)) continue;
130
+ if (act?.type === "overflow") {
131
+ const value = str(asObject(act.selected_option)?.value);
132
+ const split = value ? splitActionValue(value) : undefined;
133
+ if (split) return { ...ids, ...split };
134
+ continue;
135
+ }
136
+ const action = actionId.slice(HOME_ACTION_PREFIX.length);
137
+ if (action) return { ...ids, action };
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /** A submission of the Home tab's own modal, or undefined for any other view. */
143
+ export function parseHomeSubmission(raw: unknown): ParsedHomeSubmission | undefined {
144
+ const p = asObject(raw);
145
+ if (p?.type !== "view_submission") return undefined;
146
+ const view = asObject(p.view);
147
+ if (view?.callback_id !== HOME_MODAL_CALLBACK_ID) return undefined;
148
+ const ids = interactionIds(p);
149
+ if (!ids) return undefined;
150
+ const meta = str(view.private_metadata);
151
+ const split = meta ? splitActionValue(meta) : undefined;
152
+ if (!split) return undefined;
153
+ const values = asObject(asObject(view.state)?.values);
154
+ const input = str(asObject(asObject(values?.[EDIT_BLOCK_ID])?.[EDIT_ELEMENT_ID])?.value);
155
+ return { ...ids, ...split, ...(input !== undefined ? { input } : {}) };
156
+ }
157
+
158
+ /** `action|ref` → its parts. The ref, when present, must look like a line digest. PURE. */
159
+ function splitActionValue(value: string): { action: string; ref?: string } | undefined {
160
+ const bar = value.indexOf("|");
161
+ const action = bar === -1 ? value : value.slice(0, bar);
162
+ const ref = bar === -1 ? undefined : value.slice(bar + 1);
163
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(action)) return undefined;
164
+ if (ref !== undefined && !/^[0-9a-f]{64}$/.test(ref)) return undefined;
165
+ return { action, ...(ref !== undefined ? { ref } : {}) };
166
+ }
167
+
168
+ // ── RENDERING (pure) ────────────────────────────────────────────────────────────────────────────
169
+
170
+ /** Escape the three characters Slack's mrkdwn treats as control. Memory lines are member-authored,
171
+ * and an unescaped `<!channel>` or `<@U…>` in one would ping a room from a private page. */
172
+ export function escapeSlackText(text: string): string {
173
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
174
+ }
175
+
176
+ function clip(text: string, max: number): string {
177
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
178
+ }
179
+
180
+ type SlackBlock = Record<string, unknown>;
181
+
182
+ function plain(text: string): Record<string, unknown> {
183
+ return { type: "plain_text", text: clip(text, 150), emoji: true };
184
+ }
185
+
186
+ /** One neutral block → Slack blocks. */
187
+ function renderBlock(b: HomeBlock): SlackBlock[] {
188
+ switch (b.kind) {
189
+ case "heading":
190
+ return [{ type: "header", text: plain(b.text) }];
191
+ case "text":
192
+ return [{ type: "section", text: { type: "mrkdwn", text: escapeSlackText(clip(b.text, 2900)) } }];
193
+ case "note":
194
+ return [{ type: "context", elements: [{ type: "mrkdwn", text: escapeSlackText(clip(b.text, 2900)) }] }];
195
+ case "divider":
196
+ return [{ type: "divider" }];
197
+ case "buttons":
198
+ return [
199
+ {
200
+ type: "actions",
201
+ elements: b.buttons.map((btn) => ({ type: "button", action_id: `${HOME_ACTION_PREFIX}${btn.id}`, text: plain(btn.label) })),
202
+ },
203
+ ];
204
+ case "room":
205
+ return [{ type: "context", elements: [{ type: "mrkdwn", text: `${escapeSlackText(b.text)} <#${b.room.nativeId}>` }] }];
206
+ case "item": {
207
+ const label = b.label ? `*${escapeSlackText(clip(b.label, 80))}* · ` : "";
208
+ const section: SlackBlock = { type: "section", text: { type: "mrkdwn", text: label + escapeSlackText(clip(b.text, ITEM_TEXT_MAX)) } };
209
+ if (b.ref && b.actions.length > 0) {
210
+ section.accessory = {
211
+ type: "overflow",
212
+ action_id: `${HOME_ACTION_PREFIX}item`,
213
+ options: b.actions.map((a) => ({ text: plain(a.label), value: `${a.id}|${b.ref}` })),
214
+ };
215
+ }
216
+ return [section];
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The whole Home view as Slack Block Kit. PURE.
223
+ *
224
+ * 🔴 THE 100-BLOCK CAP IS SLACK'S, AND A VIEW OVER IT IS REFUSED WHOLE. A refused publish leaves the
225
+ * person looking at a stale page with no word of why, so the renderer stops two blocks short of the
226
+ * cap and closes with a line that says the page was cut and Export carries the rest.
227
+ */
228
+ export function renderHomeView(sections: readonly { readonly blocks: readonly HomeBlock[] }[], notice?: string): { type: "home"; blocks: SlackBlock[] } {
229
+ const blocks: SlackBlock[] = [];
230
+ if (notice) blocks.push({ type: "section", text: { type: "mrkdwn", text: `:information_source: ${escapeSlackText(clip(notice, 2900))}` } });
231
+ const room = SLACK_HOME_MAX_BLOCKS - 1;
232
+ let cut = false;
233
+ outer: for (const s of sections) {
234
+ for (const b of s.blocks) {
235
+ const rendered = renderBlock(b);
236
+ if (blocks.length + rendered.length > room) {
237
+ cut = true;
238
+ break outer;
239
+ }
240
+ blocks.push(...rendered);
241
+ }
242
+ }
243
+ if (cut) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: "This page is full, so some lines are not shown. Export gives you everything in your memory." }] });
244
+ return { type: "home", blocks };
245
+ }
246
+
247
+ /** The modal an item's overflow choice opens. `edit` asks for the new text, `confirm` asks for a yes. */
248
+ function homeActionModal(input: {
249
+ readonly action: string;
250
+ readonly ref: string;
251
+ readonly kind: "edit" | "confirm";
252
+ readonly itemText: string;
253
+ readonly title: string;
254
+ readonly yes: string;
255
+ }): Record<string, unknown> {
256
+ const quoted = escapeSlackText(clip(input.itemText, 2800));
257
+ return {
258
+ type: "modal",
259
+ callback_id: HOME_MODAL_CALLBACK_ID,
260
+ private_metadata: `${input.action}|${input.ref}`,
261
+ title: plain(clip(input.title, 24)),
262
+ submit: plain(clip(input.yes, 24)),
263
+ close: plain("Cancel"),
264
+ blocks:
265
+ input.kind === "edit"
266
+ ? [
267
+ {
268
+ type: "input",
269
+ block_id: EDIT_BLOCK_ID,
270
+ label: plain("The fact, as I should remember it"),
271
+ element: { type: "plain_text_input", action_id: EDIT_ELEMENT_ID, initial_value: clip(input.itemText, 1000), max_length: 1000 },
272
+ },
273
+ { type: "context", elements: [{ type: "mrkdwn", text: "The audit log records that this line changed, by fingerprint — never the words." }] },
274
+ ]
275
+ : [
276
+ { type: "section", text: { type: "mrkdwn", text: `> ${quoted}` } },
277
+ { type: "context", elements: [{ type: "mrkdwn", text: "I remove this line from your memory everywhere it is stored. You cannot undo it." }] },
278
+ ],
279
+ };
280
+ }
281
+
282
+ // ── THE VIEWER ──────────────────────────────────────────────────────────────────────────────────
283
+
284
+ /** The Slack calls the viewer needs. Injected so the gate is tested without Slack. */
285
+ export interface SlackHomeDeps {
286
+ /** One Slack Web API call; undefined on any failure (fail-soft, like `slackForm`). */
287
+ readonly call: (method: string, body: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
288
+ /** Is this person a full member of the workspace? undefined when the lookup failed. */
289
+ readonly authorIsMember: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
290
+ /** Is this channel confirmed internal? false on any doubt. */
291
+ readonly channelInternal: (channelId: string) => Promise<boolean>;
292
+ }
293
+
294
+ /** The live deps: every call through `slackForm` (the one Web API seam, fail-soft), the shared member
295
+ * probe, and the shared fail-closed channel gate. */
296
+ export function liveSlackHomeDeps(): SlackHomeDeps {
297
+ return {
298
+ call: (method, body) => slackForm(method, encodeSlackForm(body)),
299
+ authorIsMember: slackAuthorIsMember,
300
+ channelInternal: confirmChannelInternal,
301
+ };
302
+ }
303
+
304
+ /** The BindableContext a person's own DM resolves to — the SAME shape a DM wake binds with, so the
305
+ * partition (org, workspace, surface) is the one the DM's memory was written under. */
306
+ function dmBindable(ids: { teamId?: string; userId: string; channelId: string }): BindableContext {
307
+ return {
308
+ channel: { kind: "slack" },
309
+ session: {
310
+ auth: {
311
+ current: {
312
+ attributes: { team_id: ids.teamId ?? "", channel_id: ids.channelId, channel_type: "im" },
313
+ principalId: ids.userId,
314
+ principalType: "slack-user",
315
+ },
316
+ },
317
+ },
318
+ } as BindableContext;
319
+ }
320
+
321
+ /**
322
+ * Build the VERIFIED viewer for one Slack person. Every input is from the signed envelope.
323
+ *
324
+ * • the personal partition: `conversations.open` with the person names THEIR DM with the app,
325
+ * the same `D…` their DM wakes bind to. On failure the personal section says it could not load.
326
+ * • trust: `users.info` through the shared member probe. Unresolvable ⇒ external (fail-closed).
327
+ * • domains (internal viewers only): the channels Slack lists for the person, mapped to a domain
328
+ * through the SAME router a wake uses, kept only where Slack confirms the channel internal.
329
+ */
330
+ export async function buildSlackHomeViewer(ids: ParsedHomeInteraction, deps: SlackHomeDeps): Promise<HomeViewer | undefined> {
331
+ const opened = await deps.call("conversations.open", { users: ids.userId });
332
+ const dmId = opened?.ok === true ? str(asObject(opened.channel)?.id) : undefined;
333
+ const anchor = dmBindable({ ...ids, channelId: dmId ?? "home" });
334
+ const binding = bindFrom(anchor);
335
+ if (!binding.principalId) return undefined;
336
+ const partition = resolvePartition(anchor);
337
+ const member = await deps.authorIsMember(ids.userId, ids.teamId);
338
+ const trust: "internal" | "external" = member === true ? "internal" : "external";
339
+ const memberships = trust === "internal" ? await slackDomainMemberships(ids.userId, partition.workspaceKey, deps) : [];
340
+ return {
341
+ org: partition.org,
342
+ platform: partition.platform,
343
+ workspace: partition.workspace,
344
+ principalId: binding.principalId,
345
+ ...(dmId ? { personal: partition } : {}),
346
+ trust,
347
+ domains: homeDomainsFor(trust, memberships),
348
+ };
349
+ }
350
+
351
+ /** The domains a person belongs to through an internal channel. Fail-soft: a failed page ends the walk. */
352
+ async function slackDomainMemberships(userId: string, workspaceKey: string, deps: SlackHomeDeps): Promise<HomeDomainMembership[]> {
353
+ const domains = getConfig().loadDomains();
354
+ const byDomain = new Map<string, HomeDomainMembership>();
355
+ let cursor: string | undefined;
356
+ for (let page = 0; page < MEMBERSHIP_PAGES_MAX; page++) {
357
+ const res = await deps.call("users.conversations", {
358
+ user: userId,
359
+ types: "public_channel,private_channel",
360
+ exclude_archived: true,
361
+ limit: 200,
362
+ ...(cursor ? { cursor } : {}),
363
+ });
364
+ if (res?.ok !== true || !Array.isArray(res.channels)) break;
365
+ for (const c of res.channels) {
366
+ const channelId = str(asObject(c)?.id);
367
+ if (!channelId) continue;
368
+ const surfaceKey = `${workspaceKey}/${channelId}`;
369
+ const domain = domainForChannel(surfaceKey, domains);
370
+ if (!domain || domain.memory === undefined) continue;
371
+ if (!(await deps.channelInternal(channelId))) continue;
372
+ const existing = byDomain.get(domain.name);
373
+ const room = { surfaceKey, nativeId: channelId };
374
+ byDomain.set(domain.name, existing ? { domain, rooms: [...existing.rooms, room] } : { domain, rooms: [room] });
375
+ }
376
+ cursor = str(asObject(res.response_metadata)?.next_cursor);
377
+ if (!cursor) break;
378
+ }
379
+ return [...byDomain.values()];
380
+ }
381
+
382
+ // ── THE THREE HANDLERS ──────────────────────────────────────────────────────────────────────────
383
+
384
+ /** The audit action every publish attempt writes. Declared in core's registry (operational). */
385
+ const HOME_PUBLISH_ACTION = "home.publish";
386
+
387
+ /**
388
+ * Publish the page for one viewer, with an optional notice at the top, and write one `home.publish`
389
+ * row. The row is how a reader outside Slack (the e2e board, an operator) learns the page reached a
390
+ * person: Slack offers no API to read another person's Home tab back. It carries counts and Slack's
391
+ * error code only — never a line of the page.
392
+ */
393
+ async function publishHome(userId: string, viewer: HomeViewer, deps: SlackHomeDeps, notice?: string): Promise<boolean> {
394
+ const view = renderHomeView(await buildHomeView(viewer), notice);
395
+ const res = await deps.call("views.publish", { user_id: userId, view });
396
+ const ok = res?.ok === true;
397
+ if (!ok) console.log(`[open-everywhere:slack-home] views.publish failed: ${String(res?.error ?? "no response")}`);
398
+ await appendAudit({
399
+ org: viewer.org,
400
+ platform: viewer.platform,
401
+ workspace: viewer.workspace,
402
+ surface: viewer.personal?.surface ?? "home",
403
+ principalId: viewer.principalId,
404
+ action: HOME_PUBLISH_ACTION,
405
+ detail: {
406
+ ok,
407
+ blocks: view.blocks.length,
408
+ trust: viewer.trust,
409
+ domains: viewer.domains.length,
410
+ notice: notice !== undefined,
411
+ ...(ok ? {} : { error: typeof res?.error === "string" ? res.error : "no response" }),
412
+ },
413
+ }).catch(() => undefined);
414
+ return ok;
415
+ }
416
+
417
+ /** `app_home_opened` → build the viewer, publish the page. */
418
+ export async function handleHomeOpened(ev: ParsedHomeOpen, deps: SlackHomeDeps): Promise<boolean> {
419
+ const viewer = await buildSlackHomeViewer(ev, deps);
420
+ if (!viewer) return false;
421
+ return publishHome(ev.userId, viewer, deps);
422
+ }
423
+
424
+ /**
425
+ * A Home-tab click. A button runs its action and republishes. An overflow choice opens the action's
426
+ * modal — the item text it quotes is read from the VIEWER's own page model, never from the payload,
427
+ * so a forged digest that names no line of theirs opens nothing.
428
+ */
429
+ export async function handleHomeClick(click: ParsedHomeClick, deps: SlackHomeDeps): Promise<boolean> {
430
+ if (!hasHomeAction(click.action)) return false;
431
+ const viewer = await buildSlackHomeViewer(click, deps);
432
+ if (!viewer) return false;
433
+ if (click.ref === undefined) {
434
+ const result = await runHomeAction(viewer, click.action);
435
+ if (result.file) await deliverFile(click.userId, result.file, result.notice, deps);
436
+ const notice = result.file ? `${result.notice} I sent it to our direct messages.` : result.notice;
437
+ return publishHome(click.userId, viewer, deps, notice || undefined);
438
+ }
439
+ const located = await locateItem(viewer, click.action, click.ref);
440
+ if (!located || !click.triggerId) {
441
+ return publishHome(click.userId, viewer, deps, "That line is not on your page any more.");
442
+ }
443
+ const res = await deps.call("views.open", {
444
+ trigger_id: click.triggerId,
445
+ view: homeActionModal({
446
+ action: click.action,
447
+ ref: click.ref,
448
+ kind: located.action.kind,
449
+ itemText: located.text,
450
+ title: located.action.confirm?.title ?? `${located.action.label}`,
451
+ yes: located.action.confirm?.yes ?? "Save",
452
+ }),
453
+ });
454
+ return res?.ok === true;
455
+ }
456
+
457
+ /** Find the item a digest names on the VIEWER's own page, and the action it offers. */
458
+ async function locateItem(viewer: HomeViewer, actionId: string, ref: string) {
459
+ for (const section of await buildHomeView(viewer)) {
460
+ for (const b of section.blocks) {
461
+ if (b.kind !== "item" || b.ref !== ref) continue;
462
+ const action = b.actions.find((a) => a.id === actionId);
463
+ if (action) return { text: b.text, action };
464
+ }
465
+ }
466
+ return undefined;
467
+ }
468
+
469
+ /** A Home modal's submission → run the action, republish with its notice. */
470
+ export async function handleHomeSubmission(sub: ParsedHomeSubmission, deps: SlackHomeDeps): Promise<boolean> {
471
+ if (!hasHomeAction(sub.action)) return false;
472
+ const viewer = await buildSlackHomeViewer(sub, deps);
473
+ if (!viewer) return false;
474
+ const result = await runHomeAction(viewer, sub.action, sub.ref, sub.input);
475
+ return publishHome(sub.userId, viewer, deps, result.notice || undefined);
476
+ }
477
+
478
+ /** Hand a document to the person: upload it into their DM with the app (Slack's external upload). */
479
+ async function deliverFile(
480
+ userId: string,
481
+ file: { readonly filename: string; readonly title: string; readonly content: string },
482
+ comment: string,
483
+ deps: SlackHomeDeps,
484
+ ): Promise<boolean> {
485
+ const opened = await deps.call("conversations.open", { users: userId });
486
+ const channelId = opened?.ok === true ? str(asObject(opened.channel)?.id) : undefined;
487
+ if (!channelId) return false;
488
+ const bytes = new TextEncoder().encode(file.content);
489
+ const url = await deps.call("files.getUploadURLExternal", { filename: file.filename, length: bytes.byteLength });
490
+ const uploadUrl = url?.ok === true ? str(url.upload_url) : undefined;
491
+ const fileId = url?.ok === true ? str(url.file_id) : undefined;
492
+ if (!uploadUrl || !fileId) return false;
493
+ const put = await fetch(uploadUrl, { method: "POST", headers: { "content-type": "text/markdown" }, body: bytes }).catch(() => undefined);
494
+ if (!put?.ok) return false;
495
+ const done = await deps.call("files.completeUploadExternal", {
496
+ files: [{ id: fileId, title: file.title }],
497
+ channel_id: channelId,
498
+ initial_comment: comment,
499
+ });
500
+ return done?.ok === true;
501
+ }
502
+
503
+ // ── helpers ─────────────────────────────────────────────────────────────────────────────────────
504
+
505
+ function asObject(v: unknown): Record<string, unknown> | undefined {
506
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
507
+ }
508
+
509
+ function str(v: unknown): string | undefined {
510
+ return typeof v === "string" && v.length > 0 ? v : undefined;
511
+ }