@stratusagent/channel-slack 0.11.8 → 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 +43 -9
- package/dist/index.d.ts +71 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +778 -12
- 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/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
|
/**
|
|
@@ -83,6 +83,23 @@ const ATTENTION_MS = 15 * 60 * 1000;
|
|
|
83
83
|
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
84
84
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
85
85
|
const NO_REPLY_TEXT = '(no reply)';
|
|
86
|
+
/**
|
|
87
|
+
* How much of a thread a late arrival is handed: the parent, which is
|
|
88
|
+
* usually the question, and the newest messages before the mention. A
|
|
89
|
+
* longer thread is cut in the middle and says so, rather than flooding the
|
|
90
|
+
* opening turn or dropping the message that started it.
|
|
91
|
+
*/
|
|
92
|
+
const EARLIER_MESSAGE_LIMIT = 40;
|
|
93
|
+
/** The most pages of 200 read from a thread for that: 4,000 replies. */
|
|
94
|
+
const EARLIER_THREAD_PAGES = 20;
|
|
95
|
+
/**
|
|
96
|
+
* At the top of a channel, how many of its most recent messages a mention
|
|
97
|
+
* opens with, and how many are read to find them.
|
|
98
|
+
*/
|
|
99
|
+
const EARLIER_CHANNEL_LIMIT = 20;
|
|
100
|
+
const EARLIER_CHANNEL_READ = 100;
|
|
101
|
+
/** The most pages read to find them: 1,000 messages. */
|
|
102
|
+
const EARLIER_CHANNEL_PAGES = 10;
|
|
86
103
|
// Lazy (and CJS-interoperable) so tests with injected fakes never load the
|
|
87
104
|
// real SDK stack.
|
|
88
105
|
const requireModule = createRequire(import.meta.url);
|
|
@@ -90,6 +107,37 @@ const defaultSocketClient = (appToken) => {
|
|
|
90
107
|
const { SocketModeClient } = requireModule('@slack/socket-mode');
|
|
91
108
|
return new SocketModeClient({ appToken });
|
|
92
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
|
+
};
|
|
93
141
|
const defaultWebClient = (botToken) => {
|
|
94
142
|
const { WebClient } = requireModule('@slack/web-api');
|
|
95
143
|
return new WebClient(botToken);
|
|
@@ -254,6 +302,28 @@ class ReplyRenderer {
|
|
|
254
302
|
*/
|
|
255
303
|
turnId = randomUUID();
|
|
256
304
|
buffer = '';
|
|
305
|
+
/**
|
|
306
|
+
* In `final` mode, what the model has said since its last tool call. A
|
|
307
|
+
* turn that writes "On it, checking the PRs" and then works for an hour
|
|
308
|
+
* should not leave the thread showing only a loading status: text the
|
|
309
|
+
* model wrote before calling a tool is posted when the call starts, as a
|
|
310
|
+
* message of its own, and the finished reply follows it.
|
|
311
|
+
*/
|
|
312
|
+
interim = '';
|
|
313
|
+
/** The last interim message posted, so a reply that repeats it is not posted twice. */
|
|
314
|
+
lastInterim;
|
|
315
|
+
/** Whether any interim message landed — something said, whatever the reply's fate. */
|
|
316
|
+
interimPosted = false;
|
|
317
|
+
/** Every interim message that landed whole, in order: what a hosted-loop reply is stripped of. */
|
|
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;
|
|
257
327
|
turnBreakPending = false;
|
|
258
328
|
toolLine;
|
|
259
329
|
ref;
|
|
@@ -376,6 +446,7 @@ class ReplyRenderer {
|
|
|
376
446
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
377
447
|
this.after = options.after ?? Promise.resolve();
|
|
378
448
|
this.dmWith = options.dmWith;
|
|
449
|
+
this.onInterim = options.onInterim;
|
|
379
450
|
}
|
|
380
451
|
/**
|
|
381
452
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -466,6 +537,7 @@ class ReplyRenderer {
|
|
|
466
537
|
this.pendingEdit = undefined;
|
|
467
538
|
}
|
|
468
539
|
this.buffer = '';
|
|
540
|
+
this.interim = '';
|
|
469
541
|
this.toolLine = undefined;
|
|
470
542
|
this.runningTool = undefined;
|
|
471
543
|
this.turnBreakPending = false;
|
|
@@ -493,6 +565,12 @@ class ReplyRenderer {
|
|
|
493
565
|
if (this.finalized) {
|
|
494
566
|
return;
|
|
495
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
|
+
}
|
|
496
574
|
if (event.type === 'provider.delta' && event.delta.type === 'text') {
|
|
497
575
|
// Text from a new provider turn (after tools ran) must not fuse
|
|
498
576
|
// with the previous turn's — "I'll check.The result is…" reads as
|
|
@@ -502,6 +580,7 @@ class ReplyRenderer {
|
|
|
502
580
|
}
|
|
503
581
|
this.turnBreakPending = false;
|
|
504
582
|
this.buffer += event.delta.text;
|
|
583
|
+
this.interim += event.delta.text;
|
|
505
584
|
this.scheduleEdit();
|
|
506
585
|
return;
|
|
507
586
|
}
|
|
@@ -510,12 +589,22 @@ class ReplyRenderer {
|
|
|
510
589
|
// mid-stream failure): discard everything streamed so far so two
|
|
511
590
|
// attempts never fuse into one message.
|
|
512
591
|
this.buffer = '';
|
|
592
|
+
this.interim = '';
|
|
513
593
|
this.turnBreakPending = false;
|
|
514
594
|
this.scheduleEdit();
|
|
515
595
|
return;
|
|
516
596
|
}
|
|
517
597
|
if (event.type === 'provider.response') {
|
|
518
598
|
this.turnBreakPending = true;
|
|
599
|
+
// A provider that answers in one piece streams no deltas: the
|
|
600
|
+
// response is the only place its words before a tool call appear.
|
|
601
|
+
// One that streamed them has them already, and is not given them twice.
|
|
602
|
+
if (this.interim.trim().length === 0) {
|
|
603
|
+
this.interim = event.parts
|
|
604
|
+
.filter((part) => part.type === 'text')
|
|
605
|
+
.map((part) => part.text)
|
|
606
|
+
.join('');
|
|
607
|
+
}
|
|
519
608
|
return;
|
|
520
609
|
}
|
|
521
610
|
if (event.type === 'tool.called') {
|
|
@@ -527,6 +616,7 @@ class ReplyRenderer {
|
|
|
527
616
|
this.turnBreakPending = true;
|
|
528
617
|
this.toolLine = `⚙ ${event.call.toolName}…`;
|
|
529
618
|
this.runningTool = event.call.toolName;
|
|
619
|
+
this.flushInterim(ofThisTurn);
|
|
530
620
|
this.scheduleEdit();
|
|
531
621
|
return;
|
|
532
622
|
}
|
|
@@ -538,12 +628,18 @@ class ReplyRenderer {
|
|
|
538
628
|
this.toolLine = undefined;
|
|
539
629
|
this.runningTool = undefined;
|
|
540
630
|
this.turnBreakPending = true;
|
|
631
|
+
// Words written before a call that was refused were still said
|
|
632
|
+
// before a tool, and must not run into the next response's.
|
|
633
|
+
this.flushInterim(ofThisTurn);
|
|
541
634
|
this.scheduleEdit();
|
|
542
635
|
return;
|
|
543
636
|
}
|
|
544
637
|
if (event.type === 'tool.completed') {
|
|
545
638
|
this.toolLine = undefined;
|
|
546
639
|
this.runningTool = undefined;
|
|
640
|
+
// Settled here too for a call rejected before it ran, which never
|
|
641
|
+
// reached `tool.called`; after one that did, this is empty.
|
|
642
|
+
this.flushInterim(ofThisTurn);
|
|
547
643
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
548
644
|
// execution settles as tool.completed without ever having emitted
|
|
549
645
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -623,6 +719,98 @@ class ReplyRenderer {
|
|
|
623
719
|
})
|
|
624
720
|
.catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
|
|
625
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Post what the model said before the tool it is now calling, in `final`
|
|
724
|
+
* mode only: a streaming turn already shows it in its placeholder, and a
|
|
725
|
+
* turn nobody asked for must not speak before it decides to. Behind the
|
|
726
|
+
* turn ahead and any handover, on the upload chain, so interim text,
|
|
727
|
+
* files, and the reply land in the order they happened.
|
|
728
|
+
*/
|
|
729
|
+
flushInterim(ofThisTurn) {
|
|
730
|
+
const text = this.interim.trim();
|
|
731
|
+
this.interim = '';
|
|
732
|
+
if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const handover = this.handover;
|
|
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);
|
|
743
|
+
this.uploadChain = this.uploadChain
|
|
744
|
+
.then(() => handover)
|
|
745
|
+
.then(() => after)
|
|
746
|
+
.then(async () => {
|
|
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);
|
|
769
|
+
}
|
|
770
|
+
this.lastInterim = text;
|
|
771
|
+
this.interimSaid.push(text);
|
|
772
|
+
// A post of the app's in this thread takes the status down while
|
|
773
|
+
// the turn is still working; put it back.
|
|
774
|
+
this.refreshLoading();
|
|
775
|
+
})
|
|
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));
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Everything this turn said in the thread, for its other agents to hear:
|
|
783
|
+
* the interim messages that landed, then the reply unless it only
|
|
784
|
+
* repeats the last of them. Read once the turn's posts have landed.
|
|
785
|
+
*/
|
|
786
|
+
spokenText(reply) {
|
|
787
|
+
return this.unsaid(reply);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* The part of a reply not already posted as interim messages. A provider
|
|
791
|
+
* that hosts its own loop (codex) returns every message of the turn
|
|
792
|
+
* joined into one reply, so the interim messages are stripped from its
|
|
793
|
+
* front in order; one whose last words came before its final tool call
|
|
794
|
+
* returns just those words, which were posted already.
|
|
795
|
+
*/
|
|
796
|
+
unsaid(reply) {
|
|
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.
|
|
801
|
+
for (const said of this.interimSaid) {
|
|
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 {
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return rest === this.lastInterim ? '' : rest;
|
|
813
|
+
}
|
|
626
814
|
currentText() {
|
|
627
815
|
const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
|
|
628
816
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
@@ -755,6 +943,22 @@ class ReplyRenderer {
|
|
|
755
943
|
await this.uploadChain;
|
|
756
944
|
return this.outcome(false, this.uploaded);
|
|
757
945
|
}
|
|
946
|
+
// Interim messages land before the reply is judged: one that already
|
|
947
|
+
// said what the reply says (the model's last words came before a
|
|
948
|
+
// tool call) is not posted again, and a turn that said things along
|
|
949
|
+
// the way is not followed by `(no reply)`.
|
|
950
|
+
await this.uploadChain;
|
|
951
|
+
if (this.interimPosted && !this.ref) {
|
|
952
|
+
reply = this.unsaid(reply);
|
|
953
|
+
}
|
|
954
|
+
if (!this.ref && this.interimPosted && reply.trim().length === 0) {
|
|
955
|
+
// Nothing posts after this to take the status down, so it is cleared.
|
|
956
|
+
if (hadStatus) {
|
|
957
|
+
this.setStatus('');
|
|
958
|
+
await this.statusChain;
|
|
959
|
+
}
|
|
960
|
+
return this.outcome(true, true);
|
|
961
|
+
}
|
|
758
962
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
759
963
|
const chunks = messageChunks(text);
|
|
760
964
|
// No placeholder — a handover could not open a fresh one — and the
|
|
@@ -1031,6 +1235,31 @@ const pastCodePoint = (text, index) => {
|
|
|
1031
1235
|
const code = text.charCodeAt(index - 1);
|
|
1032
1236
|
return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
|
|
1033
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
|
+
};
|
|
1034
1263
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
1035
1264
|
? text
|
|
1036
1265
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
@@ -1087,8 +1316,9 @@ const splitForSlack = (text) => {
|
|
|
1087
1316
|
// point, or the step that guarantees progress lands inside an emoji.
|
|
1088
1317
|
const fence = run !== undefined && run.closer.startsWith('\n');
|
|
1089
1318
|
if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
|
|
1090
|
-
|
|
1091
|
-
rest
|
|
1319
|
+
const at = run === undefined ? outsideSlackMarkup(rest, cut) : cut;
|
|
1320
|
+
chunks.push(rest.slice(0, at));
|
|
1321
|
+
rest = rest.slice(at).replace(/^\n+/, '');
|
|
1092
1322
|
continue;
|
|
1093
1323
|
}
|
|
1094
1324
|
const inner = rest.lastIndexOf('\n', budget - 1);
|
|
@@ -1721,6 +1951,8 @@ const credentialModal = (post, requestId) => ({
|
|
|
1721
1951
|
* those were in it receives only `app_mention` and behaves as it always
|
|
1722
1952
|
* did, which makes the workspace's own grant the switch.
|
|
1723
1953
|
*/
|
|
1954
|
+
/** Uncached author names one `message.read` looks up; the rest keep their id. */
|
|
1955
|
+
const READ_NAME_LOOKUPS = 10;
|
|
1724
1956
|
export const createSlackChannelAdapter = (options) => {
|
|
1725
1957
|
const log = options.log ?? (() => { });
|
|
1726
1958
|
const warn = options.warn ?? (() => { });
|
|
@@ -1934,6 +2166,205 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1934
2166
|
}
|
|
1935
2167
|
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1936
2168
|
};
|
|
2169
|
+
/**
|
|
2170
|
+
* What was said before an agent was mentioned, for the dispatch that
|
|
2171
|
+
* creates its session (`earlier` on the gateway's dispatch). Without it
|
|
2172
|
+
* an agent named partway through a conversation holds one message —
|
|
2173
|
+
* "can you do this?" — and nothing it refers to. In a thread that is the
|
|
2174
|
+
* thread so far: its parent and the newest messages before the mention.
|
|
2175
|
+
* At the top of a channel it is the channel's most recent messages,
|
|
2176
|
+
* which is where a channel-per-client or channel-per-project keeps the
|
|
2177
|
+
* context a mention leans on.
|
|
2178
|
+
*
|
|
2179
|
+
* Each message keeps its own speaker and its own trust, so the session
|
|
2180
|
+
* starts at the least trusted of everyone in it: a principal's mention
|
|
2181
|
+
* cannot carry a stranger's earlier words in under the principal's
|
|
2182
|
+
* label. Under `admit: 'principals'` a sender the operator did not list
|
|
2183
|
+
* gets no place here, as they get none anywhere else. A bot's message —
|
|
2184
|
+
* another agent, an integration — is `unknown`: nothing here can say
|
|
2185
|
+
* what its words were built from. Messages that will not be shown are
|
|
2186
|
+
* dropped before the cap is applied, so they never crowd out ones that
|
|
2187
|
+
* would.
|
|
2188
|
+
*
|
|
2189
|
+
* A read that fails warns and returns nothing: the turn still runs, on
|
|
2190
|
+
* the one message, as it did before this.
|
|
2191
|
+
*/
|
|
2192
|
+
const earlierContext = async (connection, channel, thread, before, senderTrust) => {
|
|
2193
|
+
const usable = (message) => {
|
|
2194
|
+
if (message.ts === undefined || !(Number(message.ts) < Number(before))) {
|
|
2195
|
+
return false;
|
|
2196
|
+
}
|
|
2197
|
+
// The agent's own mention is stripped when the message is shown, so
|
|
2198
|
+
// a bare ping is as empty as no text, and takes no slot.
|
|
2199
|
+
const text = (message.text ?? '').replaceAll(`<@${connection.botUserId}>`, '').trim();
|
|
2200
|
+
if (text.length === 0 && (message.files ?? []).length === 0) {
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
if (message.bot_id !== undefined || message.subtype === 'bot_message') {
|
|
2204
|
+
// Under `admit: 'principals'` an unlisted integration is as kept out
|
|
2205
|
+
// as an unlisted person. This fleet's own agents are not: they are
|
|
2206
|
+
// the conversation the mention joins — an authenticated peer whose
|
|
2207
|
+
// socket failed to start included, since what it said is still its.
|
|
2208
|
+
return admitsSender(connection.config, message.user ?? '')
|
|
2209
|
+
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
|
|
2210
|
+
}
|
|
2211
|
+
return message.user !== undefined
|
|
2212
|
+
&& isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
|
|
2213
|
+
&& admitsSender(connection.config, message.user);
|
|
2214
|
+
};
|
|
2215
|
+
let kept = [];
|
|
2216
|
+
let omitted = 0;
|
|
2217
|
+
let more = false;
|
|
2218
|
+
// The channel read stopped at its page bound short of enough messages.
|
|
2219
|
+
let historyCut = false;
|
|
2220
|
+
let firstOfTail = 0;
|
|
2221
|
+
try {
|
|
2222
|
+
if (thread !== undefined) {
|
|
2223
|
+
const replies = connection.web.conversations.replies;
|
|
2224
|
+
if (!replies) {
|
|
2225
|
+
return [];
|
|
2226
|
+
}
|
|
2227
|
+
// Slack pages a thread oldest first, so the whole thread is walked
|
|
2228
|
+
// and only a bounded tail is held: the newest messages are the
|
|
2229
|
+
// last ones read.
|
|
2230
|
+
let parent;
|
|
2231
|
+
const tail = [];
|
|
2232
|
+
let cursor;
|
|
2233
|
+
let pages = 0;
|
|
2234
|
+
do {
|
|
2235
|
+
const page = await replies.call(connection.web.conversations, {
|
|
2236
|
+
channel,
|
|
2237
|
+
ts: thread,
|
|
2238
|
+
latest: before,
|
|
2239
|
+
inclusive: false,
|
|
2240
|
+
limit: 200,
|
|
2241
|
+
...(cursor ? { cursor } : {}),
|
|
2242
|
+
});
|
|
2243
|
+
pages += 1;
|
|
2244
|
+
for (const message of page.messages ?? []) {
|
|
2245
|
+
if (!usable(message)) {
|
|
2246
|
+
continue;
|
|
2247
|
+
}
|
|
2248
|
+
if (message.ts === thread) {
|
|
2249
|
+
parent = message;
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
tail.push(message);
|
|
2253
|
+
if (tail.length > EARLIER_MESSAGE_LIMIT) {
|
|
2254
|
+
tail.shift();
|
|
2255
|
+
omitted += 1;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
// Slack's cursor is the signal, not `has_more`: a short page can
|
|
2259
|
+
// still have more behind it.
|
|
2260
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2261
|
+
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
2262
|
+
more = cursor !== undefined;
|
|
2263
|
+
if (more) {
|
|
2264
|
+
// Past the read bound the newest replies were never reached, and a
|
|
2265
|
+
// slice from the middle must not pass for them: the parent alone,
|
|
2266
|
+
// and the note says the rest was not read.
|
|
2267
|
+
omitted += tail.length;
|
|
2268
|
+
tail.length = 0;
|
|
2269
|
+
}
|
|
2270
|
+
// The parent takes a slot only when it is shown.
|
|
2271
|
+
while (tail.length > (parent !== undefined ? EARLIER_MESSAGE_LIMIT - 1 : EARLIER_MESSAGE_LIMIT)) {
|
|
2272
|
+
tail.shift();
|
|
2273
|
+
omitted += 1;
|
|
2274
|
+
}
|
|
2275
|
+
kept = parent !== undefined ? [parent, ...tail] : tail;
|
|
2276
|
+
firstOfTail = parent !== undefined ? 1 : 0;
|
|
2277
|
+
}
|
|
2278
|
+
else {
|
|
2279
|
+
const history = connection.web.conversations.history;
|
|
2280
|
+
if (!history) {
|
|
2281
|
+
return [];
|
|
2282
|
+
}
|
|
2283
|
+
// Newest first, paged until enough usable messages are found (joins
|
|
2284
|
+
// and bots' chatter do not count against the limit), then put back
|
|
2285
|
+
// in the order they were said.
|
|
2286
|
+
const recent = [];
|
|
2287
|
+
let cursor;
|
|
2288
|
+
let pages = 0;
|
|
2289
|
+
do {
|
|
2290
|
+
const page = await history.call(connection.web.conversations, {
|
|
2291
|
+
channel,
|
|
2292
|
+
latest: before,
|
|
2293
|
+
inclusive: false,
|
|
2294
|
+
limit: EARLIER_CHANNEL_READ,
|
|
2295
|
+
...(cursor ? { cursor } : {}),
|
|
2296
|
+
});
|
|
2297
|
+
pages += 1;
|
|
2298
|
+
recent.push(...(page.messages ?? []).filter(usable));
|
|
2299
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2300
|
+
} while (cursor && recent.length < EARLIER_CHANNEL_LIMIT && pages < EARLIER_CHANNEL_PAGES);
|
|
2301
|
+
historyCut = cursor !== undefined && recent.length < EARLIER_CHANNEL_LIMIT;
|
|
2302
|
+
kept = recent.slice(0, EARLIER_CHANNEL_LIMIT).reverse();
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
catch (error) {
|
|
2306
|
+
warn(`slack: ${connection.config.agentId} could not read the earlier messages in ${channel}, so it sees only the message that named it: `
|
|
2307
|
+
+ `${error instanceof Error ? error.message : String(error)} (the history scopes are channels:history, groups:history, and mpim:history)`);
|
|
2308
|
+
return [];
|
|
2309
|
+
}
|
|
2310
|
+
const entries = [];
|
|
2311
|
+
for (const [index, message] of kept.entries()) {
|
|
2312
|
+
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
2313
|
+
const text = await humanizeMentions(connection, message.text ?? '');
|
|
2314
|
+
const files = message.files ?? [];
|
|
2315
|
+
const author = bot
|
|
2316
|
+
? message.user === connection.botUserId
|
|
2317
|
+
? 'you'
|
|
2318
|
+
: boundedDisplayName(message.bot_profile?.name ?? message.username ?? 'a bot')
|
|
2319
|
+
: await authorFor(connection, message.user);
|
|
2320
|
+
const reasons = new Map(files.map((file) => [file, 'not-opened']));
|
|
2321
|
+
let note = '';
|
|
2322
|
+
if (thread === undefined && index === 0) {
|
|
2323
|
+
note = historyCut
|
|
2324
|
+
? `[The ${kept.length} messages found in this channel's recent history before you were mentioned, oldest first. Older history was not read; ask for what you need.]\n`
|
|
2325
|
+
: `[The ${kept.length} most recent messages in this channel before you were mentioned, oldest first:]\n`;
|
|
2326
|
+
}
|
|
2327
|
+
else if (thread !== undefined && index === firstOfTail && omitted > 0 && !more) {
|
|
2328
|
+
note = `[${omitted} earlier messages in this thread are not shown.]\n`;
|
|
2329
|
+
}
|
|
2330
|
+
entries.push({
|
|
2331
|
+
message: `${note}${author}: ${text}${attachmentNote(files, reasons)}`,
|
|
2332
|
+
metadata: {
|
|
2333
|
+
channel: 'slack',
|
|
2334
|
+
slackChannel: channel,
|
|
2335
|
+
...(thread !== undefined ? { slackThread: thread } : {}),
|
|
2336
|
+
...(message.user !== undefined ? { slackUser: message.user } : {}),
|
|
2337
|
+
[SENDER_TRUST_METADATA_KEY]: bot ? 'unknown' : senderTrustFor(connection.config, message.user),
|
|
2338
|
+
},
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
if (historyCut && entries.length === 0) {
|
|
2342
|
+
entries.push({
|
|
2343
|
+
message: '[None of this channel\'s recent history before you were mentioned could be shown, and older history was not read. Ask for what you need.]',
|
|
2344
|
+
// The adapter's own words, nobody else's: they carry the trust of
|
|
2345
|
+
// the message they arrive with, and lower nothing.
|
|
2346
|
+
metadata: { channel: 'slack', slackChannel: channel, [SENDER_TRUST_METADATA_KEY]: senderTrust },
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
if (more) {
|
|
2350
|
+
// With no first message to show (unusable, or from someone the room
|
|
2351
|
+
// does not admit), the notice stands alone: an empty backfill would
|
|
2352
|
+
// read as a thread with nothing before the mention. It is the
|
|
2353
|
+
// adapter's own words, so it carries the trust of the message it
|
|
2354
|
+
// arrives with and lowers nothing.
|
|
2355
|
+
const notice = '[This thread is too long to read here: the replies after its first message are not shown. Ask for what you need.]';
|
|
2356
|
+
if (entries.length > 0) {
|
|
2357
|
+
entries[entries.length - 1].message += `\n${notice}`;
|
|
2358
|
+
}
|
|
2359
|
+
else {
|
|
2360
|
+
entries.push({
|
|
2361
|
+
message: notice,
|
|
2362
|
+
metadata: { channel: 'slack', slackChannel: channel, ...(thread !== undefined ? { slackThread: thread } : {}), [SENDER_TRUST_METADATA_KEY]: senderTrust },
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return entries;
|
|
2367
|
+
};
|
|
1937
2368
|
/**
|
|
1938
2369
|
* What kind of room a conversation is and how many are in it, for the
|
|
1939
2370
|
* turn's `conversation` metadata: an agent that did not know told
|
|
@@ -1980,6 +2411,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1980
2411
|
kind,
|
|
1981
2412
|
...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
|
|
1982
2413
|
...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
|
|
2414
|
+
...(kind !== 'direct' && typeof info.id === 'string' ? { id: info.id } : {}),
|
|
1983
2415
|
};
|
|
1984
2416
|
}
|
|
1985
2417
|
catch {
|
|
@@ -2009,9 +2441,108 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2009
2441
|
}
|
|
2010
2442
|
return result.trim();
|
|
2011
2443
|
};
|
|
2444
|
+
/**
|
|
2445
|
+
* Slack's stored `mrkdwn` as the plain text a read promises
|
|
2446
|
+
* (`ConversationMessage.text`): `<@U…>` mentions, `<#C…|name>` channels,
|
|
2447
|
+
* `<!here>`-style broadcasts, `<!date^…|fallback>` dates, and
|
|
2448
|
+
* `<url|label>` links, then the `< > &` escapes, decoded last
|
|
2449
|
+
* so text someone typed is never read as markup. A mention is named only
|
|
2450
|
+
* for a principal or the agent's own app, for the reason
|
|
2451
|
+
* `humanizeMentions` gives; anyone else stays their stable id.
|
|
2452
|
+
*/
|
|
2453
|
+
const nameableMentions = (connection, text) => {
|
|
2454
|
+
const principals = new Set(connection.config.principals ?? []);
|
|
2455
|
+
const ids = new Set();
|
|
2456
|
+
for (const match of text.matchAll(/<@([^|<>]+)(?:\|[^<>]*)?>/g)) {
|
|
2457
|
+
const id = match[1];
|
|
2458
|
+
if (principals.has(id) || id === connection.botUserId) {
|
|
2459
|
+
ids.add(id);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
return [...ids];
|
|
2463
|
+
};
|
|
2464
|
+
/**
|
|
2465
|
+
* `resolved` is the only source of names: the caller has already looked
|
|
2466
|
+
* them up under its own bound (`readConversation`), and a mention it did
|
|
2467
|
+
* not resolve stays an id rather than costing a lookup here.
|
|
2468
|
+
*/
|
|
2469
|
+
const plainSlackText = (connection, text, resolved) => {
|
|
2470
|
+
const names = new Map();
|
|
2471
|
+
for (const id of nameableMentions(connection, text)) {
|
|
2472
|
+
const name = resolved.get(id);
|
|
2473
|
+
if (name !== undefined) {
|
|
2474
|
+
names.set(id, boundedDisplayName(name));
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
return text
|
|
2478
|
+
.replace(/<([^<>]*)>/g, (_whole, inner) => {
|
|
2479
|
+
const bar = inner.indexOf('|');
|
|
2480
|
+
const target = bar === -1 ? inner : inner.slice(0, bar);
|
|
2481
|
+
const label = bar === -1 ? undefined : inner.slice(bar + 1);
|
|
2482
|
+
if (target.startsWith('@')) {
|
|
2483
|
+
const id = target.slice(1);
|
|
2484
|
+
return `@${names.get(id) ?? id}`;
|
|
2485
|
+
}
|
|
2486
|
+
if (target.startsWith('#')) {
|
|
2487
|
+
return `#${label || target.slice(1)}`;
|
|
2488
|
+
}
|
|
2489
|
+
if (target.startsWith('!')) {
|
|
2490
|
+
const command = target.slice(1);
|
|
2491
|
+
if (command.startsWith('subteam^')) {
|
|
2492
|
+
return label || `@${command.slice('subteam^'.length)}`;
|
|
2493
|
+
}
|
|
2494
|
+
if (command.startsWith('date^')) {
|
|
2495
|
+
return label ?? command;
|
|
2496
|
+
}
|
|
2497
|
+
return `@${command}`;
|
|
2498
|
+
}
|
|
2499
|
+
if (label && label !== target) {
|
|
2500
|
+
return `${label} (${target})`;
|
|
2501
|
+
}
|
|
2502
|
+
return target;
|
|
2503
|
+
})
|
|
2504
|
+
.replaceAll('<', '<')
|
|
2505
|
+
.replaceAll('>', '>')
|
|
2506
|
+
.replaceAll('&', '&');
|
|
2507
|
+
};
|
|
2012
2508
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
2013
2509
|
/** An agent's configuration, whether or not its socket came up. */
|
|
2014
2510
|
const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
|
|
2511
|
+
/**
|
|
2512
|
+
* Which agent each home channel belongs to, keyed `team:channel` because
|
|
2513
|
+
* a channel id is unique only within its workspace. Settled at start,
|
|
2514
|
+
* once every app has authenticated and its team is known. A channel two
|
|
2515
|
+
* agents in one workspace both call home belongs to neither: two agents
|
|
2516
|
+
* answering every message there is the double reply the thread rules
|
|
2517
|
+
* exist to prevent, and picking one would hang on an order the config
|
|
2518
|
+
* file does not reliably carry (JSON objects enumerate integer-like keys
|
|
2519
|
+
* numerically). Both still answer there when mentioned, and a startup
|
|
2520
|
+
* warning says so.
|
|
2521
|
+
*/
|
|
2522
|
+
const homeOwners = new Map();
|
|
2523
|
+
const settleHomeOwners = () => {
|
|
2524
|
+
homeOwners.clear();
|
|
2525
|
+
const claims = new Map();
|
|
2526
|
+
for (const agent of options.agents) {
|
|
2527
|
+
const teamId = botIdentities.get(agent.agentId)?.teamId;
|
|
2528
|
+
if (teamId === undefined) {
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
for (const channelId of agent.homeChannels ?? []) {
|
|
2532
|
+
const key = `${teamId}:${channelId}`;
|
|
2533
|
+
claims.set(key, [...(claims.get(key) ?? []), agent.agentId]);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
for (const [key, claimants] of claims) {
|
|
2537
|
+
const agentIds = [...new Set(claimants)];
|
|
2538
|
+
if (agentIds.length === 1) {
|
|
2539
|
+
homeOwners.set(key, agentIds[0]);
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
const channelId = key.slice(key.indexOf(':') + 1);
|
|
2543
|
+
warn(`slack: ${channelId} is a home channel for ${agentIds.join(' and ')}; a channel can be home to one agent, so none of them answers there unless mentioned.`);
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2015
2546
|
/**
|
|
2016
2547
|
* How an agent listens, from its soul — read per message, so a roster
|
|
2017
2548
|
* reload takes effect on the next one. `thread` when the soul says
|
|
@@ -2153,7 +2684,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2153
2684
|
*/
|
|
2154
2685
|
const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
|
|
2155
2686
|
const gateway = gatewayRef;
|
|
2156
|
-
if (!gateway?.observe || reply.trim().length === 0) {
|
|
2687
|
+
if (!gateway?.observe || (typeof reply === 'string' && reply.trim().length === 0)) {
|
|
2157
2688
|
return;
|
|
2158
2689
|
}
|
|
2159
2690
|
const team = speaker.teamId;
|
|
@@ -2163,7 +2694,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2163
2694
|
slackChannel: channel,
|
|
2164
2695
|
slackUser: speaker.botUserId,
|
|
2165
2696
|
slackThread: thread,
|
|
2166
|
-
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
|
|
2167
2697
|
};
|
|
2168
2698
|
// In the reply's own workspace only, because a session key carries a
|
|
2169
2699
|
// team — the same reason `resolveFollowUpWinner` stays in-workspace.
|
|
@@ -2181,6 +2711,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2181
2711
|
if (!(await published)) {
|
|
2182
2712
|
return { observed: undefined };
|
|
2183
2713
|
}
|
|
2714
|
+
// Read now that the posts have landed: what the turn said along
|
|
2715
|
+
// the way is known only once they have.
|
|
2716
|
+
const text = typeof reply === 'string' ? reply : reply();
|
|
2717
|
+
if (text.trim().length === 0) {
|
|
2718
|
+
return { observed: undefined };
|
|
2719
|
+
}
|
|
2720
|
+
const trust = sessionWriteTrust(typeof spoken === 'function' ? await spoken() : spoken);
|
|
2184
2721
|
let member;
|
|
2185
2722
|
try {
|
|
2186
2723
|
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
@@ -2195,7 +2732,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2195
2732
|
// spoke, by display name. A bot user's is the app's own, set by
|
|
2196
2733
|
// whoever installed it.
|
|
2197
2734
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2198
|
-
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${
|
|
2735
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
|
|
2199
2736
|
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
2200
2737
|
});
|
|
2201
2738
|
await observed;
|
|
@@ -2247,12 +2784,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2247
2784
|
}
|
|
2248
2785
|
const channelId = conversation.id;
|
|
2249
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;
|
|
2250
2790
|
return {
|
|
2251
2791
|
async post(text) {
|
|
2252
2792
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
2253
2793
|
let first;
|
|
2254
2794
|
for (const chunk of chunks) {
|
|
2255
|
-
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 } : {}) });
|
|
2256
2796
|
if (!first) {
|
|
2257
2797
|
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
2258
2798
|
}
|
|
@@ -2275,6 +2815,130 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2275
2815
|
},
|
|
2276
2816
|
};
|
|
2277
2817
|
};
|
|
2818
|
+
/**
|
|
2819
|
+
* The read side of the same boundary `resolveOutbound` draws: a channel
|
|
2820
|
+
* the agent's own app is a member of, public or private. DMs and group
|
|
2821
|
+
* DMs are refused outright, member or not. Their only members are the
|
|
2822
|
+
* people in them, and a read from a conversation elsewhere — a public
|
|
2823
|
+
* thread above all — would carry what they said to people who were never
|
|
2824
|
+
* in the room. A thread in a channel is fine for the same reason the
|
|
2825
|
+
* channel is.
|
|
2826
|
+
*/
|
|
2827
|
+
const readConversation = async (request) => {
|
|
2828
|
+
const channel = request.conversation.trim();
|
|
2829
|
+
if (!channel) {
|
|
2830
|
+
throw new Error('slack: a read needs a channel id (C…/G…).');
|
|
2831
|
+
}
|
|
2832
|
+
const connection = connectionFor(request.agentId);
|
|
2833
|
+
if (!connection) {
|
|
2834
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
2835
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so ${channel} cannot be read.`
|
|
2836
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot read Slack at all.`);
|
|
2837
|
+
}
|
|
2838
|
+
let info;
|
|
2839
|
+
try {
|
|
2840
|
+
info = (await connection.web.conversations.info({ channel })).channel;
|
|
2841
|
+
}
|
|
2842
|
+
catch (error) {
|
|
2843
|
+
throw new Error(`slack: ${request.agentId}'s app cannot see ${channel} (${error instanceof Error ? error.message : String(error)}). `
|
|
2844
|
+
+ 'Use the channel id, not a name.');
|
|
2845
|
+
}
|
|
2846
|
+
if (!info?.id) {
|
|
2847
|
+
throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
|
|
2848
|
+
}
|
|
2849
|
+
if (info.is_im === true || info.is_mpim === true) {
|
|
2850
|
+
throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them.`);
|
|
2851
|
+
}
|
|
2852
|
+
if (info.is_member !== true) {
|
|
2853
|
+
throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
|
|
2854
|
+
}
|
|
2855
|
+
const { history, replies } = connection.web.conversations;
|
|
2856
|
+
const fetchPage = request.thread !== undefined
|
|
2857
|
+
? (replies ? (args) => replies({ ...args, ts: request.thread }) : undefined)
|
|
2858
|
+
: history;
|
|
2859
|
+
if (!fetchPage) {
|
|
2860
|
+
throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
|
|
2861
|
+
}
|
|
2862
|
+
const base = {
|
|
2863
|
+
channel: info.id,
|
|
2864
|
+
inclusive: false,
|
|
2865
|
+
...(request.after !== undefined ? { oldest: request.after } : {}),
|
|
2866
|
+
...(request.before !== undefined ? { latest: request.before } : {}),
|
|
2867
|
+
};
|
|
2868
|
+
const raw = [];
|
|
2869
|
+
let cursor;
|
|
2870
|
+
let more = false;
|
|
2871
|
+
do {
|
|
2872
|
+
const page = await fetchPage({ ...base, limit: Math.min(200, request.limit - raw.length), ...(cursor ? { cursor } : {}) });
|
|
2873
|
+
raw.push(...(page.messages ?? []));
|
|
2874
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2875
|
+
more = cursor !== undefined || page.has_more === true;
|
|
2876
|
+
} while (cursor && raw.length < request.limit);
|
|
2877
|
+
if (raw.length > request.limit) {
|
|
2878
|
+
raw.length = request.limit;
|
|
2879
|
+
more = true;
|
|
2880
|
+
}
|
|
2881
|
+
// History rarely embeds profiles, so names mostly come from users.info.
|
|
2882
|
+
// Cached names are free; at most READ_NAME_LOOKUPS uncached people —
|
|
2883
|
+
// authors and the mentions in their text together — are looked up per
|
|
2884
|
+
// read, at once, and the rest keep their id, so a read of many strangers
|
|
2885
|
+
// never waits through a rate limit one call at a time.
|
|
2886
|
+
const lookups = new Map();
|
|
2887
|
+
let fresh = 0;
|
|
2888
|
+
const want = (user) => {
|
|
2889
|
+
if (lookups.has(user)) {
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const cached = displayNames.get(`${connection.teamId}:${user}`);
|
|
2893
|
+
if (cached) {
|
|
2894
|
+
lookups.set(user, Promise.resolve(cached));
|
|
2895
|
+
}
|
|
2896
|
+
else if (fresh < READ_NAME_LOOKUPS) {
|
|
2897
|
+
fresh += 1;
|
|
2898
|
+
lookups.set(user, displayNameFor(connection, user));
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
for (const message of raw) {
|
|
2902
|
+
const user = message.user;
|
|
2903
|
+
if (user && !message.user_profile?.display_name && !message.user_profile?.real_name) {
|
|
2904
|
+
want(user);
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
for (const message of raw) {
|
|
2908
|
+
for (const id of nameableMentions(connection, message.text ?? '')) {
|
|
2909
|
+
want(id);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
const names = new Map();
|
|
2913
|
+
for (const [user, name] of lookups) {
|
|
2914
|
+
names.set(user, await name);
|
|
2915
|
+
}
|
|
2916
|
+
const messages = [];
|
|
2917
|
+
for (const message of raw) {
|
|
2918
|
+
if (!message.ts) {
|
|
2919
|
+
continue;
|
|
2920
|
+
}
|
|
2921
|
+
const author = message.user ?? message.bot_id ?? 'unknown';
|
|
2922
|
+
const name = message.user_profile?.display_name
|
|
2923
|
+
|| message.user_profile?.real_name
|
|
2924
|
+
|| message.bot_profile?.name
|
|
2925
|
+
|| message.username
|
|
2926
|
+
|| (message.user ? names.get(message.user) : undefined);
|
|
2927
|
+
const at = Number.parseFloat(message.ts);
|
|
2928
|
+
const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
|
|
2929
|
+
messages.push({
|
|
2930
|
+
id: message.ts,
|
|
2931
|
+
author,
|
|
2932
|
+
text: plainSlackText(connection, message.text ?? '', names),
|
|
2933
|
+
...(name && name !== author ? { authorName: boundedDisplayName(name) } : {}),
|
|
2934
|
+
...(Number.isFinite(at) ? { at: new Date(at * 1000).toISOString() } : {}),
|
|
2935
|
+
...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
|
|
2936
|
+
...(message.reply_count ? { replies: message.reply_count } : {}),
|
|
2937
|
+
...(files.length > 0 ? { files } : {}),
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
return { messages, more };
|
|
2941
|
+
};
|
|
2278
2942
|
/**
|
|
2279
2943
|
* Renders one parked call as buttons and remembers where it went.
|
|
2280
2944
|
*
|
|
@@ -3251,6 +3915,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3251
3915
|
}
|
|
3252
3916
|
const first = engaged[0];
|
|
3253
3917
|
if (!first) {
|
|
3918
|
+
// No agent has a session in this thread, but one may have STARTED
|
|
3919
|
+
// it: a post made through message.send or a schedule's delivery
|
|
3920
|
+
// lands at the top level from some other session, so nothing ties
|
|
3921
|
+
// the thread under it to the agent that wrote it. A reply there is
|
|
3922
|
+
// a reply to that agent. Read off the event, so every connection
|
|
3923
|
+
// reaches the same answer, and after a restart too. Looked up among
|
|
3924
|
+
// authenticated identities, not live sockets: they are all learned
|
|
3925
|
+
// before any socket starts, so an agent whose socket is still coming
|
|
3926
|
+
// up is not judged absent and the memoized verdict cannot drop the
|
|
3927
|
+
// reply when its own socket delivers it.
|
|
3928
|
+
if (parts.parentUser === undefined) {
|
|
3929
|
+
return undefined;
|
|
3930
|
+
}
|
|
3931
|
+
for (const [agentId, identity] of botIdentities) {
|
|
3932
|
+
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
3933
|
+
return agentId;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3254
3936
|
return undefined;
|
|
3255
3937
|
}
|
|
3256
3938
|
if (engaged.length === 1) {
|
|
@@ -3372,7 +4054,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3372
4054
|
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
3373
4055
|
: undefined;
|
|
3374
4056
|
const text = event.text ?? '';
|
|
3375
|
-
const
|
|
4057
|
+
const mentioned = isDm || mentions(text, connection.botUserId);
|
|
3376
4058
|
// Who this message hands the thread to — worked out by EVERY connection
|
|
3377
4059
|
// that sees it, not only by the one being named, and recorded before any
|
|
3378
4060
|
// of the rejections below. Each agent is its own Socket Mode connection,
|
|
@@ -3395,6 +4077,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3395
4077
|
// no policy, and nobody's to refuse here, so it admits.
|
|
3396
4078
|
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
3397
4079
|
const named = handoverTo ?? agentNamedIn(text, team);
|
|
4080
|
+
// A new top-level message in this agent's home channel is addressed to
|
|
4081
|
+
// it as surely as a mention would be — unless it names somebody else,
|
|
4082
|
+
// which hands it to them exactly as it would anywhere. Top-level only:
|
|
4083
|
+
// inside a thread the soul's `listens` already says what an untagged
|
|
4084
|
+
// reply is, and the home channel does not overrule it.
|
|
4085
|
+
const homeOwner = !isDm && event.thread_ts === undefined && named === undefined
|
|
4086
|
+
? homeOwners.get(`${team}:${event.channel}`)
|
|
4087
|
+
: undefined;
|
|
4088
|
+
const addressed = mentioned || homeOwner === connection.config.agentId;
|
|
3398
4089
|
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
3399
4090
|
// it; see the dedupe below for why it is not the event id.
|
|
3400
4091
|
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
@@ -3409,6 +4100,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3409
4100
|
if (threadKey && handoverTo) {
|
|
3410
4101
|
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
3411
4102
|
}
|
|
4103
|
+
// A home-channel message hands its new thread to the channel's owner
|
|
4104
|
+
// the way a mention would, and is recorded by every socket for the same
|
|
4105
|
+
// reason: a reply in that thread can reach another agent's socket before
|
|
4106
|
+
// the owner's first turn has written any session routing, and without
|
|
4107
|
+
// this record every agent would stand down and the reply would be lost.
|
|
4108
|
+
// On the owner's admission, as a handover is.
|
|
4109
|
+
if (threadKey && homeOwner !== undefined && admitsSender(agentConfigFor(homeOwner), sender)) {
|
|
4110
|
+
rememberAddressee(threadKey, homeOwner, event.ts);
|
|
4111
|
+
}
|
|
3412
4112
|
// Who may speak at all, judged before anything below remembers this
|
|
3413
4113
|
// message. The adapter's own checks establish nothing about who is
|
|
3414
4114
|
// typing; under `admit: 'principals'` the operator's list is the door,
|
|
@@ -3448,7 +4148,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3448
4148
|
// place in that queue, and the line is an audit, not a turn; `track`
|
|
3449
4149
|
// holds it so `stop()` drains it like any other background work.
|
|
3450
4150
|
const thread = event.thread_ts;
|
|
3451
|
-
track(followUpWinner({
|
|
4151
|
+
track(followUpWinner({
|
|
4152
|
+
team,
|
|
4153
|
+
conversation: event.channel,
|
|
4154
|
+
thread,
|
|
4155
|
+
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4156
|
+
}, event.ts).then((winner) => {
|
|
3452
4157
|
if (winner === connection.config.agentId) {
|
|
3453
4158
|
log(refusal);
|
|
3454
4159
|
}
|
|
@@ -3627,7 +4332,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3627
4332
|
if (!admitted.settled) {
|
|
3628
4333
|
// One verdict for this message, shared with whichever other agents
|
|
3629
4334
|
// are asking about it — see `followUpWinner`.
|
|
3630
|
-
const winner = await followUpWinner({
|
|
4335
|
+
const winner = await followUpWinner({
|
|
4336
|
+
team,
|
|
4337
|
+
conversation: event.channel,
|
|
4338
|
+
thread: event.thread_ts ?? '',
|
|
4339
|
+
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4340
|
+
}, event.ts);
|
|
3631
4341
|
if (winner !== connection.config.agentId) {
|
|
3632
4342
|
// Not this agent's to answer — and, if it is in the thread,
|
|
3633
4343
|
// this agent's to hear, same as the two warm stand-downs.
|
|
@@ -3735,6 +4445,29 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3735
4445
|
// if it ever has any — see `ReplyRenderer.lazy`. One the agent's
|
|
3736
4446
|
// reply mode does not stream opens none at all, and shows the
|
|
3737
4447
|
// loading status instead — see `ReplyRenderer.streaming`.
|
|
4448
|
+
// Mentioned into a thread partway: hand the new session what was
|
|
4449
|
+
// said before this. Inside the chain, before the placeholder, so the
|
|
4450
|
+
// read cannot reorder this message against the next one.
|
|
4451
|
+
let earlier = [];
|
|
4452
|
+
if (!isDm && thread !== undefined) {
|
|
4453
|
+
// A top-level mention roots its own session, so it is new but for
|
|
4454
|
+
// a redelivery, which the gateway answers without reading this.
|
|
4455
|
+
let exists = false;
|
|
4456
|
+
if (thread !== event.ts && gateway.sessionRouting) {
|
|
4457
|
+
try {
|
|
4458
|
+
exists = (await gateway.sessionRouting(sessionId)) !== undefined;
|
|
4459
|
+
}
|
|
4460
|
+
catch {
|
|
4461
|
+
// Unknown is read as new: a session that does exist ignores
|
|
4462
|
+
// the list, so the cost of guessing wrong is one API read.
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
if (!exists) {
|
|
4466
|
+
// A mention that starts its own thread follows the channel; one
|
|
4467
|
+
// inside a thread follows that thread.
|
|
4468
|
+
earlier = await earlierContext(connection, event.channel, thread === event.ts ? undefined : thread, event.ts, senderTrust);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
3738
4471
|
const streaming = connection.config.replies === 'stream';
|
|
3739
4472
|
const ahead = streaming ? undefined : replyOrder.get(sessionId);
|
|
3740
4473
|
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
|
|
@@ -3745,6 +4478,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3745
4478
|
statusWarned: connection.statusWarned,
|
|
3746
4479
|
...(ahead ? { after: ahead } : {}),
|
|
3747
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
|
+
: {}),
|
|
3748
4500
|
});
|
|
3749
4501
|
if (!streaming) {
|
|
3750
4502
|
replyOrder.set(sessionId, renderer.posted);
|
|
@@ -3782,6 +4534,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3782
4534
|
userMessage,
|
|
3783
4535
|
...(images.length > 0 ? { images } : {}),
|
|
3784
4536
|
...(judged ? { addressed: false } : {}),
|
|
4537
|
+
...(earlier.length > 0 ? { earlier } : {}),
|
|
3785
4538
|
turnId: renderer.turnId,
|
|
3786
4539
|
metadata,
|
|
3787
4540
|
// A redelivery this process no longer remembers (it restarted, or
|
|
@@ -3902,8 +4655,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3902
4655
|
// rule with the gateway's `sessionRouting`, which posts the same
|
|
3903
4656
|
// message for a turn this adapter did not start.
|
|
3904
4657
|
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
|
|
3905
|
-
|
|
3906
|
-
|
|
4658
|
+
// What the turn said along the way as well as its reply: interim
|
|
4659
|
+
// messages are in the thread, so they are in what the others hear.
|
|
4660
|
+
// Heard once the posts have settled, and the reply only if it landed:
|
|
4661
|
+
// interim messages already in the thread are heard either way.
|
|
4662
|
+
let replyLanded = false;
|
|
4663
|
+
const settled = finalized.then((outcome) => {
|
|
4664
|
+
replyLanded = outcome.published;
|
|
4665
|
+
return true;
|
|
4666
|
+
}, () => true);
|
|
4667
|
+
const heard = thread !== undefined
|
|
4668
|
+
? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
|
|
3907
4669
|
: undefined;
|
|
3908
4670
|
const { spoke, spokeAt } = await finalized;
|
|
3909
4671
|
if (spoke && threadKey !== undefined) {
|
|
@@ -3938,6 +4700,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3938
4700
|
return {
|
|
3939
4701
|
name: 'slack',
|
|
3940
4702
|
resolveOutbound,
|
|
4703
|
+
readConversation,
|
|
3941
4704
|
requestCredential,
|
|
3942
4705
|
async start(gateway) {
|
|
3943
4706
|
gatewayRef = gateway;
|
|
@@ -4072,6 +4835,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4072
4835
|
const auth = await web.auth.test();
|
|
4073
4836
|
const botUserId = auth.user_id ?? '';
|
|
4074
4837
|
const teamId = auth.team_id ?? '';
|
|
4838
|
+
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
4075
4839
|
// Recorded before the socket is even built: an app that never
|
|
4076
4840
|
// comes up must still be recognizable when somebody names it.
|
|
4077
4841
|
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
@@ -4089,6 +4853,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4089
4853
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4090
4854
|
}
|
|
4091
4855
|
}
|
|
4856
|
+
settleHomeOwners();
|
|
4092
4857
|
for (const connection of authenticated) {
|
|
4093
4858
|
const { config, socket } = connection;
|
|
4094
4859
|
try {
|
|
@@ -4153,6 +4918,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4153
4918
|
coldVerdicts.clear();
|
|
4154
4919
|
pendingObserves.clear();
|
|
4155
4920
|
botIdentities.clear();
|
|
4921
|
+
homeOwners.clear();
|
|
4156
4922
|
rendering.clear();
|
|
4157
4923
|
resolvedWhileRendering.clear();
|
|
4158
4924
|
credentialsPosting.clear();
|