@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.
- package/agent/app-manifest.ts +37 -1
- package/agent/channels/slack-bot-ask.ts +101 -0
- package/agent/channels/slack-commands.ts +6 -5
- package/agent/channels/slack-events.ts +20 -4
- package/agent/channels/slack-friendly-names.ts +55 -0
- package/agent/channels/slack-front.ts +105 -2
- package/agent/channels/slack-home.ts +40 -5
- package/agent/channels/slack-memory-canvas.ts +7 -5
- package/agent/channels/slack-side-panel.ts +104 -0
- 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/ops.ts +3 -0
- package/agent/register.ts +18 -0
- package/dist/agent/app-manifest.d.ts +12 -0
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +25 -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 +10 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +9 -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 +24 -2
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +77 -2
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts +9 -1
- package/dist/agent/channels/slack-home.d.ts.map +1 -1
- package/dist/agent/channels/slack-home.js +33 -5
- package/dist/agent/channels/slack-home.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-side-panel.d.ts +41 -0
- package/dist/agent/channels/slack-side-panel.d.ts.map +1 -0
- package/dist/agent/channels/slack-side-panel.js +78 -0
- package/dist/agent/channels/slack-side-panel.js.map +1 -0
- 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/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +3 -1
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +16 -1
- package/dist/agent/register.js.map +1 -1
- package/package.json +18 -3
package/agent/app-manifest.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
],
|
|
@@ -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
|
-
|
|
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 } =
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|