@open-everywhere/provider-slack 0.6.0 → 0.8.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/access/slack-attachment-fetch.ts +2 -1
- package/agent/app-manifest.ts +15 -1
- package/agent/channels/slack-agent-session.ts +249 -0
- package/agent/channels/slack-attachments.ts +20 -11
- package/agent/channels/slack-bot-ask.ts +101 -0
- package/agent/channels/slack-commands.ts +178 -17
- package/agent/channels/slack-context.ts +0 -5
- package/agent/channels/slack-events.ts +110 -33
- package/agent/channels/slack-follow-ups.ts +148 -0
- package/agent/channels/slack-friendly-names.ts +55 -0
- package/agent/channels/slack-front.ts +262 -27
- package/agent/channels/slack-home.ts +9 -3
- package/agent/channels/slack-identity.ts +9 -1
- package/agent/channels/slack-memory-canvas.ts +197 -54
- package/agent/channels/slack-origin-codec.ts +9 -0
- package/agent/channels/slack-post-arm.ts +68 -11
- package/agent/channels/slack-read.ts +27 -2
- package/agent/channels/slack-reply-blocks.ts +180 -0
- package/agent/channels/slack-reply-feedback.ts +75 -0
- package/agent/channels/slack-shortcut.ts +1 -1
- package/agent/channels/slack-voice-clips.ts +82 -0
- package/agent/channels/slack-wake-backscroll.ts +0 -5
- package/agent/channels/slack-web-api.ts +4 -2
- package/agent/channels/slack.ts +81 -19
- package/agent/docs-generated.ts +1282 -2
- package/agent/ops.ts +14 -21
- package/agent/register.ts +25 -0
- package/agent/setup/credentials.ts +17 -0
- package/agent/setup/page.ts +6 -3
- package/agent/surface-parity.ts +2 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +15 -1
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-agent-session.d.ts +81 -0
- package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
- package/dist/agent/channels/slack-agent-session.js +198 -0
- package/dist/agent/channels/slack-agent-session.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +11 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
- package/dist/agent/channels/slack-attachments.js +21 -14
- package/dist/agent/channels/slack-attachments.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 +113 -10
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +151 -18
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-context.d.ts +1 -2
- package/dist/agent/channels/slack-context.d.ts.map +1 -1
- package/dist/agent/channels/slack-context.js +0 -4
- package/dist/agent/channels/slack-context.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +47 -97
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +90 -22
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
- package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
- package/dist/agent/channels/slack-follow-ups.js +129 -0
- package/dist/agent/channels/slack-follow-ups.js.map +1 -0
- 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 +38 -46
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +230 -23
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts.map +1 -1
- package/dist/agent/channels/slack-home.js +10 -3
- package/dist/agent/channels/slack-home.js.map +1 -1
- package/dist/agent/channels/slack-identity.d.ts +7 -21
- package/dist/agent/channels/slack-identity.d.ts.map +1 -1
- package/dist/agent/channels/slack-identity.js +3 -1
- package/dist/agent/channels/slack-identity.js.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.js +166 -55
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/slack-origin-codec.js +10 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -1
- package/dist/agent/channels/slack-post-arm.d.ts +21 -1
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
- package/dist/agent/channels/slack-post-arm.js +57 -10
- package/dist/agent/channels/slack-post-arm.js.map +1 -1
- package/dist/agent/channels/slack-read.d.ts +11 -1
- package/dist/agent/channels/slack-read.d.ts.map +1 -1
- package/dist/agent/channels/slack-read.js +27 -3
- package/dist/agent/channels/slack-read.js.map +1 -1
- package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
- package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-blocks.js +172 -0
- package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.js +63 -0
- package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +10 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
- package/dist/agent/channels/slack-shortcut.js +1 -1
- package/dist/agent/channels/slack-shortcut.js.map +1 -1
- package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
- package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
- package/dist/agent/channels/slack-voice-clips.js +80 -0
- package/dist/agent/channels/slack-voice-clips.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
- package/dist/agent/channels/slack-wake-backscroll.js +0 -4
- package/dist/agent/channels/slack-wake-backscroll.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.d.ts +22 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +70 -16
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +1282 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +15 -21
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +22 -1
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/credentials.d.ts +12 -0
- package/dist/agent/setup/credentials.d.ts.map +1 -1
- package/dist/agent/setup/credentials.js +17 -0
- package/dist/agent/setup/credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +6 -3
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +2 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +40 -3
|
@@ -36,18 +36,23 @@ 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,
|
|
42
43
|
parseSlackCommandText,
|
|
43
44
|
resolvePartition,
|
|
45
|
+
auditSurfaceModeFrom,
|
|
44
46
|
getOperationalStore,
|
|
45
47
|
admittedTenantsFromConfig,
|
|
46
48
|
unadmittedTenantLog,
|
|
47
49
|
UNADMITTED_TENANT_DROP_REASON,
|
|
48
50
|
type BindableContext,
|
|
51
|
+
type SurfaceBinding,
|
|
49
52
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
50
53
|
import { slackTenantAdmission } from "./slack-tenant-fence.js";
|
|
54
|
+
import { slackAuthorIsMember, slackChannelFactsFor } from "./slack-events.js";
|
|
55
|
+
import { slackMode } from "./slack-identity.js";
|
|
51
56
|
|
|
52
57
|
function log(msg: string): void {
|
|
53
58
|
console.log(`[open-everywhere:slack-commands] ${msg}`);
|
|
@@ -63,6 +68,21 @@ function formField(params: URLSearchParams, name: string): string | undefined {
|
|
|
63
68
|
return v && v.length > 0 ? v : undefined;
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/** What the route learned about a command's trust before it binds. `dmPartnerTrust` is the invoker's own
|
|
72
|
+
* verdict in a 1:1 DM. `room` is a channel's `conversations.info` verdict, present whenever Slack answered
|
|
73
|
+
* it, whoever typed the command. `invokerMember` is present only when `users.info` CONFIRMED the invoker a
|
|
74
|
+
* member (see {@link slashRoomVerdict}). The person's binding stamps `room` only beside `invokerMember`;
|
|
75
|
+
* the room's binding stamps it alone (OPE-2588). */
|
|
76
|
+
export interface SlashTrustFacts {
|
|
77
|
+
readonly dmPartnerTrust?: "internal" | "external";
|
|
78
|
+
readonly room?: { readonly extShared: boolean };
|
|
79
|
+
readonly invokerMember?: true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Which verdict a slash binding resolves: the PERSON's (the room's verdict folded with the invoker
|
|
83
|
+
* check, which every member gate reads) or the ROOM's alone (which the room's budget reads). */
|
|
84
|
+
export type SlashTrustLeg = "person" | "room";
|
|
85
|
+
|
|
66
86
|
/** Build the BindableContext the identity seam (bindFrom/bindSlack) expects from Slack's
|
|
67
87
|
* slash-command form fields. Slack's slash-command payload carries no `channel_type` /
|
|
68
88
|
* `is_private` bit (unlike an app_mention/message event) — `bindSlack`'s existing fallbacks
|
|
@@ -75,16 +95,36 @@ function formField(params: URLSearchParams, name: string): string | undefined {
|
|
|
75
95
|
* reads — so a Grid declared once now answers here the same way it answers at every other door,
|
|
76
96
|
* instead of resolving as though the line were absent.
|
|
77
97
|
*
|
|
78
|
-
* 🔴 STATE ITS REACH EXACTLY
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
98
|
+
* 🔴 STATE ITS REACH EXACTLY. For a CHANNEL command the rung does NOT change `binding.trust`: the
|
|
99
|
+
* room's own verdict answers there (see below), and `bindFrom` takes that raw verdict ahead of any
|
|
100
|
+
* vouch. The rung reaches `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), and only
|
|
101
|
+
* when the invoker probe gave no verdict: the mapper then leaves trust undefined and the vouch
|
|
102
|
+
* answers (see below). Spread conditionally, like every other door: a single-workspace install
|
|
103
|
+
* stamps nothing.
|
|
104
|
+
*
|
|
105
|
+
* 🔴 A CHANNEL COMMAND CARRIES THE ROOM'S OWN VERDICT (OPE-2579). Slack's slash payload carries no
|
|
106
|
+
* sharing bit, so before this every channel `/oe` bound `slackTrust(undefined, undefined,
|
|
107
|
+
* "channel")`, which fails closed to `external`. A member in an internal channel was then refused
|
|
108
|
+
* `/oe suggest on` as an outsider. The route now passes {@link slashRoomVerdict}'s answer as
|
|
109
|
+
* `is_ext_shared` + `trust_checked`, the two attributes a channel message stamps from the same
|
|
110
|
+
* TTL-cached `conversations.info`. So a channel command reads the room's `internal` or `external` as a
|
|
111
|
+
* message in that room does. On the `person` leg (the default) the verdict is stamped only for an
|
|
112
|
+
* invoker `users.info` confirms a member. On the `room` leg it is stamped for anybody, because that
|
|
113
|
+
* leg answers the room's budget, not a member gate (OPE-2588, {@link bindSlashCommand}). No verdict
|
|
114
|
+
* stamps nothing, and the command stays fail-closed `external`.
|
|
115
|
+
*
|
|
116
|
+
* 🔴 A DM COMMAND CARRIES THE INVOKER'S OWN VERDICT (OPE-2579). In a 1:1 DM the invoker IS the
|
|
117
|
+
* partner, and the route passes {@link slashInvokerDmTrust}'s answer as `dm_partner_trust`, the
|
|
118
|
+
* attribute the chat DM leg stamps from the same `users.info` probe. Before this, a workspace vouch
|
|
119
|
+
* alone answered a DM command, so a guest or a Connect stranger in their DM with the app read
|
|
120
|
+
* `internal` here while the same person read `external` in a DM turn. A verdict outranks the vouch
|
|
121
|
+
* in both directions, exactly as in a DM turn: a guest reads `external` under a workspace vouch, and
|
|
122
|
+
* a confirmed member reads `internal` with no declaration at all. No verdict leaves the attribute
|
|
123
|
+
* unset and the binding resolves as before. This reaches every `/oe` command typed in a DM, not
|
|
124
|
+
* only `watch`. */
|
|
125
|
+
export function bindableForSlashCommand(params: URLSearchParams, invoker?: SlashTrustFacts, leg: SlashTrustLeg = "person"): BindableContext {
|
|
87
126
|
const enterpriseId = formField(params, "enterprise_id");
|
|
127
|
+
const room = invoker?.room && (leg === "room" || invoker.invokerMember === true) ? invoker.room : undefined;
|
|
88
128
|
return {
|
|
89
129
|
channel: { kind: "slack" },
|
|
90
130
|
session: {
|
|
@@ -94,6 +134,8 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
|
|
|
94
134
|
team_id: formField(params, "team_id") ?? "",
|
|
95
135
|
channel_id: formField(params, "channel_id") ?? "",
|
|
96
136
|
...(enterpriseId ? { enterprise_id: enterpriseId } : {}),
|
|
137
|
+
...(invoker?.dmPartnerTrust ? { dm_partner_trust: invoker.dmPartnerTrust } : {}),
|
|
138
|
+
...(room ? { is_ext_shared: String(room.extShared), trust_checked: "true" } : {}),
|
|
97
139
|
},
|
|
98
140
|
principalId: formField(params, "user_id") ?? "",
|
|
99
141
|
principalType: "slack-user",
|
|
@@ -103,6 +145,120 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
|
|
|
103
145
|
};
|
|
104
146
|
}
|
|
105
147
|
|
|
148
|
+
/** How long each trust read (the invoker probe, the room read) may take before the route answers without
|
|
149
|
+
* it. Slack wants its ack inside three seconds, and `slackForm`'s 429 backoff can wait longer than that. */
|
|
150
|
+
const INVOKER_PROBE_MS = 1_500;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The invoker's own membership verdict for a 1:1 DM command, in `dm_partner_trust`'s vocabulary, or
|
|
154
|
+
* `{}` when there is none to give (OPE-2579). It is the chat DM leg's reading of the same probe
|
|
155
|
+
* (slack-front.ts `liveDmPartnerTrust`): `slackAuthorIsMember` decides, so the two doors cannot
|
|
156
|
+
* drift on what a guest is. A channel or group DM asks nothing here: there the ROOM's verdict is the
|
|
157
|
+
* binding's trust, and {@link slashRoomVerdict} reads it.
|
|
158
|
+
*
|
|
159
|
+
* 🔴 NO VERDICT IS NOT A VERDICT. No user id, no bot token, a refused `users.info`, a transport
|
|
160
|
+
* failure or a probe slower than {@link INVOKER_PROBE_MS} all answer `{}`, which leaves the binding
|
|
161
|
+
* exactly as it resolved before this probe existed. An unanswered probe never invents a guest, and
|
|
162
|
+
* never invents a member.
|
|
163
|
+
*/
|
|
164
|
+
export async function slashInvokerDmTrust(
|
|
165
|
+
params: URLSearchParams,
|
|
166
|
+
probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
|
|
167
|
+
): Promise<{ readonly dmPartnerTrust?: "internal" | "external" }> {
|
|
168
|
+
const userId = formField(params, "user_id");
|
|
169
|
+
if (!userId || slackMode(undefined, formField(params, "channel_id")) !== "dm") return {};
|
|
170
|
+
const member = await withinAckBudget(probe(userId, formField(params, "team_id")));
|
|
171
|
+
return member === undefined ? {} : { dmPartnerTrust: member ? "internal" : "external" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The room facts {@link slashRoomVerdict} reads: `slackChannelFactsFor`'s shape, which every channel wake reads. */
|
|
175
|
+
export type SlashRoomFacts = (channelId: string) => Promise<{ readonly channelType?: string; readonly extShared?: boolean; readonly trustChecked?: boolean }>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A CHANNEL command's room verdict and invoker check, or `{}` when there is no room verdict to give
|
|
179
|
+
* (OPE-2579). The room read is `slackChannelFactsFor`, the TTL-cached `conversations.info` every
|
|
180
|
+
* channel wake reads, so the command door and the message door cannot disagree about one room.
|
|
181
|
+
*
|
|
182
|
+
* 🔴 THE INVOKER LEG, AND IT FAILS CLOSED. A channel message keeps the room's verdict on the auth and marks
|
|
183
|
+
* a guest author on the side (slack-events.ts `slackWakeAuthorFacts`), because one guest's message must not
|
|
184
|
+
* demote the channel's memory for everybody. A command has no session to protect: its binding exists for
|
|
185
|
+
* one answer, and `/oe suggest on` and `/oe watch more|less` read `binding.trust` as "is this person a
|
|
186
|
+
* member". So the route folds the author leg into the binding's `trust`, the composition Discord and
|
|
187
|
+
* Teams make: `invokerMember` is set only when `users.info` CONFIRMS the invoker a member, and the
|
|
188
|
+
* person's binding stamps the room's verdict only beside it. A guest, a Slack Connect stranger, and a
|
|
189
|
+
* person the probe could not check (no `users:read`, a refusal, a timeout) all leave `trust` fail-closed
|
|
190
|
+
* `external`. The message door fails OPEN on an unanswered probe because it marks a turn. Here `trust`
|
|
191
|
+
* IS the member gate, and a channel has no declaration to fall back to the way a DM command does.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 THE ROOM'S VERDICT IS RETURNED FOR EVERY INVOKER (OPE-2588). `/oe budget` names the room's budget,
|
|
194
|
+
* and a channel's turns are charged under the room's domain, whoever typed them. So {@link bindSlashCommand}
|
|
195
|
+
* binds the room's verdict alone into `roomTrust`, and the budget reads that. A guest's `trust` is
|
|
196
|
+
* unchanged, and `/oe budget` reads both to refuse a guest in an internal channel (commands/core.ts
|
|
197
|
+
* `budgetWithheldFrom`). `/oe cover on` refuses outside a DM before it reads trust.
|
|
198
|
+
*
|
|
199
|
+
* 🔴 ONLY A ROOM IN THE CHANNEL MODE IS STAMPED. A 1:1 DM has its own leg ({@link slashInvokerDmTrust}).
|
|
200
|
+
* A group DM, or a room whose type Slack did not report, stamps nothing, so its binding is unchanged.
|
|
201
|
+
* Stamping a sharing verdict on a group DM the binding still calls a channel would mix two modes' rules.
|
|
202
|
+
*
|
|
203
|
+
* 🔴 NO VERDICT IS NOT A VERDICT. A failed or refused `conversations.info`, a response without the check,
|
|
204
|
+
* or a read slower than {@link INVOKER_PROBE_MS} answers `{}`. The command then binds `external`, as
|
|
205
|
+
* before this read existed. The room read and the invoker probe run in parallel inside one ack budget.
|
|
206
|
+
*/
|
|
207
|
+
export async function slashRoomVerdict(
|
|
208
|
+
params: URLSearchParams,
|
|
209
|
+
roomFacts: SlashRoomFacts = slackChannelFactsFor,
|
|
210
|
+
probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
|
|
211
|
+
): Promise<SlashTrustFacts> {
|
|
212
|
+
const channelId = formField(params, "channel_id");
|
|
213
|
+
if (!channelId || slackMode(undefined, channelId) === "dm") return {};
|
|
214
|
+
const userId = formField(params, "user_id");
|
|
215
|
+
const [facts, member] = await Promise.all([
|
|
216
|
+
withinAckBudget(roomFacts(channelId)),
|
|
217
|
+
userId ? withinAckBudget(probe(userId, formField(params, "team_id"))) : Promise.resolve(undefined),
|
|
218
|
+
]);
|
|
219
|
+
if (!facts || facts.trustChecked !== true || typeof facts.extShared !== "boolean") return {};
|
|
220
|
+
if (facts.channelType !== "channel" && facts.channelType !== "group") return {};
|
|
221
|
+
return { room: { extShared: facts.extShared }, ...(member === true ? { invokerMember: true as const } : {}) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Both legs, for whichever kind of room the command came from. Never throws. */
|
|
225
|
+
export async function slashTrustFacts(params: URLSearchParams): Promise<SlashTrustFacts> {
|
|
226
|
+
return slackMode(undefined, formField(params, "channel_id")) === "dm" ? slashInvokerDmTrust(params) : slashRoomVerdict(params);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The slash command's binding, with the room's trust and the person's trust kept apart (OPE-2588).
|
|
231
|
+
*
|
|
232
|
+
* `trust` is the PERSON leg: the member gate `/oe suggest on`, `/oe watch more|less` and `/oe cover on`
|
|
233
|
+
* read. `roomTrust` is the ROOM leg: the same `bindFrom` walk over the room's verdict alone, so the
|
|
234
|
+
* declarations and the fail-closed default apply to it unchanged. `/oe budget` and the caps behind
|
|
235
|
+
* `raise` and `clear` read it through `roomTrustOf`. Before this the binding carried one value, so a
|
|
236
|
+
* guest in an internal channel read the external domain's budget while that channel's turns were
|
|
237
|
+
* charged under the internal domain. Now `/oe budget` refuses that guest, because a guest must not read
|
|
238
|
+
* an internal room's caps and spend (Tommy's ruling on #1431).
|
|
239
|
+
*
|
|
240
|
+
* In a DM both legs bind the same context, so the two values are equal. Every other field comes from
|
|
241
|
+
* the person's binding. The two bindings differ only in the two trust attributes, which move no key.
|
|
242
|
+
*/
|
|
243
|
+
export function bindSlashCommand(params: URLSearchParams, facts: SlashTrustFacts): SurfaceBinding {
|
|
244
|
+
const person = bindFrom(bindableForSlashCommand(params, facts, "person"));
|
|
245
|
+
const room = bindFrom(bindableForSlashCommand(params, facts, "room"));
|
|
246
|
+
return { ...person, roomTrust: room.trust };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `p`'s answer, or `undefined` when it throws or takes longer than {@link INVOKER_PROBE_MS}. */
|
|
250
|
+
async function withinAckBudget<T>(p: Promise<T>): Promise<T | undefined> {
|
|
251
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
252
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
253
|
+
timer = setTimeout(() => resolve(undefined), INVOKER_PROBE_MS);
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
return await Promise.race([p.catch(() => undefined), timeout]);
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
106
262
|
/**
|
|
107
263
|
* Make a refused slash command OBSERVABLE, the same way `slack.ts`'s `auditWakeDropped` makes a
|
|
108
264
|
* refused wake observable: the webhook function's console output never reaches `vercel logs`, so
|
|
@@ -122,16 +278,18 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
|
|
|
122
278
|
*/
|
|
123
279
|
async function auditCommandDropped(teamId: string | undefined, channelId: string | undefined, reason: string, enterpriseId?: string): Promise<void> {
|
|
124
280
|
try {
|
|
125
|
-
const
|
|
281
|
+
const bindable = {
|
|
126
282
|
channel: { kind: "slack" },
|
|
127
283
|
session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
|
|
128
|
-
} as BindableContext
|
|
284
|
+
} as BindableContext;
|
|
285
|
+
const p = resolvePartition(bindable);
|
|
129
286
|
await getOperationalStore().appendAudit({
|
|
130
287
|
ts: new Date().toISOString(),
|
|
131
288
|
org: p.org,
|
|
132
289
|
platform: p.platform,
|
|
133
290
|
workspace: p.workspace,
|
|
134
291
|
surface: p.surface,
|
|
292
|
+
...auditSurfaceModeFrom(bindable),
|
|
135
293
|
action: "surface.wake_dropped",
|
|
136
294
|
detail: { reason, door: "slash-command", channel: channelId ?? null, team: teamId ?? null, enterprise: enterpriseId ?? null },
|
|
137
295
|
});
|
|
@@ -141,6 +299,9 @@ async function auditCommandDropped(teamId: string | undefined, channelId: string
|
|
|
141
299
|
}
|
|
142
300
|
|
|
143
301
|
function ephemeralResponse(text: string): Response {
|
|
302
|
+
// OPE-2490: an empty reply means the command's card posted in this channel and IS the answer. An
|
|
303
|
+
// empty 200 acknowledges the command, and Slack then shows nothing beside the card.
|
|
304
|
+
if (text.length === 0) return new Response(null, { status: 200 });
|
|
144
305
|
return new Response(JSON.stringify({ response_type: "ephemeral", text }), {
|
|
145
306
|
status: 200,
|
|
146
307
|
headers: { "content-type": "application/json" },
|
|
@@ -218,17 +379,17 @@ export default defineChannel({
|
|
|
218
379
|
}
|
|
219
380
|
|
|
220
381
|
const cmd = parseSlackCommandText(formField(params, "text"));
|
|
221
|
-
const binding =
|
|
382
|
+
const binding = bindSlashCommand(params, await slashTrustFacts(params));
|
|
222
383
|
try {
|
|
223
384
|
const reply = await runOeCommand(cmd, binding, { attachSession });
|
|
224
385
|
return ephemeralResponse(reply);
|
|
225
386
|
} catch (err) {
|
|
226
|
-
//
|
|
227
|
-
// (
|
|
228
|
-
//
|
|
229
|
-
// "operation_timeout"/dispatch_failed toast.
|
|
387
|
+
// A PersonFacingError is already worded for the person — it renders as-is. Anything else
|
|
388
|
+
// (a store or network failure, a planner's model-facing refusal) renders the generic line,
|
|
389
|
+
// and its message goes to the log only (OPE-2486). Never let a thrown error escape as a
|
|
390
|
+
// 500; Slack's slash-command UX for that is an ugly "operation_timeout"/dispatch_failed toast.
|
|
230
391
|
log(`"${expected}" command failed softly: ${String(err)}`);
|
|
231
|
-
return ephemeralResponse(
|
|
392
|
+
return ephemeralResponse(commandFailureText(err));
|
|
232
393
|
}
|
|
233
394
|
}),
|
|
234
395
|
],
|
|
@@ -38,11 +38,6 @@ import { loadThreadContextMessages } from "eve/channels/slack";
|
|
|
38
38
|
import type { SlackMessage } from "eve/channels/slack";
|
|
39
39
|
import { MAX_MESSAGES, renderContext, type ContextLine } from "@open-everywhere/core/agent/adapter-api.js";
|
|
40
40
|
|
|
41
|
-
// Re-export the shared render + line shape so slack-wake-backscroll.ts (which imports them from
|
|
42
|
-
// here) keeps compiling unchanged after the extraction, and any future Slack consumer has one
|
|
43
|
-
// import site. The mapping (`linesFromMessages`) is defined below — it's Slack-message-shaped.
|
|
44
|
-
export { renderContext, type ContextLine };
|
|
45
|
-
|
|
46
41
|
// When the rendered slice fills MAX_MESSAGES exactly, there's very likely more history above
|
|
47
42
|
// it (both fetchers below request MAX_MESSAGES-worth from Slack, so filling the cap usually
|
|
48
43
|
// means older messages were left out) — tell the model this is a bounded slice, not the whole
|
|
@@ -23,6 +23,7 @@ import { slackBotToken } from "../setup/credentials.js";
|
|
|
23
23
|
import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
|
|
24
24
|
import {
|
|
25
25
|
resolvePartition,
|
|
26
|
+
auditSurfaceModeFrom,
|
|
26
27
|
cancelAnchoredTurn,
|
|
27
28
|
turnAnchorKey,
|
|
28
29
|
STOP_HAND_SLACK_NAME,
|
|
@@ -113,6 +114,7 @@ import {
|
|
|
113
114
|
dmTrustTtlMs,
|
|
114
115
|
engagedFollowUpContract,
|
|
115
116
|
dispatchNativeWakeWithRetry,
|
|
117
|
+
coverForWake,
|
|
116
118
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
117
119
|
import {
|
|
118
120
|
slackMode,
|
|
@@ -129,9 +131,8 @@ import { holdRouteWork } from "./slack-route-hold.js";
|
|
|
129
131
|
import { fetchWakeBackscrollLines, fetchWakeEnvelopeBackscroll, RAW_FETCH_REACHBACK_HOURS, WAKE_BACKSCROLL_MAX, type WakeBackscrollLines } from "./slack-wake-backscroll.js";
|
|
130
132
|
// The silence vocabulary (NO_REPLY_SENTINEL / PREFER_NO_REPLY_TOOL) MOVED to normalized-wake.ts —
|
|
131
133
|
// the shared wake renderer that emits it — so no per-surface dialect can drift its own copy.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
export { PREFER_NO_REPLY_TOOL };
|
|
134
|
+
// Provider-neutral callers reach NO_REPLY_SENTINEL through turn-delivery.ts (OPE-114 Stage 0), never
|
|
135
|
+
// through this provider file.
|
|
135
136
|
import { registerSlackAttachment } from "./slack-attachments.js";
|
|
136
137
|
// Provider-neutral turn/delivery helpers lifted to turn-delivery.ts (OPE-114 Stage 0). The
|
|
137
138
|
// Slack code below still consumes these three (turnEffectsFor builds on FinalMessageAction,
|
|
@@ -165,6 +166,7 @@ import {
|
|
|
165
166
|
type ReactionIngestOutcome,
|
|
166
167
|
type ReactionVerb,
|
|
167
168
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
169
|
+
import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
168
170
|
|
|
169
171
|
/**
|
|
170
172
|
* Minimal Slack `event_callback` envelope shape (the subset the parser reads). Mirrors
|
|
@@ -260,6 +262,12 @@ export interface ParsedMessageEvent {
|
|
|
260
262
|
* wake envelope's `<previous-body>` (wake-envelope.ts) so a judged turn can diff old↔new.
|
|
261
263
|
*/
|
|
262
264
|
readonly previousText?: string;
|
|
265
|
+
/**
|
|
266
|
+
* TRUE ⇒ a `message_changed` edit whose words and files are exactly the pre-edit ones
|
|
267
|
+
* ({@link isUnchangedSlackEdit}). Absent on every other event, and on an edit that carries no
|
|
268
|
+
* `previous_message` to compare against, so an unknown edit reads as a real one.
|
|
269
|
+
*/
|
|
270
|
+
readonly unchangedEdit?: true;
|
|
263
271
|
/** True when the text @mentions the bot — those go through the normal mention path. */
|
|
264
272
|
readonly isAppMention: boolean;
|
|
265
273
|
/** Image/file attachment REFERENCES parsed off Slack's `files[]` (docs Part B) — surfaced in
|
|
@@ -724,6 +732,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
724
732
|
for (const a of attachments) {
|
|
725
733
|
if (a.kind === "image" && a.id && a.url) registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId });
|
|
726
734
|
}
|
|
735
|
+
const unchangedEdit = previousMessage !== undefined && isUnchangedSlackEdit(inner, previousMessage);
|
|
727
736
|
return {
|
|
728
737
|
channelId,
|
|
729
738
|
ts,
|
|
@@ -737,6 +746,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
737
746
|
text,
|
|
738
747
|
authoredText,
|
|
739
748
|
previousText: typeof previousMessage?.text === "string" ? previousMessage.text : undefined,
|
|
749
|
+
...(unchangedEdit ? { unchangedEdit: true as const } : {}),
|
|
740
750
|
// `app_mention` arrives as its own event type; a `message` whose text contains the bot
|
|
741
751
|
// ref is also a mention. We don't know our own bot id here, so the event-type check is
|
|
742
752
|
// the reliable signal; the route also filters app_mention so the mention path owns it.
|
|
@@ -749,6 +759,49 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
749
759
|
};
|
|
750
760
|
}
|
|
751
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Does a `message_changed` edit leave the message's WORDS and FILES as they were? Pure.
|
|
764
|
+
*
|
|
765
|
+
* 🔴 WHY THIS EXISTS (OPE-2577). Slack raises `message_changed` for more than a person's edit. It
|
|
766
|
+
* raises one each time the message's METADATA changes: a link unfurl, and, in the person's DM with
|
|
767
|
+
* the app, each `agents.sessions.setStatus` call that OE itself makes on the session's root message
|
|
768
|
+
* (slack-agent-session.ts: the title at the door, `processing` at turn start, `active` or
|
|
769
|
+
* `suspended` at the park). The event is hidden: `conversations.history` never lists it, and its
|
|
770
|
+
* `ts` is a fresh notice stamp. Its inner message is the person's own message, with the person's
|
|
771
|
+
* own author. So before this predicate, the DM door woke the seat on every one of them, as a NEW
|
|
772
|
+
* inbound wake. Ship #252 measured it: one DM drew three more wakes within 15 seconds, the
|
|
773
|
+
* reply-freshness gate refused the answer to the person's real wake as `stale-conversation`, and the
|
|
774
|
+
* answer landed under a later edit wake. A voice clip was lost, because an edit is never heard again
|
|
775
|
+
* (slack-voice-clips.ts).
|
|
776
|
+
*
|
|
777
|
+
* An edit that changes no words and no files says nothing new, so no door wakes on it. A REAL edit
|
|
778
|
+
* still wakes: any difference in the typed text, in the quoted-message text Slack parks in
|
|
779
|
+
* `attachments[]` ({@link resolveSlackInboundText}), or in a file's id or its tombstone mark (a deleted file
|
|
780
|
+
* stays as `mode: "tombstone"`) makes this false.
|
|
781
|
+
*/
|
|
782
|
+
export function isUnchangedSlackEdit(inner: Record<string, unknown>, previous: Record<string, unknown>): boolean {
|
|
783
|
+
const textOf = (m: Record<string, unknown>): string => resolveSlackInboundText(typeof m.text === "string" ? m.text : "", m);
|
|
784
|
+
if (typeof previous.text !== "string") return false; // no pre-edit text to compare ⇒ treat it as a real edit
|
|
785
|
+
if (textOf(inner) !== textOf(previous)) return false;
|
|
786
|
+
// Each file's identity, plus a tombstone mark: Slack keeps a deleted file's `id` on the message and
|
|
787
|
+
// marks it `mode: "tombstone"`, so a removal changes the key even when the id set does not. Only that
|
|
788
|
+
// one mode counts, so a field that one copy carries and the other omits cannot turn a metadata-only
|
|
789
|
+
// edit into a real one.
|
|
790
|
+
const fileKeys = (m: Record<string, unknown>): string =>
|
|
791
|
+
(Array.isArray(m.files) ? m.files : [])
|
|
792
|
+
.map((f) => {
|
|
793
|
+
const o = typeof f === "object" && f !== null ? (f as Record<string, unknown>) : {};
|
|
794
|
+
const key = [o["id"], o["url_private"], o["name"]].find((v) => typeof v === "string") ?? "";
|
|
795
|
+
return `${String(key)}${o["mode"] === "tombstone" ? ":tombstone" : ""}`;
|
|
796
|
+
})
|
|
797
|
+
.sort()
|
|
798
|
+
.join("\n");
|
|
799
|
+
return fileKeys(inner) === fileKeys(previous);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The log reason both Slack wake doors give when they drop an {@link isUnchangedSlackEdit} edit. */
|
|
803
|
+
export const UNCHANGED_EDIT_DROP_REASON = "an edit that changed no words and no files (a metadata-only message_changed) — not news";
|
|
804
|
+
|
|
752
805
|
// --- judged replies: the wake instruction + the silence sentinel -------------
|
|
753
806
|
//
|
|
754
807
|
// An engaged thread forwards EVERY human reply to the session (that's what engagement
|
|
@@ -1236,7 +1289,7 @@ function seatWakeAddressing(opts: SlackWakeOpts): "direct-mention" | "name-drop"
|
|
|
1236
1289
|
* surface", which an mpim is — inventing a fourth value for it would extrapolate a Tag-derived
|
|
1237
1290
|
* vocabulary with no evidence behind the new entry.
|
|
1238
1291
|
*/
|
|
1239
|
-
|
|
1292
|
+
function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake {
|
|
1240
1293
|
return slackJudgedWake(ev, "direct-mention", opts, "dm");
|
|
1241
1294
|
}
|
|
1242
1295
|
|
|
@@ -1296,7 +1349,7 @@ export interface MessageRouteDeps {
|
|
|
1296
1349
|
*/
|
|
1297
1350
|
readonly confirmInternal?: (channelId: string) => Promise<boolean>;
|
|
1298
1351
|
/** Append a durable audit row. Optional; best-effort. */
|
|
1299
|
-
readonly audit?: (action: string, detail:
|
|
1352
|
+
readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
|
|
1300
1353
|
/** Resolve the trigger author's display name for the envelope (fail-soft). Optional so
|
|
1301
1354
|
* existing test doubles keep compiling AND so a wake never depends on a name lookup — absent
|
|
1302
1355
|
* or throwing ⇒ the raw id renders. Live wiring points this at {@link fetchUserProfileNames}. */
|
|
@@ -1319,7 +1372,7 @@ export function bindableFor(ev: ParsedMessageEvent): BindableContext {
|
|
|
1319
1372
|
* them off — the interactivity CLICK routes (OPE-1680), which carry a signed payload rather than a
|
|
1320
1373
|
* message. ONE builder, so a row written from a click keys to the same partition a message from the
|
|
1321
1374
|
* same room would. */
|
|
1322
|
-
function bindableForSlackIds(ids: {
|
|
1375
|
+
export function bindableForSlackIds(ids: {
|
|
1323
1376
|
readonly teamId?: string | undefined;
|
|
1324
1377
|
readonly channelId: string;
|
|
1325
1378
|
readonly channelType?: string | undefined;
|
|
@@ -1513,15 +1566,16 @@ function wakeState(ev: ParsedMessageEvent, threadTs: string, extra?: Record<stri
|
|
|
1513
1566
|
|
|
1514
1567
|
/** Best-effort durable audit row from a route dep (`engagement.stop` / etc.). Resolves the
|
|
1515
1568
|
* partition from the surface key so the row is org/surface-scoped like every other audit. */
|
|
1516
|
-
async function appendRouteAudit(surfaceKey: string, action: string, detail:
|
|
1569
|
+
async function appendRouteAudit(surfaceKey: string, action: string, detail: AuditDetail): Promise<void> {
|
|
1517
1570
|
try {
|
|
1518
1571
|
// surfaceKey is `platform/workspace/surface` — split to the partition fields the audit row needs.
|
|
1519
1572
|
const [platform = "slack", workspace = "", surface = ""] = surfaceKey.split("/");
|
|
1520
|
-
const
|
|
1573
|
+
const bindable = {
|
|
1521
1574
|
channel: { kind: platform },
|
|
1522
1575
|
session: { auth: { current: { attributes: { team_id: workspace, channel_id: surface }, principalId: "", principalType: "slack-user" } } },
|
|
1523
|
-
} as Parameters<typeof resolvePartition>[0]
|
|
1524
|
-
|
|
1576
|
+
} as Parameters<typeof resolvePartition>[0];
|
|
1577
|
+
const org = resolvePartition(bindable).org;
|
|
1578
|
+
await getOperationalStore().appendAudit({ ts: new Date().toISOString(), org, platform, workspace, surface, ...auditSurfaceModeFrom(bindable), action, detail });
|
|
1525
1579
|
} catch (err) {
|
|
1526
1580
|
console.warn(`[open-everywhere:slack] route audit write failed: ${String(err)}`);
|
|
1527
1581
|
}
|
|
@@ -1556,7 +1610,7 @@ async function appendRouteAudit(surfaceKey: string, action: string, detail: Reco
|
|
|
1556
1610
|
* new thread still gets told why it's being ignored. Fail-open by contract — a dedupe-store
|
|
1557
1611
|
* outage posts anyway.
|
|
1558
1612
|
*/
|
|
1559
|
-
|
|
1613
|
+
async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision> {
|
|
1560
1614
|
return slackSpendGate({
|
|
1561
1615
|
bindable: bindableFor(ev),
|
|
1562
1616
|
mode: slackMode(ev.channelType, ev.channelId),
|
|
@@ -1607,7 +1661,7 @@ export async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, t
|
|
|
1607
1661
|
* changes the decision. The route never WAITS on the audit sink — it holds the write open instead,
|
|
1608
1662
|
* so the row lands after the 200 rather than racing the freeze that follows it.
|
|
1609
1663
|
*/
|
|
1610
|
-
function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail:
|
|
1664
|
+
function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail: AuditDetail = {}): void {
|
|
1611
1665
|
// HELD ON THE ROUTE, not fire-and-forget (OPE-1648). Every engagement route runs inside the front
|
|
1612
1666
|
// route's `waitUntil`, but a `void`-ed write is OUTSIDE the promise that holds it — so the row
|
|
1613
1667
|
// raced the freeze that follows the 200 exactly as the mention door's did.
|
|
@@ -1617,7 +1671,7 @@ function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, deta
|
|
|
1617
1671
|
/** The awaitable half of {@link auditEngagementWakeDropped}. Never throws. The DM door hands the
|
|
1618
1672
|
* promise to `waitUntil` directly, because that route returns a 200 immediately and the serverless
|
|
1619
1673
|
* invocation can freeze before an unawaited write lands. */
|
|
1620
|
-
async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail:
|
|
1674
|
+
async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail: AuditDetail): Promise<void> {
|
|
1621
1675
|
await appendWakeRow(ev, "surface.wake_dropped", reason, detail);
|
|
1622
1676
|
}
|
|
1623
1677
|
|
|
@@ -1625,7 +1679,7 @@ async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, deta
|
|
|
1625
1679
|
* DM door's admission row below. It resolves the event's own partition, so a row is always keyed to
|
|
1626
1680
|
* the surface it happened on, and it swallows every failure: an observability write must never
|
|
1627
1681
|
* block a route or change its decision. */
|
|
1628
|
-
async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail:
|
|
1682
|
+
async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail: AuditDetail): Promise<void> {
|
|
1629
1683
|
await appendWakeRowForIds({ teamId: ev.teamId, channelId: ev.channelId, channelType: ev.channelType, userId: ev.userId }, ev.ts, action, reason, detail);
|
|
1630
1684
|
}
|
|
1631
1685
|
|
|
@@ -1637,16 +1691,18 @@ async function appendWakeRowForIds(
|
|
|
1637
1691
|
ts: string | null,
|
|
1638
1692
|
action: string,
|
|
1639
1693
|
reason: string,
|
|
1640
|
-
detail:
|
|
1694
|
+
detail: AuditDetail,
|
|
1641
1695
|
): Promise<void> {
|
|
1642
1696
|
try {
|
|
1643
|
-
const
|
|
1697
|
+
const bindable = bindableForSlackIds(ids);
|
|
1698
|
+
const p = resolvePartition(bindable);
|
|
1644
1699
|
await getOperationalStore().appendAudit({
|
|
1645
1700
|
ts: new Date().toISOString(),
|
|
1646
1701
|
org: p.org,
|
|
1647
1702
|
platform: p.platform,
|
|
1648
1703
|
workspace: p.workspace,
|
|
1649
1704
|
surface: p.surface,
|
|
1705
|
+
...auditSurfaceModeFrom(bindable),
|
|
1650
1706
|
action,
|
|
1651
1707
|
detail: { reason, channel: ids.channelId, ts, ...detail },
|
|
1652
1708
|
});
|
|
@@ -1955,6 +2011,9 @@ export async function auditDmDriverAdmitted(ev: ParsedMessageEvent, dmKind: "im"
|
|
|
1955
2011
|
dmKind,
|
|
1956
2012
|
botId: ev.botId ?? null,
|
|
1957
2013
|
team: ev.teamId ?? null,
|
|
2014
|
+
// The event's subtype, so a reader can tell a typed message from an edit or an upload without
|
|
2015
|
+
// a second source (OPE-2577 had to infer it from the notice-shaped `ts`).
|
|
2016
|
+
subtype: ev.subtype ?? null,
|
|
1958
2017
|
});
|
|
1959
2018
|
}
|
|
1960
2019
|
|
|
@@ -2043,10 +2102,6 @@ function authForEvent(ev: ParsedMessageEvent, facts?: ChannelFacts): OpenEverywh
|
|
|
2043
2102
|
});
|
|
2044
2103
|
}
|
|
2045
2104
|
|
|
2046
|
-
// Keep mode resolution referenced (the wake scopes to a channel, never mpim/dm here) so a
|
|
2047
|
-
// future spike that needs it doesn't re-derive — and to assert the import stays live.
|
|
2048
|
-
export const _engagementWakeMode = (ev: ParsedMessageEvent) => slackMode(ev.channelType, ev.channelId);
|
|
2049
|
-
|
|
2050
2105
|
// --- name-drop wake: judged replies to unmentioned TOP-LEVEL messages ---------
|
|
2051
2106
|
//
|
|
2052
2107
|
// Claude Tag's documented behaviour ("a top-level message without a mention may still
|
|
@@ -2154,7 +2209,7 @@ export function shouldNameDropWake(input: NameDropInput): ReplyDecision {
|
|
|
2154
2209
|
* disqualifiers would go stale the first time somebody adds one to the policy above, and the arm
|
|
2155
2210
|
* would then reach a message the policy had just started dropping.
|
|
2156
2211
|
*/
|
|
2157
|
-
|
|
2212
|
+
function nameDropAbstained(input: NameDropInput): boolean {
|
|
2158
2213
|
if (input.textMatchesName) return false;
|
|
2159
2214
|
return shouldNameDropWake({ ...input, textMatchesName: true }).deliver;
|
|
2160
2215
|
}
|
|
@@ -2717,11 +2772,6 @@ export function botDisplayName(): Promise<string | undefined> {
|
|
|
2717
2772
|
return botDisplayNamePromise;
|
|
2718
2773
|
}
|
|
2719
2774
|
|
|
2720
|
-
/** Reset the cached display name (tests only). */
|
|
2721
|
-
export function _resetBotDisplayName(): void {
|
|
2722
|
-
botDisplayNamePromise = undefined;
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
2775
|
// Positively confirm a channel is internal via conversations.info, TTL-cached (channels
|
|
2726
2776
|
// change shared-ness rarely; the API is rate-limited). Any failure → { internal: false }
|
|
2727
2777
|
// (fail-closed). The SAME response also carries the channel's descriptive metadata
|
|
@@ -3234,7 +3284,7 @@ function externalPolicyDropReason(verdict: SlackExternalVerdict): string {
|
|
|
3234
3284
|
* On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
|
|
3235
3285
|
* parallel, and the render budget caps the hydration at ten lines.
|
|
3236
3286
|
*/
|
|
3237
|
-
|
|
3287
|
+
async function withholdNameDropContext(
|
|
3238
3288
|
nw: NormalizedWake,
|
|
3239
3289
|
policy: ExternalPolicy,
|
|
3240
3290
|
teamId: string | undefined,
|
|
@@ -3552,17 +3602,20 @@ async function fetchChannelTranscript(channelId: string): Promise<string | undef
|
|
|
3552
3602
|
// `handle` = the person's SELF-SET display name (Tag treats the handle as the better human-address
|
|
3553
3603
|
// signal). Both are UNTRUSTED, attacker-controllable profile content — the wake renderer XML-escapes
|
|
3554
3604
|
// 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> {
|
|
3605
|
+
const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string; email?: string } | undefined>>();
|
|
3606
|
+
export function fetchUserProfileNames(userId: string): Promise<{ name?: string; handle?: string; email?: string } | undefined> {
|
|
3557
3607
|
let p = userProfileCache.get(userId);
|
|
3558
3608
|
if (!p) {
|
|
3559
3609
|
p = (async () => {
|
|
3560
3610
|
const info = await slackForm("users.info", `user=${encodeURIComponent(userId)}`);
|
|
3561
3611
|
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;
|
|
3612
|
+
const user = info.user as { profile?: { display_name?: string; real_name?: string; email?: string }; real_name?: string; name?: string } | undefined;
|
|
3563
3613
|
const handle = user?.profile?.display_name?.trim() || undefined;
|
|
3564
3614
|
const name = user?.profile?.real_name?.trim() || user?.real_name?.trim() || user?.name?.trim() || undefined;
|
|
3565
|
-
|
|
3615
|
+
// The workspace-verified address, present only under the `users:read.email` scope (OPE-2482): the
|
|
3616
|
+
// identifier a person can check beside a self-set name.
|
|
3617
|
+
const email = user?.profile?.email?.trim() || undefined;
|
|
3618
|
+
return { name, handle, ...(email ? { email } : {}) };
|
|
3566
3619
|
})();
|
|
3567
3620
|
userProfileCache.set(userId, p);
|
|
3568
3621
|
}
|
|
@@ -4050,7 +4103,7 @@ export interface SupervisorRouteDeps {
|
|
|
4050
4103
|
window?: UnifiedWindowWake;
|
|
4051
4104
|
}) => Promise<void>;
|
|
4052
4105
|
/** Append a durable audit row (e.g. `engagement.stop`). Optional; best-effort. */
|
|
4053
|
-
readonly audit?: (action: string, detail:
|
|
4106
|
+
readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
|
|
4054
4107
|
/**
|
|
4055
4108
|
* ENGAGE the trigger's thread as a PARTICIPATING surface (task #100 — window-riding stop parity):
|
|
4056
4109
|
* called ONLY on an ADDRESSED `directMention` so a mention diverted into the window enrolls its
|
|
@@ -4107,6 +4160,11 @@ export interface SupervisorRouteDeps {
|
|
|
4107
4160
|
* route's own singleWindow + internal facts — a directMention flag on a non-single-window or
|
|
4108
4161
|
* non-internal wake degrades safely to the plain channel-watch treatment. Unset ⇒ byte-identical.
|
|
4109
4162
|
*/
|
|
4163
|
+
/** The Slack user ids a message mentions (`<@U…>` or `<@U…|name>`), in the order it names them. Pure. */
|
|
4164
|
+
export function slackMentionedUserIds(text: string | undefined): string[] {
|
|
4165
|
+
return [...(text ?? "").matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((m) => m[1]!);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4110
4168
|
export async function routeSupervisorEvent(
|
|
4111
4169
|
ev: ParsedMessageEvent,
|
|
4112
4170
|
deps: SupervisorRouteDeps,
|
|
@@ -4506,7 +4564,7 @@ export async function routeSupervisorEvent(
|
|
|
4506
4564
|
// `priorContext` map above) and the hydrated lines are decided by one transform, and it returns
|
|
4507
4565
|
// the wake BY REFERENCE on the `taint` opt-out grain, so nothing moves for a deployment that
|
|
4508
4566
|
// opted out. Run only on the name-drop tier, which is the only tier that carries the slot.
|
|
4509
|
-
const
|
|
4567
|
+
const watchWake = slackChannelWatchWake(ev, {
|
|
4510
4568
|
mode: "supervisor",
|
|
4511
4569
|
trust: wakeTrust,
|
|
4512
4570
|
engagementTier,
|
|
@@ -4543,6 +4601,25 @@ export async function routeSupervisorEvent(
|
|
|
4543
4601
|
? { contextLines: nameDropRendered, contextSource: nameDropContext?.source, readMoreHint: nameDropContext?.readMoreHint }
|
|
4544
4602
|
: {}),
|
|
4545
4603
|
});
|
|
4604
|
+
// 🔴 OUT-OF-OFFICE COVER (OPE-2513). A message that mentions a person with live cover gets OE's cover note on
|
|
4605
|
+
// THIS wake, and nothing else changes: the binding, the auth bag and the room's memory stay the room's, so the
|
|
4606
|
+
// answer can use only what this room already knows. No window, no wake id, no cover (cover/wake.ts).
|
|
4607
|
+
const cover = await coverForWake({
|
|
4608
|
+
org: p.org,
|
|
4609
|
+
platform: "slack",
|
|
4610
|
+
workspace: p.workspace,
|
|
4611
|
+
surface: p.surface,
|
|
4612
|
+
surfaceKey: p.surfaceKey,
|
|
4613
|
+
channelId: ev.channelId,
|
|
4614
|
+
roomPublic: ev.channelType === "channel",
|
|
4615
|
+
wakeId: window?.wakeId,
|
|
4616
|
+
mentionedIds: slackMentionedUserIds(ev.authoredText),
|
|
4617
|
+
...(ev.userId ? { askerId: ev.userId } : {}),
|
|
4618
|
+
...(trigger.authorName ? { askerName: trigger.authorName } : {}),
|
|
4619
|
+
text: ev.authoredText,
|
|
4620
|
+
now: new Date(atIso),
|
|
4621
|
+
}).catch(() => ({ covered: [] }) as { systemNote?: string });
|
|
4622
|
+
const wake = cover.systemNote ? { ...watchWake, systemNote: watchWake.systemNote ? `${watchWake.systemNote}\n${cover.systemNote}` : cover.systemNote } : watchWake;
|
|
4546
4623
|
const rendered = renderWake(
|
|
4547
4624
|
nameDropTier ? await withholdNameDropContext(wake, externalPolicy, ev.teamId, deps.authorIsMember) : wake,
|
|
4548
4625
|
deps.now,
|