@open-everywhere/provider-slack 0.5.0 → 0.7.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 (62) hide show
  1. package/agent/app-manifest.ts +37 -1
  2. package/agent/channels/slack-bot-ask.ts +101 -0
  3. package/agent/channels/slack-commands.ts +6 -5
  4. package/agent/channels/slack-events.ts +20 -4
  5. package/agent/channels/slack-friendly-names.ts +55 -0
  6. package/agent/channels/slack-front.ts +105 -2
  7. package/agent/channels/slack-home.ts +40 -5
  8. package/agent/channels/slack-memory-canvas.ts +7 -5
  9. package/agent/channels/slack-side-panel.ts +104 -0
  10. package/agent/channels/slack-web-api.ts +4 -2
  11. package/agent/channels/slack.ts +7 -6
  12. package/agent/docs-generated.ts +1 -1
  13. package/agent/ops.ts +3 -0
  14. package/agent/register.ts +18 -0
  15. package/dist/agent/app-manifest.d.ts +12 -0
  16. package/dist/agent/app-manifest.d.ts.map +1 -1
  17. package/dist/agent/app-manifest.js +25 -1
  18. package/dist/agent/app-manifest.js.map +1 -1
  19. package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
  20. package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
  21. package/dist/agent/channels/slack-bot-ask.js +91 -0
  22. package/dist/agent/channels/slack-bot-ask.js.map +1 -0
  23. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-commands.js +6 -6
  25. package/dist/agent/channels/slack-commands.js.map +1 -1
  26. package/dist/agent/channels/slack-events.d.ts +10 -0
  27. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-events.js +9 -1
  29. package/dist/agent/channels/slack-events.js.map +1 -1
  30. package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
  31. package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
  32. package/dist/agent/channels/slack-friendly-names.js +45 -0
  33. package/dist/agent/channels/slack-friendly-names.js.map +1 -0
  34. package/dist/agent/channels/slack-front.d.ts +24 -2
  35. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  36. package/dist/agent/channels/slack-front.js +77 -2
  37. package/dist/agent/channels/slack-front.js.map +1 -1
  38. package/dist/agent/channels/slack-home.d.ts +9 -1
  39. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  40. package/dist/agent/channels/slack-home.js +33 -5
  41. package/dist/agent/channels/slack-home.js.map +1 -1
  42. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-memory-canvas.js +7 -6
  44. package/dist/agent/channels/slack-memory-canvas.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-web-api.d.ts.map +1 -1
  50. package/dist/agent/channels/slack-web-api.js +4 -2
  51. package/dist/agent/channels/slack-web-api.js.map +1 -1
  52. package/dist/agent/channels/slack.js +7 -3
  53. package/dist/agent/channels/slack.js.map +1 -1
  54. package/dist/agent/docs-generated.js +1 -1
  55. package/dist/agent/docs-generated.js.map +1 -1
  56. package/dist/agent/ops.d.ts.map +1 -1
  57. package/dist/agent/ops.js +3 -1
  58. package/dist/agent/ops.js.map +1 -1
  59. package/dist/agent/register.d.ts.map +1 -1
  60. package/dist/agent/register.js +16 -1
  61. package/dist/agent/register.js.map +1 -1
  62. package/package.json +18 -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] | watch [more | less | none] | 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
