@open-everywhere/provider-slack 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/app-manifest.ts +37 -1
- package/agent/channels/slack-events.ts +13 -0
- package/agent/channels/slack-front.ts +75 -1
- package/agent/channels/slack-home.ts +40 -5
- package/agent/channels/slack-side-panel.ts +104 -0
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +3 -0
- package/agent/register.ts +6 -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-events.d.ts +9 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +5 -0
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-front.d.ts +14 -1
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +54 -1
- 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-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/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 +6 -1
- package/dist/agent/register.js.map +1 -1
- package/package.json +9 -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] | help",
|
|
123
154
|
should_escape: false,
|
|
124
155
|
},
|
|
125
156
|
],
|
|
@@ -212,6 +243,9 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
|
|
|
212
243
|
// knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
|
|
213
244
|
// per channel, and memory itself is unaffected.
|
|
214
245
|
"canvases:write",
|
|
246
|
+
// The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
|
|
247
|
+
// subscription makes Slack attach the viewed room to each side-panel `message.im`.
|
|
248
|
+
"assistant:write",
|
|
215
249
|
// Slack requires this bot scope on an app whose people connect to Slack's MCP server
|
|
216
250
|
// (OPE-2421). Declared only when the app declares "search Slack as you".
|
|
217
251
|
...(personalMcp ? [SLACK_MCP_BOT_SCOPE] : []),
|
|
@@ -236,6 +270,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
|
|
|
236
270
|
// app_home_opened → no scope (the Home tab page, OPE-2451)
|
|
237
271
|
// channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
|
|
238
272
|
// user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
|
|
273
|
+
// app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
|
|
239
274
|
// scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
|
|
240
275
|
// this list drops one (OPE-2223).
|
|
241
276
|
bot_events: [
|
|
@@ -250,6 +285,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
|
|
|
250
285
|
"app_home_opened",
|
|
251
286
|
"channel_shared",
|
|
252
287
|
"user_change",
|
|
288
|
+
"app_context_changed",
|
|
253
289
|
],
|
|
254
290
|
},
|
|
255
291
|
// The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// envelope reaches `routeMessageEvent`. Until then this is unit-tested but unmounted.
|
|
18
18
|
|
|
19
19
|
import { slackContinuationToken } from "eve/channels/slack";
|
|
20
|
+
import { parseSlackAppContext, type SlackAppContextRoom } from "./slack-side-panel.js";
|
|
20
21
|
import { callSlackWebApi } from "./slack-web-api.js";
|
|
21
22
|
import { slackBotToken } from "../setup/credentials.js";
|
|
22
23
|
import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
|
|
@@ -265,6 +266,10 @@ export interface ParsedMessageEvent {
|
|
|
265
266
|
* the wake envelope so the agent knows an image exists and can `describe_image` it. Empty/
|
|
266
267
|
* absent when the message carries no files. Never the bytes — just id/name/mimetype/url. */
|
|
267
268
|
readonly attachments?: readonly WakeAttachment[];
|
|
269
|
+
/** The room the person was viewing in the side panel (OPE-2457), from the event's `app_context`.
|
|
270
|
+
* UNVERIFIED — slack-side-panel.ts `sidePanelViewedRoom` gates it before any use. Absent on every
|
|
271
|
+
* event that carries no channel entity, which is every non-panel message. */
|
|
272
|
+
readonly appContext?: SlackAppContextRoom;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
/** Parse Slack's `files[]` (on a message event's inner message) into attachment references.
|
|
@@ -710,6 +715,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
710
715
|
const teamId = envelope.team_id ?? (typeof e.team === "string" ? e.team : undefined);
|
|
711
716
|
const enterpriseId = slackEnterpriseIdFromEnvelope(envelope);
|
|
712
717
|
const attachments = parseSlackFiles(inner.files);
|
|
718
|
+
const appContext = parseSlackAppContext(e);
|
|
713
719
|
// TRUSTED PROVENANCE: record each IMAGE attachment's url_private under its Slack file id so
|
|
714
720
|
// describe_image's `attachmentId` path (via the attachment plane's Slack resolver) can resolve the
|
|
715
721
|
// model-named id → this event's url_private and fetch it with the bot token
|
|
@@ -738,6 +744,8 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
738
744
|
// Image/file attachments (docs Part B) — read off the same inner message the text came
|
|
739
745
|
// from (so an edit's post-edit files win). Absent for the common no-file message.
|
|
740
746
|
attachments,
|
|
747
|
+
// The side panel's viewed room (OPE-2457) rides the OUTER event, never an edit's inner message.
|
|
748
|
+
...(appContext === undefined ? {} : { appContext }),
|
|
741
749
|
};
|
|
742
750
|
}
|
|
743
751
|
|
|
@@ -1058,6 +1066,10 @@ export interface SlackWakeOpts {
|
|
|
1058
1066
|
* (channels/seat-catch-up.ts `catchUpUnavailableReason`). Rendered as a trusted `<system-note>`;
|
|
1059
1067
|
* unset ⇒ no note, which is every Slack wake, because Slack registers a fetch. */
|
|
1060
1068
|
readonly catchUpUnavailable?: string;
|
|
1069
|
+
/** OE's own trusted note for this wake (`<system-note>`): fixed words around host-checked ids only.
|
|
1070
|
+
* Set by the DM door alone — the side panel's viewed room and the person's opt-out
|
|
1071
|
+
* (slack-side-panel.ts `slackDmSystemNote`). Unset ⇒ no note, byte-identical to before. */
|
|
1072
|
+
readonly systemNote?: string;
|
|
1061
1073
|
/**
|
|
1062
1074
|
* THE NAME-DROP HYDRATION, ON THE SEAT ROAD (OPE-1725). The surrounding conversation
|
|
1063
1075
|
* {@link routeNameDropEvent} fetches for a name-drop, as RAW {@link ContextLine}s — renderWake
|
|
@@ -1142,6 +1154,7 @@ function slackJudgedWake(
|
|
|
1142
1154
|
...(addressing === "name-drop" ? { nameDrop: { followUp: "follows-on-reply" as const } } : {}),
|
|
1143
1155
|
// The catch-up bound, stated on the wake that could not take one ({@link SlackWakeOpts.catchUpUnavailable}).
|
|
1144
1156
|
...(opts.catchUpUnavailable !== undefined ? { catchUpUnavailable: opts.catchUpUnavailable } : {}),
|
|
1157
|
+
...(opts.systemNote !== undefined ? { systemNote: opts.systemNote } : {}),
|
|
1145
1158
|
// The name-drop hydration's side-channel slot ({@link SlackWakeOpts.contextLines}). Spread
|
|
1146
1159
|
// conditionally so a wake that states none carries no key at all — the shape every
|
|
1147
1160
|
// characterization golden was taken against.
|
|
@@ -120,7 +120,13 @@ import {
|
|
|
120
120
|
tailAuthorGrain,
|
|
121
121
|
dmWindowRegistryKey,
|
|
122
122
|
stampSurfaceEventVerified,
|
|
123
|
+
readPersonOptOut,
|
|
124
|
+
auditPersonOptOutRefusal,
|
|
125
|
+
PERSON_OPT_OUT_SHORTCUT_NOTICE,
|
|
126
|
+
PERSON_OPT_OUT_UNREADABLE_NOTICE,
|
|
127
|
+
type PersonOptOutState,
|
|
123
128
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
129
|
+
import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
|
|
124
130
|
import {
|
|
125
131
|
renderWakeMessage,
|
|
126
132
|
slackChannelFactsFor,
|
|
@@ -219,7 +225,10 @@ export type FrontDecision =
|
|
|
219
225
|
/** An `oe_home:` click on the Home tab (OPE-2451) — ours: run a button, or open an item's modal. */
|
|
220
226
|
| { readonly kind: "home-click"; readonly reason: string; readonly click: ParsedHomeClick }
|
|
221
227
|
/** A submission of the Home tab's own modal (OPE-2451) — ours: run the action, republish the page. */
|
|
222
|
-
| { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
|
|
228
|
+
| { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
|
|
229
|
+
/** An `app_context_changed` (OPE-2457) — ours, and nothing to do. The subscription exists only so
|
|
230
|
+
* Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
|
|
231
|
+
| { readonly kind: "context-change"; readonly reason: string };
|
|
223
232
|
|
|
224
233
|
/**
|
|
225
234
|
* Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
|
|
@@ -446,6 +455,13 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
|
|
|
446
455
|
if (open) return { kind: "home-open", reason: "app_home_opened (home tab) — publish the person's page", open };
|
|
447
456
|
return { kind: "forward", reason: "app_home_opened for a tab other than home — nothing to publish" };
|
|
448
457
|
}
|
|
458
|
+
if (eventType === "app_context_changed") {
|
|
459
|
+
// THE SIDE PANEL (OPE-2457). Slack fires this each time the person moves the panel to another
|
|
460
|
+
// room. The room that matters arrives on the `message.im` they then send (`app_context`), so a
|
|
461
|
+
// change alone wakes nothing — but it is acknowledged HERE rather than forwarded, because a
|
|
462
|
+
// forward costs eve a parse and a re-verify for every click the person makes in Slack.
|
|
463
|
+
return { kind: "context-change", reason: "app_context_changed — the room rides the next side-panel message; nothing to wake" };
|
|
464
|
+
}
|
|
449
465
|
if (eventType === "user_change") {
|
|
450
466
|
// OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours — automatic offboarding.
|
|
451
467
|
return { kind: "user-change", reason: "user_change — a deactivation report candidate", env };
|
|
@@ -667,6 +683,13 @@ export interface FrontOptions {
|
|
|
667
683
|
* governance DM-delivery arm. Defaults to the live call; FAIL-CLOSED: undefined ⇒ the shortcut
|
|
668
684
|
* wakes nothing and says so, never a wake in a guessed room. Injectable for tests. */
|
|
669
685
|
readonly openDirectMessageChannel?: (userId: string) => Promise<string | undefined>;
|
|
686
|
+
/** The person opt-out read (OPE-2436): may the agent act on this person's content? Defaults to
|
|
687
|
+
* core's `readPersonOptOut`, which is fail-CLOSED (an unreadable store reads as opted out).
|
|
688
|
+
* Injectable for tests. */
|
|
689
|
+
readonly personOptOutFor?: (org: string, principalId: string) => Promise<PersonOptOutState>;
|
|
690
|
+
/** Tell one person, privately in their own DM, why a shortcut they pressed did nothing (OPE-2436).
|
|
691
|
+
* Best-effort. Defaults to `conversations.open` + `chat.postMessage`. Injectable for tests. */
|
|
692
|
+
readonly postPrivateNotice?: (userId: string, text: string) => Promise<void>;
|
|
670
693
|
/** The trigger author's two membership facts, from ONE `users.info` probe — the exact pair every
|
|
671
694
|
* wake route in slack-events.ts resolves. Two readers: the native `app_mention` forward's
|
|
672
695
|
* external-policy gate, which calls it only when that policy is not inert (so a deployment on the
|
|
@@ -1066,6 +1089,13 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1066
1089
|
const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
|
|
1067
1090
|
const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
|
|
1068
1091
|
const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
|
|
1092
|
+
const personOptOutFor = opts.personOptOutFor ?? readPersonOptOut;
|
|
1093
|
+
const postPrivateNotice =
|
|
1094
|
+
opts.postPrivateNotice ??
|
|
1095
|
+
(async (userId: string, text: string) => {
|
|
1096
|
+
const dm = await openDirectMessageChannel(userId);
|
|
1097
|
+
if (dm) await slackForm("chat.postMessage", new URLSearchParams({ channel: dm, text }).toString());
|
|
1098
|
+
});
|
|
1069
1099
|
const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
|
|
1070
1100
|
const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
|
|
1071
1101
|
const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
|
|
@@ -1572,9 +1602,25 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1572
1602
|
// by design (the seat is one long-lived session over the whole DM, so replaying history a live
|
|
1573
1603
|
// session already holds would duplicate it) — the tail is exactly the case that reasoning does
|
|
1574
1604
|
// not cover, because a rotation is when the session stops holding it.
|
|
1605
|
+
// THE SIDE PANEL (OPE-2457) AND THE PERSON OPT-OUT (OPE-2436) — two host facts for a 1:1 DM, in
|
|
1606
|
+
// one trusted note. The viewed room passes every strictest-default gate first
|
|
1607
|
+
// (slack-side-panel.ts); the opt-out tells the seat not to promise memory the write road will
|
|
1608
|
+
// refuse anyway. A group DM states neither: it has no single person, and no panel.
|
|
1609
|
+
const systemNote = isGroup
|
|
1610
|
+
? undefined
|
|
1611
|
+
: slackDmSystemNote({
|
|
1612
|
+
viewedRoom: await sidePanelViewedRoom({
|
|
1613
|
+
room: ev.appContext,
|
|
1614
|
+
teamId: ev.teamId,
|
|
1615
|
+
partnerTrust,
|
|
1616
|
+
channelInternal: async (id) => (await channelFactsFor(id)).internal === true,
|
|
1617
|
+
}),
|
|
1618
|
+
personOptOut: ev.userId ? await personOptOutFor(p.org, ev.userId) : undefined,
|
|
1619
|
+
});
|
|
1575
1620
|
const message = renderWakeMessage(ev, () => atIso, {
|
|
1576
1621
|
dm: true,
|
|
1577
1622
|
trust: wakeTrust,
|
|
1623
|
+
...(systemNote === undefined ? {} : { systemNote }),
|
|
1578
1624
|
// The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
|
|
1579
1625
|
// and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
|
|
1580
1626
|
// no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
|
|
@@ -1912,6 +1958,32 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1912
1958
|
// Keyed on the click, not the message — a person may invoke the same shortcut on the same
|
|
1913
1959
|
// message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
|
|
1914
1960
|
if (!(await notYetSeenKey(shortcutWakeKey(sc), sev))) return new Response(null, { status: 200 });
|
|
1961
|
+
// 🔴 THE PERSON OPT-OUT (OPE-2436). A message shortcut hands the agent somebody's message to act
|
|
1962
|
+
// on — summarise it, answer it, file it. When the message's AUTHOR opted out of agent actions on
|
|
1963
|
+
// their content and somebody ELSE pressed the button, nothing wakes: one refusal row, and a
|
|
1964
|
+
// private line to the person who pressed it. The author's own shortcut on their own message is
|
|
1965
|
+
// their own request, so it wakes as before. The read is fail-closed (core `readPersonOptOut`).
|
|
1966
|
+
const shortcutAuthor = sc.kind === "message" ? sc.message?.userId : undefined;
|
|
1967
|
+
if (shortcutAuthor && shortcutAuthor !== sc.userId) {
|
|
1968
|
+
const p = resolvePartition(bindableFor(sev));
|
|
1969
|
+
const authorOptOut = await personOptOutFor(p.org, shortcutAuthor);
|
|
1970
|
+
if (authorOptOut.optedOut) {
|
|
1971
|
+
console.log(`[open-everywhere:slack-front] message shortcut "${sc.callbackId}" in ${shortcutChannelId}: refused — the message's author opted out`);
|
|
1972
|
+
args.waitUntil(
|
|
1973
|
+
Promise.allSettled([
|
|
1974
|
+
auditPersonOptOutRefusal(
|
|
1975
|
+
{ org: p.org, platform: p.platform, workspace: p.workspace, surface: p.surface, principalKey: shortcutAuthor },
|
|
1976
|
+
"message-shortcut",
|
|
1977
|
+
{ requestedBy: sc.userId, storeReadable: authorOptOut.readable },
|
|
1978
|
+
),
|
|
1979
|
+
// An unreadable store is the fail-closed GUESS, never the author's decision, so the presser
|
|
1980
|
+
// is not told the author asked for anything.
|
|
1981
|
+
postPrivateNotice(sc.userId, authorOptOut.readable ? PERSON_OPT_OUT_SHORTCUT_NOTICE : PERSON_OPT_OUT_UNREADABLE_NOTICE),
|
|
1982
|
+
]),
|
|
1983
|
+
);
|
|
1984
|
+
return new Response(null, { status: 200 });
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1915
1987
|
// BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
|
|
1916
1988
|
// the in-memory one for this process's seat, the durable one for a delegated worker in
|
|
1917
1989
|
// another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
|
|
@@ -2319,6 +2391,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2319
2391
|
return OK();
|
|
2320
2392
|
}
|
|
2321
2393
|
|
|
2394
|
+
if (decision.kind === "context-change") return OK();
|
|
2395
|
+
|
|
2322
2396
|
if (decision.kind === "user-change") {
|
|
2323
2397
|
// AUTOMATIC OFFBOARDING (OPE-2423). The signature above has authenticated the envelope, so its
|
|
2324
2398
|
// `team_id` is the workspace that delivered this report. Every other decision — the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// THE SLACK APP HOME TAB (OPE-2451) — the person's own page: what the agent remembers about them,
|
|
2
|
-
// what their domains know, and (OPE-2456
|
|
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;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// THE SLACK SIDE PANEL (OPE-2457) — the person's own DM seat, opened beside whatever they are reading.
|
|
2
|
+
//
|
|
3
|
+
// Slack's agent experience (the `agent_view` manifest feature, docs.slack.dev/ai) shows the app's
|
|
4
|
+
// Messages tab in a panel the person can open from anywhere in Slack. A message the person sends there
|
|
5
|
+
// arrives as an ordinary `message.im`, so it wakes the SAME DM seat a DM wakes: no new seat type, no
|
|
6
|
+
// new session, no new grant. What the panel adds is one fact — the room the person is looking at —
|
|
7
|
+
// which Slack attaches to the `message.im` as `app_context` once the app subscribes to
|
|
8
|
+
// `app_context_changed`.
|
|
9
|
+
//
|
|
10
|
+
// 🔴 THE VIEWED ROOM IS CONTEXT, NEVER ACCESS. The seat learns the room's id in a trusted host note
|
|
11
|
+
// and nothing else: no history, no name, no member list. A read of that room still goes through the
|
|
12
|
+
// rules that already apply to the DM, so opening the panel beside a channel gives the seat none of
|
|
13
|
+
// that channel's reads. The note says so to the model in the same words.
|
|
14
|
+
//
|
|
15
|
+
// 🔴 THE STRICTEST DEFAULT FOR GUESTS AND EXTERNAL ROOMS. The id rides the note only when ALL hold:
|
|
16
|
+
// • the person is a full member of the workspace (the DM-partner probe answered `internal`);
|
|
17
|
+
// • the entity is a channel id of THIS workspace (its `team_id` equals the event's team);
|
|
18
|
+
// • Slack confirms the channel internal (`conversations.info`, the same TTL-cached, fail-closed
|
|
19
|
+
// verdict every wake route reads) — a Slack Connect channel, or one the app cannot see, fails it.
|
|
20
|
+
// Anything else passes nothing, silently: the person still gets an answer, just without the room.
|
|
21
|
+
|
|
22
|
+
/** Slack's entity type for a conversation id inside `app_context.entities`. */
|
|
23
|
+
export const SLACK_CHANNEL_ENTITY_TYPE = "slack#/types/channel_id";
|
|
24
|
+
|
|
25
|
+
/** A public (`C…`) or private (`G…`) channel id. A DM (`D…`) is not a room the panel adds. */
|
|
26
|
+
const CHANNEL_ID = /^[CG][A-Z0-9]{2,}$/u;
|
|
27
|
+
|
|
28
|
+
/** The room the person was viewing when they wrote, as Slack reported it. Unverified until
|
|
29
|
+
* {@link sidePanelViewedRoom} checks it. */
|
|
30
|
+
export interface SlackAppContextRoom {
|
|
31
|
+
readonly channelId: string;
|
|
32
|
+
/** The workspace the entity belongs to. */
|
|
33
|
+
readonly teamId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The most relevant CHANNEL entity of a `message.im`'s `app_context`, or undefined. Slack orders
|
|
38
|
+
* entities by relevance, so the first channel entity wins. Pure and tolerant: any other shape answers
|
|
39
|
+
* undefined, which is the no-context default.
|
|
40
|
+
*/
|
|
41
|
+
export function parseSlackAppContext(event: Readonly<Record<string, unknown>>): SlackAppContextRoom | undefined {
|
|
42
|
+
const ctx = event.app_context;
|
|
43
|
+
if (ctx === null || typeof ctx !== "object") return undefined;
|
|
44
|
+
const entities = (ctx as { entities?: unknown }).entities;
|
|
45
|
+
if (!Array.isArray(entities)) return undefined;
|
|
46
|
+
for (const raw of entities) {
|
|
47
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
48
|
+
const e = raw as Record<string, unknown>;
|
|
49
|
+
if (e.type !== SLACK_CHANNEL_ENTITY_TYPE || typeof e.value !== "string" || !CHANNEL_ID.test(e.value)) continue;
|
|
50
|
+
return { channelId: e.value, ...(typeof e.team_id === "string" && e.team_id.length > 0 ? { teamId: e.team_id } : {}) };
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What {@link sidePanelViewedRoom} needs from the wake. */
|
|
56
|
+
export interface SidePanelGate {
|
|
57
|
+
readonly room: SlackAppContextRoom | undefined;
|
|
58
|
+
/** The DM's own workspace. */
|
|
59
|
+
readonly teamId: string | undefined;
|
|
60
|
+
/** The DM-partner probe's verdict on the person. Only `internal` admits a room. */
|
|
61
|
+
readonly partnerTrust: "internal" | "external" | undefined;
|
|
62
|
+
/** Slack's fail-closed internal verdict for one channel. */
|
|
63
|
+
readonly channelInternal: (channelId: string) => Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The viewed room's id, or undefined when any gate in the module header refuses it. Never throws. */
|
|
67
|
+
export async function sidePanelViewedRoom(gate: SidePanelGate): Promise<string | undefined> {
|
|
68
|
+
const { room } = gate;
|
|
69
|
+
if (!room || gate.partnerTrust !== "internal") return undefined;
|
|
70
|
+
if (!gate.teamId || room.teamId !== gate.teamId) return undefined;
|
|
71
|
+
const internal = await gate.channelInternal(room.channelId).catch(() => false);
|
|
72
|
+
return internal ? room.channelId : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The trusted host note for a DM-seat wake: the viewed room, and the person's opt-out (OPE-2436), in
|
|
77
|
+
* FIXED words around host-checked ids — the only text the envelope's `<system-note>` may carry (see
|
|
78
|
+
* `WakeEnvelopeInput.systemNote`). Undefined when there is nothing to say.
|
|
79
|
+
*/
|
|
80
|
+
export function slackDmSystemNote(input: {
|
|
81
|
+
readonly viewedRoom?: string;
|
|
82
|
+
/** The person's opt-out answer. `readable: false` is the fail-closed guess, and gets its own line. */
|
|
83
|
+
readonly personOptOut?: { readonly optedOut: boolean; readonly readable: boolean };
|
|
84
|
+
}): string | undefined {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
if (input.viewedRoom) {
|
|
87
|
+
lines.push(
|
|
88
|
+
`The person wrote this from the side panel while viewing the room ${input.viewedRoom} (Slack shows <#${input.viewedRoom}> as its link). ` +
|
|
89
|
+
"That is context only. It gives this conversation none of that room's access: a read of that room follows the rules that already apply here.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (input.personOptOut?.optedOut && input.personOptOut.readable) {
|
|
93
|
+
lines.push(
|
|
94
|
+
"This person has opted out of agent actions on their own content. Nothing from this conversation is saved to memory. " +
|
|
95
|
+
"Do not promise to remember anything. If they ask, tell them their opt-out is on.",
|
|
96
|
+
);
|
|
97
|
+
} else if (input.personOptOut?.optedOut) {
|
|
98
|
+
lines.push(
|
|
99
|
+
"The person's opt-out setting could not be read just now, so this turn may not be saved to memory. " +
|
|
100
|
+
"Do not promise to remember anything, and do not tell them their opt-out is on.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return lines.length > 0 ? lines.join(" ") : undefined;
|
|
104
|
+
}
|