@open-everywhere/provider-slack 0.5.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 (37) hide show
  1. package/agent/app-manifest.ts +37 -1
  2. package/agent/channels/slack-events.ts +13 -0
  3. package/agent/channels/slack-front.ts +75 -1
  4. package/agent/channels/slack-home.ts +40 -5
  5. package/agent/channels/slack-side-panel.ts +104 -0
  6. package/agent/docs-generated.ts +1 -1
  7. package/agent/ops.ts +3 -0
  8. package/agent/register.ts +6 -0
  9. package/dist/agent/app-manifest.d.ts +12 -0
  10. package/dist/agent/app-manifest.d.ts.map +1 -1
  11. package/dist/agent/app-manifest.js +25 -1
  12. package/dist/agent/app-manifest.js.map +1 -1
  13. package/dist/agent/channels/slack-events.d.ts +9 -0
  14. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  15. package/dist/agent/channels/slack-events.js +5 -0
  16. package/dist/agent/channels/slack-events.js.map +1 -1
  17. package/dist/agent/channels/slack-front.d.ts +14 -1
  18. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  19. package/dist/agent/channels/slack-front.js +54 -1
  20. package/dist/agent/channels/slack-front.js.map +1 -1
  21. package/dist/agent/channels/slack-home.d.ts +9 -1
  22. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-home.js +33 -5
  24. package/dist/agent/channels/slack-home.js.map +1 -1
  25. package/dist/agent/channels/slack-side-panel.d.ts +41 -0
  26. package/dist/agent/channels/slack-side-panel.d.ts.map +1 -0
  27. package/dist/agent/channels/slack-side-panel.js +78 -0
  28. package/dist/agent/channels/slack-side-panel.js.map +1 -0
  29. package/dist/agent/docs-generated.js +1 -1
  30. package/dist/agent/docs-generated.js.map +1 -1
  31. package/dist/agent/ops.d.ts.map +1 -1
  32. package/dist/agent/ops.js +3 -1
  33. package/dist/agent/ops.js.map +1 -1
  34. package/dist/agent/register.d.ts.map +1 -1
  35. package/dist/agent/register.js +6 -1
  36. package/dist/agent/register.js.map +1 -1
  37. package/package.json +9 -3