@@ -0,0 +1,101 @@
1
+ // SLACK'S ARM OF CHIEF-OF-STAFF DELEGATION (OPE-2426) — the probe that proves an ask to another bot, and
2
+ // the facts the front route hands core for an inbound bot message. Core holds the policy
3
+ // (core/agent/channels/bot-ask.ts); this file only reads Slack.
4
+ //
5
+ // THE PROBE reads three facts with the BOT token, never a model string:
6
+ // 1. the ask message itself (`conversations.replies` on its ts). The bot token reads it only when this
7
+ // app is in the channel, and the app must be in the channel to receive the other bot's reply at all.
8
+ // So an unreadable message is a refusal that says "invite the agent";
9
+ // 2. its author is the person who asks, and its own text @mentions the named bot;
10
+ // 3. the named account is a bot (`users.info` `is_bot`), and not this app itself (`auth.test`). An
11
+ // unknown own identity refuses: it cannot prove "not this app".
12
+ // Any failed call refuses (`unreadable`). The probe never throws.
13
+
14
+ import type { BotAskProbe, BotAskProbeAnswer, BotReplyFacts } from "@open-everywhere/core/agent/adapter-api.js";
15
+ import { encodeSlackForm } from "./slack-web-api.js";
16
+ import type { ParsedMessageEvent } from "./slack-events.js";
17
+
18
+ /** The Slack calls the probe makes (injectable for the unit test). */
19
+ export interface SlackBotAskDeps {
20
+ /** One Web API call with the bot token; resolves the parsed body, or undefined on a transport failure. */
21
+ readonly call: (method: string, body: Record<string, string>) => Promise<Record<string, unknown> | undefined>;
22
+ /** This app's own bot user id and bot id (`auth.test`), best-effort. */
23
+ readonly self: () => Promise<{ readonly userId?: string; readonly botId?: string }>;
24
+ }
25
+
26
+ type SlackMessage = { readonly ts?: unknown; readonly thread_ts?: unknown; readonly user?: unknown; readonly text?: unknown };
27
+
28
+ /** Does the text @mention this user id (`<@U123>` or `<@U123|name>`)? PURE. */
29
+ export function mentionsUser(text: string, userId: string): boolean {
30
+ return text.includes(`<@${userId}>`) || text.includes(`<@${userId}|`);
31
+ }
32
+
33
+ /** Build Slack's probe. */
34
+ export function slackBotAskProbe(deps: SlackBotAskDeps): BotAskProbe {
35
+ return async (q): Promise<BotAskProbeAnswer> => {
36
+ try {
37
+ const [platform, teamId] = q.workspaceKey.split("/");
38
+ if (platform !== "slack" || !teamId) return { ok: false, reason: "unreadable" };
39
+ const replies = await deps.call("conversations.replies", { channel: q.channel, ts: q.messageTs, limit: "1", inclusive: "true" });
40
+ const messages = replies?.ok === true && Array.isArray(replies.messages) ? (replies.messages as SlackMessage[]) : [];
41
+ const msg = messages.find((m) => m.ts === q.messageTs);
42
+ if (!msg) return { ok: false, reason: "unreadable" };
43
+ if (msg.user !== q.personId) return { ok: false, reason: "not-yours" };
44
+ const text = typeof msg.text === "string" ? msg.text : "";
45
+ if (!mentionsUser(text, q.bot)) return { ok: false, reason: "bot-not-named" };
46
+ const self = await deps.self().catch(() => ({}) as { userId?: string; botId?: string });
47
+ // FAIL-CLOSED: without this app's own user id, a mention of this app cannot be told from another bot's.
48
+ if (self.userId === undefined) return { ok: false, reason: "unreadable" };
49
+ if (self.userId === q.bot) return { ok: false, reason: "self" };
50
+ const info = await deps.call("users.info", { user: q.bot });
51
+ const user = info?.ok === true && info.user && typeof info.user === "object" ? (info.user as Record<string, unknown>) : undefined;
52
+ if (!user) return { ok: false, reason: "unreadable" };
53
+ if (user.is_bot !== true) return { ok: false, reason: "not-a-bot" };
54
+ const profile = user.profile && typeof user.profile === "object" ? (user.profile as Record<string, unknown>) : {};
55
+ const botId = typeof profile.bot_id === "string" && profile.bot_id.length > 0 ? profile.bot_id : undefined;
56
+ if (botId !== undefined && self.botId !== undefined && botId === self.botId) return { ok: false, reason: "self" };
57
+ const label = [user.real_name, profile.display_name, user.name].find((v) => typeof v === "string" && v.trim().length > 0) as string | undefined;
58
+ return {
59
+ ok: true,
60
+ threadTs: typeof msg.thread_ts === "string" && msg.thread_ts.length > 0 ? msg.thread_ts : q.messageTs,
61
+ botUserId: q.bot,
62
+ ...(botId === undefined ? {} : { botId }),
63
+ ...(label === undefined ? {} : { botLabel: label }),
64
+ askText: text,
65
+ };
66
+ } catch {
67
+ return { ok: false, reason: "unreadable" };
68
+ }
69
+ };
70
+ }
71
+
72
+ /** The live deps: `slackForm` with the bot token, and the front route's cached `auth.test`. */
73
+ export function liveSlackBotAskDeps(
74
+ form: (method: string, body: string) => Promise<Record<string, unknown> | undefined>,
75
+ self: () => Promise<{ readonly userId?: string; readonly botId?: string }>,
76
+ ): SlackBotAskDeps {
77
+ return { call: (method, body) => form(method, encodeSlackForm(body)), self };
78
+ }
79
+
80
+ /** Is this parsed message a candidate for the asked-thread exception at all? A cheap, pure pre-filter, so a
81
+ * human message never pays a store read. PURE. */
82
+ export function botReplyCandidate(ev: ParsedMessageEvent): boolean {
83
+ return (Boolean(ev.botId) || ev.subtype === "bot_message") && ev.threadTs !== undefined && ev.threadTs !== ev.ts;
84
+ }
85
+
86
+ /** Core's facts for one parsed Slack message. PURE. */
87
+ export function slackBotReplyFacts(ev: ParsedMessageEvent, org: string, self: { readonly userId?: string; readonly botId?: string }): BotReplyFacts {
88
+ return {
89
+ org,
90
+ platform: "slack",
91
+ workspace: ev.teamId,
92
+ channel: ev.channelId,
93
+ threadTs: ev.threadTs,
94
+ messageTs: ev.ts,
95
+ authorUserId: ev.userId,
96
+ authorBotId: ev.botId,
97
+ subtype: ev.subtype,
98
+ ...(self.userId === undefined ? {} : { selfUserId: self.userId }),
99
+ ...(self.botId === undefined ? {} : { selfBotId: self.botId }),
100
+ };
101
+ }
@@ -36,6 +36,7 @@ import { defineChannel, POST } from "eve/channels";
36
36
  import { verifySlackSignature } from "./slack-front.js";
