@open-everywhere/provider-slack 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) 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-bot-ask.ts +101 -0
  6. package/agent/channels/slack-commands.ts +178 -17
  7. package/agent/channels/slack-context.ts +0 -5
  8. package/agent/channels/slack-events.ts +110 -33
  9. package/agent/channels/slack-follow-ups.ts +148 -0
  10. package/agent/channels/slack-friendly-names.ts +55 -0
  11. package/agent/channels/slack-front.ts +262 -27
  12. package/agent/channels/slack-home.ts +9 -3
  13. package/agent/channels/slack-identity.ts +9 -1
  14. package/agent/channels/slack-memory-canvas.ts +197 -54
  15. package/agent/channels/slack-origin-codec.ts +9 -0
  16. package/agent/channels/slack-post-arm.ts +68 -11
  17. package/agent/channels/slack-read.ts +27 -2
  18. package/agent/channels/slack-reply-blocks.ts +180 -0
  19. package/agent/channels/slack-reply-feedback.ts +75 -0
  20. package/agent/channels/slack-shortcut.ts +1 -1
  21. package/agent/channels/slack-voice-clips.ts +82 -0
  22. package/agent/channels/slack-wake-backscroll.ts +0 -5
  23. package/agent/channels/slack-web-api.ts +4 -2
  24. package/agent/channels/slack.ts +81 -19
  25. package/agent/docs-generated.ts +1282 -2
  26. package/agent/ops.ts +14 -21
  27. package/agent/register.ts +25 -0
  28. package/agent/setup/credentials.ts +17 -0
  29. package/agent/setup/page.ts +6 -3
  30. package/agent/surface-parity.ts +2 -0
  31. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
  32. package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
  33. package/dist/agent/app-manifest.d.ts.map +1 -1
  34. package/dist/agent/app-manifest.js +15 -1
  35. package/dist/agent/app-manifest.js.map +1 -1
  36. package/dist/agent/channels/slack-agent-session.d.ts +81 -0
  37. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  38. package/dist/agent/channels/slack-agent-session.js +198 -0
  39. package/dist/agent/channels/slack-agent-session.js.map +1 -0
  40. package/dist/agent/channels/slack-attachments.d.ts +11 -0
  41. package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
  42. package/dist/agent/channels/slack-attachments.js +21 -14
  43. package/dist/agent/channels/slack-attachments.js.map +1 -1
  44. package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
  45. package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
  46. package/dist/agent/channels/slack-bot-ask.js +91 -0
  47. package/dist/agent/channels/slack-bot-ask.js.map +1 -0
  48. package/dist/agent/channels/slack-commands.d.ts +113 -10
  49. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  50. package/dist/agent/channels/slack-commands.js +151 -18
  51. package/dist/agent/channels/slack-commands.js.map +1 -1
  52. package/dist/agent/channels/slack-context.d.ts +1 -2
  53. package/dist/agent/channels/slack-context.d.ts.map +1 -1
  54. package/dist/agent/channels/slack-context.js +0 -4
  55. package/dist/agent/channels/slack-context.js.map +1 -1
  56. package/dist/agent/channels/slack-events.d.ts +47 -97
  57. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  58. package/dist/agent/channels/slack-events.js +90 -22
  59. package/dist/agent/channels/slack-events.js.map +1 -1
  60. package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
  61. package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
  62. package/dist/agent/channels/slack-follow-ups.js +129 -0
  63. package/dist/agent/channels/slack-follow-ups.js.map +1 -0
  64. package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
  65. package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
  66. package/dist/agent/channels/slack-friendly-names.js +45 -0
  67. package/dist/agent/channels/slack-friendly-names.js.map +1 -0
  68. package/dist/agent/channels/slack-front.d.ts +38 -46
  69. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  70. package/dist/agent/channels/slack-front.js +230 -23
  71. package/dist/agent/channels/slack-front.js.map +1 -1
  72. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  73. package/dist/agent/channels/slack-home.js +10 -3
  74. package/dist/agent/channels/slack-home.js.map +1 -1
  75. package/dist/agent/channels/slack-identity.d.ts +7 -21
  76. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  77. package/dist/agent/channels/slack-identity.js +3 -1
  78. package/dist/agent/channels/slack-identity.js.map +1 -1
  79. package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
  80. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  81. package/dist/agent/channels/slack-memory-canvas.js +166 -55
  82. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  83. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  84. package/dist/agent/channels/slack-origin-codec.js +10 -0
  85. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  86. package/dist/agent/channels/slack-post-arm.d.ts +21 -1
  87. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  88. package/dist/agent/channels/slack-post-arm.js +57 -10
  89. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  90. package/dist/agent/channels/slack-read.d.ts +11 -1
  91. package/dist/agent/channels/slack-read.d.ts.map +1 -1
  92. package/dist/agent/channels/slack-read.js +27 -3
  93. package/dist/agent/channels/slack-read.js.map +1 -1
  94. package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
  95. package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
  96. package/dist/agent/channels/slack-reply-blocks.js +172 -0
  97. package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
  98. package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
  99. package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
  100. package/dist/agent/channels/slack-reply-feedback.js +63 -0
  101. package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
  102. package/dist/agent/channels/slack-shortcut.d.ts +10 -0
  103. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
  104. package/dist/agent/channels/slack-shortcut.js +1 -1
  105. package/dist/agent/channels/slack-shortcut.js.map +1 -1
  106. package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
  107. package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
  108. package/dist/agent/channels/slack-voice-clips.js +80 -0
  109. package/dist/agent/channels/slack-voice-clips.js.map +1 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
  111. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  112. package/dist/agent/channels/slack-wake-backscroll.js +0 -4
  113. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
  115. package/dist/agent/channels/slack-web-api.js +4 -2
  116. package/dist/agent/channels/slack-web-api.js.map +1 -1
  117. package/dist/agent/channels/slack.d.ts +22 -1
  118. package/dist/agent/channels/slack.d.ts.map +1 -1
  119. package/dist/agent/channels/slack.js +70 -16
  120. package/dist/agent/channels/slack.js.map +1 -1
  121. package/dist/agent/docs-generated.d.ts.map +1 -1
  122. package/dist/agent/docs-generated.js +1282 -2
  123. package/dist/agent/docs-generated.js.map +1 -1
  124. package/dist/agent/ops.d.ts +1 -1
  125. package/dist/agent/ops.d.ts.map +1 -1
  126. package/dist/agent/ops.js +15 -21
  127. package/dist/agent/ops.js.map +1 -1
  128. package/dist/agent/register.d.ts.map +1 -1
  129. package/dist/agent/register.js +22 -1
  130. package/dist/agent/register.js.map +1 -1
  131. package/dist/agent/setup/credentials.d.ts +12 -0
  132. package/dist/agent/setup/credentials.d.ts.map +1 -1
  133. package/dist/agent/setup/credentials.js +17 -0
  134. package/dist/agent/setup/credentials.js.map +1 -1
  135. package/dist/agent/setup/page.d.ts.map +1 -1
  136. package/dist/agent/setup/page.js +6 -3
  137. package/dist/agent/setup/page.js.map +1 -1
  138. package/dist/agent/surface-parity.d.ts.map +1 -1
  139. package/dist/agent/surface-parity.js +2 -0
  140. package/dist/agent/surface-parity.js.map +1 -1
  141. package/package.json +40 -3