@@ -62,6 +62,24 @@ export interface SlackAppManifestOptions {
62
62
  * The MCP toggle itself (the app config's Agents section) has no manifest field, so it stays a click.
63
63
  */
64
64
  readonly personalMcp?: boolean;
65
+ /**
66
+ * The side panel's suggested prompts (OPE-2457) — the `slack.suggestedPrompts` setting, which the
67
+ * caller reads (this module imports nothing). Absent or empty ⇒ the manifest offers none. The setup
68
+ * CLI passes none: it runs before the app's settings resolve, and the post-deploy re-paste from the
69
+ * ops tool or `/setup` carries them.
70
+ */
71
+ readonly suggestedPrompts?: readonly { readonly title: string; readonly message: string }[];
72
+ }
73
+
74
+ /** Slack's limit on `features.agent_view.agent_description` (the manifest reference: 300). */
75
+ export const SLACK_AGENT_DESCRIPTION_MAX = 300;
76
+
77
+ /** The side panel's one-line description. Names the configured agent, never the brand on its own. */
78
+ function slackAgentDescription(appName: string): string {
79
+ return `Ask ${appName} beside anything you are reading in Slack. It is your own direct-message conversation, with the same rules as a DM.`.slice(
80
+ 0,
81
+ SLACK_AGENT_DESCRIPTION_MAX,
82
+ );
65
83
  }
66
84
 
67
85
  /** The path the person's OAuth sign-in returns to — core's `OE_OAUTH_CALLBACK_PATH`
@@ -104,6 +122,19 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
104
122
  // it delivers `app_home_opened` (below), and the front route publishes the page
105
123
  // (channels/slack-home.ts).
106
124
  app_home: { home_tab_enabled: true, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
125
+ // THE SIDE PANEL (OPE-2457) — Slack's agent experience. With `agent_view` on, a person can
126
+ // open the app's Messages tab beside any channel, and what they send there is an ordinary
127
+ // `message.im` to their own DM seat (channels/slack-side-panel.ts). `agent_view`, not the
128
+ // older `assistant_view`: Slack's manifest reference says a NEW app can only use `agent_view`.
129
+ // 🔴 FOR AN EXISTING APP THE SWITCH IS ONE-WAY: Slack cannot move an app from `assistant_view`
130
+ // back, and its people need a hard refresh. An app that never had `assistant_view` (every
131
+ // install of this manifest before OPE-2457) has nothing to switch from.
132
+ agent_view: {
133
+ agent_description: slackAgentDescription(appName),
134
+ ...(opts.suggestedPrompts && opts.suggestedPrompts.length > 0
135
+ ? { suggested_prompts: opts.suggestedPrompts.map((p) => ({ title: p.title, message: p.message })) }
136
+ : {}),
137
+ },
107
138
  bot_user: { display_name: appName, always_online: true },
108
139
  // The "/oe" ZERO-INFERENCE control command (agent/commands/core.ts). Declared here rather
109
140
  // than left to a manual setup step because one of its subcommands is `/oe stop` (OPE-418),
@@ -119,7 +150,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
119
150
  description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
120
151
  // Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
121
152
  // scripts/slack-app-manifest-test.ts fails if one is missing here.
122
- usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | help",
153
+ usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | help",
123
154
  should_escape: false,
124
155
  },
125
156
  ],
@@ -212,6 +243,9 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
212
243
  // knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
213
244
  // per channel, and memory itself is unaffected.
214
245
  "canvases:write",
246
+ // The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
247
+ // subscription makes Slack attach the viewed room to each side-panel `message.im`.
248
+ "assistant:write",
215
249
  // Slack requires this bot scope on an app whose people connect to Slack's MCP server
216
250
  // (OPE-2421). Declared only when the app declares "search Slack as you".
217
251
  ...(personalMcp ? [SLACK_MCP_BOT_SCOPE] : []),
@@ -236,6 +270,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
236
270
  // app_home_opened → no scope (the Home tab page, OPE-2451)
237
271
  // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
238
272
  // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
273
+ // app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
239
274
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
240
275
  // this list drops one (OPE-2223).
241
276
  bot_events: [
@@ -250,6 +285,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
250
285
  "app_home_opened",
251
286
  "channel_shared",
252
287
  "user_change",
288
+ "app_context_changed",
253
289
  ],
254
290
  },
255
291
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -17,6 +17,7 @@
17
17
  // envelope reaches `routeMessageEvent`. Until then this is unit-tested but unmounted.
18
18
 
19
19
  import { slackContinuationToken } from "eve/channels/slack";
20
+ import { parseSlackAppContext, type SlackAppContextRoom } from "./slack-side-panel.js";
20
21
  import { callSlackWebApi } from "./slack-web-api.js";
21
22
  import { slackBotToken } from "../setup/credentials.js";
22
23
  import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
@@ -265,6 +266,10 @@ export interface ParsedMessageEvent {
265
266
  * the wake envelope so the agent knows an image exists and can `describe_image` it. Empty/
266
267
  * absent when the message carries no files. Never the bytes — just id/name/mimetype/url. */
267
268
  readonly attachments?: readonly WakeAttachment[];
269
+ /** The room the person was viewing in the side panel (OPE-2457), from the event's `app_context`.
270
+ * UNVERIFIED — slack-side-panel.ts `sidePanelViewedRoom` gates it before any use. Absent on every
271
+ * event that carries no channel entity, which is every non-panel message. */
272
+ readonly appContext?: SlackAppContextRoom;
268
273
  }
269
274
 
