@stratusagent/channel-slack 0.11.9 → 0.12.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/README.md +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +192 -38
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts.map +1 -1
- package/dist/mrkdwn.js +198 -9
- package/dist/mrkdwn.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no w
|
|
|
4
4
|
|
|
5
5
|
- **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (DMs: the DM channel id) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. A turn parked on a human when the daemon died is re-asked after the restart, and a turn that was still running is continued from where it got to (unless the agent runs on a harness — `codex` or a Claude subscription — where it is failed instead, since the harness may already have acted on it); either way, when it finishes its reply is posted into the thread as a fresh message — the placeholder it would have edited belonged to the old process.
|
|
6
6
|
- **Replies post when they are ready**: by default each message is posted once, finished: what the agent writes before starting a tool is posted as the tool starts, and the reply follows, with Slack's loading status (`assistant.threads.setStatus` — "is thinking…", "is running shell.run…") shown while the turn works, so the notification carries the answer. The `stream` reply mode (`slack.replies` in the [config](../../docs/guides/slack.md#how-replies-appear)) keeps the placeholder-and-edit rendering instead: post `…`, edit as deltas arrive (throttled for `chat.update` limits), show `⚙ tool…` status lines, finalize with the full reply. Either way a reply that outgrows one message is split across several.
|
|
7
|
-
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. **A pipe table**, which mrkdwn has no syntax for, becomes a code block with its columns padded to line up (alignment colons respected, `**`/`` ` `` markers dropped from cells, since nothing renders inside code); a grid wider than 60 characters — past what a phone shows unwrapped — or one with a link in it (a link inside a code block is not clickable) becomes one line per row, `*Header*: value · *Header*: value`. A one-column table has nothing to line up and becomes a bulleted list under its bold header. A table inside a fence is somebody's text and stays as written, and a grid longer than one message is split like any code block, closed at the cut and reopened in the next, with its header and rule repeated so every part names its columns.
|
|
7
|
+
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. A bare address is sent as `<https://…>`, Slack's own link markup, so emphasis around it (`**https://…**`) cannot be read as part of it, and a `*`, `_`, or `~` inside one is part of the address rather than a marker. Trailing punctuation (`.`, `,`, `**`) and a `)` the address never opened belong to the sentence, as in GFM autolinks, so `…/wiki/Function_(mathematics)` stays whole. A bare address is sent as `<https://…>`, Slack's own link markup, so emphasis around it (`**https://…**`) cannot be read as part of it, and the `*`, `_`, or `~` inside one is part of the address rather than a marker; trailing punctuation (`.`, `,`, `**`) is the sentence's, as in GFM autolinks. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. **A pipe table**, which mrkdwn has no syntax for, becomes a code block with its columns padded to line up (alignment colons respected, `**`/`` ` `` markers dropped from cells, since nothing renders inside code); a grid wider than 60 characters — past what a phone shows unwrapped — or one with a link in it (a link inside a code block is not clickable) becomes one line per row, `*Header*: value · *Header*: value`. A one-column table has nothing to line up and becomes a bulleted list under its bold header. A table inside a fence is somebody's text and stays as written, and a grid longer than one message is split like any code block, closed at the cut and reopened in the next, with its header and rule repeated so every part names its columns.
|
|
8
8
|
- **Mention to start, then it stays in the thread.** A mention opens a conversation; every reply in that thread reaches the agent without tagging it again, and free-form DMs are unchanged. A mention partway into an existing thread opens the conversation with what was said before it: the thread's parent and its newest 39 earlier messages. A mention at the top of a channel opens with the channel's 20 most recent messages instead, which is where a channel per client or per project keeps its context. Either way each message keeps its own speaker and its own trust, so a principal's mention does not carry a stranger's earlier words in under the principal's label (other bots count as `unknown`, and under `admit: "principals"` unlisted senders are left out). This needs the history scopes below; without them the agent sees only the message that named it, and the adapter logs a warning. Who an untagged reply is for is decided by [Who a message is for](#who-a-message-is-for). Inbound mentions of other users are humanized to `@Display Name` for the model; redeliveries are deduped so a slow turn never runs twice — in memory while the process remembers them, and after a restart by the gateway: each message is dispatched with its own key (`idempotencyKey`), a repeat runs no second turn, and the adapter posts that message's own reply and files (a file changed since that turn is skipped, since the path may now hold another file), or its failure if the turn failed — once more, if the process died after posting it, which beats a reply never posted.
|
|
9
9
|
- **Approval buttons** for the gateway's `remote` permission mode: a gated tool call parks the turn and asks in its thread with **Allow once** / **Always allow** / **Deny**. See [Approving tool calls](#approving-tool-calls) — clicks are authorized by *who clicked*, never by who can see the message, and **Always allow** is offered only where the daemon would actually remember it.
|
|
10
10
|
- **Tokens are gateway infrastructure secrets**, stored in the `channels` namespace of `~/.stratus/credentials.json` — never in an agent's credential allowlist:
|
package/dist/index.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export interface SlackAgentConfig {
|
|
|
45
45
|
* a `…` placeholder at once and edits it as the reply is written.
|
|
46
46
|
*/
|
|
47
47
|
replies?: 'final' | 'stream';
|
|
48
|
+
/**
|
|
49
|
+
* `false` posts this agent's messages without link and media previews
|
|
50
|
+
* (Slack's `unfurl_links`/`unfurl_media`). Absent or `true` leaves
|
|
51
|
+
* Slack's default, which previews them.
|
|
52
|
+
*/
|
|
53
|
+
linkPreviews?: boolean;
|
|
48
54
|
/**
|
|
49
55
|
* Channel ids where a new top-level message from an admitted sender is
|
|
50
56
|
* this agent's to answer without a mention: the channel people come to
|
|
@@ -244,6 +250,9 @@ export interface SlackWebLike {
|
|
|
244
250
|
test(): Promise<{
|
|
245
251
|
user_id?: string;
|
|
246
252
|
team_id?: string;
|
|
253
|
+
response_metadata?: {
|
|
254
|
+
scopes?: string[];
|
|
255
|
+
};
|
|
247
256
|
}>;
|
|
248
257
|
};
|
|
249
258
|
chat: {
|
|
@@ -252,6 +261,8 @@ export interface SlackWebLike {
|
|
|
252
261
|
text: string;
|
|
253
262
|
thread_ts?: string;
|
|
254
263
|
blocks?: SlackBlock[];
|
|
264
|
+
unfurl_links?: boolean;
|
|
265
|
+
unfurl_media?: boolean;
|
|
255
266
|
}): Promise<{
|
|
256
267
|
ts?: string;
|
|
257
268
|
channel?: string;
|
|
@@ -413,6 +424,13 @@ export interface SlackAdapterOptions {
|
|
|
413
424
|
*/
|
|
414
425
|
now?: () => number;
|
|
415
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* A client whose every post goes out without link or media previews, for
|
|
429
|
+
* an agent configured with `linkPreviews: false`. Applied once around the
|
|
430
|
+
* client, so no post site can forget it. `chat.update` takes no unfurl
|
|
431
|
+
* flags; an edited placeholder keeps the post's own setting.
|
|
432
|
+
*/
|
|
433
|
+
export declare const withoutLinkPreviews: (web: SlackWebLike) => SlackWebLike;
|
|
416
434
|
/**
|
|
417
435
|
* The default fetcher over a `fetch`. Exported as a factory so the bounded
|
|
418
436
|
* read can be tested against a fake `fetch` with a body that never ends;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, open, readFile } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
|
|
5
|
+
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
6
6
|
import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
|
|
7
7
|
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
8
|
/**
|
|
@@ -107,6 +107,52 @@ const defaultSocketClient = (appToken) => {
|
|
|
107
107
|
const { SocketModeClient } = requireModule('@slack/socket-mode');
|
|
108
108
|
return new SocketModeClient({ appToken });
|
|
109
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Scopes the manifest asks for. An app created before they shipped has
|
|
112
|
+
* fewer, and some features silently break — threads are answered without
|
|
113
|
+
* their context, images arrive as names. Checked once at connect, so the
|
|
114
|
+
* operator finds out at start, not at the first broken thread.
|
|
115
|
+
*
|
|
116
|
+
* Events (`message.channels`, `message.groups`, …) cannot be read from a
|
|
117
|
+
* bot token, so the warning says they are needed too.
|
|
118
|
+
*/
|
|
119
|
+
const REQUIRED_SCOPES = [
|
|
120
|
+
'app_mentions:read',
|
|
121
|
+
'channels:history', 'channels:read',
|
|
122
|
+
'chat:write',
|
|
123
|
+
'files:read', 'files:write',
|
|
124
|
+
'groups:history', 'groups:read',
|
|
125
|
+
'im:history', 'im:read', 'im:write',
|
|
126
|
+
'mpim:history', 'mpim:read',
|
|
127
|
+
'users:read',
|
|
128
|
+
];
|
|
129
|
+
const checkBotScopes = (agentId, granted, warn) => {
|
|
130
|
+
const have = new Set(granted);
|
|
131
|
+
const missing = REQUIRED_SCOPES.filter((scope) => !have.has(scope));
|
|
132
|
+
if (missing.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
warn(`slack: ${agentId}'s app is missing bot scopes: ${missing.join(', ')}. `
|
|
136
|
+
+ 'Add them under OAuth & Permissions, add the matching message.* events '
|
|
137
|
+
+ 'under Event Subscriptions, and reinstall the app to the workspace. '
|
|
138
|
+
+ 'Without them, some features (thread follow-through, image viewing, '
|
|
139
|
+
+ 'channel history) will silently fail.');
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* A client whose every post goes out without link or media previews, for
|
|
143
|
+
* an agent configured with `linkPreviews: false`. Applied once around the
|
|
144
|
+
* client, so no post site can forget it. `chat.update` takes no unfurl
|
|
145
|
+
* flags; an edited placeholder keeps the post's own setting.
|
|
146
|
+
*/
|
|
147
|
+
export const withoutLinkPreviews = (web) => {
|
|
148
|
+
// Delegating objects, not copies: everything else on the client (and a
|
|
149
|
+
// method replaced on it later) is still reached through the original.
|
|
150
|
+
const chat = Object.create(web.chat);
|
|
151
|
+
chat.postMessage = (args) => web.chat.postMessage({ ...args, unfurl_links: false, unfurl_media: false });
|
|
152
|
+
const wrapped = Object.create(web);
|
|
153
|
+
wrapped.chat = chat;
|
|
154
|
+
return wrapped;
|
|
155
|
+
};
|
|
110
156
|
const defaultWebClient = (botToken) => {
|
|
111
157
|
const { WebClient } = requireModule('@slack/web-api');
|
|
112
158
|
return new WebClient(botToken);
|
|
@@ -283,8 +329,16 @@ class ReplyRenderer {
|
|
|
283
329
|
lastInterim;
|
|
284
330
|
/** Whether any interim message landed — something said, whatever the reply's fate. */
|
|
285
331
|
interimPosted = false;
|
|
286
|
-
/** Every interim message that landed, in order
|
|
332
|
+
/** Every interim message that landed whole, in order: what a hosted-loop reply is stripped of. */
|
|
287
333
|
interimSaid = [];
|
|
334
|
+
/**
|
|
335
|
+
* Told of each interim message as it is queued — before the reply, and
|
|
336
|
+
* before anything typed while its tool runs — with what of it landed,
|
|
337
|
+
* once that is known, so the thread's other agents hear it in its place.
|
|
338
|
+
*/
|
|
339
|
+
onInterim;
|
|
340
|
+
/** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
|
|
341
|
+
taintedTo;
|
|
288
342
|
turnBreakPending = false;
|
|
289
343
|
toolLine;
|
|
290
344
|
ref;
|
|
@@ -407,6 +461,7 @@ class ReplyRenderer {
|
|
|
407
461
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
408
462
|
this.after = options.after ?? Promise.resolve();
|
|
409
463
|
this.dmWith = options.dmWith;
|
|
464
|
+
this.onInterim = options.onInterim;
|
|
410
465
|
}
|
|
411
466
|
/**
|
|
412
467
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -525,6 +580,12 @@ class ReplyRenderer {
|
|
|
525
580
|
if (this.finalized) {
|
|
526
581
|
return;
|
|
527
582
|
}
|
|
583
|
+
if (event.type === 'session.tainted') {
|
|
584
|
+
if (ofThisTurn && this.turnStarted) {
|
|
585
|
+
this.taintedTo = this.taintedTo === undefined ? event.trust : leastTrusted(this.taintedTo, event.trust);
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
528
589
|
if (event.type === 'provider.delta' && event.delta.type === 'text') {
|
|
529
590
|
// Text from a new provider turn (after tools ran) must not fuse
|
|
530
591
|
// with the previous turn's — "I'll check.The result is…" reads as
|
|
@@ -688,26 +749,49 @@ class ReplyRenderer {
|
|
|
688
749
|
}
|
|
689
750
|
const handover = this.handover;
|
|
690
751
|
const after = this.after;
|
|
752
|
+
let heard = '';
|
|
753
|
+
let settleLanded;
|
|
754
|
+
const landedNow = new Promise((resolve) => {
|
|
755
|
+
settleLanded = resolve;
|
|
756
|
+
});
|
|
757
|
+
this.onInterim?.(() => heard, landedNow);
|
|
691
758
|
this.uploadChain = this.uploadChain
|
|
692
759
|
.then(() => handover)
|
|
693
760
|
.then(() => after)
|
|
694
761
|
.then(async () => {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
762
|
+
const landed = [];
|
|
763
|
+
try {
|
|
764
|
+
for (const chunk of messageChunks(text)) {
|
|
765
|
+
const posted = await this.web.chat.postMessage({
|
|
766
|
+
channel: this.channel,
|
|
767
|
+
text: chunk,
|
|
768
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
769
|
+
});
|
|
770
|
+
this.placeText(posted.ts);
|
|
771
|
+
landed.push(chunk);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
// Whatever landed was said, whole or not: it counts as speaking,
|
|
776
|
+
// and the others hear it. Only a message that landed whole is
|
|
777
|
+
// stripped from a hosted-loop reply; part of one is left in the
|
|
778
|
+
// reply, where repeating a few lines beats losing the rest.
|
|
779
|
+
if (landed.length > 0) {
|
|
780
|
+
this.interimPosted = true;
|
|
781
|
+
heard = landed.length === messageChunks(text).length ? text : landed.join('\n\n');
|
|
782
|
+
}
|
|
783
|
+
settleLanded(landed.length > 0);
|
|
702
784
|
}
|
|
703
|
-
this.interimPosted = true;
|
|
704
785
|
this.lastInterim = text;
|
|
705
786
|
this.interimSaid.push(text);
|
|
706
787
|
// A post of the app's in this thread takes the status down while
|
|
707
788
|
// the turn is still working; put it back.
|
|
708
789
|
this.refreshLoading();
|
|
709
790
|
})
|
|
710
|
-
.catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`))
|
|
791
|
+
.catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`))
|
|
792
|
+
// Settled whatever happened above, or the other agents' intake would
|
|
793
|
+
// wait on it for good. A second settle is a no-op.
|
|
794
|
+
.finally(() => settleLanded(false));
|
|
711
795
|
}
|
|
712
796
|
/**
|
|
713
797
|
* Everything this turn said in the thread, for its other agents to hear:
|
|
@@ -715,8 +799,7 @@ class ReplyRenderer {
|
|
|
715
799
|
* repeats the last of them. Read once the turn's posts have landed.
|
|
716
800
|
*/
|
|
717
801
|
spokenText(reply) {
|
|
718
|
-
|
|
719
|
-
return [...this.interimSaid, ...(rest.trim().length > 0 ? [rest] : [])].join('\n\n');
|
|
802
|
+
return this.unsaid(reply);
|
|
720
803
|
}
|
|
721
804
|
/**
|
|
722
805
|
* The part of a reply not already posted as interim messages. A provider
|
|
@@ -727,11 +810,19 @@ class ReplyRenderer {
|
|
|
727
810
|
*/
|
|
728
811
|
unsaid(reply) {
|
|
729
812
|
let rest = reply.trim();
|
|
813
|
+
// Whole messages only, at the separator the codex provider joins them
|
|
814
|
+
// with: a reply of its own that merely begins with the same words
|
|
815
|
+
// ("Sure" then "Sure, the build passes.") is not cut.
|
|
730
816
|
for (const said of this.interimSaid) {
|
|
731
|
-
if (
|
|
817
|
+
if (rest === said) {
|
|
818
|
+
rest = '';
|
|
819
|
+
}
|
|
820
|
+
else if (rest.startsWith(`${said}\n\n`)) {
|
|
821
|
+
rest = rest.slice(said.length + 2).trimStart();
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
732
824
|
break;
|
|
733
825
|
}
|
|
734
|
-
rest = rest.slice(said.length).trimStart();
|
|
735
826
|
}
|
|
736
827
|
return rest === this.lastInterim ? '' : rest;
|
|
737
828
|
}
|
|
@@ -1159,6 +1250,31 @@ const pastCodePoint = (text, index) => {
|
|
|
1159
1250
|
const code = text.charCodeAt(index - 1);
|
|
1160
1251
|
return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
|
|
1161
1252
|
};
|
|
1253
|
+
/**
|
|
1254
|
+
* `cut` moved in front of any Slack markup it would land inside — a link
|
|
1255
|
+
* `<https://…>` or `<https://…|label>`, a mention `<@U…>` — when that still
|
|
1256
|
+
* leaves half a message. Cut through, the first message ends on an
|
|
1257
|
+
* unterminated `<https://…` and the next starts with the rest of the
|
|
1258
|
+
* address and a stray `>`, and neither half is a link. Markup is one line,
|
|
1259
|
+
* so only the cut's own line is looked at.
|
|
1260
|
+
*/
|
|
1261
|
+
const outsideSlackMarkup = (text, cut) => {
|
|
1262
|
+
const open = text.lastIndexOf('<', cut - 1);
|
|
1263
|
+
if (open <= SLACK_MAX_MESSAGE_CHARS / 2) {
|
|
1264
|
+
return cut;
|
|
1265
|
+
}
|
|
1266
|
+
const closedBefore = text.lastIndexOf('>', cut - 1);
|
|
1267
|
+
const lineBreak = text.lastIndexOf('\n', cut - 1);
|
|
1268
|
+
if (closedBefore > open || lineBreak > open) {
|
|
1269
|
+
return cut;
|
|
1270
|
+
}
|
|
1271
|
+
const close = text.indexOf('>', cut);
|
|
1272
|
+
const nextLine = text.indexOf('\n', cut);
|
|
1273
|
+
if (close === -1 || (nextLine !== -1 && nextLine < close)) {
|
|
1274
|
+
return cut;
|
|
1275
|
+
}
|
|
1276
|
+
return open;
|
|
1277
|
+
};
|
|
1162
1278
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
1163
1279
|
? text
|
|
1164
1280
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
@@ -1215,8 +1331,9 @@ const splitForSlack = (text) => {
|
|
|
1215
1331
|
// point, or the step that guarantees progress lands inside an emoji.
|
|
1216
1332
|
const fence = run !== undefined && run.closer.startsWith('\n');
|
|
1217
1333
|
if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
|
|
1218
|
-
|
|
1219
|
-
rest
|
|
1334
|
+
const at = run === undefined ? outsideSlackMarkup(rest, cut) : cut;
|
|
1335
|
+
chunks.push(rest.slice(0, at));
|
|
1336
|
+
rest = rest.slice(at).replace(/^\n+/, '');
|
|
1220
1337
|
continue;
|
|
1221
1338
|
}
|
|
1222
1339
|
const inner = rest.lastIndexOf('\n', budget - 1);
|
|
@@ -2592,7 +2709,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2592
2709
|
slackChannel: channel,
|
|
2593
2710
|
slackUser: speaker.botUserId,
|
|
2594
2711
|
slackThread: thread,
|
|
2595
|
-
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
|
|
2596
2712
|
};
|
|
2597
2713
|
// In the reply's own workspace only, because a session key carries a
|
|
2598
2714
|
// team — the same reason `resolveFollowUpWinner` stays in-workspace.
|
|
@@ -2616,6 +2732,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2616
2732
|
if (text.trim().length === 0) {
|
|
2617
2733
|
return { observed: undefined };
|
|
2618
2734
|
}
|
|
2735
|
+
const trust = sessionWriteTrust(typeof spoken === 'function' ? await spoken() : spoken);
|
|
2619
2736
|
let member;
|
|
2620
2737
|
try {
|
|
2621
2738
|
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
@@ -2630,7 +2747,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2630
2747
|
// spoke, by display name. A bot user's is the app's own, set by
|
|
2631
2748
|
// whoever installed it.
|
|
2632
2749
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2633
|
-
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata });
|
|
2750
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
|
|
2634
2751
|
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
2635
2752
|
});
|
|
2636
2753
|
await observed;
|
|
@@ -2682,12 +2799,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2682
2799
|
}
|
|
2683
2800
|
const channelId = conversation.id;
|
|
2684
2801
|
const web = connection.web;
|
|
2802
|
+
// A reply under a message rather than a post beside it. Every chunk of
|
|
2803
|
+
// a long one goes to the same thread, so a split reply stays together.
|
|
2804
|
+
const thread = address.thread?.trim() || undefined;
|
|
2685
2805
|
return {
|
|
2686
2806
|
async post(text) {
|
|
2687
2807
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
2688
2808
|
let first;
|
|
2689
2809
|
for (const chunk of chunks) {
|
|
2690
|
-
const posted = await web.chat.postMessage({ channel: channelId, text: chunk });
|
|
2810
|
+
const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
|
|
2691
2811
|
if (!first) {
|
|
2692
2812
|
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
2693
2813
|
}
|
|
@@ -2848,7 +2968,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2848
2968
|
if (!gateway) {
|
|
2849
2969
|
return;
|
|
2850
2970
|
}
|
|
2851
|
-
|
|
2971
|
+
// A delegated call is asked where the work was asked for: the
|
|
2972
|
+
// delegating conversation, through the app and approvers of the agent
|
|
2973
|
+
// at the root of the chain. The agent doing the work may have no
|
|
2974
|
+
// connection here at all, and its sub-session has no conversation of
|
|
2975
|
+
// its own — asked the old way, nobody saw it and the turn sat out the
|
|
2976
|
+
// whole timeout. The gateway makes such a call one-shot, so routing it
|
|
2977
|
+
// through another agent's approvers never grants anything standing.
|
|
2978
|
+
const origin = event.delegatedFrom;
|
|
2979
|
+
const viaOrigin = origin ? connectionFor(origin.agentId) : undefined;
|
|
2980
|
+
const connection = viaOrigin ?? connectionFor(event.agentId);
|
|
2852
2981
|
if (!connection) {
|
|
2853
2982
|
// Two different situations, and only one of them is this adapter's
|
|
2854
2983
|
// to answer.
|
|
@@ -2863,14 +2992,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2863
2992
|
// agent — denying here would let Slack refuse a question somebody
|
|
2864
2993
|
// else was going to ask. `stratus serve` reports agents that no
|
|
2865
2994
|
// channel can ask for, at startup, where the whole roster is visible.
|
|
2866
|
-
if (configuredAgents.has(event.agentId)) {
|
|
2867
|
-
warn(`slack: ${event.agentId} has no live connection; denying ${event.call.toolName}`);
|
|
2995
|
+
if (configuredAgents.has(event.agentId) || (origin && configuredAgents.has(origin.agentId))) {
|
|
2996
|
+
warn(`slack: ${origin?.agentId ?? event.agentId} has no live connection; denying ${event.call.toolName}`);
|
|
2868
2997
|
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
2869
2998
|
}
|
|
2870
2999
|
return;
|
|
2871
3000
|
}
|
|
2872
3001
|
const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
|
|
2873
|
-
const metadata = event.metadata ?? {};
|
|
3002
|
+
const metadata = (viaOrigin ? origin?.metadata : event.metadata) ?? {};
|
|
2874
3003
|
const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
|
|
2875
3004
|
? metadata.slackChannel
|
|
2876
3005
|
: undefined;
|
|
@@ -2884,14 +3013,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2884
3013
|
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
2885
3014
|
};
|
|
2886
3015
|
if (approvers.size === 0) {
|
|
2887
|
-
decline(`no approvers configured for ${
|
|
3016
|
+
decline(`no approvers configured for ${connection.config.agentId}`);
|
|
2888
3017
|
return;
|
|
2889
3018
|
}
|
|
2890
3019
|
if (!channel) {
|
|
2891
|
-
decline(`no conversation to ask in for ${
|
|
3020
|
+
decline(`no conversation to ask in for ${connection.config.agentId}`);
|
|
2892
3021
|
return;
|
|
2893
3022
|
}
|
|
2894
|
-
const
|
|
3023
|
+
const nameOf = (agentId) => gateway.agents().find((agent) => agent.id === agentId)?.name ?? agentId;
|
|
3024
|
+
// "Quinn (for Nova)": the approver is being asked about Quinn's call,
|
|
3025
|
+
// by Nova's app, in Nova's conversation, and should be told both.
|
|
3026
|
+
const agentName = viaOrigin && origin && origin.agentId !== event.agentId
|
|
3027
|
+
? `${nameOf(event.agentId)} (for ${nameOf(origin.agentId)})`
|
|
3028
|
+
: nameOf(event.agentId);
|
|
2895
3029
|
// The notification preview is all some approvers see before deciding
|
|
2896
3030
|
// whether to open the thread, so it carries the arguments too — just
|
|
2897
3031
|
// the short form.
|
|
@@ -4298,7 +4432,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4298
4432
|
: {
|
|
4299
4433
|
...room,
|
|
4300
4434
|
...(room.kind === 'direct' && senderTrust === 'user' ? { with: author } : {}),
|
|
4301
|
-
...(thread !== undefined && room.kind !== 'direct' ? { thread: true } : {}),
|
|
4435
|
+
...(thread !== undefined && room.kind !== 'direct' ? { thread: true, threadRoot: thread } : {}),
|
|
4302
4436
|
// People outside this workspace read a Slack Connect channel, and
|
|
4303
4437
|
// the event says so itself, even when the lookup failed.
|
|
4304
4438
|
...(room.kind !== 'direct' && args.body?.is_ext_shared_channel === true ? { shared: true } : {}),
|
|
@@ -4373,6 +4507,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4373
4507
|
statusWarned: connection.statusWarned,
|
|
4374
4508
|
...(ahead ? { after: ahead } : {}),
|
|
4375
4509
|
...(isDm ? { dmWith: userId } : {}),
|
|
4510
|
+
// Each interim message, as it lands, takes its place in the
|
|
4511
|
+
// thread's other agents' sessions — ahead of a follow-up typed
|
|
4512
|
+
// while the tool it preceded is still running. Under the session's
|
|
4513
|
+
// label as stored, lowered by anything that tainted it during this
|
|
4514
|
+
// turn, and the least trusted label where it cannot be read.
|
|
4515
|
+
...(thread !== undefined
|
|
4516
|
+
? {
|
|
4517
|
+
onInterim: (heard, landed) => {
|
|
4518
|
+
// Tracked, so a shutdown drains it like any other reaction:
|
|
4519
|
+
// the text is already in the thread, whatever the turn does.
|
|
4520
|
+
track(overhearReply(connection, event.channel, thread, heard, async () => {
|
|
4521
|
+
const routed = await gateway.sessionRouting?.(sessionId).catch(() => undefined);
|
|
4522
|
+
const stored = routed ? sessionTrustOf(routed) : 'external';
|
|
4523
|
+
const live = renderer.taintedTo === undefined ? stored : leastTrusted(stored, renderer.taintedTo);
|
|
4524
|
+
return { metadata: { [SESSION_TRUST_METADATA_KEY]: live } };
|
|
4525
|
+
}, landed));
|
|
4526
|
+
},
|
|
4527
|
+
}
|
|
4528
|
+
: {}),
|
|
4376
4529
|
});
|
|
4377
4530
|
if (!streaming) {
|
|
4378
4531
|
replyOrder.set(sessionId, renderer.posted);
|
|
@@ -4565,15 +4718,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4565
4718
|
await heard;
|
|
4566
4719
|
}
|
|
4567
4720
|
else {
|
|
4568
|
-
|
|
4569
|
-
//
|
|
4570
|
-
//
|
|
4571
|
-
//
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
await
|
|
4576
|
-
}
|
|
4721
|
+
// A paused daemon's refusal is its answer, said as it is: the
|
|
4722
|
+
// operator wrote that sentence for the person reading it, and it is
|
|
4723
|
+
// not a failure of the agent. Matched by name, as this package does
|
|
4724
|
+
// not import the gateway. A turn nobody asked for stays silent.
|
|
4725
|
+
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
4726
|
+
const { spoke, spokeAt } = pausedReply
|
|
4727
|
+
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
4728
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
4577
4729
|
if (spoke && threadKey !== undefined) {
|
|
4578
4730
|
// A file it posted before breaking is still the last thing said.
|
|
4579
4731
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -4715,10 +4867,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4715
4867
|
continue;
|
|
4716
4868
|
}
|
|
4717
4869
|
try {
|
|
4718
|
-
const
|
|
4870
|
+
const created = createWeb(config.botToken);
|
|
4871
|
+
const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
|
|
4719
4872
|
const auth = await web.auth.test();
|
|
4720
4873
|
const botUserId = auth.user_id ?? '';
|
|
4721
4874
|
const teamId = auth.team_id ?? '';
|
|
4875
|
+
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
4722
4876
|
// Recorded before the socket is even built: an app that never
|
|
4723
4877
|
// comes up must still be recognizable when somebody names it.
|
|
4724
4878
|
botIdentities.set(config.agentId, { botUserId, teamId });
|