@stratusagent/channel-slack 0.11.8 → 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 +656 -7
- 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
|
|
@@ -1721,6 +1849,8 @@ const credentialModal = (post, requestId) => ({
|
|
|
1721
1849
|
* those were in it receives only `app_mention` and behaves as it always
|
|
1722
1850
|
* did, which makes the workspace's own grant the switch.
|
|
1723
1851
|
*/
|
|
1852
|
+
/** Uncached author names one `message.read` looks up; the rest keep their id. */
|
|
1853
|
+
const READ_NAME_LOOKUPS = 10;
|
|
1724
1854
|
export const createSlackChannelAdapter = (options) => {
|
|
1725
1855
|
const log = options.log ?? (() => { });
|
|
1726
1856
|
const warn = options.warn ?? (() => { });
|
|
@@ -1934,6 +2064,205 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1934
2064
|
}
|
|
1935
2065
|
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1936
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
|
+
};
|
|
1937
2266
|
/**
|
|
1938
2267
|
* What kind of room a conversation is and how many are in it, for the
|
|
1939
2268
|
* turn's `conversation` metadata: an agent that did not know told
|
|
@@ -1980,6 +2309,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1980
2309
|
kind,
|
|
1981
2310
|
...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
|
|
1982
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 } : {}),
|
|
1983
2313
|
};
|
|
1984
2314
|
}
|
|
1985
2315
|
catch {
|
|
@@ -2009,9 +2339,108 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2009
2339
|
}
|
|
2010
2340
|
return result.trim();
|
|
2011
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
|
+
};
|
|
2012
2406
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
2013
2407
|
/** An agent's configuration, whether or not its socket came up. */
|
|
2014
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
|
+
};
|
|
2015
2444
|
/**
|
|
2016
2445
|
* How an agent listens, from its soul — read per message, so a roster
|
|
2017
2446
|
* reload takes effect on the next one. `thread` when the soul says
|
|
@@ -2153,7 +2582,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2153
2582
|
*/
|
|
2154
2583
|
const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
|
|
2155
2584
|
const gateway = gatewayRef;
|
|
2156
|
-
if (!gateway?.observe || reply.trim().length === 0) {
|
|
2585
|
+
if (!gateway?.observe || (typeof reply === 'string' && reply.trim().length === 0)) {
|
|
2157
2586
|
return;
|
|
2158
2587
|
}
|
|
2159
2588
|
const team = speaker.teamId;
|
|
@@ -2181,6 +2610,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2181
2610
|
if (!(await published)) {
|
|
2182
2611
|
return { observed: undefined };
|
|
2183
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
|
+
}
|
|
2184
2619
|
let member;
|
|
2185
2620
|
try {
|
|
2186
2621
|
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
@@ -2195,7 +2630,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2195
2630
|
// spoke, by display name. A bot user's is the app's own, set by
|
|
2196
2631
|
// whoever installed it.
|
|
2197
2632
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2198
|
-
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 });
|
|
2199
2634
|
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
2200
2635
|
});
|
|
2201
2636
|
await observed;
|
|
@@ -2275,6 +2710,130 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2275
2710
|
},
|
|
2276
2711
|
};
|
|
2277
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
|
+
};
|
|
2278
2837
|
/**
|
|
2279
2838
|
* Renders one parked call as buttons and remembers where it went.
|
|
2280
2839
|
*
|
|
@@ -3251,6 +3810,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3251
3810
|
}
|
|
3252
3811
|
const first = engaged[0];
|
|
3253
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
|
+
}
|
|
3254
3831
|
return undefined;
|
|
3255
3832
|
}
|
|
3256
3833
|
if (engaged.length === 1) {
|
|
@@ -3372,7 +3949,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3372
3949
|
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
3373
3950
|
: undefined;
|
|
3374
3951
|
const text = event.text ?? '';
|
|
3375
|
-
const
|
|
3952
|
+
const mentioned = isDm || mentions(text, connection.botUserId);
|
|
3376
3953
|
// Who this message hands the thread to — worked out by EVERY connection
|
|
3377
3954
|
// that sees it, not only by the one being named, and recorded before any
|
|
3378
3955
|
// of the rejections below. Each agent is its own Socket Mode connection,
|
|
@@ -3395,6 +3972,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3395
3972
|
// no policy, and nobody's to refuse here, so it admits.
|
|
3396
3973
|
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
3397
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;
|
|
3398
3984
|
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
3399
3985
|
// it; see the dedupe below for why it is not the event id.
|
|
3400
3986
|
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
@@ -3409,6 +3995,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3409
3995
|
if (threadKey && handoverTo) {
|
|
3410
3996
|
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
3411
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
|
+
}
|
|
3412
4007
|
// Who may speak at all, judged before anything below remembers this
|
|
3413
4008
|
// message. The adapter's own checks establish nothing about who is
|
|
3414
4009
|
// typing; under `admit: 'principals'` the operator's list is the door,
|
|
@@ -3448,7 +4043,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3448
4043
|
// place in that queue, and the line is an audit, not a turn; `track`
|
|
3449
4044
|
// holds it so `stop()` drains it like any other background work.
|
|
3450
4045
|
const thread = event.thread_ts;
|
|
3451
|
-
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) => {
|
|
3452
4052
|
if (winner === connection.config.agentId) {
|
|
3453
4053
|
log(refusal);
|
|
3454
4054
|
}
|
|
@@ -3627,7 +4227,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3627
4227
|
if (!admitted.settled) {
|
|
3628
4228
|
// One verdict for this message, shared with whichever other agents
|
|
3629
4229
|
// are asking about it — see `followUpWinner`.
|
|
3630
|
-
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);
|
|
3631
4236
|
if (winner !== connection.config.agentId) {
|
|
3632
4237
|
// Not this agent's to answer — and, if it is in the thread,
|
|
3633
4238
|
// this agent's to hear, same as the two warm stand-downs.
|
|
@@ -3735,6 +4340,29 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3735
4340
|
// if it ever has any — see `ReplyRenderer.lazy`. One the agent's
|
|
3736
4341
|
// reply mode does not stream opens none at all, and shows the
|
|
3737
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
|
+
}
|
|
3738
4366
|
const streaming = connection.config.replies === 'stream';
|
|
3739
4367
|
const ahead = streaming ? undefined : replyOrder.get(sessionId);
|
|
3740
4368
|
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
|
|
@@ -3782,6 +4410,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3782
4410
|
userMessage,
|
|
3783
4411
|
...(images.length > 0 ? { images } : {}),
|
|
3784
4412
|
...(judged ? { addressed: false } : {}),
|
|
4413
|
+
...(earlier.length > 0 ? { earlier } : {}),
|
|
3785
4414
|
turnId: renderer.turnId,
|
|
3786
4415
|
metadata,
|
|
3787
4416
|
// A redelivery this process no longer remembers (it restarted, or
|
|
@@ -3902,8 +4531,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3902
4531
|
// rule with the gateway's `sessionRouting`, which posts the same
|
|
3903
4532
|
// message for a turn this adapter did not start.
|
|
3904
4533
|
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
|
|
3905
|
-
|
|
3906
|
-
|
|
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)
|
|
3907
4545
|
: undefined;
|
|
3908
4546
|
const { spoke, spokeAt } = await finalized;
|
|
3909
4547
|
if (spoke && threadKey !== undefined) {
|
|
@@ -3928,6 +4566,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3928
4566
|
}
|
|
3929
4567
|
else {
|
|
3930
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
|
+
}
|
|
3931
4577
|
if (spoke && threadKey !== undefined) {
|
|
3932
4578
|
// A file it posted before breaking is still the last thing said.
|
|
3933
4579
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -3938,6 +4584,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3938
4584
|
return {
|
|
3939
4585
|
name: 'slack',
|
|
3940
4586
|
resolveOutbound,
|
|
4587
|
+
readConversation,
|
|
3941
4588
|
requestCredential,
|
|
3942
4589
|
async start(gateway) {
|
|
3943
4590
|
gatewayRef = gateway;
|
|
@@ -4089,6 +4736,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4089
4736
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4090
4737
|
}
|
|
4091
4738
|
}
|
|
4739
|
+
settleHomeOwners();
|
|
4092
4740
|
for (const connection of authenticated) {
|
|
4093
4741
|
const { config, socket } = connection;
|
|
4094
4742
|
try {
|
|
@@ -4153,6 +4801,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4153
4801
|
coldVerdicts.clear();
|
|
4154
4802
|
pendingObserves.clear();
|
|
4155
4803
|
botIdentities.clear();
|
|
4804
|
+
homeOwners.clear();
|
|
4156
4805
|
rendering.clear();
|
|
4157
4806
|
resolvedWhileRendering.clear();
|
|
4158
4807
|
credentialsPosting.clear();
|