270
275
  /** Parse Slack's `files[]` (on a message event's inner message) into attachment references.
@@ -710,6 +715,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
710
715
  const teamId = envelope.team_id ?? (typeof e.team === "string" ? e.team : undefined);
711
716
  const enterpriseId = slackEnterpriseIdFromEnvelope(envelope);
712
717
  const attachments = parseSlackFiles(inner.files);
718
+ const appContext = parseSlackAppContext(e);
713
719
  // TRUSTED PROVENANCE: record each IMAGE attachment's url_private under its Slack file id so
714
720
  // describe_image's `attachmentId` path (via the attachment plane's Slack resolver) can resolve the
715
721
  // model-named id → this event's url_private and fetch it with the bot token
@@ -738,6 +744,8 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
738
744
  // Image/file attachments (docs Part B) — read off the same inner message the text came
739
745
  // from (so an edit's post-edit files win). Absent for the common no-file message.
740
746
  attachments,
747
+ // The side panel's viewed room (OPE-2457) rides the OUTER event, never an edit's inner message.
748
+ ...(appContext === undefined ? {} : { appContext }),
741
749
  };
742
750
  }
743
751
 
@@ -1058,6 +1066,10 @@ export interface SlackWakeOpts {
1058
1066
  * (channels/seat-catch-up.ts `catchUpUnavailableReason`). Rendered as a trusted `<system-note>`;
1059
1067
  * unset ⇒ no note, which is every Slack wake, because Slack registers a fetch. */
1060
1068
  readonly catchUpUnavailable?: string;
1069
+ /** OE's own trusted note for this wake (`<system-note>`): fixed words around host-checked ids only.
1070
+ * Set by the DM door alone — the side panel's viewed room and the person's opt-out
1071
+ * (slack-side-panel.ts `slackDmSystemNote`). Unset ⇒ no note, byte-identical to before. */
1072
+ readonly systemNote?: string;
1061
1073
  /**
1062
1074
  * THE NAME-DROP HYDRATION, ON THE SEAT ROAD (OPE-1725). The surrounding conversation
1063
1075
  * {@link routeNameDropEvent} fetches for a name-drop, as RAW {@link ContextLine}s — renderWake
@@ -1142,6 +1154,7 @@ function slackJudgedWake(
1142
1154
  ...(addressing === "name-drop" ? { nameDrop: { followUp: "follows-on-reply" as const } } : {}),
1143
1155
  // The catch-up bound, stated on the wake that could not take one ({@link SlackWakeOpts.catchUpUnavailable}).
1144
1156
  ...(opts.catchUpUnavailable !== undefined ? { catchUpUnavailable: opts.catchUpUnavailable } : {}),
1157
+ ...(opts.systemNote !== undefined ? { systemNote: opts.systemNote } : {}),
1145
1158
  // The name-drop hydration's side-channel slot ({@link SlackWakeOpts.contextLines}). Spread
1146
1159
  // conditionally so a wake that states none carries no key at all — the shape every
1147
1160
  // characterization golden was taken against.
@@ -120,7 +120,13 @@ import {
120
120
  tailAuthorGrain,
121
121
  dmWindowRegistryKey,
122
122
  stampSurfaceEventVerified,
123
+ readPersonOptOut,
124
+ auditPersonOptOutRefusal,
125
+ PERSON_OPT_OUT_SHORTCUT_NOTICE,
126
+ PERSON_OPT_OUT_UNREADABLE_NOTICE,
127
+ type PersonOptOutState,
123
128
  } from "@open-everywhere/core/agent/adapter-api.js";
129
+ import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
124
130
  import {
125
131
  renderWakeMessage,
126
132
  slackChannelFactsFor,
@@ -219,7 +225,10 @@ export type FrontDecision =
219
225
  /** An `oe_home:` click on the Home tab (OPE-2451) — ours: run a button, or open an item's modal. */
220
226
  | { readonly kind: "home-click"; readonly reason: string; readonly click: ParsedHomeClick }
221
227
  /** A submission of the Home tab's own modal (OPE-2451) — ours: run the action, republish the page. */
