@open-everywhere/provider-slack 0.7.0 → 0.8.1
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 +18 -2
- package/agent/channels/slack-agent-session.ts +338 -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 +161 -33
- 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 +43 -2
- 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 +1299 -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 +18 -2
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-agent-session.d.ts +85 -0
- package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
- package/dist/agent/channels/slack-agent-session.js +282 -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 +53 -100
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +142 -26
- 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 +33 -21
- package/dist/agent/channels/slack-identity.d.ts.map +1 -1
- package/dist/agent/channels/slack-identity.js +36 -2
- 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 +1299 -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,
|
|
@@ -102,6 +103,7 @@ import {
|
|
|
102
103
|
type EngagementRow,
|
|
103
104
|
type ChannelSessionRow,
|
|
104
105
|
type RoomRoster,
|
|
106
|
+
type RoomShape,
|
|
105
107
|
type ContextLine,
|
|
106
108
|
type WakeAttachment,
|
|
107
109
|
type WakeMessageInput,
|
|
@@ -113,12 +115,15 @@ import {
|
|
|
113
115
|
dmTrustTtlMs,
|
|
114
116
|
engagedFollowUpContract,
|
|
115
117
|
dispatchNativeWakeWithRetry,
|
|
118
|
+
coverForWake,
|
|
116
119
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
117
120
|
import {
|
|
118
121
|
slackMode,
|
|
119
122
|
buildSlackAuth,
|
|
120
123
|
channelFactsFromInfo,
|
|
124
|
+
imRosterFactsFromInfo,
|
|
121
125
|
partnerFactsFromInfo,
|
|
126
|
+
slackUserGuestFlagged,
|
|
122
127
|
appCanReadConversation,
|
|
123
128
|
type ChannelFacts,
|
|
124
129
|
type OpenEverywhereSlackAuth,
|
|
@@ -129,9 +134,8 @@ import { holdRouteWork } from "./slack-route-hold.js";
|
|
|
129
134
|
import { fetchWakeBackscrollLines, fetchWakeEnvelopeBackscroll, RAW_FETCH_REACHBACK_HOURS, WAKE_BACKSCROLL_MAX, type WakeBackscrollLines } from "./slack-wake-backscroll.js";
|
|
130
135
|
// The silence vocabulary (NO_REPLY_SENTINEL / PREFER_NO_REPLY_TOOL) MOVED to normalized-wake.ts —
|
|
131
136
|
// 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 };
|
|
137
|
+
// Provider-neutral callers reach NO_REPLY_SENTINEL through turn-delivery.ts (OPE-114 Stage 0), never
|
|
138
|
+
// through this provider file.
|
|
135
139
|
import { registerSlackAttachment } from "./slack-attachments.js";
|
|
136
140
|
// Provider-neutral turn/delivery helpers lifted to turn-delivery.ts (OPE-114 Stage 0). The
|
|
137
141
|
// Slack code below still consumes these three (turnEffectsFor builds on FinalMessageAction,
|
|
@@ -165,6 +169,7 @@ import {
|
|
|
165
169
|
type ReactionIngestOutcome,
|
|
166
170
|
type ReactionVerb,
|
|
167
171
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
172
|
+
import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
168
173
|
|
|
169
174
|
/**
|
|
170
175
|
* Minimal Slack `event_callback` envelope shape (the subset the parser reads). Mirrors
|
|
@@ -260,6 +265,12 @@ export interface ParsedMessageEvent {
|
|
|
260
265
|
* wake envelope's `<previous-body>` (wake-envelope.ts) so a judged turn can diff old↔new.
|
|
261
266
|
*/
|
|
262
267
|
readonly previousText?: string;
|
|
268
|
+
/**
|
|
269
|
+
* TRUE ⇒ a `message_changed` edit whose words and files are exactly the pre-edit ones
|
|
270
|
+
* ({@link isUnchangedSlackEdit}). Absent on every other event, and on an edit that carries no
|
|
271
|
+
* `previous_message` to compare against, so an unknown edit reads as a real one.
|
|
272
|
+
*/
|
|
273
|
+
readonly unchangedEdit?: true;
|
|
263
274
|
/** True when the text @mentions the bot — those go through the normal mention path. */
|
|
264
275
|
readonly isAppMention: boolean;
|
|
265
276
|
/** Image/file attachment REFERENCES parsed off Slack's `files[]` (docs Part B) — surfaced in
|
|
@@ -724,6 +735,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
724
735
|
for (const a of attachments) {
|
|
725
736
|
if (a.kind === "image" && a.id && a.url) registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId });
|
|
726
737
|
}
|
|
738
|
+
const unchangedEdit = previousMessage !== undefined && isUnchangedSlackEdit(inner, previousMessage);
|
|
727
739
|
return {
|
|
728
740
|
channelId,
|
|
729
741
|
ts,
|
|
@@ -737,6 +749,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
737
749
|
text,
|
|
738
750
|
authoredText,
|
|
739
751
|
previousText: typeof previousMessage?.text === "string" ? previousMessage.text : undefined,
|
|
752
|
+
...(unchangedEdit ? { unchangedEdit: true as const } : {}),
|
|
740
753
|
// `app_mention` arrives as its own event type; a `message` whose text contains the bot
|
|
741
754
|
// ref is also a mention. We don't know our own bot id here, so the event-type check is
|
|
742
755
|
// the reliable signal; the route also filters app_mention so the mention path owns it.
|
|
@@ -749,6 +762,49 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
|
|
|
749
762
|
};
|
|
750
763
|
}
|
|
751
764
|
|
|
765
|
+
/**
|
|
766
|
+
* Does a `message_changed` edit leave the message's WORDS and FILES as they were? Pure.
|
|
767
|
+
*
|
|
768
|
+
* 🔴 WHY THIS EXISTS (OPE-2577). Slack raises `message_changed` for more than a person's edit. It
|
|
769
|
+
* raises one each time the message's METADATA changes: a link unfurl, and, in the person's DM with
|
|
770
|
+
* the app, each `agents.sessions.setStatus` call that OE itself makes on the session's root message
|
|
771
|
+
* (slack-agent-session.ts: the title at the door, `processing` at turn start, `active` or
|
|
772
|
+
* `suspended` at the park). The event is hidden: `conversations.history` never lists it, and its
|
|
773
|
+
* `ts` is a fresh notice stamp. Its inner message is the person's own message, with the person's
|
|
774
|
+
* own author. So before this predicate, the DM door woke the seat on every one of them, as a NEW
|
|
775
|
+
* inbound wake. Ship #252 measured it: one DM drew three more wakes within 15 seconds, the
|
|
776
|
+
* reply-freshness gate refused the answer to the person's real wake as `stale-conversation`, and the
|
|
777
|
+
* answer landed under a later edit wake. A voice clip was lost, because an edit is never heard again
|
|
778
|
+
* (slack-voice-clips.ts).
|
|
779
|
+
*
|
|
780
|
+
* An edit that changes no words and no files says nothing new, so no door wakes on it. A REAL edit
|
|
781
|
+
* still wakes: any difference in the typed text, in the quoted-message text Slack parks in
|
|
782
|
+
* `attachments[]` ({@link resolveSlackInboundText}), or in a file's id or its tombstone mark (a deleted file
|
|
783
|
+
* stays as `mode: "tombstone"`) makes this false.
|
|
784
|
+
*/
|
|
785
|
+
export function isUnchangedSlackEdit(inner: Record<string, unknown>, previous: Record<string, unknown>): boolean {
|
|
786
|
+
const textOf = (m: Record<string, unknown>): string => resolveSlackInboundText(typeof m.text === "string" ? m.text : "", m);
|
|
787
|
+
if (typeof previous.text !== "string") return false; // no pre-edit text to compare ⇒ treat it as a real edit
|
|
788
|
+
if (textOf(inner) !== textOf(previous)) return false;
|
|
789
|
+
// Each file's identity, plus a tombstone mark: Slack keeps a deleted file's `id` on the message and
|
|
790
|
+
// marks it `mode: "tombstone"`, so a removal changes the key even when the id set does not. Only that
|
|
791
|
+
// one mode counts, so a field that one copy carries and the other omits cannot turn a metadata-only
|
|
792
|
+
// edit into a real one.
|
|
793
|
+
const fileKeys = (m: Record<string, unknown>): string =>
|
|
794
|
+
(Array.isArray(m.files) ? m.files : [])
|
|
795
|
+
.map((f) => {
|
|
796
|
+
const o = typeof f === "object" && f !== null ? (f as Record<string, unknown>) : {};
|
|
797
|
+
const key = [o["id"], o["url_private"], o["name"]].find((v) => typeof v === "string") ?? "";
|
|
798
|
+
return `${String(key)}${o["mode"] === "tombstone" ? ":tombstone" : ""}`;
|
|
799
|
+
})
|
|
800
|
+
.sort()
|
|
801
|
+
.join("\n");
|
|
802
|
+
return fileKeys(inner) === fileKeys(previous);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The log reason both Slack wake doors give when they drop an {@link isUnchangedSlackEdit} edit. */
|
|
806
|
+
export const UNCHANGED_EDIT_DROP_REASON = "an edit that changed no words and no files (a metadata-only message_changed) — not news";
|
|
807
|
+
|
|
752
808
|
// --- judged replies: the wake instruction + the silence sentinel -------------
|
|
753
809
|
//
|
|
754
810
|
// An engaged thread forwards EVERY human reply to the session (that's what engagement
|
|
@@ -1236,7 +1292,7 @@ function seatWakeAddressing(opts: SlackWakeOpts): "direct-mention" | "name-drop"
|
|
|
1236
1292
|
* surface", which an mpim is — inventing a fourth value for it would extrapolate a Tag-derived
|
|
1237
1293
|
* vocabulary with no evidence behind the new entry.
|
|
1238
1294
|
*/
|
|
1239
|
-
|
|
1295
|
+
function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake {
|
|
1240
1296
|
return slackJudgedWake(ev, "direct-mention", opts, "dm");
|
|
1241
1297
|
}
|
|
1242
1298
|
|
|
@@ -1296,7 +1352,7 @@ export interface MessageRouteDeps {
|
|
|
1296
1352
|
*/
|
|
1297
1353
|
readonly confirmInternal?: (channelId: string) => Promise<boolean>;
|
|
1298
1354
|
/** Append a durable audit row. Optional; best-effort. */
|
|
1299
|
-
readonly audit?: (action: string, detail:
|
|
1355
|
+
readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
|
|
1300
1356
|
/** Resolve the trigger author's display name for the envelope (fail-soft). Optional so
|
|
1301
1357
|
* existing test doubles keep compiling AND so a wake never depends on a name lookup — absent
|
|
1302
1358
|
* or throwing ⇒ the raw id renders. Live wiring points this at {@link fetchUserProfileNames}. */
|
|
@@ -1319,7 +1375,7 @@ export function bindableFor(ev: ParsedMessageEvent): BindableContext {
|
|
|
1319
1375
|
* them off — the interactivity CLICK routes (OPE-1680), which carry a signed payload rather than a
|
|
1320
1376
|
* message. ONE builder, so a row written from a click keys to the same partition a message from the
|
|
1321
1377
|
* same room would. */
|
|
1322
|
-
function bindableForSlackIds(ids: {
|
|
1378
|
+
export function bindableForSlackIds(ids: {
|
|
1323
1379
|
readonly teamId?: string | undefined;
|
|
1324
1380
|
readonly channelId: string;
|
|
1325
1381
|
readonly channelType?: string | undefined;
|
|
@@ -1513,15 +1569,16 @@ function wakeState(ev: ParsedMessageEvent, threadTs: string, extra?: Record<stri
|
|
|
1513
1569
|
|
|
1514
1570
|
/** Best-effort durable audit row from a route dep (`engagement.stop` / etc.). Resolves the
|
|
1515
1571
|
* 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:
|
|
1572
|
+
async function appendRouteAudit(surfaceKey: string, action: string, detail: AuditDetail): Promise<void> {
|
|
1517
1573
|
try {
|
|
1518
1574
|
// surfaceKey is `platform/workspace/surface` — split to the partition fields the audit row needs.
|
|
1519
1575
|
const [platform = "slack", workspace = "", surface = ""] = surfaceKey.split("/");
|
|
1520
|
-
const
|
|
1576
|
+
const bindable = {
|
|
1521
1577
|
channel: { kind: platform },
|
|
1522
1578
|
session: { auth: { current: { attributes: { team_id: workspace, channel_id: surface }, principalId: "", principalType: "slack-user" } } },
|
|
1523
|
-
} as Parameters<typeof resolvePartition>[0]
|
|
1524
|
-
|
|
1579
|
+
} as Parameters<typeof resolvePartition>[0];
|
|
1580
|
+
const org = resolvePartition(bindable).org;
|
|
1581
|
+
await getOperationalStore().appendAudit({ ts: new Date().toISOString(), org, platform, workspace, surface, ...auditSurfaceModeFrom(bindable), action, detail });
|
|
1525
1582
|
} catch (err) {
|
|
1526
1583
|
console.warn(`[open-everywhere:slack] route audit write failed: ${String(err)}`);
|
|
1527
1584
|
}
|
|
@@ -1556,7 +1613,7 @@ async function appendRouteAudit(surfaceKey: string, action: string, detail: Reco
|
|
|
1556
1613
|
* new thread still gets told why it's being ignored. Fail-open by contract — a dedupe-store
|
|
1557
1614
|
* outage posts anyway.
|
|
1558
1615
|
*/
|
|
1559
|
-
|
|
1616
|
+
async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision> {
|
|
1560
1617
|
return slackSpendGate({
|
|
1561
1618
|
bindable: bindableFor(ev),
|
|
1562
1619
|
mode: slackMode(ev.channelType, ev.channelId),
|
|
@@ -1607,7 +1664,7 @@ export async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, t
|
|
|
1607
1664
|
* changes the decision. The route never WAITS on the audit sink — it holds the write open instead,
|
|
1608
1665
|
* so the row lands after the 200 rather than racing the freeze that follows it.
|
|
1609
1666
|
*/
|
|
1610
|
-
function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail:
|
|
1667
|
+
function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail: AuditDetail = {}): void {
|
|
1611
1668
|
// HELD ON THE ROUTE, not fire-and-forget (OPE-1648). Every engagement route runs inside the front
|
|
1612
1669
|
// route's `waitUntil`, but a `void`-ed write is OUTSIDE the promise that holds it — so the row
|
|
1613
1670
|
// raced the freeze that follows the 200 exactly as the mention door's did.
|
|
@@ -1617,7 +1674,7 @@ function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, deta
|
|
|
1617
1674
|
/** The awaitable half of {@link auditEngagementWakeDropped}. Never throws. The DM door hands the
|
|
1618
1675
|
* promise to `waitUntil` directly, because that route returns a 200 immediately and the serverless
|
|
1619
1676
|
* invocation can freeze before an unawaited write lands. */
|
|
1620
|
-
async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail:
|
|
1677
|
+
async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail: AuditDetail): Promise<void> {
|
|
1621
1678
|
await appendWakeRow(ev, "surface.wake_dropped", reason, detail);
|
|
1622
1679
|
}
|
|
1623
1680
|
|
|
@@ -1625,7 +1682,7 @@ async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, deta
|
|
|
1625
1682
|
* DM door's admission row below. It resolves the event's own partition, so a row is always keyed to
|
|
1626
1683
|
* the surface it happened on, and it swallows every failure: an observability write must never
|
|
1627
1684
|
* block a route or change its decision. */
|
|
1628
|
-
async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail:
|
|
1685
|
+
async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail: AuditDetail): Promise<void> {
|
|
1629
1686
|
await appendWakeRowForIds({ teamId: ev.teamId, channelId: ev.channelId, channelType: ev.channelType, userId: ev.userId }, ev.ts, action, reason, detail);
|
|
1630
1687
|
}
|
|
1631
1688
|
|
|
@@ -1637,16 +1694,18 @@ async function appendWakeRowForIds(
|
|
|
1637
1694
|
ts: string | null,
|
|
1638
1695
|
action: string,
|
|
1639
1696
|
reason: string,
|
|
1640
|
-
detail:
|
|
1697
|
+
detail: AuditDetail,
|
|
1641
1698
|
): Promise<void> {
|
|
1642
1699
|
try {
|
|
1643
|
-
const
|
|
1700
|
+
const bindable = bindableForSlackIds(ids);
|
|
1701
|
+
const p = resolvePartition(bindable);
|
|
1644
1702
|
await getOperationalStore().appendAudit({
|
|
1645
1703
|
ts: new Date().toISOString(),
|
|
1646
1704
|
org: p.org,
|
|
1647
1705
|
platform: p.platform,
|
|
1648
1706
|
workspace: p.workspace,
|
|
1649
1707
|
surface: p.surface,
|
|
1708
|
+
...auditSurfaceModeFrom(bindable),
|
|
1650
1709
|
action,
|
|
1651
1710
|
detail: { reason, channel: ids.channelId, ts, ...detail },
|
|
1652
1711
|
});
|
|
@@ -1955,6 +2014,9 @@ export async function auditDmDriverAdmitted(ev: ParsedMessageEvent, dmKind: "im"
|
|
|
1955
2014
|
dmKind,
|
|
1956
2015
|
botId: ev.botId ?? null,
|
|
1957
2016
|
team: ev.teamId ?? null,
|
|
2017
|
+
// The event's subtype, so a reader can tell a typed message from an edit or an upload without
|
|
2018
|
+
// a second source (OPE-2577 had to infer it from the notice-shaped `ts`).
|
|
2019
|
+
subtype: ev.subtype ?? null,
|
|
1958
2020
|
});
|
|
1959
2021
|
}
|
|
1960
2022
|
|
|
@@ -2043,10 +2105,6 @@ function authForEvent(ev: ParsedMessageEvent, facts?: ChannelFacts): OpenEverywh
|
|
|
2043
2105
|
});
|
|
2044
2106
|
}
|
|
2045
2107
|
|
|
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
2108
|
// --- name-drop wake: judged replies to unmentioned TOP-LEVEL messages ---------
|
|
2051
2109
|
//
|
|
2052
2110
|
// Claude Tag's documented behaviour ("a top-level message without a mention may still
|
|
@@ -2154,7 +2212,7 @@ export function shouldNameDropWake(input: NameDropInput): ReplyDecision {
|
|
|
2154
2212
|
* disqualifiers would go stale the first time somebody adds one to the policy above, and the arm
|
|
2155
2213
|
* would then reach a message the policy had just started dropping.
|
|
2156
2214
|
*/
|
|
2157
|
-
|
|
2215
|
+
function nameDropAbstained(input: NameDropInput): boolean {
|
|
2158
2216
|
if (input.textMatchesName) return false;
|
|
2159
2217
|
return shouldNameDropWake({ ...input, textMatchesName: true }).deliver;
|
|
2160
2218
|
}
|
|
@@ -2717,11 +2775,6 @@ export function botDisplayName(): Promise<string | undefined> {
|
|
|
2717
2775
|
return botDisplayNamePromise;
|
|
2718
2776
|
}
|
|
2719
2777
|
|
|
2720
|
-
/** Reset the cached display name (tests only). */
|
|
2721
|
-
export function _resetBotDisplayName(): void {
|
|
2722
|
-
botDisplayNamePromise = undefined;
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
2778
|
// Positively confirm a channel is internal via conversations.info, TTL-cached (channels
|
|
2726
2779
|
// change shared-ness rarely; the API is rate-limited). Any failure → { internal: false }
|
|
2727
2780
|
// (fail-closed). The SAME response also carries the channel's descriptive metadata
|
|
@@ -3234,7 +3287,7 @@ function externalPolicyDropReason(verdict: SlackExternalVerdict): string {
|
|
|
3234
3287
|
* On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
|
|
3235
3288
|
* parallel, and the render budget caps the hydration at ten lines.
|
|
3236
3289
|
*/
|
|
3237
|
-
|
|
3290
|
+
async function withholdNameDropContext(
|
|
3238
3291
|
nw: NormalizedWake,
|
|
3239
3292
|
policy: ExternalPolicy,
|
|
3240
3293
|
teamId: string | undefined,
|
|
@@ -3735,13 +3788,16 @@ function slackRosterFailureState(res: Record<string, unknown> | undefined): "uno
|
|
|
3735
3788
|
/**
|
|
3736
3789
|
* Live Slack roster for a governance channel's floor tripwire (OPE-110, access/audience-floor.ts).
|
|
3737
3790
|
* Up to three Slack reads: conversations.info (is_private + externally-shared bit),
|
|
3738
|
-
* conversations.members (member ids) and auth.test (the bot's own user id).
|
|
3791
|
+
* conversations.members (member ids) and auth.test (the bot's own user id). A 1:1 DM adds a fourth,
|
|
3792
|
+
* users.info on the partner, and reads neither `is_org_shared` nor `is_shared` (OPE-2630, {@link slackImRoster}). The roster excludes the
|
|
3739
3793
|
* bot's own user id, so the agent doesn't count as a non-admin member. A failed conversations.info
|
|
3740
3794
|
* or conversations.members read, a members list of a NON-SHARED channel longer than one page, or a
|
|
3741
3795
|
* thrown error returns `resolved: false`, with an `unresolvedState` (OPE-2306). A transient failure
|
|
3742
3796
|
* is `failed`: a non-ok answer outside SLACK_ROSTER_UNOBSERVABLE_ERRORS, no answer, a thrown error, a
|
|
3743
3797
|
* conversations.info answer with no channel object. A steady state is `unobservable`: a code in that
|
|
3744
|
-
* set, or a non-shared members list longer than one page.
|
|
3798
|
+
* set, or a non-shared members list longer than one page. For a 1:1 DM, a failed partner users.info
|
|
3799
|
+
* read also returns `resolved: false`, with the same split ({@link slackImRoster}): so a short users.info
|
|
3800
|
+
* outage makes the connector-disclosure floor refuse in every 1:1 DM for up to 30 s. A failed auth.test does not unresolve the roster: the roster stays resolved and counts the bot as a
|
|
3745
3801
|
* non-admin member, so the governance channel does not arm. `resolved: false` is closed for the
|
|
3746
3802
|
* governance tripwire: the floor computes to `guest`, so the channel can never arm. It is
|
|
3747
3803
|
* also closed for the clicker floor's beneficiary leg, which refuses on it (`unattested-room`). It
|
|
@@ -3753,7 +3809,8 @@ function slackRosterFailureState(res: Record<string, unknown> | undefined): "uno
|
|
|
3753
3809
|
* answer for UNRESOLVED_ROSTER_TTL_MS (30 s) only, not for the full memo TTL (OPE-2150), so one
|
|
3754
3810
|
* failed read disarms the inert legs, and refuses the connector-disclosure floor, for at most that
|
|
3755
3811
|
* short window.
|
|
3756
|
-
* `hasGuest` uses the channel's externally-shared bit as the available external signal
|
|
3812
|
+
* `hasGuest` uses the channel's externally-shared bit as the available external signal (a 1:1 DM
|
|
3813
|
+
* reads the Slack Connect bits and the partner's guest flags instead, {@link slackImRoster});
|
|
3757
3814
|
* per-member guest classification rides the guest-detection work (design §2/§10 fragile-point #1) and
|
|
3758
3815
|
* is a future tightening, not a loosening (an internal-only private channel already needs every
|
|
3759
3816
|
* member on the roster to arm). Governance channels are small by config lint, so a single members
|
|
@@ -3784,7 +3841,7 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
|
|
|
3784
3841
|
// with `hasGuest: true` and an EMPTY member list, never a partial one. The floor is `guest` for every consumer, and
|
|
3785
3842
|
// the observed-guest legs refuse. The share bit is a steady state, so the full memo TTL is fine.
|
|
3786
3843
|
if (facts.trustChecked === true && facts.isExternal === true) {
|
|
3787
|
-
return { humanMemberIds: [], resolved: true, isPrivate: facts.isPrivate === true, hasGuest: true };
|
|
3844
|
+
return { humanMemberIds: [], resolved: true, isPrivate: facts.isPrivate === true, hasGuest: true, ...slackRoomShape(facts) };
|
|
3788
3845
|
}
|
|
3789
3846
|
// An ok conversations.info with no channel object is an odd answer, not a steady state: it is
|
|
3790
3847
|
// `failed`, as on the one-page path below.
|
|
@@ -3803,6 +3860,8 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
|
|
|
3803
3860
|
const humanMemberIds = (members.members as unknown[])
|
|
3804
3861
|
.filter((m): m is string => typeof m === "string")
|
|
3805
3862
|
.filter((m) => m !== botId);
|
|
3863
|
+
const im = imRosterFactsFromInfo(info);
|
|
3864
|
+
if (im) return await slackImRoster(im, humanMemberIds, facts.isPrivate === true);
|
|
3806
3865
|
return {
|
|
3807
3866
|
humanMemberIds,
|
|
3808
3867
|
resolved: facts.trustChecked === true, // conversations.info authoritative
|
|
@@ -3810,12 +3869,57 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
|
|
|
3810
3869
|
...(facts.trustChecked === true ? {} : { unresolvedState: "failed" as const }),
|
|
3811
3870
|
isPrivate: facts.isPrivate === true,
|
|
3812
3871
|
hasGuest: facts.isExternal === true,
|
|
3872
|
+
...slackRoomShape(facts),
|
|
3813
3873
|
};
|
|
3814
3874
|
} catch {
|
|
3815
3875
|
return { humanMemberIds: [], resolved: false, unresolvedState: "failed", isPrivate: false, hasGuest: false };
|
|
3816
3876
|
}
|
|
3817
3877
|
}
|
|
3818
3878
|
|
|
3879
|
+
/**
|
|
3880
|
+
* The roster of a 1:1 DM (OPE-2630). A DM has two parties, the app and one person, so its audience is
|
|
3881
|
+
* that person. `hasGuest` is true when Slack OBSERVES an outside party: a Slack Connect DM
|
|
3882
|
+
* (`is_ext_shared` / `is_pending_ext_shared`), or a partner whose `users.info` carries a guest flag
|
|
3883
|
+
* (`slackUserGuestFlagged`). `is_org_shared` and `is_shared` do not count. `is_org_shared` marks a
|
|
3884
|
+
* share across the Grid's own workspaces. `is_shared` marks any share, so a Connect DM sets it too, but
|
|
3885
|
+
* a Connect DM also carries the Connect bits read here. Reading the two as a guest refused every
|
|
3886
|
+
* approval card in a full member's own Grid DM (`imRosterFactsFromInfo` holds the reason).
|
|
3887
|
+
*
|
|
3888
|
+
* The workspace rung walk (`partnerFactsFromInfo`) is NOT read here. An undeclared Grid workspace is
|
|
3889
|
+
* a guest by INFERENCE (OPE-1508), not by observation, and the observed-guest legs of the floor refuse
|
|
3890
|
+
* only an observation (access/audience-floor.ts, the polarity rule). Reading the walk here would
|
|
3891
|
+
* floor every DM of an undeclared Grid to `guest` again.
|
|
3892
|
+
*
|
|
3893
|
+
* A failed `users.info` read does not resolve the roster: `failed` on a transient error, and
|
|
3894
|
+
* `unobservable` on a steady one (`missing_scope`), the same split as the room reads above.
|
|
3895
|
+
*/
|
|
3896
|
+
async function slackImRoster(
|
|
3897
|
+
im: { readonly partnerId?: string; readonly connectShared: boolean },
|
|
3898
|
+
humanMemberIds: readonly string[],
|
|
3899
|
+
isPrivate: boolean,
|
|
3900
|
+
): Promise<RoomRoster> {
|
|
3901
|
+
if (im.connectShared) return { humanMemberIds, resolved: true, isPrivate, hasGuest: true, room: "dm" };
|
|
3902
|
+
const partnerId = im.partnerId ?? (humanMemberIds.length === 1 ? humanMemberIds[0] : undefined);
|
|
3903
|
+
// A DM that names no partner gives no person to ask about. The roster then rests on the Connect
|
|
3904
|
+
// bits alone (read above): resolved, with no outside party observed. Before OPE-2630 a DM like this
|
|
3905
|
+
// with `is_org_shared` or `is_shared` set floored to `guest`; now it does not.
|
|
3906
|
+
if (partnerId === undefined) return { humanMemberIds, resolved: true, isPrivate, hasGuest: false, room: "dm" };
|
|
3907
|
+
const who = await slackForm("users.info", `user=${encodeURIComponent(partnerId)}`);
|
|
3908
|
+
const user = who?.ok === true ? (who.user as Record<string, unknown> | undefined) : undefined;
|
|
3909
|
+
if (!user || typeof user !== "object") {
|
|
3910
|
+
return { humanMemberIds: [], resolved: false, unresolvedState: who?.ok === true ? "failed" : slackRosterFailureState(who), isPrivate: false, hasGuest: false };
|
|
3911
|
+
}
|
|
3912
|
+
return { humanMemberIds, resolved: true, isPrivate, hasGuest: slackUserGuestFlagged(user), room: "dm" };
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
/** The roster's `room` from a `conversations.info` channel type, or nothing when Slack did not say. */
|
|
3916
|
+
function slackRoomShape(facts: ChannelFacts): { readonly room?: RoomShape } {
|
|
3917
|
+
if (facts.channelType === "im") return { room: "dm" };
|
|
3918
|
+
if (facts.channelType === "mpim") return { room: "group-dm" };
|
|
3919
|
+
if (facts.channelType === "channel" || facts.channelType === "group") return { room: "channel" };
|
|
3920
|
+
return {};
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3819
3923
|
/**
|
|
3820
3924
|
* Register {@link slackGovernanceRoster} as the governance floor-tripwire member supplier (OPE-110).
|
|
3821
3925
|
* Core owns the seam + the fail-closed default; this is the Slack arm, wired at boot from
|
|
@@ -4053,7 +4157,7 @@ export interface SupervisorRouteDeps {
|
|
|
4053
4157
|
window?: UnifiedWindowWake;
|
|
4054
4158
|
}) => Promise<void>;
|
|
4055
4159
|
/** Append a durable audit row (e.g. `engagement.stop`). Optional; best-effort. */
|
|
4056
|
-
readonly audit?: (action: string, detail:
|
|
4160
|
+
readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
|
|
4057
4161
|
/**
|
|
4058
4162
|
* ENGAGE the trigger's thread as a PARTICIPATING surface (task #100 — window-riding stop parity):
|
|
4059
4163
|
* called ONLY on an ADDRESSED `directMention` so a mention diverted into the window enrolls its
|
|
@@ -4110,6 +4214,11 @@ export interface SupervisorRouteDeps {
|
|
|
4110
4214
|
* route's own singleWindow + internal facts — a directMention flag on a non-single-window or
|
|
4111
4215
|
* non-internal wake degrades safely to the plain channel-watch treatment. Unset ⇒ byte-identical.
|
|
4112
4216
|
*/
|
|
4217
|
+
/** The Slack user ids a message mentions (`<@U…>` or `<@U…|name>`), in the order it names them. Pure. */
|
|
4218
|
+
export function slackMentionedUserIds(text: string | undefined): string[] {
|
|
4219
|
+
return [...(text ?? "").matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((m) => m[1]!);
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4113
4222
|
export async function routeSupervisorEvent(
|
|
4114
4223
|
ev: ParsedMessageEvent,
|
|
4115
4224
|
deps: SupervisorRouteDeps,
|
|
@@ -4509,7 +4618,7 @@ export async function routeSupervisorEvent(
|
|
|
4509
4618
|
// `priorContext` map above) and the hydrated lines are decided by one transform, and it returns
|
|
4510
4619
|
// the wake BY REFERENCE on the `taint` opt-out grain, so nothing moves for a deployment that
|
|
4511
4620
|
// opted out. Run only on the name-drop tier, which is the only tier that carries the slot.
|
|
4512
|
-
const
|
|
4621
|
+
const watchWake = slackChannelWatchWake(ev, {
|
|
4513
4622
|
mode: "supervisor",
|
|
4514
4623
|
trust: wakeTrust,
|
|
4515
4624
|
engagementTier,
|
|
@@ -4546,6 +4655,25 @@ export async function routeSupervisorEvent(
|
|
|
4546
4655
|
? { contextLines: nameDropRendered, contextSource: nameDropContext?.source, readMoreHint: nameDropContext?.readMoreHint }
|
|
4547
4656
|
: {}),
|
|
4548
4657
|
});
|
|
4658
|
+
// 🔴 OUT-OF-OFFICE COVER (OPE-2513). A message that mentions a person with live cover gets OE's cover note on
|
|
4659
|
+
// THIS wake, and nothing else changes: the binding, the auth bag and the room's memory stay the room's, so the
|
|
4660
|
+
// answer can use only what this room already knows. No window, no wake id, no cover (cover/wake.ts).
|
|
4661
|
+
const cover = await coverForWake({
|
|
4662
|
+
org: p.org,
|
|
4663
|
+
platform: "slack",
|
|
4664
|
+
workspace: p.workspace,
|
|
4665
|
+
surface: p.surface,
|
|
4666
|
+
surfaceKey: p.surfaceKey,
|
|
4667
|
+
channelId: ev.channelId,
|
|
4668
|
+
roomPublic: ev.channelType === "channel",
|
|
4669
|
+
wakeId: window?.wakeId,
|
|
4670
|
+
mentionedIds: slackMentionedUserIds(ev.authoredText),
|
|
4671
|
+
...(ev.userId ? { askerId: ev.userId } : {}),
|
|
4672
|
+
...(trigger.authorName ? { askerName: trigger.authorName } : {}),
|
|
4673
|
+
text: ev.authoredText,
|
|
4674
|
+
now: new Date(atIso),
|
|
4675
|
+
}).catch(() => ({ covered: [] }) as { systemNote?: string });
|
|
4676
|
+
const wake = cover.systemNote ? { ...watchWake, systemNote: watchWake.systemNote ? `${watchWake.systemNote}\n${cover.systemNote}` : cover.systemNote } : watchWake;
|
|
4549
4677
|
const rendered = renderWake(
|
|
4550
4678
|
nameDropTier ? await withholdNameDropContext(wake, externalPolicy, ev.teamId, deps.authorIsMember) : wake,
|
|
4551
4679
|
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
|
+
}
|