@stratusagent/channel-slack 0.11.7 → 0.11.9
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 +42 -8
- package/dist/index.d.ts +68 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +662 -10
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -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);
|
|
@@ -254,6 +271,20 @@ class ReplyRenderer {
|
|
|
254
271
|
*/
|
|
255
272
|
turnId = randomUUID();
|
|
256
273
|
buffer = '';
|
|
274
|
+
/**
|
|
275
|
+
* In `final` mode, what the model has said since its last tool call. A
|
|
276
|
+
* turn that writes "On it, checking the PRs" and then works for an hour
|
|
277
|
+
* should not leave the thread showing only a loading status: text the
|
|
278
|
+
* model wrote before calling a tool is posted when the call starts, as a
|
|
279
|
+
* message of its own, and the finished reply follows it.
|
|
280
|
+
*/
|
|
281
|
+
interim = '';
|
|
282
|
+
/** The last interim message posted, so a reply that repeats it is not posted twice. */
|
|
283
|
+
lastInterim;
|
|
284
|
+
/** Whether any interim message landed — something said, whatever the reply's fate. */
|
|
285
|
+
interimPosted = false;
|
|
286
|
+
/** Every interim message that landed, in order, for the thread's other agents to hear. */
|
|
287
|
+
interimSaid = [];
|
|
257
288
|
turnBreakPending = false;
|
|
258
289
|
toolLine;
|
|
259
290
|
ref;
|
|
@@ -466,6 +497,7 @@ class ReplyRenderer {
|
|
|
466
497
|
this.pendingEdit = undefined;
|
|
467
498
|
}
|
|
468
499
|
this.buffer = '';
|
|
500
|
+
this.interim = '';
|
|
469
501
|
this.toolLine = undefined;
|
|
470
502
|
this.runningTool = undefined;
|
|
471
503
|
this.turnBreakPending = false;
|
|
@@ -502,6 +534,7 @@ class ReplyRenderer {
|
|
|
502
534
|
}
|
|
503
535
|
this.turnBreakPending = false;
|
|
504
536
|
this.buffer += event.delta.text;
|
|
537
|
+
this.interim += event.delta.text;
|
|
505
538
|
this.scheduleEdit();
|
|
506
539
|
return;
|
|
507
540
|
}
|
|
@@ -510,12 +543,22 @@ class ReplyRenderer {
|
|
|
510
543
|
// mid-stream failure): discard everything streamed so far so two
|
|
511
544
|
// attempts never fuse into one message.
|
|
512
545
|
this.buffer = '';
|
|
546
|
+
this.interim = '';
|
|
513
547
|
this.turnBreakPending = false;
|
|
514
548
|
this.scheduleEdit();
|
|
515
549
|
return;
|
|
516
550
|
}
|
|
517
551
|
if (event.type === 'provider.response') {
|
|
518
552
|
this.turnBreakPending = true;
|
|
553
|
+
// A provider that answers in one piece streams no deltas: the
|
|
554
|
+
// response is the only place its words before a tool call appear.
|
|
555
|
+
// One that streamed them has them already, and is not given them twice.
|
|
556
|
+
if (this.interim.trim().length === 0) {
|
|
557
|
+
this.interim = event.parts
|
|
558
|
+
.filter((part) => part.type === 'text')
|
|
559
|
+
.map((part) => part.text)
|
|
560
|
+
.join('');
|
|
561
|
+
}
|
|
519
562
|
return;
|
|
520
563
|
}
|
|
521
564
|
if (event.type === 'tool.called') {
|
|
@@ -527,6 +570,7 @@ class ReplyRenderer {
|
|
|
527
570
|
this.turnBreakPending = true;
|
|
528
571
|
this.toolLine = `⚙ ${event.call.toolName}…`;
|
|
529
572
|
this.runningTool = event.call.toolName;
|
|
573
|
+
this.flushInterim(ofThisTurn);
|
|
530
574
|
this.scheduleEdit();
|
|
531
575
|
return;
|
|
532
576
|
}
|
|
@@ -538,12 +582,18 @@ class ReplyRenderer {
|
|
|
538
582
|
this.toolLine = undefined;
|
|
539
583
|
this.runningTool = undefined;
|
|
540
584
|
this.turnBreakPending = true;
|
|
585
|
+
// Words written before a call that was refused were still said
|
|
586
|
+
// before a tool, and must not run into the next response's.
|
|
587
|
+
this.flushInterim(ofThisTurn);
|
|
541
588
|
this.scheduleEdit();
|
|
542
589
|
return;
|
|
543
590
|
}
|
|
544
591
|
if (event.type === 'tool.completed') {
|
|
545
592
|
this.toolLine = undefined;
|
|
546
593
|
this.runningTool = undefined;
|
|
594
|
+
// Settled here too for a call rejected before it ran, which never
|
|
595
|
+
// reached `tool.called`; after one that did, this is empty.
|
|
596
|
+
this.flushInterim(ofThisTurn);
|
|
547
597
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
548
598
|
// execution settles as tool.completed without ever having emitted
|
|
549
599
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -623,6 +673,68 @@ class ReplyRenderer {
|
|
|
623
673
|
})
|
|
624
674
|
.catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
|
|
625
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Post what the model said before the tool it is now calling, in `final`
|
|
678
|
+
* mode only: a streaming turn already shows it in its placeholder, and a
|
|
679
|
+
* turn nobody asked for must not speak before it decides to. Behind the
|
|
680
|
+
* turn ahead and any handover, on the upload chain, so interim text,
|
|
681
|
+
* files, and the reply land in the order they happened.
|
|
682
|
+
*/
|
|
683
|
+
flushInterim(ofThisTurn) {
|
|
684
|
+
const text = this.interim.trim();
|
|
685
|
+
this.interim = '';
|
|
686
|
+
if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const handover = this.handover;
|
|
690
|
+
const after = this.after;
|
|
691
|
+
this.uploadChain = this.uploadChain
|
|
692
|
+
.then(() => handover)
|
|
693
|
+
.then(() => after)
|
|
694
|
+
.then(async () => {
|
|
695
|
+
for (const chunk of messageChunks(text)) {
|
|
696
|
+
const posted = await this.web.chat.postMessage({
|
|
697
|
+
channel: this.channel,
|
|
698
|
+
text: chunk,
|
|
699
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
700
|
+
});
|
|
701
|
+
this.placeText(posted.ts);
|
|
702
|
+
}
|
|
703
|
+
this.interimPosted = true;
|
|
704
|
+
this.lastInterim = text;
|
|
705
|
+
this.interimSaid.push(text);
|
|
706
|
+
// A post of the app's in this thread takes the status down while
|
|
707
|
+
// the turn is still working; put it back.
|
|
708
|
+
this.refreshLoading();
|
|
709
|
+
})
|
|
710
|
+
.catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`));
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Everything this turn said in the thread, for its other agents to hear:
|
|
714
|
+
* the interim messages that landed, then the reply unless it only
|
|
715
|
+
* repeats the last of them. Read once the turn's posts have landed.
|
|
716
|
+
*/
|
|
717
|
+
spokenText(reply) {
|
|
718
|
+
const rest = this.unsaid(reply);
|
|
719
|
+
return [...this.interimSaid, ...(rest.trim().length > 0 ? [rest] : [])].join('\n\n');
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* The part of a reply not already posted as interim messages. A provider
|
|
723
|
+
* that hosts its own loop (codex) returns every message of the turn
|
|
724
|
+
* joined into one reply, so the interim messages are stripped from its
|
|
725
|
+
* front in order; one whose last words came before its final tool call
|
|
726
|
+
* returns just those words, which were posted already.
|
|
727
|
+
*/
|
|
728
|
+
unsaid(reply) {
|
|
729
|
+
let rest = reply.trim();
|
|
730
|
+
for (const said of this.interimSaid) {
|
|
731
|
+
if (!rest.startsWith(said)) {
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
rest = rest.slice(said.length).trimStart();
|
|
735
|
+
}
|
|
736
|
+
return rest === this.lastInterim ? '' : rest;
|
|
737
|
+
}
|
|
626
738
|
currentText() {
|
|
627
739
|
const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
|
|
628
740
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
@@ -755,6 +867,22 @@ class ReplyRenderer {
|
|
|
755
867
|
await this.uploadChain;
|
|
756
868
|
return this.outcome(false, this.uploaded);
|
|
757
869
|
}
|
|
870
|
+
// Interim messages land before the reply is judged: one that already
|
|
871
|
+
// said what the reply says (the model's last words came before a
|
|
872
|
+
// tool call) is not posted again, and a turn that said things along
|
|
873
|
+
// the way is not followed by `(no reply)`.
|
|
874
|
+
await this.uploadChain;
|
|
875
|
+
if (this.interimPosted && !this.ref) {
|
|
876
|
+
reply = this.unsaid(reply);
|
|
877
|
+
}
|
|
878
|
+
if (!this.ref && this.interimPosted && reply.trim().length === 0) {
|
|
879
|
+
// Nothing posts after this to take the status down, so it is cleared.
|
|
880
|
+
if (hadStatus) {
|
|
881
|
+
this.setStatus('');
|
|
882
|
+
await this.statusChain;
|
|
883
|
+
}
|
|
884
|
+
return this.outcome(true, true);
|
|
885
|
+
}
|
|
758
886
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
759
887
|
const chunks = messageChunks(text);
|
|
760
888
|
// No placeholder — a handover could not open a fresh one — and the
|
|
@@ -793,9 +921,12 @@ class ReplyRenderer {
|
|
|
793
921
|
this.placeText(this.ref?.ts);
|
|
794
922
|
}
|
|
795
923
|
}
|
|
796
|
-
// A reply posted
|
|
797
|
-
// leave "is thinking…" standing over a turn
|
|
798
|
-
|
|
924
|
+
// A reply posted into the status's thread clears the status itself;
|
|
925
|
+
// one Slack refused would leave "is thinking…" standing over a turn
|
|
926
|
+
// that has ended. So would one posted anywhere else: a top-level DM
|
|
927
|
+
// reply keys its status to the message it answers (`statusThread`),
|
|
928
|
+
// but posts beside it rather than under it, and Slack never clears it.
|
|
929
|
+
if (hadStatus && (!landed || this.threadTs !== this.statusThread)) {
|
|
799
930
|
this.setStatus('');
|
|
800
931
|
// Awaited: the turn queued behind this one re-shows its own status
|
|
801
932
|
// once this renderer is done, and a clear that reached Slack after
|
|
@@ -1718,6 +1849,8 @@ const credentialModal = (post, requestId) => ({
|
|
|
1718
1849
|
* those were in it receives only `app_mention` and behaves as it always
|
|
1719
1850
|
* did, which makes the workspace's own grant the switch.
|
|
1720
1851
|
*/
|
|
1852
|
+
/** Uncached author names one `message.read` looks up; the rest keep their id. */
|
|
1853
|
+
const READ_NAME_LOOKUPS = 10;
|
|
1721
1854
|
export const createSlackChannelAdapter = (options) => {
|
|
1722
1855
|
const log = options.log ?? (() => { });
|
|
1723
1856
|
const warn = options.warn ?? (() => { });
|
|
@@ -1931,6 +2064,205 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1931
2064
|
}
|
|
1932
2065
|
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1933
2066
|
};
|
|
2067
|
+
/**
|
|
2068
|
+
* What was said before an agent was mentioned, for the dispatch that
|
|
2069
|
+
* creates its session (`earlier` on the gateway's dispatch). Without it
|
|
2070
|
+
* an agent named partway through a conversation holds one message —
|
|
2071
|
+
* "can you do this?" — and nothing it refers to. In a thread that is the
|
|
2072
|
+
* thread so far: its parent and the newest messages before the mention.
|
|
2073
|
+
* At the top of a channel it is the channel's most recent messages,
|
|
2074
|
+
* which is where a channel-per-client or channel-per-project keeps the
|
|
2075
|
+
* context a mention leans on.
|
|
2076
|
+
*
|
|
2077
|
+
* Each message keeps its own speaker and its own trust, so the session
|
|
2078
|
+
* starts at the least trusted of everyone in it: a principal's mention
|
|
2079
|
+
* cannot carry a stranger's earlier words in under the principal's
|
|
2080
|
+
* label. Under `admit: 'principals'` a sender the operator did not list
|
|
2081
|
+
* gets no place here, as they get none anywhere else. A bot's message —
|
|
2082
|
+
* another agent, an integration — is `unknown`: nothing here can say
|
|
2083
|
+
* what its words were built from. Messages that will not be shown are
|
|
2084
|
+
* dropped before the cap is applied, so they never crowd out ones that
|
|
2085
|
+
* would.
|
|
2086
|
+
*
|
|
2087
|
+
* A read that fails warns and returns nothing: the turn still runs, on
|
|
2088
|
+
* the one message, as it did before this.
|
|
2089
|
+
*/
|
|
2090
|
+
const earlierContext = async (connection, channel, thread, before, senderTrust) => {
|
|
2091
|
+
const usable = (message) => {
|
|
2092
|
+
if (message.ts === undefined || !(Number(message.ts) < Number(before))) {
|
|
2093
|
+
return false;
|
|
2094
|
+
}
|
|
2095
|
+
// The agent's own mention is stripped when the message is shown, so
|
|
2096
|
+
// a bare ping is as empty as no text, and takes no slot.
|
|
2097
|
+
const text = (message.text ?? '').replaceAll(`<@${connection.botUserId}>`, '').trim();
|
|
2098
|
+
if (text.length === 0 && (message.files ?? []).length === 0) {
|
|
2099
|
+
return false;
|
|
2100
|
+
}
|
|
2101
|
+
if (message.bot_id !== undefined || message.subtype === 'bot_message') {
|
|
2102
|
+
// Under `admit: 'principals'` an unlisted integration is as kept out
|
|
2103
|
+
// as an unlisted person. This fleet's own agents are not: they are
|
|
2104
|
+
// the conversation the mention joins — an authenticated peer whose
|
|
2105
|
+
// socket failed to start included, since what it said is still its.
|
|
2106
|
+
return admitsSender(connection.config, message.user ?? '')
|
|
2107
|
+
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
|
|
2108
|
+
}
|
|
2109
|
+
return message.user !== undefined
|
|
2110
|
+
&& isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
|
|
2111
|
+
&& admitsSender(connection.config, message.user);
|
|
2112
|
+
};
|
|
2113
|
+
let kept = [];
|
|
2114
|
+
let omitted = 0;
|
|
2115
|
+
let more = false;
|
|
2116
|
+
// The channel read stopped at its page bound short of enough messages.
|
|
2117
|
+
let historyCut = false;
|
|
2118
|
+
let firstOfTail = 0;
|
|
2119
|
+
try {
|
|
2120
|
+
if (thread !== undefined) {
|
|
2121
|
+
const replies = connection.web.conversations.replies;
|
|
2122
|
+
if (!replies) {
|
|
2123
|
+
return [];
|
|
2124
|
+
}
|
|
2125
|
+
// Slack pages a thread oldest first, so the whole thread is walked
|
|
2126
|
+
// and only a bounded tail is held: the newest messages are the
|
|
2127
|
+
// last ones read.
|
|
2128
|
+
let parent;
|
|
2129
|
+
const tail = [];
|
|
2130
|
+
let cursor;
|
|
2131
|
+
let pages = 0;
|
|
2132
|
+
do {
|
|
2133
|
+
const page = await replies.call(connection.web.conversations, {
|
|
2134
|
+
channel,
|
|
2135
|
+
ts: thread,
|
|
2136
|
+
latest: before,
|
|
2137
|
+
inclusive: false,
|
|
2138
|
+
limit: 200,
|
|
2139
|
+
...(cursor ? { cursor } : {}),
|
|
2140
|
+
});
|
|
2141
|
+
pages += 1;
|
|
2142
|
+
for (const message of page.messages ?? []) {
|
|
2143
|
+
if (!usable(message)) {
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
if (message.ts === thread) {
|
|
2147
|
+
parent = message;
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
tail.push(message);
|
|
2151
|
+
if (tail.length > EARLIER_MESSAGE_LIMIT) {
|
|
2152
|
+
tail.shift();
|
|
2153
|
+
omitted += 1;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
// Slack's cursor is the signal, not `has_more`: a short page can
|
|
2157
|
+
// still have more behind it.
|
|
2158
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2159
|
+
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
2160
|
+
more = cursor !== undefined;
|
|
2161
|
+
if (more) {
|
|
2162
|
+
// Past the read bound the newest replies were never reached, and a
|
|
2163
|
+
// slice from the middle must not pass for them: the parent alone,
|
|
2164
|
+
// and the note says the rest was not read.
|
|
2165
|
+
omitted += tail.length;
|
|
2166
|
+
tail.length = 0;
|
|
2167
|
+
}
|
|
2168
|
+
// The parent takes a slot only when it is shown.
|
|
2169
|
+
while (tail.length > (parent !== undefined ? EARLIER_MESSAGE_LIMIT - 1 : EARLIER_MESSAGE_LIMIT)) {
|
|
2170
|
+
tail.shift();
|
|
2171
|
+
omitted += 1;
|
|
2172
|
+
}
|
|
2173
|
+
kept = parent !== undefined ? [parent, ...tail] : tail;
|
|
2174
|
+
firstOfTail = parent !== undefined ? 1 : 0;
|
|
2175
|
+
}
|
|
2176
|
+
else {
|
|
2177
|
+
const history = connection.web.conversations.history;
|
|
2178
|
+
if (!history) {
|
|
2179
|
+
return [];
|
|
2180
|
+
}
|
|
2181
|
+
// Newest first, paged until enough usable messages are found (joins
|
|
2182
|
+
// and bots' chatter do not count against the limit), then put back
|
|
2183
|
+
// in the order they were said.
|
|
2184
|
+
const recent = [];
|
|
2185
|
+
let cursor;
|
|
2186
|
+
let pages = 0;
|
|
2187
|
+
do {
|
|
2188
|
+
const page = await history.call(connection.web.conversations, {
|
|
2189
|
+
channel,
|
|
2190
|
+
latest: before,
|
|
2191
|
+
inclusive: false,
|
|
2192
|
+
limit: EARLIER_CHANNEL_READ,
|
|
2193
|
+
...(cursor ? { cursor } : {}),
|
|
2194
|
+
});
|
|
2195
|
+
pages += 1;
|
|
2196
|
+
recent.push(...(page.messages ?? []).filter(usable));
|
|
2197
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2198
|
+
} while (cursor && recent.length < EARLIER_CHANNEL_LIMIT && pages < EARLIER_CHANNEL_PAGES);
|
|
2199
|
+
historyCut = cursor !== undefined && recent.length < EARLIER_CHANNEL_LIMIT;
|
|
2200
|
+
kept = recent.slice(0, EARLIER_CHANNEL_LIMIT).reverse();
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
catch (error) {
|
|
2204
|
+
warn(`slack: ${connection.config.agentId} could not read the earlier messages in ${channel}, so it sees only the message that named it: `
|
|
2205
|
+
+ `${error instanceof Error ? error.message : String(error)} (the history scopes are channels:history, groups:history, and mpim:history)`);
|
|
2206
|
+
return [];
|
|
2207
|
+
}
|
|
2208
|
+
const entries = [];
|
|
2209
|
+
for (const [index, message] of kept.entries()) {
|
|
2210
|
+
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
2211
|
+
const text = await humanizeMentions(connection, message.text ?? '');
|
|
2212
|
+
const files = message.files ?? [];
|
|
2213
|
+
const author = bot
|
|
2214
|
+
? message.user === connection.botUserId
|
|
2215
|
+
? 'you'
|
|
2216
|
+
: boundedDisplayName(message.bot_profile?.name ?? message.username ?? 'a bot')
|
|
2217
|
+
: await authorFor(connection, message.user);
|
|
2218
|
+
const reasons = new Map(files.map((file) => [file, 'not-opened']));
|
|
2219
|
+
let note = '';
|
|
2220
|
+
if (thread === undefined && index === 0) {
|
|
2221
|
+
note = historyCut
|
|
2222
|
+
? `[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`
|
|
2223
|
+
: `[The ${kept.length} most recent messages in this channel before you were mentioned, oldest first:]\n`;
|
|
2224
|
+
}
|
|
2225
|
+
else if (thread !== undefined && index === firstOfTail && omitted > 0 && !more) {
|
|
2226
|
+
note = `[${omitted} earlier messages in this thread are not shown.]\n`;
|
|
2227
|
+
}
|
|
2228
|
+
entries.push({
|
|
2229
|
+
message: `${note}${author}: ${text}${attachmentNote(files, reasons)}`,
|
|
2230
|
+
metadata: {
|
|
2231
|
+
channel: 'slack',
|
|
2232
|
+
slackChannel: channel,
|
|
2233
|
+
...(thread !== undefined ? { slackThread: thread } : {}),
|
|
2234
|
+
...(message.user !== undefined ? { slackUser: message.user } : {}),
|
|
2235
|
+
[SENDER_TRUST_METADATA_KEY]: bot ? 'unknown' : senderTrustFor(connection.config, message.user),
|
|
2236
|
+
},
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
if (historyCut && entries.length === 0) {
|
|
2240
|
+
entries.push({
|
|
2241
|
+
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.]',
|
|
2242
|
+
// The adapter's own words, nobody else's: they carry the trust of
|
|
2243
|
+
// the message they arrive with, and lower nothing.
|
|
2244
|
+
metadata: { channel: 'slack', slackChannel: channel, [SENDER_TRUST_METADATA_KEY]: senderTrust },
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
if (more) {
|
|
2248
|
+
// With no first message to show (unusable, or from someone the room
|
|
2249
|
+
// does not admit), the notice stands alone: an empty backfill would
|
|
2250
|
+
// read as a thread with nothing before the mention. It is the
|
|
2251
|
+
// adapter's own words, so it carries the trust of the message it
|
|
2252
|
+
// arrives with and lowers nothing.
|
|
2253
|
+
const notice = '[This thread is too long to read here: the replies after its first message are not shown. Ask for what you need.]';
|
|
2254
|
+
if (entries.length > 0) {
|
|
2255
|
+
entries[entries.length - 1].message += `\n${notice}`;
|
|
2256
|
+
}
|
|
2257
|
+
else {
|
|
2258
|
+
entries.push({
|
|
2259
|
+
message: notice,
|
|
2260
|
+
metadata: { channel: 'slack', slackChannel: channel, ...(thread !== undefined ? { slackThread: thread } : {}), [SENDER_TRUST_METADATA_KEY]: senderTrust },
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return entries;
|
|
2265
|
+
};
|
|
1934
2266
|
/**
|
|
1935
2267
|
* What kind of room a conversation is and how many are in it, for the
|
|
1936
2268
|
* turn's `conversation` metadata: an agent that did not know told
|
|
@@ -1977,6 +2309,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1977
2309
|
kind,
|
|
1978
2310
|
...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
|
|
1979
2311
|
...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
|
|
2312
|
+
...(kind !== 'direct' && typeof info.id === 'string' ? { id: info.id } : {}),
|
|
1980
2313
|
};
|
|
1981
2314
|
}
|
|
1982
2315
|
catch {
|
|
@@ -2006,9 +2339,108 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2006
2339
|
}
|
|
2007
2340
|
return result.trim();
|
|
2008
2341
|
};
|
|
2342
|
+
/**
|
|
2343
|
+
* Slack's stored `mrkdwn` as the plain text a read promises
|
|
2344
|
+
* (`ConversationMessage.text`): `<@U…>` mentions, `<#C…|name>` channels,
|
|
2345
|
+
* `<!here>`-style broadcasts, `<!date^…|fallback>` dates, and
|
|
2346
|
+
* `<url|label>` links, then the `< > &` escapes, decoded last
|
|
2347
|
+
* so text someone typed is never read as markup. A mention is named only
|
|
2348
|
+
* for a principal or the agent's own app, for the reason
|
|
2349
|
+
* `humanizeMentions` gives; anyone else stays their stable id.
|
|
2350
|
+
*/
|
|
2351
|
+
const nameableMentions = (connection, text) => {
|
|
2352
|
+
const principals = new Set(connection.config.principals ?? []);
|
|
2353
|
+
const ids = new Set();
|
|
2354
|
+
for (const match of text.matchAll(/<@([^|<>]+)(?:\|[^<>]*)?>/g)) {
|
|
2355
|
+
const id = match[1];
|
|
2356
|
+
if (principals.has(id) || id === connection.botUserId) {
|
|
2357
|
+
ids.add(id);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return [...ids];
|
|
2361
|
+
};
|
|
2362
|
+
/**
|
|
2363
|
+
* `resolved` is the only source of names: the caller has already looked
|
|
2364
|
+
* them up under its own bound (`readConversation`), and a mention it did
|
|
2365
|
+
* not resolve stays an id rather than costing a lookup here.
|
|
2366
|
+
*/
|
|
2367
|
+
const plainSlackText = (connection, text, resolved) => {
|
|
2368
|
+
const names = new Map();
|
|
2369
|
+
for (const id of nameableMentions(connection, text)) {
|
|
2370
|
+
const name = resolved.get(id);
|
|
2371
|
+
if (name !== undefined) {
|
|
2372
|
+
names.set(id, boundedDisplayName(name));
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return text
|
|
2376
|
+
.replace(/<([^<>]*)>/g, (_whole, inner) => {
|
|
2377
|
+
const bar = inner.indexOf('|');
|
|
2378
|
+
const target = bar === -1 ? inner : inner.slice(0, bar);
|
|
2379
|
+
const label = bar === -1 ? undefined : inner.slice(bar + 1);
|
|
2380
|
+
if (target.startsWith('@')) {
|
|
2381
|
+
const id = target.slice(1);
|
|
2382
|
+
return `@${names.get(id) ?? id}`;
|
|
2383
|
+
}
|
|
2384
|
+
if (target.startsWith('#')) {
|
|
2385
|
+
return `#${label || target.slice(1)}`;
|
|
2386
|
+
}
|
|
2387
|
+
if (target.startsWith('!')) {
|
|
2388
|
+
const command = target.slice(1);
|
|
2389
|
+
if (command.startsWith('subteam^')) {
|
|
2390
|
+
return label || `@${command.slice('subteam^'.length)}`;
|
|
2391
|
+
}
|
|
2392
|
+
if (command.startsWith('date^')) {
|
|
2393
|
+
return label ?? command;
|
|
2394
|
+
}
|
|
2395
|
+
return `@${command}`;
|
|
2396
|
+
}
|
|
2397
|
+
if (label && label !== target) {
|
|
2398
|
+
return `${label} (${target})`;
|
|
2399
|
+
}
|
|
2400
|
+
return target;
|
|
2401
|
+
})
|
|
2402
|
+
.replaceAll('<', '<')
|
|
2403
|
+
.replaceAll('>', '>')
|
|
2404
|
+
.replaceAll('&', '&');
|
|
2405
|
+
};
|
|
2009
2406
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
2010
2407
|
/** An agent's configuration, whether or not its socket came up. */
|
|
2011
2408
|
const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
|
|
2409
|
+
/**
|
|
2410
|
+
* Which agent each home channel belongs to, keyed `team:channel` because
|
|
2411
|
+
* a channel id is unique only within its workspace. Settled at start,
|
|
2412
|
+
* once every app has authenticated and its team is known. A channel two
|
|
2413
|
+
* agents in one workspace both call home belongs to neither: two agents
|
|
2414
|
+
* answering every message there is the double reply the thread rules
|
|
2415
|
+
* exist to prevent, and picking one would hang on an order the config
|
|
2416
|
+
* file does not reliably carry (JSON objects enumerate integer-like keys
|
|
2417
|
+
* numerically). Both still answer there when mentioned, and a startup
|
|
2418
|
+
* warning says so.
|
|
2419
|
+
*/
|
|
2420
|
+
const homeOwners = new Map();
|
|
2421
|
+
const settleHomeOwners = () => {
|
|
2422
|
+
homeOwners.clear();
|
|
2423
|
+
const claims = new Map();
|
|
2424
|
+
for (const agent of options.agents) {
|
|
2425
|
+
const teamId = botIdentities.get(agent.agentId)?.teamId;
|
|
2426
|
+
if (teamId === undefined) {
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
for (const channelId of agent.homeChannels ?? []) {
|
|
2430
|
+
const key = `${teamId}:${channelId}`;
|
|
2431
|
+
claims.set(key, [...(claims.get(key) ?? []), agent.agentId]);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
for (const [key, claimants] of claims) {
|
|
2435
|
+
const agentIds = [...new Set(claimants)];
|
|
2436
|
+
if (agentIds.length === 1) {
|
|
2437
|
+
homeOwners.set(key, agentIds[0]);
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
const channelId = key.slice(key.indexOf(':') + 1);
|
|
2441
|
+
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.`);
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2012
2444
|
/**
|
|
2013
2445
|
* How an agent listens, from its soul — read per message, so a roster
|
|
2014
2446
|
* reload takes effect on the next one. `thread` when the soul says
|
|
@@ -2150,7 +2582,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2150
2582
|
*/
|
|
2151
2583
|
const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
|
|
2152
2584
|
const gateway = gatewayRef;
|
|
2153
|
-
if (!gateway?.observe || reply.trim().length === 0) {
|
|
2585
|
+
if (!gateway?.observe || (typeof reply === 'string' && reply.trim().length === 0)) {
|
|
2154
2586
|
return;
|
|
2155
2587
|
}
|
|
2156
2588
|
const team = speaker.teamId;
|
|
@@ -2178,6 +2610,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2178
2610
|
if (!(await published)) {
|
|
2179
2611
|
return { observed: undefined };
|
|
2180
2612
|
}
|
|
2613
|
+
// Read now that the posts have landed: what the turn said along
|
|
2614
|
+
// the way is known only once they have.
|
|
2615
|
+
const text = typeof reply === 'string' ? reply : reply();
|
|
2616
|
+
if (text.trim().length === 0) {
|
|
2617
|
+
return { observed: undefined };
|
|
2618
|
+
}
|
|
2181
2619
|
let member;
|
|
2182
2620
|
try {
|
|
2183
2621
|
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
@@ -2192,7 +2630,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2192
2630
|
// spoke, by display name. A bot user's is the app's own, set by
|
|
2193
2631
|
// whoever installed it.
|
|
2194
2632
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2195
|
-
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${
|
|
2633
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata });
|
|
2196
2634
|
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
2197
2635
|
});
|
|
2198
2636
|
await observed;
|
|
@@ -2272,6 +2710,130 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2272
2710
|
},
|
|
2273
2711
|
};
|
|
2274
2712
|
};
|
|
2713
|
+
/**
|
|
2714
|
+
* The read side of the same boundary `resolveOutbound` draws: a channel
|
|
2715
|
+
* the agent's own app is a member of, public or private. DMs and group
|
|
2716
|
+
* DMs are refused outright, member or not. Their only members are the
|
|
2717
|
+
* people in them, and a read from a conversation elsewhere — a public
|
|
2718
|
+
* thread above all — would carry what they said to people who were never
|
|
2719
|
+
* in the room. A thread in a channel is fine for the same reason the
|
|
2720
|
+
* channel is.
|
|
2721
|
+
*/
|
|
2722
|
+
const readConversation = async (request) => {
|
|
2723
|
+
const channel = request.conversation.trim();
|
|
2724
|
+
if (!channel) {
|
|
2725
|
+
throw new Error('slack: a read needs a channel id (C…/G…).');
|
|
2726
|
+
}
|
|
2727
|
+
const connection = connectionFor(request.agentId);
|
|
2728
|
+
if (!connection) {
|
|
2729
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
2730
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so ${channel} cannot be read.`
|
|
2731
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot read Slack at all.`);
|
|
2732
|
+
}
|
|
2733
|
+
let info;
|
|
2734
|
+
try {
|
|
2735
|
+
info = (await connection.web.conversations.info({ channel })).channel;
|
|
2736
|
+
}
|
|
2737
|
+
catch (error) {
|
|
2738
|
+
throw new Error(`slack: ${request.agentId}'s app cannot see ${channel} (${error instanceof Error ? error.message : String(error)}). `
|
|
2739
|
+
+ 'Use the channel id, not a name.');
|
|
2740
|
+
}
|
|
2741
|
+
if (!info?.id) {
|
|
2742
|
+
throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
|
|
2743
|
+
}
|
|
2744
|
+
if (info.is_im === true || info.is_mpim === true) {
|
|
2745
|
+
throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them.`);
|
|
2746
|
+
}
|
|
2747
|
+
if (info.is_member !== true) {
|
|
2748
|
+
throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
|
|
2749
|
+
}
|
|
2750
|
+
const { history, replies } = connection.web.conversations;
|
|
2751
|
+
const fetchPage = request.thread !== undefined
|
|
2752
|
+
? (replies ? (args) => replies({ ...args, ts: request.thread }) : undefined)
|
|
2753
|
+
: history;
|
|
2754
|
+
if (!fetchPage) {
|
|
2755
|
+
throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
|
|
2756
|
+
}
|
|
2757
|
+
const base = {
|
|
2758
|
+
channel: info.id,
|
|
2759
|
+
inclusive: false,
|
|
2760
|
+
...(request.after !== undefined ? { oldest: request.after } : {}),
|
|
2761
|
+
...(request.before !== undefined ? { latest: request.before } : {}),
|
|
2762
|
+
};
|
|
2763
|
+
const raw = [];
|
|
2764
|
+
let cursor;
|
|
2765
|
+
let more = false;
|
|
2766
|
+
do {
|
|
2767
|
+
const page = await fetchPage({ ...base, limit: Math.min(200, request.limit - raw.length), ...(cursor ? { cursor } : {}) });
|
|
2768
|
+
raw.push(...(page.messages ?? []));
|
|
2769
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2770
|
+
more = cursor !== undefined || page.has_more === true;
|
|
2771
|
+
} while (cursor && raw.length < request.limit);
|
|
2772
|
+
if (raw.length > request.limit) {
|
|
2773
|
+
raw.length = request.limit;
|
|
2774
|
+
more = true;
|
|
2775
|
+
}
|
|
2776
|
+
// History rarely embeds profiles, so names mostly come from users.info.
|
|
2777
|
+
// Cached names are free; at most READ_NAME_LOOKUPS uncached people —
|
|
2778
|
+
// authors and the mentions in their text together — are looked up per
|
|
2779
|
+
// read, at once, and the rest keep their id, so a read of many strangers
|
|
2780
|
+
// never waits through a rate limit one call at a time.
|
|
2781
|
+
const lookups = new Map();
|
|
2782
|
+
let fresh = 0;
|
|
2783
|
+
const want = (user) => {
|
|
2784
|
+
if (lookups.has(user)) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
const cached = displayNames.get(`${connection.teamId}:${user}`);
|
|
2788
|
+
if (cached) {
|
|
2789
|
+
lookups.set(user, Promise.resolve(cached));
|
|
2790
|
+
}
|
|
2791
|
+
else if (fresh < READ_NAME_LOOKUPS) {
|
|
2792
|
+
fresh += 1;
|
|
2793
|
+
lookups.set(user, displayNameFor(connection, user));
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2796
|
+
for (const message of raw) {
|
|
2797
|
+
const user = message.user;
|
|
2798
|
+
if (user && !message.user_profile?.display_name && !message.user_profile?.real_name) {
|
|
2799
|
+
want(user);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
for (const message of raw) {
|
|
2803
|
+
for (const id of nameableMentions(connection, message.text ?? '')) {
|
|
2804
|
+
want(id);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
const names = new Map();
|
|
2808
|
+
for (const [user, name] of lookups) {
|
|
2809
|
+
names.set(user, await name);
|
|
2810
|
+
}
|
|
2811
|
+
const messages = [];
|
|
2812
|
+
for (const message of raw) {
|
|
2813
|
+
if (!message.ts) {
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
const author = message.user ?? message.bot_id ?? 'unknown';
|
|
2817
|
+
const name = message.user_profile?.display_name
|
|
2818
|
+
|| message.user_profile?.real_name
|
|
2819
|
+
|| message.bot_profile?.name
|
|
2820
|
+
|| message.username
|
|
2821
|
+
|| (message.user ? names.get(message.user) : undefined);
|
|
2822
|
+
const at = Number.parseFloat(message.ts);
|
|
2823
|
+
const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
|
|
2824
|
+
messages.push({
|
|
2825
|
+
id: message.ts,
|
|
2826
|
+
author,
|
|
2827
|
+
text: plainSlackText(connection, message.text ?? '', names),
|
|
2828
|
+
...(name && name !== author ? { authorName: boundedDisplayName(name) } : {}),
|
|
2829
|
+
...(Number.isFinite(at) ? { at: new Date(at * 1000).toISOString() } : {}),
|
|
2830
|
+
...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
|
|
2831
|
+
...(message.reply_count ? { replies: message.reply_count } : {}),
|
|
2832
|
+
...(files.length > 0 ? { files } : {}),
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
return { messages, more };
|
|
2836
|
+
};
|
|
2275
2837
|
/**
|
|
2276
2838
|
* Renders one parked call as buttons and remembers where it went.
|
|
2277
2839
|
*
|
|
@@ -3248,6 +3810,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3248
3810
|
}
|
|
3249
3811
|
const first = engaged[0];
|
|
3250
3812
|
if (!first) {
|
|
3813
|
+
// No agent has a session in this thread, but one may have STARTED
|
|
3814
|
+
// it: a post made through message.send or a schedule's delivery
|
|
3815
|
+
// lands at the top level from some other session, so nothing ties
|
|
3816
|
+
// the thread under it to the agent that wrote it. A reply there is
|
|
3817
|
+
// a reply to that agent. Read off the event, so every connection
|
|
3818
|
+
// reaches the same answer, and after a restart too. Looked up among
|
|
3819
|
+
// authenticated identities, not live sockets: they are all learned
|
|
3820
|
+
// before any socket starts, so an agent whose socket is still coming
|
|
3821
|
+
// up is not judged absent and the memoized verdict cannot drop the
|
|
3822
|
+
// reply when its own socket delivers it.
|
|
3823
|
+
if (parts.parentUser === undefined) {
|
|
3824
|
+
return undefined;
|
|
3825
|
+
}
|
|
3826
|
+
for (const [agentId, identity] of botIdentities) {
|
|
3827
|
+
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
3828
|
+
return agentId;
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3251
3831
|
return undefined;
|
|
3252
3832
|
}
|
|
3253
3833
|
if (engaged.length === 1) {
|
|
@@ -3369,7 +3949,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3369
3949
|
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
3370
3950
|
: undefined;
|
|
3371
3951
|
const text = event.text ?? '';
|
|
3372
|
-
const
|
|
3952
|
+
const mentioned = isDm || mentions(text, connection.botUserId);
|
|
3373
3953
|
// Who this message hands the thread to — worked out by EVERY connection
|
|
3374
3954
|
// that sees it, not only by the one being named, and recorded before any
|
|
3375
3955
|
// of the rejections below. Each agent is its own Socket Mode connection,
|
|
@@ -3392,6 +3972,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3392
3972
|
// no policy, and nobody's to refuse here, so it admits.
|
|
3393
3973
|
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
3394
3974
|
const named = handoverTo ?? agentNamedIn(text, team);
|
|
3975
|
+
// A new top-level message in this agent's home channel is addressed to
|
|
3976
|
+
// it as surely as a mention would be — unless it names somebody else,
|
|
3977
|
+
// which hands it to them exactly as it would anywhere. Top-level only:
|
|
3978
|
+
// inside a thread the soul's `listens` already says what an untagged
|
|
3979
|
+
// reply is, and the home channel does not overrule it.
|
|
3980
|
+
const homeOwner = !isDm && event.thread_ts === undefined && named === undefined
|
|
3981
|
+
? homeOwners.get(`${team}:${event.channel}`)
|
|
3982
|
+
: undefined;
|
|
3983
|
+
const addressed = mentioned || homeOwner === connection.config.agentId;
|
|
3395
3984
|
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
3396
3985
|
// it; see the dedupe below for why it is not the event id.
|
|
3397
3986
|
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
@@ -3406,6 +3995,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3406
3995
|
if (threadKey && handoverTo) {
|
|
3407
3996
|
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
3408
3997
|
}
|
|
3998
|
+
// A home-channel message hands its new thread to the channel's owner
|
|
3999
|
+
// the way a mention would, and is recorded by every socket for the same
|
|
4000
|
+
// reason: a reply in that thread can reach another agent's socket before
|
|
4001
|
+
// the owner's first turn has written any session routing, and without
|
|
4002
|
+
// this record every agent would stand down and the reply would be lost.
|
|
4003
|
+
// On the owner's admission, as a handover is.
|
|
4004
|
+
if (threadKey && homeOwner !== undefined && admitsSender(agentConfigFor(homeOwner), sender)) {
|
|
4005
|
+
rememberAddressee(threadKey, homeOwner, event.ts);
|
|
4006
|
+
}
|
|
3409
4007
|
// Who may speak at all, judged before anything below remembers this
|
|
3410
4008
|
// message. The adapter's own checks establish nothing about who is
|
|
3411
4009
|
// typing; under `admit: 'principals'` the operator's list is the door,
|
|
@@ -3445,7 +4043,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3445
4043
|
// place in that queue, and the line is an audit, not a turn; `track`
|
|
3446
4044
|
// holds it so `stop()` drains it like any other background work.
|
|
3447
4045
|
const thread = event.thread_ts;
|
|
3448
|
-
track(followUpWinner({
|
|
4046
|
+
track(followUpWinner({
|
|
4047
|
+
team,
|
|
4048
|
+
conversation: event.channel,
|
|
4049
|
+
thread,
|
|
4050
|
+
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4051
|
+
}, event.ts).then((winner) => {
|
|
3449
4052
|
if (winner === connection.config.agentId) {
|
|
3450
4053
|
log(refusal);
|
|
3451
4054
|
}
|
|
@@ -3624,7 +4227,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3624
4227
|
if (!admitted.settled) {
|
|
3625
4228
|
// One verdict for this message, shared with whichever other agents
|
|
3626
4229
|
// are asking about it — see `followUpWinner`.
|
|
3627
|
-
const winner = await followUpWinner({
|
|
4230
|
+
const winner = await followUpWinner({
|
|
4231
|
+
team,
|
|
4232
|
+
conversation: event.channel,
|
|
4233
|
+
thread: event.thread_ts ?? '',
|
|
4234
|
+
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4235
|
+
}, event.ts);
|
|
3628
4236
|
if (winner !== connection.config.agentId) {
|
|
3629
4237
|
// Not this agent's to answer — and, if it is in the thread,
|
|
3630
4238
|
// this agent's to hear, same as the two warm stand-downs.
|
|
@@ -3732,6 +4340,29 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3732
4340
|
// if it ever has any — see `ReplyRenderer.lazy`. One the agent's
|
|
3733
4341
|
// reply mode does not stream opens none at all, and shows the
|
|
3734
4342
|
// loading status instead — see `ReplyRenderer.streaming`.
|
|
4343
|
+
// Mentioned into a thread partway: hand the new session what was
|
|
4344
|
+
// said before this. Inside the chain, before the placeholder, so the
|
|
4345
|
+
// read cannot reorder this message against the next one.
|
|
4346
|
+
let earlier = [];
|
|
4347
|
+
if (!isDm && thread !== undefined) {
|
|
4348
|
+
// A top-level mention roots its own session, so it is new but for
|
|
4349
|
+
// a redelivery, which the gateway answers without reading this.
|
|
4350
|
+
let exists = false;
|
|
4351
|
+
if (thread !== event.ts && gateway.sessionRouting) {
|
|
4352
|
+
try {
|
|
4353
|
+
exists = (await gateway.sessionRouting(sessionId)) !== undefined;
|
|
4354
|
+
}
|
|
4355
|
+
catch {
|
|
4356
|
+
// Unknown is read as new: a session that does exist ignores
|
|
4357
|
+
// the list, so the cost of guessing wrong is one API read.
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
if (!exists) {
|
|
4361
|
+
// A mention that starts its own thread follows the channel; one
|
|
4362
|
+
// inside a thread follows that thread.
|
|
4363
|
+
earlier = await earlierContext(connection, event.channel, thread === event.ts ? undefined : thread, event.ts, senderTrust);
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
3735
4366
|
const streaming = connection.config.replies === 'stream';
|
|
3736
4367
|
const ahead = streaming ? undefined : replyOrder.get(sessionId);
|
|
3737
4368
|
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
|
|
@@ -3779,6 +4410,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3779
4410
|
userMessage,
|
|
3780
4411
|
...(images.length > 0 ? { images } : {}),
|
|
3781
4412
|
...(judged ? { addressed: false } : {}),
|
|
4413
|
+
...(earlier.length > 0 ? { earlier } : {}),
|
|
3782
4414
|
turnId: renderer.turnId,
|
|
3783
4415
|
metadata,
|
|
3784
4416
|
// A redelivery this process no longer remembers (it restarted, or
|
|
@@ -3899,8 +4531,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3899
4531
|
// rule with the gateway's `sessionRouting`, which posts the same
|
|
3900
4532
|
// message for a turn this adapter did not start.
|
|
3901
4533
|
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
|
|
3902
|
-
|
|
3903
|
-
|
|
4534
|
+
// What the turn said along the way as well as its reply: interim
|
|
4535
|
+
// messages are in the thread, so they are in what the others hear.
|
|
4536
|
+
// Heard once the posts have settled, and the reply only if it landed:
|
|
4537
|
+
// interim messages already in the thread are heard either way.
|
|
4538
|
+
let replyLanded = false;
|
|
4539
|
+
const settled = finalized.then((outcome) => {
|
|
4540
|
+
replyLanded = outcome.published;
|
|
4541
|
+
return true;
|
|
4542
|
+
}, () => true);
|
|
4543
|
+
const heard = thread !== undefined
|
|
4544
|
+
? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
|
|
3904
4545
|
: undefined;
|
|
3905
4546
|
const { spoke, spokeAt } = await finalized;
|
|
3906
4547
|
if (spoke && threadKey !== undefined) {
|
|
@@ -3925,6 +4566,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3925
4566
|
}
|
|
3926
4567
|
else {
|
|
3927
4568
|
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
|
+
}
|
|
3928
4577
|
if (spoke && threadKey !== undefined) {
|
|
3929
4578
|
// A file it posted before breaking is still the last thing said.
|
|
3930
4579
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -3935,6 +4584,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3935
4584
|
return {
|
|
3936
4585
|
name: 'slack',
|
|
3937
4586
|
resolveOutbound,
|
|
4587
|
+
readConversation,
|
|
3938
4588
|
requestCredential,
|
|
3939
4589
|
async start(gateway) {
|
|
3940
4590
|
gatewayRef = gateway;
|
|
@@ -4086,6 +4736,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4086
4736
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4087
4737
|
}
|
|
4088
4738
|
}
|
|
4739
|
+
settleHomeOwners();
|
|
4089
4740
|
for (const connection of authenticated) {
|
|
4090
4741
|
const { config, socket } = connection;
|
|
4091
4742
|
try {
|
|
@@ -4150,6 +4801,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4150
4801
|
coldVerdicts.clear();
|
|
4151
4802
|
pendingObserves.clear();
|
|
4152
4803
|
botIdentities.clear();
|
|
4804
|
+
homeOwners.clear();
|
|
4153
4805
|
rendering.clear();
|
|
4154
4806
|
resolvedWhileRendering.clear();
|
|
4155
4807
|
credentialsPosting.clear();
|