@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.
Files changed (127) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +15 -1
  3. package/agent/channels/slack-agent-session.ts +249 -0
  4. package/agent/channels/slack-attachments.ts +20 -11
  5. package/agent/channels/slack-commands.ts +172 -12
  6. package/agent/channels/slack-context.ts +0 -5
  7. package/agent/channels/slack-events.ts +103 -29
  8. package/agent/channels/slack-follow-ups.ts +148 -0
  9. package/agent/channels/slack-front.ts +232 -26
  10. package/agent/channels/slack-home.ts +9 -3
  11. package/agent/channels/slack-identity.ts +9 -1
  12. package/agent/channels/slack-memory-canvas.ts +192 -51
  13. package/agent/channels/slack-origin-codec.ts +9 -0
  14. package/agent/channels/slack-post-arm.ts +68 -11
  15. package/agent/channels/slack-read.ts +27 -2
  16. package/agent/channels/slack-reply-blocks.ts +180 -0
  17. package/agent/channels/slack-reply-feedback.ts +75 -0
  18. package/agent/channels/slack-shortcut.ts +1 -1
  19. package/agent/channels/slack-voice-clips.ts +82 -0
  20. package/agent/channels/slack-wake-backscroll.ts +0 -5
  21. package/agent/channels/slack.ts +74 -13
  22. package/agent/docs-generated.ts +1282 -2
  23. package/agent/ops.ts +14 -21
  24. package/agent/register.ts +13 -0
  25. package/agent/setup/credentials.ts +17 -0
  26. package/agent/setup/page.ts +6 -3
  27. package/agent/surface-parity.ts +2 -0
  28. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
  29. package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
  30. package/dist/agent/app-manifest.d.ts.map +1 -1
  31. package/dist/agent/app-manifest.js +15 -1
  32. package/dist/agent/app-manifest.js.map +1 -1
  33. package/dist/agent/channels/slack-agent-session.d.ts +81 -0
  34. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-agent-session.js +198 -0
  36. package/dist/agent/channels/slack-agent-session.js.map +1 -0
  37. package/dist/agent/channels/slack-attachments.d.ts +11 -0
  38. package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
  39. package/dist/agent/channels/slack-attachments.js +21 -14
  40. package/dist/agent/channels/slack-attachments.js.map +1 -1
  41. package/dist/agent/channels/slack-commands.d.ts +113 -10
  42. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-commands.js +146 -13
  44. package/dist/agent/channels/slack-commands.js.map +1 -1
  45. package/dist/agent/channels/slack-context.d.ts +1 -2
  46. package/dist/agent/channels/slack-context.d.ts.map +1 -1
  47. package/dist/agent/channels/slack-context.js +0 -4
  48. package/dist/agent/channels/slack-context.js.map +1 -1
  49. package/dist/agent/channels/slack-events.d.ts +46 -97
  50. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  51. package/dist/agent/channels/slack-events.js +86 -21
  52. package/dist/agent/channels/slack-events.js.map +1 -1
  53. package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
  54. package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-follow-ups.js +129 -0
  56. package/dist/agent/channels/slack-follow-ups.js.map +1 -0
  57. package/dist/agent/channels/slack-front.d.ts +28 -45
  58. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  59. package/dist/agent/channels/slack-front.js +207 -22
  60. package/dist/agent/channels/slack-front.js.map +1 -1
  61. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  62. package/dist/agent/channels/slack-home.js +10 -3
  63. package/dist/agent/channels/slack-home.js.map +1 -1
  64. package/dist/agent/channels/slack-identity.d.ts +7 -21
  65. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  66. package/dist/agent/channels/slack-identity.js +3 -1
  67. package/dist/agent/channels/slack-identity.js.map +1 -1
  68. package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
  69. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  70. package/dist/agent/channels/slack-memory-canvas.js +162 -52
  71. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  72. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  73. package/dist/agent/channels/slack-origin-codec.js +10 -0
  74. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  75. package/dist/agent/channels/slack-post-arm.d.ts +21 -1
  76. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  77. package/dist/agent/channels/slack-post-arm.js +57 -10
  78. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  79. package/dist/agent/channels/slack-read.d.ts +11 -1
  80. package/dist/agent/channels/slack-read.d.ts.map +1 -1
  81. package/dist/agent/channels/slack-read.js +27 -3
  82. package/dist/agent/channels/slack-read.js.map +1 -1
  83. package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
  84. package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
  85. package/dist/agent/channels/slack-reply-blocks.js +172 -0
  86. package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
  87. package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
  88. package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
  89. package/dist/agent/channels/slack-reply-feedback.js +63 -0
  90. package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
  91. package/dist/agent/channels/slack-shortcut.d.ts +10 -0
  92. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
  93. package/dist/agent/channels/slack-shortcut.js +1 -1
  94. package/dist/agent/channels/slack-shortcut.js.map +1 -1
  95. package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
  96. package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
  97. package/dist/agent/channels/slack-voice-clips.js +80 -0
  98. package/dist/agent/channels/slack-voice-clips.js.map +1 -0
  99. package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
  100. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  101. package/dist/agent/channels/slack-wake-backscroll.js +0 -4
  102. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  103. package/dist/agent/channels/slack.d.ts +22 -1
  104. package/dist/agent/channels/slack.d.ts.map +1 -1
  105. package/dist/agent/channels/slack.js +63 -13
  106. package/dist/agent/channels/slack.js.map +1 -1
  107. package/dist/agent/docs-generated.d.ts.map +1 -1
  108. package/dist/agent/docs-generated.js +1282 -2
  109. package/dist/agent/docs-generated.js.map +1 -1
  110. package/dist/agent/ops.d.ts +1 -1
  111. package/dist/agent/ops.d.ts.map +1 -1
  112. package/dist/agent/ops.js +15 -21
  113. package/dist/agent/ops.js.map +1 -1
  114. package/dist/agent/register.d.ts.map +1 -1
  115. package/dist/agent/register.js +12 -1
  116. package/dist/agent/register.js.map +1 -1
  117. package/dist/agent/setup/credentials.d.ts +12 -0
  118. package/dist/agent/setup/credentials.d.ts.map +1 -1
  119. package/dist/agent/setup/credentials.js +17 -0
  120. package/dist/agent/setup/credentials.js.map +1 -1
  121. package/dist/agent/setup/page.d.ts.map +1 -1
  122. package/dist/agent/setup/page.js +6 -3
  123. package/dist/agent/setup/page.js.map +1 -1
  124. package/dist/agent/surface-parity.d.ts.map +1 -1
  125. package/dist/agent/surface-parity.js +2 -0
  126. package/dist/agent/surface-parity.js.map +1 -1
  127. 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
- // PREFER_NO_REPLY_TOOL is re-exported here for existing importers; provider-neutral callers reach
133
- // NO_REPLY_SENTINEL through turn-delivery.ts (OPE-114 Stage 0), never through this provider file.
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
- export function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake {
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: Record<string, unknown>) => Promise<void>;
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: Record<string, unknown>): Promise<void> {
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 org = resolvePartition({
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]).org;
1524
- await getOperationalStore().appendAudit({ ts: new Date().toISOString(), org, platform, workspace, surface, action, detail });
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
- export async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision> {
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: Record<string, unknown> = {}): void {
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: Record<string, unknown>): Promise<void> {
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: Record<string, unknown>): Promise<void> {
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: Record<string, unknown>,
1694
+ detail: AuditDetail,
1641
1695
  ): Promise<void> {
1642
1696
  try {
1643
- const p = resolvePartition(bindableForSlackIds(ids));
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
- export function nameDropAbstained(input: NameDropInput): boolean {
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
- export async function withholdNameDropContext(
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: Record<string, unknown>) => Promise<void>;
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 wake = slackChannelWatchWake(ev, {
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
+ }