37
37
  import { slackSigningSecret } from "../setup/credentials.js";
38
38
  import {
39
+ commandFailureText,
39
40
  bindFrom,
40
41
  runOeCommand,
41
42
  oeSlash,
@@ -223,12 +224,12 @@ export default defineChannel({
223
224
  const reply = await runOeCommand(cmd, binding, { attachSession });
224
225
  return ephemeralResponse(reply);
225
226
  } catch (err) {
226
- // runOeCommand's budget-raise/clear branches can throw a user-facing validation error
227
- // (e.g. proposing from a DM with no explicit scope) — surface it verbatim. Never let a
228
- // thrown error escape as a 500; Slack's slash-command UX for that is an ugly
229
- // "operation_timeout"/dispatch_failed toast.
227
+ // A PersonFacingError is already worded for the person — it renders as-is. Anything else
228
+ // (a store or network failure, a planner's model-facing refusal) renders the generic line,
229
+ // and its message goes to the log only (OPE-2486). Never let a thrown error escape as a
230
+ // 500; Slack's slash-command UX for that is an ugly "operation_timeout"/dispatch_failed toast.
230
231
  log(`"${expected}" command failed softly: ${String(err)}`);
231
- return ephemeralResponse(`Couldn't complete that: ${err instanceof Error ? err.message : String(err)}`);
232
+ return ephemeralResponse(commandFailureText(err));
232
233
  }
233
234
  }),
234
235
  ],
@@ -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.
@@ -3539,17 +3552,20 @@ async function fetchChannelTranscript(channelId: string): Promise<string | undef
3539
3552
  // `handle` = the person's SELF-SET display name (Tag treats the handle as the better human-address
3540
3553
  // signal). Both are UNTRUSTED, attacker-controllable profile content — the wake renderer XML-escapes
3541
3554
  // them wherever they land in the envelope frame.
