@stratusagent/channel-slack 0.11.9 → 0.11.10
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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +145 -28
- 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
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,37 @@ 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
|
+
};
|
|
110
141
|
const defaultWebClient = (botToken) => {
|
|
111
142
|
const { WebClient } = requireModule('@slack/web-api');
|
|
112
143
|
return new WebClient(botToken);
|
|
@@ -283,8 +314,16 @@ class ReplyRenderer {
|
|
|
283
314
|
lastInterim;
|
|
284
315
|
/** Whether any interim message landed — something said, whatever the reply's fate. */
|
|
285
316
|
interimPosted = false;
|
|
286
|
-
/** Every interim message that landed, in order
|
|
317
|
+
/** Every interim message that landed whole, in order: what a hosted-loop reply is stripped of. */
|
|
287
318
|
interimSaid = [];
|
|
319
|
+
/**
|
|
320
|
+
* Told of each interim message as it is queued — before the reply, and
|
|
321
|
+
* before anything typed while its tool runs — with what of it landed,
|
|
322
|
+
* once that is known, so the thread's other agents hear it in its place.
|
|
323
|
+
*/
|
|
324
|
+
onInterim;
|
|
325
|
+
/** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
|
|
326
|
+
taintedTo;
|
|
288
327
|
turnBreakPending = false;
|
|
289
328
|
toolLine;
|
|
290
329
|
ref;
|
|
@@ -407,6 +446,7 @@ class ReplyRenderer {
|
|
|
407
446
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
408
447
|
this.after = options.after ?? Promise.resolve();
|
|
409
448
|
this.dmWith = options.dmWith;
|
|
449
|
+
this.onInterim = options.onInterim;
|
|
410
450
|
}
|
|
411
451
|
/**
|
|
412
452
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -525,6 +565,12 @@ class ReplyRenderer {
|
|
|
525
565
|
if (this.finalized) {
|
|
526
566
|
return;
|
|
527
567
|
}
|
|
568
|
+
if (event.type === 'session.tainted') {
|
|
569
|
+
if (ofThisTurn && this.turnStarted) {
|
|
570
|
+
this.taintedTo = this.taintedTo === undefined ? event.trust : leastTrusted(this.taintedTo, event.trust);
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
528
574
|
if (event.type === 'provider.delta' && event.delta.type === 'text') {
|
|
529
575
|
// Text from a new provider turn (after tools ran) must not fuse
|
|
530
576
|
// with the previous turn's — "I'll check.The result is…" reads as
|
|
@@ -688,26 +734,49 @@ class ReplyRenderer {
|
|
|
688
734
|
}
|
|
689
735
|
const handover = this.handover;
|
|
690
736
|
const after = this.after;
|
|
737
|
+
let heard = '';
|
|
738
|
+
let settleLanded;
|
|
739
|
+
const landedNow = new Promise((resolve) => {
|
|
740
|
+
settleLanded = resolve;
|
|
741
|
+
});
|
|
742
|
+
this.onInterim?.(() => heard, landedNow);
|
|
691
743
|
this.uploadChain = this.uploadChain
|
|
692
744
|
.then(() => handover)
|
|
693
745
|
.then(() => after)
|
|
694
746
|
.then(async () => {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
747
|
+
const landed = [];
|
|
748
|
+
try {
|
|
749
|
+
for (const chunk of messageChunks(text)) {
|
|
750
|
+
const posted = await this.web.chat.postMessage({
|
|
751
|
+
channel: this.channel,
|
|
752
|
+
text: chunk,
|
|
753
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
754
|
+
});
|
|
755
|
+
this.placeText(posted.ts);
|
|
756
|
+
landed.push(chunk);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
// Whatever landed was said, whole or not: it counts as speaking,
|
|
761
|
+
// and the others hear it. Only a message that landed whole is
|
|
762
|
+
// stripped from a hosted-loop reply; part of one is left in the
|
|
763
|
+
// reply, where repeating a few lines beats losing the rest.
|
|
764
|
+
if (landed.length > 0) {
|
|
765
|
+
this.interimPosted = true;
|
|
766
|
+
heard = landed.length === messageChunks(text).length ? text : landed.join('\n\n');
|
|
767
|
+
}
|
|
768
|
+
settleLanded(landed.length > 0);
|
|
702
769
|
}
|
|
703
|
-
this.interimPosted = true;
|
|
704
770
|
this.lastInterim = text;
|
|
705
771
|
this.interimSaid.push(text);
|
|
706
772
|
// A post of the app's in this thread takes the status down while
|
|
707
773
|
// the turn is still working; put it back.
|
|
708
774
|
this.refreshLoading();
|
|
709
775
|
})
|
|
710
|
-
.catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`))
|
|
776
|
+
.catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`))
|
|
777
|
+
// Settled whatever happened above, or the other agents' intake would
|
|
778
|
+
// wait on it for good. A second settle is a no-op.
|
|
779
|
+
.finally(() => settleLanded(false));
|
|
711
780
|
}
|
|
712
781
|
/**
|
|
713
782
|
* Everything this turn said in the thread, for its other agents to hear:
|
|
@@ -715,8 +784,7 @@ class ReplyRenderer {
|
|
|
715
784
|
* repeats the last of them. Read once the turn's posts have landed.
|
|
716
785
|
*/
|
|
717
786
|
spokenText(reply) {
|
|
718
|
-
|
|
719
|
-
return [...this.interimSaid, ...(rest.trim().length > 0 ? [rest] : [])].join('\n\n');
|
|
787
|
+
return this.unsaid(reply);
|
|
720
788
|
}
|
|
721
789
|
/**
|
|
722
790
|
* The part of a reply not already posted as interim messages. A provider
|
|
@@ -727,11 +795,19 @@ class ReplyRenderer {
|
|
|
727
795
|
*/
|
|
728
796
|
unsaid(reply) {
|
|
729
797
|
let rest = reply.trim();
|
|
798
|
+
// Whole messages only, at the separator the codex provider joins them
|
|
799
|
+
// with: a reply of its own that merely begins with the same words
|
|
800
|
+
// ("Sure" then "Sure, the build passes.") is not cut.
|
|
730
801
|
for (const said of this.interimSaid) {
|
|
731
|
-
if (
|
|
802
|
+
if (rest === said) {
|
|
803
|
+
rest = '';
|
|
804
|
+
}
|
|
805
|
+
else if (rest.startsWith(`${said}\n\n`)) {
|
|
806
|
+
rest = rest.slice(said.length + 2).trimStart();
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
732
809
|
break;
|
|
733
810
|
}
|
|
734
|
-
rest = rest.slice(said.length).trimStart();
|
|
735
811
|
}
|
|
736
812
|
return rest === this.lastInterim ? '' : rest;
|
|
737
813
|
}
|
|
@@ -1159,6 +1235,31 @@ const pastCodePoint = (text, index) => {
|
|
|
1159
1235
|
const code = text.charCodeAt(index - 1);
|
|
1160
1236
|
return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
|
|
1161
1237
|
};
|
|
1238
|
+
/**
|
|
1239
|
+
* `cut` moved in front of any Slack markup it would land inside — a link
|
|
1240
|
+
* `<https://…>` or `<https://…|label>`, a mention `<@U…>` — when that still
|
|
1241
|
+
* leaves half a message. Cut through, the first message ends on an
|
|
1242
|
+
* unterminated `<https://…` and the next starts with the rest of the
|
|
1243
|
+
* address and a stray `>`, and neither half is a link. Markup is one line,
|
|
1244
|
+
* so only the cut's own line is looked at.
|
|
1245
|
+
*/
|
|
1246
|
+
const outsideSlackMarkup = (text, cut) => {
|
|
1247
|
+
const open = text.lastIndexOf('<', cut - 1);
|
|
1248
|
+
if (open <= SLACK_MAX_MESSAGE_CHARS / 2) {
|
|
1249
|
+
return cut;
|
|
1250
|
+
}
|
|
1251
|
+
const closedBefore = text.lastIndexOf('>', cut - 1);
|
|
1252
|
+
const lineBreak = text.lastIndexOf('\n', cut - 1);
|
|
1253
|
+
if (closedBefore > open || lineBreak > open) {
|
|
1254
|
+
return cut;
|
|
1255
|
+
}
|
|
1256
|
+
const close = text.indexOf('>', cut);
|
|
1257
|
+
const nextLine = text.indexOf('\n', cut);
|
|
1258
|
+
if (close === -1 || (nextLine !== -1 && nextLine < close)) {
|
|
1259
|
+
return cut;
|
|
1260
|
+
}
|
|
1261
|
+
return open;
|
|
1262
|
+
};
|
|
1162
1263
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
1163
1264
|
? text
|
|
1164
1265
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
@@ -1215,8 +1316,9 @@ const splitForSlack = (text) => {
|
|
|
1215
1316
|
// point, or the step that guarantees progress lands inside an emoji.
|
|
1216
1317
|
const fence = run !== undefined && run.closer.startsWith('\n');
|
|
1217
1318
|
if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
|
|
1218
|
-
|
|
1219
|
-
rest
|
|
1319
|
+
const at = run === undefined ? outsideSlackMarkup(rest, cut) : cut;
|
|
1320
|
+
chunks.push(rest.slice(0, at));
|
|
1321
|
+
rest = rest.slice(at).replace(/^\n+/, '');
|
|
1220
1322
|
continue;
|
|
1221
1323
|
}
|
|
1222
1324
|
const inner = rest.lastIndexOf('\n', budget - 1);
|
|
@@ -2592,7 +2694,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2592
2694
|
slackChannel: channel,
|
|
2593
2695
|
slackUser: speaker.botUserId,
|
|
2594
2696
|
slackThread: thread,
|
|
2595
|
-
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
|
|
2596
2697
|
};
|
|
2597
2698
|
// In the reply's own workspace only, because a session key carries a
|
|
2598
2699
|
// team — the same reason `resolveFollowUpWinner` stays in-workspace.
|
|
@@ -2616,6 +2717,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2616
2717
|
if (text.trim().length === 0) {
|
|
2617
2718
|
return { observed: undefined };
|
|
2618
2719
|
}
|
|
2720
|
+
const trust = sessionWriteTrust(typeof spoken === 'function' ? await spoken() : spoken);
|
|
2619
2721
|
let member;
|
|
2620
2722
|
try {
|
|
2621
2723
|
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
@@ -2630,7 +2732,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2630
2732
|
// spoke, by display name. A bot user's is the app's own, set by
|
|
2631
2733
|
// whoever installed it.
|
|
2632
2734
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2633
|
-
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata });
|
|
2735
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
|
|
2634
2736
|
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
2635
2737
|
});
|
|
2636
2738
|
await observed;
|
|
@@ -2682,12 +2784,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2682
2784
|
}
|
|
2683
2785
|
const channelId = conversation.id;
|
|
2684
2786
|
const web = connection.web;
|
|
2787
|
+
// A reply under a message rather than a post beside it. Every chunk of
|
|
2788
|
+
// a long one goes to the same thread, so a split reply stays together.
|
|
2789
|
+
const thread = address.thread?.trim() || undefined;
|
|
2685
2790
|
return {
|
|
2686
2791
|
async post(text) {
|
|
2687
2792
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
2688
2793
|
let first;
|
|
2689
2794
|
for (const chunk of chunks) {
|
|
2690
|
-
const posted = await web.chat.postMessage({ channel: channelId, text: chunk });
|
|
2795
|
+
const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
|
|
2691
2796
|
if (!first) {
|
|
2692
2797
|
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
2693
2798
|
}
|
|
@@ -4373,6 +4478,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4373
4478
|
statusWarned: connection.statusWarned,
|
|
4374
4479
|
...(ahead ? { after: ahead } : {}),
|
|
4375
4480
|
...(isDm ? { dmWith: userId } : {}),
|
|
4481
|
+
// Each interim message, as it lands, takes its place in the
|
|
4482
|
+
// thread's other agents' sessions — ahead of a follow-up typed
|
|
4483
|
+
// while the tool it preceded is still running. Under the session's
|
|
4484
|
+
// label as stored, lowered by anything that tainted it during this
|
|
4485
|
+
// turn, and the least trusted label where it cannot be read.
|
|
4486
|
+
...(thread !== undefined
|
|
4487
|
+
? {
|
|
4488
|
+
onInterim: (heard, landed) => {
|
|
4489
|
+
// Tracked, so a shutdown drains it like any other reaction:
|
|
4490
|
+
// the text is already in the thread, whatever the turn does.
|
|
4491
|
+
track(overhearReply(connection, event.channel, thread, heard, async () => {
|
|
4492
|
+
const routed = await gateway.sessionRouting?.(sessionId).catch(() => undefined);
|
|
4493
|
+
const stored = routed ? sessionTrustOf(routed) : 'external';
|
|
4494
|
+
const live = renderer.taintedTo === undefined ? stored : leastTrusted(stored, renderer.taintedTo);
|
|
4495
|
+
return { metadata: { [SESSION_TRUST_METADATA_KEY]: live } };
|
|
4496
|
+
}, landed));
|
|
4497
|
+
},
|
|
4498
|
+
}
|
|
4499
|
+
: {}),
|
|
4376
4500
|
});
|
|
4377
4501
|
if (!streaming) {
|
|
4378
4502
|
replyOrder.set(sessionId, renderer.posted);
|
|
@@ -4566,14 +4690,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4566
4690
|
}
|
|
4567
4691
|
else {
|
|
4568
4692
|
const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
4569
|
-
// What the turn said before it broke is in the thread, so the
|
|
4570
|
-
// others hear it; the error note is the failure's, and is not. Under
|
|
4571
|
-
// the session's own label where it can be read, and the least
|
|
4572
|
-
// trusted one where it cannot.
|
|
4573
|
-
if (thread !== undefined && renderer.spokenText('').length > 0) {
|
|
4574
|
-
const routed = await gateway.sessionRouting?.(sessionId).catch(() => undefined);
|
|
4575
|
-
await overhearReply(connection, event.channel, thread, renderer.spokenText(''), routed ?? { metadata: {} }, Promise.resolve(true));
|
|
4576
|
-
}
|
|
4577
4693
|
if (spoke && threadKey !== undefined) {
|
|
4578
4694
|
// A file it posted before breaking is still the last thing said.
|
|
4579
4695
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -4719,6 +4835,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4719
4835
|
const auth = await web.auth.test();
|
|
4720
4836
|
const botUserId = auth.user_id ?? '';
|
|
4721
4837
|
const teamId = auth.team_id ?? '';
|
|
4838
|
+
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
4722
4839
|
// Recorded before the socket is even built: an app that never
|
|
4723
4840
|
// comes up must still be recognizable when somebody names it.
|
|
4724
4841
|
botIdentities.set(config.agentId, { botUserId, teamId });
|