@@ -0,0 +1,180 @@
1
+ // slack-reply-blocks — the Block Kit shape of an agent's ANSWER on Slack (OPE-2520): the answer's text,
2
+ // its numbered source links, Slack's native thumbs up / down, and the suggested follow-ups (OPE-2516).
3
+ //
4
+ // A reply used to post `text` alone. An answer now also carries two blocks Slack draws natively:
5
+ // • a `context` block with the numbered source links — `[1] Launch checklist · [2] OPE-42 Fix login`,
6
+ // the link on each title. Only present when the reply choke point decided links may show (a 1:1 DM,
7
+ // the reach rule held — `reply-provenance.ts`).
8
+ // • a `context_actions` block holding one `feedback_buttons` element — Slack's own thumbs up / down
9
+ // for an AI app's message. A click arrives at the interactivity URL as a `block_actions` payload
10
+ // with the action id {@link SLACK_FEEDBACK_ACTION_ID}, and `slack-front.ts` records it.
11
+ //
12
+ // • an `actions` block with up to three suggested FOLLOW-UPS (OPE-2516) — one button each, labelled
13
+ // with the whole text a press sends. A press arrives as a `block_actions` payload whose action id
14
+ // starts {@link SLACK_FOLLOW_UP_ACTION_PREFIX}, and `slack-follow-ups.ts` turns it into the
15
+ // person's next message. Only present when the choke point decided they may show (a 1:1 DM).
16
+ //
17
+ // 🔴 ONCE `blocks` IS PRESENT, SLACK SHOWS THE BLOCKS AND NOT `text`. `text` becomes the notification
18
+ // fallback only. So the answer itself must ride the blocks too, as `section` blocks — and a section
19
+ // holds at most 3000 characters, so a long answer is cut into several. A cut never splits a code fence
20
+ // open: a chunk that ends inside a ``` fence closes it, and the next chunk opens it again.
21
+ //
22
+ // 🔴 A REFUSED BLOCK MUST NEVER COST THE ANSWER. The arm retries a post Slack refused for its blocks
23
+ // (`invalid_blocks`) once without them, so a workspace that rejects a block shows the plain answer.
24
+
25
+ import type { PostIntent, SourceLink } from "@open-everywhere/core/agent/adapter-api.js";
26
+
27
+ /** The action id of the native thumbs control. Exact, never a prefix: no other door uses it. */
28
+ export const SLACK_FEEDBACK_ACTION_ID = "oe_feedback";
29
+
30
+ /** The two button values, which ARE the rating a click records. */
31
+ export const SLACK_FEEDBACK_VALUE_UP = "up";
32
+ export const SLACK_FEEDBACK_VALUE_DOWN = "down";
33
+
34
+ /** The action id prefix of a follow-up button: `oe_followup:<n>`. No other door uses it. */
35
+ export const SLACK_FOLLOW_UP_ACTION_PREFIX = "oe_followup:";
36
+
37
+ /** The block id of the follow-up row, so a press can find and remove it (`slack-follow-ups.ts`). */
38
+ export const SLACK_FOLLOW_UP_BLOCK_ID = "oe_followups";
39
+
40
+ /** A control (`Cc`) or format (`Cf`) character: it draws nothing on a button. The follow-up row and
41
+ * the press parse (`slack-follow-ups.ts`) both refuse a text that holds one. */
42
+ export const SLACK_INVISIBLE_CHARACTER = /[\p{Cc}\p{Cf}]/u;
43
+
44
+ /** Slack's cap on a button label, which is also the core cap on a follow-up's length. */
45
+ const BUTTON_TEXT_MAX = 75;
46
+
47
+ /** Slack's cap on one section block's text. */
48
+ const SECTION_TEXT_MAX = 3000;
49
+
50
+ /** Slack's cap on blocks in one message. */
51
+ const MAX_BLOCKS = 50;
52
+
53
+ /** Escape the three characters Slack's mrkdwn reads as control characters, for a host-written label. */
54
+ function escapeMrkdwn(text: string): string {
55
+ return text.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
56
+ }
57
+
58
+ /**
59
+ * Cut mrkdwn into section-sized chunks at line ends. A line longer than a chunk is cut by code point.
60
+ * A chunk that ends inside a ``` fence closes it, and the next chunk reopens it, so each chunk renders
61
+ * on its own. Exported for the unit test.
62
+ */
63
+ export function chunkSlackSectionText(text: string, max: number = SECTION_TEXT_MAX): string[] {
64
+ const fence = "```";
65
+ const reopen = `${fence}\n`;
66
+ // Room for the closing fence a chunk may need, and the opening fence the next chunk then carries.
67
+ const budget = max - 2 * reopen.length;
68
+ const chunks: string[] = [];
69
+ let cur = "";
70
+ // True while the text placed so far stands inside an open ``` fence.
71
+ let open = false;
72
+ const push = (): void => {
73
+ chunks.push(open ? `${cur}\n${fence}` : cur);
74
+ cur = open ? reopen : "";
75
+ };
76
+ for (const line of text.split("\n")) {
77
+ let rest = line;
78
+ for (;;) {
79
+ const sep = cur.length === 0 || cur === reopen ? "" : "\n";
80
+ if (cur.length + sep.length + rest.length <= budget) {
81
+ cur += sep + rest;
82
+ break;
83
+ }
84
+ if (sep.length > 0) {
85
+ push();
86
+ continue;
87
+ }
88
+ // The line alone does not fit an empty chunk: cut it by code point, never inside a character.
89
+ const cps = Array.from(rest);
90
+ const room = budget - cur.length;
91
+ cur += cps.slice(0, room).join("");
92
+ rest = cps.slice(room).join("");
93
+ push();
94
+ }
95
+ // An odd count of fences on the line opens or closes a fence.
96
+ if ((line.split(fence).length - 1) % 2 === 1) open = !open;
97
+ }
98
+ if (cur.length > 0 && cur !== reopen) chunks.push(cur);
99
+ return chunks;
100
+ }
101
+
102
+ /** The numbered source links as ONE line of mrkdwn: `*Sources:* [1] <url|title> · [2] <url|title>`. */
103
+ export function slackSourcesLine(sources: readonly SourceLink[]): string {
104
+ const items = sources.map((s, i) => `[${i + 1}] <${s.url}|${escapeMrkdwn(s.name)}>`);
105
+ return `*Sources:* ${items.join(" · ")}`;
106
+ }
107
+
108
+ /** Slack's native thumbs up / down, as one `context_actions` block. */
109
+ function slackFeedbackBlock(): Record<string, unknown> {
110
+ return {
111
+ type: "context_actions",
112
+ elements: [
113
+ {
114
+ type: "feedback_buttons",
115
+ action_id: SLACK_FEEDBACK_ACTION_ID,
116
+ positive_button: {
117
+ text: { type: "plain_text", text: "Good response" },
118
+ accessibility_label: "Mark this response as good",
119
+ value: SLACK_FEEDBACK_VALUE_UP,
120
+ },
121
+ negative_button: {
122
+ text: { type: "plain_text", text: "Bad response" },
123
+ accessibility_label: "Mark this response as bad",
124
+ value: SLACK_FEEDBACK_VALUE_DOWN,
125
+ },
126
+ },
127
+ ],
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The suggested follow-ups as one `actions` block: one button per item, its label the whole text and
133
+ * its value the same text. An item over Slack's label cap, or holding an invisible character, is
134
+ * dropped here too: the friendly pass runs after core's check, and a resolved name can lengthen a label
135
+ * or bring a joiner back in an emoji. Over the cap, Slack would refuse the block and cost the thumbs as
136
+ * well. With an invisible character, the press parse would refuse the press, so the button would do
137
+ * nothing.
138
+ */
139
+ function slackFollowUpsBlock(followUps: readonly string[]): Record<string, unknown> | undefined {
140
+ const fit = followUps.filter((f) => f.length > 0 && Array.from(f).length <= BUTTON_TEXT_MAX && !SLACK_INVISIBLE_CHARACTER.test(f));
141
+ if (fit.length === 0) return undefined;
142
+ return {
143
+ type: "actions",
144
+ block_id: SLACK_FOLLOW_UP_BLOCK_ID,
145
+ elements: fit.map((text, i) => ({
146
+ type: "button",
147
+ action_id: `${SLACK_FOLLOW_UP_ACTION_PREFIX}${i}`,
148
+ text: { type: "plain_text", text, emoji: true },
149
+ value: text,
150
+ })),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * The blocks for one answer, or `undefined` when the intent asks for no links, no thumbs and no follow-ups — the
156
+ * post then sends `text` alone, as before OPE-2520. `mrkdwn` is the already-converted answer text.
157
+ */
158
+ export function slackReplyBlocks(mrkdwn: string, intent: PostIntent | undefined): Record<string, unknown>[] | undefined {
159
+ const sources = intent?.sources ?? [];
160
+ const feedback = intent?.feedback === true;
161
+ const followUps = slackFollowUpsBlock(intent?.followUps ?? []);
162
+ if (sources.length === 0 && !feedback && followUps === undefined) return undefined;
163
+ const blocks: Record<string, unknown>[] = [];
164
+ for (const chunk of chunkSlackSectionText(mrkdwn)) {
165
+ if (chunk.trim().length === 0) continue;
166
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk } });
167
+ }
168
+ if (sources.length > 0) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: slackSourcesLine(sources) }] });
169
+ if (feedback) blocks.push(slackFeedbackBlock());
170
+ // The next steps sit last, under the answer and its thumbs.
171
+ if (followUps !== undefined) blocks.push(followUps);
172
+ // An answer too long for Slack's block cap posts as text alone: the answer matters more than the extras.
173
+ return blocks.length > MAX_BLOCKS ? undefined : blocks;
174
+ }
175
+
176
+ /** True when a Slack error names the blocks as the reason it refused the post. */
177
+ export function isSlackBlocksRefusal(err: unknown): boolean {
178
+ const message = err instanceof Error ? err.message : String(err);
179
+ return /invalid_blocks|invalid_attachments|invalid_blocks_format|msg_blocks_too_long/u.test(message);
180
+ }
@@ -0,0 +1,75 @@
1
+ // slack-reply-feedback — a click on Slack's native thumbs up / down under an agent answer (OPE-2520).
2
+ //
3
+ // The answer carries one `feedback_buttons` element (slack-reply-blocks.ts). Slack sends a click to the
4
+ // app's interactivity URL as a signed `block_actions` payload whose action id is exactly
5
+ // `oe_feedback` and whose value is `up` or `down`. The front route classifies it (`slack-front.ts`,
6
+ // FrontDecision `feedback-click`), runs the tenant fence, acknowledges with an empty 200, and hands it
7
+ // here. This module turns it into core's surface-neutral click and writes the `reply.feedback` row.
8
+ //
9
+ // 🔴 ONLY OUR OWN MESSAGES CARRY THE BUTTON. Slack sends a `block_actions` payload only for a block the
10
+ // app itself posted, so a click always rates one of this app's messages: no bot-author check is needed,
11
+ // unlike the reaction road, where a 👍 can land on anyone's message.
12
+
13
+ import { recordReplyFeedback, resolvePartition, type ReplyFeedbackClick, type ReplyFeedbackRating } from "@open-everywhere/core/agent/adapter-api.js";
14
+ import { bindableForSlackIds } from "./slack-events.js";
15
+ import { SLACK_FEEDBACK_ACTION_ID, SLACK_FEEDBACK_VALUE_DOWN, SLACK_FEEDBACK_VALUE_UP } from "./slack-reply-blocks.js";
16
+
17
+ /** One parsed click on the native thumbs control. IDS ONLY. */
18
+ export interface ParsedSlackFeedbackClick {
19
+ readonly rating: ReplyFeedbackRating;
20
+ readonly userId: string;
21
+ readonly channelId: string;
22
+ readonly messageTs: string;
23
+ readonly teamId?: string;
24
+ }
25
+
26
+ const obj = (v: unknown): Record<string, unknown> | undefined => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined);
27
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
28
+
29
+ /**
30
+ * Parse a decoded interactivity payload into a thumbs click, or `undefined` when it is not one: not a
31
+ * `block_actions`, no `oe_feedback` action, a value other than `up`/`down`, or a missing user, channel
32
+ * or message. Pure and total: it never throws.
33
+ */
34
+ export function parseSlackFeedbackClick(payload: unknown): ParsedSlackFeedbackClick | undefined {
35
+ const p = obj(payload);
36
+ if (p?.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
37
+ const action = (p.actions as unknown[]).map(obj).find((a) => a?.action_id === SLACK_FEEDBACK_ACTION_ID);
38
+ if (action === undefined) return undefined;
39
+ const value = str(action.value);
40
+ const rating: ReplyFeedbackRating | undefined = value === SLACK_FEEDBACK_VALUE_UP ? "up" : value === SLACK_FEEDBACK_VALUE_DOWN ? "down" : undefined;
41
+ if (rating === undefined) return undefined;
42
+ const userId = str(obj(p.user)?.id);
43
+ const channelId = str(obj(p.channel)?.id) ?? str(obj(p.container)?.channel_id);
44
+ const messageTs = str(obj(p.message)?.ts) ?? str(obj(p.container)?.message_ts);
45
+ if (userId === undefined || channelId === undefined || messageTs === undefined) return undefined;
46
+ const teamId = str(obj(p.team)?.id) ?? str(obj(p.user)?.team_id);
47
+ return { rating, userId, channelId, messageTs, ...(teamId === undefined ? {} : { teamId }) };
48
+ }
49
+
50
+ /** What {@link routeSlackFeedbackClick} writes through. The live default is core's audit writer. */
51
+ export interface SlackFeedbackRouteDeps {
52
+ readonly record?: (click: ReplyFeedbackClick) => Promise<boolean>;
53
+ }
54
+
55
+ /** Record one thumbs click as a `reply.feedback` row, under the clicked conversation's partition.
56
+ * Answers whether the row was written. Never throws. */
57
+ export async function routeSlackFeedbackClick(click: ParsedSlackFeedbackClick, deps: SlackFeedbackRouteDeps = {}): Promise<boolean> {
58
+ try {
59
+ const p = resolvePartition(bindableForSlackIds({ teamId: click.teamId, channelId: click.channelId, userId: click.userId }));
60
+ const record = deps.record ?? ((c: ReplyFeedbackClick) => recordReplyFeedback(c));
61
+ return await record({
62
+ surface: "slack",
63
+ rating: click.rating,
64
+ raterId: click.userId,
65
+ channelId: click.channelId,
66
+ messageId: click.messageTs,
67
+ org: p.org,
68
+ platform: p.platform,
69
+ workspace: p.workspace,
70
+ surfaceKey: p.surfaceKey,
71
+ });
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
@@ -255,7 +255,7 @@ export function globalShortcutText(sc: ParsedSlackShortcut): string {
255
255
  * instant's milliseconds; the last three digits are zero, because a millisecond clock cannot
256
256
  * honestly claim more precision than it has.
257
257
  */
258
- function slackTsFromMs(nowMs: number): string {
258
+ export function slackTsFromMs(nowMs: number): string {
259
259
  const seconds = Math.floor(nowMs / 1000);
260
260
  const millis = nowMs % 1000;
261
261
  return `${seconds}.${String(millis).padStart(3, "0")}000`;
@@ -0,0 +1,82 @@
1
+ // SLACK VOICE CLIPS (OPE-2524) — hear the voice clip on a Slack message the agent was ALREADY going to answer.
2
+ //
3
+ // The front door (slack-front.ts) calls {@link withSlackVoiceClips} on four roads, each one after the road decided to
4
+ // wake the agent and inside the road's own serialized queue, so the 200 to Slack never waits on a transcription:
5
+ // • a DM or group DM (the DM window seat);
6
+ // • a group-DM @mention diverted to that seat;
7
+ // • an addressed seat wake: a diverted @mention, or a follow-up in a thread the seat is holding;
8
+ // • a follow-up in an engaged thread on an unseated deployment.
9
+ // "Holding" means an engaged thread that was not stood down: the threads where a typed follow-up reaches the agent
10
+ // (slack-front.ts `threadHearsClips`).
11
+ //
12
+ // 🔴 A CLIP IN A ROOM THAT DID NOT ADDRESS THE AGENT IS NEVER DOWNLOADED. A top-level channel message, an unengaged
13
+ // thread and an ambient (unaddressed) seat wake all skip this. The name-drop judge reads typed words only, so a person
14
+ // who wants the agent to hear a clip in a channel @mentions it or replies in its thread. That keeps a room's audio out
15
+ // of a speech-to-text provider unless somebody pointed it at the agent.
16
+ //
17
+ // THE FETCH is the same bot-token, host-pinned, size-guarded read `read_file_upload` uses
18
+ // (slack-attachments.ts `pullSlackAttachmentBytes`), over the EVENT's own `url_private`. The bytes go to the
19
+ // transcriber and are dropped.
20
+ //
21
+ // 🔴 THE HEARD MESSAGE IS A PLAIN MESSAGE. Slack sends an upload as subtype `file_share`, and every thread gate drops a
22
+ // subtype (`shouldDeliverReply`, `shouldSupervisorWake`). Once a clip is heard, the message is what the person said,
23
+ // so the subtype is cleared. Only `file_share` is cleared. An edit (`message_changed`) is not heard again at all.
24
+
25
+ import { hearVoiceClips, isVoiceClip, voiceClipBody, type VoiceClipOutcome } from "@open-everywhere/core/agent/adapter-api.js";
26
+ import { bindableFor, type ParsedMessageEvent } from "./slack-events.js";
27
+ import { pullSlackAttachmentBytes } from "./slack-attachments.js";
28
+ import { slackTenantAdmission } from "./slack-tenant-fence.js";
29
+
30
+ /** Slack records a voice clip as `audio/webm` or `audio/mp4`. A clip that names no type is sent as mp4. */
31
+ const FALLBACK_MEDIA_TYPE = "audio/mp4";
32
+
33
+ export interface SlackVoiceClipDeps {
34
+ readonly hear?: typeof hearVoiceClips;
35
+ readonly pull?: typeof pullSlackAttachmentBytes;
36
+ }
37
+
38
+ /** True when the message carries a voice clip, so a road can skip the whole step on the common message. */
39
+ export function hasSlackVoiceClip(ev: ParsedMessageEvent): boolean {
40
+ return (ev.attachments ?? []).some(isVoiceClip);
41
+ }
42
+
43
+ /**
44
+ * The message with its voice clips heard: the transcript appended to `text` and `authoredText`, and a `file_share`
45
+ * subtype cleared. A message with no clip comes back unchanged (the same object). Total: any failure gives the message
46
+ * back unchanged, so a broken clip never costs the person their typed words.
47
+ */
48
+ export async function withSlackVoiceClips(ev: ParsedMessageEvent, deps: SlackVoiceClipDeps = {}): Promise<ParsedMessageEvent> {
49
+ if (!hasSlackVoiceClip(ev)) return ev;
50
+ // An EDIT is not heard again: the clip was heard on first delivery, and a caption edit must not pay for the same audio
51
+ // twice. The edit's own text goes on as typed.
52
+ if (ev.subtype === "message_changed") return ev;
53
+ // 🔴 THE TENANT FENCE, ASKED AGAIN HERE. Every road refuses an undeclared workspace on its own, but the group-DM
54
+ // mention road reaches its fence (`scheduleDmWake`) only AFTER this step. So a foreign workspace's clip must not be
55
+ // downloaded or billed here. The question is pure: the refusal's audit row stays with the road's own fence.
56
+ if (slackTenantAdmission(ev.teamId, ev.enterpriseId) === "unadmitted") return ev;
57
+ const pull = deps.pull ?? pullSlackAttachmentBytes;
58
+ let outcomes: VoiceClipOutcome[];
59
+ try {
60
+ const bindable = bindableFor(ev);
61
+ outcomes = await (deps.hear ?? hearVoiceClips)({
62
+ attachments: ev.attachments,
63
+ bindable,
64
+ load: async (clip, maxBytes) => {
65
+ if (clip.url === undefined) throw new Error("the clip carries no download URL");
66
+ const audio = await pull(bindable, { urlPrivate: clip.url, ...(clip.id ? { id: clip.id } : {}) }, maxBytes);
67
+ return { audio, mediaType: clip.mimeType ?? FALLBACK_MEDIA_TYPE };
68
+ },
69
+ });
70
+ } catch (err) {
71
+ console.warn(`[open-everywhere:slack-voice-clips] ${ev.channelId}/${ev.ts}: voice clip step failed — the message goes on as typed: ${String(err)}`);
72
+ return ev;
73
+ }
74
+ if (outcomes.length === 0) return ev;
75
+ const { subtype, ...rest } = ev;
76
+ return {
77
+ ...rest,
78
+ ...(subtype === undefined || subtype === "file_share" ? {} : { subtype }),
79
+ text: voiceClipBody(ev.text, outcomes),
80
+ authoredText: voiceClipBody(ev.authoredText, outcomes),
81
+ };
82
+ }
@@ -139,11 +139,6 @@ function liveWakeBotIdentity(): Promise<WakeBotIdentity> {
139
139
  return wakeBotIdentityPromise;
140
140
  }
141
141
 
142
- /** Reset the cached wake bot identity (tests only). */
143
- export function _resetWakeBotIdentity(): void {
144
- wakeBotIdentityPromise = undefined;
145
- }
146
-
147
142
  const liveDeps: WakeBackscrollDeps = { request: liveRequest, identity: liveWakeBotIdentity };
148
143
 
149
144
  // --- the fetcher ------------------------------------------------------------
@@ -40,7 +40,7 @@
40
40
  // `channels/rate-limit-retry.js`. When the attempts run out the call throws `RateLimitExhaustedError`
41
41
  // — a NAMED failure. Non-429 responses are untouched.
42
42
 
43
- import { withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
43
+ import { friendlySlackBody, withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
44
44
  import { slackBotToken } from "../setup/credentials.js";
45
45
 
46
46
  /** Slack's Web API base. A trailing slash is required — `new URL(operation, base)` resolves the
@@ -172,7 +172,9 @@ export async function callSlackWebApi(
172
172
  // still throws when no token resolves, so no ordinary call can silently lose its authorization
173
173
  // header by forgetting to pass one.
174
174
  const token = input.unauthenticated === true ? undefined : await resolveBotToken(input.botToken);
175
- const encoded = encodeSlackForm(input.body);
175
+ // THE FRIENDLY-NAMES GUARD (OPE-2482). A message method's text, blocks and attachments render every raw
176
+ // id as a mention, a name or plain words before the body is encoded. Every other method is unchanged.
177
+ const encoded = encodeSlackForm(await friendlySlackBody(input.operation, input.body));
176
178
  const url = new URL(input.operation, deps.apiBase ?? SLACK_API_BASE).toString();
177
179
  const send = (): Promise<Response> =>
178
180
  (deps.fetch ?? (globalThis.fetch as SlackFetchLike))(url, {
@@ -18,6 +18,7 @@
18
18
  // left eve reading `process.env` on a path OE no longer does. Capturing channel_type/is_private is a RAW-fact job; the
19
19
  // security-critical interpretation (fail-closed to private) lives in the binding.
20
20
 
21
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
21
22
  import { SLACK_EVE_CREDENTIALS } from "../setup/eve-credentials.js";
22
23
  import {
23
24
  slackChannel,
@@ -38,6 +39,7 @@ import {
38
39
  } from "./slack-identity.js";
39
40
  import {
40
41
  resolvePartition,
42
+ auditSurfaceModeFrom,
41
43
  getOperationalStore,
42
44
  postOnce,
43
45
  outboundKey,
@@ -89,8 +91,11 @@ import {
89
91
  dmTrustTtlMs,
90
92
  swallowRoomOp,
91
93
  domainRoster,
94
+ domainKeyOf,
95
+ resolveWakeTrust,
96
+ stampNonMemberChatAuth,
92
97
  } from "@open-everywhere/core/agent/adapter-api.js";
93
- import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
98
+ import { TURN_FAILED_TEXT, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
94
99
  import { loadConversationContext } from "./slack-context.js";
95
100
  import { slackSpendGate } from "./slack-spend-gate.js";
96
101
  import { slackTenantAdmission } from "./slack-tenant-fence.js";
@@ -98,9 +103,10 @@ import { holdRouteWork } from "./slack-route-hold.js";
98
103
  import { frontSlackChannel, INTERNAL_SLACK_ROUTE } from "./slack-front.js";
99
104
  import { admitSlackInputResponse } from "./slack-input-response.js";
100
105
  import { taskCardConfidentiality } from "./slack-task-card.js";
101
- import { turnEffectsFor, botDisplayName, slackForm, isSupervisorReplyTurn, deliverSupervisorReply, dmWakeThreadOverride, replyThreadTsForState, type SupervisorReplyTarget } from "./slack-events.js";
106
+ import { turnEffectsFor, botDisplayName, slackForm, isSupervisorReplyTurn, deliverSupervisorReply, dmWakeThreadOverride, replyThreadTsForState, slackAuthorIsMember, slackWakeAuthorFacts, type SlackMemberProbe, type SupervisorReplyTarget } from "./slack-events.js";
102
107
  import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
103
108
  import { stepStatusFor, type StepAction } from "./slack-step-label.js";
109
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
104
110
 
105
111
  // WARM_SILENT_LIMIT (the warm-thread shed threshold) now lives in channels/engagement.ts so
106
112
  // Slack and Discord share ONE constant instead of each defining their own (which could drift).
@@ -244,18 +250,20 @@ async function recordQuietFailure(
244
250
  ): Promise<void> {
245
251
  console.warn(`[open-everywhere:slack] ${event} on an uninvited turn — suppressed on-surface (${message})`);
246
252
  try {
247
- const p = resolvePartition({
253
+ const bindable: Parameters<typeof resolvePartition>[0] = {
248
254
  channel: { kind: "slack" },
249
255
  session: { auth: { current: { attributes: { team_id: state.teamId ?? "", channel_id: state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
250
- });
256
+ };
257
+ const p = resolvePartition(bindable);
251
258
  await getOperationalStore().appendAudit({
252
259
  ts: new Date().toISOString(),
253
260
  org: p.org,
254
261
  platform: p.platform,
255
262
  workspace: p.workspace,
256
263
  surface: p.surface,
264
+ ...auditSurfaceModeFrom(bindable),
257
265
  action: "surface.quiet_fail",
258
- detail: { event, threadTs: state.threadTs ?? undefined, message: message.slice(0, 500) },
266
+ detail: { event, threadTs: state.threadTs ?? undefined, message: auditErrorDetail(message).error },
259
267
  });
260
268
  } catch (err) {
261
269
  console.warn(`[open-everywhere:slack] quiet-fail audit write failed: ${String(err)}`);
@@ -443,6 +451,7 @@ async function maybeOnboard(ctx: SlackContext, where: BindableContext): Promise<
443
451
  silo: target.silo,
444
452
  access: target.access,
445
453
  road: "pinned-instructions",
454
+ ...auditSurfaceModeFrom(where),
446
455
  });
447
456
  adopted = true;
448
457
  }
@@ -460,6 +469,7 @@ async function maybeOnboard(ctx: SlackContext, where: BindableContext): Promise<
460
469
  message: `onboarding ${p.surfaceKey}`,
461
470
  road: "onboarding-marker",
462
471
  kind: "operational",
472
+ ...auditSurfaceModeFrom(where),
463
473
  });
464
474
  try {
465
475
  // Introduce as the bot's actual workspace name (fail-soft → product-name fallback).
@@ -535,20 +545,22 @@ const DROP = null;
535
545
  * waits on. `holdRouteWork` additionally tracks it on the delivery's keepalive
536
546
  * (slack-route-hold.ts), which is what covers a writer no caller can await.
537
547
  */
538
- function auditWakeDropped(message: SlackMessage, reason: string, detail: Record<string, unknown> = {}): Promise<void> {
548
+ function auditWakeDropped(message: SlackMessage, reason: string, detail: AuditDetail = {}): Promise<void> {
539
549
  return holdRouteWork(
540
550
  (async () => {
541
551
  try {
542
- const p = resolvePartition({
552
+ const bindable: Parameters<typeof resolvePartition>[0] = {
543
553
  channel: { kind: "slack" },
544
554
  session: { auth: { current: { attributes: { team_id: message.teamId ?? "", channel_id: message.channelId }, principalId: "", principalType: "slack-user" } } },
545
- });
555
+ };
556
+ const p = resolvePartition(bindable);
546
557
  await getOperationalStore().appendAudit({
547
558
  ts: new Date().toISOString(),
548
559
  org: p.org,
549
560
  platform: p.platform,
550
561
  workspace: p.workspace,
551
562
  surface: p.surface,
563
+ ...auditSurfaceModeFrom(bindable),
552
564
  action: "surface.wake_dropped",
553
565
  detail: { reason, channel: message.channelId, ts: message.ts, ...detail },
554
566
  });
@@ -729,7 +741,52 @@ async function onInbound(
729
741
  // its silent streak, and exempts it from warm auto-disengage).
730
742
  await markThreadEngaged(partitionForMessage(message, facts), message.threadTs, new Date().toISOString(), { participating: true });
731
743
  }
732
- return { auth: authFromFacts(message, facts), context };
744
+ return { auth: await stampNativeAuthorLeg(message, facts, isDm, authFromFacts(message, facts)), context };
745
+ }
746
+
747
+ /**
748
+ * THE AUTHOR LEG ON THE NATIVE MENTION DOOR (OPE-2572). Every other Slack wake road probes who wrote
749
+ * the trigger and stamps `oe_chat_nonmember` on a positive refutation (slack-events.ts
750
+ * `slackWakeAuthorFacts`, the front's DM and group-DM legs). This door did neither, so a guest who
751
+ * @mentioned the agent in an internal channel woke an unmarked turn: no taint seed, no member-plane
752
+ * refusal, and a check-in that counted (hooks/routine-report-reply.ts). This runs the SAME probe and
753
+ * the SAME join (`stampNonMemberChatAuth`), so the native door and the windowed doors cannot disagree
754
+ * about one author.
755
+ *
756
+ * 🔴 IT STAMPS, IT DOES NOT DEMOTE THE ROOM. The auth keeps the room's verdict (`trust_checked` /
757
+ * `is_ext_shared`), the rule `slackWakeAuthorFacts` states for the channel routes: one guest's message
758
+ * must not demote the channel's memory tier. The stamp is what the consumers read.
759
+ *
760
+ * The room leg rides along exactly as on the channel routes: a channel the operator declared `public`
761
+ * marks the turn whoever wrote it. A DM or group DM contributes no room leg (`dm: true`).
762
+ *
763
+ * FAIL-OPEN, like every carriage leg: a probe that cannot answer refutes nobody. `probe` is injectable
764
+ * for the unit test only.
765
+ */
766
+ export async function stampNativeAuthorLeg(
767
+ message: SlackMessage,
768
+ facts: ChannelFacts,
769
+ isDm: boolean,
770
+ auth: OpenEverywhereSlackAuth | null,
771
+ probe: SlackMemberProbe = slackAuthorIsMember,
772
+ ): Promise<OpenEverywhereSlackAuth | null> {
773
+ if (auth === null) return null;
774
+ const mode = slackMode(facts.channelType, message.channelId);
775
+ // The surface fact `resolveWakeTrust` takes: on a 1:1 the partner probe, elsewhere the room's own
776
+ // confirmed-internal verdict (the expression `channelInternalAndMeta` uses).
777
+ const surfaceInternal = isDm ? facts.dmPartnerTrust === "internal" : facts.trustChecked === true && facts.isExternal === false;
778
+ const authorFacts = await slackWakeAuthorFacts(surfaceInternal, message.author?.userId, message.teamId, probe);
779
+ const channelKey = domainKeyOf(partitionForMessage(message, facts));
780
+ const rawBotId = typeof (message.raw as { bot_id?: unknown })?.bot_id === "string" ? (message.raw as { bot_id: string }).bot_id : undefined;
781
+ const enterpriseId = forwardedSlackEnterpriseId(message);
782
+ const trust = resolveWakeTrust({
783
+ authorIsBot: Boolean(message.author?.isBot) && !isAllowedSlackDriver(message.teamId, rawBotId),
784
+ authorId: message.author?.userId,
785
+ surfaceInternal: authorFacts.surfaceInternal,
786
+ channelKey,
787
+ ...(enterpriseId === undefined ? {} : { enterpriseId }),
788
+ });
789
+ return stampNonMemberChatAuth(auth, [{ trust, refuted: authorFacts.refuted }], mode === "channel" ? { surfaceKey: channelKey } : { dm: true });
733
790
  }
734
791
 
735
792
  /**
@@ -1016,16 +1073,18 @@ const slackMentionChannel = slackChannel({
1016
1073
  `(${channelId ?? "?"}/${threadTs ?? "?"}) — this usually means a judgment call went missing, not that one was made.`,
1017
1074
  );
1018
1075
  try {
1019
- const p = resolvePartition({
1076
+ const bindable: Parameters<typeof resolvePartition>[0] = {
1020
1077
  channel: { kind: "slack" },
1021
1078
  session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
1022
- });
1079
+ };
1080
+ const p = resolvePartition(bindable);
1023
1081
  await getOperationalStore().appendAudit({
1024
1082
  ts: new Date().toISOString(),
1025
1083
  org: p.org,
1026
1084
  platform: p.platform,
1027
1085
  workspace: p.workspace,
1028
1086
  surface: p.surface,
1087
+ ...auditSurfaceModeFrom(bindable),
1029
1088
  action: "turn.bare_stop",
1030
1089
  detail: { threadTs, ...(sessionId ? { sessionId } : {}) },
1031
1090
  });
@@ -1101,12 +1160,13 @@ const slackMentionChannel = slackChannel({
1101
1160
  // partition from the session state, independent of whether a reply target resolved.
1102
1161
  const channelId = channel.state.channelId ?? undefined;
1103
1162
  const teamId = channel.state.teamId;
1104
- const partition = channelId
1105
- ? resolvePartition({
1163
+ const supervisorBindable: Parameters<typeof resolvePartition>[0] | undefined = channelId
1164
+ ? {
1106
1165
  channel: { kind: "slack" },
1107
1166
  session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1108
- })
1167
+ }
1109
1168
  : undefined;
1169
+ const partition = supervisorBindable ? resolvePartition(supervisorBindable) : undefined;
1110
1170
  // --- MESSAGE-TOOL-ONLY hard-silence gate (OpenClaw steal, slack-events.ts) -----------
1111
1171
  // A supervisor's terminal completion text is NOT a reply: a visible reply must come from
1112
1172
  // the `reply` tool (which posted mid-turn via reply-post.ts + marked repliedThisTurn).
@@ -1130,6 +1190,7 @@ const slackMentionChannel = slackChannel({
1130
1190
  platform: partition.platform,
1131
1191
  workspace: partition.workspace,
1132
1192
  surface: partition.surface,
1193
+ ...(supervisorBindable ? auditSurfaceModeFrom(supervisorBindable) : {}),
1133
1194
  action: "turn.supervisor_prose_dropped",
1134
1195
  detail: { threadTs: target?.threadTs, chars: action.text.length, ...(sessionId ? { sessionId } : {}) },
1135
1196
  });
@@ -1302,10 +1363,10 @@ const slackMentionChannel = slackChannel({
1302
1363
  }
1303
1364
  await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1304
1365
  try {
1305
- await postWakeText(
1306
- channel,
1307
- `:octagonal_sign: I hit an error while handling your request (${data.message}). Please try again or rephrase.`,
1308
- );
1366
+ // OPE-2486 — the raw error text never reaches the room: the log line below carries it
1367
+ // for an operator, and the person reads one plain line.
1368
+ console.warn(`[open-everywhere:slack] turn failed: ${data.message}`);
1369
+ await postWakeText(channel, `:octagonal_sign: ${TURN_FAILED_TEXT}`);
1309
1370
  } catch {
1310
1371
  /* best-effort */
1311
1372
  }
@@ -1319,7 +1380,8 @@ const slackMentionChannel = slackChannel({
1319
1380
  }
1320
1381
  await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1321
1382
  try {
1322
- await postWakeText(channel, `:octagonal_sign: Task failed: ${data.message}`);
1383
+ console.warn(`[open-everywhere:slack] session failed: ${data.message}`);
1384
+ await postWakeText(channel, `:octagonal_sign: ${TURN_FAILED_TEXT}`);
1323
1385
  } catch {
1324
1386
  /* best-effort */
1325
1387
  }