@stratusagent/channel-slack 0.11.5 → 0.11.7
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 +99 -15
- package/dist/index.d.ts +55 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +987 -89
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts +19 -0
- package/dist/mrkdwn.d.ts.map +1 -1
- package/dist/mrkdwn.js +345 -3
- package/dist/mrkdwn.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +5 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readFile } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
|
|
6
|
-
import { channelSessionKey, } from '@stratusagent/channels';
|
|
7
|
-
import { toSlackMrkdwn } from "./mrkdwn.js";
|
|
5
|
+
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
|
|
6
|
+
import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
|
|
7
|
+
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
8
|
/**
|
|
9
9
|
* The longest display name the model is shown. Slack caps profile names
|
|
10
10
|
* well under this; the bound is for a profile that is not a name.
|
|
@@ -141,6 +141,68 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
|
|
|
141
141
|
return { status: response.status, ...typed, body: Buffer.concat(chunks) };
|
|
142
142
|
};
|
|
143
143
|
const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
|
|
144
|
+
/**
|
|
145
|
+
* The dispatch's idempotency key for a message's dedupe key: the key itself
|
|
146
|
+
* when it fits the gateway's bound, and a digest of it when it does not —
|
|
147
|
+
* an agent id is not length-bounded (overlong ids that predate validation
|
|
148
|
+
* are kept), and a key over the bound would refuse every message for that
|
|
149
|
+
* agent. Deterministic, so a redelivery after a restart maps to the same.
|
|
150
|
+
*/
|
|
151
|
+
const dispatchKeyFor = (eventKey) => eventKey.length <= MAX_IDEMPOTENCY_KEY_LENGTH ? eventKey : `slack-sha256:${createHash('sha256').update(eventKey).digest('hex')}`;
|
|
152
|
+
const NOTHING_REPORTED = { said: false, landed: [] };
|
|
153
|
+
/**
|
|
154
|
+
* How far a file's change time may run past the record of the tool result
|
|
155
|
+
* naming it: the rest of the millisecond the record was stamped in, since
|
|
156
|
+
* the record is whole milliseconds and the file time is finer. No more,
|
|
157
|
+
* because any window is one an overwrite fits inside — two seconds was, in
|
|
158
|
+
* review. A filesystem that rounds file times up, coarser than that, fails
|
|
159
|
+
* closed: the file is warned about and not uploaded, which a repeat can
|
|
160
|
+
* afford and a wrong attachment cannot.
|
|
161
|
+
*/
|
|
162
|
+
const PRODUCED_AT_SLACK_MS = 1;
|
|
163
|
+
/**
|
|
164
|
+
* A file a finished turn produced, read for a repeat of it — refused unless
|
|
165
|
+
* it is provably the file the turn left there. The transcript keeps a path,
|
|
166
|
+
* not the bytes, and a repeat can come after a later turn or another
|
|
167
|
+
* process has written the same path; uploading what is there now would
|
|
168
|
+
* answer the old message with a different file, or a sensitive one.
|
|
169
|
+
*
|
|
170
|
+
* The change time, not the modification time, is what is compared: it
|
|
171
|
+
* moves on every write, on a rename into the path, and on `utimes` itself,
|
|
172
|
+
* so neither an older file renamed in nor a backdated one passes. Then the
|
|
173
|
+
* path has to name the same file before and after it is opened — the open
|
|
174
|
+
* handle's file, when it is not a link — and the handle has to be unchanged
|
|
175
|
+
* after the read, so a swap or a write landing between the checks and the
|
|
176
|
+
* read is caught rather than uploaded.
|
|
177
|
+
*/
|
|
178
|
+
const readUnchangedSince = async (filePath, producedAt) => {
|
|
179
|
+
// Exclusive: a change time at or past it was a change after the record.
|
|
180
|
+
const writtenBefore = producedAt + PRODUCED_AT_SLACK_MS;
|
|
181
|
+
const changed = () => new Error('it was changed after the turn that produced it, so it may hold another file now; it was not uploaded.');
|
|
182
|
+
const named = await lstat(filePath);
|
|
183
|
+
if (named.ctimeMs >= writtenBefore) {
|
|
184
|
+
throw changed();
|
|
185
|
+
}
|
|
186
|
+
const handle = await open(filePath, 'r');
|
|
187
|
+
try {
|
|
188
|
+
const opened = await handle.stat();
|
|
189
|
+
const stillNamed = await lstat(filePath);
|
|
190
|
+
const samePath = stillNamed.ino === named.ino && stillNamed.dev === named.dev && stillNamed.ctimeMs === named.ctimeMs;
|
|
191
|
+
const sameFile = named.isSymbolicLink() || (opened.ino === named.ino && opened.dev === named.dev);
|
|
192
|
+
if (!samePath || !sameFile || opened.ctimeMs >= writtenBefore) {
|
|
193
|
+
throw changed();
|
|
194
|
+
}
|
|
195
|
+
const data = await handle.readFile();
|
|
196
|
+
const after = await handle.stat();
|
|
197
|
+
if (after.ctimeMs !== opened.ctimeMs || after.size !== data.length) {
|
|
198
|
+
throw changed();
|
|
199
|
+
}
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await handle.close();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
144
206
|
/**
|
|
145
207
|
* A clock reading as a Slack timestamp, for a file whose upload answered
|
|
146
208
|
* with no share ts. Slack timestamps are epoch seconds with six decimals,
|
|
@@ -205,6 +267,15 @@ class ReplyRenderer {
|
|
|
205
267
|
* writes into the fresh one rather than over the reply it was given to.
|
|
206
268
|
*/
|
|
207
269
|
handover = Promise.resolve();
|
|
270
|
+
/**
|
|
271
|
+
* The report of a turn nobody was rendering, made ahead of this renderer
|
|
272
|
+
* while it waited — through its placeholder or as messages of their own
|
|
273
|
+
* — resolving to what of that outcome reached the thread. Set when the
|
|
274
|
+
* report is claimed, before the turn it reports lets this renderer's
|
|
275
|
+
* dispatch go, so a repeat resolving after it can tell its own turn was
|
|
276
|
+
* that one, and post only what the report did not.
|
|
277
|
+
*/
|
|
278
|
+
outcomeReportedAhead;
|
|
208
279
|
/**
|
|
209
280
|
* Bumped by a handover. An edit scheduled before it carries the text of
|
|
210
281
|
* the turn that was handed over — the recovery's stream, routed to this
|
|
@@ -280,6 +351,13 @@ class ReplyRenderer {
|
|
|
280
351
|
/** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
|
|
281
352
|
runningTool;
|
|
282
353
|
web;
|
|
354
|
+
/**
|
|
355
|
+
* The one person in the conversation when this turn is a DM: whoever sent
|
|
356
|
+
* the message. Undefined in a channel, where who can see a thread is
|
|
357
|
+
* Slack's membership to answer, not this turn's.
|
|
358
|
+
*/
|
|
359
|
+
dmWith;
|
|
360
|
+
/** The conversation, and the thread in it, this turn replies into. */
|
|
283
361
|
channel;
|
|
284
362
|
threadTs;
|
|
285
363
|
editIntervalMs;
|
|
@@ -297,6 +375,7 @@ class ReplyRenderer {
|
|
|
297
375
|
this.statusThread = options.statusThread ?? threadTs;
|
|
298
376
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
299
377
|
this.after = options.after ?? Promise.resolve();
|
|
378
|
+
this.dmWith = options.dmWith;
|
|
300
379
|
}
|
|
301
380
|
/**
|
|
302
381
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -309,10 +388,12 @@ class ReplyRenderer {
|
|
|
309
388
|
return;
|
|
310
389
|
}
|
|
311
390
|
this.loading = true;
|
|
312
|
-
// A thread has one status per app, so a turn queued behind another
|
|
313
|
-
// must not publish its own: it would overwrite what the
|
|
314
|
-
// says it is doing. It publishes when it reaches the head
|
|
315
|
-
// or `refreshLoading` once the turn ahead has posted).
|
|
391
|
+
// A thread has one status per app, so a turn queued behind another in
|
|
392
|
+
// the same thread must not publish its own: it would overwrite what the
|
|
393
|
+
// running turn says it is doing. It publishes when it reaches the head
|
|
394
|
+
// (`beginTurn`, or `refreshLoading` once the turn ahead has posted).
|
|
395
|
+
// The caller passes `head` for a queued turn whose status lives on a
|
|
396
|
+
// key of its own — every DM message is — since it overwrites nothing.
|
|
316
397
|
if (head) {
|
|
317
398
|
this.publishLoading();
|
|
318
399
|
}
|
|
@@ -482,11 +563,21 @@ class ReplyRenderer {
|
|
|
482
563
|
}
|
|
483
564
|
}
|
|
484
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Upload files a turn produced that no event delivered: a repeat of a
|
|
568
|
+
* finished turn has only the session to read them from (`turnFilesFor`),
|
|
569
|
+
* and a path written since then is refused (see `readUnchangedSince`).
|
|
570
|
+
*/
|
|
571
|
+
queueFiles(files) {
|
|
572
|
+
for (const file of files) {
|
|
573
|
+
this.queueUpload(file.path, Date.parse(file.producedAt));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
485
576
|
// Tool results that reference local output files (a screenshot, a
|
|
486
577
|
// generated report) become real attachments in the conversation — the
|
|
487
578
|
// channel contract's upload operation. Uploads chain so they land in
|
|
488
579
|
// order and finalize() waits for them.
|
|
489
|
-
queueUpload(filePath) {
|
|
580
|
+
queueUpload(filePath, producedAt) {
|
|
490
581
|
// Behind a handover in progress, for the same reason edits and the
|
|
491
582
|
// finalize are: the turn being handed the placeholder has its own
|
|
492
583
|
// attachments to put in the thread first, and an upload is a message
|
|
@@ -504,7 +595,7 @@ class ReplyRenderer {
|
|
|
504
595
|
// not a path, and a missing file surfaces as this upload's own
|
|
505
596
|
// failure instead of an unhandled stream error.
|
|
506
597
|
.then(async () => {
|
|
507
|
-
const data = await readFile(filePath);
|
|
598
|
+
const data = producedAt === undefined ? await readFile(filePath) : await readUnchangedSince(filePath, producedAt);
|
|
508
599
|
// Where the file will sit is Slack's to say — the ts of the
|
|
509
600
|
// message it becomes, in the shares the upload answers with. When
|
|
510
601
|
// the answer carries none, the clock stands in, read BEFORE the
|
|
@@ -755,6 +846,44 @@ class ReplyRenderer {
|
|
|
755
846
|
this.textAt = undefined;
|
|
756
847
|
return true;
|
|
757
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Stand down without saying anything: the message this renderer was
|
|
851
|
+
* opened for repeated one whose turn another delivery in this process
|
|
852
|
+
* is still waiting on, and that delivery posts the answer. Whatever this
|
|
853
|
+
* renderer put up — a placeholder, a loading status — comes down, so a
|
|
854
|
+
* redelivery leaves no trace.
|
|
855
|
+
*/
|
|
856
|
+
async withdraw() {
|
|
857
|
+
try {
|
|
858
|
+
await this.handover;
|
|
859
|
+
const hadStatus = this.statusTimer !== undefined;
|
|
860
|
+
this.stopLoading();
|
|
861
|
+
this.finalized = true;
|
|
862
|
+
// The barrier `finalizeInOrder` takes, for the same reason: the turn
|
|
863
|
+
// queued behind this one waits on `posted`, and settling it before
|
|
864
|
+
// the reply ahead has posted would let that turn's reply overtake it.
|
|
865
|
+
if (!this.streaming) {
|
|
866
|
+
await this.after;
|
|
867
|
+
await this.statusChain;
|
|
868
|
+
}
|
|
869
|
+
if (this.pendingEdit) {
|
|
870
|
+
clearTimeout(this.pendingEdit);
|
|
871
|
+
this.pendingEdit = undefined;
|
|
872
|
+
}
|
|
873
|
+
await this.editChain;
|
|
874
|
+
// A repeat can queue files the report ahead of it could not post
|
|
875
|
+
// and then stand down: they land before it settles.
|
|
876
|
+
await this.uploadChain;
|
|
877
|
+
await this.retract();
|
|
878
|
+
if (hadStatus) {
|
|
879
|
+
this.setStatus('');
|
|
880
|
+
await this.statusChain;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
this.settle();
|
|
885
|
+
}
|
|
886
|
+
}
|
|
758
887
|
async fail(message) {
|
|
759
888
|
// The same wait `finalize` takes, for the same reason: a lazy turn's
|
|
760
889
|
// first text may be opening its placeholder right now, and a failure
|
|
@@ -894,9 +1023,23 @@ const safeCutIndex = (text, index) => {
|
|
|
894
1023
|
const code = text.charCodeAt(index - 1);
|
|
895
1024
|
return code >= 0xd800 && code <= 0xdbff ? index - 1 : index;
|
|
896
1025
|
};
|
|
1026
|
+
/** `index` moved forward, never back, off the middle of a surrogate pair. */
|
|
1027
|
+
const pastCodePoint = (text, index) => {
|
|
1028
|
+
const code = text.charCodeAt(index - 1);
|
|
1029
|
+
return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
|
|
1030
|
+
};
|
|
897
1031
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
898
1032
|
? text
|
|
899
1033
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
1034
|
+
/**
|
|
1035
|
+
* Text as however many messages Slack's limit makes it, never leaving a
|
|
1036
|
+
* code block broken across the cut. A cut blind to fences ended one
|
|
1037
|
+
* message on an unterminated block and started the next mid-block, where
|
|
1038
|
+
* Slack read the rest of somebody's log as prose — every `*` and `_` in it
|
|
1039
|
+
* formatting. So a cut that would land inside a code run moves in front of
|
|
1040
|
+
* it when that still leaves a message worth sending, and otherwise closes
|
|
1041
|
+
* the run at the cut and reopens it in the next.
|
|
1042
|
+
*/
|
|
900
1043
|
const splitForSlack = (text) => {
|
|
901
1044
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
902
1045
|
return [text];
|
|
@@ -906,11 +1049,49 @@ const splitForSlack = (text) => {
|
|
|
906
1049
|
while (rest.length > SLACK_MAX_MESSAGE_CHARS) {
|
|
907
1050
|
// Prefer a newline break inside the window; fall back to a hard cut.
|
|
908
1051
|
const window = rest.slice(0, SLACK_MAX_MESSAGE_CHARS);
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1052
|
+
const newline = window.lastIndexOf('\n');
|
|
1053
|
+
const cut = newline > SLACK_MAX_MESSAGE_CHARS / 2 ? newline : safeCutIndex(rest, SLACK_MAX_MESSAGE_CHARS);
|
|
1054
|
+
// Scanned two messages deep, not to the end: rescanning the whole rest
|
|
1055
|
+
// for every chunk made a megabyte reply quadratic on the event loop. A
|
|
1056
|
+
// fence that closes past the window still reads as open to its end,
|
|
1057
|
+
// which is what it is here; an inline span that does is longer than a
|
|
1058
|
+
// message, and is cut as text either way.
|
|
1059
|
+
const run = codeRunsOf(rest.slice(0, SLACK_MAX_MESSAGE_CHARS * 2))
|
|
1060
|
+
.find((candidate) => candidate.start < cut && cut < candidate.end);
|
|
1061
|
+
if (run !== undefined && run.start > SLACK_MAX_MESSAGE_CHARS / 4) {
|
|
1062
|
+
chunks.push(rest.slice(0, run.start).replace(/\n+$/, ''));
|
|
1063
|
+
rest = rest.slice(run.start);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
// The run is most of the message, so it is cut itself — at its last
|
|
1067
|
+
// newline that leaves room for the closer, and past its opener so the
|
|
1068
|
+
// continuation always makes progress. A continuation reopens with the
|
|
1069
|
+
// info string only while it is short: a two-thousand-character info
|
|
1070
|
+
// line repeated on every part would crowd out the code it labels.
|
|
1071
|
+
const budget = SLACK_MAX_MESSAGE_CHARS - (run?.closer.length ?? 0);
|
|
1072
|
+
const floor = run === undefined ? 0 : run.start + run.opener.length;
|
|
1073
|
+
const reopen = run === undefined || run.opener.length <= SLACK_MAX_MESSAGE_CHARS / 8
|
|
1074
|
+
? run?.opener
|
|
1075
|
+
: run.opener.endsWith('\n') ? `${run.closer.slice(1)}\n` : run.opener;
|
|
1076
|
+
// A delimiter too long to close and reopen inside one message — a fence
|
|
1077
|
+
// of a thousand backticks, an opening line that fills the window — is
|
|
1078
|
+
// cut raw, as before this existed. Reopening it would send a message
|
|
1079
|
+
// over the limit, and consume a character a message after that.
|
|
1080
|
+
// Only a fence is reopened. Its closer and opener sit on lines of their
|
|
1081
|
+
// own, so they can never run into a backtick of the code; an inline
|
|
1082
|
+
// span's would, and `x` + `` would read as a different delimiter.
|
|
1083
|
+
// `floor + 2`: the first cut past the opener has to fit a whole code
|
|
1084
|
+
// point, or the step that guarantees progress lands inside an emoji.
|
|
1085
|
+
const fence = run !== undefined && run.closer.startsWith('\n');
|
|
1086
|
+
if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
|
|
1087
|
+
chunks.push(rest.slice(0, cut));
|
|
1088
|
+
rest = rest.slice(cut).replace(/^\n+/, '');
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const inner = rest.lastIndexOf('\n', budget - 1);
|
|
1092
|
+
const within = inner > Math.max(floor, SLACK_MAX_MESSAGE_CHARS / 2) ? inner : Math.max(safeCutIndex(rest, budget), pastCodePoint(rest, floor + 1));
|
|
1093
|
+
chunks.push(`${rest.slice(0, within)}${run.closer}`);
|
|
1094
|
+
rest = `${reopen}${rest.slice(within).replace(/^\n/, '')}`;
|
|
914
1095
|
}
|
|
915
1096
|
if (rest.length > 0) {
|
|
916
1097
|
chunks.push(rest);
|
|
@@ -932,9 +1113,6 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
|
|
|
932
1113
|
* no rewrite is ever asked to span a boundary.
|
|
933
1114
|
*/
|
|
934
1115
|
const messageChunks = (text) => splitForSlack(toSlackMrkdwn(text));
|
|
935
|
-
// One rule with the gateway's `sessionRouting`, which posts the same
|
|
936
|
-
// message for a turn this adapter did not start.
|
|
937
|
-
const lastAssistantReply = (session) => latestTurnReply(session) ?? NO_REPLY_TEXT;
|
|
938
1116
|
const APPROVAL_ACTIONS = {
|
|
939
1117
|
stratus_approve_once: 'once',
|
|
940
1118
|
stratus_approve_always: 'always',
|
|
@@ -1028,21 +1206,37 @@ const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broa
|
|
|
1028
1206
|
const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
|
|
1029
1207
|
/** At most this many attachment names are listed; the rest are counted. */
|
|
1030
1208
|
const MAX_LISTED_ATTACHMENTS = 5;
|
|
1209
|
+
const UNREAD_REASON_TEXT = {
|
|
1210
|
+
unsupported: 'not a kind of file that can be read here',
|
|
1211
|
+
'too-large': 'too large to read here',
|
|
1212
|
+
failed: 'could not be downloaded',
|
|
1213
|
+
'timed-out': 'not downloaded in time',
|
|
1214
|
+
unreadable: 'not readable as text or as an image',
|
|
1215
|
+
'not-opened': 'not opened, since this message was not addressed to you',
|
|
1216
|
+
};
|
|
1031
1217
|
/**
|
|
1032
|
-
* What a message's attachments are called,
|
|
1033
|
-
* them. Without this the agent is handed "here's
|
|
1034
|
-
* answers as though it had read something
|
|
1035
|
-
* the true thing instead. Images the model was
|
|
1036
|
-
* listed here: they travel on the dispatch, name
|
|
1218
|
+
* What a message's attachments are called, and why each was not read, for
|
|
1219
|
+
* a turn that cannot open them. Without this the agent is handed "here's
|
|
1220
|
+
* the log" and no log, and answers as though it had read something; the
|
|
1221
|
+
* note is what lets it say the true thing instead. Images the model was
|
|
1222
|
+
* actually shown are not listed here: they travel on the dispatch, name
|
|
1223
|
+
* included. A file with no recorded reason is one no reader was handed,
|
|
1224
|
+
* which is `unsupported`.
|
|
1037
1225
|
*/
|
|
1038
|
-
const attachmentNote = (files) => {
|
|
1226
|
+
const attachmentNote = (files, reasons) => {
|
|
1039
1227
|
if (files.length === 0) {
|
|
1040
1228
|
return '';
|
|
1041
1229
|
}
|
|
1042
|
-
const
|
|
1043
|
-
const
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1230
|
+
const listed = files.slice(0, MAX_LISTED_ATTACHMENTS);
|
|
1231
|
+
const groups = new Map();
|
|
1232
|
+
for (const file of listed) {
|
|
1233
|
+
const reason = reasons.get(file) ?? 'unsupported';
|
|
1234
|
+
groups.set(reason, [...(groups.get(reason) ?? []), file.name ?? file.title ?? 'an unnamed file']);
|
|
1235
|
+
}
|
|
1236
|
+
const parts = [...groups].map(([reason, names]) => `${names.join(', ')} (${UNREAD_REASON_TEXT[reason]})`);
|
|
1237
|
+
const rest = files.length - listed.length;
|
|
1238
|
+
const all = rest > 0 ? `${parts.join('; ')}; and ${rest} more` : parts.join('; ');
|
|
1239
|
+
return `\n[Attached, name only: ${all}. You have only the names, so say so rather than guessing at what is in them.]`;
|
|
1046
1240
|
};
|
|
1047
1241
|
/** What Slack calls a file, in a warning about it. */
|
|
1048
1242
|
const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
|
|
@@ -1057,15 +1251,19 @@ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed fi
|
|
|
1057
1251
|
* fix — the warning here names the scope instead, and the file falls back
|
|
1058
1252
|
* to the by-name note like any other attachment the turn cannot open.
|
|
1059
1253
|
*/
|
|
1060
|
-
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
|
|
1254
|
+
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, reasons, deadline = AbortSignal.timeout(timeoutMs)) => {
|
|
1061
1255
|
// Decided from the last file back, the way the replay window is spent:
|
|
1062
1256
|
// when a message's images do not all fit, the ones kept are the ones it
|
|
1063
1257
|
// listed last, so intake and replay agree about which survive. Delivered
|
|
1064
1258
|
// in the message's own order, because that is how the person sees them.
|
|
1065
1259
|
const kept = new Map();
|
|
1066
1260
|
const dropped = new Set();
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1261
|
+
// Recorded beside the drop, so the note says why and never guesses. A
|
|
1262
|
+
// non-image goes on to the text reader with no reason: it decides.
|
|
1263
|
+
const drop = (position, reason) => {
|
|
1264
|
+
dropped.add(position);
|
|
1265
|
+
reasons.set(files[position], reason);
|
|
1266
|
+
};
|
|
1069
1267
|
// Decoded bytes accepted so far. Checked against Slack's reported size
|
|
1070
1268
|
// before a download and the real length after it, because the message
|
|
1071
1269
|
// as a whole has a budget the per-image cap alone cannot keep.
|
|
@@ -1090,19 +1288,23 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1090
1288
|
for (let position = files.length - 1; position >= 0; position -= 1) {
|
|
1091
1289
|
const file = files[position];
|
|
1092
1290
|
const url = file.url_private_download ?? file.url_private;
|
|
1093
|
-
if (!isImageAttachmentMediaType(file.mimetype)
|
|
1291
|
+
if (!isImageAttachmentMediaType(file.mimetype)) {
|
|
1094
1292
|
dropped.add(position);
|
|
1095
1293
|
continue;
|
|
1096
1294
|
}
|
|
1295
|
+
if (url === undefined) {
|
|
1296
|
+
drop(position, 'failed');
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1097
1299
|
if (windowClosed) {
|
|
1098
1300
|
warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
|
|
1099
|
-
|
|
1301
|
+
drop(position, 'too-large');
|
|
1100
1302
|
continue;
|
|
1101
1303
|
}
|
|
1102
1304
|
const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
|
|
1103
1305
|
if (tooBig !== undefined) {
|
|
1104
1306
|
warn(tooBig);
|
|
1105
|
-
|
|
1307
|
+
drop(position, 'too-large');
|
|
1106
1308
|
continue;
|
|
1107
1309
|
}
|
|
1108
1310
|
// What this image may weigh: the per-image cap, or what is left of the
|
|
@@ -1111,7 +1313,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1111
1313
|
const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1112
1314
|
if (deadline.aborted) {
|
|
1113
1315
|
warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
|
|
1114
|
-
|
|
1316
|
+
drop(position, 'timed-out');
|
|
1115
1317
|
continue;
|
|
1116
1318
|
}
|
|
1117
1319
|
let download;
|
|
@@ -1123,7 +1325,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1123
1325
|
? `this message's downloads took longer than ${timeoutMs}ms`
|
|
1124
1326
|
: (error instanceof Error ? error.message : String(error));
|
|
1125
1327
|
warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
|
|
1126
|
-
|
|
1328
|
+
drop(position, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
|
|
1127
1329
|
continue;
|
|
1128
1330
|
}
|
|
1129
1331
|
// Refused on what Slack sends when it will not serve the file — a
|
|
@@ -1133,7 +1335,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1133
1335
|
const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1134
1336
|
if (download.status !== 200 || contentType.startsWith('text/')) {
|
|
1135
1337
|
warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
|
|
1136
|
-
|
|
1338
|
+
drop(position, 'failed');
|
|
1137
1339
|
continue;
|
|
1138
1340
|
}
|
|
1139
1341
|
if (download.truncated === true) {
|
|
@@ -1144,13 +1346,13 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1144
1346
|
windowClosed = true;
|
|
1145
1347
|
}
|
|
1146
1348
|
warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned and the turn is told it cannot be read.`);
|
|
1147
|
-
|
|
1349
|
+
drop(position, 'too-large');
|
|
1148
1350
|
continue;
|
|
1149
1351
|
}
|
|
1150
1352
|
const stillTooBig = overLimit(fileLabel(file), download.body.length);
|
|
1151
1353
|
if (stillTooBig !== undefined) {
|
|
1152
1354
|
warn(stillTooBig);
|
|
1153
|
-
|
|
1355
|
+
drop(position, 'too-large');
|
|
1154
1356
|
continue;
|
|
1155
1357
|
}
|
|
1156
1358
|
// Read from the bytes, not from Slack's metadata: an image the model
|
|
@@ -1159,12 +1361,12 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1159
1361
|
const dimensions = imageDimensions(download.body, file.mimetype);
|
|
1160
1362
|
if (dimensions === undefined) {
|
|
1161
1363
|
warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
|
|
1162
|
-
|
|
1364
|
+
drop(position, 'unreadable');
|
|
1163
1365
|
continue;
|
|
1164
1366
|
}
|
|
1165
1367
|
if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
|
|
1166
1368
|
warn(`slack: ${fileLabel(file)} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take; the turn is told it cannot be read.`);
|
|
1167
|
-
|
|
1369
|
+
drop(position, 'too-large');
|
|
1168
1370
|
continue;
|
|
1169
1371
|
}
|
|
1170
1372
|
total += download.body.length;
|
|
@@ -1188,6 +1390,126 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1188
1390
|
});
|
|
1189
1391
|
return { images, unread };
|
|
1190
1392
|
};
|
|
1393
|
+
/** The most one text attachment may weigh to be read into the message. */
|
|
1394
|
+
const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
1395
|
+
/**
|
|
1396
|
+
* The most one message's text attachments may weigh together. Read text
|
|
1397
|
+
* joins the transcript and is sent again on every later turn, so the cap
|
|
1398
|
+
* is a cost decision as much as a context one.
|
|
1399
|
+
*/
|
|
1400
|
+
const TEXT_ATTACHMENTS_MAX_TOTAL_BYTES = 200 * 1024;
|
|
1401
|
+
const TEXT_APPLICATION_TYPES = new Set([
|
|
1402
|
+
'application/json',
|
|
1403
|
+
'application/xml',
|
|
1404
|
+
'application/yaml',
|
|
1405
|
+
'application/x-yaml',
|
|
1406
|
+
'application/toml',
|
|
1407
|
+
'application/javascript',
|
|
1408
|
+
'application/x-sh',
|
|
1409
|
+
'application/sql',
|
|
1410
|
+
]);
|
|
1411
|
+
const TEXT_EXTENSIONS = /\.(md|markdown|txt|text|csv|tsv|json|ya?ml|toml|xml|log|ini|conf)$/i;
|
|
1412
|
+
/**
|
|
1413
|
+
* Whether a file is text the model can be handed as it is. Slack's
|
|
1414
|
+
* mimetype comes from the uploader's client and a Markdown file can arrive
|
|
1415
|
+
* as `text/markdown`, `text/plain`, or an octet stream, so an unhelpful
|
|
1416
|
+
* type falls back to the name. HTML is left out: a sign-in page is HTML
|
|
1417
|
+
* too, and a file of it could not be told apart from Slack refusing one.
|
|
1418
|
+
*/
|
|
1419
|
+
const isTextAttachment = (file) => {
|
|
1420
|
+
const mimetype = file.mimetype?.toLowerCase();
|
|
1421
|
+
if (mimetype === 'text/html') {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
if (mimetype !== undefined && (mimetype.startsWith('text/') || TEXT_APPLICATION_TYPES.has(mimetype))) {
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
const name = file.name ?? file.title;
|
|
1428
|
+
return (mimetype === undefined || mimetype === 'application/octet-stream') && name !== undefined && TEXT_EXTENSIONS.test(name);
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* The text files among a message's attachments, read so the turn gets
|
|
1432
|
+
* their contents rather than their names. A Markdown plan attached in a
|
|
1433
|
+
* DM reached the agent as a filename, and it told the person it had no
|
|
1434
|
+
* way to read Slack at all. Anything that is not UTF-8 text, is over the
|
|
1435
|
+
* caps, or comes back as a page stays in `unread` and is named in the
|
|
1436
|
+
* note, exactly as before; the warning says why.
|
|
1437
|
+
*/
|
|
1438
|
+
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons) => {
|
|
1439
|
+
const texts = [];
|
|
1440
|
+
const unread = [];
|
|
1441
|
+
const skip = (file, reason) => {
|
|
1442
|
+
unread.push(file);
|
|
1443
|
+
reasons.set(file, reason);
|
|
1444
|
+
};
|
|
1445
|
+
let total = 0;
|
|
1446
|
+
for (const file of files) {
|
|
1447
|
+
const url = file.url_private_download ?? file.url_private;
|
|
1448
|
+
if (!isTextAttachment(file)) {
|
|
1449
|
+
// An image the image reader already refused keeps its own reason.
|
|
1450
|
+
unread.push(file);
|
|
1451
|
+
if (!reasons.has(file)) {
|
|
1452
|
+
reasons.set(file, 'unsupported');
|
|
1453
|
+
}
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
if (url === undefined) {
|
|
1457
|
+
skip(file, 'failed');
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1461
|
+
if (file.size !== undefined && file.size > maxBytes) {
|
|
1462
|
+
warn(`slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
|
|
1463
|
+
skip(file, 'too-large');
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (deadline.aborted) {
|
|
1467
|
+
warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
|
|
1468
|
+
skip(file, 'timed-out');
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
let download;
|
|
1472
|
+
try {
|
|
1473
|
+
download = await fetchFile(url, botToken, deadline, maxBytes);
|
|
1474
|
+
}
|
|
1475
|
+
catch (error) {
|
|
1476
|
+
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
1477
|
+
? `this message's downloads took longer than ${timeoutMs}ms`
|
|
1478
|
+
: (error instanceof Error ? error.message : String(error));
|
|
1479
|
+
warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
|
|
1480
|
+
skip(file, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
// HTML is never a file read here (see isTextAttachment), so an HTML
|
|
1484
|
+
// body is Slack's sign-in page: what a token without files:read gets.
|
|
1485
|
+
const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1486
|
+
const head = download.body.subarray(0, 64).toString('utf8').trimStart().toLowerCase();
|
|
1487
|
+
if (download.status !== 200 || contentType === 'text/html' || head.startsWith('<!doctype html') || head.startsWith('<html')) {
|
|
1488
|
+
warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than the file. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
|
|
1489
|
+
skip(file, 'failed');
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (download.truncated === true || download.body.length > maxBytes) {
|
|
1493
|
+
warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes of text this message could still take; the download was abandoned and the turn is told it cannot be read.`);
|
|
1494
|
+
skip(file, 'too-large');
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
let text;
|
|
1498
|
+
try {
|
|
1499
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(download.body);
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
warn(`slack: ${fileLabel(file)} is not UTF-8 text; the turn is told it cannot be read.`);
|
|
1503
|
+
skip(file, 'unreadable');
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
total += download.body.length;
|
|
1507
|
+
texts.push({ name: fileLabel(file), text });
|
|
1508
|
+
}
|
|
1509
|
+
return { texts, unread };
|
|
1510
|
+
};
|
|
1511
|
+
/** Read text attachments, set into the message after what was said. */
|
|
1512
|
+
const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attached: ${name}. Its contents follow.]\n${text.replace(/\s+$/, '')}\n[End of ${name}]`).join('');
|
|
1191
1513
|
/**
|
|
1192
1514
|
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
1193
1515
|
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
@@ -1294,6 +1616,87 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
1294
1616
|
},
|
|
1295
1617
|
];
|
|
1296
1618
|
};
|
|
1619
|
+
const CREDENTIAL_ADD_ACTION = 'stratus_credential_add';
|
|
1620
|
+
const CREDENTIAL_VIEW = 'stratus_credential';
|
|
1621
|
+
const CREDENTIAL_BLOCK = 'credential_value';
|
|
1622
|
+
const CREDENTIAL_INPUT = 'value';
|
|
1623
|
+
// Under Slack's three-second acknowledgement window, with room for the ack
|
|
1624
|
+
// itself to travel.
|
|
1625
|
+
const CREDENTIAL_ACK_MS = 2500;
|
|
1626
|
+
// A reason is the agent's own text, shown to a person deciding whether to
|
|
1627
|
+
// hand over a key; long enough to explain, short enough to read.
|
|
1628
|
+
const CREDENTIAL_REASON_LIMIT = 300;
|
|
1629
|
+
const credentialWhose = (agentName, scope) => scope === 'agent'
|
|
1630
|
+
? `for ${agentName} only`
|
|
1631
|
+
: `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
|
|
1632
|
+
/**
|
|
1633
|
+
* The request as it appears in the conversation. What it says about the
|
|
1634
|
+
* value is the promise the form keeps: straight to the credential store,
|
|
1635
|
+
* never into the thread, and never over a key already there.
|
|
1636
|
+
*/
|
|
1637
|
+
const credentialRequestBlocks = (agentName, name, scope, reason, requestId) => {
|
|
1638
|
+
const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
|
|
1639
|
+
? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
|
|
1640
|
+
: reason;
|
|
1641
|
+
return [
|
|
1642
|
+
{
|
|
1643
|
+
type: 'section',
|
|
1644
|
+
text: {
|
|
1645
|
+
type: 'mrkdwn',
|
|
1646
|
+
text: `*${escapeSlackText(agentName)}* is asking for a credential: \`${escapeSlackText(name)}\`, ${credentialWhose(escapeSlackText(agentName), scope)}.`,
|
|
1647
|
+
},
|
|
1648
|
+
},
|
|
1649
|
+
...(shortReason !== undefined
|
|
1650
|
+
? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
|
|
1651
|
+
: []),
|
|
1652
|
+
{
|
|
1653
|
+
type: 'context',
|
|
1654
|
+
elements: [{
|
|
1655
|
+
type: 'mrkdwn',
|
|
1656
|
+
text: 'Only an approver can add it. The value goes straight to the credential store, never into this conversation, and a key already stored is never replaced from here.',
|
|
1657
|
+
}],
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
type: 'actions',
|
|
1661
|
+
elements: [{
|
|
1662
|
+
type: 'button',
|
|
1663
|
+
action_id: CREDENTIAL_ADD_ACTION,
|
|
1664
|
+
style: 'primary',
|
|
1665
|
+
value: requestId,
|
|
1666
|
+
text: { type: 'plain_text', text: 'Add credential' },
|
|
1667
|
+
}],
|
|
1668
|
+
},
|
|
1669
|
+
];
|
|
1670
|
+
};
|
|
1671
|
+
/**
|
|
1672
|
+
* The form. `private_metadata` carries only the request id: where the key
|
|
1673
|
+
* goes, and for whom, is the gateway's record of the request, never
|
|
1674
|
+
* something a submission can say about itself.
|
|
1675
|
+
*/
|
|
1676
|
+
const credentialModal = (post, requestId) => ({
|
|
1677
|
+
type: 'modal',
|
|
1678
|
+
callback_id: CREDENTIAL_VIEW,
|
|
1679
|
+
private_metadata: requestId,
|
|
1680
|
+
title: { type: 'plain_text', text: 'Add a credential' },
|
|
1681
|
+
submit: { type: 'plain_text', text: 'Add' },
|
|
1682
|
+
close: { type: 'plain_text', text: 'Cancel' },
|
|
1683
|
+
blocks: [
|
|
1684
|
+
{
|
|
1685
|
+
type: 'section',
|
|
1686
|
+
text: {
|
|
1687
|
+
type: 'mrkdwn',
|
|
1688
|
+
text: `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
|
|
1689
|
+
+ `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`,
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
type: 'input',
|
|
1694
|
+
block_id: CREDENTIAL_BLOCK,
|
|
1695
|
+
label: { type: 'plain_text', text: `Value for ${post.name}` },
|
|
1696
|
+
element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT },
|
|
1697
|
+
},
|
|
1698
|
+
],
|
|
1699
|
+
});
|
|
1297
1700
|
/**
|
|
1298
1701
|
* The Slack channel adapter. One Slack app per agent — Slack has no way to
|
|
1299
1702
|
* give a single bot several identities with real avatars, presence, and
|
|
@@ -1428,6 +1831,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1428
1831
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
1429
1832
|
// same id the buttons carry back.
|
|
1430
1833
|
const approvalPosts = new Map();
|
|
1834
|
+
// Credential requests whose post is in flight: Slack can show the message,
|
|
1835
|
+
// and take a click on it, before postMessage resolves here. The same
|
|
1836
|
+
// window `rendering` covers for approvals, and bounded the same way.
|
|
1837
|
+
const credentialsPosting = new Set();
|
|
1838
|
+
// Credential requests with a live button, keyed by the gateway's request id.
|
|
1839
|
+
const credentialPosts = new Map();
|
|
1431
1840
|
// Requests whose message is mid-post. A request can settle — expire, or
|
|
1432
1841
|
// its turn be cancelled — while the postMessage that announces it is
|
|
1433
1842
|
// still in flight, and the retraction would then find nothing to retract
|
|
@@ -1522,6 +1931,58 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1522
1931
|
}
|
|
1523
1932
|
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1524
1933
|
};
|
|
1934
|
+
/**
|
|
1935
|
+
* What kind of room a conversation is and how many are in it, for the
|
|
1936
|
+
* turn's `conversation` metadata: an agent that did not know told
|
|
1937
|
+
* someone in a DM they were "talking on the terminal", and answers the same
|
|
1938
|
+
* way whether one person or a thousand will read it. Never the channel's
|
|
1939
|
+
* name, which whoever made the channel chose (`ConversationContext`).
|
|
1940
|
+
*
|
|
1941
|
+
* Looked up on every turn outside a DM, and never cached: who can read a
|
|
1942
|
+
* channel is the point, and a cached answer described a channel made
|
|
1943
|
+
* public, or shared with another workspace, as it was before. Lookups
|
|
1944
|
+
* for one conversation that overlap share one call. A lookup that fails
|
|
1945
|
+
* (an app missing the read scopes) leaves the kind the event gave, or
|
|
1946
|
+
* nothing: the prompt then says only that the conversation is in Slack,
|
|
1947
|
+
* as it always did.
|
|
1948
|
+
*/
|
|
1949
|
+
const roomLookups = new Map();
|
|
1950
|
+
const KIND_BY_CHANNEL_TYPE = { im: 'direct', mpim: 'group', group: 'private', channel: 'public' };
|
|
1951
|
+
const roomFor = async (connection, channel, channelType) => {
|
|
1952
|
+
const fromEvent = channelType !== undefined ? KIND_BY_CHANNEL_TYPE[channelType] : undefined;
|
|
1953
|
+
// A DM is two people, the app and the sender: nothing to look up.
|
|
1954
|
+
if (fromEvent === 'direct') {
|
|
1955
|
+
return { kind: 'direct' };
|
|
1956
|
+
}
|
|
1957
|
+
const key = `${connection.config.agentId}:${channel}`;
|
|
1958
|
+
let lookup = roomLookups.get(key);
|
|
1959
|
+
if (!lookup) {
|
|
1960
|
+
lookup = lookupRoom(connection, channel).finally(() => roomLookups.delete(key));
|
|
1961
|
+
roomLookups.set(key, lookup);
|
|
1962
|
+
}
|
|
1963
|
+
return (await lookup) ?? (fromEvent !== undefined ? { kind: fromEvent } : undefined);
|
|
1964
|
+
};
|
|
1965
|
+
const lookupRoom = async (connection, channel) => {
|
|
1966
|
+
try {
|
|
1967
|
+
const info = (await connection.web.conversations.info({ channel, include_num_members: true })).channel;
|
|
1968
|
+
if (info === undefined) {
|
|
1969
|
+
return undefined;
|
|
1970
|
+
}
|
|
1971
|
+
const kind = info.is_im === true
|
|
1972
|
+
? 'direct'
|
|
1973
|
+
: info.is_mpim === true
|
|
1974
|
+
? 'group'
|
|
1975
|
+
: info.is_private === true ? 'private' : 'public';
|
|
1976
|
+
return {
|
|
1977
|
+
kind,
|
|
1978
|
+
...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
|
|
1979
|
+
...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
catch {
|
|
1983
|
+
return undefined;
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1525
1986
|
// Mentions arrive as <@U123> markup; the model should read names.
|
|
1526
1987
|
/**
|
|
1527
1988
|
* `<@U…>` mentions become `@name` — for principals only. A display name
|
|
@@ -1548,17 +2009,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1548
2009
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
1549
2010
|
/** An agent's configuration, whether or not its socket came up. */
|
|
1550
2011
|
const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
|
|
1551
|
-
/**
|
|
1552
|
-
* Whether an agent takes a message from this sender at all — judged on
|
|
1553
|
-
* its configuration, not on a live connection: an agent whose socket
|
|
1554
|
-
* failed to start is still recognizable in a mention, and its door is
|
|
1555
|
-
* still its own. An agent this adapter was never configured for is
|
|
1556
|
-
* nobody's to refuse here, so it admits — the answer from before the
|
|
1557
|
-
* door existed.
|
|
1558
|
-
*/
|
|
1559
|
-
const admitsSender = (config, userId) => config === undefined
|
|
1560
|
-
|| config.admit !== 'principals'
|
|
1561
|
-
|| (config.principals ?? []).includes(userId);
|
|
1562
2012
|
/**
|
|
1563
2013
|
* How an agent listens, from its soul — read per message, so a roster
|
|
1564
2014
|
* reload takes effect on the next one. `thread` when the soul says
|
|
@@ -1985,7 +2435,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1985
2435
|
});
|
|
1986
2436
|
}
|
|
1987
2437
|
catch (error) {
|
|
1988
|
-
warn(`slack: could not retire an orphaned
|
|
2438
|
+
warn(`slack: could not retire an orphaned request: ${error instanceof Error ? error.message : String(error)}`);
|
|
1989
2439
|
}
|
|
1990
2440
|
};
|
|
1991
2441
|
const retractApprovalRequest = async (event) => {
|
|
@@ -2100,6 +2550,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2100
2550
|
};
|
|
2101
2551
|
/** Post the files an unrendered turn produced into its thread, in order. */
|
|
2102
2552
|
const uploadUnrenderedFiles = async (connection, channel, thread, paths) => {
|
|
2553
|
+
// Which files landed: a redelivery waiting behind this report posts
|
|
2554
|
+
// the rest (`outcomeReportedAhead`), since a file that did not land is
|
|
2555
|
+
// one only it can still post — and only the rest, or it would post
|
|
2556
|
+
// twice the ones that did.
|
|
2557
|
+
const landed = [];
|
|
2103
2558
|
for (const filePath of paths) {
|
|
2104
2559
|
try {
|
|
2105
2560
|
const data = await readFile(filePath);
|
|
@@ -2109,16 +2564,18 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2109
2564
|
filename: path.basename(filePath),
|
|
2110
2565
|
...(thread ? { thread_ts: thread } : {}),
|
|
2111
2566
|
});
|
|
2567
|
+
landed.push(filePath);
|
|
2112
2568
|
}
|
|
2113
2569
|
catch (error) {
|
|
2114
2570
|
warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2115
2571
|
}
|
|
2116
2572
|
}
|
|
2573
|
+
return landed;
|
|
2117
2574
|
};
|
|
2118
2575
|
const reportUnrenderedFailure = async (event, behind, files) => {
|
|
2119
2576
|
const gateway = gatewayRef;
|
|
2120
2577
|
if (!gateway?.sessionRouting) {
|
|
2121
|
-
return;
|
|
2578
|
+
return NOTHING_REPORTED;
|
|
2122
2579
|
}
|
|
2123
2580
|
let routing;
|
|
2124
2581
|
try {
|
|
@@ -2129,17 +2586,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2129
2586
|
// the turn is already failed — there is nothing here to salvage
|
|
2130
2587
|
// beyond saying why the thread stayed quiet.
|
|
2131
2588
|
warn(`slack: could not read the routing for a failed turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
2132
|
-
return;
|
|
2589
|
+
return NOTHING_REPORTED;
|
|
2133
2590
|
}
|
|
2134
2591
|
const metadata = routing?.metadata;
|
|
2135
2592
|
// Another surface's session, or one with no conversation to speak
|
|
2136
2593
|
// into. Not this adapter's to answer either way.
|
|
2137
2594
|
if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
|
|
2138
|
-
return;
|
|
2595
|
+
return NOTHING_REPORTED;
|
|
2139
2596
|
}
|
|
2140
2597
|
const connection = connectionFor(routing.agentId);
|
|
2141
2598
|
if (!connection) {
|
|
2142
|
-
return;
|
|
2599
|
+
return NOTHING_REPORTED;
|
|
2143
2600
|
}
|
|
2144
2601
|
const channel = metadata.slackChannel;
|
|
2145
2602
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
@@ -2160,10 +2617,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2160
2617
|
// interruption it existed to avoid; a line left standing happens
|
|
2161
2618
|
// only to a turn that had already chosen to speak.
|
|
2162
2619
|
warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
|
|
2163
|
-
await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
2164
|
-
return;
|
|
2620
|
+
return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
|
|
2165
2621
|
}
|
|
2166
|
-
|
|
2622
|
+
let landed = [];
|
|
2623
|
+
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), async () => {
|
|
2624
|
+
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
2625
|
+
});
|
|
2626
|
+
return { said, landed };
|
|
2167
2627
|
};
|
|
2168
2628
|
/**
|
|
2169
2629
|
* The other half of `reportUnrenderedFailure`: a turn nobody here was
|
|
@@ -2177,7 +2637,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2177
2637
|
const reportUnrenderedReply = async (event, behind, files) => {
|
|
2178
2638
|
const gateway = gatewayRef;
|
|
2179
2639
|
if (!gateway?.sessionRouting) {
|
|
2180
|
-
return;
|
|
2640
|
+
return NOTHING_REPORTED;
|
|
2181
2641
|
}
|
|
2182
2642
|
let routing;
|
|
2183
2643
|
try {
|
|
@@ -2185,15 +2645,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2185
2645
|
}
|
|
2186
2646
|
catch (error) {
|
|
2187
2647
|
warn(`slack: could not read the routing for a finished turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
2188
|
-
return;
|
|
2648
|
+
return NOTHING_REPORTED;
|
|
2189
2649
|
}
|
|
2190
2650
|
const metadata = routing?.metadata;
|
|
2191
2651
|
if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
|
|
2192
|
-
return;
|
|
2652
|
+
return NOTHING_REPORTED;
|
|
2193
2653
|
}
|
|
2194
2654
|
const connection = connectionFor(routing.agentId);
|
|
2195
2655
|
if (!connection) {
|
|
2196
|
-
return;
|
|
2656
|
+
return NOTHING_REPORTED;
|
|
2197
2657
|
}
|
|
2198
2658
|
const channel = metadata.slackChannel;
|
|
2199
2659
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
@@ -2206,10 +2666,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2206
2666
|
// order to keep and nothing to say, so the files go on their own.
|
|
2207
2667
|
const chunks = routing.reply ? messageChunks(routing.reply) : [];
|
|
2208
2668
|
const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
|
|
2209
|
-
|
|
2669
|
+
let landed = [];
|
|
2670
|
+
const uploads = async () => {
|
|
2671
|
+
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
2672
|
+
};
|
|
2210
2673
|
if (posting.length === 0) {
|
|
2211
2674
|
await uploads();
|
|
2212
|
-
|
|
2675
|
+
// Nothing was said. A turn nobody asked for chose that; an addressed
|
|
2676
|
+
// one is owed `(no reply)`, which a rendered turn would have posted,
|
|
2677
|
+
// so a redelivery waiting behind this report is left to post it.
|
|
2678
|
+
return { said: routing.unaddressed === true, landed };
|
|
2213
2679
|
}
|
|
2214
2680
|
// A reply the thread's other agents hear like any other, taking their
|
|
2215
2681
|
// place for it before the post, as `handleInbound` does.
|
|
@@ -2217,11 +2683,288 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2217
2683
|
const heard = routing.reply !== undefined && thread !== undefined
|
|
2218
2684
|
? overhearReply(connection, channel, thread, routing.reply, routing, published)
|
|
2219
2685
|
: undefined;
|
|
2220
|
-
await published;
|
|
2686
|
+
const delivered = await published;
|
|
2221
2687
|
await heard;
|
|
2222
2688
|
log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
|
|
2689
|
+
return { said: delivered, landed };
|
|
2690
|
+
};
|
|
2691
|
+
/**
|
|
2692
|
+
* Whether an approver can see a conversation: any public channel, or a
|
|
2693
|
+
* private one with an approver among its members. Rejects, with the
|
|
2694
|
+
* reason, when Slack will not say, so the caller refuses rather than
|
|
2695
|
+
* guesses.
|
|
2696
|
+
*/
|
|
2697
|
+
const approverCanSee = async (connection, channel, approvers) => {
|
|
2698
|
+
try {
|
|
2699
|
+
const info = (await connection.web.conversations.info({ channel })).channel;
|
|
2700
|
+
if (info?.is_private !== true && info?.is_mpim !== true) {
|
|
2701
|
+
return true;
|
|
2702
|
+
}
|
|
2703
|
+
let cursor;
|
|
2704
|
+
do {
|
|
2705
|
+
const page = await connection.web.conversations.members({ channel, limit: 200, ...(cursor ? { cursor } : {}) });
|
|
2706
|
+
if ((page.members ?? []).some((member) => approvers.has(member))) {
|
|
2707
|
+
return true;
|
|
2708
|
+
}
|
|
2709
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
2710
|
+
} while (cursor !== undefined);
|
|
2711
|
+
return false;
|
|
2712
|
+
}
|
|
2713
|
+
catch (error) {
|
|
2714
|
+
throw new Error(`Slack would not say who can see this conversation (${error instanceof Error ? error.message : String(error)}), so the form was not posted. `
|
|
2715
|
+
+ 'The app needs the conversations read scopes (channels:read, groups:read, mpim:read); see the app manifest.');
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
/**
|
|
2719
|
+
* The channel contract's `requestCredential`: posts the form and resolves
|
|
2720
|
+
* only once it is up with a button an approver can press. Every refusal
|
|
2721
|
+
* rejects with a sentence for the agent, which the gateway hands it in
|
|
2722
|
+
* place of "your operator was asked": a request nobody can answer would
|
|
2723
|
+
* otherwise sit pending while the agent says it is waiting on someone.
|
|
2724
|
+
*/
|
|
2725
|
+
const requestCredential = async (request) => {
|
|
2726
|
+
const gateway = gatewayRef;
|
|
2727
|
+
const connection = connectionFor(request.agentId);
|
|
2728
|
+
if (!gateway || !connection) {
|
|
2729
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
2730
|
+
? `${request.agentId}'s Slack app is not connected right now.`
|
|
2731
|
+
: `${request.agentId} has no Slack app.`);
|
|
2732
|
+
}
|
|
2733
|
+
// Only in the conversation the agent asked in, and only for a turn this
|
|
2734
|
+
// adapter dispatched from a Slack message: where to post comes from
|
|
2735
|
+
// that turn's renderer, never from the session's metadata, which an
|
|
2736
|
+
// HTTP caller can write (`channel: slack`, any `slackChannel`) to put a
|
|
2737
|
+
// request nobody made in that conversation in front of its approvers.
|
|
2738
|
+
// Unlike an approval there is no fallback channel: the person who can
|
|
2739
|
+
// answer is the one the agent is talking to.
|
|
2740
|
+
const active = gateway.activeTurnId?.(request.sessionId);
|
|
2741
|
+
const turn = active === undefined
|
|
2742
|
+
? undefined
|
|
2743
|
+
: (renderers.get(request.sessionId) ?? []).find((renderer) => renderer.turnId === active);
|
|
2744
|
+
if (!turn) {
|
|
2745
|
+
throw new Error('This turn did not come from a Slack message, so there is nobody in Slack to show the form to.');
|
|
2746
|
+
}
|
|
2747
|
+
const channel = turn.channel;
|
|
2748
|
+
const thread = turn.threadTs;
|
|
2749
|
+
const unseen = `Nobody who can add it can see this conversation`;
|
|
2750
|
+
const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
|
|
2751
|
+
const agentName = gateway.agents().find((agent) => agent.id === request.agentId)?.name ?? request.agentId;
|
|
2752
|
+
if (approvers.size === 0 || gateway.provideCredential === undefined) {
|
|
2753
|
+
throw new Error(`Nobody can add it from Slack, because no approver is configured for ${agentName} (approvals.slackApprovers).`);
|
|
2754
|
+
}
|
|
2755
|
+
// A DM has one person in it besides the app. Posted there for someone
|
|
2756
|
+
// who is not an approver, the form is a button only they can see and
|
|
2757
|
+
// only approvers can press, which is a request nobody can answer.
|
|
2758
|
+
const elsewhere = 'Ask in a channel an approver is in, or ask one of them directly.';
|
|
2759
|
+
if (turn.dmWith !== undefined && !approvers.has(turn.dmWith)) {
|
|
2760
|
+
throw new Error(`${unseen}: it is a direct message with someone who is not an approver for ${agentName}. ${elsewhere}`);
|
|
2761
|
+
}
|
|
2762
|
+
// A private channel or group DM shows the button only to its members,
|
|
2763
|
+
// so one with no approver in it is the DM case again with more people.
|
|
2764
|
+
// A public channel is open to anyone in the workspace, approvers
|
|
2765
|
+
// included, and is not checked. Unknown is refused, not assumed fine:
|
|
2766
|
+
// a form posted where nobody can answer it waits forever.
|
|
2767
|
+
if (turn.dmWith === undefined && !(await approverCanSee(connection, channel, approvers))) {
|
|
2768
|
+
throw new Error(`${unseen}: none of ${agentName}'s approvers is a member of this private conversation. ${elsewhere}`);
|
|
2769
|
+
}
|
|
2770
|
+
let posted;
|
|
2771
|
+
credentialsPosting.add(request.requestId);
|
|
2772
|
+
try {
|
|
2773
|
+
posted = await connection.web.chat.postMessage({
|
|
2774
|
+
channel,
|
|
2775
|
+
text: escapeSlackText(`${agentName} is asking for a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
|
|
2776
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
2777
|
+
blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId),
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
catch (error) {
|
|
2781
|
+
credentialsPosting.delete(request.requestId);
|
|
2782
|
+
warn(`slack: could not post a credential request for ${request.agentId} to ${channel} (${error instanceof Error ? error.message : String(error)})`);
|
|
2783
|
+
throw new Error(`Slack refused the post (${error instanceof Error ? error.message : String(error)}).`);
|
|
2784
|
+
}
|
|
2785
|
+
credentialsPosting.delete(request.requestId);
|
|
2786
|
+
if (!posted.ts) {
|
|
2787
|
+
throw new Error('Slack accepted the post but returned no message to attach the form to.');
|
|
2788
|
+
}
|
|
2789
|
+
credentialPosts.set(request.requestId, {
|
|
2790
|
+
connection,
|
|
2791
|
+
channel: posted.channel ?? channel,
|
|
2792
|
+
...(thread !== undefined ? { thread } : {}),
|
|
2793
|
+
ts: posted.ts,
|
|
2794
|
+
agentId: request.agentId,
|
|
2795
|
+
agentName,
|
|
2796
|
+
name: request.name,
|
|
2797
|
+
scope: request.scope,
|
|
2798
|
+
approvers,
|
|
2799
|
+
});
|
|
2800
|
+
};
|
|
2801
|
+
const settleCredentialPost = async (event) => {
|
|
2802
|
+
const post = credentialPosts.get(event.requestId);
|
|
2803
|
+
if (!post) {
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
credentialPosts.delete(event.requestId);
|
|
2807
|
+
const whose = event.scope === 'agent'
|
|
2808
|
+
? `for ${escapeSlackText(post.agentName)} only`
|
|
2809
|
+
: `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
|
|
2810
|
+
const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
|
|
2811
|
+
const text = event.grantError === undefined
|
|
2812
|
+
? `Added \`${escapeSlackText(post.name)}\` ${whose}${by}. ${escapeSlackText(post.agentName)} can use it from its next reply; the value is in the credential store, not here.`
|
|
2813
|
+
: `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
|
|
2814
|
+
+ `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
|
|
2815
|
+
+ 'Add the name to the credentials list in its soul on the machine.';
|
|
2816
|
+
try {
|
|
2817
|
+
await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
|
|
2818
|
+
}
|
|
2819
|
+
catch (error) {
|
|
2820
|
+
warn(`slack: could not update the credential request for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
|
|
2821
|
+
}
|
|
2822
|
+
};
|
|
2823
|
+
/**
|
|
2824
|
+
* Takes down a request no answer can land on any more, so its button stops
|
|
2825
|
+
* inviting submissions that can only be refused the same way. A request
|
|
2826
|
+
* `credential.provided` already settled is left to what that wrote, which
|
|
2827
|
+
* says more (a key stored but not granted).
|
|
2828
|
+
*/
|
|
2829
|
+
const retireCredentialPost = async (requestId, post, reason) => {
|
|
2830
|
+
if (credentialPosts.get(requestId) !== post) {
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
credentialPosts.delete(requestId);
|
|
2834
|
+
const text = `\`${escapeSlackText(post.name)}\` was not added: ${escapeSlackText(reason)}`;
|
|
2835
|
+
try {
|
|
2836
|
+
await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
|
|
2837
|
+
}
|
|
2838
|
+
catch (error) {
|
|
2839
|
+
warn(`slack: could not retire the credential request for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
|
|
2840
|
+
}
|
|
2841
|
+
};
|
|
2842
|
+
const handleCredentialClick = async (connection, args, requestId) => {
|
|
2843
|
+
await args.ack();
|
|
2844
|
+
const post = credentialPosts.get(requestId);
|
|
2845
|
+
if (!post && credentialsPosting.has(requestId)) {
|
|
2846
|
+
// Live, with the index not caught up yet: retiring it here would
|
|
2847
|
+
// strip the button from a request about to be registered as pending.
|
|
2848
|
+
await tellClicker(connection, args, 'That credential request is still being posted. Try the button again in a moment.');
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (!post || post.connection !== connection) {
|
|
2852
|
+
await tellClicker(connection, args, 'That credential request is no longer pending. Ask the agent to request it again.');
|
|
2853
|
+
// A daemon restart forgets every request, and the buttons it posted
|
|
2854
|
+
// stay live for everyone else in the thread: take this one down the
|
|
2855
|
+
// way an orphaned approval is, from the click's own copy.
|
|
2856
|
+
if (!post) {
|
|
2857
|
+
await retireOrphanedPrompt(connection, args);
|
|
2858
|
+
}
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
// By actor, as approvals are: the request sits in a thread anyone in
|
|
2862
|
+
// it can read, and a key is a bigger thing to hand over than a click.
|
|
2863
|
+
const clicker = args.body?.user?.id;
|
|
2864
|
+
if (!clicker || !post.approvers.has(clicker)) {
|
|
2865
|
+
await tellClicker(connection, args, `You are not an approver for ${post.agentName}, so you cannot add its credentials.`);
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const triggerId = args.body?.trigger_id;
|
|
2869
|
+
if (!connection.web.views || !triggerId) {
|
|
2870
|
+
await tellClicker(connection, args, 'Slack did not hand over a way to open the form. Try the button again.');
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
try {
|
|
2874
|
+
await connection.web.views.open({ trigger_id: triggerId, view: credentialModal(post, requestId) });
|
|
2875
|
+
}
|
|
2876
|
+
catch (error) {
|
|
2877
|
+
warn(`slack: could not open the credential form for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
|
|
2878
|
+
await tellClicker(connection, args, 'The form could not be opened. Try the button again.');
|
|
2879
|
+
}
|
|
2880
|
+
};
|
|
2881
|
+
/**
|
|
2882
|
+
* The form's answer. Acked last rather than first, because the ack is how
|
|
2883
|
+
* a refusal reaches the form: `errors` keeps it open with the reason
|
|
2884
|
+
* beside the field, so a person told "already stored" or "no longer
|
|
2885
|
+
* pending" learns it where they typed. The value is read here, handed to
|
|
2886
|
+
* the gateway, and touched nowhere else: not logged, not posted.
|
|
2887
|
+
*/
|
|
2888
|
+
const handleCredentialSubmission = async (connection, args) => {
|
|
2889
|
+
const refuse = (message) => args.ack({ response_action: 'errors', errors: { [CREDENTIAL_BLOCK]: message } });
|
|
2890
|
+
const view = args.body?.view;
|
|
2891
|
+
const requestId = view?.private_metadata ?? '';
|
|
2892
|
+
const post = credentialPosts.get(requestId);
|
|
2893
|
+
const gateway = gatewayRef;
|
|
2894
|
+
if (!post || post.connection !== connection || !gateway?.provideCredential) {
|
|
2895
|
+
await refuse('That credential request is no longer pending. Ask the agent to request it again.');
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
// Checked again on the answer, not only on the click: the form is a
|
|
2899
|
+
// separate interaction, and the click is not what stores anything.
|
|
2900
|
+
const submitter = args.body?.user?.id;
|
|
2901
|
+
if (!submitter || !post.approvers.has(submitter)) {
|
|
2902
|
+
await refuse(`You are not an approver for ${post.agentName}, so you cannot add its credentials.`);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
const value = view?.state?.values?.[CREDENTIAL_BLOCK]?.[CREDENTIAL_INPUT]?.value ?? '';
|
|
2906
|
+
const answer = gateway.provideCredential({ requestId, value, actor: submitter }).catch((error) => ({
|
|
2907
|
+
ok: false,
|
|
2908
|
+
message: `It could not be stored: ${error instanceof Error ? error.message : String(error)}`,
|
|
2909
|
+
}));
|
|
2910
|
+
// Slack wants the submission acked within three seconds, and storing
|
|
2911
|
+
// is two locked file writes and every bus subscriber: usually far
|
|
2912
|
+
// inside that, not always. An answer in time keeps the form open with
|
|
2913
|
+
// a refusal beside the field; a late one closes the form first and
|
|
2914
|
+
// tells the submitter privately, rather than letting Slack time out
|
|
2915
|
+
// and retry a submission the first attempt may already have stored.
|
|
2916
|
+
let timer;
|
|
2917
|
+
const deadline = new Promise((resolve) => {
|
|
2918
|
+
timer = setTimeout(() => resolve('late'), CREDENTIAL_ACK_MS);
|
|
2919
|
+
});
|
|
2920
|
+
const first = await Promise.race([answer, deadline]);
|
|
2921
|
+
clearTimeout(timer);
|
|
2922
|
+
if (first !== 'late') {
|
|
2923
|
+
// A plain ack closes the form. The message is rewritten from
|
|
2924
|
+
// `credential.provided`, the one path every way of answering goes
|
|
2925
|
+
// through.
|
|
2926
|
+
await (first.ok ? args.ack() : refuse(first.message));
|
|
2927
|
+
if (!first.ok && first.retired === true) {
|
|
2928
|
+
track(retireCredentialPost(requestId, post, first.message));
|
|
2929
|
+
}
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
await args.ack();
|
|
2933
|
+
track(answer.then(async (result) => {
|
|
2934
|
+
if (result.ok) {
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
if (result.retired === true) {
|
|
2938
|
+
await retireCredentialPost(requestId, post, result.message);
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
await connection.web.chat.postEphemeral({
|
|
2942
|
+
channel: post.channel,
|
|
2943
|
+
user: submitter,
|
|
2944
|
+
text: `${post.name} was not added: ${result.message}`,
|
|
2945
|
+
...(post.thread !== undefined ? { thread_ts: post.thread } : {}),
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
catch (error) {
|
|
2949
|
+
warn(`slack: could not tell ${submitter} a credential was not added (${error instanceof Error ? error.message : String(error)})`);
|
|
2950
|
+
}
|
|
2951
|
+
}));
|
|
2223
2952
|
};
|
|
2224
2953
|
const handleInteractive = async (connection, args) => {
|
|
2954
|
+
if (args.body?.type === 'view_submission') {
|
|
2955
|
+
if (args.body.view?.callback_id === CREDENTIAL_VIEW) {
|
|
2956
|
+
await handleCredentialSubmission(connection, args);
|
|
2957
|
+
}
|
|
2958
|
+
else {
|
|
2959
|
+
await args.ack();
|
|
2960
|
+
}
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
const first = args.body?.actions?.[0];
|
|
2964
|
+
if (first?.action_id === CREDENTIAL_ADD_ACTION && first.value) {
|
|
2965
|
+
await handleCredentialClick(connection, args, first.value);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2225
2968
|
// Ack first and unconditionally: Slack retries an unacked interaction,
|
|
2226
2969
|
// and a redelivered click on a request that is no longer pending would
|
|
2227
2970
|
// read as a second, rejected decision.
|
|
@@ -2442,11 +3185,40 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2442
3185
|
* a reply nobody answers. The agent alone in its thread, which is nearly
|
|
2443
3186
|
* every thread, is answered without the question arising.
|
|
2444
3187
|
*/
|
|
2445
|
-
const resolveFollowUpWinner = async (parts) => {
|
|
3188
|
+
const resolveFollowUpWinner = async (parts, ts) => {
|
|
2446
3189
|
const gateway = gatewayRef;
|
|
2447
3190
|
if (!gateway?.sessionRouting || !parts.thread) {
|
|
2448
3191
|
return undefined;
|
|
2449
3192
|
}
|
|
3193
|
+
// A message an agent already accepted is that agent's, whoever has spoken
|
|
3194
|
+
// since. Nothing in memory says so after a restart, and the order below
|
|
3195
|
+
// would hand a redelivery to the newer speaker — whose session holds no
|
|
3196
|
+
// key for it, so it would run the message as new. Asked of every
|
|
3197
|
+
// configured agent, whatever it listens to now and whether or not it is
|
|
3198
|
+
// connected, unlike the order below: what counts is that it took the
|
|
3199
|
+
// message as addressed to it (a judging agent's turn nobody asked for
|
|
3200
|
+
// does not), an operator may have changed how it listens since, and an
|
|
3201
|
+
// owner whose app failed to come back after the restart still started
|
|
3202
|
+
// that turn — the live agents stand down for it rather than run it again.
|
|
3203
|
+
// Its workspace is the one it last authenticated in, when known.
|
|
3204
|
+
if (gateway.holdsMessage) {
|
|
3205
|
+
for (const agentId of configuredAgents) {
|
|
3206
|
+
const teamId = botIdentities.get(agentId)?.teamId;
|
|
3207
|
+
if (teamId !== undefined && teamId !== parts.team) {
|
|
3208
|
+
continue;
|
|
3209
|
+
}
|
|
3210
|
+
const sessionId = channelSessionKey({
|
|
3211
|
+
channel: 'slack',
|
|
3212
|
+
agentId,
|
|
3213
|
+
team: parts.team,
|
|
3214
|
+
conversation: parts.conversation,
|
|
3215
|
+
thread: parts.thread,
|
|
3216
|
+
});
|
|
3217
|
+
if (await gateway.holdsMessage(sessionId, dispatchKeyFor(`${agentId}:${parts.conversation}:${ts}`))) {
|
|
3218
|
+
return agentId;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
2450
3222
|
const engaged = [];
|
|
2451
3223
|
// Live connections in this message's OWN workspace. Live, because an
|
|
2452
3224
|
// agent whose app failed to connect cannot answer anything, and leaving
|
|
@@ -2525,7 +3297,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2525
3297
|
if (existing) {
|
|
2526
3298
|
return existing.verdict;
|
|
2527
3299
|
}
|
|
2528
|
-
const entry = { verdict: resolveFollowUpWinner(parts), settled: false };
|
|
3300
|
+
const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
|
|
2529
3301
|
coldVerdicts.set(key, entry);
|
|
2530
3302
|
void entry.verdict.then(() => {
|
|
2531
3303
|
entry.settled = true;
|
|
@@ -2614,6 +3386,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2614
3386
|
// answer it and hold the thread. One naming only agents that refuse
|
|
2615
3387
|
// the sender is still theirs and not this agent's to answer — so the
|
|
2616
3388
|
// refusing agent stays `named`, and only the handover is withheld.
|
|
3389
|
+
// Judged on configuration, not on a live connection: an agent whose
|
|
3390
|
+
// socket failed to start is still recognizable in a mention, and its
|
|
3391
|
+
// door is still its own. One this adapter was never configured for has
|
|
3392
|
+
// no policy, and nobody's to refuse here, so it admits.
|
|
2617
3393
|
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
2618
3394
|
const named = handoverTo ?? agentNamedIn(text, team);
|
|
2619
3395
|
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
@@ -2704,6 +3480,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2704
3480
|
event,
|
|
2705
3481
|
isDm,
|
|
2706
3482
|
team,
|
|
3483
|
+
eventKey,
|
|
2707
3484
|
userId: event.user,
|
|
2708
3485
|
sessionId: channelSessionKey({
|
|
2709
3486
|
channel: 'slack',
|
|
@@ -2803,7 +3580,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2803
3580
|
if (!gateway) {
|
|
2804
3581
|
return;
|
|
2805
3582
|
}
|
|
2806
|
-
const { event, isDm, team, userId, thread, sessionId, threadKey } = admitted;
|
|
3583
|
+
const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
|
|
3584
|
+
// What the gateway knows this message by (see `dispatchKeyFor`).
|
|
3585
|
+
const dispatchKey = dispatchKeyFor(eventKey);
|
|
2807
3586
|
// Everything up to (and including) the dispatch call is serialized per
|
|
2808
3587
|
// session in Slack receipt order: the user lookups and placeholder
|
|
2809
3588
|
// post below await network I/O, and without this chain a later message
|
|
@@ -2868,27 +3647,54 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2868
3647
|
// nobody asked for, since they reach the model ahead of the frame
|
|
2869
3648
|
// that marks the message as somebody else's, and a judged message
|
|
2870
3649
|
// names its attachments the way an overheard one does.
|
|
2871
|
-
|
|
3650
|
+
// Images and text files share the message's one download deadline.
|
|
3651
|
+
const deadline = AbortSignal.timeout(fileDownloadTimeoutMs);
|
|
3652
|
+
const reasons = new Map();
|
|
3653
|
+
if (overhear || judged) {
|
|
3654
|
+
for (const file of event.files ?? []) {
|
|
3655
|
+
reasons.set(file, 'not-opened');
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
const { images, unread: notImages } = overhear || judged
|
|
2872
3659
|
? { images: [], unread: event.files ?? [] }
|
|
2873
|
-
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
3660
|
+
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
|
|
3661
|
+
const { texts, unread } = overhear || judged
|
|
3662
|
+
? { texts: [], unread: notImages }
|
|
3663
|
+
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons);
|
|
3664
|
+
// An image or a document with nothing said is still a question
|
|
3665
|
+
// ("what's this?"); a file the turn could not open with nothing said
|
|
3666
|
+
// is not one, and gets no reply.
|
|
3667
|
+
if (cleaned.length === 0 && images.length === 0 && texts.length === 0) {
|
|
2878
3668
|
return undefined;
|
|
2879
3669
|
}
|
|
2880
3670
|
const author = await authorFor(connection, userId);
|
|
2881
3671
|
// In shared channels the model should know who is speaking; a DM is
|
|
2882
3672
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
2883
3673
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
2884
|
-
const userMessage = `${spoken}${attachmentNote(unread)}`;
|
|
3674
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread, reasons)}`;
|
|
2885
3675
|
// Who sent this, judged against the operator's list — per message,
|
|
2886
3676
|
// never remembered from the first one in the thread. A DM proves
|
|
2887
3677
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
2888
3678
|
// `unknown` exactly as a channel mention would be. The same label
|
|
2889
3679
|
// whether the agent answers or only hears: the text is in its
|
|
2890
3680
|
// transcript either way.
|
|
2891
|
-
const senderTrust = (connection.config
|
|
3681
|
+
const senderTrust = senderTrustFor(connection.config, userId);
|
|
3682
|
+
// Who can read this, so the agent writes for them, and a thread says
|
|
3683
|
+
// it is one. A DM names the other person only when they are one of
|
|
3684
|
+
// the operator's principals: this lands in the system prompt, and
|
|
3685
|
+
// anyone else's display name is text they chose, which a pattern
|
|
3686
|
+
// cannot tell from an instruction phrased as a name.
|
|
3687
|
+
const room = await roomFor(connection, event.channel, isDm ? 'im' : event.channel_type);
|
|
3688
|
+
const conversation = room === undefined
|
|
3689
|
+
? undefined
|
|
3690
|
+
: {
|
|
3691
|
+
...room,
|
|
3692
|
+
...(room.kind === 'direct' && senderTrust === 'user' ? { with: author } : {}),
|
|
3693
|
+
...(thread !== undefined && room.kind !== 'direct' ? { thread: true } : {}),
|
|
3694
|
+
// People outside this workspace read a Slack Connect channel, and
|
|
3695
|
+
// the event says so itself, even when the lookup failed.
|
|
3696
|
+
...(room.kind !== 'direct' && args.body?.is_ext_shared_channel === true ? { shared: true } : {}),
|
|
3697
|
+
};
|
|
2892
3698
|
const metadata = {
|
|
2893
3699
|
channel: 'slack',
|
|
2894
3700
|
team,
|
|
@@ -2897,6 +3703,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2897
3703
|
// Carried so a mid-turn approval question is asked in the thread
|
|
2898
3704
|
// the turn belongs to rather than at the top of a busy channel.
|
|
2899
3705
|
...(thread ? { slackThread: thread } : {}),
|
|
3706
|
+
...(conversation !== undefined ? { [CONVERSATION_METADATA_KEY]: conversation } : {}),
|
|
2900
3707
|
[SENDER_TRUST_METADATA_KEY]: senderTrust,
|
|
2901
3708
|
};
|
|
2902
3709
|
if (overhear) {
|
|
@@ -2934,6 +3741,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2934
3741
|
statusThread: thread ?? event.ts,
|
|
2935
3742
|
statusWarned: connection.statusWarned,
|
|
2936
3743
|
...(ahead ? { after: ahead } : {}),
|
|
3744
|
+
...(isDm ? { dmWith: userId } : {}),
|
|
2937
3745
|
});
|
|
2938
3746
|
if (!streaming) {
|
|
2939
3747
|
replyOrder.set(sessionId, renderer.posted);
|
|
@@ -2958,7 +3766,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2958
3766
|
const queue = renderers.get(sessionId) ?? [];
|
|
2959
3767
|
queue.push(renderer);
|
|
2960
3768
|
renderers.set(sessionId, queue);
|
|
2961
|
-
|
|
3769
|
+
// In a DM each message is its own status key, and Slack stops showing
|
|
3770
|
+
// the running turn's once a newer message sits below it — so a
|
|
3771
|
+
// message queued there that stayed dark read as the agent having
|
|
3772
|
+
// stopped until the reply ahead of it posted. In a channel thread the
|
|
3773
|
+
// key is shared, and the running turn's status stays up instead.
|
|
3774
|
+
renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
|
|
3775
|
+
let repeat;
|
|
2962
3776
|
const turn = gateway.dispatch({
|
|
2963
3777
|
sessionId,
|
|
2964
3778
|
agentId: connection.config.agentId,
|
|
@@ -2967,8 +3781,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2967
3781
|
...(judged ? { addressed: false } : {}),
|
|
2968
3782
|
turnId: renderer.turnId,
|
|
2969
3783
|
metadata,
|
|
3784
|
+
// A redelivery this process no longer remembers (it restarted, or
|
|
3785
|
+
// the dedupe evicted it) is still the message it was: the gateway
|
|
3786
|
+
// runs no second turn for it, and says so through `onRepeat`.
|
|
3787
|
+
idempotencyKey: dispatchKey,
|
|
3788
|
+
onRepeat: (kind) => {
|
|
3789
|
+
repeat = kind;
|
|
3790
|
+
},
|
|
2970
3791
|
});
|
|
2971
|
-
return { renderer, turn };
|
|
3792
|
+
return { renderer, turn, repeat: () => repeat };
|
|
2972
3793
|
});
|
|
2973
3794
|
const started = await intake;
|
|
2974
3795
|
if (!started) {
|
|
@@ -2986,7 +3807,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2986
3807
|
}
|
|
2987
3808
|
return;
|
|
2988
3809
|
}
|
|
2989
|
-
const { renderer, turn } = started;
|
|
3810
|
+
const { renderer, turn, repeat } = started;
|
|
2990
3811
|
const removeFromQueue = () => {
|
|
2991
3812
|
const current = renderers.get(sessionId);
|
|
2992
3813
|
if (current) {
|
|
@@ -3011,15 +3832,73 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3011
3832
|
// events must reach ITS renderer, so this one leaves the queue BEFORE
|
|
3012
3833
|
// the potentially slow final edit, uploads, and overflow posts.
|
|
3013
3834
|
removeFromQueue();
|
|
3835
|
+
// The turn is a live earlier delivery's, and that delivery's renderer
|
|
3836
|
+
// posts its answer, or its failure: this one takes down whatever it put
|
|
3837
|
+
// up and says nothing.
|
|
3838
|
+
if (repeat() === 'live') {
|
|
3839
|
+
await renderer.withdraw();
|
|
3840
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
// A finished one's outcome is posted here, because nothing says the
|
|
3844
|
+
// delivery that ran it lived to post it, and no event will carry its
|
|
3845
|
+
// files: they are read from the session, like its reply below.
|
|
3846
|
+
//
|
|
3847
|
+
// Less what the start-up sweep already said, when it finished this
|
|
3848
|
+
// message's turn while the redelivery waited behind it: that outcome
|
|
3849
|
+
// was reported ahead of this renderer as a turn nobody was rendering,
|
|
3850
|
+
// and posting it again would say it twice. It was this message's turn
|
|
3851
|
+
// if it is the session's latest — anything finished after it would be
|
|
3852
|
+
// the latest instead — and the report says, part by part, what reached
|
|
3853
|
+
// the thread: the files it landed are not posted again, and the text
|
|
3854
|
+
// only if it was not said in full.
|
|
3855
|
+
const finished = repeat() === 'finished';
|
|
3856
|
+
const reportedAhead = session && finished && renderer.outcomeReportedAhead !== undefined && isLatestTurn(session, dispatchKey)
|
|
3857
|
+
? await renderer.outcomeReportedAhead
|
|
3858
|
+
: undefined;
|
|
3859
|
+
if (session && finished) {
|
|
3860
|
+
// Counted, not a set: a turn can produce one path twice, and each
|
|
3861
|
+
// result is an upload of its own, so one landing does not cover both.
|
|
3862
|
+
const landed = new Map();
|
|
3863
|
+
for (const filePath of reportedAhead?.landed ?? []) {
|
|
3864
|
+
landed.set(filePath, (landed.get(filePath) ?? 0) + 1);
|
|
3865
|
+
}
|
|
3866
|
+
renderer.queueFiles(turnFilesFor(session, dispatchKey).filter((file) => {
|
|
3867
|
+
const left = landed.get(file.path) ?? 0;
|
|
3868
|
+
if (left > 0) {
|
|
3869
|
+
landed.set(file.path, left - 1);
|
|
3870
|
+
return false;
|
|
3871
|
+
}
|
|
3872
|
+
return true;
|
|
3873
|
+
}));
|
|
3874
|
+
}
|
|
3875
|
+
if (reportedAhead?.said === true) {
|
|
3876
|
+
await renderer.withdraw();
|
|
3877
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
// A dispatch whose turn fails rejects, but one that repeats a turn that
|
|
3881
|
+
// already failed — or continues a crashed one the gateway could only
|
|
3882
|
+
// fail — resolves with the failed session. Its failure is posted as the
|
|
3883
|
+
// original delivery's would have been, not as a reply that says nothing.
|
|
3884
|
+
const failedWith = session ? turnFailureFor(session, dispatchKey) : undefined;
|
|
3885
|
+
if (failedWith !== undefined) {
|
|
3886
|
+
failure = new Error(failedWith);
|
|
3887
|
+
session = undefined;
|
|
3888
|
+
}
|
|
3014
3889
|
if (session) {
|
|
3015
3890
|
// The reply is final here, so this is when the thread's other agents
|
|
3016
3891
|
// take their place for it — before the final edit and the overflow
|
|
3017
3892
|
// posts, which are network I/O a later message must not overtake.
|
|
3018
3893
|
// A DM has no thread, and nobody else in it.
|
|
3019
|
-
|
|
3894
|
+
// A finished repeat's session may have moved on since; its own turn
|
|
3895
|
+
// is found by the key.
|
|
3896
|
+
const reply = finished ? turnReplyFor(session, dispatchKey) : latestTurnReply(session);
|
|
3020
3897
|
// A turn nobody asked for that said nothing posts nothing; every
|
|
3021
|
-
// other turn says `(no reply)` where its answer would have gone
|
|
3022
|
-
|
|
3898
|
+
// other turn says `(no reply)` where its answer would have gone — one
|
|
3899
|
+
// rule with the gateway's `sessionRouting`, which posts the same
|
|
3900
|
+
// message for a turn this adapter did not start.
|
|
3901
|
+
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
|
|
3023
3902
|
const heard = thread !== undefined && reply !== undefined
|
|
3024
3903
|
? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
|
|
3025
3904
|
: undefined;
|
|
@@ -3056,6 +3935,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3056
3935
|
return {
|
|
3057
3936
|
name: 'slack',
|
|
3058
3937
|
resolveOutbound,
|
|
3938
|
+
requestCredential,
|
|
3059
3939
|
async start(gateway) {
|
|
3060
3940
|
gatewayRef = gateway;
|
|
3061
3941
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
@@ -3066,6 +3946,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3066
3946
|
track(renderApprovalRequest(event));
|
|
3067
3947
|
return;
|
|
3068
3948
|
}
|
|
3949
|
+
if (event.type === 'credential.provided') {
|
|
3950
|
+
track(settleCredentialPost(event));
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3069
3953
|
if (event.type === 'tool.approval-resolved') {
|
|
3070
3954
|
// The question was posted into the turn's thread, which took the
|
|
3071
3955
|
// loading status down with it; the turn works on once answered.
|
|
@@ -3140,7 +4024,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3140
4024
|
// connection — never leaves the queued reply stalled behind it.
|
|
3141
4025
|
if (event.type === 'session.failed' && !rendered) {
|
|
3142
4026
|
const claim = head?.reserveHandover();
|
|
3143
|
-
|
|
4027
|
+
const report = reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId));
|
|
4028
|
+
if (head) {
|
|
4029
|
+
head.outcomeReportedAhead = report.catch(() => NOTHING_REPORTED);
|
|
4030
|
+
}
|
|
4031
|
+
track(report.then(() => { }).finally(() => {
|
|
3144
4032
|
claim?.release();
|
|
3145
4033
|
head?.refreshLoading();
|
|
3146
4034
|
}));
|
|
@@ -3148,7 +4036,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3148
4036
|
}
|
|
3149
4037
|
if (event.type === 'session.completed' && !rendered) {
|
|
3150
4038
|
const claim = head?.reserveHandover();
|
|
3151
|
-
|
|
4039
|
+
const report = reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId));
|
|
4040
|
+
if (head) {
|
|
4041
|
+
head.outcomeReportedAhead = report.catch(() => NOTHING_REPORTED);
|
|
4042
|
+
}
|
|
4043
|
+
track(report.then(() => { }).finally(() => {
|
|
3152
4044
|
claim?.release();
|
|
3153
4045
|
head?.refreshLoading();
|
|
3154
4046
|
}));
|
|
@@ -3197,19 +4089,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3197
4089
|
for (const connection of authenticated) {
|
|
3198
4090
|
const { config, socket } = connection;
|
|
3199
4091
|
try {
|
|
4092
|
+
// Returns the work it started: the SDK ignores it, and a test's fake
|
|
4093
|
+
// socket waits on it rather than guessing how long it takes.
|
|
3200
4094
|
const onEvent = (args) => {
|
|
3201
4095
|
const handled = handleInbound(connection, args).catch((error) => {
|
|
3202
4096
|
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3203
4097
|
});
|
|
3204
4098
|
inflight.add(handled);
|
|
3205
4099
|
void handled.finally(() => inflight.delete(handled));
|
|
4100
|
+
return handled;
|
|
3206
4101
|
};
|
|
3207
4102
|
socket.on('app_mention', onEvent);
|
|
3208
4103
|
socket.on('message', onEvent);
|
|
3209
4104
|
socket.on('interactive', (args) => {
|
|
3210
|
-
|
|
4105
|
+
const handled = handleInteractive(connection, args).catch((error) => {
|
|
3211
4106
|
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3212
|
-
})
|
|
4107
|
+
});
|
|
4108
|
+
track(handled);
|
|
4109
|
+
return handled;
|
|
3213
4110
|
});
|
|
3214
4111
|
await socket.start();
|
|
3215
4112
|
connections.push(connection);
|
|
@@ -3255,6 +4152,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3255
4152
|
botIdentities.clear();
|
|
3256
4153
|
rendering.clear();
|
|
3257
4154
|
resolvedWhileRendering.clear();
|
|
4155
|
+
credentialsPosting.clear();
|
|
3258
4156
|
},
|
|
3259
4157
|
};
|
|
3260
4158
|
};
|