222
- | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission };
228
+ | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
229
+ /** An `app_context_changed` (OPE-2457) — ours, and nothing to do. The subscription exists only so
230
+ * Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
231
+ | { readonly kind: "context-change"; readonly reason: string };
223
232
 
224
233
  /**
225
234
  * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
@@ -446,6 +455,13 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
446
455
  if (open) return { kind: "home-open", reason: "app_home_opened (home tab) — publish the person's page", open };
447
456
  return { kind: "forward", reason: "app_home_opened for a tab other than home — nothing to publish" };
448
457
  }
458
+ if (eventType === "app_context_changed") {
459
+ // THE SIDE PANEL (OPE-2457). Slack fires this each time the person moves the panel to another
460
+ // room. The room that matters arrives on the `message.im` they then send (`app_context`), so a
461
+ // change alone wakes nothing — but it is acknowledged HERE rather than forwarded, because a
462
+ // forward costs eve a parse and a re-verify for every click the person makes in Slack.
463
+ return { kind: "context-change", reason: "app_context_changed — the room rides the next side-panel message; nothing to wake" };
464
+ }
449
465
  if (eventType === "user_change") {
450
466
  // OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours — automatic offboarding.
451
467
  return { kind: "user-change", reason: "user_change — a deactivation report candidate", env };
@@ -667,6 +683,13 @@ export interface FrontOptions {
667
683
  * governance DM-delivery arm. Defaults to the live call; FAIL-CLOSED: undefined ⇒ the shortcut
668
684
  * wakes nothing and says so, never a wake in a guessed room. Injectable for tests. */
669
685
  readonly openDirectMessageChannel?: (userId: string) => Promise<string | undefined>;
