@open-everywhere/provider-slack 0.6.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 (46) hide show
  1. package/agent/app-manifest.ts +1 -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 +7 -4
  5. package/agent/channels/slack-friendly-names.ts +55 -0
  6. package/agent/channels/slack-front.ts +30 -1
  7. package/agent/channels/slack-memory-canvas.ts +7 -5
  8. package/agent/channels/slack-web-api.ts +4 -2
  9. package/agent/channels/slack.ts +7 -6
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/register.ts +12 -0
  12. package/dist/agent/app-manifest.js +1 -1
  13. package/dist/agent/app-manifest.js.map +1 -1
  14. package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
  15. package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
  16. package/dist/agent/channels/slack-bot-ask.js +91 -0
  17. package/dist/agent/channels/slack-bot-ask.js.map +1 -0
  18. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  19. package/dist/agent/channels/slack-commands.js +6 -6
  20. package/dist/agent/channels/slack-commands.js.map +1 -1
  21. package/dist/agent/channels/slack-events.d.ts +1 -0
  22. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-events.js +4 -1
  24. package/dist/agent/channels/slack-events.js.map +1 -1
  25. package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
  26. package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
  27. package/dist/agent/channels/slack-friendly-names.js +45 -0
  28. package/dist/agent/channels/slack-friendly-names.js.map +1 -0
  29. package/dist/agent/channels/slack-front.d.ts +10 -1
  30. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  31. package/dist/agent/channels/slack-front.js +24 -2
  32. package/dist/agent/channels/slack-front.js.map +1 -1
  33. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  34. package/dist/agent/channels/slack-memory-canvas.js +7 -6
  35. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  36. package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
  37. package/dist/agent/channels/slack-web-api.js +4 -2
  38. package/dist/agent/channels/slack-web-api.js.map +1 -1
  39. package/dist/agent/channels/slack.js +7 -3
  40. package/dist/agent/channels/slack.js.map +1 -1
  41. package/dist/agent/docs-generated.js +1 -1
  42. package/dist/agent/docs-generated.js.map +1 -1
  43. package/dist/agent/register.d.ts.map +1 -1
  44. package/dist/agent/register.js +11 -1
  45. package/dist/agent/register.js.map +1 -1
  46. package/package.json +11 -2
@@ -150,7 +150,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
150
150
  description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
151
151
  // Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
152
152
  // scripts/slack-app-manifest-test.ts fails if one is missing here.
153
- usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | help",
153
+ usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | watch [more | less | none] | help",
154
154
  should_escape: false,
155
155
  },
156
156
  ],
@@ -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
  ],
@@ -3552,17 +3552,20 @@ async function fetchChannelTranscript(channelId: string): Promise<string | undef
3552
3552
  // `handle` = the person's SELF-SET display name (Tag treats the handle as the better human-address
3553
3553
  // signal). Both are UNTRUSTED, attacker-controllable profile content — the wake renderer XML-escapes
3554
3554
  // them wherever they land in the envelope frame.
3555
- const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string } | undefined>>();
3556
- 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> {
3557
3557
  let p = userProfileCache.get(userId);
3558
3558
  if (!p) {
3559
3559
  p = (async () => {
3560
3560
  const info = await slackForm("users.info", `user=${encodeURIComponent(userId)}`);
3561
3561
  if (info?.ok !== true) return undefined;
3562
- 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;
3563
3563
  const handle = user?.profile?.display_name?.trim() || undefined;
3564
3564
  const name = user?.profile?.real_name?.trim() || user?.real_name?.trim() || user?.name?.trim() || undefined;
3565
- 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 } : {}) };
3566
3569
  })();
3567
3570
  userProfileCache.set(userId, p);
3568
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
+ }
@@ -125,7 +125,10 @@ import {
125
125
  PERSON_OPT_OUT_SHORTCUT_NOTICE,
126
126
  PERSON_OPT_OUT_UNREADABLE_NOTICE,
127
127
  type PersonOptOutState,
128
+ admitAskedBotReply,
129
+ deploymentOrg,
128
130
  } from "@open-everywhere/core/agent/adapter-api.js";
131
+ import { botReplyCandidate, slackBotReplyFacts } from "./slack-bot-ask.js";
129
132
  import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