3542
- const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string } | undefined>>();
3543
- export function fetchUserProfileNames(userId: string): Promise<{ name?: string; handle?: string } | undefined> {
3555
+ const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string; email?: string } | undefined>>();
3556
+ export function fetchUserProfileNames(userId: string): Promise<{ name?: string; handle?: string; email?: string } | undefined> {
3544
3557
  let p = userProfileCache.get(userId);
3545
3558
  if (!p) {
3546
3559
  p = (async () => {
3547
3560
  const info = await slackForm("users.info", `user=${encodeURIComponent(userId)}`);
3548
3561
  if (info?.ok !== true) return undefined;
3549
- const user = info.user as { profile?: { display_name?: string; real_name?: string }; real_name?: string; name?: string } | undefined;
3562
+ const user = info.user as { profile?: { display_name?: string; real_name?: string; email?: string }; real_name?: string; name?: string } | undefined;
3550
3563
  const handle = user?.profile?.display_name?.trim() || undefined;
3551
3564
  const name = user?.profile?.real_name?.trim() || user?.real_name?.trim() || user?.name?.trim() || undefined;
3552
- return { name, handle };
3565
+ // The workspace-verified address, present only under the `users:read.email` scope (OPE-2482): the
3566
+ // identifier a person can check beside a self-set name.
3567
+ const email = user?.profile?.email?.trim() || undefined;
3568
+ return { name, handle, ...(email ? { email } : {}) };
3553
3569
  })();
3554
3570
  userProfileCache.set(userId, p);
3555
3571
  }
@@ -0,0 +1,55 @@
1
+ // slack-friendly-names — the Slack name resolver for the friendly-names guard (OPE-2482).
2
+ //
3
+ // The guard (core `channels/friendly-outbound.ts`) asks this resolver for a display name before it
4
+ // renders a raw Slack id in text a person reads. A member id answers the person's display name, a
5
+ // channel id answers `#name`, and the workspace id answers the install's recorded team name. Every
6
+ // lookup rides a cache that already exists (`users.info`, `conversations.info`), so a name costs one
7
+ // Slack call per id per instance. Any failure answers undefined: the id then renders as a mention or
8
+ // plain words, never as itself.
9
+
10
+ import type { FriendlyNameResolver } from "@open-everywhere/core/agent/adapter-api.js";
11
+ import { fetchChannelDisplayName, fetchUserDisplayName } from "./slack-events.js";
12
+ import { slackInstallState } from "../setup/install-state.js";
13
+
14
+ /** The lookups the resolver uses. Live code passes none; a test injects each. */
15
+ export interface SlackFriendlyNameDeps {
16
+ readonly userName?: (id: string) => Promise<string | undefined>;
17
+ readonly channelName?: (id: string) => Promise<string | undefined>;
18
+ readonly teamName?: (id: string) => Promise<string | undefined>;
19
+ }
20
+
21
+ // The install record is read at most once per TEAM_NAME_TTL_MS, and only a FOUND name is kept: a post
22
+ // path must not read the store per message, and a deployment that installs (or renames its workspace)
23
+ // after boot must still be named without a restart.
24
+ const TEAM_NAME_TTL_MS = 30 * 60_000;
25
+ let teamNameMemo: { readonly teamId: string; readonly name: string; readonly at: number } | undefined;
26
+
27
+ /** The workspace name, when the recorded install is that workspace. */
28
+ async function liveTeamName(id: string): Promise<string | undefined> {
29
+ if (teamNameMemo && teamNameMemo.teamId === id && Date.now() - teamNameMemo.at < TEAM_NAME_TTL_MS) return teamNameMemo.name;
30
+ const install = await slackInstallState().catch(() => undefined);
31
+ if (install?.teamId === id && install.teamName) {
32
+ teamNameMemo = { teamId: id, name: install.teamName, at: Date.now() };
33
+ return install.teamName;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** Build the Slack resolver. */
39
+ export function slackFriendlyNameResolver(deps: SlackFriendlyNameDeps = {}): FriendlyNameResolver {
40
+ return async (id) => {
41
+ if (/^[UW]/.test(id)) {
42
+ const name = await (deps.userName ?? fetchUserDisplayName)(id);
43
+ return name ? { label: name, kind: "person" } : undefined;
44
+ }
45
+ if (/^[CG]/.test(id)) {
46
+ const name = await (deps.channelName ?? fetchChannelDisplayName)(id);
47
+ return name ? { label: `#${name.replace(/^#/, "")}`, kind: "channel" } : undefined;
48
+ }
49
+ if (/^T/.test(id)) {
50
+ const name = await (deps.teamName ?? liveTeamName)(id);
51
+ return name ? { label: name, kind: "server" } : undefined;
52
+ }
53
+ return undefined;
54
+ };
55
+ }
@@ -120,7 +120,16 @@ 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,
128
+ admitAskedBotReply,
129
+ deploymentOrg,
123
130
  } from "@open-everywhere/core/agent/adapter-api.js";
131
+ import { botReplyCandidate, slackBotReplyFacts } from "./slack-bot-ask.js";
132
+ import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
124
133
  import {
125
134
  renderWakeMessage,
126
135
  slackChannelFactsFor,
@@ -219,7 +228,10 @@ export type FrontDecision =
219
228
  /** An `oe_home:` click on the Home tab (OPE-2451) — ours: run a button, or open an item's modal. */
220
229
  | { readonly kind: "home-click"; readonly reason: string; readonly click: ParsedHomeClick }
221
230
  /** 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 };
231
+ | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
232
+ /** An `app_context_changed` (OPE-2457) — ours, and nothing to do. The subscription exists only so
233
+ * Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
234
+ | { readonly kind: "context-change"; readonly reason: string };
223
235
 
224
236
  /**
225
237
  * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
@@ -446,6 +458,13 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
446
458
  if (open) return { kind: "home-open", reason: "app_home_opened (home tab) — publish the person's page", open };
447
459
  return { kind: "forward", reason: "app_home_opened for a tab other than home — nothing to publish" };
448
460
  }
461
+ if (eventType === "app_context_changed") {
462
+ // THE SIDE PANEL (OPE-2457). Slack fires this each time the person moves the panel to another
463
+ // room. The room that matters arrives on the `message.im` they then send (`app_context`), so a
464
+ // change alone wakes nothing — but it is acknowledged HERE rather than forwarded, because a
465
+ // forward costs eve a parse and a re-verify for every click the person makes in Slack.
466
+ return { kind: "context-change", reason: "app_context_changed — the room rides the next side-panel message; nothing to wake" };
467
+ }
449
468
  if (eventType === "user_change") {
450
469
  // OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours — automatic offboarding.
451
470
  return { kind: "user-change", reason: "user_change — a deactivation report candidate", env };
@@ -618,6 +637,10 @@ export interface FrontOptions {
618
637
  readonly handleHomeOpened?: (open: ParsedHomeOpen) => Promise<boolean>;
619
638
  readonly handleHomeClick?: (click: ParsedHomeClick) => Promise<boolean>;
620
639
  readonly handleHomeSubmission?: (submission: ParsedHomeSubmission) => Promise<boolean>;
640
+ /** OPE-2426 — the asked-thread exception for a bot-authored thread reply. Returns the relay to run (the
641
+ * reply goes to the asker's DM, never to this channel's seat), or null to fall through to the ordinary
642
+ * bot-author drop. Injected in tests; the live default is core's `admitAskedBotReply`. */
643
+ readonly admitBotReply?: (ev: ParsedMessageEvent, self: { readonly userId?: string; readonly botId?: string }) => Promise<{ readonly relay: () => Promise<unknown> } | null>;
621
644
  /** The domain memory canvas recheck a trust event runs (OPE-2451): a person joined the channel, or it
622
645
  * was shared. Injected in tests; the live default is slack-memory-canvas.ts's, over the live deps. */
623
646
  readonly recheckChannelCanvas?: (input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => Promise<unknown>;
@@ -667,6 +690,13 @@ export interface FrontOptions {
667
690
  * governance DM-delivery arm. Defaults to the live call; FAIL-CLOSED: undefined ⇒ the shortcut
668
691
  * wakes nothing and says so, never a wake in a guessed room. Injectable for tests. */
669
692
  readonly openDirectMessageChannel?: (userId: string) => Promise<string | undefined>;
693
+ /** The person opt-out read (OPE-2436): may the agent act on this person's content? Defaults to
694
+ * core's `readPersonOptOut`, which is fail-CLOSED (an unreadable store reads as opted out).
695
+ * Injectable for tests. */
696
+ readonly personOptOutFor?: (org: string, principalId: string) => Promise<PersonOptOutState>;
697
+ /** Tell one person, privately in their own DM, why a shortcut they pressed did nothing (OPE-2436).
698
+ * Best-effort. Defaults to `conversations.open` + `chat.postMessage`. Injectable for tests. */
699
+ readonly postPrivateNotice?: (userId: string, text: string) => Promise<void>;
670
700
  /** The trigger author's two membership facts, from ONE `users.info` probe — the exact pair every
671
701
  * wake route in slack-events.ts resolves. Two readers: the native `app_mention` forward's
672
702
  * external-policy gate, which calls it only when that policy is not inert (so a deployment on the
@@ -1062,10 +1092,18 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1062
1092
  const homeClicked = opts.handleHomeClick ?? ((click: ParsedHomeClick) => handleHomeClick(click, liveSlackHomeDeps()));
1063
1093
  const recheckCanvas = opts.recheckChannelCanvas ?? ((input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => recheckChannelCanvas(input, liveMemoryCanvasDeps()));
1064
1094
  const homeSubmitted = opts.handleHomeSubmission ?? ((submission: ParsedHomeSubmission) => handleHomeSubmission(submission, liveSlackHomeDeps()));
1095
+ const admitBotReply = opts.admitBotReply ?? ((ev: ParsedMessageEvent, self: { readonly userId?: string; readonly botId?: string }) => admitAskedBotReply(slackBotReplyFacts(ev, deploymentOrg(), self), ev.authoredText));
1065
1096
  const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
1066
1097
  const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
1067
1098
  const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
1068
1099
  const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
1100
+ const personOptOutFor = opts.personOptOutFor ?? readPersonOptOut;
1101
+ const postPrivateNotice =
1102
+ opts.postPrivateNotice ??
1103
+ (async (userId: string, text: string) => {
1104
+ const dm = await openDirectMessageChannel(userId);
1105
+ if (dm) await slackForm("chat.postMessage", new URLSearchParams({ channel: dm, text }).toString());
1106
+ });
1069
1107
  const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
1070
1108
  const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
1071
1109
  const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
@@ -1572,9 +1610,25 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1572
1610
  // by design (the seat is one long-lived session over the whole DM, so replaying history a live
1573
1611
  // session already holds would duplicate it) — the tail is exactly the case that reasoning does
1574
1612
  // not cover, because a rotation is when the session stops holding it.
1613
+ // THE SIDE PANEL (OPE-2457) AND THE PERSON OPT-OUT (OPE-2436) — two host facts for a 1:1 DM, in
1614
+ // one trusted note. The viewed room passes every strictest-default gate first
1615
+ // (slack-side-panel.ts); the opt-out tells the seat not to promise memory the write road will
1616
+ // refuse anyway. A group DM states neither: it has no single person, and no panel.
1617
+ const systemNote = isGroup
1618
+ ? undefined
1619
+ : slackDmSystemNote({
1620
+ viewedRoom: await sidePanelViewedRoom({
1621
+ room: ev.appContext,
1622
+ teamId: ev.teamId,
1623
+ partnerTrust,
1624
+ channelInternal: async (id) => (await channelFactsFor(id)).internal === true,
1625
+ }),
1626
+ personOptOut: ev.userId ? await personOptOutFor(p.org, ev.userId) : undefined,
1627
+ });
1575
1628
  const message = renderWakeMessage(ev, () => atIso, {
1576
1629
  dm: true,
1577
1630
  trust: wakeTrust,
1631
+ ...(systemNote === undefined ? {} : { systemNote }),
1578
1632
  // The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
1579
1633
  // and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
1580
1634
  // no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
@@ -1912,6 +1966,32 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1912
1966
  // Keyed on the click, not the message — a person may invoke the same shortcut on the same
1913
1967
  // message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
1914
1968
  if (!(await notYetSeenKey(shortcutWakeKey(sc), sev))) return new Response(null, { status: 200 });
1969
+ // 🔴 THE PERSON OPT-OUT (OPE-2436). A message shortcut hands the agent somebody's message to act
1970
+ // on — summarise it, answer it, file it. When the message's AUTHOR opted out of agent actions on
1971
+ // their content and somebody ELSE pressed the button, nothing wakes: one refusal row, and a
1972
+ // private line to the person who pressed it. The author's own shortcut on their own message is
1973
+ // their own request, so it wakes as before. The read is fail-closed (core `readPersonOptOut`).
1974
+ const shortcutAuthor = sc.kind === "message" ? sc.message?.userId : undefined;
1975
+ if (shortcutAuthor && shortcutAuthor !== sc.userId) {
1976
+ const p = resolvePartition(bindableFor(sev));
1977
+ const authorOptOut = await personOptOutFor(p.org, shortcutAuthor);
1978
+ if (authorOptOut.optedOut) {
1979
+ console.log(`[open-everywhere:slack-front] message shortcut "${sc.callbackId}" in ${shortcutChannelId}: refused — the message's author opted out`);
1980
+ args.waitUntil(
1981
+ Promise.allSettled([
1982
+ auditPersonOptOutRefusal(
1983
+ { org: p.org, platform: p.platform, workspace: p.workspace, surface: p.surface, principalKey: shortcutAuthor },
1984
+ "message-shortcut",
1985
+ { requestedBy: sc.userId, storeReadable: authorOptOut.readable },
1986
+ ),
1987
+ // An unreadable store is the fail-closed GUESS, never the author's decision, so the presser
1988
+ // is not told the author asked for anything.
1989
+ postPrivateNotice(sc.userId, authorOptOut.readable ? PERSON_OPT_OUT_SHORTCUT_NOTICE : PERSON_OPT_OUT_UNREADABLE_NOTICE),
1990
+ ]),
1991
+ );
1992
+ return new Response(null, { status: 200 });
1993
+ }
1994
+ }
1915
1995
  // BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
1916
1996
  // the in-memory one for this process's seat, the durable one for a delegated worker in
1917
1997
  // another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
@@ -2319,6 +2399,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2319
2399
  return OK();
2320
2400
  }
2321
2401
 
2402
+ if (decision.kind === "context-change") return OK();
2403
+
2322
2404
  if (decision.kind === "user-change") {
2323
2405
  // AUTOMATIC OFFBOARDING (OPE-2423). The signature above has authenticated the envelope, so its
2324
2406
  // `team_id` is the workspace that delivered this report. Every other decision — the
@@ -2416,9 +2498,30 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2416
2498
  if (!ev) return OK();
2417
2499
  persistAttachments(ev); // durable upload registry (task #107) — so a delegated worker can read this message's files
2418
2500
 
2501
+ // 🔴 THE ASKED-THREAD EXCEPTION (OPE-2426): the only road by which another bot's message WAKES the
2502
+ // agent on a real deployment. (An attributable bot's earlier post can still enter a woken seat's
2503
+ // backscroll as context-only awareness: slack-wake-backscroll.ts `isBackscrollVisible`.) The other bot admissions are the test-bed e2e driver settings (OPE-1023):
2504
+ // `wake.driverAllowlist` on the engagement and mention doors, channel messages included, and
2505
+ // `wake.e2eDrivers` on the DM door and the seat's own reads. A real deployment leaves both empty. A bot's reply in a thread where the person's DM asked THAT bot, inside the window, is
2506
+ // relayed to the person's DM and stops here: it never wakes this channel's seat, and the host posts
2507
+ // nothing in the thread. Every other bot message — a top-level post, a thread nobody asked it in, a
2508
+ // different bot, a late reply, an edit — gets null and falls through to the ordinary bot-author drop
2509
+ // below, unchanged. It runs BEFORE mention suppression, so a reply that also @mentions the agent is
2510
+ // still relayed. Its `app_mention` twin is dropped as a bot author further down: by
2511
+ // `shouldSupervisorWake` on the single-window road, or by the native mention handler (slack.ts). The relay
2512
+ // claims each message once, so a retried event relays nothing twice.
2513
+ const identity = await getBotIdentity();
2514
+ if (botReplyCandidate(ev)) {
2515
+ const admitted = await admitBotReply(ev, identity).catch(() => null);
2516
+ if (admitted) {
2517
+ args.waitUntil(admitted.relay().then((route) => console.log(`[open-everywhere:slack-front] asked-thread bot reply ${ev.channelId}/${ev.threadTs}: relayed to the asker's DM (${String(route)})`)));
2518
+ return OK();
2519
+ }
2520
+ }
2521
+
2419
2522
  // Mention-sibling suppression — see {@link mentionSiblingSuppressed} for the whole rule and for
2420
2523
  // why an EDIT is exempt from it.
2421
- const { userId: botUserId } = await getBotIdentity();
2524
+ const { userId: botUserId } = identity;
2422
2525
  if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
2423
2526
  return OK();
2424
2527
  }
@@ -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;