@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.
- package/agent/app-manifest.ts +1 -1
- package/agent/channels/slack-bot-ask.ts +101 -0
- package/agent/channels/slack-commands.ts +6 -5
- package/agent/channels/slack-events.ts +7 -4
- package/agent/channels/slack-friendly-names.ts +55 -0
- package/agent/channels/slack-front.ts +30 -1
- package/agent/channels/slack-memory-canvas.ts +7 -5
- package/agent/channels/slack-web-api.ts +4 -2
- package/agent/channels/slack.ts +7 -6
- package/agent/docs-generated.ts +1 -1
- package/agent/register.ts +12 -0
- package/dist/agent/app-manifest.js +1 -1
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
- package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
- package/dist/agent/channels/slack-bot-ask.js +91 -0
- package/dist/agent/channels/slack-bot-ask.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +6 -6
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +1 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +4 -1
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
- package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
- package/dist/agent/channels/slack-friendly-names.js +45 -0
- package/dist/agent/channels/slack-friendly-names.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +10 -1
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +24 -2
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.js +7 -6
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
- package/dist/agent/channels/slack-web-api.js +4 -2
- package/dist/agent/channels/slack-web-api.js.map +1 -1
- package/dist/agent/channels/slack.js +7 -3
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +11 -1
- package/dist/agent/register.js.map +1 -1
- package/package.json +11 -2
package/agent/app-manifest.ts
CHANGED
|
@@ -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
|
-
//
|
|
227
|
-
// (
|
|
228
|
-
//
|
|
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(
|
|
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
|
-
|
|
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 } =
|
|
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
|
|
115
|
-
`
|
|
116
|
-
|
|
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
|
|
185
|
-
`The memory itself is unchanged. Members can still ask ${name} about it
|
|
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
|
-
|
|
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, {
|
package/agent/channels/slack.ts
CHANGED
|
@@ -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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
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
|
}
|