130
133
  import {
131
134
  renderWakeMessage,
@@ -634,6 +637,10 @@ export interface FrontOptions {
634
637
  readonly handleHomeOpened?: (open: ParsedHomeOpen) => Promise<boolean>;
635
638
  readonly handleHomeClick?: (click: ParsedHomeClick) => Promise<boolean>;
636
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>;
637
644
  /** The domain memory canvas recheck a trust event runs (OPE-2451): a person joined the channel, or it
638
645
  * was shared. Injected in tests; the live default is slack-memory-canvas.ts's, over the live deps. */
639
646
  readonly recheckChannelCanvas?: (input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => Promise<unknown>;
@@ -1085,6 +1092,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1085
1092
  const homeClicked = opts.handleHomeClick ?? ((click: ParsedHomeClick) => handleHomeClick(click, liveSlackHomeDeps()));
1086
1093
  const recheckCanvas = opts.recheckChannelCanvas ?? ((input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => recheckChannelCanvas(input, liveMemoryCanvasDeps()));
1087
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));
1088
1096
  const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
1089
1097
  const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
1090
1098
  const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
@@ -2490,9 +2498,30 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2490
2498
  if (!ev) return OK();
2491
2499
  persistAttachments(ev); // durable upload registry (task #107) — so a delegated worker can read this message's files
2492
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
+
2493
2522
  // Mention-sibling suppression — see {@link mentionSiblingSuppressed} for the whole rule and for
2494
2523
  // why an EDIT is exempt from it.
2495
- const { userId: botUserId } = await getBotIdentity();
2524
+ const { userId: botUserId } = identity;
2496
2525
  if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
2497
2526
  return OK();
2498
2527
  }
@@ -40,6 +40,7 @@ import {
40
40
  domainsNamingSilo,
41
41
  getConfig,
42
42
  readDomainMemory,
43
+ memoryLineForReader,
43
44
  renderMemoryMarkdown,
44
45
  type Domain,
45
46
  type MemoryWriteEvent,
@@ -111,9 +112,10 @@ function memoryCanvasMarkdown(domain: Domain, groups: Parameters<typeof renderMe
111
112
  const name = agentDisplayName();
112
113
  const body = renderMemoryMarkdown(`What ${name} knows — ${domain.title ?? domain.name}`, groups, nowIso);
113
114
  const footer =
114
- `\n---\n${name} keeps this canvas up to date from this domain's memory. ` +
115
- `To correct or forget a line, ask ${name} in this channel. A direct edit to this canvas is replaced on the next memory change.\n`;
116
- const text = defuseCanvasText(body);
115
+ `\n---\n${name} keeps this canvas up to date. To correct or forget a line, ask ${name} in this channel. ` +
116
+ `Edits made directly to this canvas get replaced.\n`;
117
+ // OPE-2486 — the memory provenance carriers read as plain words on a canvas a whole channel reads.
118
+ const text = defuseCanvasText(memoryLineForReader(body));
117
119
  const cut = text.length > CANVAS_MARKDOWN_MAX ? text.slice(0, CANVAS_MARKDOWN_MAX) + "\n\n_This canvas is full. Ask in this channel for the rest._\n" : text;
118
120
  return cut + footer;
119
121
  }
@@ -181,8 +183,8 @@ function withdrawnCanvasMarkdown(): string {
181
183
  const name = agentDisplayName();
182
184
  return (
183
185
  `# ${name}'s memory canvas was withdrawn\n\n` +
184
- `This channel is shared outside the workspace, or one of its members is not a full member, so ${name} no longer shows a domain's memory here. ` +
185
- `The memory itself is unchanged. Members can still ask ${name} about it where the domain's memory is readable.\n`
186
+ `This channel includes guests or people from outside your organisation, so ${name} no longer shows its memory here. ` +
187
+ `The memory itself is unchanged. Members can still ask ${name} about it in an internal channel.\n`
186
188
  );
187
189
  }
188
190
 
@@ -40,7 +40,7 @@
40
40
  // `channels/rate-limit-retry.js`. When the attempts run out the call throws `RateLimitExhaustedError`
41
41
  // — a NAMED failure. Non-429 responses are untouched.
42
42
 
43
- import { withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
43
+ import { friendlySlackBody, withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
44
44
  import { slackBotToken } from "../setup/credentials.js";
45
45
 
46
46
  /** Slack's Web API base. A trailing slash is required — `new URL(operation, base)` resolves the
@@ -172,7 +172,9 @@ export async function callSlackWebApi(
172
172
  // still throws when no token resolves, so no ordinary call can silently lose its authorization
173
173
  // header by forgetting to pass one.
174
174
  const token = input.unauthenticated === true ? undefined : await resolveBotToken(input.botToken);
175
- const encoded = encodeSlackForm(input.body);
175
+ // THE FRIENDLY-NAMES GUARD (OPE-2482). A message method's text, blocks and attachments render every raw
176
+ // id as a mention, a name or plain words before the body is encoded. Every other method is unchanged.
177
+ const encoded = encodeSlackForm(await friendlySlackBody(input.operation, input.body));
176
178
  const url = new URL(input.operation, deps.apiBase ?? SLACK_API_BASE).toString();
177
179
  const send = (): Promise<Response> =>
178
180
  (deps.fetch ?? (globalThis.fetch as SlackFetchLike))(url, {
@@ -90,7 +90,7 @@ import {
90
90
  swallowRoomOp,
91
91
  domainRoster,
92
92
  } from "@open-everywhere/core/agent/adapter-api.js";
93
- import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
93
+ import { TURN_FAILED_TEXT, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
94
94
  import { loadConversationContext } from "./slack-context.js";
95
95
  import { slackSpendGate } from "./slack-spend-gate.js";
96
96
  import { slackTenantAdmission } from "./slack-tenant-fence.js";
@@ -1302,10 +1302,10 @@ const slackMentionChannel = slackChannel({
1302
1302
  }
1303
1303
  await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1304
1304
  try {
1305
- await postWakeText(
1306
- channel,
1307
- `:octagonal_sign: I hit an error while handling your request (${data.message}). Please try again or rephrase.`,
1308
- );
1305
+ // OPE-2486 — the raw error text never reaches the room: the log line below carries it
1306
+ // for an operator, and the person reads one plain line.
1307
+ console.warn(`[open-everywhere:slack] turn failed: ${data.message}`);
1308
+ await postWakeText(channel, `:octagonal_sign: ${TURN_FAILED_TEXT}`);
1309
1309
  } catch {
1310
1310
  /* best-effort */
1311
1311
  }
@@ -1319,7 +1319,8 @@ const slackMentionChannel = slackChannel({
1319
1319
  }
1320
1320
  await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1321
1321
  try {
1322
- await postWakeText(channel, `:octagonal_sign: Task failed: ${data.message}`);
1322
+ console.warn(`[open-everywhere:slack] session failed: ${data.message}`);
1323
+ await postWakeText(channel, `:octagonal_sign: ${TURN_FAILED_TEXT}`);
1323
1324
  } catch {
1324
1325
  /* best-effort */
1325
1326
  }