@stratusagent/channel-slack 0.14.0 → 0.14.2
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 +100 -5
- package/dist/index.d.ts +67 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1262 -175
- package/dist/index.js.map +1 -1
- package/dist/texts.d.ts +30 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +101 -0
- package/dist/texts.js.map +1 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
|
|
6
|
+
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
|
|
7
7
|
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
|
+
import { textingParts } from "./texts.js";
|
|
8
9
|
/**
|
|
9
10
|
* The longest display name the model is shown. Slack caps profile names
|
|
10
11
|
* well under this; the bound is for a profile that is not a name.
|
|
@@ -110,6 +111,72 @@ const STATUS_REFRESH_MS = 60_000;
|
|
|
110
111
|
* long.
|
|
111
112
|
*/
|
|
112
113
|
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
114
|
+
/**
|
|
115
|
+
* How long `stop()` waits on a socket that is still starting (card 935).
|
|
116
|
+
* Well inside the gateway's shutdown window, so a socket stuck connecting
|
|
117
|
+
* is reported by name rather than as the whole window running out.
|
|
118
|
+
*/
|
|
119
|
+
const DEFAULT_STOP_WAIT_MS = 10_000;
|
|
120
|
+
/**
|
|
121
|
+
* How long an approval prompt waits for the words its turn wrote before the
|
|
122
|
+
* call to land first (card 950). Long enough for an ordinary post, short
|
|
123
|
+
* enough that a slow or refusing Slack never keeps the question from an
|
|
124
|
+
* approver: the turn is parked on that answer.
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_APPROVAL_PROMPT_WAIT_MS = 5_000;
|
|
127
|
+
/**
|
|
128
|
+
* How long a task works before its progress message opens (card 949): long
|
|
129
|
+
* enough that a quick answer, a lookup or two, is just its reply; short
|
|
130
|
+
* enough that a task taking minutes shows what it is doing while it does.
|
|
131
|
+
*/
|
|
132
|
+
const DEFAULT_PROGRESS_AFTER_MS = 10_000;
|
|
133
|
+
/** Room kept for a progress message's first line and its `…` when its notes are trimmed. */
|
|
134
|
+
const PROGRESS_HEADER_ROOM = 200;
|
|
135
|
+
/**
|
|
136
|
+
* The most a progress message holds under its first line. Words longer than
|
|
137
|
+
* this are content, not a step — an analysis written before a tool call —
|
|
138
|
+
* and are posted whole as messages of their own, as before card 949, rather
|
|
139
|
+
* than cut to fit and lost.
|
|
140
|
+
*/
|
|
141
|
+
const PROGRESS_NOTE_ROOM = SLACK_MAX_MESSAGE_CHARS - PROGRESS_HEADER_ROOM;
|
|
142
|
+
const stepCount = (steps) => `${steps} ${steps === 1 ? 'step' : 'steps'}`;
|
|
143
|
+
/** A tool's name as code, which no backtick of its own can close early. */
|
|
144
|
+
const toolCode = (toolName) => `\`${escapeSlackText(toolName.replace(/`/g, ''))}\``;
|
|
145
|
+
/**
|
|
146
|
+
* The first line of a progress message: what the turn is doing, from the
|
|
147
|
+
* tool calls it made and is making. Words the model wrote are under it, as
|
|
148
|
+
* notes; this line is the part that cannot claim more than happened.
|
|
149
|
+
*/
|
|
150
|
+
const progressHeader = (state) => {
|
|
151
|
+
switch (state.end) {
|
|
152
|
+
case 'done':
|
|
153
|
+
return `✓ Done · ${stepCount(state.steps)}`;
|
|
154
|
+
case 'stopped':
|
|
155
|
+
return `■ Stopped after ${stepCount(state.steps)}`;
|
|
156
|
+
case 'failed':
|
|
157
|
+
return `✗ Failed after ${stepCount(state.steps)}`;
|
|
158
|
+
default:
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (state.waitingOn !== undefined) {
|
|
162
|
+
return `⏸ Waiting for approval to run ${toolCode(state.waitingOn)}`;
|
|
163
|
+
}
|
|
164
|
+
const latest = [...state.running.values()].at(-1);
|
|
165
|
+
if (latest !== undefined) {
|
|
166
|
+
return `⚙ Working · step ${state.steps}: ${toolCode(latest)}`;
|
|
167
|
+
}
|
|
168
|
+
return `⚙ Working · ${stepCount(state.steps)} done`;
|
|
169
|
+
};
|
|
170
|
+
/** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
|
|
171
|
+
const settledWithin = (work, ms) => new Promise((resolve) => {
|
|
172
|
+
const timer = setTimeout(resolve, ms);
|
|
173
|
+
timer.unref?.();
|
|
174
|
+
const done = () => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve();
|
|
177
|
+
};
|
|
178
|
+
work.then(done, done);
|
|
179
|
+
});
|
|
113
180
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
114
181
|
const NO_REPLY_TEXT = '(no reply)';
|
|
115
182
|
/**
|
|
@@ -266,6 +333,40 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
|
|
|
266
333
|
return { status: response.status, ...typed, body: Buffer.concat(chunks) };
|
|
267
334
|
};
|
|
268
335
|
const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
|
|
336
|
+
/**
|
|
337
|
+
* The one origin a file URL may name for the bot token to go with it.
|
|
338
|
+
* `url_private` does not by itself mean Slack hosts the file: for a remote
|
|
339
|
+
* file (an app's pointer to a file it hosts), Slack returns the app's own
|
|
340
|
+
* URL there, on whatever host the app chose, so any app with
|
|
341
|
+
* `remote_files:write` could collect the token of every agent that opened
|
|
342
|
+
* its file. Slack's rule is never to send Slack credentials to such a URL,
|
|
343
|
+
* and to compare the parsed host exactly rather than a prefix
|
|
344
|
+
* (docs.slack.dev, Working with files: Downloading files). Previews and
|
|
345
|
+
* downloads both live here. A redirect elsewhere is not a leak either:
|
|
346
|
+
* fetch drops `authorization` on a cross-origin redirect.
|
|
347
|
+
*/
|
|
348
|
+
const SLACK_FILE_ORIGIN = 'https://files.slack.com';
|
|
349
|
+
/**
|
|
350
|
+
* A fetcher that is only ever handed URLs on Slack's file host. Anything
|
|
351
|
+
* else is refused before a request is made, with a reason that names the
|
|
352
|
+
* origin, and the callers' own handling turns that into a name-only file
|
|
353
|
+
* and an operator warning, as for any download that failed.
|
|
354
|
+
*/
|
|
355
|
+
const onlySlackFileHost = (fetchFile) => (url, botToken, signal, maxBytes) => {
|
|
356
|
+
let origin;
|
|
357
|
+
try {
|
|
358
|
+
const parsed = new URL(url);
|
|
359
|
+
// Credentials in the URL would be sent with the token: not Slack's.
|
|
360
|
+
origin = parsed.username === '' && parsed.password === '' ? parsed.origin : `${parsed.protocol}//(credentials)@${parsed.host}`;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
origin = undefined;
|
|
364
|
+
}
|
|
365
|
+
if (origin !== SLACK_FILE_ORIGIN) {
|
|
366
|
+
return Promise.reject(new Error(`${origin ?? 'its link'} is not Slack's file host (${SLACK_FILE_ORIGIN}), so the bot token is not sent there`));
|
|
367
|
+
}
|
|
368
|
+
return fetchFile(url, botToken, signal, maxBytes);
|
|
369
|
+
};
|
|
269
370
|
/**
|
|
270
371
|
* The dispatch's idempotency key for a message's dedupe key: the key itself
|
|
271
372
|
* when it fits the gateway's bound, and a digest of it when it does not —
|
|
@@ -407,6 +508,40 @@ class ReplyRenderer {
|
|
|
407
508
|
* once that is known, so the thread's other agents hear it in its place.
|
|
408
509
|
*/
|
|
409
510
|
onInterim;
|
|
511
|
+
/**
|
|
512
|
+
* Card 949, `final` mode: a task says what it is about to do in a
|
|
513
|
+
* message of its own (its first words before a tool), then keeps ONE
|
|
514
|
+
* progress message up to date — a first line built from its tool events,
|
|
515
|
+
* and under it whatever else it wrote between tools — and posts its reply
|
|
516
|
+
* as a message of its own. Narration that used to be a post per step is
|
|
517
|
+
* an edit, so a long task is three messages, not twenty.
|
|
518
|
+
*
|
|
519
|
+
* Whether this turn's first words have gone out, so later words are notes.
|
|
520
|
+
*/
|
|
521
|
+
firstWordsSaid = false;
|
|
522
|
+
/** Whether the progress message has been asked for; it may not have landed yet. */
|
|
523
|
+
progressOpen = false;
|
|
524
|
+
/** The progress message, once Slack took it. */
|
|
525
|
+
progressRef;
|
|
526
|
+
/** What the first line says, from this turn's own tool events only. */
|
|
527
|
+
progress = { steps: 0, running: new Map(), waitingOn: undefined, end: undefined };
|
|
528
|
+
/**
|
|
529
|
+
* The notes under that line, as sent: converted, oldest dropped past one
|
|
530
|
+
* message's room. `shown` once a write that held the note has landed: a
|
|
531
|
+
* note trimmed while its write was still queued never reached anyone.
|
|
532
|
+
*/
|
|
533
|
+
progressNotes = [];
|
|
534
|
+
/** Whether older notes were dropped for room, so the message says so. */
|
|
535
|
+
progressNotesCut = false;
|
|
536
|
+
/** Whether the last thing this turn said is a note rather than a message of its own. */
|
|
537
|
+
lastSaidWasNote = false;
|
|
538
|
+
progressTimer;
|
|
539
|
+
progressOpenTimer;
|
|
540
|
+
lastProgressAt = 0;
|
|
541
|
+
turnStartedAt = 0;
|
|
542
|
+
/** How the turn ended, for the progress message: a stop or a failure says so before it finalizes. */
|
|
543
|
+
ending = 'done';
|
|
544
|
+
progressAfterMs;
|
|
410
545
|
/** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
|
|
411
546
|
taintedTo;
|
|
412
547
|
turnBreakPending = false;
|
|
@@ -473,6 +608,12 @@ class ReplyRenderer {
|
|
|
473
608
|
* could not be opened.
|
|
474
609
|
*/
|
|
475
610
|
streaming;
|
|
611
|
+
/**
|
|
612
|
+
* Whether the reply, and what the turn says before a tool, are posted a
|
|
613
|
+
* paragraph to a message (`texts` mode). Never with `streaming`: there
|
|
614
|
+
* is one placeholder to stream into.
|
|
615
|
+
*/
|
|
616
|
+
texts;
|
|
476
617
|
/**
|
|
477
618
|
* The thread the loading status belongs to: the reply's thread, or the
|
|
478
619
|
* message itself in a DM, whose reply has no thread to key one on.
|
|
@@ -505,6 +646,13 @@ class ReplyRenderer {
|
|
|
505
646
|
statusTimer;
|
|
506
647
|
/** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
|
|
507
648
|
runningTool;
|
|
649
|
+
/**
|
|
650
|
+
* Calls started and not yet settled, by call id, oldest first. Core runs
|
|
651
|
+
* a response's read-only calls together, so one can settle while another
|
|
652
|
+
* still runs: both lines name the newest call still running, and clear
|
|
653
|
+
* only once none is.
|
|
654
|
+
*/
|
|
655
|
+
activeTools = new Map();
|
|
508
656
|
web;
|
|
509
657
|
/**
|
|
510
658
|
* The one person in the conversation when this turn is a DM: whoever sent
|
|
@@ -527,11 +675,13 @@ class ReplyRenderer {
|
|
|
527
675
|
this.lazy = options.lazy ?? false;
|
|
528
676
|
this.now = options.now ?? Date.now;
|
|
529
677
|
this.streaming = options.streaming ?? true;
|
|
678
|
+
this.texts = !this.streaming && options.texts === true;
|
|
530
679
|
this.statusThread = options.statusThread ?? threadTs;
|
|
531
680
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
532
681
|
this.after = options.after ?? Promise.resolve();
|
|
533
682
|
this.dmWith = options.dmWith;
|
|
534
683
|
this.onInterim = options.onInterim;
|
|
684
|
+
this.progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
|
|
535
685
|
}
|
|
536
686
|
/**
|
|
537
687
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -622,6 +772,9 @@ class ReplyRenderer {
|
|
|
622
772
|
* included, so this turn's placeholder starts empty.
|
|
623
773
|
*/
|
|
624
774
|
beginTurn() {
|
|
775
|
+
if (!this.turnStarted) {
|
|
776
|
+
this.turnStartedAt = Date.now();
|
|
777
|
+
}
|
|
625
778
|
this.turnStarted = true;
|
|
626
779
|
if (this.pendingEdit) {
|
|
627
780
|
clearTimeout(this.pendingEdit);
|
|
@@ -629,6 +782,7 @@ class ReplyRenderer {
|
|
|
629
782
|
}
|
|
630
783
|
this.buffer = '';
|
|
631
784
|
this.interim = '';
|
|
785
|
+
this.activeTools.clear();
|
|
632
786
|
this.toolLine = undefined;
|
|
633
787
|
this.runningTool = undefined;
|
|
634
788
|
this.turnBreakPending = false;
|
|
@@ -705,9 +859,16 @@ class ReplyRenderer {
|
|
|
705
859
|
// all, so this is the only thing between the text before a tool and
|
|
706
860
|
// the text after it — without it they fuse in the live message.
|
|
707
861
|
this.turnBreakPending = true;
|
|
708
|
-
this.
|
|
709
|
-
this.
|
|
862
|
+
this.activeTools.set(event.call.id, event.call.toolName);
|
|
863
|
+
this.showActiveTool();
|
|
710
864
|
this.flushInterim(ofThisTurn);
|
|
865
|
+
if (ofThisTurn && this.mayShowProgress()) {
|
|
866
|
+
this.progress.steps += 1;
|
|
867
|
+
this.progress.running.set(event.call.id, event.call.toolName);
|
|
868
|
+
this.progress.waitingOn = undefined;
|
|
869
|
+
this.openProgressInTime();
|
|
870
|
+
this.scheduleProgress();
|
|
871
|
+
}
|
|
711
872
|
this.scheduleEdit();
|
|
712
873
|
return;
|
|
713
874
|
}
|
|
@@ -716,21 +877,27 @@ class ReplyRenderer {
|
|
|
716
877
|
// else clears the status line or marks the boundary for it — the
|
|
717
878
|
// message would keep claiming a refused tool is running, and the
|
|
718
879
|
// model's follow-up would fuse with the text before the attempt.
|
|
719
|
-
this.
|
|
720
|
-
this.
|
|
880
|
+
this.activeTools.delete(event.call.id);
|
|
881
|
+
this.showActiveTool();
|
|
721
882
|
this.turnBreakPending = true;
|
|
722
883
|
// Words written before a call that was refused were still said
|
|
723
884
|
// before a tool, and must not run into the next response's.
|
|
724
885
|
this.flushInterim(ofThisTurn);
|
|
886
|
+
if (ofThisTurn) {
|
|
887
|
+
this.settleProgressStep(event.call.id);
|
|
888
|
+
}
|
|
725
889
|
this.scheduleEdit();
|
|
726
890
|
return;
|
|
727
891
|
}
|
|
728
892
|
if (event.type === 'tool.completed') {
|
|
729
|
-
this.
|
|
730
|
-
this.
|
|
893
|
+
this.activeTools.delete(event.result.callId);
|
|
894
|
+
this.showActiveTool();
|
|
731
895
|
// Settled here too for a call rejected before it ran, which never
|
|
732
896
|
// reached `tool.called`; after one that did, this is empty.
|
|
733
897
|
this.flushInterim(ofThisTurn);
|
|
898
|
+
if (ofThisTurn) {
|
|
899
|
+
this.settleProgressStep(event.result.callId);
|
|
900
|
+
}
|
|
734
901
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
735
902
|
// execution settles as tool.completed without ever having emitted
|
|
736
903
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -821,8 +988,18 @@ class ReplyRenderer {
|
|
|
821
988
|
const text = this.interim.trim();
|
|
822
989
|
this.interim = '';
|
|
823
990
|
if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
|
|
824
|
-
return;
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
// Only words before the turn's first tool are a message of their own:
|
|
994
|
+
// after one, a step has run, and what follows is progress whether or
|
|
995
|
+
// not the turn opened with a line.
|
|
996
|
+
if (this.firstWordsSaid || this.progressOpen || this.progress.steps > 0) {
|
|
997
|
+
const note = toSlackMrkdwn(text);
|
|
998
|
+
if (note.length <= PROGRESS_NOTE_ROOM) {
|
|
999
|
+
return this.noteProgress(text, note);
|
|
1000
|
+
}
|
|
825
1001
|
}
|
|
1002
|
+
this.firstWordsSaid = true;
|
|
826
1003
|
const handover = this.handover;
|
|
827
1004
|
const after = this.after;
|
|
828
1005
|
let heard = '';
|
|
@@ -837,7 +1014,7 @@ class ReplyRenderer {
|
|
|
837
1014
|
.then(async () => {
|
|
838
1015
|
const landed = [];
|
|
839
1016
|
try {
|
|
840
|
-
for (const chunk of
|
|
1017
|
+
for (const chunk of this.chunksOf(text)) {
|
|
841
1018
|
const posted = await this.web.chat.postMessage({
|
|
842
1019
|
channel: this.channel,
|
|
843
1020
|
text: chunk,
|
|
@@ -854,12 +1031,13 @@ class ReplyRenderer {
|
|
|
854
1031
|
// reply, where repeating a few lines beats losing the rest.
|
|
855
1032
|
if (landed.length > 0) {
|
|
856
1033
|
this.interimPosted = true;
|
|
857
|
-
heard = landed.length ===
|
|
1034
|
+
heard = landed.length === this.chunksOf(text).length ? text : landed.join('\n\n');
|
|
858
1035
|
}
|
|
859
1036
|
settleLanded(landed.length > 0);
|
|
860
1037
|
}
|
|
861
1038
|
this.lastInterim = text;
|
|
862
1039
|
this.interimSaid.push(text);
|
|
1040
|
+
this.lastSaidWasNote = false;
|
|
863
1041
|
// A post of the app's in this thread takes the status down while
|
|
864
1042
|
// the turn is still working; put it back.
|
|
865
1043
|
this.refreshLoading();
|
|
@@ -868,6 +1046,230 @@ class ReplyRenderer {
|
|
|
868
1046
|
// Settled whatever happened above, or the other agents' intake would
|
|
869
1047
|
// wait on it for good. A second settle is a no-op.
|
|
870
1048
|
.finally(() => settleLanded(false));
|
|
1049
|
+
return landedNow;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Whether this turn keeps a progress message (card 949): `final` mode,
|
|
1053
|
+
* a turn somebody asked for, and its own — the same turns that post
|
|
1054
|
+
* their words before a tool. A streaming turn's placeholder already
|
|
1055
|
+
* shows its tool lines, and a turn nobody asked for must not speak
|
|
1056
|
+
* before it decides to.
|
|
1057
|
+
*/
|
|
1058
|
+
mayShowProgress() {
|
|
1059
|
+
return !this.streaming && !this.lazy && this.turnStarted && !this.finalized;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Words the turn wrote between tools after its first line: a note in the
|
|
1063
|
+
* progress message, opening it if need be, rather than a post of their
|
|
1064
|
+
* own. Heard by the thread's other agents and stripped from a reply that
|
|
1065
|
+
* repeats them, as a message of their own would be. A note Slack refused
|
|
1066
|
+
* is taken out again, so a later edit never shows words nobody heard.
|
|
1067
|
+
*/
|
|
1068
|
+
noteProgress(text, converted) {
|
|
1069
|
+
this.progressOpen = true;
|
|
1070
|
+
this.clearProgressOpenTimer();
|
|
1071
|
+
const note = { text: converted };
|
|
1072
|
+
this.progressNotes.push(note);
|
|
1073
|
+
this.trimProgressNotes();
|
|
1074
|
+
let heard = '';
|
|
1075
|
+
let settleLanded;
|
|
1076
|
+
const landedNow = new Promise((resolve) => {
|
|
1077
|
+
settleLanded = resolve;
|
|
1078
|
+
});
|
|
1079
|
+
this.onInterim?.(() => heard, landedNow);
|
|
1080
|
+
// Said once a write that held it has landed, whichever write that was,
|
|
1081
|
+
// and only then. Its own write can land after a slow Slack let later
|
|
1082
|
+
// notes trim it out unseen, or be refused after an earlier write showed
|
|
1083
|
+
// it. Unseen, it is not said: the others do not hear it, and a reply
|
|
1084
|
+
// that repeats it keeps it.
|
|
1085
|
+
const settle = () => {
|
|
1086
|
+
if (!note.shown) {
|
|
1087
|
+
this.progressNotes = this.progressNotes.filter((kept) => kept !== note);
|
|
1088
|
+
settleLanded(false);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.interimPosted = true;
|
|
1092
|
+
heard = text;
|
|
1093
|
+
this.lastInterim = text;
|
|
1094
|
+
this.interimSaid.push(text);
|
|
1095
|
+
this.lastSaidWasNote = true;
|
|
1096
|
+
settleLanded(true);
|
|
1097
|
+
};
|
|
1098
|
+
this.queueProgressWrite(settle, settle);
|
|
1099
|
+
return landedNow;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Keep the notes to what one message holds with its first line: the
|
|
1103
|
+
* newest, since the oldest are what a reader has already seen. Past
|
|
1104
|
+
* that the message opens with `…`, and memory stays bounded however
|
|
1105
|
+
* long the task runs.
|
|
1106
|
+
*/
|
|
1107
|
+
trimProgressNotes() {
|
|
1108
|
+
let size = this.progressNotes.reduce((total, note) => total + note.text.length + 2, 0);
|
|
1109
|
+
while (size > PROGRESS_NOTE_ROOM && this.progressNotes.length > 1) {
|
|
1110
|
+
size -= (this.progressNotes.shift()?.text.length ?? 0) + 2;
|
|
1111
|
+
this.progressNotesCut = true;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
progressText(omitLast = false) {
|
|
1115
|
+
const notes = omitLast ? this.progressNotes.slice(0, -1) : this.progressNotes;
|
|
1116
|
+
const parts = [progressHeader(this.progress), ...(this.progressNotesCut ? ['…'] : []), ...notes.map((note) => note.text)];
|
|
1117
|
+
return truncateForSlack(parts.join('\n\n'));
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Put the progress message as it stands now into the thread: posted the
|
|
1121
|
+
* first time, edited after. On the upload chain, behind the turn ahead
|
|
1122
|
+
* and any handover, so it lands below this turn's first line and above
|
|
1123
|
+
* its files and reply, and reads its state when it runs, not when asked.
|
|
1124
|
+
*/
|
|
1125
|
+
queueProgressWrite(landed, refused) {
|
|
1126
|
+
const handover = this.handover;
|
|
1127
|
+
const after = this.after;
|
|
1128
|
+
this.uploadChain = this.uploadChain
|
|
1129
|
+
.then(() => handover)
|
|
1130
|
+
.then(() => after)
|
|
1131
|
+
.then(async () => {
|
|
1132
|
+
this.lastProgressAt = Date.now();
|
|
1133
|
+
const held = [...this.progressNotes];
|
|
1134
|
+
await this.writeProgress(this.progressText());
|
|
1135
|
+
for (const note of held) {
|
|
1136
|
+
note.shown = true;
|
|
1137
|
+
}
|
|
1138
|
+
landed?.();
|
|
1139
|
+
})
|
|
1140
|
+
.catch((error) => {
|
|
1141
|
+
refused?.();
|
|
1142
|
+
this.warn(`could not update the progress message: ${error instanceof Error ? error.message : String(error)}`);
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
async writeProgress(text) {
|
|
1146
|
+
const ref = this.progressRef;
|
|
1147
|
+
if (ref) {
|
|
1148
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const posted = await this.web.chat.postMessage({
|
|
1152
|
+
channel: this.channel,
|
|
1153
|
+
text,
|
|
1154
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
1155
|
+
});
|
|
1156
|
+
if (posted.ts) {
|
|
1157
|
+
this.progressRef = { channel: posted.channel ?? this.channel, ts: posted.ts };
|
|
1158
|
+
}
|
|
1159
|
+
this.placeText(posted.ts);
|
|
1160
|
+
// A post of the app's in this thread takes the status down while the
|
|
1161
|
+
// turn is still working; put it back.
|
|
1162
|
+
this.refreshLoading();
|
|
1163
|
+
}
|
|
1164
|
+
/** Edit the progress message for a step that started or settled, at most once per edit interval. */
|
|
1165
|
+
scheduleProgress() {
|
|
1166
|
+
if (!this.progressOpen || this.finalized || this.progressTimer) {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const delay = Math.max(0, this.editIntervalMs - (Date.now() - this.lastProgressAt));
|
|
1170
|
+
this.progressTimer = setTimeout(() => {
|
|
1171
|
+
this.progressTimer = undefined;
|
|
1172
|
+
if (!this.finalized) {
|
|
1173
|
+
this.queueProgressWrite();
|
|
1174
|
+
}
|
|
1175
|
+
}, delay);
|
|
1176
|
+
this.progressTimer.unref?.();
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Open the progress message once the turn has worked `progressAfterMs`:
|
|
1180
|
+
* now, if it has, or when it will have. Asked on a tool call, so there
|
|
1181
|
+
* is always a step to show; a quick answer ends first and opens none.
|
|
1182
|
+
*/
|
|
1183
|
+
openProgressInTime() {
|
|
1184
|
+
if (this.progressOpen || this.progressOpenTimer) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const open = () => {
|
|
1188
|
+
this.progressOpen = true;
|
|
1189
|
+
this.queueProgressWrite();
|
|
1190
|
+
};
|
|
1191
|
+
const wait = this.progressAfterMs - (Date.now() - this.turnStartedAt);
|
|
1192
|
+
if (wait <= 0) {
|
|
1193
|
+
open();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.progressOpenTimer = setTimeout(() => {
|
|
1197
|
+
this.progressOpenTimer = undefined;
|
|
1198
|
+
if (!this.finalized && !this.progressOpen) {
|
|
1199
|
+
open();
|
|
1200
|
+
}
|
|
1201
|
+
}, wait);
|
|
1202
|
+
this.progressOpenTimer.unref?.();
|
|
1203
|
+
}
|
|
1204
|
+
/** A step of this turn's finished or was refused. */
|
|
1205
|
+
settleProgressStep(callId) {
|
|
1206
|
+
if (!this.mayShowProgress()) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
this.progress.running.delete(callId);
|
|
1210
|
+
this.progress.waitingOn = undefined;
|
|
1211
|
+
this.scheduleProgress();
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* A call of this turn's is waiting on a person: the progress message says
|
|
1215
|
+
* so until the call runs or is refused. The prompt itself is a message
|
|
1216
|
+
* of its own, below it.
|
|
1217
|
+
*/
|
|
1218
|
+
awaitingApproval(toolName, ofThisTurn) {
|
|
1219
|
+
if (!ofThisTurn || !this.mayShowProgress()) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
this.progress.waitingOn = toolName;
|
|
1223
|
+
this.scheduleProgress();
|
|
1224
|
+
}
|
|
1225
|
+
clearProgressOpenTimer() {
|
|
1226
|
+
if (this.progressOpenTimer) {
|
|
1227
|
+
clearTimeout(this.progressOpenTimer);
|
|
1228
|
+
this.progressOpenTimer = undefined;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
clearProgressTimers() {
|
|
1232
|
+
this.clearProgressOpenTimer();
|
|
1233
|
+
if (this.progressTimer) {
|
|
1234
|
+
clearTimeout(this.progressTimer);
|
|
1235
|
+
this.progressTimer = undefined;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The progress message's last edit: how the task ended, and every note
|
|
1240
|
+
* but one that became the reply. Resolves once Slack has answered; a
|
|
1241
|
+
* refusal leaves the last state that landed, and is logged.
|
|
1242
|
+
*/
|
|
1243
|
+
async closeProgress(omitLast) {
|
|
1244
|
+
this.clearProgressTimers();
|
|
1245
|
+
const ref = this.progressRef;
|
|
1246
|
+
if (!ref) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
this.progress.end = this.ending;
|
|
1250
|
+
try {
|
|
1251
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text: this.progressText(omitLast) });
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
this.warn(`could not finish the progress message: ${error instanceof Error ? error.message : String(error)}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Post what the model said before a call that is about to ask for
|
|
1259
|
+
* approval, and resolve once it has landed (or could not), or at once
|
|
1260
|
+
* when there is nothing to post (card 950).
|
|
1261
|
+
*
|
|
1262
|
+
* The prompt is posted before the call starts, so waiting for
|
|
1263
|
+
* `tool.called` put the question above the line saying what the agent
|
|
1264
|
+
* set out to do, and the approver answered without it. The same flush
|
|
1265
|
+
* `tool.called` would have done, only earlier: what it posts is stripped
|
|
1266
|
+
* from the reply as before, and the flush at `tool.called` finds nothing.
|
|
1267
|
+
*/
|
|
1268
|
+
sayBeforeAsking(ofThisTurn) {
|
|
1269
|
+
if (this.finalized) {
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
return this.flushInterim(ofThisTurn);
|
|
871
1273
|
}
|
|
872
1274
|
/**
|
|
873
1275
|
* Everything this turn said in the thread, for its other agents to hear:
|
|
@@ -902,6 +1304,12 @@ class ReplyRenderer {
|
|
|
902
1304
|
}
|
|
903
1305
|
return rest === this.lastInterim ? '' : rest;
|
|
904
1306
|
}
|
|
1307
|
+
/** Point both status lines at the newest call still running, or clear them. */
|
|
1308
|
+
showActiveTool() {
|
|
1309
|
+
const running = [...this.activeTools.values()].at(-1);
|
|
1310
|
+
this.runningTool = running;
|
|
1311
|
+
this.toolLine = running === undefined ? undefined : `⚙ ${running}…`;
|
|
1312
|
+
}
|
|
905
1313
|
currentText() {
|
|
906
1314
|
const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
|
|
907
1315
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
@@ -1011,6 +1419,7 @@ class ReplyRenderer {
|
|
|
1011
1419
|
const hadStatus = this.statusTimer !== undefined;
|
|
1012
1420
|
this.stopLoading();
|
|
1013
1421
|
this.finalized = true;
|
|
1422
|
+
this.clearProgressTimers();
|
|
1014
1423
|
if (!this.streaming) {
|
|
1015
1424
|
await this.after;
|
|
1016
1425
|
await this.statusChain;
|
|
@@ -1039,10 +1448,25 @@ class ReplyRenderer {
|
|
|
1039
1448
|
// tool call) is not posted again, and a turn that said things along
|
|
1040
1449
|
// the way is not followed by `(no reply)`.
|
|
1041
1450
|
await this.uploadChain;
|
|
1451
|
+
// Words the turn ended on, written before its last tool call, are its
|
|
1452
|
+
// answer (card 949): a reply that only repeats them, or an empty one,
|
|
1453
|
+
// posts them as the reply, and the progress message lets them go. Left
|
|
1454
|
+
// as its last note, the answer would sit in the message that says how
|
|
1455
|
+
// the work went, above where anyone looks for it.
|
|
1456
|
+
let promoted = false;
|
|
1042
1457
|
if (this.interimPosted && !this.ref) {
|
|
1043
|
-
|
|
1044
|
-
|
|
1458
|
+
const rest = this.unsaid(reply);
|
|
1459
|
+
promoted = rest.length === 0 && this.lastSaidWasNote && this.lastInterim !== undefined;
|
|
1460
|
+
reply = promoted && this.lastInterim !== undefined ? this.lastInterim : rest;
|
|
1461
|
+
}
|
|
1462
|
+
// Alongside the reply rather than ahead of it: an edit keeps its place,
|
|
1463
|
+
// and the answer should not wait on a round trip for a status line.
|
|
1464
|
+
// Words promoted to the reply are the exception: they leave the
|
|
1465
|
+
// progress message only once the reply holding them has landed, so a
|
|
1466
|
+
// reply Slack refuses does not take the answer out of the thread.
|
|
1467
|
+
const closed = promoted ? undefined : this.closeProgress(false);
|
|
1045
1468
|
if (!this.ref && this.interimPosted && reply.trim().length === 0) {
|
|
1469
|
+
await (closed ?? this.closeProgress(false));
|
|
1046
1470
|
// Nothing posts after this to take the status down, so it is cleared.
|
|
1047
1471
|
if (hadStatus) {
|
|
1048
1472
|
this.setStatus('');
|
|
@@ -1051,7 +1475,7 @@ class ReplyRenderer {
|
|
|
1051
1475
|
return this.outcome(true, true);
|
|
1052
1476
|
}
|
|
1053
1477
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
1054
|
-
const chunks =
|
|
1478
|
+
const chunks = this.chunksOf(text);
|
|
1055
1479
|
// No placeholder — a handover could not open a fresh one — and the
|
|
1056
1480
|
// reply is a message of its own rather than nothing at all.
|
|
1057
1481
|
const rest = this.ref ? chunks.slice(1) : chunks;
|
|
@@ -1081,6 +1505,7 @@ class ReplyRenderer {
|
|
|
1081
1505
|
// avoid, so it is taken back like an abandoned attempt's. One Slack
|
|
1082
1506
|
// will not let go stands, and a message the thread can see is the
|
|
1083
1507
|
// last thing said whatever it says.
|
|
1508
|
+
await (closed ?? this.closeProgress(published));
|
|
1084
1509
|
let stranded = false;
|
|
1085
1510
|
if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
|
|
1086
1511
|
stranded = !(await this.retract());
|
|
@@ -1102,6 +1527,10 @@ class ReplyRenderer {
|
|
|
1102
1527
|
}
|
|
1103
1528
|
return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
|
|
1104
1529
|
}
|
|
1530
|
+
/** This turn's text as the messages it is posted as, in this agent's reply mode. */
|
|
1531
|
+
chunksOf(text) {
|
|
1532
|
+
return messageChunks(text, this.texts ? 'texts' : undefined);
|
|
1533
|
+
}
|
|
1105
1534
|
placeText(ts) {
|
|
1106
1535
|
if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
|
|
1107
1536
|
this.textAt = ts;
|
|
@@ -1157,6 +1586,7 @@ class ReplyRenderer {
|
|
|
1157
1586
|
const hadStatus = this.statusTimer !== undefined;
|
|
1158
1587
|
this.stopLoading();
|
|
1159
1588
|
this.finalized = true;
|
|
1589
|
+
this.clearProgressTimers();
|
|
1160
1590
|
// The barrier `finalizeInOrder` takes, for the same reason: the turn
|
|
1161
1591
|
// queued behind this one waits on `posted`, and settling it before
|
|
1162
1592
|
// the reply ahead has posted would let that turn's reply overtake it.
|
|
@@ -1192,6 +1622,7 @@ class ReplyRenderer {
|
|
|
1192
1622
|
if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
|
|
1193
1623
|
return this.finalize('');
|
|
1194
1624
|
}
|
|
1625
|
+
this.ending = 'stopped';
|
|
1195
1626
|
return this.finalize(`Stopped by <@${by}>.`);
|
|
1196
1627
|
}
|
|
1197
1628
|
/**
|
|
@@ -1217,6 +1648,8 @@ class ReplyRenderer {
|
|
|
1217
1648
|
this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
|
|
1218
1649
|
return this.finalize('');
|
|
1219
1650
|
}
|
|
1651
|
+
// A spent budget is a limit working, not a fault: the task stopped.
|
|
1652
|
+
this.ending = refused ? 'stopped' : 'failed';
|
|
1220
1653
|
return this.finalize(refused ? message : `Something went wrong: ${message}`);
|
|
1221
1654
|
}
|
|
1222
1655
|
/**
|
|
@@ -1453,8 +1886,12 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
|
|
|
1453
1886
|
* Agent text as however many Slack messages it takes. Converted before it
|
|
1454
1887
|
* is split, so a chunk is measured at the length it will be sent at, and so
|
|
1455
1888
|
* no rewrite is ever asked to span a boundary.
|
|
1889
|
+
*
|
|
1890
|
+
* In `texts` mode a reply is first cut into the few messages it is texted
|
|
1891
|
+
* as, each then converted and sized on its own: the cuts never fall inside
|
|
1892
|
+
* code, and nothing else in the conversion crosses a line.
|
|
1456
1893
|
*/
|
|
1457
|
-
const messageChunks = (text) => splitForSlack(toSlackMrkdwn(
|
|
1894
|
+
const messageChunks = (text, replies) => (replies === 'texts' ? textingParts(text) : [text]).flatMap((part) => splitForSlack(toSlackMrkdwn(part)));
|
|
1458
1895
|
const APPROVAL_ACTIONS = {
|
|
1459
1896
|
stratus_approve_once: 'once',
|
|
1460
1897
|
stratus_approve_always: 'always',
|
|
@@ -1629,7 +2066,9 @@ const MIB = 1024 * 1024;
|
|
|
1629
2066
|
* a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
|
|
1630
2067
|
* what the small text says when it could only guess at it.
|
|
1631
2068
|
*/
|
|
1632
|
-
const previewNote = (previews) => previews.map((
|
|
2069
|
+
const previewNote = (previews) => previews.map((preview) => `\n[${previewSentence(preview)}]`).join('');
|
|
2070
|
+
/** One preview, said the same way to a turn and to a read of history. */
|
|
2071
|
+
const previewSentence = ({ name, width, height, original }) => `${name} is shown as Slack's ${width}×${height} preview, because the original ${original}. Small text in it may not be legible: ask for a crop rather than guessing at it.`;
|
|
1633
2072
|
/**
|
|
1634
2073
|
* A message's attachments, sorted into the images the model will be shown
|
|
1635
2074
|
* and everything else. An image is fetched only when Slack's own metadata
|
|
@@ -1820,6 +2259,102 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
|
|
|
1820
2259
|
});
|
|
1821
2260
|
return { images, unread, previews };
|
|
1822
2261
|
};
|
|
2262
|
+
/** A read's byte cap, said in the unit it was most likely set in. */
|
|
2263
|
+
const readCapLabel = (bytes) => (bytes % MIB === 0 ? `${bytes / MIB} MB` : `${bytes} bytes`);
|
|
2264
|
+
/**
|
|
2265
|
+
* The image cap a read asked for, held to what one request can carry, or
|
|
2266
|
+
* undefined when it asked for none: then files come back by name only.
|
|
2267
|
+
*/
|
|
2268
|
+
const historyImageCap = (images) => {
|
|
2269
|
+
if (images === undefined || !Number.isInteger(images.count) || images.count <= 0 || !Number.isFinite(images.bytes) || images.bytes < 1) {
|
|
2270
|
+
return undefined;
|
|
2271
|
+
}
|
|
2272
|
+
return {
|
|
2273
|
+
count: Math.min(images.count, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT),
|
|
2274
|
+
bytes: Math.min(Math.floor(images.bytes), IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES),
|
|
2275
|
+
};
|
|
2276
|
+
};
|
|
2277
|
+
/** What a read is told about an image it tried to fetch and could not show. */
|
|
2278
|
+
const unshownImageNote = (label, reason) => {
|
|
2279
|
+
switch (reason) {
|
|
2280
|
+
case 'too-large':
|
|
2281
|
+
return `${label} is not shown: it is over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB or ${IMAGE_ATTACHMENT_MAX_DIMENSION} pixels a side a model can be sent, and Slack had no smaller preview of it that could be.`;
|
|
2282
|
+
case 'timed-out':
|
|
2283
|
+
return `${label} is not shown: it was not downloaded in time.`;
|
|
2284
|
+
case 'unreadable':
|
|
2285
|
+
return `${label} is not shown: its bytes are not a complete image.`;
|
|
2286
|
+
default:
|
|
2287
|
+
return `${label} is not shown: it could not be downloaded.`;
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
/**
|
|
2291
|
+
* The images on the messages a read returns, fetched for a read that asks
|
|
2292
|
+
* (`ConversationReadRequest.images`) and set on each message with a note
|
|
2293
|
+
* for every image file it does not show. Each image goes through the same
|
|
2294
|
+
* reader a turn's attachments do — Slack's metadata first, the bytes
|
|
2295
|
+
* checked, Slack's preview for one over the per-image limits, and only
|
|
2296
|
+
* from Slack's own file host — one file at a time, in the order the
|
|
2297
|
+
* messages are returned. The read's cap is spent in that order, and once
|
|
2298
|
+
* an image does not fit, none after it is taken, so what is shown is one
|
|
2299
|
+
* unbroken stretch of the read rather than whichever small ones squeezed
|
|
2300
|
+
* in. One deadline covers every download in the read.
|
|
2301
|
+
*/
|
|
2302
|
+
const readHistoryImages = async (sources, cap, botToken, fetchFile, timeoutMs, warn) => {
|
|
2303
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
2304
|
+
const pastBytes = `past this read's cap of ${readCapLabel(cap.bytes)} of images`;
|
|
2305
|
+
let count = 0;
|
|
2306
|
+
let remaining = cap.bytes;
|
|
2307
|
+
let full;
|
|
2308
|
+
for (const { message, files } of sources) {
|
|
2309
|
+
const images = [];
|
|
2310
|
+
const notes = [];
|
|
2311
|
+
for (const file of files) {
|
|
2312
|
+
const label = fileLabel(file);
|
|
2313
|
+
if (!isImageAttachmentMediaType(file.mimetype)) {
|
|
2314
|
+
if (file.mimetype?.startsWith('image/') === true) {
|
|
2315
|
+
notes.push(`${label} is not shown: it is ${file.mimetype}, a kind of image a model cannot be shown.`);
|
|
2316
|
+
}
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
if (full === undefined && count >= cap.count) {
|
|
2320
|
+
full = `past this read's cap of ${cap.count} images`;
|
|
2321
|
+
}
|
|
2322
|
+
// Slack's own size, trusted before any bytes move. An original over
|
|
2323
|
+
// the per-image limit may still go as its preview, whose size only
|
|
2324
|
+
// its bytes can say.
|
|
2325
|
+
if (full === undefined && file.size !== undefined && file.size <= IMAGE_ATTACHMENT_MAX_BYTES && file.size > remaining) {
|
|
2326
|
+
full = pastBytes;
|
|
2327
|
+
}
|
|
2328
|
+
if (full !== undefined) {
|
|
2329
|
+
notes.push(`${label} is not shown: ${full}.`);
|
|
2330
|
+
continue;
|
|
2331
|
+
}
|
|
2332
|
+
const reasons = new Map();
|
|
2333
|
+
const read = await readImageAttachments([file], botToken, fetchFile, timeoutMs, warn, reasons, deadline);
|
|
2334
|
+
const image = read.images[0];
|
|
2335
|
+
if (image === undefined) {
|
|
2336
|
+
notes.push(unshownImageNote(label, reasons.get(file)));
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
const size = Buffer.byteLength(image.data, 'base64');
|
|
2340
|
+
if (size > remaining) {
|
|
2341
|
+
full = pastBytes;
|
|
2342
|
+
notes.push(`${label} is not shown: ${full}.`);
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
remaining -= size;
|
|
2346
|
+
count += 1;
|
|
2347
|
+
images.push(image);
|
|
2348
|
+
notes.push(...read.previews.map(previewSentence));
|
|
2349
|
+
}
|
|
2350
|
+
if (images.length > 0) {
|
|
2351
|
+
message.images = images;
|
|
2352
|
+
}
|
|
2353
|
+
if (notes.length > 0) {
|
|
2354
|
+
message.imageNotes = notes;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
};
|
|
1823
2358
|
/** The most one text attachment may weigh to be read into the message. */
|
|
1824
2359
|
const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
1825
2360
|
/**
|
|
@@ -2304,7 +2839,10 @@ const credentialModal = (post, requestId) => ({
|
|
|
2304
2839
|
type: 'input',
|
|
2305
2840
|
block_id: CREDENTIAL_BLOCK,
|
|
2306
2841
|
label: { type: 'plain_text', text: `Value for ${post.name}` },
|
|
2307
|
-
|
|
2842
|
+
// Several lines: a one-line field flattens the line breaks of a pasted
|
|
2843
|
+
// key file, such as a PEM private key, and the key stops working.
|
|
2844
|
+
element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT, multiline: true },
|
|
2845
|
+
hint: { type: 'plain_text', text: 'Paste the whole value. A key file, such as a PEM private key, keeps its line breaks.' },
|
|
2308
2846
|
},
|
|
2309
2847
|
],
|
|
2310
2848
|
});
|
|
@@ -2351,6 +2889,33 @@ const slackErrorCode = (error) => {
|
|
|
2351
2889
|
const code = error?.data?.error;
|
|
2352
2890
|
return typeof code === 'string' && code.length > 0 ? code : undefined;
|
|
2353
2891
|
};
|
|
2892
|
+
/** Slack's code for a failed call, or the SDK's message when Slack never answered. */
|
|
2893
|
+
const describeSlackError = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
2894
|
+
/** Codes Slack answers when the token itself is refused. */
|
|
2895
|
+
const SLACK_AUTH_ERRORS = new Set(['not_authed', 'invalid_auth', 'account_inactive', 'token_revoked', 'token_expired']);
|
|
2896
|
+
/**
|
|
2897
|
+
* Codes with which Slack refuses a whole `conversations.invite` without
|
|
2898
|
+
* inviting anyone, so `inviteToConversation` can reject and the members
|
|
2899
|
+
* are known unchanged. Everything else, per-person codes and failures that
|
|
2900
|
+
* may have partly worked included, is settled per entry instead.
|
|
2901
|
+
*/
|
|
2902
|
+
const SLACK_INVITE_REFUSALS = new Set([
|
|
2903
|
+
'not_in_channel',
|
|
2904
|
+
'missing_scope',
|
|
2905
|
+
'is_archived',
|
|
2906
|
+
'channel_not_found',
|
|
2907
|
+
'method_not_supported_for_channel_type',
|
|
2908
|
+
'no_user',
|
|
2909
|
+
'ratelimited',
|
|
2910
|
+
...SLACK_AUTH_ERRORS,
|
|
2911
|
+
]);
|
|
2912
|
+
/**
|
|
2913
|
+
* What `channel_not_found` means to an app: Slack says the same for a
|
|
2914
|
+
* wrong id and for a private channel it can't see, and so does
|
|
2915
|
+
* `conversations.info`, so the sentence names both.
|
|
2916
|
+
*/
|
|
2917
|
+
const channelNotFoundSentence = (agentId, conversation) => `Slack found no channel ${conversation} that ${agentId}'s app can see. Check the id (copy it from the channel's link; a name won't work). `
|
|
2918
|
+
+ 'If the id is right, the channel is private, and a member has to invite the app.';
|
|
2354
2919
|
/**
|
|
2355
2920
|
* Orders two Slack message timestamps ("seconds.micros"). Compared as
|
|
2356
2921
|
* integers, not floats: a current ts has more digits than a double holds.
|
|
@@ -2377,13 +2942,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2377
2942
|
const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
|
|
2378
2943
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
2379
2944
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
2380
|
-
const fetchFile = options.fetchFile ?? defaultFetchFile;
|
|
2945
|
+
const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
|
|
2381
2946
|
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
2947
|
+
const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
|
|
2948
|
+
const approvalPromptWaitMs = options.approvalPromptWaitMs ?? DEFAULT_APPROVAL_PROMPT_WAIT_MS;
|
|
2949
|
+
const progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
|
|
2382
2950
|
const now = options.now ?? Date.now;
|
|
2383
2951
|
const connections = [];
|
|
2952
|
+
// The agents this adapter carries: `options.agents` until a reload reads
|
|
2953
|
+
// another list (`options.loadAgents`).
|
|
2954
|
+
let carried = options.agents;
|
|
2384
2955
|
// Every agent this adapter was asked to carry, connected or not — the
|
|
2385
2956
|
// difference between "mine and broken" and "not mine".
|
|
2386
|
-
const configuredAgents = new Set(
|
|
2957
|
+
const configuredAgents = new Set(carried.map((agent) => agent.agentId));
|
|
2387
2958
|
// Renderers queue per session: two messages in one thread share a
|
|
2388
2959
|
// session id, and the gateway serializes their turns — so events route
|
|
2389
2960
|
// to the FIRST registered renderer (the running turn), never a later
|
|
@@ -2577,8 +3148,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2577
3148
|
* is the correct outcome there, and it is also the visible one — the same
|
|
2578
3149
|
* thing a mention has always done when an app is down.
|
|
2579
3150
|
*
|
|
2580
|
-
* Insertion order is `options.agents
|
|
2581
|
-
* `agentNamedIn` deterministic
|
|
3151
|
+
* Insertion order is the carried agents' order (`options.agents`, or the
|
|
3152
|
+
* list a reload read), which is what makes `agentNamedIn` deterministic
|
|
3153
|
+
* across every connection.
|
|
2582
3154
|
*/
|
|
2583
3155
|
const botIdentities = new Map();
|
|
2584
3156
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
@@ -2605,8 +3177,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2605
3177
|
// outstanding.
|
|
2606
3178
|
const rendering = new Set();
|
|
2607
3179
|
const resolvedWhileRendering = new Map();
|
|
3180
|
+
// Requests whose prompt is waiting for its turn's line to land first
|
|
3181
|
+
// (card 950), and those of them settled in that wait: a request answered
|
|
3182
|
+
// or cancelled before anyone was asked has no prompt to post. Bounded the
|
|
3183
|
+
// same way: an id is here only for one bounded wait.
|
|
3184
|
+
const askingAfterLine = new Set();
|
|
3185
|
+
const settledBeforeAsked = new Set();
|
|
2608
3186
|
let unsubscribe;
|
|
2609
3187
|
let gatewayRef;
|
|
3188
|
+
// Set as `stop()` begins, so a reload still connecting an app leaves
|
|
3189
|
+
// nothing up behind the stop.
|
|
3190
|
+
let stopRequested = false;
|
|
3191
|
+
// Sockets between `start()` and `connections`, by the agent they serve
|
|
3192
|
+
// (card 935). A stop can't see them to close them: `connect` closes each
|
|
3193
|
+
// once its start returns, and the stop waits for that, bounded.
|
|
3194
|
+
const socketsStarting = new Map();
|
|
3195
|
+
// Agents whose socket `connect` could not close for a stop: up still,
|
|
3196
|
+
// which the stop has to report.
|
|
3197
|
+
const leftUpAtStop = new Set();
|
|
2610
3198
|
// Everything the adapter owes Slack goes through here, so stop() drains
|
|
2611
3199
|
// it: an approval question or its retraction must reach the workspace
|
|
2612
3200
|
// before the sockets close, or a message keeps offering buttons that
|
|
@@ -3361,7 +3949,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3361
3949
|
};
|
|
3362
3950
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
3363
3951
|
/** An agent's configuration, whether or not its socket came up. */
|
|
3364
|
-
const agentConfigFor = (agentId) =>
|
|
3952
|
+
const agentConfigFor = (agentId) => carried.find((candidate) => candidate.agentId === agentId);
|
|
3365
3953
|
/**
|
|
3366
3954
|
* Which agent each home channel belongs to, keyed `team:channel` because
|
|
3367
3955
|
* a channel id is unique only within its workspace. Settled at start,
|
|
@@ -3377,7 +3965,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3377
3965
|
const settleHomeOwners = () => {
|
|
3378
3966
|
homeOwners.clear();
|
|
3379
3967
|
const claims = new Map();
|
|
3380
|
-
for (const agent of
|
|
3968
|
+
for (const agent of carried) {
|
|
3381
3969
|
const teamId = botIdentities.get(agent.agentId)?.teamId;
|
|
3382
3970
|
if (teamId === undefined) {
|
|
3383
3971
|
continue;
|
|
@@ -3908,6 +4496,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3908
4496
|
names.set(user, await name);
|
|
3909
4497
|
}
|
|
3910
4498
|
const messages = [];
|
|
4499
|
+
const sources = [];
|
|
3911
4500
|
for (const message of raw) {
|
|
3912
4501
|
if (!message.ts) {
|
|
3913
4502
|
continue;
|
|
@@ -3920,7 +4509,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3920
4509
|
|| (message.user ? names.get(message.user) : undefined);
|
|
3921
4510
|
const at = Number.parseFloat(message.ts);
|
|
3922
4511
|
const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
|
|
3923
|
-
|
|
4512
|
+
const read = {
|
|
3924
4513
|
id: message.ts,
|
|
3925
4514
|
author,
|
|
3926
4515
|
text: plainSlackText(connection, message.text ?? '', names),
|
|
@@ -3929,7 +4518,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3929
4518
|
...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
|
|
3930
4519
|
...(message.reply_count ? { replies: message.reply_count } : {}),
|
|
3931
4520
|
...(files.length > 0 ? { files } : {}),
|
|
3932
|
-
}
|
|
4521
|
+
};
|
|
4522
|
+
messages.push(read);
|
|
4523
|
+
if (message.files !== undefined && message.files.length > 0) {
|
|
4524
|
+
sources.push({ message: read, files: message.files });
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
const imageCap = historyImageCap(request.images);
|
|
4528
|
+
if (imageCap !== undefined) {
|
|
4529
|
+
await readHistoryImages(sources, imageCap, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
3933
4530
|
}
|
|
3934
4531
|
return { messages, more, ...(listing ? await listing : {}) };
|
|
3935
4532
|
};
|
|
@@ -3972,15 +4569,60 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3972
4569
|
if (!create || !setTopic || !setPurpose || !inviteUsers) {
|
|
3973
4570
|
throw new Error('slack: this Slack client cannot create channels.');
|
|
3974
4571
|
}
|
|
3975
|
-
// Who each entry is, before anything exists
|
|
3976
|
-
//
|
|
3977
|
-
const
|
|
4572
|
+
// Who each entry is, before anything exists. The creating app is a
|
|
4573
|
+
// member already, so naming it counts as invited.
|
|
4574
|
+
const { own, notInvited, toInvite } = resolveInvitees(connection, request.agentId, invite);
|
|
4575
|
+
const invited = [...own];
|
|
4576
|
+
const kind = request.private === true ? 'private' : 'public';
|
|
4577
|
+
let created;
|
|
4578
|
+
try {
|
|
4579
|
+
created = await create({ name, is_private: request.private === true });
|
|
4580
|
+
}
|
|
4581
|
+
catch (error) {
|
|
4582
|
+
const code = slackErrorCode(error);
|
|
4583
|
+
if (code === 'missing_scope') {
|
|
4584
|
+
throw new Error(`slack: ${request.agentId}'s Slack app can't create ${kind} channels because it lacks the ${request.private === true ? 'groups:write' : 'channels:manage'} scope. Add it to the app's manifest and reinstall the app.`);
|
|
4585
|
+
}
|
|
4586
|
+
if (code === 'name_taken') {
|
|
4587
|
+
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4588
|
+
}
|
|
4589
|
+
throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
|
|
4590
|
+
}
|
|
4591
|
+
const conversation = created.channel?.id;
|
|
4592
|
+
if (!conversation) {
|
|
4593
|
+
throw new Error(`slack: Slack answered the create of #${name} without a channel id, so whether it exists is unknown. Read the channel list before trying again.`);
|
|
4594
|
+
}
|
|
4595
|
+
const notSet = [];
|
|
4596
|
+
if (request.topic) {
|
|
4597
|
+
await setTopic({ channel: conversation, topic: request.topic })
|
|
4598
|
+
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describeSlackError(error)}).` }));
|
|
4599
|
+
}
|
|
4600
|
+
if (request.purpose) {
|
|
4601
|
+
await setPurpose({ channel: conversation, purpose: request.purpose })
|
|
4602
|
+
.catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describeSlackError(error)}).` }));
|
|
4603
|
+
}
|
|
4604
|
+
// The channel exists now, so every code is settled per entry and none
|
|
4605
|
+
// turns into a rejection the agent would retry into a second channel.
|
|
4606
|
+
const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite);
|
|
4607
|
+
invited.push(...settled.invited);
|
|
4608
|
+
notInvited.push(...settled.notInvited);
|
|
4609
|
+
sortByRequest(invite, invited, notInvited);
|
|
4610
|
+
return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
|
|
4611
|
+
};
|
|
4612
|
+
/**
|
|
4613
|
+
* Who each invite entry is, before Slack is asked: agents on this host
|
|
4614
|
+
* become the user their app posts as, in the same workspace only. `own`
|
|
4615
|
+
* holds the entries that are the asking app itself (the agent, or one
|
|
4616
|
+
* sharing its bot user), which no invitation can add.
|
|
4617
|
+
*/
|
|
4618
|
+
const resolveInvitees = (connection, agentId, invite) => {
|
|
4619
|
+
const own = [];
|
|
3978
4620
|
const notInvited = [];
|
|
3979
4621
|
const toInvite = new Map();
|
|
3980
4622
|
for (const who of invite) {
|
|
3981
4623
|
const identity = botIdentities.get(who);
|
|
3982
|
-
if (who ===
|
|
3983
|
-
|
|
4624
|
+
if (who === agentId || identity?.botUserId === connection.botUserId) {
|
|
4625
|
+
own.push(who);
|
|
3984
4626
|
}
|
|
3985
4627
|
else if (configuredAgents.has(who) && !identity?.botUserId) {
|
|
3986
4628
|
notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
|
|
@@ -4000,90 +4642,316 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4000
4642
|
notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
|
|
4001
4643
|
}
|
|
4002
4644
|
}
|
|
4003
|
-
|
|
4004
|
-
|
|
4645
|
+
return { own, notInvited, toInvite };
|
|
4646
|
+
};
|
|
4647
|
+
/**
|
|
4648
|
+
* Sends one `conversations.invite`, force on so a bad id cannot stop the
|
|
4649
|
+
* good ones, and settles each entry of `toInvite` (entry -> Slack user).
|
|
4650
|
+
* Slack's per-id failures name who was refused, and `already_in_channel`
|
|
4651
|
+
* counts as invited. A failed call that does not name everyone leaves
|
|
4652
|
+
* the rest to the channel's members, and who cannot be checked is never
|
|
4653
|
+
* claimed. A call Slack refused with a code `refusesWhole` accepts stops
|
|
4654
|
+
* there, before any members check, with nobody settled and the code in
|
|
4655
|
+
* `refusedWith` for the caller to reject on. Without `refusesWhole`
|
|
4656
|
+
* every code is settled per entry.
|
|
4657
|
+
*/
|
|
4658
|
+
const inviteAtSlack = async (connection, inviteUsers, conversation, toInvite, refusesWhole = () => false) => {
|
|
4659
|
+
const invited = [];
|
|
4660
|
+
const notInvited = [];
|
|
4661
|
+
if (toInvite.size === 0) {
|
|
4662
|
+
return { invited, notInvited };
|
|
4663
|
+
}
|
|
4664
|
+
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4665
|
+
// the SDK throws for an ok: false answer.
|
|
4666
|
+
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4667
|
+
let refused;
|
|
4668
|
+
let failure;
|
|
4005
4669
|
try {
|
|
4006
|
-
|
|
4670
|
+
refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
|
|
4007
4671
|
}
|
|
4008
4672
|
catch (error) {
|
|
4009
4673
|
const code = slackErrorCode(error);
|
|
4010
|
-
if (code
|
|
4011
|
-
|
|
4012
|
-
}
|
|
4013
|
-
if (code === 'name_taken') {
|
|
4014
|
-
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4674
|
+
if (code !== undefined && refusesWhole(code)) {
|
|
4675
|
+
return { invited, notInvited, refusedWith: code };
|
|
4015
4676
|
}
|
|
4016
|
-
|
|
4677
|
+
refused = refusedOf(error?.data?.errors);
|
|
4678
|
+
failure = describeSlackError(error);
|
|
4017
4679
|
}
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
4024
|
-
if (request.topic) {
|
|
4025
|
-
await setTopic({ channel: conversation, topic: request.topic })
|
|
4026
|
-
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
|
|
4027
|
-
}
|
|
4028
|
-
if (request.purpose) {
|
|
4029
|
-
await setPurpose({ channel: conversation, purpose: request.purpose })
|
|
4030
|
-
.catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
|
|
4031
|
-
}
|
|
4032
|
-
if (toInvite.size > 0) {
|
|
4033
|
-
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4034
|
-
// the SDK throws for an ok: false answer.
|
|
4035
|
-
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4036
|
-
let refused;
|
|
4037
|
-
let failure;
|
|
4680
|
+
// A failed call that does not name everyone leaves the rest unknown,
|
|
4681
|
+
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4682
|
+
let members;
|
|
4683
|
+
let membersFailure;
|
|
4684
|
+
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4038
4685
|
try {
|
|
4039
|
-
|
|
4686
|
+
members = new Set();
|
|
4687
|
+
let cursor;
|
|
4688
|
+
do {
|
|
4689
|
+
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4690
|
+
for (const member of page.members ?? []) {
|
|
4691
|
+
members.add(member);
|
|
4692
|
+
}
|
|
4693
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4694
|
+
} while (cursor);
|
|
4040
4695
|
}
|
|
4041
4696
|
catch (error) {
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
}
|
|
4045
|
-
// A failed call that does not name everyone leaves the rest unknown,
|
|
4046
|
-
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4047
|
-
let members;
|
|
4048
|
-
let membersFailure;
|
|
4049
|
-
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4050
|
-
try {
|
|
4051
|
-
members = new Set();
|
|
4052
|
-
let cursor;
|
|
4053
|
-
do {
|
|
4054
|
-
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4055
|
-
for (const member of page.members ?? []) {
|
|
4056
|
-
members.add(member);
|
|
4057
|
-
}
|
|
4058
|
-
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4059
|
-
} while (cursor);
|
|
4060
|
-
}
|
|
4061
|
-
catch (error) {
|
|
4062
|
-
members = undefined;
|
|
4063
|
-
membersFailure = describe(error);
|
|
4064
|
-
}
|
|
4697
|
+
members = undefined;
|
|
4698
|
+
membersFailure = describeSlackError(error);
|
|
4065
4699
|
}
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
})
|
|
4079
|
-
}
|
|
4700
|
+
}
|
|
4701
|
+
for (const [who, user] of toInvite) {
|
|
4702
|
+
const code = refused.get(user);
|
|
4703
|
+
if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
|
|
4704
|
+
invited.push(who);
|
|
4705
|
+
}
|
|
4706
|
+
else if (code !== undefined || members !== undefined) {
|
|
4707
|
+
notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
|
|
4708
|
+
}
|
|
4709
|
+
else {
|
|
4710
|
+
notInvited.push({
|
|
4711
|
+
who,
|
|
4712
|
+
reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
|
|
4713
|
+
});
|
|
4080
4714
|
}
|
|
4081
4715
|
}
|
|
4716
|
+
return { invited, notInvited };
|
|
4717
|
+
};
|
|
4718
|
+
/** Puts both lists back in the order the caller named the entries. */
|
|
4719
|
+
const sortByRequest = (invite, invited, notInvited) => {
|
|
4082
4720
|
const order = new Map(invite.map((who, index) => [who, index]));
|
|
4083
4721
|
const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
|
|
4084
4722
|
invited.sort(byRequest);
|
|
4085
4723
|
notInvited.sort((left, right) => byRequest(left.who, right.who));
|
|
4086
|
-
|
|
4724
|
+
};
|
|
4725
|
+
/**
|
|
4726
|
+
* Joins an existing public channel with the asking agent's own app —
|
|
4727
|
+
* `channel.join`. Anything it did not join rejects with a sentence; an
|
|
4728
|
+
* answer that leaves it unknown says so, and that a retry is safe,
|
|
4729
|
+
* since Slack treats a second join as a no-op.
|
|
4730
|
+
*/
|
|
4731
|
+
const joinConversation = async (request) => {
|
|
4732
|
+
const conversation = request.conversation.trim();
|
|
4733
|
+
if (!conversation) {
|
|
4734
|
+
throw new Error('slack: joining needs a channel id (C…).');
|
|
4735
|
+
}
|
|
4736
|
+
const connection = connectionFor(request.agentId);
|
|
4737
|
+
if (!connection) {
|
|
4738
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4739
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot join ${conversation}.`
|
|
4740
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot join a Slack channel.`);
|
|
4741
|
+
}
|
|
4742
|
+
const { join } = connection.web.conversations;
|
|
4743
|
+
if (!join) {
|
|
4744
|
+
throw new Error('slack: this Slack client cannot join channels.');
|
|
4745
|
+
}
|
|
4746
|
+
const agent = request.agentId;
|
|
4747
|
+
let joined;
|
|
4748
|
+
try {
|
|
4749
|
+
joined = await join({ channel: conversation });
|
|
4750
|
+
}
|
|
4751
|
+
catch (error) {
|
|
4752
|
+
const code = slackErrorCode(error);
|
|
4753
|
+
if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
|
|
4754
|
+
throw new Error(`slack: Slack's answer left it unknown whether ${agent}'s app joined ${conversation} (${describeSlackError(error)}). Trying again is safe: joining a channel twice changes nothing.`);
|
|
4755
|
+
}
|
|
4756
|
+
if (code === 'missing_scope') {
|
|
4757
|
+
throw new Error(`slack: ${agent}'s Slack app can't join channels on its own because it lacks the channels:join scope. Add it to the app's manifest and reinstall the app.`);
|
|
4758
|
+
}
|
|
4759
|
+
if (code === 'method_not_supported_for_channel_type') {
|
|
4760
|
+
throw new Error(`slack: ${conversation} isn't a public channel. Slack never lets an app join a private channel on its own: a member has to invite the app.`);
|
|
4761
|
+
}
|
|
4762
|
+
if (code === 'channel_not_found') {
|
|
4763
|
+
throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)}`);
|
|
4764
|
+
}
|
|
4765
|
+
if (code === 'is_archived') {
|
|
4766
|
+
throw new Error(`slack: ${conversation} is archived, so it can't be joined.`);
|
|
4767
|
+
}
|
|
4768
|
+
if (code === 'too_many_members') {
|
|
4769
|
+
throw new Error(`slack: ${conversation} is at Slack's member limit, so ${agent}'s app can't join it.`);
|
|
4770
|
+
}
|
|
4771
|
+
if (code === 'ratelimited') {
|
|
4772
|
+
throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try joining ${conversation} again later.`);
|
|
4773
|
+
}
|
|
4774
|
+
if (SLACK_AUTH_ERRORS.has(code)) {
|
|
4775
|
+
throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`);
|
|
4776
|
+
}
|
|
4777
|
+
throw new Error(`slack: Slack did not let ${agent}'s app join ${conversation} (${code}).`);
|
|
4778
|
+
}
|
|
4779
|
+
const warnings = [joined.warning, ...(joined.response_metadata?.warnings ?? [])];
|
|
4780
|
+
return { conversation: joined.channel?.id ?? conversation, alreadyMember: warnings.includes('already_in_channel') };
|
|
4781
|
+
};
|
|
4782
|
+
/**
|
|
4783
|
+
* Invites people and agents to an existing channel with the asking
|
|
4784
|
+
* agent's own app — `channel.invite`. It rejects only when nobody was
|
|
4785
|
+
* invited: a request refused before Slack is asked, or a whole call Slack
|
|
4786
|
+
* refused without acting. Everything else is settled per entry by the
|
|
4787
|
+
* same code `createConversation` uses.
|
|
4788
|
+
*/
|
|
4789
|
+
const inviteToConversation = async (request) => {
|
|
4790
|
+
const conversation = request.conversation.trim();
|
|
4791
|
+
if (!conversation) {
|
|
4792
|
+
throw new Error('slack: an invitation needs a channel id (C…).');
|
|
4793
|
+
}
|
|
4794
|
+
const invite = request.invite;
|
|
4795
|
+
if (invite.length === 0) {
|
|
4796
|
+
throw new Error('slack: an invitation needs at least one person or agent to invite.');
|
|
4797
|
+
}
|
|
4798
|
+
if (invite.length > CHANNEL_INVITE_LIMIT) {
|
|
4799
|
+
throw new Error(`slack: that invites ${invite.length.toLocaleString('en-US')} people; Slack invites at most ${CHANNEL_INVITE_LIMIT.toLocaleString('en-US')} at once. Nobody was invited.`);
|
|
4800
|
+
}
|
|
4801
|
+
const connection = connectionFor(request.agentId);
|
|
4802
|
+
if (!connection) {
|
|
4803
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4804
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot invite anyone to ${conversation}.`
|
|
4805
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot invite anyone to a Slack channel.`);
|
|
4806
|
+
}
|
|
4807
|
+
const inviteUsers = connection.web.conversations.invite;
|
|
4808
|
+
if (!inviteUsers) {
|
|
4809
|
+
throw new Error('slack: this Slack client cannot invite people to channels.');
|
|
4810
|
+
}
|
|
4811
|
+
const agent = request.agentId;
|
|
4812
|
+
const { own, notInvited, toInvite } = resolveInvitees(connection, agent, invite);
|
|
4813
|
+
for (const who of own) {
|
|
4814
|
+
notInvited.push({ who, reason: 'That is your own app, which can\'t invite itself. To join a public channel, use channel.join.' });
|
|
4815
|
+
}
|
|
4816
|
+
const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite, (code) => SLACK_INVITE_REFUSALS.has(code));
|
|
4817
|
+
if (settled.refusedWith !== undefined) {
|
|
4818
|
+
const code = settled.refusedWith;
|
|
4819
|
+
const why = code === 'not_in_channel'
|
|
4820
|
+
? `${agent}'s app isn't in ${conversation}, so it can't invite anyone there. For a public channel, join it first with channel.join; for a private one, a member has to invite the app.`
|
|
4821
|
+
: code === 'channel_not_found'
|
|
4822
|
+
? channelNotFoundSentence(agent, conversation)
|
|
4823
|
+
: code === 'missing_scope'
|
|
4824
|
+
? `${agent}'s Slack app lacks the scope to invite people: channels:write.invites for a public channel or groups:write.invites for a private one (channels:manage or groups:write also work). Add it to the app's manifest and reinstall the app.`
|
|
4825
|
+
: code === 'is_archived'
|
|
4826
|
+
? `${conversation} is archived, so nobody can be invited to it.`
|
|
4827
|
+
: code === 'method_not_supported_for_channel_type'
|
|
4828
|
+
? `${conversation} is a direct message or another conversation Slack doesn't allow invitations to.`
|
|
4829
|
+
: code === 'ratelimited'
|
|
4830
|
+
? `Slack is rate-limiting ${agent}'s app; try the invitation again later.`
|
|
4831
|
+
: SLACK_AUTH_ERRORS.has(code)
|
|
4832
|
+
? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
|
|
4833
|
+
: `Slack did not invite anyone to ${conversation} (${code}).`;
|
|
4834
|
+
throw new Error(`slack: ${why} Nobody was invited.`);
|
|
4835
|
+
}
|
|
4836
|
+
const invited = settled.invited;
|
|
4837
|
+
notInvited.push(...settled.notInvited);
|
|
4838
|
+
sortByRequest(invite, invited, notInvited);
|
|
4839
|
+
return { invited, notInvited };
|
|
4840
|
+
};
|
|
4841
|
+
/**
|
|
4842
|
+
* Other agents on this host whose Slack app is this agent's: the same
|
|
4843
|
+
* bot token configured (connected or not), or the same bot user in the
|
|
4844
|
+
* same workspace. Slack's `creator` names the app's bot user, so it
|
|
4845
|
+
* can't tell those agents' channels apart. This sees only this process;
|
|
4846
|
+
* one app per agent is the supported setup (see the README).
|
|
4847
|
+
*/
|
|
4848
|
+
const agentsSharingAppWith = (connection) => {
|
|
4849
|
+
const agentId = connection.config.agentId;
|
|
4850
|
+
const sharing = new Set();
|
|
4851
|
+
for (const other of carried) {
|
|
4852
|
+
if (other.agentId !== agentId && other.botToken === connection.config.botToken) {
|
|
4853
|
+
sharing.add(other.agentId);
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
for (const [other, identity] of botIdentities) {
|
|
4857
|
+
if (other !== agentId && identity.botUserId !== '' && identity.botUserId === connection.botUserId && identity.teamId === connection.teamId) {
|
|
4858
|
+
sharing.add(other);
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
return [...sharing];
|
|
4862
|
+
};
|
|
4863
|
+
/**
|
|
4864
|
+
* Archives a channel the asking agent's own app created — what
|
|
4865
|
+
* `channel.archive` runs on. Everything it refuses is refused before
|
|
4866
|
+
* Slack is asked to archive: an id Slack can't show this app, a direct
|
|
4867
|
+
* message, a channel another user or app created, an app other agents
|
|
4868
|
+
* here share, a channel shared outside the workspace. Slack keeps who
|
|
4869
|
+
* created a channel and whether it is archived, so nothing is stored
|
|
4870
|
+
* here and the rule holds across restarts and hosts. Refusals name the
|
|
4871
|
+
* channel by id only: its name is whatever its members typed.
|
|
4872
|
+
*/
|
|
4873
|
+
const archiveConversation = async (request) => {
|
|
4874
|
+
const conversation = request.conversation.trim();
|
|
4875
|
+
if (!conversation) {
|
|
4876
|
+
throw new Error('slack: archiving needs a channel id (C…).');
|
|
4877
|
+
}
|
|
4878
|
+
const connection = connectionFor(request.agentId);
|
|
4879
|
+
if (!connection) {
|
|
4880
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4881
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot archive ${conversation}.`
|
|
4882
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot archive a Slack channel.`);
|
|
4883
|
+
}
|
|
4884
|
+
const { archive } = connection.web.conversations;
|
|
4885
|
+
if (!archive) {
|
|
4886
|
+
throw new Error('slack: this Slack client cannot archive channels.');
|
|
4887
|
+
}
|
|
4888
|
+
const agent = request.agentId;
|
|
4889
|
+
const nothing = 'Nothing was archived.';
|
|
4890
|
+
const sharing = agentsSharingAppWith(connection);
|
|
4891
|
+
if (sharing.length > 0) {
|
|
4892
|
+
throw new Error(`slack: ${agent}'s Slack app is also ${sharing.map((other) => `${other}'s`).join(' and ')} here, so Stratus can't tell which agent created ${conversation}. `
|
|
4893
|
+
+ `Give each agent its own Slack app. A person can archive it in Slack. ${nothing}`);
|
|
4894
|
+
}
|
|
4895
|
+
let info;
|
|
4896
|
+
try {
|
|
4897
|
+
info = (await connection.web.conversations.info({ channel: conversation })).channel;
|
|
4898
|
+
}
|
|
4899
|
+
catch (error) {
|
|
4900
|
+
const code = slackErrorCode(error);
|
|
4901
|
+
if (code === 'channel_not_found') {
|
|
4902
|
+
throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)} Either way, ${agent} can only archive channels its own app created. ${nothing}`);
|
|
4903
|
+
}
|
|
4904
|
+
if (code === 'ratelimited') {
|
|
4905
|
+
throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later. ${nothing}`);
|
|
4906
|
+
}
|
|
4907
|
+
if (code !== undefined && SLACK_AUTH_ERRORS.has(code)) {
|
|
4908
|
+
throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token. ${nothing}`);
|
|
4909
|
+
}
|
|
4910
|
+
throw new Error(`slack: Slack could not tell ${agent}'s app who created ${conversation} (${describeSlackError(error)}). ${nothing}`);
|
|
4911
|
+
}
|
|
4912
|
+
if (info?.is_im === true || info?.is_mpim === true) {
|
|
4913
|
+
throw new Error(`slack: ${conversation} is a direct message, and only channels can be archived. ${nothing}`);
|
|
4914
|
+
}
|
|
4915
|
+
const creator = info?.creator ?? '';
|
|
4916
|
+
if (creator === '' || connection.botUserId === '' || creator !== connection.botUserId) {
|
|
4917
|
+
throw new Error(`slack: ${agent}'s app didn't create ${conversation}: another user or app did, so ${agent} can't archive it. A person can archive it in Slack. ${nothing}`);
|
|
4918
|
+
}
|
|
4919
|
+
if (info?.is_shared === true || info?.is_ext_shared === true || info?.is_org_shared === true || info?.is_pending_ext_shared === true) {
|
|
4920
|
+
throw new Error(`slack: ${conversation} is shared with another workspace or organization, so archiving it would reach people outside this one. A person can archive it in Slack. ${nothing}`);
|
|
4921
|
+
}
|
|
4922
|
+
if (info?.is_archived === true) {
|
|
4923
|
+
return { conversation, alreadyArchived: true };
|
|
4924
|
+
}
|
|
4925
|
+
try {
|
|
4926
|
+
await archive({ channel: conversation });
|
|
4927
|
+
}
|
|
4928
|
+
catch (error) {
|
|
4929
|
+
const code = slackErrorCode(error);
|
|
4930
|
+
if (code === 'already_archived') {
|
|
4931
|
+
return { conversation, alreadyArchived: true };
|
|
4932
|
+
}
|
|
4933
|
+
warn(`slack: ${agent} could not archive ${conversation} (${describeSlackError(error)})`);
|
|
4934
|
+
if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
|
|
4935
|
+
throw new Error(`slack: Slack's answer left it unknown whether ${agent}'s app archived ${conversation} (${describeSlackError(error)}). It may be archived. Trying again is safe: archiving a channel twice changes nothing.`);
|
|
4936
|
+
}
|
|
4937
|
+
const isPrivate = info?.is_private === true || info?.is_group === true;
|
|
4938
|
+
const why = code === 'missing_scope'
|
|
4939
|
+
? `${agent}'s Slack app can't archive ${isPrivate ? 'private' : 'public'} channels because it lacks the ${isPrivate ? 'groups:write' : 'channels:manage'} scope. Add it to the app's manifest and reinstall the app.`
|
|
4940
|
+
: code === 'not_in_channel'
|
|
4941
|
+
? `${agent}'s app is no longer a member of ${conversation}, so it can't archive it. A member has to add the app back first.`
|
|
4942
|
+
: code === 'restricted_action'
|
|
4943
|
+
? `This workspace limits who can archive channels, so ${agent}'s app can't archive ${conversation}. A person has to do it in Slack.`
|
|
4944
|
+
: code === 'ratelimited'
|
|
4945
|
+
? `Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later.`
|
|
4946
|
+
: code === 'channel_not_found'
|
|
4947
|
+
? channelNotFoundSentence(agent, conversation)
|
|
4948
|
+
: SLACK_AUTH_ERRORS.has(code)
|
|
4949
|
+
? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
|
|
4950
|
+
: `Slack did not let ${agent}'s app archive ${conversation} (${code}).`;
|
|
4951
|
+
throw new Error(`slack: ${why} ${nothing}`);
|
|
4952
|
+
}
|
|
4953
|
+
log(`slack: ${agent} archived ${conversation}`);
|
|
4954
|
+
return { conversation, alreadyArchived: false };
|
|
4087
4955
|
};
|
|
4088
4956
|
/**
|
|
4089
4957
|
* Renders one parked call as buttons and remembers where it went.
|
|
@@ -4530,7 +5398,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4530
5398
|
// last. `NO_REPLY_TEXT` is what a rendered turn with nothing to say
|
|
4531
5399
|
// puts in its own message too. With nothing queued behind, there is no
|
|
4532
5400
|
// order to keep and nothing to say, so the files go on their own.
|
|
4533
|
-
const chunks = routing.reply ? messageChunks(routing.reply) : [];
|
|
5401
|
+
const chunks = routing.reply ? messageChunks(routing.reply, connection.config.replies) : [];
|
|
4534
5402
|
const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
|
|
4535
5403
|
let landed = [];
|
|
4536
5404
|
const uploads = async () => {
|
|
@@ -6498,6 +7366,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6498
7366
|
lazy: judged,
|
|
6499
7367
|
now,
|
|
6500
7368
|
streaming,
|
|
7369
|
+
progressAfterMs,
|
|
7370
|
+
texts: connection.config.replies === 'texts',
|
|
6501
7371
|
statusThread: thread ?? event.ts,
|
|
6502
7372
|
statusWarned: connection.statusWarned,
|
|
6503
7373
|
...(ahead ? { after: ahead } : {}),
|
|
@@ -6816,12 +7686,244 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6816
7686
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
6817
7687
|
}
|
|
6818
7688
|
};
|
|
7689
|
+
/**
|
|
7690
|
+
* One agent's app, authenticated, with who its bot is recorded, and its
|
|
7691
|
+
* socket built but not started: undefined, with a warning, when it would
|
|
7692
|
+
* not authenticate. Recorded before the socket is even built: an app
|
|
7693
|
+
* that never comes up must still be recognizable when somebody names it.
|
|
7694
|
+
*/
|
|
7695
|
+
const authenticate = async (config) => {
|
|
7696
|
+
try {
|
|
7697
|
+
const created = createWeb(config.botToken);
|
|
7698
|
+
const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
|
|
7699
|
+
const auth = await web.auth.test();
|
|
7700
|
+
const botUserId = auth.user_id ?? '';
|
|
7701
|
+
const teamId = auth.team_id ?? '';
|
|
7702
|
+
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
7703
|
+
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
7704
|
+
return {
|
|
7705
|
+
config,
|
|
7706
|
+
web,
|
|
7707
|
+
socket: createSocket(config.appToken),
|
|
7708
|
+
botUserId,
|
|
7709
|
+
teamId,
|
|
7710
|
+
statusWarned: { value: false },
|
|
7711
|
+
};
|
|
7712
|
+
}
|
|
7713
|
+
catch (error) {
|
|
7714
|
+
// One broken app must not take the rest of the fleet down.
|
|
7715
|
+
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7716
|
+
return undefined;
|
|
7717
|
+
}
|
|
7718
|
+
};
|
|
7719
|
+
/**
|
|
7720
|
+
* Starts an authenticated app's socket and serves it. Resolves with the
|
|
7721
|
+
* listener a replay of kept messages delivers through, or undefined when
|
|
7722
|
+
* the socket would not start: authenticated but not serving, it answers
|
|
7723
|
+
* nothing, and stays recognizable so nobody answers in its place.
|
|
7724
|
+
*/
|
|
7725
|
+
const connect = async (connection) => {
|
|
7726
|
+
const { config, socket } = connection;
|
|
7727
|
+
try {
|
|
7728
|
+
// Returns the work it started: the SDK ignores it, and a test's fake
|
|
7729
|
+
// socket waits on it rather than guessing how long it takes.
|
|
7730
|
+
const handle = (inbound) => {
|
|
7731
|
+
const handled = inbound.catch((error) => {
|
|
7732
|
+
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7733
|
+
});
|
|
7734
|
+
inflight.add(handled);
|
|
7735
|
+
void handled.finally(() => inflight.delete(handled));
|
|
7736
|
+
return handled;
|
|
7737
|
+
};
|
|
7738
|
+
// The replay's own deliveries never wait on the hold they release.
|
|
7739
|
+
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
7740
|
+
const onEvent = (args) => {
|
|
7741
|
+
const channel = args.event?.channel;
|
|
7742
|
+
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
7743
|
+
if (hold === undefined) {
|
|
7744
|
+
return onReplay(args);
|
|
7745
|
+
}
|
|
7746
|
+
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
7747
|
+
// message that is only waiting its turn; admitted after the hold.
|
|
7748
|
+
void Promise.resolve(args.ack()).catch((error) => {
|
|
7749
|
+
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
7750
|
+
});
|
|
7751
|
+
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
7752
|
+
};
|
|
7753
|
+
socket.on('app_mention', onEvent);
|
|
7754
|
+
socket.on('message', onEvent);
|
|
7755
|
+
socket.on('interactive', (args) => {
|
|
7756
|
+
const handled = handleInteractive(connection, args).catch((error) => {
|
|
7757
|
+
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7758
|
+
});
|
|
7759
|
+
track(handled);
|
|
7760
|
+
return handled;
|
|
7761
|
+
});
|
|
7762
|
+
const up = (async () => {
|
|
7763
|
+
await socket.start();
|
|
7764
|
+
// A stop that began while this socket was starting has already
|
|
7765
|
+
// disconnected what it could see; one left up here would answer
|
|
7766
|
+
// beside the next daemon. Checked in the same step that adds it
|
|
7767
|
+
// to `connections`, so a stop either sees it there or finds it
|
|
7768
|
+
// here.
|
|
7769
|
+
if (stopRequested) {
|
|
7770
|
+
if (!await disconnect(connection, 'the adapter is stopping')) {
|
|
7771
|
+
leftUpAtStop.add(config.agentId);
|
|
7772
|
+
}
|
|
7773
|
+
return false;
|
|
7774
|
+
}
|
|
7775
|
+
connections.push(connection);
|
|
7776
|
+
return true;
|
|
7777
|
+
})();
|
|
7778
|
+
socketsStarting.set(up, config.agentId);
|
|
7779
|
+
try {
|
|
7780
|
+
if (!await up) {
|
|
7781
|
+
return undefined;
|
|
7782
|
+
}
|
|
7783
|
+
}
|
|
7784
|
+
finally {
|
|
7785
|
+
socketsStarting.delete(up);
|
|
7786
|
+
}
|
|
7787
|
+
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
7788
|
+
return onReplay;
|
|
7789
|
+
}
|
|
7790
|
+
catch (error) {
|
|
7791
|
+
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7792
|
+
return undefined;
|
|
7793
|
+
}
|
|
7794
|
+
};
|
|
7795
|
+
/** Closes an app's socket; false, with a warning, when the close was refused. */
|
|
7796
|
+
const disconnect = async (connection, reason) => {
|
|
7797
|
+
try {
|
|
7798
|
+
await connection.socket.disconnect();
|
|
7799
|
+
return true;
|
|
7800
|
+
}
|
|
7801
|
+
catch (error) {
|
|
7802
|
+
warn(`slack: could not disconnect ${connection.config.agentId} (${reason}): ${error instanceof Error ? error.message : String(error)}`);
|
|
7803
|
+
return false;
|
|
7804
|
+
}
|
|
7805
|
+
};
|
|
7806
|
+
const sameApp = (a, b) => a.appToken === b.appToken && a.botToken === b.botToken;
|
|
7807
|
+
/**
|
|
7808
|
+
* Brings the connected apps in line with the agents to carry now (card
|
|
7809
|
+
* 894): connects an agent whose app is new, or that the roster has only
|
|
7810
|
+
* now, or that did not come up before; reconnects one whose tokens
|
|
7811
|
+
* changed; disconnects one whose tokens were removed or whose soul is
|
|
7812
|
+
* gone. Every other socket is left alone, so a conversation on it never
|
|
7813
|
+
* notices. Settings come with the list `options.loadAgents` reads, which
|
|
7814
|
+
* for the daemon is the config it started with.
|
|
7815
|
+
*/
|
|
7816
|
+
const reconcile = async () => {
|
|
7817
|
+
const gateway = gatewayRef;
|
|
7818
|
+
if (gateway === undefined || stopRequested || options.loadAgents === undefined) {
|
|
7819
|
+
return;
|
|
7820
|
+
}
|
|
7821
|
+
const next = await options.loadAgents();
|
|
7822
|
+
if (gatewayRef !== gateway || stopRequested) {
|
|
7823
|
+
return;
|
|
7824
|
+
}
|
|
7825
|
+
const known = new Set(gateway.agents().map((agent) => agent.id));
|
|
7826
|
+
const wanted = new Map(next.filter((config) => known.has(config.agentId)).map((config) => [config.agentId, config]));
|
|
7827
|
+
const connected = new Map(connections.map((connection) => [connection.config.agentId, connection]));
|
|
7828
|
+
const leaving = [...connected.values()].filter((connection) => {
|
|
7829
|
+
const config = wanted.get(connection.config.agentId);
|
|
7830
|
+
return config === undefined || !sameApp(config, connection.config);
|
|
7831
|
+
});
|
|
7832
|
+
// Closed first, and only a closed one is let go of. A socket whose close
|
|
7833
|
+
// was refused may still deliver, so it stays this agent's connection,
|
|
7834
|
+
// on the tokens it has, and nothing is started beside it: a second
|
|
7835
|
+
// socket for the agent would answer every message twice, and a removed
|
|
7836
|
+
// app would keep answering while reported gone. A restart clears it.
|
|
7837
|
+
for (const connection of leaving) {
|
|
7838
|
+
const agentId = connection.config.agentId;
|
|
7839
|
+
const reason = !known.has(agentId)
|
|
7840
|
+
? 'its soul is gone'
|
|
7841
|
+
: wanted.has(agentId) ? 'its tokens changed' : 'its tokens were removed';
|
|
7842
|
+
if (!await disconnect(connection, reason)) {
|
|
7843
|
+
warn(`slack: ${agentId} stays connected with the app it had until a restart, since its socket would not close`);
|
|
7844
|
+
continue;
|
|
7845
|
+
}
|
|
7846
|
+
const index = connections.indexOf(connection);
|
|
7847
|
+
if (index >= 0) {
|
|
7848
|
+
connections.splice(index, 1);
|
|
7849
|
+
}
|
|
7850
|
+
log(`slack: ${agentId} disconnected (${reason})`);
|
|
7851
|
+
}
|
|
7852
|
+
// What is still connected now, stuck sockets included, serves on.
|
|
7853
|
+
const serving = (agentId) => connections.find((connection) => connection.config.agentId === agentId);
|
|
7854
|
+
const arriving = [...wanted.values()].filter((config) => serving(config.agentId) === undefined);
|
|
7855
|
+
// A connected app keeps the configuration it is serving with.
|
|
7856
|
+
carried = [
|
|
7857
|
+
...next.map((config) => serving(config.agentId)?.config ?? config),
|
|
7858
|
+
...connections.filter((connection) => !next.some((config) => config.agentId === connection.config.agentId)).map((connection) => connection.config),
|
|
7859
|
+
];
|
|
7860
|
+
configuredAgents.clear();
|
|
7861
|
+
for (const config of carried) {
|
|
7862
|
+
configuredAgents.add(config.agentId);
|
|
7863
|
+
}
|
|
7864
|
+
// Who each remaining bot is, in the carried order: an app no longer
|
|
7865
|
+
// carried is nobody's to recognize, and a new one is learned below.
|
|
7866
|
+
const kept = new Map(botIdentities);
|
|
7867
|
+
botIdentities.clear();
|
|
7868
|
+
for (const config of carried) {
|
|
7869
|
+
const identity = kept.get(config.agentId);
|
|
7870
|
+
if (identity !== undefined && serving(config.agentId) !== undefined) {
|
|
7871
|
+
botIdentities.set(config.agentId, identity);
|
|
7872
|
+
}
|
|
7873
|
+
}
|
|
7874
|
+
for (const config of next) {
|
|
7875
|
+
if (!known.has(config.agentId) && serving(config.agentId) === undefined) {
|
|
7876
|
+
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
7877
|
+
}
|
|
7878
|
+
}
|
|
7879
|
+
// Every new identity first, then the sockets, as `start` does: a
|
|
7880
|
+
// mention of an agent not yet known would read as an untagged reply.
|
|
7881
|
+
const authenticated = [];
|
|
7882
|
+
for (const config of arriving) {
|
|
7883
|
+
if (stopRequested) {
|
|
7884
|
+
return;
|
|
7885
|
+
}
|
|
7886
|
+
const connection = await authenticate(config);
|
|
7887
|
+
if (connection) {
|
|
7888
|
+
authenticated.push(connection);
|
|
7889
|
+
}
|
|
7890
|
+
}
|
|
7891
|
+
if (authenticated.length > 0) {
|
|
7892
|
+
const learned = new Map(botIdentities);
|
|
7893
|
+
botIdentities.clear();
|
|
7894
|
+
for (const config of carried) {
|
|
7895
|
+
const identity = learned.get(config.agentId);
|
|
7896
|
+
if (identity !== undefined) {
|
|
7897
|
+
botIdentities.set(config.agentId, identity);
|
|
7898
|
+
}
|
|
7899
|
+
}
|
|
7900
|
+
}
|
|
7901
|
+
settleHomeOwners();
|
|
7902
|
+
for (const connection of authenticated) {
|
|
7903
|
+
if (stopRequested) {
|
|
7904
|
+
return;
|
|
7905
|
+
}
|
|
7906
|
+
await connect(connection);
|
|
7907
|
+
}
|
|
7908
|
+
};
|
|
7909
|
+
// One reload at a time: the gateway can call again before an earlier one
|
|
7910
|
+
// settled, since it stops waiting after a bound.
|
|
7911
|
+
let reloading = Promise.resolve();
|
|
7912
|
+
const reload = () => {
|
|
7913
|
+
const run = reloading.then(reconcile, reconcile);
|
|
7914
|
+
reloading = run.catch(() => undefined);
|
|
7915
|
+
return run;
|
|
7916
|
+
};
|
|
6819
7917
|
return {
|
|
6820
7918
|
name: 'slack',
|
|
7919
|
+
...(options.loadAgents !== undefined ? { reload } : {}),
|
|
6821
7920
|
resolveOutbound,
|
|
6822
7921
|
readConversation,
|
|
6823
7922
|
sessionConversation,
|
|
6824
7923
|
createConversation,
|
|
7924
|
+
joinConversation,
|
|
7925
|
+
inviteToConversation,
|
|
7926
|
+
archiveConversation,
|
|
6825
7927
|
// A channel id is unique only within its workspace, so a standing
|
|
6826
7928
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
6827
7929
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
@@ -6831,23 +7933,21 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6831
7933
|
credentialEnvForms: true,
|
|
6832
7934
|
async start(gateway) {
|
|
6833
7935
|
gatewayRef = gateway;
|
|
7936
|
+
stopRequested = false;
|
|
7937
|
+
leftUpAtStop.clear();
|
|
6834
7938
|
// Nothing counts as seen live until every socket is up (Codex
|
|
6835
7939
|
// 4321128637): a message sent while the sockets are still coming up
|
|
6836
7940
|
// reaches none of them, so a read stopping before it stops short.
|
|
6837
7941
|
liveFrom = NOT_LIVE_YET;
|
|
6838
7942
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
6839
|
-
// Approval traffic is not part of a reply: it gets its own message
|
|
6840
|
-
// with its own lifetime, and routing it through the running turn's
|
|
6841
|
-
// renderer would fold a question into the answer being streamed.
|
|
6842
|
-
if (event.type === 'tool.approval-requested') {
|
|
6843
|
-
track(renderApprovalRequest(event));
|
|
6844
|
-
return;
|
|
6845
|
-
}
|
|
6846
7943
|
if (event.type === 'credential.provided') {
|
|
6847
7944
|
track(settleCredentialPost(event));
|
|
6848
7945
|
return;
|
|
6849
7946
|
}
|
|
6850
7947
|
if (event.type === 'tool.approval-resolved') {
|
|
7948
|
+
if (askingAfterLine.has(event.requestId)) {
|
|
7949
|
+
settledBeforeAsked.add(event.requestId);
|
|
7950
|
+
}
|
|
6851
7951
|
// The question was posted into the turn's thread, which took the
|
|
6852
7952
|
// loading status down with it; the turn works on once answered.
|
|
6853
7953
|
track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
|
|
@@ -6900,6 +8000,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6900
8000
|
// turn from a renderer still waiting to start, and the renderer
|
|
6901
8001
|
// keeps what it is handed, as it always has.
|
|
6902
8002
|
const foreign = exact && !rendered;
|
|
8003
|
+
// Approval traffic is not part of a reply: it gets its own message
|
|
8004
|
+
// with its own lifetime, and routing it through the running turn's
|
|
8005
|
+
// renderer would fold a question into the answer being streamed.
|
|
8006
|
+
// What the turn wrote before the call goes out first, though, so
|
|
8007
|
+
// the approver reads the line saying what the agent is doing above
|
|
8008
|
+
// the question it leads to (card 950). The wait is bounded: a line
|
|
8009
|
+
// Slack is slow to take must not keep the question from anyone.
|
|
8010
|
+
if (event.type === 'tool.approval-requested') {
|
|
8011
|
+
head?.awaitingApproval(event.call.toolName, !foreign);
|
|
8012
|
+
const said = head?.sayBeforeAsking(!foreign);
|
|
8013
|
+
if (!said) {
|
|
8014
|
+
track(renderApprovalRequest(event));
|
|
8015
|
+
return;
|
|
8016
|
+
}
|
|
8017
|
+
askingAfterLine.add(event.requestId);
|
|
8018
|
+
track(settledWithin(said, approvalPromptWaitMs).then(async () => {
|
|
8019
|
+
askingAfterLine.delete(event.requestId);
|
|
8020
|
+
if (settledBeforeAsked.delete(event.requestId)) {
|
|
8021
|
+
return;
|
|
8022
|
+
}
|
|
8023
|
+
await renderApprovalRequest(event);
|
|
8024
|
+
}));
|
|
8025
|
+
return;
|
|
8026
|
+
}
|
|
6903
8027
|
if (event.type === 'tool.completed' && event.result.ok && (foreign || !head)) {
|
|
6904
8028
|
const produced = collectFilePaths(event.result);
|
|
6905
8029
|
if (produced.length > 0) {
|
|
@@ -6956,33 +8080,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6956
8080
|
// agent not yet known reads as an untagged reply — which the agent
|
|
6957
8081
|
// holding that thread would then answer.
|
|
6958
8082
|
const authenticated = [];
|
|
6959
|
-
for (const config of
|
|
8083
|
+
for (const config of carried) {
|
|
6960
8084
|
if (!known.has(config.agentId)) {
|
|
6961
8085
|
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
6962
8086
|
continue;
|
|
6963
8087
|
}
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
const auth = await web.auth.test();
|
|
6968
|
-
const botUserId = auth.user_id ?? '';
|
|
6969
|
-
const teamId = auth.team_id ?? '';
|
|
6970
|
-
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
6971
|
-
// Recorded before the socket is even built: an app that never
|
|
6972
|
-
// comes up must still be recognizable when somebody names it.
|
|
6973
|
-
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
6974
|
-
authenticated.push({
|
|
6975
|
-
config,
|
|
6976
|
-
web,
|
|
6977
|
-
socket: createSocket(config.appToken),
|
|
6978
|
-
botUserId,
|
|
6979
|
-
teamId,
|
|
6980
|
-
statusWarned: { value: false },
|
|
6981
|
-
});
|
|
6982
|
-
}
|
|
6983
|
-
catch (error) {
|
|
6984
|
-
// One broken app must not take the rest of the fleet down.
|
|
6985
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8088
|
+
const connection = await authenticate(config);
|
|
8089
|
+
if (connection) {
|
|
8090
|
+
authenticated.push(connection);
|
|
6986
8091
|
}
|
|
6987
8092
|
}
|
|
6988
8093
|
settleHomeOwners();
|
|
@@ -7005,51 +8110,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7005
8110
|
}
|
|
7006
8111
|
}
|
|
7007
8112
|
for (const connection of authenticated) {
|
|
7008
|
-
const
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
// socket waits on it rather than guessing how long it takes.
|
|
7012
|
-
const handle = (inbound) => {
|
|
7013
|
-
const handled = inbound.catch((error) => {
|
|
7014
|
-
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7015
|
-
});
|
|
7016
|
-
inflight.add(handled);
|
|
7017
|
-
void handled.finally(() => inflight.delete(handled));
|
|
7018
|
-
return handled;
|
|
7019
|
-
};
|
|
7020
|
-
// The replay's own deliveries never wait on the hold they release.
|
|
7021
|
-
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
7022
|
-
const onEvent = (args) => {
|
|
7023
|
-
const channel = args.event?.channel;
|
|
7024
|
-
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
7025
|
-
if (hold === undefined) {
|
|
7026
|
-
return onReplay(args);
|
|
7027
|
-
}
|
|
7028
|
-
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
7029
|
-
// message that is only waiting its turn; admitted after the hold.
|
|
7030
|
-
void Promise.resolve(args.ack()).catch((error) => {
|
|
7031
|
-
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
7032
|
-
});
|
|
7033
|
-
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
7034
|
-
};
|
|
7035
|
-
socket.on('app_mention', onEvent);
|
|
7036
|
-
socket.on('message', onEvent);
|
|
7037
|
-
listeners.set(config.agentId, onReplay);
|
|
7038
|
-
socket.on('interactive', (args) => {
|
|
7039
|
-
const handled = handleInteractive(connection, args).catch((error) => {
|
|
7040
|
-
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7041
|
-
});
|
|
7042
|
-
track(handled);
|
|
7043
|
-
return handled;
|
|
7044
|
-
});
|
|
7045
|
-
await socket.start();
|
|
7046
|
-
connections.push(connection);
|
|
7047
|
-
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
7048
|
-
}
|
|
7049
|
-
catch (error) {
|
|
7050
|
-
// Authenticated but not serving: it answers nothing, and stays
|
|
7051
|
-
// recognizable so nobody answers in its place.
|
|
7052
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8113
|
+
const onReplay = await connect(connection);
|
|
8114
|
+
if (onReplay) {
|
|
8115
|
+
listeners.set(connection.config.agentId, onReplay);
|
|
7053
8116
|
}
|
|
7054
8117
|
}
|
|
7055
8118
|
const liveMs = now() + LIVE_FROM_MARGIN_MS;
|
|
@@ -7063,11 +8126,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7063
8126
|
track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
|
|
7064
8127
|
},
|
|
7065
8128
|
async stop() {
|
|
8129
|
+
stopRequested = true;
|
|
7066
8130
|
replayStopping?.abort();
|
|
7067
8131
|
releaseAllConversations();
|
|
7068
8132
|
// Stop intake first, so no new INBOUND event can slip into
|
|
7069
|
-
// `inflight` while the drain below runs.
|
|
7070
|
-
|
|
8133
|
+
// `inflight` while the drain below runs. A close that is refused
|
|
8134
|
+
// leaves a socket that may still deliver (card 935), and so does one
|
|
8135
|
+
// still starting that does not come down in time: the stop rejects
|
|
8136
|
+
// naming them, after the drain, and the gateway counts that as not
|
|
8137
|
+
// drained, so a restart exits for the service manager rather than
|
|
8138
|
+
// start a second daemon answering beside them.
|
|
8139
|
+
const closing = Promise.all(connections.map(async (connection) => await disconnect(connection, 'the adapter is stopping') ? undefined : connection.config.agentId));
|
|
8140
|
+
// Whatever is still in here once the wait ends never came up or down.
|
|
8141
|
+
const starting = new Map(socketsStarting);
|
|
8142
|
+
let waited;
|
|
8143
|
+
await Promise.race([
|
|
8144
|
+
Promise.allSettled([...starting.keys()].map((up) => up.finally(() => starting.delete(up)))),
|
|
8145
|
+
new Promise((resolve) => { waited = setTimeout(resolve, stopWaitMs); }),
|
|
8146
|
+
]);
|
|
8147
|
+
clearTimeout(waited);
|
|
8148
|
+
const refused = [...new Set([...(await closing).filter((agentId) => agentId !== undefined), ...leftUpAtStop])];
|
|
8149
|
+
const stillStarting = [...new Set(starting.values())];
|
|
8150
|
+
leftUpAtStop.clear();
|
|
7071
8151
|
// Until it stays empty, not once. The bus subscription is still live
|
|
7072
8152
|
// here — it comes down after the drain, because a turn finishing
|
|
7073
8153
|
// inside the drain still has an outcome to render — so a turn the
|
|
@@ -7101,6 +8181,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7101
8181
|
rendering.clear();
|
|
7102
8182
|
resolvedWhileRendering.clear();
|
|
7103
8183
|
credentialsPosting.clear();
|
|
8184
|
+
const left = [
|
|
8185
|
+
...(refused.length > 0 ? [`the socket for ${refused.join(', ')} would not close`] : []),
|
|
8186
|
+
...(stillStarting.length > 0 ? [`the socket for ${stillStarting.join(', ')} was still starting after ${stopWaitMs}ms`] : []),
|
|
8187
|
+
];
|
|
8188
|
+
if (left.length > 0) {
|
|
8189
|
+
throw new Error(`${left.join('; ')}, so it may still deliver messages`);
|
|
8190
|
+
}
|
|
7104
8191
|
},
|
|
7105
8192
|
};
|
|
7106
8193
|
};
|