@open-everywhere/provider-slack 0.7.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-commands.ts +172 -12
- package/agent/channels/slack-context.ts +0 -5
- package/agent/channels/slack-events.ts +103 -29
- package/agent/channels/slack-follow-ups.ts +148 -0
- package/agent/channels/slack-front.ts +232 -26
- package/agent/channels/slack-home.ts +9 -3
- package/agent/channels/slack-identity.ts +9 -1
- package/agent/channels/slack-memory-canvas.ts +192 -51
- 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.ts +74 -13
- package/agent/docs-generated.ts +1282 -2
- package/agent/ops.ts +14 -21
- package/agent/register.ts +13 -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-commands.d.ts +113 -10
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +146 -13
- 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 +46 -97
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +86 -21
- 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-front.d.ts +28 -45
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +207 -22
- 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 +162 -52
- 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.d.ts +22 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +63 -13
- 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 +12 -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 +31 -3
|
@@ -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,
|
|
@@ -4053,7 +4103,7 @@ export interface SupervisorRouteDeps {
|
|
|
4053
4103
|
window?: UnifiedWindowWake;
|
|
4054
4104
|
}) => Promise<void>;
|
|
4055
4105
|
/** Append a durable audit row (e.g. `engagement.stop`). Optional; best-effort. */
|
|
4056
|
-
readonly audit?: (action: string, detail:
|
|
4106
|
+
readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
|
|
4057
4107
|
/**
|
|
4058
4108
|
* ENGAGE the trigger's thread as a PARTICIPATING surface (task #100 — window-riding stop parity):
|
|
4059
4109
|
* called ONLY on an ADDRESSED `directMention` so a mention diverted into the window enrolls its
|
|
@@ -4110,6 +4160,11 @@ export interface SupervisorRouteDeps {
|
|
|
4110
4160
|
* route's own singleWindow + internal facts — a directMention flag on a non-single-window or
|
|
4111
4161
|
* non-internal wake degrades safely to the plain channel-watch treatment. Unset ⇒ byte-identical.
|
|
4112
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
|
+
|
|
4113
4168
|
export async function routeSupervisorEvent(
|
|
4114
4169
|
ev: ParsedMessageEvent,
|
|
4115
4170
|
deps: SupervisorRouteDeps,
|
|
@@ -4509,7 +4564,7 @@ export async function routeSupervisorEvent(
|
|
|
4509
4564
|
// `priorContext` map above) and the hydrated lines are decided by one transform, and it returns
|
|
4510
4565
|
// the wake BY REFERENCE on the `taint` opt-out grain, so nothing moves for a deployment that
|
|
4511
4566
|
// opted out. Run only on the name-drop tier, which is the only tier that carries the slot.
|
|
4512
|
-
const
|
|
4567
|
+
const watchWake = slackChannelWatchWake(ev, {
|
|
4513
4568
|
mode: "supervisor",
|
|
4514
4569
|
trust: wakeTrust,
|
|
4515
4570
|
engagementTier,
|
|
@@ -4546,6 +4601,25 @@ export async function routeSupervisorEvent(
|
|
|
4546
4601
|
? { contextLines: nameDropRendered, contextSource: nameDropContext?.source, readMoreHint: nameDropContext?.readMoreHint }
|
|
4547
4602
|
: {}),
|
|
4548
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;
|
|
4549
4623
|
const rendered = renderWake(
|
|
4550
4624
|
nameDropTier ? await withholdNameDropContext(wake, externalPolicy, ev.teamId, deps.authorIsMember) : wake,
|
|
4551
4625
|
deps.now,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// slack-follow-ups — a press on a suggested follow-up under an agent answer (OPE-2516).
|
|
2
|
+
//
|
|
3
|
+
// An answer in a 1:1 DM (the side panel included) can carry up to three follow-up buttons
|
|
4
|
+
// (slack-reply-blocks.ts). Slack sends a press to the interactivity URL as a signed `block_actions`
|
|
5
|
+
// payload whose action id starts `oe_followup:` and whose value is the button's text. The front route
|
|
6
|
+
// classifies it (`slack-front.ts`, FrontDecision `followup-click`), runs the tenant fence, and then:
|
|
7
|
+
// 1. marks the answer as answered: `chat.update` removes the button row and adds one line,
|
|
8
|
+
// "↳ You asked: <text>", so the conversation shows what the person sent and no second press
|
|
9
|
+
// can follow ({@link markFollowUpPressed});
|
|
10
|
+
// 2. wakes the DM seat with the text as the person's next message ({@link followUpMessageEvent}),
|
|
11
|
+
// through the SAME `scheduleDmWake` road a typed DM takes — every gate on that road applies.
|
|
12
|
+
//
|
|
13
|
+
// 🔴 ONLY A 1:1 DM. A press counts only in a `D…` conversation, where the app and ONE person are the
|
|
14
|
+
// only members, so the presser is the person the answer addressed. The choke point shows the buttons
|
|
15
|
+
// nowhere else; this check is the backstop for a payload that names another conversation.
|
|
16
|
+
//
|
|
17
|
+
// 🔴 THE TEXT IS THE BUTTON'S OWN LABEL. Slack signs the payload and sends a `block_actions` only for
|
|
18
|
+
// a block this app posted, so the value is the text this app wrote under the answer and the person
|
|
19
|
+
// saw on the button. Core drops any follow-up too long to show whole, or holding a character that
|
|
20
|
+
// draws nothing (`reply-follow-ups.ts`), and the parse below refuses both again.
|
|
21
|
+
|
|
22
|
+
import type { ParsedMessageEvent } from "./slack-events.js";
|
|
23
|
+
import { slackForm } from "./slack-events.js";
|
|
24
|
+
import { SLACK_FOLLOW_UP_ACTION_PREFIX, SLACK_FOLLOW_UP_BLOCK_ID, SLACK_INVISIBLE_CHARACTER } from "./slack-reply-blocks.js";
|
|
25
|
+
import { slackTsFromMs } from "./slack-shortcut.js";
|
|
26
|
+
import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
|
|
27
|
+
|
|
28
|
+
/** The longest value a press may carry: core's follow-up cap. A longer value is not one of ours. */
|
|
29
|
+
const FOLLOW_UP_VALUE_MAX = 75;
|
|
30
|
+
|
|
31
|
+
/** One parsed press on a follow-up button. IDS and the button's text only. */
|
|
32
|
+
export interface ParsedSlackFollowUpClick {
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly userId: string;
|
|
35
|
+
readonly channelId: string;
|
|
36
|
+
/** The answer the button sits under. */
|
|
37
|
+
readonly messageTs: string;
|
|
38
|
+
/** The answer's thread, when the answer sits in one (a side-panel or agent-session thread). */
|
|
39
|
+
readonly threadTs?: string;
|
|
40
|
+
readonly teamId?: string;
|
|
41
|
+
readonly enterpriseId?: string;
|
|
42
|
+
/** The answer's blocks as Slack sent them back, so the press can rewrite the message. */
|
|
43
|
+
readonly blocks?: readonly unknown[];
|
|
44
|
+
/** The answer's notification text, kept on the rewrite. */
|
|
45
|
+
readonly messageText?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const obj = (v: unknown): Record<string, unknown> | undefined => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined);
|
|
49
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse a decoded interactivity payload into a follow-up press, or `undefined` when it is not one: not
|
|
53
|
+
* a `block_actions`, no `oe_followup:` action, an empty or over-long value, a conversation that is not
|
|
54
|
+
* a 1:1 DM, or a missing user, channel or message. Pure and total: it never throws.
|
|
55
|
+
*/
|
|
56
|
+
export function parseSlackFollowUpClick(payload: unknown): ParsedSlackFollowUpClick | undefined {
|
|
57
|
+
const p = obj(payload);
|
|
58
|
+
if (p?.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
|
|
59
|
+
const action = (p.actions as unknown[]).map(obj).find((a) => typeof a?.action_id === "string" && a.action_id.startsWith(SLACK_FOLLOW_UP_ACTION_PREFIX));
|
|
60
|
+
if (action === undefined) return undefined;
|
|
61
|
+
const text = str(action.value)?.trim();
|
|
62
|
+
if (text === undefined || text.length === 0 || Array.from(text).length > FOLLOW_UP_VALUE_MAX) return undefined;
|
|
63
|
+
// A value with a control or format character holds words no button shows, so it is not one of ours.
|
|
64
|
+
if (SLACK_INVISIBLE_CHARACTER.test(text)) return undefined;
|
|
65
|
+
const userId = str(obj(p.user)?.id);
|
|
66
|
+
const channelId = str(obj(p.channel)?.id) ?? str(obj(p.container)?.channel_id);
|
|
67
|
+
const message = obj(p.message);
|
|
68
|
+
const messageTs = str(message?.ts) ?? str(obj(p.container)?.message_ts);
|
|
69
|
+
if (userId === undefined || channelId === undefined || messageTs === undefined) return undefined;
|
|
70
|
+
if (!channelId.startsWith("D")) return undefined;
|
|
71
|
+
const threadTs = str(message?.thread_ts) ?? str(obj(p.container)?.thread_ts);
|
|
72
|
+
const { teamId, enterpriseId } = slackInteractivityTenantIds(p);
|
|
73
|
+
const blocks = Array.isArray(message?.blocks) ? (message.blocks as unknown[]) : undefined;
|
|
74
|
+
const messageText = str(message?.text);
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
userId,
|
|
78
|
+
channelId,
|
|
79
|
+
messageTs,
|
|
80
|
+
// A top-level answer names itself as its own thread root on some payloads: that is no thread.
|
|
81
|
+
...(threadTs !== undefined && threadTs !== messageTs ? { threadTs } : {}),
|
|
82
|
+
...(teamId === undefined ? {} : { teamId }),
|
|
83
|
+
...(enterpriseId === undefined ? {} : { enterpriseId }),
|
|
84
|
+
...(blocks === undefined ? {} : { blocks }),
|
|
85
|
+
...(messageText === undefined ? {} : { messageText }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The press as the person's next DM message. The ts is minted from the clock, as a global shortcut's
|
|
91
|
+
* is, because no Slack message stands behind the press. The thread is the answer's thread, so the
|
|
92
|
+
* answer to the follow-up lands where the answer it follows sits.
|
|
93
|
+
*/
|
|
94
|
+
export function followUpMessageEvent(click: ParsedSlackFollowUpClick, nowMs: number = Date.now()): ParsedMessageEvent {
|
|
95
|
+
return {
|
|
96
|
+
channelId: click.channelId,
|
|
97
|
+
ts: slackTsFromMs(nowMs),
|
|
98
|
+
...(click.threadTs === undefined ? {} : { threadTs: click.threadTs }),
|
|
99
|
+
...(click.teamId === undefined ? {} : { teamId: click.teamId }),
|
|
100
|
+
...(click.enterpriseId === undefined ? {} : { enterpriseId: click.enterpriseId }),
|
|
101
|
+
userId: click.userId,
|
|
102
|
+
channelType: "im",
|
|
103
|
+
text: click.text,
|
|
104
|
+
// The person chose these words by pressing them, so they are the person's own text.
|
|
105
|
+
authoredText: click.text,
|
|
106
|
+
isAppMention: false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The dedupe key of one press: one answer takes one press, so a double press or a redelivery wakes once. */
|
|
111
|
+
export function followUpPressKey(click: ParsedSlackFollowUpClick): string {
|
|
112
|
+
return `slack-followup:${click.channelId}:${click.messageTs}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The line that replaces the buttons once the person pressed one. Plain text: it draws no mention. */
|
|
116
|
+
export function followUpPressedLine(text: string): string {
|
|
117
|
+
return `↳ You asked: ${text}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The answer's blocks after a press: the follow-up row removed, and one context line naming what the
|
|
122
|
+
* person sent. `undefined` when the blocks hold no follow-up row — then there is nothing to rewrite.
|
|
123
|
+
*/
|
|
124
|
+
export function blocksAfterFollowUp(blocks: readonly unknown[], text: string): unknown[] | undefined {
|
|
125
|
+
const kept = blocks.filter((b) => obj(b)?.block_id !== SLACK_FOLLOW_UP_BLOCK_ID);
|
|
126
|
+
if (kept.length === blocks.length) return undefined;
|
|
127
|
+
return [...kept, { type: "context", elements: [{ type: "plain_text", text: followUpPressedLine(text), emoji: true }] }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rewrite the answer after a press (see the header). Answers whether Slack took the rewrite. Never
|
|
132
|
+
* throws: a refused rewrite costs only the line, never the person's message, which wakes regardless.
|
|
133
|
+
*/
|
|
134
|
+
export async function markFollowUpPressed(
|
|
135
|
+
click: ParsedSlackFollowUpClick,
|
|
136
|
+
form: (method: string, body: string) => Promise<Record<string, unknown> | undefined> = slackForm,
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
try {
|
|
139
|
+
const blocks = click.blocks === undefined ? undefined : blocksAfterFollowUp(click.blocks, click.text);
|
|
140
|
+
if (blocks === undefined) return false;
|
|
141
|
+
const body = new URLSearchParams({ channel: click.channelId, ts: click.messageTs, blocks: JSON.stringify(blocks) });
|
|
142
|
+
if (click.messageText !== undefined) body.set("text", click.messageText);
|
|
143
|
+
const res = await form("chat.update", body.toString());
|
|
144
|
+
return res?.ok === true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|