@open-everywhere/provider-slack 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/access/slack-attachment-fetch.ts +2 -1
- package/agent/app-manifest.ts +15 -1
- package/agent/channels/slack-agent-session.ts +249 -0
- package/agent/channels/slack-attachments.ts +20 -11
- package/agent/channels/slack-commands.ts +172 -12
- package/agent/channels/slack-context.ts +0 -5
- package/agent/channels/slack-events.ts +103 -29
- package/agent/channels/slack-follow-ups.ts +148 -0
- package/agent/channels/slack-front.ts +232 -26
- package/agent/channels/slack-home.ts +9 -3
- package/agent/channels/slack-identity.ts +9 -1
- package/agent/channels/slack-memory-canvas.ts +192 -51
- package/agent/channels/slack-origin-codec.ts +9 -0
- package/agent/channels/slack-post-arm.ts +68 -11
- package/agent/channels/slack-read.ts +27 -2
- package/agent/channels/slack-reply-blocks.ts +180 -0
- package/agent/channels/slack-reply-feedback.ts +75 -0
- package/agent/channels/slack-shortcut.ts +1 -1
- package/agent/channels/slack-voice-clips.ts +82 -0
- package/agent/channels/slack-wake-backscroll.ts +0 -5
- package/agent/channels/slack.ts +74 -13
- package/agent/docs-generated.ts +1282 -2
- package/agent/ops.ts +14 -21
- package/agent/register.ts +13 -0
- package/agent/setup/credentials.ts +17 -0
- package/agent/setup/page.ts +6 -3
- package/agent/surface-parity.ts +2 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +15 -1
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-agent-session.d.ts +81 -0
- package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
- package/dist/agent/channels/slack-agent-session.js +198 -0
- package/dist/agent/channels/slack-agent-session.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +11 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
- package/dist/agent/channels/slack-attachments.js +21 -14
- package/dist/agent/channels/slack-attachments.js.map +1 -1
- package/dist/agent/channels/slack-commands.d.ts +113 -10
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +146 -13
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-context.d.ts +1 -2
- package/dist/agent/channels/slack-context.d.ts.map +1 -1
- package/dist/agent/channels/slack-context.js +0 -4
- package/dist/agent/channels/slack-context.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +46 -97
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +86 -21
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
- package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
- package/dist/agent/channels/slack-follow-ups.js +129 -0
- package/dist/agent/channels/slack-follow-ups.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +28 -45
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +207 -22
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts.map +1 -1
- package/dist/agent/channels/slack-home.js +10 -3
- package/dist/agent/channels/slack-home.js.map +1 -1
- package/dist/agent/channels/slack-identity.d.ts +7 -21
- package/dist/agent/channels/slack-identity.d.ts.map +1 -1
- package/dist/agent/channels/slack-identity.js +3 -1
- package/dist/agent/channels/slack-identity.js.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.js +162 -52
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/slack-origin-codec.js +10 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -1
- package/dist/agent/channels/slack-post-arm.d.ts +21 -1
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
- package/dist/agent/channels/slack-post-arm.js +57 -10
- package/dist/agent/channels/slack-post-arm.js.map +1 -1
- package/dist/agent/channels/slack-read.d.ts +11 -1
- package/dist/agent/channels/slack-read.d.ts.map +1 -1
- package/dist/agent/channels/slack-read.js +27 -3
- package/dist/agent/channels/slack-read.js.map +1 -1
- package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
- package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-blocks.js +172 -0
- package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.js +63 -0
- package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +10 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
- package/dist/agent/channels/slack-shortcut.js +1 -1
- package/dist/agent/channels/slack-shortcut.js.map +1 -1
- package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
- package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
- package/dist/agent/channels/slack-voice-clips.js +80 -0
- package/dist/agent/channels/slack-voice-clips.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
- package/dist/agent/channels/slack-wake-backscroll.js +0 -4
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
- package/dist/agent/channels/slack.d.ts +22 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +63 -13
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +1282 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +15 -21
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +12 -1
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/credentials.d.ts +12 -0
- package/dist/agent/setup/credentials.d.ts.map +1 -1
- package/dist/agent/setup/credentials.js +17 -0
- package/dist/agent/setup/credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +6 -3
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +2 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +31 -3
|
@@ -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, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
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 ------------------------------------------------------------
|
package/agent/channels/slack.ts
CHANGED
|
@@ -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,6 +91,9 @@ 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
98
|
import { TURN_FAILED_TEXT, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
|
|
94
99
|
import { loadConversationContext } from "./slack-context.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
|
|
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.
|
|
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:
|
|
548
|
+
function auditWakeDropped(message: SlackMessage, reason: string, detail: AuditDetail = {}): Promise<void> {
|
|
539
549
|
return holdRouteWork(
|
|
540
550
|
(async () => {
|
|
541
551
|
try {
|
|
542
|
-
const
|
|
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
|
|
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
|
|
1105
|
-
?
|
|
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
|
});
|