686
+ /** The person opt-out read (OPE-2436): may the agent act on this person's content? Defaults to
687
+ * core's `readPersonOptOut`, which is fail-CLOSED (an unreadable store reads as opted out).
688
+ * Injectable for tests. */
689
+ readonly personOptOutFor?: (org: string, principalId: string) => Promise<PersonOptOutState>;
690
+ /** Tell one person, privately in their own DM, why a shortcut they pressed did nothing (OPE-2436).
691
+ * Best-effort. Defaults to `conversations.open` + `chat.postMessage`. Injectable for tests. */
692
+ readonly postPrivateNotice?: (userId: string, text: string) => Promise<void>;
670
693
  /** The trigger author's two membership facts, from ONE `users.info` probe — the exact pair every
671
694
  * wake route in slack-events.ts resolves. Two readers: the native `app_mention` forward's
672
695
  * external-policy gate, which calls it only when that policy is not inert (so a deployment on the
@@ -1066,6 +1089,13 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1066
1089
  const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
1067
1090
  const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
1068
1091
  const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
1092
+ const personOptOutFor = opts.personOptOutFor ?? readPersonOptOut;
1093
+ const postPrivateNotice =
1094
+ opts.postPrivateNotice ??
1095
+ (async (userId: string, text: string) => {
1096
+ const dm = await openDirectMessageChannel(userId);
1097
+ if (dm) await slackForm("chat.postMessage", new URLSearchParams({ channel: dm, text }).toString());
1098
+ });
1069
1099
  const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
1070
1100
  const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
1071
1101
  const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
@@ -1572,9 +1602,25 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1572
1602
  // by design (the seat is one long-lived session over the whole DM, so replaying history a live
1573
1603
  // session already holds would duplicate it) — the tail is exactly the case that reasoning does
1574
1604
  // not cover, because a rotation is when the session stops holding it.
1605
+ // THE SIDE PANEL (OPE-2457) AND THE PERSON OPT-OUT (OPE-2436) — two host facts for a 1:1 DM, in
1606
+ // one trusted note. The viewed room passes every strictest-default gate first
1607
+ // (slack-side-panel.ts); the opt-out tells the seat not to promise memory the write road will
1608
+ // refuse anyway. A group DM states neither: it has no single person, and no panel.
1609
+ const systemNote = isGroup
1610
+ ? undefined
1611
+ : slackDmSystemNote({
1612
+ viewedRoom: await sidePanelViewedRoom({
1613
+ room: ev.appContext,
1614
+ teamId: ev.teamId,
1615
+ partnerTrust,
1616
+ channelInternal: async (id) => (await channelFactsFor(id)).internal === true,
1617
+ }),
1618
+ personOptOut: ev.userId ? await personOptOutFor(p.org, ev.userId) : undefined,
1619
+ });
1575
1620
  const message = renderWakeMessage(ev, () => atIso, {
1576
1621
  dm: true,
1577
1622
  trust: wakeTrust,
1623
+ ...(systemNote === undefined ? {} : { systemNote }),
1578
1624
  // The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
1579
1625
  // and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
1580
1626
  // no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
@@ -1912,6 +1958,32 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1912
1958
  // Keyed on the click, not the message — a person may invoke the same shortcut on the same
1913
1959
  // message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
1914
1960
  if (!(await notYetSeenKey(shortcutWakeKey(sc), sev))) return new Response(null, { status: 200 });
1961
+ // 🔴 THE PERSON OPT-OUT (OPE-2436). A message shortcut hands the agent somebody's message to act
1962
+ // on — summarise it, answer it, file it. When the message's AUTHOR opted out of agent actions on
1963
+ // their content and somebody ELSE pressed the button, nothing wakes: one refusal row, and a
1964
+ // private line to the person who pressed it. The author's own shortcut on their own message is
1965
+ // their own request, so it wakes as before. The read is fail-closed (core `readPersonOptOut`).
1966
+ const shortcutAuthor = sc.kind === "message" ? sc.message?.userId : undefined;
1967
+ if (shortcutAuthor && shortcutAuthor !== sc.userId) {
1968
+ const p = resolvePartition(bindableFor(sev));
1969
+ const authorOptOut = await personOptOutFor(p.org, shortcutAuthor);
1970
+ if (authorOptOut.optedOut) {
1971
+ console.log(`[open-everywhere:slack-front] message shortcut "${sc.callbackId}" in ${shortcutChannelId}: refused — the message's author opted out`);
1972
+ args.waitUntil(
1973
+ Promise.allSettled([
1974
+ auditPersonOptOutRefusal(
1975
+ { org: p.org, platform: p.platform, workspace: p.workspace, surface: p.surface, principalKey: shortcutAuthor },
1976
+ "message-shortcut",
1977
+ { requestedBy: sc.userId, storeReadable: authorOptOut.readable },
1978
+ ),
1979
+ // An unreadable store is the fail-closed GUESS, never the author's decision, so the presser
1980
+ // is not told the author asked for anything.
1981
+ postPrivateNotice(sc.userId, authorOptOut.readable ? PERSON_OPT_OUT_SHORTCUT_NOTICE : PERSON_OPT_OUT_UNREADABLE_NOTICE),
1982
+ ]),
1983
+ );
1984
+ return new Response(null, { status: 200 });
1985
+ }
1986
+ }
1915
1987
  // BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
1916
1988
  // the in-memory one for this process's seat, the durable one for a delegated worker in
1917
1989
  // another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
@@ -2319,6 +2391,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2319
2391
  return OK();
2320
2392
  }
2321
2393
 
2394
+ if (decision.kind === "context-change") return OK();
2395
+
2322
2396
  if (decision.kind === "user-change") {
2323
2397
  // AUTOMATIC OFFBOARDING (OPE-2423). The signature above has authenticated the envelope, so its
2324
2398
  // `team_id` is the workspace that delivered this report. Every other decision — the
@@ -1,5 +1,5 @@
1
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
2
+ // what their domains know, and their rules (OPE-2456). Core owns the page model
3
3
  // (`home/home-view.ts`); this module is the Slack half: it parses the three Slack deliveries the
4
4
  // page needs, builds the VERIFIED viewer, renders Block Kit, and drives the dialogs.
5
5
  //
@@ -36,6 +36,8 @@ import {
36
36
  runHomeAction,
37
37
  SLACK_HOME_WIRE,
38
38
  type BindableContext,
39
+ type Domain,
40
+ type DomainMembershipProbe,
39
41
  type HomeBlock,
40
42
  type HomeDomainMembership,
41
43
  type HomeViewer,
@@ -252,6 +254,8 @@ function homeActionModal(input: {
252
254
  readonly itemText: string;
253
255
  readonly title: string;
254
256
  readonly yes: string;
257
+ /** What the confirmation does, from the section that owns the action. */
258
+ readonly note?: string;
255
259
  }): Record<string, unknown> {
256
260
  const quoted = escapeSlackText(clip(input.itemText, 2800));
257
261
  return {
@@ -274,7 +278,7 @@ function homeActionModal(input: {
274
278
  ]
275
279
  : [
276
280
  { 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." }] },
281
+ { type: "context", elements: [{ type: "mrkdwn", text: escapeSlackText(input.note ?? "I remove this line from your memory everywhere it is stored. You cannot undo it.") }] },
278
282
  ],
279
283
  };
280
284
  }
@@ -348,8 +352,14 @@ export async function buildSlackHomeViewer(ids: ParsedHomeInteraction, deps: Sla
348
352
  };
349
353
  }
350
354
 
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[]> {
355
+ /** The domains a person belongs to through an internal channel. Fail-soft: a failed page ends the walk.
356
+ * The Home tab lists a domain only when it has memory to show (`keep`). */
357
+ async function slackDomainMemberships(
358
+ userId: string,
359
+ workspaceKey: string,
360
+ deps: SlackHomeDeps,
361
+ keep: (domain: Domain) => boolean = (d) => d.memory !== undefined,
362
+ ): Promise<HomeDomainMembership[]> {
353
363
  const domains = getConfig().loadDomains();
354
364
  const byDomain = new Map<string, HomeDomainMembership>();
355
365
  let cursor: string | undefined;
@@ -367,7 +377,7 @@ async function slackDomainMemberships(userId: string, workspaceKey: string, deps
367
377
  if (!channelId) continue;
368
378
  const surfaceKey = `${workspaceKey}/${channelId}`;
369
379
  const domain = domainForChannel(surfaceKey, domains);
370
- if (!domain || domain.memory === undefined) continue;
380
+ if (!domain || !keep(domain)) continue;
371
381
  if (!(await deps.channelInternal(channelId))) continue;
372
382
  const existing = byDomain.get(domain.name);
373
383
  const room = { surfaceKey, nativeId: channelId };
@@ -379,6 +389,30 @@ async function slackDomainMemberships(userId: string, workspaceKey: string, deps
379
389
  return [...byDomain.values()];
380
390
  }
381
391
 
392
+ /**
393
+ * THE DM QUESTION'S MEMBERSHIP PROBE (OPE-2452), registered as Slack's `DomainMembershipProbe`. The SAME
394
+ * trust gate the Home tab applies, applied to the person who asks from their DM: a full member of the
395
+ * workspace (`users.info`, fail-closed), and only the domain channels Slack confirms internal
396
+ * (`conversations.info`, fail-closed). Unlike the Home tab it keeps a domain with no memory: a domain
397
+ * with no silo still has a seat that can answer.
398
+ */
399
+ export function slackDomainMembershipProbe(deps: SlackHomeDeps = liveSlackHomeDeps()): DomainMembershipProbe {
400
+ return async ({ principalId, workspaceKey }) => {
401
+ try {
402
+ const [platform, teamId] = workspaceKey.split("/");
403
+ if (platform !== "slack" || !teamId || principalId.length === 0) return { fullMember: false, rooms: [] };
404
+ if ((await deps.authorIsMember(principalId, teamId)) !== true) return { fullMember: false, rooms: [] };
405
+ const memberships = await slackDomainMemberships(principalId, workspaceKey, deps, () => true);
406
+ return {
407
+ fullMember: true,
408
+ rooms: memberships.flatMap((m) => m.rooms.map((r) => ({ domain: m.domain.name, surfaceKey: r.surfaceKey, origin: { platform: "slack" as const, channelId: r.nativeId } }))),
409
+ };
410
+ } catch {
411
+ return { fullMember: false, rooms: [] };
412
+ }
413
+ };
414
+ }
415
+
382
416
  // ── THE THREE HANDLERS ──────────────────────────────────────────────────────────────────────────
383
417
 
384
418
  /** The audit action every publish attempt writes. Declared in core's registry (operational). */
@@ -449,6 +483,7 @@ export async function handleHomeClick(click: ParsedHomeClick, deps: SlackHomeDep
449
483
  itemText: located.text,
450
484
  title: located.action.confirm?.title ?? `${located.action.label}`,
451
485
  yes: located.action.confirm?.yes ?? "Save",
486
+ ...(located.action.confirm?.note !== undefined ? { note: located.action.confirm.note } : {}),
452
487
  }),
453
488
  });
454
489
  return res?.ok === true;
@@ -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
+ }