@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.
Files changed (127) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +18 -2
  3. package/agent/channels/slack-agent-session.ts +338 -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 +161 -33
  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 +43 -2
  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 +1299 -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 +18 -2
  32. package/dist/agent/app-manifest.js.map +1 -1
  33. package/dist/agent/channels/slack-agent-session.d.ts +85 -0
  34. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-agent-session.js +282 -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 +53 -100
  50. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  51. package/dist/agent/channels/slack-events.js +142 -26
  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 +33 -21
  65. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  66. package/dist/agent/channels/slack-identity.js +36 -2
  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 +1299 -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
@@ -39,6 +39,7 @@
39
39
  // commands) still forwards exactly as before.
40
40
  // 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
41
41
 
42
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
42
43
  import { parseSlackUserDeactivation, routeSlackUserDeactivation, type SlackUserDeactivation } from "./slack-offboarding.js";
43
44
  import type { OffboardOutcome } from "@open-everywhere/core/agent/adapter-api.js";
44
45
  import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
@@ -50,6 +51,7 @@ import {
50
51
  auditDmBotAuthorDrop,
51
52
  auditDmDriverAdmitted,
52
53
  dmDoorAdmitsDriver,
54
+ UNCHANGED_EDIT_DROP_REASON,
53
55
  parseMessageEvent,
54
56
  routeMessageEvent,
55
57
  liveDeps,
@@ -67,6 +69,7 @@ import {
67
69
  bindableFor,
68
70
  bindableForJoin,
69
71
  bindableForReaction,
72
+ bindableForSlackIds,
70
73
  slackSingleWindowMentionPolicy,
71
74
  slackDomainUsesUnifiedWindow,
72
75
  slackFrontTenantFenceDeclined,
@@ -84,6 +87,7 @@ import {
84
87
  touchDmSeat as liveTouchDmSeat,
85
88
  dmSeatThresholds,
86
89
  resolvePartition,
90
+ auditSurfaceModeFrom,
87
91
  domainKeyOf,
88
92
  isThreadEngaged,
89
93
  divertInboundToUnifiedWindow,
@@ -130,6 +134,9 @@ import {
130
134
  } from "@open-everywhere/core/agent/adapter-api.js";
131
135
  import { botReplyCandidate, slackBotReplyFacts } from "./slack-bot-ask.js";
132
136
  import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
137
+ import { parseAgentSessionStopped, routeAgentSessionStop, setSlackSessionStatus, slackSessionTitle } from "./slack-agent-session.js";
138
+ import { parseSlackFeedbackClick, routeSlackFeedbackClick, type ParsedSlackFeedbackClick } from "./slack-reply-feedback.js";
139
+ import { followUpMessageEvent, followUpPressKey, markFollowUpPressed, parseSlackFollowUpClick, type ParsedSlackFollowUpClick } from "./slack-follow-ups.js";
133
140
  import {
134
141
  renderWakeMessage,
135
142
  slackChannelFactsFor,
@@ -141,7 +148,6 @@ import {
141
148
  stripUnfencedSlackAttachments,
142
149
  slackEnterpriseIdFromEnvelope,
143
150
  slackForm,
144
- _resetAuthorMemberCache,
145
151
  } from "./slack-events.js";
146
152
  import { buildSlackAuth, withForwardedSlackEnvelope, type ForwardedSlackEnvelope } from "./slack-identity.js";
147
153
  import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
@@ -159,6 +165,7 @@ import {
159
165
  type ParsedHomeSubmission,
160
166
  } from "./slack-home.js";
161
167
  import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
168
+ import { hasSlackVoiceClip, withSlackVoiceClips } from "./slack-voice-clips.js";
162
169
  import {
163
170
  parseSlackShortcutPayload,
164
171
  shortcutMessageEvent,
@@ -202,6 +209,12 @@ export type FrontDecision =
202
209
  * OWNER of the ask answered it in their own DM. Core verifies the clicker IS that owner, then
203
210
  * approves or declines. Never forwarded. */
204
211
  | { readonly kind: "grant-click"; readonly reason: string; readonly payload: unknown }
212
+ /** A click on Slack's native thumbs up / down under an agent answer (OPE-2520, slack-reply-blocks.ts)
213
+ * โ€” ours: record it as a `reply.feedback` row. Never forwarded, and it wakes nothing. */
214
+ | { readonly kind: "feedback-click"; readonly reason: string; readonly payload: unknown; readonly click: ParsedSlackFeedbackClick }
215
+ /** OPE-2516 โ€” a press on a suggested follow-up under an answer in a 1:1 DM (`oe_followup:<n>`). The
216
+ * handler wakes the DM seat with the button's text as the person's next message. */
217
+ | { readonly kind: "followup-click"; readonly reason: string; readonly payload: unknown; readonly click: ParsedSlackFollowUpClick }
205
218
  /** An `app_mention` โ€” the native slackChannel path owns it UNLESS the handler's single-window
206
219
  * policy says the unified window does (then the forward is suppressed and the mention's
207
220
  * `message.channels` sibling carries it as an addressed wake). Carries `env` so the handler
@@ -231,7 +244,10 @@ export type FrontDecision =
231
244
  | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
232
245
  /** An `app_context_changed` (OPE-2457) โ€” ours, and nothing to do. The subscription exists only so
233
246
  * Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
234
- | { readonly kind: "context-change"; readonly reason: string };
247
+ | { readonly kind: "context-change"; readonly reason: string }
248
+ /** An `agent_session_stopped` (OPE-2519) โ€” ours: the person pressed Slack's native Stop on an agent
249
+ * session. It takes the โœ‹ arm's cancel path (slack-agent-session.ts `routeAgentSessionStop`). */
250
+ | { readonly kind: "session-stop"; readonly reason: string; readonly env: SlackEventEnvelope };
235
251
 
236
252
  /**
237
253
  * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
@@ -405,6 +421,19 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
405
421
  if (grantClick !== undefined) {
406
422
  return { kind: "grant-click", reason: "block_actions oe_grant: click โ€” ours, never forwarded", payload: grantClick };
407
423
  }
424
+ // OPE-2520 โ€” the native thumbs under an answer. Its action id is exactly `oe_feedback`, which no
425
+ // prefix above matches, so the order among these branches does not matter.
426
+ const feedbackPayload = decodeInteractivityPayload(rawBody);
427
+ const feedbackClick = parseSlackFeedbackClick(feedbackPayload);
428
+ if (feedbackClick !== undefined) {
429
+ return { kind: "feedback-click", reason: `block_actions oe_feedback ${feedbackClick.rating} โ€” ours, never forwarded`, payload: feedbackPayload, click: feedbackClick };
430
+ }
431
+ // OPE-2516 โ€” a suggested follow-up under an answer. Its action ids start `oe_followup:`, which no
432
+ // branch above matches.
433
+ const followUpClick = parseSlackFollowUpClick(feedbackPayload);
434
+ if (followUpClick !== undefined) {
435
+ return { kind: "followup-click", reason: "block_actions oe_followup: press in a DM โ€” ours, never forwarded", payload: feedbackPayload, click: followUpClick };
436
+ }
408
437
  // THE HOME TAB (OPE-2451). Its clicks carry an `oe_home:` action id and its one modal a callback
409
438
  // id of its own, so neither can be mistaken for another door's payload, nor for eve's.
410
439
  const homePayload = decodeInteractivityPayload(rawBody);
@@ -465,6 +494,10 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
465
494
  // forward costs eve a parse and a re-verify for every click the person makes in Slack.
466
495
  return { kind: "context-change", reason: "app_context_changed โ€” the room rides the next side-panel message; nothing to wake" };
467
496
  }
497
+ if (eventType === "agent_session_stopped") {
498
+ // SLACK'S NATIVE STOP (OPE-2519). The subscription is what makes Slack show the button at all.
499
+ return { kind: "session-stop", reason: "agent_session_stopped โ€” stop the session's running turn", env };
500
+ }
468
501
  if (eventType === "user_change") {
469
502
  // OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours โ€” automatic offboarding.
470
503
  return { kind: "user-change", reason: "user_change โ€” a deactivation report candidate", env };
@@ -578,16 +611,14 @@ export function resolveBotIdentity(): Promise<{ readonly userId?: string; readon
578
611
  return botIdentityPromise;
579
612
  }
580
613
 
581
- /** Reset the cached identity (tests only). */
582
- export function _resetBotIdentity(): void {
583
- botIdentityPromise = undefined;
584
- }
585
-
586
614
  // --- the composed channel ---------------------------------------------------
587
615
 
588
616
  /** Injectable seams so the wake path is testable without live crypto / Slack. */
589
617
  export interface FrontOptions {
590
618
  readonly signingSecret?: string;
619
+ /** Hear the voice clips on a message an addressed road already decided to wake (OPE-2524). Defaults to
620
+ * slack-voice-clips.ts `withSlackVoiceClips`; a test injects a stub so no audio is fetched. */
621
+ readonly hearVoiceClips?: (ev: ParsedMessageEvent) => Promise<ParsedMessageEvent>;
591
622
  readonly dedupe?: EventDedupe;
592
623
  readonly fifo?: ThreadFifo;
593
624
  readonly getBotIdentity?: () => Promise<{ readonly userId?: string; readonly botId?: string }>;
@@ -850,7 +881,7 @@ const logOutcome =
850
881
  * ({@link markUndeliveredAfterHeal}) before it rethrows, so {@link recordWakeFailure} can give the
851
882
  * row its own reason.
852
883
  */
853
- export async function deliverWithStrandRetry<T>(deliver: () => Promise<T>): Promise<T> {
884
+ async function deliverWithStrandRetry<T>(deliver: () => Promise<T>): Promise<T> {
854
885
  try {
855
886
  return await deliver();
856
887
  } catch (err) {
@@ -878,10 +909,11 @@ export function recordWakeFailure(key: string, err: unknown): void {
878
909
  void holdRouteWork(
879
910
  (async () => {
880
911
  try {
881
- const p = resolvePartition({
912
+ const bindable = {
882
913
  channel: { kind: "slack" },
883
914
  session: { auth: { current: { attributes: { team_id: "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
884
- } as Parameters<typeof resolvePartition>[0]);
915
+ } as Parameters<typeof resolvePartition>[0];
916
+ const p = resolvePartition(bindable);
885
917
  // OPE-970: a retry that ALSO failed after the pruned-deployment heal gets its own reason โ€”
886
918
  // otherwise it is indistinguishable from any other send failure, and the only way to tell
887
919
  // "the seat is fine now but this message was lost" from "a transient blip" was to read the
@@ -893,10 +925,11 @@ export function recordWakeFailure(key: string, err: unknown): void {
893
925
  platform: p.platform,
894
926
  workspace: p.workspace,
895
927
  surface: p.surface,
928
+ ...auditSurfaceModeFrom(bindable),
896
929
  action: "surface.wake_failed",
897
930
  detail: {
898
931
  fifoKey: key,
899
- message: String(err instanceof Error ? err.message : err).slice(0, 500),
932
+ message: auditErrorDetail(err).error,
900
933
  ...(undeliveredAfterHeal
901
934
  ? { reason: "pruned-deployment-heal-retry-failed", remedy: "seat healed for its next wake; this message got no answer" }
902
935
  : {}),
@@ -998,11 +1031,6 @@ async function liveDmPartnerTrust(userId: string, teamId: string | undefined): P
998
1031
  return member === undefined ? undefined : member ? "internal" : "external";
999
1032
  }
1000
1033
 
1001
- /** Reset the cached DM-partner trust (tests only). */
1002
- export function _resetDmTrustCache(): void {
1003
- _resetAuthorMemberCache();
1004
- }
1005
-
1006
1034
  // `wrapDmSeatFrom` (OPE-90) is DELETED at OPE-351 stage 2. It existed to remap the address of eve's
1007
1035
  // NATIVE DM send onto a rotatable seat token โ€” a bolt-on to a dispatch path a DM no longer takes. The
1008
1036
  // DM is now diverted onto the `unified` channel before the forward ever happens (`scheduleDmWake`
@@ -1058,9 +1086,27 @@ export function mentionSiblingSuppressed(input: {
1058
1086
  return input.authoredText.includes(`<@${input.botUserId}>`);
1059
1087
  }
1060
1088
 
1089
+ /**
1090
+ * Would a TYPED message in this thread reach the agent (OPE-2524)? The thread roads hear a voice clip only then, so a clip
1091
+ * is handled exactly as the same words typed. That is an engagement row that was not stood down: an invited thread, or a
1092
+ * name-drop watcher thread (whose typed follow-ups `shouldDeliverReply` admits, and which wakes with `oeUninvited`). A
1093
+ * STOOD-DOWN row is the one case `isThreadEngaged` admits and the road then drops (`shouldDeliverReply`'s `stopped`
1094
+ * leg, `routeSupervisorEvent`'s stood-down silence), so a clip heard there would be downloaded and billed for nothing.
1095
+ * Fail-closed: an unreadable row is not heard.
1096
+ */
1097
+ async function threadHearsClips(p: { readonly org: string; readonly surfaceKey: string }, threadTs: string): Promise<boolean> {
1098
+ try {
1099
+ const row = await getOperationalStore().getEngagement(p.org, p.surfaceKey, threadTs);
1100
+ return row !== null && row.stopped !== true;
1101
+ } catch {
1102
+ return false;
1103
+ }
1104
+ }
1105
+
1061
1106
  export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions = {}): RouteHandler {
1062
1107
  const dedupe = opts.dedupe ?? new EventDedupe();
1063
1108
  const fifo = opts.fifo ?? new ThreadFifo(recordWakeFailure);
1109
+ const hearVoice = opts.hearVoiceClips ?? ((ev: ParsedMessageEvent) => withSlackVoiceClips(ev));
1064
1110
  const getBotIdentity = opts.getBotIdentity ?? resolveBotIdentity;
1065
1111
  const now = opts.now ?? (() => Date.now());
1066
1112
  // THE SIGNING SECRET RESOLVES PER REQUEST, AND IT IS ASYNC SINCE OPE-2231. It used to be a
@@ -1278,7 +1324,11 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1278
1324
  // `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled โ€” and never on the
1279
1325
  // addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
1280
1326
  // registry only for a click that actually wakes something; every other caller omits it.
1281
- const scheduleUnownedWake = (ev: ParsedMessageEvent, label: string, directMention = false, onWake?: () => void, seatEngaged = false): void => {
1327
+ // `hearsMentionClip` (OPE-2524) is an explicit opt-in, set by the diverted @mention road ONLY. `directMention` alone
1328
+ // is not enough: a channel MESSAGE SHORTCUT is also an addressed wake, but the clip on its target message is someone
1329
+ // ELSE's speech, which the shortcut fences as untrusted, and hearing it would put that person's words outside the
1330
+ // fence at the presser's trust. A shortcut's clip is not heard.
1331
+ const scheduleUnownedWake = (ev: ParsedMessageEvent, label: string, directMention = false, onWake?: () => void, seatEngaged = false, hearsMentionClip = false): void => {
1282
1332
  let ambient = false;
1283
1333
  let surfaceKey: string | undefined;
1284
1334
  try {
@@ -1327,15 +1377,31 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1327
1377
  // on the hot path exactly like the token-based key did.
1328
1378
  const supKey = supervisorFifoKey(ev.channelId);
1329
1379
  args.waitUntil(
1330
- fifo.enqueue(supKey, () =>
1331
- deliverWithStrandRetry(() =>
1380
+ fifo.enqueue(supKey, async () => {
1381
+ // A voice clip is heard on an ADDRESSED seat wake only โ€” a mention, or an engaged thread that was not stood
1382
+ // down. An ambient wake on unaddressed talk never downloads a room's audio (OPE-2524, slack-voice-clips.ts).
1383
+ // A bot author is dropped by the seat's own gate, so its clip is never paid for. The clip test comes first, so
1384
+ // a message with no clip pays no extra store read. The thread leg also asks the room's fail-closed internal
1385
+ // confirmation FIRST, the one `shouldSupervisorWake` drops on later: a thread in a room that became shared, or
1386
+ // cannot be confirmed internal, wakes nothing, so its clip must not reach speech-to-text. (A mention already
1387
+ // passed that confirmation in the single-window policy.)
1388
+ const hears =
1389
+ !ev.botId &&
1390
+ hasSlackVoiceClip(ev) &&
1391
+ ((directMention && hearsMentionClip) ||
1392
+ (seatEngaged &&
1393
+ ev.threadTs !== undefined &&
1394
+ (await channelFactsFor(ev.channelId).then((f) => f.internal === true, () => false)) &&
1395
+ (await threadHearsClips(resolvePartition(bindableFor(ev)), ev.threadTs))));
1396
+ const heard = hears ? await hearVoice(ev) : ev;
1397
+ return deliverWithStrandRetry(() =>
1332
1398
  routeSupervisorEvent(
1333
- ev,
1399
+ heard,
1334
1400
  withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey!),
1335
1401
  directMention ? { directMention: true } : undefined,
1336
1402
  ).then(logOutcome(`${label}-supervisor`, ev.channelId, ev.ts)),
1337
- ),
1338
- ),
1403
+ );
1404
+ }),
1339
1405
  );
1340
1406
  return;
1341
1407
  }
@@ -1428,7 +1494,15 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1428
1494
  /** The DM door admitted this bot author as the deployment's named e2e driver (OPE-1023).
1429
1495
  * Defaults FALSE, so every existing call site is unchanged. */
1430
1496
  driverAdmitted = false,
1497
+ /** Set only for a real `message.im` the person sent, which belongs to a Slack agent session
1498
+ * (OPE-2519): the words the person TYPED, before any voice clip was heard. A shortcut's minted
1499
+ * ts names no message, and a group DM has no agent session, so both leave it undefined and
1500
+ * leave the session untouched. The title reads only these words: a voice clip's transcript
1501
+ * rides `authoredText` behind a `[voice clip]` marker, and a clip OE could not hear adds a
1502
+ * host-written line, so neither is the person's typed text. */
1503
+ agentSessionTypedText?: string,
1431
1504
  ): Promise<void> => {
1505
+ const opensAgentSession = agentSessionTypedText !== undefined;
1432
1506
  // ๐Ÿ”ด THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
1433
1507
  // this line `slack-front.ts` consulted the fence NOWHERE, while the mention door, the four
1434
1508
  // dispatching engagement routes, the channel-join door and the `/oe` slash door all did โ€” so a
@@ -1583,6 +1657,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1583
1657
  channelType: dmKind,
1584
1658
  isPrivate: true,
1585
1659
  ...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
1660
+ // OPE-2519: the 1:1 message's Slack agent session โ€” its thread, or itself when top-level.
1661
+ ...(opensAgentSession && !isGroup ? { agentSessionTs: ev.threadTs ?? ev.ts } : {}),
1586
1662
  // ๐Ÿ”ด THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
1587
1663
  // channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
1588
1664
  // reads the pair through `slackTrust`, so this is the binding's only route to the fact
@@ -1638,6 +1714,18 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1638
1714
  ...(seat.freshGeneration && seat.tail.length > 0 ? { backscroll: seat.tail } : {}),
1639
1715
  });
1640
1716
 
1717
+ // THE SESSION TITLE (OPE-2519). A top-level 1:1 message opens a new conversation in the Messages
1718
+ // tab or the side panel, and Slack keys its agent session by that message. Create the session with
1719
+ // a title from the person's own words BEFORE the wake: the turn's `processing` call would
1720
+ // otherwise create it untitled first, and Slack applies a title only on creation. That same rule
1721
+ // keeps a title the person typed over ours. A group DM, a shortcut and a thread reply open no new
1722
+ // session. The title reads the TYPED text alone, before any voice clip was heard: `text` also
1723
+ // carries the lines of a forwarded or quoted message, which are another conversation's words.
1724
+ if (opensAgentSession && !isGroup && !ev.threadTs && ev.ts) {
1725
+ const title = slackSessionTitle(agentSessionTypedText);
1726
+ if (title) await setSlackSessionStatus({ channelId: ev.channelId, threadTs: ev.ts, status: "active", title });
1727
+ }
1728
+
1641
1729
  // ๐Ÿ”ด NO `replyAnchorTs` ON A TOP-LEVEL DM โ€” the reply stays FLAT in the conversation. The
1642
1730
  // CHANNEL divert DOES set one (slack-events.ts), because threading an answer under its trigger
1643
1731
  // keeps a busy shared channel tidy; the slack post arm reads `threadTs ?? replyAnchorTs`
@@ -1781,6 +1869,16 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1781
1869
  // between here and it. The parse above has already seeded the in-memory attachment LRU, which
1782
1870
  // is the one cost no placement in this branch avoids.
1783
1871
  if (slackFrontTenantFenceDeclined(dev, `dm-${decision.dmKind}`, "front-dm-branch")) return fencedAck(OK());
1872
+ // ๐Ÿ”ด AN EDIT THAT CHANGED NOTHING IS NOT A WAKE (OPE-2577). OE's own agent-session status calls
1873
+ // edit the person's root message, and each edit reaches this door as a `message_changed` with
1874
+ // the person as author. Woken, each one was a new inbound wake that made the answer to the
1875
+ // person's real message stale. See {@link isUnchangedSlackEdit}. Above the driver carve-out, so
1876
+ // the driver's DM and a person's DM take the same drop. A log line only, no audit row: three of
1877
+ // these follow every DM turn, the same reasoning that keeps our own reply echo out of the trail.
1878
+ if (dev.unchangedEdit) {
1879
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: no wake โ€” ${UNCHANGED_EDIT_DROP_REASON}`);
1880
+ return OK();
1881
+ }
1784
1882
  // A bot-authored DM is never a wake โ€” a no-op 200. NOT a forward: forwarding a bot_message
1785
1883
  // would hand eve's native door a turn the DM window is meant to own.
1786
1884
  //
@@ -1879,7 +1977,10 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1879
1977
  // enqueues on the SAME key, so a group DM's two legs can never run concurrently on one seat.
1880
1978
  args.waitUntil(
1881
1979
  fifo.enqueue(dmFifoKey(dev.channelId), async () => {
1882
- await deliverWithStrandRetry(() => scheduleDmWake(dev, decision.dmKind, driverAdmitted));
1980
+ // A voice clip is heard ONCE, before the retry wrapper, so a strand retry never pays for a second
1981
+ // transcription (OPE-2524, slack-voice-clips.ts). A DM is always addressed, so its clips are always heard.
1982
+ const heard = await hearVoice(dev);
1983
+ await deliverWithStrandRetry(() => scheduleDmWake(heard, decision.dmKind, driverAdmitted, decision.dmKind === "im" ? dev.authoredText : undefined));
1883
1984
  console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke โ€” diverted to the DM window seat`);
1884
1985
  }),
1885
1986
  );
@@ -2053,7 +2154,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2053
2154
  // directMention path (slack-events.ts routeSupervisorEvent) and re-checks its own
2054
2155
  // fail-closed internal confirmation โ€” the SAME process-local TTL-cached conversations.info
2055
2156
  // the policy just populated, so the two reads on this one webhook are cache-coherent.
2056
- scheduleUnownedWake(mev, "single-window-mention", true);
2157
+ scheduleUnownedWake(mev, "single-window-mention", true, undefined, false, true);
2057
2158
  return OK();
2058
2159
  }
2059
2160
  // ๐Ÿ”ด THE GROUP-DM MENTION LEG (OPE-351 stage 4). An @mention is how a human addresses the bot in
@@ -2092,7 +2193,9 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2092
2193
  // which is what makes `bindSlack` bind mode `mpim` on this leg exactly as on the other.
2093
2194
  args.waitUntil(
2094
2195
  fifo.enqueue(dmFifoKey(mev.channelId), async () => {
2095
- await deliverWithStrandRetry(() => scheduleDmWake({ ...mev, channelType: "mpim" }, "mpim"));
2196
+ // A bot's group-DM mention is not heard: `scheduleDmWake` drops a bot author after this step.
2197
+ const heard = mev.botId ? mev : await hearVoice(mev);
2198
+ await deliverWithStrandRetry(() => scheduleDmWake({ ...heard, channelType: "mpim" }, "mpim"));
2096
2199
  }),
2097
2200
  );
2098
2201
  return OK();
@@ -2248,6 +2351,56 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2248
2351
  return new Response(null, { status: 200 });
2249
2352
  }
2250
2353
 
2354
+ if (decision.kind === "feedback-click") {
2355
+ // THE TENANT FENCE FIRST (OPE-1680), as every click branch: a foreign workspace writes no row.
2356
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "feedback-click", "front-interactivity-branch")) {
2357
+ return fencedAck(new Response(null, { status: 200 }));
2358
+ }
2359
+ // OPE-2520 โ€” a rating, recorded after the acknowledgement. Slack draws the pressed state itself,
2360
+ // so the empty 200 is the whole answer, and the row write never holds the click up.
2361
+ const click = decision.click;
2362
+ args.waitUntil(
2363
+ routeSlackFeedbackClick(click)
2364
+ .then((recorded) => {
2365
+ if (!recorded) console.warn("[open-everywhere:slack-front] a thumbs click was not recorded");
2366
+ })
2367
+ .catch(() => undefined),
2368
+ );
2369
+ return new Response(null, { status: 200 });
2370
+ }
2371
+
2372
+ if (decision.kind === "followup-click") {
2373
+ // OPE-2516 โ€” the person pressed a suggested follow-up under an answer in their DM.
2374
+ // THE TENANT FENCE FIRST (OPE-1680), as every click branch. `scheduleDmWake` refuses the same
2375
+ // workspace on its own; this spares an unadmitted one the rewrite and the dedupe write.
2376
+ const click = decision.click;
2377
+ const sev = followUpMessageEvent(click, now());
2378
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "followup-click", "front-interactivity-branch")) {
2379
+ return fencedAck(new Response(null, { status: 200 }));
2380
+ }
2381
+ // One answer takes one press: a double press, or Slack's redelivery of one, wakes the seat once.
2382
+ if (!(await notYetSeenKey(followUpPressKey(click), sev))) return new Response(null, { status: 200 });
2383
+ // ACK FIRST, WORK AFTER, as the shortcut branch: the rewrite and the wake both ride `waitUntil`.
2384
+ // The rewrite removes the buttons and shows what the person sent. The wake takes the SAME road a
2385
+ // typed DM takes, so the seat, the trust probe, the external-participant policy and the spend
2386
+ // gate all decide it. The agent session is touched only when the answer sits in a thread: a
2387
+ // top-level answer has no session thread for a minted ts to name.
2388
+ args.waitUntil(
2389
+ markFollowUpPressed(click)
2390
+ .then((rewritten) => {
2391
+ if (!rewritten) console.log(`[open-everywhere:slack-front] follow-up press in ${click.channelId}: the answer was not rewritten`);
2392
+ })
2393
+ .catch(() => undefined),
2394
+ );
2395
+ args.waitUntil(
2396
+ fifo.enqueue(dmFifoKey(click.channelId), async () => {
2397
+ await deliverWithStrandRetry(() => scheduleDmWake(sev, "im", false, click.threadTs === undefined ? undefined : click.text));
2398
+ console.log(`[open-everywhere:slack-front] follow-up press in ${click.channelId}: woke the DM window`);
2399
+ }),
2400
+ );
2401
+ return new Response(null, { status: 200 });
2402
+ }
2403
+
2251
2404
  if (decision.kind === "home-open" || decision.kind === "home-click" || decision.kind === "home-submit") {
2252
2405
  // THE HOME TAB (OPE-2451). THE TENANT FENCE FIRST (OPE-1680), for the reason the approval branch
2253
2406
  // states: the page reads and changes a person's memory, keyed to a bare per-workspace member id,
@@ -2433,6 +2586,49 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2433
2586
  return OK();
2434
2587
  }
2435
2588
 
2589
+ if (decision.kind === "session-stop") {
2590
+ // SLACK'S NATIVE STOP (OPE-2519) โ€” the same cancel door as the โœ‹ arm below, with the same fence
2591
+ // in front of it: an unadmitted workspace's press must never reach `args.attachSession`.
2592
+ const stop = parseAgentSessionStopped(decision.env);
2593
+ if (!stop) return OK();
2594
+ const stopEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
2595
+ if (
2596
+ slackInteractivityTenantFenceDeclined(
2597
+ {
2598
+ ...(stop.teamId === undefined ? {} : { teamId: stop.teamId }),
2599
+ ...(stopEnterpriseId === undefined ? {} : { enterpriseId: stopEnterpriseId }),
2600
+ channelId: stop.channelId,
2601
+ userId: stop.userId,
2602
+ },
2603
+ "session-stop",
2604
+ "front-session-stop-branch",
2605
+ )
2606
+ ) {
2607
+ return fencedAck(OK());
2608
+ }
2609
+ // A Slack RETRY of the same press must not send a second cancel: in-memory first, then durable.
2610
+ const skey = wakeKey(decision.env, stop.channelId, stop.eventTs ?? stop.threadTs);
2611
+ if (dedupe.seen(skey, now())) return OK();
2612
+ dedupe.mark(skey, now());
2613
+ try {
2614
+ const org = resolvePartition(bindableForSlackIds({ teamId: stop.teamId, channelId: stop.channelId, userId: stop.userId })).org;
2615
+ if (!(await markEventSeen(org, skey, new Date(now()).toISOString()))) return OK();
2616
+ } catch {
2617
+ /* unresolvable partition โ†’ in-memory dedupe only (fail-open, as for a reaction) */
2618
+ }
2619
+ if (!args.attachSession) {
2620
+ console.warn(`[open-everywhere:slack-front] session stop ${stop.channelId}/${stop.threadTs}: no cancel door on this route โ€” nothing stopped`);
2621
+ return OK();
2622
+ }
2623
+ const attachSession = args.attachSession;
2624
+ args.waitUntil(
2625
+ routeAgentSessionStop(stop, { attachSession }).then((r) =>
2626
+ console.log(`[open-everywhere:slack-front] session stop ${stop.channelId}/${stop.threadTs}: ${r.outcome}`),
2627
+ ),
2628
+ );
2629
+ return OK();
2630
+ }
2631
+
2436
2632
  if (decision.kind === "reaction") {
2437
2633
  // Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
2438
2634
  const rev = parseSlackReactionEvent(decision.env);
@@ -2496,6 +2692,10 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2496
2692
  // Wake path. Parse the message; a non-message or id-less envelope is a no-op 200.
2497
2693
  const ev = parseMessageEvent(decision.env);
2498
2694
  if (!ev) return OK();
2695
+ // An edit that changed no words and no files (a link unfurl, for example) is not news here either:
2696
+ // the engagement and supervisor gates keep `message_changed` so a person's REAL edit wakes, and
2697
+ // this keeps a metadata-only one out (OPE-2577, {@link isUnchangedSlackEdit}).
2698
+ if (ev.unchangedEdit) return OK();
2499
2699
  persistAttachments(ev); // durable upload registry (task #107) โ€” so a delegated worker can read this message's files
2500
2700
 
2501
2701
  // ๐Ÿ”ด THE ASKED-THREAD EXCEPTION (OPE-2426): the only road by which another bot's message WAKES the
@@ -2584,7 +2784,13 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2584
2784
  // problem the durable dedupe above closes.)
2585
2785
  const threadKey = slackContinuationToken(ev.channelId, ev.threadTs);
2586
2786
  args.waitUntil(
2587
- fifo.enqueue(threadKey, () => routeMessageEvent(ev, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs!))),
2787
+ // A human's voice clip is heard here unless the thread was stood down (OPE-2524): `shouldDeliverReply` drops a bot
2788
+ // author and a stood-down thread, so their clips are never paid for. The clip test comes first, so a message with
2789
+ // no clip pays no extra store read.
2790
+ fifo.enqueue(threadKey, async () => {
2791
+ const heard = !ev.botId && hasSlackVoiceClip(ev) && (await threadHearsClips(p, ev.threadTs!)) ? await hearVoice(ev) : ev;
2792
+ return routeMessageEvent(heard, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs!));
2793
+ }),
2588
2794
  );
2589
2795
  return OK();
2590
2796
  }
@@ -1,5 +1,5 @@
1
- // THE SLACK APP HOME TAB (OPE-2451) โ€” the person's own page: what the agent remembers about them,
2
- // what their domains know, and their rules (OPE-2456). Core owns the page model
1
+ // THE SLACK APP HOME TAB (OPE-2451) โ€” the person's own page: what the agent does for them (OPE-2522),
2
+ // what it remembers about them, what their domains know, and their rules (OPE-2456). Core owns the page model
3
3
  // (`home/home-view.ts`); this module is the Slack half: it parses the three Slack deliveries the
4
4
  // page needs, builds the VERIFIED viewer, renders Block Kit, and drives the dialogs.
5
5
  //
@@ -24,6 +24,7 @@
24
24
  // A guest, or a person whose only route into a domain is a Slack Connect channel, sees their own
25
25
  // memory and no domain's.
26
26
 
27
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
27
28
  import {
28
29
  appendAudit,
29
30
  bindFrom,
@@ -214,6 +215,10 @@ function renderBlock(b: HomeBlock): SlackBlock[] {
214
215
  action_id: `${HOME_ACTION_PREFIX}item`,
215
216
  options: b.actions.map((a) => ({ text: plain(a.label), value: `${a.id}|${b.ref}` })),
216
217
  };
218
+ } else if (b.link && /^https:\/\//u.test(b.link) && b.link.length <= 3000) {
219
+ // A link button (OPE-2522). Slack still sends a click on it; `oe_home:link` names no registered action, so the
220
+ // front route claims it and runs nothing.
221
+ section.accessory = { type: "button", action_id: `${HOME_ACTION_PREFIX}link`, text: plain("Open"), url: b.link };
217
222
  }
218
223
  return [section];
219
224
  }
@@ -346,6 +351,7 @@ export async function buildSlackHomeViewer(ids: ParsedHomeInteraction, deps: Sla
346
351
  platform: partition.platform,
347
352
  workspace: partition.workspace,
348
353
  principalId: binding.principalId,
354
+ ...(ids.enterpriseId ? { enterprise: ids.enterpriseId } : {}),
349
355
  ...(dmId ? { personal: partition } : {}),
350
356
  trust,
351
357
  domains: homeDomainsFor(trust, memberships),
@@ -442,7 +448,7 @@ async function publishHome(userId: string, viewer: HomeViewer, deps: SlackHomeDe
442
448
  trust: viewer.trust,
443
449
  domains: viewer.domains.length,
444
450
  notice: notice !== undefined,
445
- ...(ok ? {} : { error: typeof res?.error === "string" ? res.error : "no response" }),
451
+ ...(ok ? {} : { error: auditErrorDetail(typeof res?.error === "string" ? res.error : "no response").error }),
446
452
  },
447
453
  }).catch(() => undefined);
448
454
  return ok;
@@ -55,6 +55,13 @@ export interface SlackIdentityInput {
55
55
  * fails closed (external), so a DM's shared read layer is `public`, never org/dm-shared.
56
56
  */
57
57
  readonly dmPartnerTrust?: "internal" | "external";
58
+ /**
59
+ * For a 1:1 DM only: the thread root of the Slack agent session the message belongs to (OPE-2519) โ€”
60
+ * its `thread_ts`, or its own `ts` for a top-level message, which opens a session. It names the
61
+ * session whose state OE sets (channels/slack-agent-session.ts). It changes no reply target: the
62
+ * reply still reads `thread_ts`.
63
+ */
64
+ readonly agentSessionTs?: string;
58
65
  }
59
66
 
60
67
  /** Build the auth context OpenEverywhere dispatches with โ€” raw facts as string attributes. */
@@ -71,6 +78,7 @@ export function buildSlackAuth(input: SlackIdentityInput): OpenEverywhereSlackAu
71
78
  if (typeof input.isExternal === "boolean") attributes.is_ext_shared = String(input.isExternal);
72
79
  if (typeof input.trustChecked === "boolean") attributes.trust_checked = String(input.trustChecked);
73
80
  if (input.dmPartnerTrust) attributes.dm_partner_trust = input.dmPartnerTrust;
81
+ if (input.agentSessionTs) attributes.agent_session_ts = input.agentSessionTs;
74
82
  // Root-DM grain stamp (window-tool-gating ยงD-DM, widened to group DMs): a 1:1 DM OR an mpim (group
75
83
  // DM) is a STANDING seat โ‡’ narrowed hands โ€” both are closed multi-party conversations with no
76
84
  // channel identity. A DM is unambiguously channelType "im" (the DM path always sets it); the
@@ -196,7 +204,7 @@ function enterpriseIdOfUser(u: Record<string, unknown>): string | undefined {
196
204
  * PURE and SYNCHRONOUS โ€” the register read is (identity/trust.ts's own contract), so this adds no
197
205
  * I/O to a leg that already pays for one `users.info`.
198
206
  */
199
- export function slackWorkspaceVouched(teamId: string | undefined, enterpriseId: string | undefined): boolean {
207
+ function slackWorkspaceVouched(teamId: string | undefined, enterpriseId: string | undefined): boolean {
200
208
  const team = surfaceKeySegment(teamId);
201
209
  if (team.length === 0) return false; // no workspace named โ‡’ nothing to ask about โ‡’ not vouched
202
210
  const enterprise = surfaceKeySegment(enterpriseId);
@@ -257,7 +265,7 @@ export function slackWorkspaceVouched(teamId: string | undefined, enterpriseId:
257
265
  export function partnerFactsFromInfo(info: unknown, expectedTeamId?: string): { dmPartnerTrust: "internal" | "external" } {
258
266
  const u = (info as { user?: Record<string, unknown> } | null)?.user;
259
267
  if (!u || typeof u !== "object") return { dmPartnerTrust: "external" }; // lookup failed โ†’ fail closed
260
- const guestOrStranger = Boolean(u["is_restricted"] || u["is_ultra_restricted"] || u["is_stranger"]);
268
+ const guestOrStranger = slackUserGuestFlagged(u);
261
269
  const deleted = Boolean(u["deleted"]);
262
270
  const homeTeamId = typeof u["team_id"] === "string" ? (u["team_id"] as string) : undefined;
263
271
  // ๐Ÿ”ด NO EXPECTED WORKSPACE IS NOT AN OWN-WORKSPACE ANSWER. This used to read the absence as
@@ -280,6 +288,39 @@ export function partnerFactsFromInfo(info: unknown, expectedTeamId?: string): {
280
288
  return { dmPartnerTrust: internal ? "internal" : "external" };
281
289
  }
282
290
 
291
+ /** Slack's own guest flags on a `users.info` user object: a multi-channel guest (`is_restricted`), a
292
+ * single-channel guest (`is_ultra_restricted`) or a Slack Connect stranger (`is_stranger`). The ONE
293
+ * reader of these three flags, so {@link partnerFactsFromInfo} and the 1:1 DM roster
294
+ * ({@link imRosterFactsFromInfo}) cannot disagree on what Slack calls a guest. It reads no workspace
295
+ * and no declaration: it is what Slack OBSERVED about the person, nothing inferred. */
296
+ export function slackUserGuestFlagged(u: Record<string, unknown>): boolean {
297
+ return Boolean(u["is_restricted"] || u["is_ultra_restricted"] || u["is_stranger"]);
298
+ }
299
+
300
+ /**
301
+ * The room facts of a 1:1 DM (`is_im`) from a `conversations.info` response, or `undefined` for any
302
+ * other conversation (OPE-2630). The DM roster reads these instead of
303
+ * {@link ChannelFacts.isExternal}.
304
+ *
305
+ * ๐Ÿ”ด WHY A DM DOES NOT READ `isExternal`. `isExternal` is true on ANY of the four share bits.
306
+ * `is_org_shared` marks a share across the workspaces of ONE Enterprise Grid organisation. `is_shared`
307
+ * marks ANY share, a Slack Connect one included, so it alone does not tell a Grid share from a Connect
308
+ * one. A Connect DM also carries `is_ext_shared` (or `is_pending_ext_shared`), and this function reads
309
+ * those two bits. A 1:1 DM has exactly two parties, the app and one person, so `is_org_shared` or `is_shared` on it
310
+ * says nothing about an outside audience. Read as "external", they floored a full member's own DM to
311
+ * `guest`, and the card-disclosure floor refused every approval card there (OPE-2630, a Grid install).
312
+ * So a DM's audience is judged by the two facts that do name an outside party: the Slack Connect bits
313
+ * (`is_ext_shared`, `is_pending_ext_shared`) here, and the partner's own guest flags
314
+ * ({@link slackUserGuestFlagged}), which the roster reads with `users.info`.
315
+ */
316
+ export function imRosterFactsFromInfo(info: unknown): { readonly partnerId?: string; readonly connectShared: boolean } | undefined {
317
+ const ch = (info as { channel?: Record<string, unknown> } | null)?.channel;
318
+ if (!ch || typeof ch !== "object" || ch["is_im"] !== true) return undefined;
319
+ const partnerId = typeof ch["user"] === "string" && ch["user"].length > 0 ? (ch["user"] as string) : undefined;
320
+ const connectShared = Boolean(ch["is_ext_shared"] || ch["is_pending_ext_shared"]);
321
+ return { ...(partnerId === undefined ? {} : { partnerId }), connectShared };
322
+ }
323
+
283
324
  /** What an app_mention envelope tells us directly (often just channel_type). */
284
325
  export function channelFactsFromRaw(raw: Record<string, unknown>): ChannelFacts {
285
326
  const channelType = typeof raw["channel_type"] === "string" ? (raw["channel_type"] as string) : undefined;