@stratusagent/channel-slack 0.13.7 → 0.14.1
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 +103 -5
- package/dist/index.d.ts +49 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +832 -159
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
8
|
/**
|
|
9
9
|
* The longest display name the model is shown. Slack caps profile names
|
|
@@ -110,6 +110,12 @@ const STATUS_REFRESH_MS = 60_000;
|
|
|
110
110
|
* long.
|
|
111
111
|
*/
|
|
112
112
|
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
113
|
+
/**
|
|
114
|
+
* How long `stop()` waits on a socket that is still starting (card 935).
|
|
115
|
+
* Well inside the gateway's shutdown window, so a socket stuck connecting
|
|
116
|
+
* is reported by name rather than as the whole window running out.
|
|
117
|
+
*/
|
|
118
|
+
const DEFAULT_STOP_WAIT_MS = 10_000;
|
|
113
119
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
114
120
|
const NO_REPLY_TEXT = '(no reply)';
|
|
115
121
|
/**
|
|
@@ -266,6 +272,40 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
|
|
|
266
272
|
return { status: response.status, ...typed, body: Buffer.concat(chunks) };
|
|
267
273
|
};
|
|
268
274
|
const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
|
|
275
|
+
/**
|
|
276
|
+
* The one origin a file URL may name for the bot token to go with it.
|
|
277
|
+
* `url_private` does not by itself mean Slack hosts the file: for a remote
|
|
278
|
+
* file (an app's pointer to a file it hosts), Slack returns the app's own
|
|
279
|
+
* URL there, on whatever host the app chose, so any app with
|
|
280
|
+
* `remote_files:write` could collect the token of every agent that opened
|
|
281
|
+
* its file. Slack's rule is never to send Slack credentials to such a URL,
|
|
282
|
+
* and to compare the parsed host exactly rather than a prefix
|
|
283
|
+
* (docs.slack.dev, Working with files: Downloading files). Previews and
|
|
284
|
+
* downloads both live here. A redirect elsewhere is not a leak either:
|
|
285
|
+
* fetch drops `authorization` on a cross-origin redirect.
|
|
286
|
+
*/
|
|
287
|
+
const SLACK_FILE_ORIGIN = 'https://files.slack.com';
|
|
288
|
+
/**
|
|
289
|
+
* A fetcher that is only ever handed URLs on Slack's file host. Anything
|
|
290
|
+
* else is refused before a request is made, with a reason that names the
|
|
291
|
+
* origin, and the callers' own handling turns that into a name-only file
|
|
292
|
+
* and an operator warning, as for any download that failed.
|
|
293
|
+
*/
|
|
294
|
+
const onlySlackFileHost = (fetchFile) => (url, botToken, signal, maxBytes) => {
|
|
295
|
+
let origin;
|
|
296
|
+
try {
|
|
297
|
+
const parsed = new URL(url);
|
|
298
|
+
// Credentials in the URL would be sent with the token: not Slack's.
|
|
299
|
+
origin = parsed.username === '' && parsed.password === '' ? parsed.origin : `${parsed.protocol}//(credentials)@${parsed.host}`;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
origin = undefined;
|
|
303
|
+
}
|
|
304
|
+
if (origin !== SLACK_FILE_ORIGIN) {
|
|
305
|
+
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`));
|
|
306
|
+
}
|
|
307
|
+
return fetchFile(url, botToken, signal, maxBytes);
|
|
308
|
+
};
|
|
269
309
|
/**
|
|
270
310
|
* The dispatch's idempotency key for a message's dedupe key: the key itself
|
|
271
311
|
* when it fits the gateway's bound, and a digest of it when it does not —
|
|
@@ -1629,7 +1669,9 @@ const MIB = 1024 * 1024;
|
|
|
1629
1669
|
* a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
|
|
1630
1670
|
* what the small text says when it could only guess at it.
|
|
1631
1671
|
*/
|
|
1632
|
-
const previewNote = (previews) => previews.map((
|
|
1672
|
+
const previewNote = (previews) => previews.map((preview) => `\n[${previewSentence(preview)}]`).join('');
|
|
1673
|
+
/** One preview, said the same way to a turn and to a read of history. */
|
|
1674
|
+
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
1675
|
/**
|
|
1634
1676
|
* A message's attachments, sorted into the images the model will be shown
|
|
1635
1677
|
* and everything else. An image is fetched only when Slack's own metadata
|
|
@@ -1820,6 +1862,102 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
|
|
|
1820
1862
|
});
|
|
1821
1863
|
return { images, unread, previews };
|
|
1822
1864
|
};
|
|
1865
|
+
/** A read's byte cap, said in the unit it was most likely set in. */
|
|
1866
|
+
const readCapLabel = (bytes) => (bytes % MIB === 0 ? `${bytes / MIB} MB` : `${bytes} bytes`);
|
|
1867
|
+
/**
|
|
1868
|
+
* The image cap a read asked for, held to what one request can carry, or
|
|
1869
|
+
* undefined when it asked for none: then files come back by name only.
|
|
1870
|
+
*/
|
|
1871
|
+
const historyImageCap = (images) => {
|
|
1872
|
+
if (images === undefined || !Number.isInteger(images.count) || images.count <= 0 || !Number.isFinite(images.bytes) || images.bytes < 1) {
|
|
1873
|
+
return undefined;
|
|
1874
|
+
}
|
|
1875
|
+
return {
|
|
1876
|
+
count: Math.min(images.count, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT),
|
|
1877
|
+
bytes: Math.min(Math.floor(images.bytes), IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES),
|
|
1878
|
+
};
|
|
1879
|
+
};
|
|
1880
|
+
/** What a read is told about an image it tried to fetch and could not show. */
|
|
1881
|
+
const unshownImageNote = (label, reason) => {
|
|
1882
|
+
switch (reason) {
|
|
1883
|
+
case 'too-large':
|
|
1884
|
+
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.`;
|
|
1885
|
+
case 'timed-out':
|
|
1886
|
+
return `${label} is not shown: it was not downloaded in time.`;
|
|
1887
|
+
case 'unreadable':
|
|
1888
|
+
return `${label} is not shown: its bytes are not a complete image.`;
|
|
1889
|
+
default:
|
|
1890
|
+
return `${label} is not shown: it could not be downloaded.`;
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
/**
|
|
1894
|
+
* The images on the messages a read returns, fetched for a read that asks
|
|
1895
|
+
* (`ConversationReadRequest.images`) and set on each message with a note
|
|
1896
|
+
* for every image file it does not show. Each image goes through the same
|
|
1897
|
+
* reader a turn's attachments do — Slack's metadata first, the bytes
|
|
1898
|
+
* checked, Slack's preview for one over the per-image limits, and only
|
|
1899
|
+
* from Slack's own file host — one file at a time, in the order the
|
|
1900
|
+
* messages are returned. The read's cap is spent in that order, and once
|
|
1901
|
+
* an image does not fit, none after it is taken, so what is shown is one
|
|
1902
|
+
* unbroken stretch of the read rather than whichever small ones squeezed
|
|
1903
|
+
* in. One deadline covers every download in the read.
|
|
1904
|
+
*/
|
|
1905
|
+
const readHistoryImages = async (sources, cap, botToken, fetchFile, timeoutMs, warn) => {
|
|
1906
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1907
|
+
const pastBytes = `past this read's cap of ${readCapLabel(cap.bytes)} of images`;
|
|
1908
|
+
let count = 0;
|
|
1909
|
+
let remaining = cap.bytes;
|
|
1910
|
+
let full;
|
|
1911
|
+
for (const { message, files } of sources) {
|
|
1912
|
+
const images = [];
|
|
1913
|
+
const notes = [];
|
|
1914
|
+
for (const file of files) {
|
|
1915
|
+
const label = fileLabel(file);
|
|
1916
|
+
if (!isImageAttachmentMediaType(file.mimetype)) {
|
|
1917
|
+
if (file.mimetype?.startsWith('image/') === true) {
|
|
1918
|
+
notes.push(`${label} is not shown: it is ${file.mimetype}, a kind of image a model cannot be shown.`);
|
|
1919
|
+
}
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
if (full === undefined && count >= cap.count) {
|
|
1923
|
+
full = `past this read's cap of ${cap.count} images`;
|
|
1924
|
+
}
|
|
1925
|
+
// Slack's own size, trusted before any bytes move. An original over
|
|
1926
|
+
// the per-image limit may still go as its preview, whose size only
|
|
1927
|
+
// its bytes can say.
|
|
1928
|
+
if (full === undefined && file.size !== undefined && file.size <= IMAGE_ATTACHMENT_MAX_BYTES && file.size > remaining) {
|
|
1929
|
+
full = pastBytes;
|
|
1930
|
+
}
|
|
1931
|
+
if (full !== undefined) {
|
|
1932
|
+
notes.push(`${label} is not shown: ${full}.`);
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
const reasons = new Map();
|
|
1936
|
+
const read = await readImageAttachments([file], botToken, fetchFile, timeoutMs, warn, reasons, deadline);
|
|
1937
|
+
const image = read.images[0];
|
|
1938
|
+
if (image === undefined) {
|
|
1939
|
+
notes.push(unshownImageNote(label, reasons.get(file)));
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
const size = Buffer.byteLength(image.data, 'base64');
|
|
1943
|
+
if (size > remaining) {
|
|
1944
|
+
full = pastBytes;
|
|
1945
|
+
notes.push(`${label} is not shown: ${full}.`);
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
remaining -= size;
|
|
1949
|
+
count += 1;
|
|
1950
|
+
images.push(image);
|
|
1951
|
+
notes.push(...read.previews.map(previewSentence));
|
|
1952
|
+
}
|
|
1953
|
+
if (images.length > 0) {
|
|
1954
|
+
message.images = images;
|
|
1955
|
+
}
|
|
1956
|
+
if (notes.length > 0) {
|
|
1957
|
+
message.imageNotes = notes;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1823
1961
|
/** The most one text attachment may weigh to be read into the message. */
|
|
1824
1962
|
const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
1825
1963
|
/**
|
|
@@ -2222,12 +2360,19 @@ const CREDENTIAL_REASON_LIMIT = 300;
|
|
|
2222
2360
|
const credentialWhose = (agentName, scope) => scope === 'agent'
|
|
2223
2361
|
? `for ${agentName} only`
|
|
2224
2362
|
: `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
|
|
2363
|
+
/**
|
|
2364
|
+
* What answering means when the agent asked for the key in a variable,
|
|
2365
|
+
* said before anything is typed: the approval covers the binding too.
|
|
2366
|
+
* `agentName` arrives escaped; the variable passed the gateway's rule, so
|
|
2367
|
+
* it is letters, digits and underscores.
|
|
2368
|
+
*/
|
|
2369
|
+
const credentialShellLine = (agentName, env) => `${agentName}'s shell commands will be able to read this as \`${env}\`, so ${agentName} can see the value.`;
|
|
2225
2370
|
/**
|
|
2226
2371
|
* The request as it appears in the conversation. What it says about the
|
|
2227
2372
|
* value is the promise the form keeps: straight to the credential store,
|
|
2228
2373
|
* never into the thread, and never over a key already there.
|
|
2229
2374
|
*/
|
|
2230
|
-
const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false) => {
|
|
2375
|
+
const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false, env) => {
|
|
2231
2376
|
const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
|
|
2232
2377
|
? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
|
|
2233
2378
|
: reason;
|
|
@@ -2244,6 +2389,9 @@ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, repl
|
|
|
2244
2389
|
...(shortReason !== undefined
|
|
2245
2390
|
? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
|
|
2246
2391
|
: []),
|
|
2392
|
+
...(env !== undefined
|
|
2393
|
+
? [{ type: 'section', text: { type: 'mrkdwn', text: `*${credentialShellLine(escapeSlackText(agentName), escapeSlackText(env))}*` } }]
|
|
2394
|
+
: []),
|
|
2247
2395
|
{
|
|
2248
2396
|
type: 'context',
|
|
2249
2397
|
elements: [{
|
|
@@ -2282,11 +2430,12 @@ const credentialModal = (post, requestId) => ({
|
|
|
2282
2430
|
type: 'section',
|
|
2283
2431
|
text: {
|
|
2284
2432
|
type: 'mrkdwn',
|
|
2285
|
-
text: post.replace === true
|
|
2433
|
+
text: (post.replace === true
|
|
2286
2434
|
? `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
|
|
2287
2435
|
+ 'The new value replaces the stored one once it saves; it never appears in the conversation.'
|
|
2288
2436
|
: `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
|
|
2289
|
-
+ `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation
|
|
2437
|
+
+ `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`)
|
|
2438
|
+
+ (post.env !== undefined ? ` ${credentialShellLine(escapeSlackText(post.agentName), escapeSlackText(post.env))}` : ''),
|
|
2290
2439
|
},
|
|
2291
2440
|
},
|
|
2292
2441
|
{
|
|
@@ -2340,6 +2489,33 @@ const slackErrorCode = (error) => {
|
|
|
2340
2489
|
const code = error?.data?.error;
|
|
2341
2490
|
return typeof code === 'string' && code.length > 0 ? code : undefined;
|
|
2342
2491
|
};
|
|
2492
|
+
/** Slack's code for a failed call, or the SDK's message when Slack never answered. */
|
|
2493
|
+
const describeSlackError = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
2494
|
+
/** Codes Slack answers when the token itself is refused. */
|
|
2495
|
+
const SLACK_AUTH_ERRORS = new Set(['not_authed', 'invalid_auth', 'account_inactive', 'token_revoked', 'token_expired']);
|
|
2496
|
+
/**
|
|
2497
|
+
* Codes with which Slack refuses a whole `conversations.invite` without
|
|
2498
|
+
* inviting anyone, so `inviteToConversation` can reject and the members
|
|
2499
|
+
* are known unchanged. Everything else, per-person codes and failures that
|
|
2500
|
+
* may have partly worked included, is settled per entry instead.
|
|
2501
|
+
*/
|
|
2502
|
+
const SLACK_INVITE_REFUSALS = new Set([
|
|
2503
|
+
'not_in_channel',
|
|
2504
|
+
'missing_scope',
|
|
2505
|
+
'is_archived',
|
|
2506
|
+
'channel_not_found',
|
|
2507
|
+
'method_not_supported_for_channel_type',
|
|
2508
|
+
'no_user',
|
|
2509
|
+
'ratelimited',
|
|
2510
|
+
...SLACK_AUTH_ERRORS,
|
|
2511
|
+
]);
|
|
2512
|
+
/**
|
|
2513
|
+
* What `channel_not_found` means to an app: Slack says the same for a
|
|
2514
|
+
* wrong id and for a private channel it can't see, and so does
|
|
2515
|
+
* `conversations.info`, so the sentence names both.
|
|
2516
|
+
*/
|
|
2517
|
+
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). `
|
|
2518
|
+
+ 'If the id is right, the channel is private, and a member has to invite the app.';
|
|
2343
2519
|
/**
|
|
2344
2520
|
* Orders two Slack message timestamps ("seconds.micros"). Compared as
|
|
2345
2521
|
* integers, not floats: a current ts has more digits than a double holds.
|
|
@@ -2366,13 +2542,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2366
2542
|
const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
|
|
2367
2543
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
2368
2544
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
2369
|
-
const fetchFile = options.fetchFile ?? defaultFetchFile;
|
|
2545
|
+
const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
|
|
2370
2546
|
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
2547
|
+
const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
|
|
2371
2548
|
const now = options.now ?? Date.now;
|
|
2372
2549
|
const connections = [];
|
|
2550
|
+
// The agents this adapter carries: `options.agents` until a reload reads
|
|
2551
|
+
// another list (`options.loadAgents`).
|
|
2552
|
+
let carried = options.agents;
|
|
2373
2553
|
// Every agent this adapter was asked to carry, connected or not — the
|
|
2374
2554
|
// difference between "mine and broken" and "not mine".
|
|
2375
|
-
const configuredAgents = new Set(
|
|
2555
|
+
const configuredAgents = new Set(carried.map((agent) => agent.agentId));
|
|
2376
2556
|
// Renderers queue per session: two messages in one thread share a
|
|
2377
2557
|
// session id, and the gateway serializes their turns — so events route
|
|
2378
2558
|
// to the FIRST registered renderer (the running turn), never a later
|
|
@@ -2566,8 +2746,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2566
2746
|
* is the correct outcome there, and it is also the visible one — the same
|
|
2567
2747
|
* thing a mention has always done when an app is down.
|
|
2568
2748
|
*
|
|
2569
|
-
* Insertion order is `options.agents
|
|
2570
|
-
* `agentNamedIn` deterministic
|
|
2749
|
+
* Insertion order is the carried agents' order (`options.agents`, or the
|
|
2750
|
+
* list a reload read), which is what makes `agentNamedIn` deterministic
|
|
2751
|
+
* across every connection.
|
|
2571
2752
|
*/
|
|
2572
2753
|
const botIdentities = new Map();
|
|
2573
2754
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
@@ -2596,6 +2777,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2596
2777
|
const resolvedWhileRendering = new Map();
|
|
2597
2778
|
let unsubscribe;
|
|
2598
2779
|
let gatewayRef;
|
|
2780
|
+
// Set as `stop()` begins, so a reload still connecting an app leaves
|
|
2781
|
+
// nothing up behind the stop.
|
|
2782
|
+
let stopRequested = false;
|
|
2783
|
+
// Sockets between `start()` and `connections`, by the agent they serve
|
|
2784
|
+
// (card 935). A stop can't see them to close them: `connect` closes each
|
|
2785
|
+
// once its start returns, and the stop waits for that, bounded.
|
|
2786
|
+
const socketsStarting = new Map();
|
|
2787
|
+
// Agents whose socket `connect` could not close for a stop: up still,
|
|
2788
|
+
// which the stop has to report.
|
|
2789
|
+
const leftUpAtStop = new Set();
|
|
2599
2790
|
// Everything the adapter owes Slack goes through here, so stop() drains
|
|
2600
2791
|
// it: an approval question or its retraction must reach the workspace
|
|
2601
2792
|
// before the sockets close, or a message keeps offering buttons that
|
|
@@ -3350,7 +3541,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3350
3541
|
};
|
|
3351
3542
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
3352
3543
|
/** An agent's configuration, whether or not its socket came up. */
|
|
3353
|
-
const agentConfigFor = (agentId) =>
|
|
3544
|
+
const agentConfigFor = (agentId) => carried.find((candidate) => candidate.agentId === agentId);
|
|
3354
3545
|
/**
|
|
3355
3546
|
* Which agent each home channel belongs to, keyed `team:channel` because
|
|
3356
3547
|
* a channel id is unique only within its workspace. Settled at start,
|
|
@@ -3366,7 +3557,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3366
3557
|
const settleHomeOwners = () => {
|
|
3367
3558
|
homeOwners.clear();
|
|
3368
3559
|
const claims = new Map();
|
|
3369
|
-
for (const agent of
|
|
3560
|
+
for (const agent of carried) {
|
|
3370
3561
|
const teamId = botIdentities.get(agent.agentId)?.teamId;
|
|
3371
3562
|
if (teamId === undefined) {
|
|
3372
3563
|
continue;
|
|
@@ -3897,6 +4088,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3897
4088
|
names.set(user, await name);
|
|
3898
4089
|
}
|
|
3899
4090
|
const messages = [];
|
|
4091
|
+
const sources = [];
|
|
3900
4092
|
for (const message of raw) {
|
|
3901
4093
|
if (!message.ts) {
|
|
3902
4094
|
continue;
|
|
@@ -3909,7 +4101,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3909
4101
|
|| (message.user ? names.get(message.user) : undefined);
|
|
3910
4102
|
const at = Number.parseFloat(message.ts);
|
|
3911
4103
|
const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
|
|
3912
|
-
|
|
4104
|
+
const read = {
|
|
3913
4105
|
id: message.ts,
|
|
3914
4106
|
author,
|
|
3915
4107
|
text: plainSlackText(connection, message.text ?? '', names),
|
|
@@ -3918,7 +4110,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3918
4110
|
...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
|
|
3919
4111
|
...(message.reply_count ? { replies: message.reply_count } : {}),
|
|
3920
4112
|
...(files.length > 0 ? { files } : {}),
|
|
3921
|
-
}
|
|
4113
|
+
};
|
|
4114
|
+
messages.push(read);
|
|
4115
|
+
if (message.files !== undefined && message.files.length > 0) {
|
|
4116
|
+
sources.push({ message: read, files: message.files });
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
const imageCap = historyImageCap(request.images);
|
|
4120
|
+
if (imageCap !== undefined) {
|
|
4121
|
+
await readHistoryImages(sources, imageCap, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
3922
4122
|
}
|
|
3923
4123
|
return { messages, more, ...(listing ? await listing : {}) };
|
|
3924
4124
|
};
|
|
@@ -3961,15 +4161,60 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3961
4161
|
if (!create || !setTopic || !setPurpose || !inviteUsers) {
|
|
3962
4162
|
throw new Error('slack: this Slack client cannot create channels.');
|
|
3963
4163
|
}
|
|
3964
|
-
// Who each entry is, before anything exists
|
|
3965
|
-
//
|
|
3966
|
-
const
|
|
4164
|
+
// Who each entry is, before anything exists. The creating app is a
|
|
4165
|
+
// member already, so naming it counts as invited.
|
|
4166
|
+
const { own, notInvited, toInvite } = resolveInvitees(connection, request.agentId, invite);
|
|
4167
|
+
const invited = [...own];
|
|
4168
|
+
const kind = request.private === true ? 'private' : 'public';
|
|
4169
|
+
let created;
|
|
4170
|
+
try {
|
|
4171
|
+
created = await create({ name, is_private: request.private === true });
|
|
4172
|
+
}
|
|
4173
|
+
catch (error) {
|
|
4174
|
+
const code = slackErrorCode(error);
|
|
4175
|
+
if (code === 'missing_scope') {
|
|
4176
|
+
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.`);
|
|
4177
|
+
}
|
|
4178
|
+
if (code === 'name_taken') {
|
|
4179
|
+
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4180
|
+
}
|
|
4181
|
+
throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
|
|
4182
|
+
}
|
|
4183
|
+
const conversation = created.channel?.id;
|
|
4184
|
+
if (!conversation) {
|
|
4185
|
+
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.`);
|
|
4186
|
+
}
|
|
4187
|
+
const notSet = [];
|
|
4188
|
+
if (request.topic) {
|
|
4189
|
+
await setTopic({ channel: conversation, topic: request.topic })
|
|
4190
|
+
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describeSlackError(error)}).` }));
|
|
4191
|
+
}
|
|
4192
|
+
if (request.purpose) {
|
|
4193
|
+
await setPurpose({ channel: conversation, purpose: request.purpose })
|
|
4194
|
+
.catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describeSlackError(error)}).` }));
|
|
4195
|
+
}
|
|
4196
|
+
// The channel exists now, so every code is settled per entry and none
|
|
4197
|
+
// turns into a rejection the agent would retry into a second channel.
|
|
4198
|
+
const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite);
|
|
4199
|
+
invited.push(...settled.invited);
|
|
4200
|
+
notInvited.push(...settled.notInvited);
|
|
4201
|
+
sortByRequest(invite, invited, notInvited);
|
|
4202
|
+
return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
|
|
4203
|
+
};
|
|
4204
|
+
/**
|
|
4205
|
+
* Who each invite entry is, before Slack is asked: agents on this host
|
|
4206
|
+
* become the user their app posts as, in the same workspace only. `own`
|
|
4207
|
+
* holds the entries that are the asking app itself (the agent, or one
|
|
4208
|
+
* sharing its bot user), which no invitation can add.
|
|
4209
|
+
*/
|
|
4210
|
+
const resolveInvitees = (connection, agentId, invite) => {
|
|
4211
|
+
const own = [];
|
|
3967
4212
|
const notInvited = [];
|
|
3968
4213
|
const toInvite = new Map();
|
|
3969
4214
|
for (const who of invite) {
|
|
3970
4215
|
const identity = botIdentities.get(who);
|
|
3971
|
-
if (who ===
|
|
3972
|
-
|
|
4216
|
+
if (who === agentId || identity?.botUserId === connection.botUserId) {
|
|
4217
|
+
own.push(who);
|
|
3973
4218
|
}
|
|
3974
4219
|
else if (configuredAgents.has(who) && !identity?.botUserId) {
|
|
3975
4220
|
notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
|
|
@@ -3989,90 +4234,316 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3989
4234
|
notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
|
|
3990
4235
|
}
|
|
3991
4236
|
}
|
|
3992
|
-
|
|
3993
|
-
|
|
4237
|
+
return { own, notInvited, toInvite };
|
|
4238
|
+
};
|
|
4239
|
+
/**
|
|
4240
|
+
* Sends one `conversations.invite`, force on so a bad id cannot stop the
|
|
4241
|
+
* good ones, and settles each entry of `toInvite` (entry -> Slack user).
|
|
4242
|
+
* Slack's per-id failures name who was refused, and `already_in_channel`
|
|
4243
|
+
* counts as invited. A failed call that does not name everyone leaves
|
|
4244
|
+
* the rest to the channel's members, and who cannot be checked is never
|
|
4245
|
+
* claimed. A call Slack refused with a code `refusesWhole` accepts stops
|
|
4246
|
+
* there, before any members check, with nobody settled and the code in
|
|
4247
|
+
* `refusedWith` for the caller to reject on. Without `refusesWhole`
|
|
4248
|
+
* every code is settled per entry.
|
|
4249
|
+
*/
|
|
4250
|
+
const inviteAtSlack = async (connection, inviteUsers, conversation, toInvite, refusesWhole = () => false) => {
|
|
4251
|
+
const invited = [];
|
|
4252
|
+
const notInvited = [];
|
|
4253
|
+
if (toInvite.size === 0) {
|
|
4254
|
+
return { invited, notInvited };
|
|
4255
|
+
}
|
|
4256
|
+
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4257
|
+
// the SDK throws for an ok: false answer.
|
|
4258
|
+
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4259
|
+
let refused;
|
|
4260
|
+
let failure;
|
|
3994
4261
|
try {
|
|
3995
|
-
|
|
4262
|
+
refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
|
|
3996
4263
|
}
|
|
3997
4264
|
catch (error) {
|
|
3998
4265
|
const code = slackErrorCode(error);
|
|
3999
|
-
if (code
|
|
4000
|
-
|
|
4001
|
-
}
|
|
4002
|
-
if (code === 'name_taken') {
|
|
4003
|
-
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4266
|
+
if (code !== undefined && refusesWhole(code)) {
|
|
4267
|
+
return { invited, notInvited, refusedWith: code };
|
|
4004
4268
|
}
|
|
4005
|
-
|
|
4269
|
+
refused = refusedOf(error?.data?.errors);
|
|
4270
|
+
failure = describeSlackError(error);
|
|
4006
4271
|
}
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
4013
|
-
if (request.topic) {
|
|
4014
|
-
await setTopic({ channel: conversation, topic: request.topic })
|
|
4015
|
-
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
|
|
4016
|
-
}
|
|
4017
|
-
if (request.purpose) {
|
|
4018
|
-
await setPurpose({ channel: conversation, purpose: request.purpose })
|
|
4019
|
-
.catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
|
|
4020
|
-
}
|
|
4021
|
-
if (toInvite.size > 0) {
|
|
4022
|
-
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4023
|
-
// the SDK throws for an ok: false answer.
|
|
4024
|
-
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4025
|
-
let refused;
|
|
4026
|
-
let failure;
|
|
4272
|
+
// A failed call that does not name everyone leaves the rest unknown,
|
|
4273
|
+
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4274
|
+
let members;
|
|
4275
|
+
let membersFailure;
|
|
4276
|
+
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4027
4277
|
try {
|
|
4028
|
-
|
|
4278
|
+
members = new Set();
|
|
4279
|
+
let cursor;
|
|
4280
|
+
do {
|
|
4281
|
+
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4282
|
+
for (const member of page.members ?? []) {
|
|
4283
|
+
members.add(member);
|
|
4284
|
+
}
|
|
4285
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4286
|
+
} while (cursor);
|
|
4029
4287
|
}
|
|
4030
4288
|
catch (error) {
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
}
|
|
4034
|
-
// A failed call that does not name everyone leaves the rest unknown,
|
|
4035
|
-
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4036
|
-
let members;
|
|
4037
|
-
let membersFailure;
|
|
4038
|
-
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4039
|
-
try {
|
|
4040
|
-
members = new Set();
|
|
4041
|
-
let cursor;
|
|
4042
|
-
do {
|
|
4043
|
-
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4044
|
-
for (const member of page.members ?? []) {
|
|
4045
|
-
members.add(member);
|
|
4046
|
-
}
|
|
4047
|
-
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4048
|
-
} while (cursor);
|
|
4049
|
-
}
|
|
4050
|
-
catch (error) {
|
|
4051
|
-
members = undefined;
|
|
4052
|
-
membersFailure = describe(error);
|
|
4053
|
-
}
|
|
4289
|
+
members = undefined;
|
|
4290
|
+
membersFailure = describeSlackError(error);
|
|
4054
4291
|
}
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
}
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
})
|
|
4068
|
-
}
|
|
4292
|
+
}
|
|
4293
|
+
for (const [who, user] of toInvite) {
|
|
4294
|
+
const code = refused.get(user);
|
|
4295
|
+
if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
|
|
4296
|
+
invited.push(who);
|
|
4297
|
+
}
|
|
4298
|
+
else if (code !== undefined || members !== undefined) {
|
|
4299
|
+
notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
|
|
4300
|
+
}
|
|
4301
|
+
else {
|
|
4302
|
+
notInvited.push({
|
|
4303
|
+
who,
|
|
4304
|
+
reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
|
|
4305
|
+
});
|
|
4069
4306
|
}
|
|
4070
4307
|
}
|
|
4308
|
+
return { invited, notInvited };
|
|
4309
|
+
};
|
|
4310
|
+
/** Puts both lists back in the order the caller named the entries. */
|
|
4311
|
+
const sortByRequest = (invite, invited, notInvited) => {
|
|
4071
4312
|
const order = new Map(invite.map((who, index) => [who, index]));
|
|
4072
4313
|
const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
|
|
4073
4314
|
invited.sort(byRequest);
|
|
4074
4315
|
notInvited.sort((left, right) => byRequest(left.who, right.who));
|
|
4075
|
-
|
|
4316
|
+
};
|
|
4317
|
+
/**
|
|
4318
|
+
* Joins an existing public channel with the asking agent's own app —
|
|
4319
|
+
* `channel.join`. Anything it did not join rejects with a sentence; an
|
|
4320
|
+
* answer that leaves it unknown says so, and that a retry is safe,
|
|
4321
|
+
* since Slack treats a second join as a no-op.
|
|
4322
|
+
*/
|
|
4323
|
+
const joinConversation = async (request) => {
|
|
4324
|
+
const conversation = request.conversation.trim();
|
|
4325
|
+
if (!conversation) {
|
|
4326
|
+
throw new Error('slack: joining needs a channel id (C…).');
|
|
4327
|
+
}
|
|
4328
|
+
const connection = connectionFor(request.agentId);
|
|
4329
|
+
if (!connection) {
|
|
4330
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4331
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot join ${conversation}.`
|
|
4332
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot join a Slack channel.`);
|
|
4333
|
+
}
|
|
4334
|
+
const { join } = connection.web.conversations;
|
|
4335
|
+
if (!join) {
|
|
4336
|
+
throw new Error('slack: this Slack client cannot join channels.');
|
|
4337
|
+
}
|
|
4338
|
+
const agent = request.agentId;
|
|
4339
|
+
let joined;
|
|
4340
|
+
try {
|
|
4341
|
+
joined = await join({ channel: conversation });
|
|
4342
|
+
}
|
|
4343
|
+
catch (error) {
|
|
4344
|
+
const code = slackErrorCode(error);
|
|
4345
|
+
if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
|
|
4346
|
+
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.`);
|
|
4347
|
+
}
|
|
4348
|
+
if (code === 'missing_scope') {
|
|
4349
|
+
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.`);
|
|
4350
|
+
}
|
|
4351
|
+
if (code === 'method_not_supported_for_channel_type') {
|
|
4352
|
+
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.`);
|
|
4353
|
+
}
|
|
4354
|
+
if (code === 'channel_not_found') {
|
|
4355
|
+
throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)}`);
|
|
4356
|
+
}
|
|
4357
|
+
if (code === 'is_archived') {
|
|
4358
|
+
throw new Error(`slack: ${conversation} is archived, so it can't be joined.`);
|
|
4359
|
+
}
|
|
4360
|
+
if (code === 'too_many_members') {
|
|
4361
|
+
throw new Error(`slack: ${conversation} is at Slack's member limit, so ${agent}'s app can't join it.`);
|
|
4362
|
+
}
|
|
4363
|
+
if (code === 'ratelimited') {
|
|
4364
|
+
throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try joining ${conversation} again later.`);
|
|
4365
|
+
}
|
|
4366
|
+
if (SLACK_AUTH_ERRORS.has(code)) {
|
|
4367
|
+
throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`);
|
|
4368
|
+
}
|
|
4369
|
+
throw new Error(`slack: Slack did not let ${agent}'s app join ${conversation} (${code}).`);
|
|
4370
|
+
}
|
|
4371
|
+
const warnings = [joined.warning, ...(joined.response_metadata?.warnings ?? [])];
|
|
4372
|
+
return { conversation: joined.channel?.id ?? conversation, alreadyMember: warnings.includes('already_in_channel') };
|
|
4373
|
+
};
|
|
4374
|
+
/**
|
|
4375
|
+
* Invites people and agents to an existing channel with the asking
|
|
4376
|
+
* agent's own app — `channel.invite`. It rejects only when nobody was
|
|
4377
|
+
* invited: a request refused before Slack is asked, or a whole call Slack
|
|
4378
|
+
* refused without acting. Everything else is settled per entry by the
|
|
4379
|
+
* same code `createConversation` uses.
|
|
4380
|
+
*/
|
|
4381
|
+
const inviteToConversation = async (request) => {
|
|
4382
|
+
const conversation = request.conversation.trim();
|
|
4383
|
+
if (!conversation) {
|
|
4384
|
+
throw new Error('slack: an invitation needs a channel id (C…).');
|
|
4385
|
+
}
|
|
4386
|
+
const invite = request.invite;
|
|
4387
|
+
if (invite.length === 0) {
|
|
4388
|
+
throw new Error('slack: an invitation needs at least one person or agent to invite.');
|
|
4389
|
+
}
|
|
4390
|
+
if (invite.length > CHANNEL_INVITE_LIMIT) {
|
|
4391
|
+
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.`);
|
|
4392
|
+
}
|
|
4393
|
+
const connection = connectionFor(request.agentId);
|
|
4394
|
+
if (!connection) {
|
|
4395
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4396
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot invite anyone to ${conversation}.`
|
|
4397
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot invite anyone to a Slack channel.`);
|
|
4398
|
+
}
|
|
4399
|
+
const inviteUsers = connection.web.conversations.invite;
|
|
4400
|
+
if (!inviteUsers) {
|
|
4401
|
+
throw new Error('slack: this Slack client cannot invite people to channels.');
|
|
4402
|
+
}
|
|
4403
|
+
const agent = request.agentId;
|
|
4404
|
+
const { own, notInvited, toInvite } = resolveInvitees(connection, agent, invite);
|
|
4405
|
+
for (const who of own) {
|
|
4406
|
+
notInvited.push({ who, reason: 'That is your own app, which can\'t invite itself. To join a public channel, use channel.join.' });
|
|
4407
|
+
}
|
|
4408
|
+
const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite, (code) => SLACK_INVITE_REFUSALS.has(code));
|
|
4409
|
+
if (settled.refusedWith !== undefined) {
|
|
4410
|
+
const code = settled.refusedWith;
|
|
4411
|
+
const why = code === 'not_in_channel'
|
|
4412
|
+
? `${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.`
|
|
4413
|
+
: code === 'channel_not_found'
|
|
4414
|
+
? channelNotFoundSentence(agent, conversation)
|
|
4415
|
+
: code === 'missing_scope'
|
|
4416
|
+
? `${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.`
|
|
4417
|
+
: code === 'is_archived'
|
|
4418
|
+
? `${conversation} is archived, so nobody can be invited to it.`
|
|
4419
|
+
: code === 'method_not_supported_for_channel_type'
|
|
4420
|
+
? `${conversation} is a direct message or another conversation Slack doesn't allow invitations to.`
|
|
4421
|
+
: code === 'ratelimited'
|
|
4422
|
+
? `Slack is rate-limiting ${agent}'s app; try the invitation again later.`
|
|
4423
|
+
: SLACK_AUTH_ERRORS.has(code)
|
|
4424
|
+
? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
|
|
4425
|
+
: `Slack did not invite anyone to ${conversation} (${code}).`;
|
|
4426
|
+
throw new Error(`slack: ${why} Nobody was invited.`);
|
|
4427
|
+
}
|
|
4428
|
+
const invited = settled.invited;
|
|
4429
|
+
notInvited.push(...settled.notInvited);
|
|
4430
|
+
sortByRequest(invite, invited, notInvited);
|
|
4431
|
+
return { invited, notInvited };
|
|
4432
|
+
};
|
|
4433
|
+
/**
|
|
4434
|
+
* Other agents on this host whose Slack app is this agent's: the same
|
|
4435
|
+
* bot token configured (connected or not), or the same bot user in the
|
|
4436
|
+
* same workspace. Slack's `creator` names the app's bot user, so it
|
|
4437
|
+
* can't tell those agents' channels apart. This sees only this process;
|
|
4438
|
+
* one app per agent is the supported setup (see the README).
|
|
4439
|
+
*/
|
|
4440
|
+
const agentsSharingAppWith = (connection) => {
|
|
4441
|
+
const agentId = connection.config.agentId;
|
|
4442
|
+
const sharing = new Set();
|
|
4443
|
+
for (const other of carried) {
|
|
4444
|
+
if (other.agentId !== agentId && other.botToken === connection.config.botToken) {
|
|
4445
|
+
sharing.add(other.agentId);
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
for (const [other, identity] of botIdentities) {
|
|
4449
|
+
if (other !== agentId && identity.botUserId !== '' && identity.botUserId === connection.botUserId && identity.teamId === connection.teamId) {
|
|
4450
|
+
sharing.add(other);
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
return [...sharing];
|
|
4454
|
+
};
|
|
4455
|
+
/**
|
|
4456
|
+
* Archives a channel the asking agent's own app created — what
|
|
4457
|
+
* `channel.archive` runs on. Everything it refuses is refused before
|
|
4458
|
+
* Slack is asked to archive: an id Slack can't show this app, a direct
|
|
4459
|
+
* message, a channel another user or app created, an app other agents
|
|
4460
|
+
* here share, a channel shared outside the workspace. Slack keeps who
|
|
4461
|
+
* created a channel and whether it is archived, so nothing is stored
|
|
4462
|
+
* here and the rule holds across restarts and hosts. Refusals name the
|
|
4463
|
+
* channel by id only: its name is whatever its members typed.
|
|
4464
|
+
*/
|
|
4465
|
+
const archiveConversation = async (request) => {
|
|
4466
|
+
const conversation = request.conversation.trim();
|
|
4467
|
+
if (!conversation) {
|
|
4468
|
+
throw new Error('slack: archiving needs a channel id (C…).');
|
|
4469
|
+
}
|
|
4470
|
+
const connection = connectionFor(request.agentId);
|
|
4471
|
+
if (!connection) {
|
|
4472
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
4473
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot archive ${conversation}.`
|
|
4474
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot archive a Slack channel.`);
|
|
4475
|
+
}
|
|
4476
|
+
const { archive } = connection.web.conversations;
|
|
4477
|
+
if (!archive) {
|
|
4478
|
+
throw new Error('slack: this Slack client cannot archive channels.');
|
|
4479
|
+
}
|
|
4480
|
+
const agent = request.agentId;
|
|
4481
|
+
const nothing = 'Nothing was archived.';
|
|
4482
|
+
const sharing = agentsSharingAppWith(connection);
|
|
4483
|
+
if (sharing.length > 0) {
|
|
4484
|
+
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}. `
|
|
4485
|
+
+ `Give each agent its own Slack app. A person can archive it in Slack. ${nothing}`);
|
|
4486
|
+
}
|
|
4487
|
+
let info;
|
|
4488
|
+
try {
|
|
4489
|
+
info = (await connection.web.conversations.info({ channel: conversation })).channel;
|
|
4490
|
+
}
|
|
4491
|
+
catch (error) {
|
|
4492
|
+
const code = slackErrorCode(error);
|
|
4493
|
+
if (code === 'channel_not_found') {
|
|
4494
|
+
throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)} Either way, ${agent} can only archive channels its own app created. ${nothing}`);
|
|
4495
|
+
}
|
|
4496
|
+
if (code === 'ratelimited') {
|
|
4497
|
+
throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later. ${nothing}`);
|
|
4498
|
+
}
|
|
4499
|
+
if (code !== undefined && SLACK_AUTH_ERRORS.has(code)) {
|
|
4500
|
+
throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token. ${nothing}`);
|
|
4501
|
+
}
|
|
4502
|
+
throw new Error(`slack: Slack could not tell ${agent}'s app who created ${conversation} (${describeSlackError(error)}). ${nothing}`);
|
|
4503
|
+
}
|
|
4504
|
+
if (info?.is_im === true || info?.is_mpim === true) {
|
|
4505
|
+
throw new Error(`slack: ${conversation} is a direct message, and only channels can be archived. ${nothing}`);
|
|
4506
|
+
}
|
|
4507
|
+
const creator = info?.creator ?? '';
|
|
4508
|
+
if (creator === '' || connection.botUserId === '' || creator !== connection.botUserId) {
|
|
4509
|
+
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}`);
|
|
4510
|
+
}
|
|
4511
|
+
if (info?.is_shared === true || info?.is_ext_shared === true || info?.is_org_shared === true || info?.is_pending_ext_shared === true) {
|
|
4512
|
+
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}`);
|
|
4513
|
+
}
|
|
4514
|
+
if (info?.is_archived === true) {
|
|
4515
|
+
return { conversation, alreadyArchived: true };
|
|
4516
|
+
}
|
|
4517
|
+
try {
|
|
4518
|
+
await archive({ channel: conversation });
|
|
4519
|
+
}
|
|
4520
|
+
catch (error) {
|
|
4521
|
+
const code = slackErrorCode(error);
|
|
4522
|
+
if (code === 'already_archived') {
|
|
4523
|
+
return { conversation, alreadyArchived: true };
|
|
4524
|
+
}
|
|
4525
|
+
warn(`slack: ${agent} could not archive ${conversation} (${describeSlackError(error)})`);
|
|
4526
|
+
if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
|
|
4527
|
+
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.`);
|
|
4528
|
+
}
|
|
4529
|
+
const isPrivate = info?.is_private === true || info?.is_group === true;
|
|
4530
|
+
const why = code === 'missing_scope'
|
|
4531
|
+
? `${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.`
|
|
4532
|
+
: code === 'not_in_channel'
|
|
4533
|
+
? `${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.`
|
|
4534
|
+
: code === 'restricted_action'
|
|
4535
|
+
? `This workspace limits who can archive channels, so ${agent}'s app can't archive ${conversation}. A person has to do it in Slack.`
|
|
4536
|
+
: code === 'ratelimited'
|
|
4537
|
+
? `Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later.`
|
|
4538
|
+
: code === 'channel_not_found'
|
|
4539
|
+
? channelNotFoundSentence(agent, conversation)
|
|
4540
|
+
: SLACK_AUTH_ERRORS.has(code)
|
|
4541
|
+
? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
|
|
4542
|
+
: `Slack did not let ${agent}'s app archive ${conversation} (${code}).`;
|
|
4543
|
+
throw new Error(`slack: ${why} ${nothing}`);
|
|
4544
|
+
}
|
|
4545
|
+
log(`slack: ${agent} archived ${conversation}`);
|
|
4546
|
+
return { conversation, alreadyArchived: false };
|
|
4076
4547
|
};
|
|
4077
4548
|
/**
|
|
4078
4549
|
* Renders one parked call as buttons and remembers where it went.
|
|
@@ -4633,7 +5104,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4633
5104
|
channel,
|
|
4634
5105
|
text: escapeSlackText(`${agentName} is asking ${request.replace === true ? 'to replace' : 'for'} a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
|
|
4635
5106
|
...(thread ? { thread_ts: thread } : {}),
|
|
4636
|
-
blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true),
|
|
5107
|
+
blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true, request.env),
|
|
4637
5108
|
});
|
|
4638
5109
|
}
|
|
4639
5110
|
catch (error) {
|
|
@@ -4655,6 +5126,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4655
5126
|
name: request.name,
|
|
4656
5127
|
scope: request.scope,
|
|
4657
5128
|
...(request.replace === true ? { replace: true } : {}),
|
|
5129
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
4658
5130
|
approvers,
|
|
4659
5131
|
});
|
|
4660
5132
|
};
|
|
@@ -4668,14 +5140,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4668
5140
|
? `for ${escapeSlackText(post.agentName)} only`
|
|
4669
5141
|
: `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
|
|
4670
5142
|
const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
|
|
4671
|
-
const
|
|
5143
|
+
const shell = post.env !== undefined && event.grantError === undefined
|
|
5144
|
+
? ` Its shell commands read it as \`${escapeSlackText(post.env)}\`.`
|
|
5145
|
+
: '';
|
|
5146
|
+
const text = (event.replace === true
|
|
4672
5147
|
? `Replaced \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}. `
|
|
4673
5148
|
+ `${escapeSlackText(post.agentName)} uses the new value from its next reply; it is in the credential store, not here.`
|
|
4674
5149
|
: event.grantError === undefined
|
|
4675
5150
|
? `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.`
|
|
4676
5151
|
: `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
|
|
4677
5152
|
+ `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
|
|
4678
|
-
+ 'Add the name to the credentials list in its soul on the machine.';
|
|
5153
|
+
+ 'Add the name to the credentials list in its soul on the machine.') + shell;
|
|
4679
5154
|
try {
|
|
4680
5155
|
await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
|
|
4681
5156
|
}
|
|
@@ -6801,20 +7276,255 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6801
7276
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
6802
7277
|
}
|
|
6803
7278
|
};
|
|
7279
|
+
/**
|
|
7280
|
+
* One agent's app, authenticated, with who its bot is recorded, and its
|
|
7281
|
+
* socket built but not started: undefined, with a warning, when it would
|
|
7282
|
+
* not authenticate. Recorded before the socket is even built: an app
|
|
7283
|
+
* that never comes up must still be recognizable when somebody names it.
|
|
7284
|
+
*/
|
|
7285
|
+
const authenticate = async (config) => {
|
|
7286
|
+
try {
|
|
7287
|
+
const created = createWeb(config.botToken);
|
|
7288
|
+
const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
|
|
7289
|
+
const auth = await web.auth.test();
|
|
7290
|
+
const botUserId = auth.user_id ?? '';
|
|
7291
|
+
const teamId = auth.team_id ?? '';
|
|
7292
|
+
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
7293
|
+
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
7294
|
+
return {
|
|
7295
|
+
config,
|
|
7296
|
+
web,
|
|
7297
|
+
socket: createSocket(config.appToken),
|
|
7298
|
+
botUserId,
|
|
7299
|
+
teamId,
|
|
7300
|
+
statusWarned: { value: false },
|
|
7301
|
+
};
|
|
7302
|
+
}
|
|
7303
|
+
catch (error) {
|
|
7304
|
+
// One broken app must not take the rest of the fleet down.
|
|
7305
|
+
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7306
|
+
return undefined;
|
|
7307
|
+
}
|
|
7308
|
+
};
|
|
7309
|
+
/**
|
|
7310
|
+
* Starts an authenticated app's socket and serves it. Resolves with the
|
|
7311
|
+
* listener a replay of kept messages delivers through, or undefined when
|
|
7312
|
+
* the socket would not start: authenticated but not serving, it answers
|
|
7313
|
+
* nothing, and stays recognizable so nobody answers in its place.
|
|
7314
|
+
*/
|
|
7315
|
+
const connect = async (connection) => {
|
|
7316
|
+
const { config, socket } = connection;
|
|
7317
|
+
try {
|
|
7318
|
+
// Returns the work it started: the SDK ignores it, and a test's fake
|
|
7319
|
+
// socket waits on it rather than guessing how long it takes.
|
|
7320
|
+
const handle = (inbound) => {
|
|
7321
|
+
const handled = inbound.catch((error) => {
|
|
7322
|
+
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7323
|
+
});
|
|
7324
|
+
inflight.add(handled);
|
|
7325
|
+
void handled.finally(() => inflight.delete(handled));
|
|
7326
|
+
return handled;
|
|
7327
|
+
};
|
|
7328
|
+
// The replay's own deliveries never wait on the hold they release.
|
|
7329
|
+
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
7330
|
+
const onEvent = (args) => {
|
|
7331
|
+
const channel = args.event?.channel;
|
|
7332
|
+
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
7333
|
+
if (hold === undefined) {
|
|
7334
|
+
return onReplay(args);
|
|
7335
|
+
}
|
|
7336
|
+
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
7337
|
+
// message that is only waiting its turn; admitted after the hold.
|
|
7338
|
+
void Promise.resolve(args.ack()).catch((error) => {
|
|
7339
|
+
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
7340
|
+
});
|
|
7341
|
+
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
7342
|
+
};
|
|
7343
|
+
socket.on('app_mention', onEvent);
|
|
7344
|
+
socket.on('message', onEvent);
|
|
7345
|
+
socket.on('interactive', (args) => {
|
|
7346
|
+
const handled = handleInteractive(connection, args).catch((error) => {
|
|
7347
|
+
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7348
|
+
});
|
|
7349
|
+
track(handled);
|
|
7350
|
+
return handled;
|
|
7351
|
+
});
|
|
7352
|
+
const up = (async () => {
|
|
7353
|
+
await socket.start();
|
|
7354
|
+
// A stop that began while this socket was starting has already
|
|
7355
|
+
// disconnected what it could see; one left up here would answer
|
|
7356
|
+
// beside the next daemon. Checked in the same step that adds it
|
|
7357
|
+
// to `connections`, so a stop either sees it there or finds it
|
|
7358
|
+
// here.
|
|
7359
|
+
if (stopRequested) {
|
|
7360
|
+
if (!await disconnect(connection, 'the adapter is stopping')) {
|
|
7361
|
+
leftUpAtStop.add(config.agentId);
|
|
7362
|
+
}
|
|
7363
|
+
return false;
|
|
7364
|
+
}
|
|
7365
|
+
connections.push(connection);
|
|
7366
|
+
return true;
|
|
7367
|
+
})();
|
|
7368
|
+
socketsStarting.set(up, config.agentId);
|
|
7369
|
+
try {
|
|
7370
|
+
if (!await up) {
|
|
7371
|
+
return undefined;
|
|
7372
|
+
}
|
|
7373
|
+
}
|
|
7374
|
+
finally {
|
|
7375
|
+
socketsStarting.delete(up);
|
|
7376
|
+
}
|
|
7377
|
+
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
7378
|
+
return onReplay;
|
|
7379
|
+
}
|
|
7380
|
+
catch (error) {
|
|
7381
|
+
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7382
|
+
return undefined;
|
|
7383
|
+
}
|
|
7384
|
+
};
|
|
7385
|
+
/** Closes an app's socket; false, with a warning, when the close was refused. */
|
|
7386
|
+
const disconnect = async (connection, reason) => {
|
|
7387
|
+
try {
|
|
7388
|
+
await connection.socket.disconnect();
|
|
7389
|
+
return true;
|
|
7390
|
+
}
|
|
7391
|
+
catch (error) {
|
|
7392
|
+
warn(`slack: could not disconnect ${connection.config.agentId} (${reason}): ${error instanceof Error ? error.message : String(error)}`);
|
|
7393
|
+
return false;
|
|
7394
|
+
}
|
|
7395
|
+
};
|
|
7396
|
+
const sameApp = (a, b) => a.appToken === b.appToken && a.botToken === b.botToken;
|
|
7397
|
+
/**
|
|
7398
|
+
* Brings the connected apps in line with the agents to carry now (card
|
|
7399
|
+
* 894): connects an agent whose app is new, or that the roster has only
|
|
7400
|
+
* now, or that did not come up before; reconnects one whose tokens
|
|
7401
|
+
* changed; disconnects one whose tokens were removed or whose soul is
|
|
7402
|
+
* gone. Every other socket is left alone, so a conversation on it never
|
|
7403
|
+
* notices. Settings come with the list `options.loadAgents` reads, which
|
|
7404
|
+
* for the daemon is the config it started with.
|
|
7405
|
+
*/
|
|
7406
|
+
const reconcile = async () => {
|
|
7407
|
+
const gateway = gatewayRef;
|
|
7408
|
+
if (gateway === undefined || stopRequested || options.loadAgents === undefined) {
|
|
7409
|
+
return;
|
|
7410
|
+
}
|
|
7411
|
+
const next = await options.loadAgents();
|
|
7412
|
+
if (gatewayRef !== gateway || stopRequested) {
|
|
7413
|
+
return;
|
|
7414
|
+
}
|
|
7415
|
+
const known = new Set(gateway.agents().map((agent) => agent.id));
|
|
7416
|
+
const wanted = new Map(next.filter((config) => known.has(config.agentId)).map((config) => [config.agentId, config]));
|
|
7417
|
+
const connected = new Map(connections.map((connection) => [connection.config.agentId, connection]));
|
|
7418
|
+
const leaving = [...connected.values()].filter((connection) => {
|
|
7419
|
+
const config = wanted.get(connection.config.agentId);
|
|
7420
|
+
return config === undefined || !sameApp(config, connection.config);
|
|
7421
|
+
});
|
|
7422
|
+
// Closed first, and only a closed one is let go of. A socket whose close
|
|
7423
|
+
// was refused may still deliver, so it stays this agent's connection,
|
|
7424
|
+
// on the tokens it has, and nothing is started beside it: a second
|
|
7425
|
+
// socket for the agent would answer every message twice, and a removed
|
|
7426
|
+
// app would keep answering while reported gone. A restart clears it.
|
|
7427
|
+
for (const connection of leaving) {
|
|
7428
|
+
const agentId = connection.config.agentId;
|
|
7429
|
+
const reason = !known.has(agentId)
|
|
7430
|
+
? 'its soul is gone'
|
|
7431
|
+
: wanted.has(agentId) ? 'its tokens changed' : 'its tokens were removed';
|
|
7432
|
+
if (!await disconnect(connection, reason)) {
|
|
7433
|
+
warn(`slack: ${agentId} stays connected with the app it had until a restart, since its socket would not close`);
|
|
7434
|
+
continue;
|
|
7435
|
+
}
|
|
7436
|
+
const index = connections.indexOf(connection);
|
|
7437
|
+
if (index >= 0) {
|
|
7438
|
+
connections.splice(index, 1);
|
|
7439
|
+
}
|
|
7440
|
+
log(`slack: ${agentId} disconnected (${reason})`);
|
|
7441
|
+
}
|
|
7442
|
+
// What is still connected now, stuck sockets included, serves on.
|
|
7443
|
+
const serving = (agentId) => connections.find((connection) => connection.config.agentId === agentId);
|
|
7444
|
+
const arriving = [...wanted.values()].filter((config) => serving(config.agentId) === undefined);
|
|
7445
|
+
// A connected app keeps the configuration it is serving with.
|
|
7446
|
+
carried = [
|
|
7447
|
+
...next.map((config) => serving(config.agentId)?.config ?? config),
|
|
7448
|
+
...connections.filter((connection) => !next.some((config) => config.agentId === connection.config.agentId)).map((connection) => connection.config),
|
|
7449
|
+
];
|
|
7450
|
+
configuredAgents.clear();
|
|
7451
|
+
for (const config of carried) {
|
|
7452
|
+
configuredAgents.add(config.agentId);
|
|
7453
|
+
}
|
|
7454
|
+
// Who each remaining bot is, in the carried order: an app no longer
|
|
7455
|
+
// carried is nobody's to recognize, and a new one is learned below.
|
|
7456
|
+
const kept = new Map(botIdentities);
|
|
7457
|
+
botIdentities.clear();
|
|
7458
|
+
for (const config of carried) {
|
|
7459
|
+
const identity = kept.get(config.agentId);
|
|
7460
|
+
if (identity !== undefined && serving(config.agentId) !== undefined) {
|
|
7461
|
+
botIdentities.set(config.agentId, identity);
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
7464
|
+
for (const config of next) {
|
|
7465
|
+
if (!known.has(config.agentId) && serving(config.agentId) === undefined) {
|
|
7466
|
+
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
7467
|
+
}
|
|
7468
|
+
}
|
|
7469
|
+
// Every new identity first, then the sockets, as `start` does: a
|
|
7470
|
+
// mention of an agent not yet known would read as an untagged reply.
|
|
7471
|
+
const authenticated = [];
|
|
7472
|
+
for (const config of arriving) {
|
|
7473
|
+
if (stopRequested) {
|
|
7474
|
+
return;
|
|
7475
|
+
}
|
|
7476
|
+
const connection = await authenticate(config);
|
|
7477
|
+
if (connection) {
|
|
7478
|
+
authenticated.push(connection);
|
|
7479
|
+
}
|
|
7480
|
+
}
|
|
7481
|
+
if (authenticated.length > 0) {
|
|
7482
|
+
const learned = new Map(botIdentities);
|
|
7483
|
+
botIdentities.clear();
|
|
7484
|
+
for (const config of carried) {
|
|
7485
|
+
const identity = learned.get(config.agentId);
|
|
7486
|
+
if (identity !== undefined) {
|
|
7487
|
+
botIdentities.set(config.agentId, identity);
|
|
7488
|
+
}
|
|
7489
|
+
}
|
|
7490
|
+
}
|
|
7491
|
+
settleHomeOwners();
|
|
7492
|
+
for (const connection of authenticated) {
|
|
7493
|
+
if (stopRequested) {
|
|
7494
|
+
return;
|
|
7495
|
+
}
|
|
7496
|
+
await connect(connection);
|
|
7497
|
+
}
|
|
7498
|
+
};
|
|
7499
|
+
// One reload at a time: the gateway can call again before an earlier one
|
|
7500
|
+
// settled, since it stops waiting after a bound.
|
|
7501
|
+
let reloading = Promise.resolve();
|
|
7502
|
+
const reload = () => {
|
|
7503
|
+
const run = reloading.then(reconcile, reconcile);
|
|
7504
|
+
reloading = run.catch(() => undefined);
|
|
7505
|
+
return run;
|
|
7506
|
+
};
|
|
6804
7507
|
return {
|
|
6805
7508
|
name: 'slack',
|
|
7509
|
+
...(options.loadAgents !== undefined ? { reload } : {}),
|
|
6806
7510
|
resolveOutbound,
|
|
6807
7511
|
readConversation,
|
|
6808
7512
|
sessionConversation,
|
|
6809
7513
|
createConversation,
|
|
7514
|
+
joinConversation,
|
|
7515
|
+
inviteToConversation,
|
|
7516
|
+
archiveConversation,
|
|
6810
7517
|
// A channel id is unique only within its workspace, so a standing
|
|
6811
7518
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
6812
7519
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
6813
7520
|
conversationKindFor,
|
|
6814
7521
|
requestCredential,
|
|
6815
7522
|
credentialReplaceForms: true,
|
|
7523
|
+
credentialEnvForms: true,
|
|
6816
7524
|
async start(gateway) {
|
|
6817
7525
|
gatewayRef = gateway;
|
|
7526
|
+
stopRequested = false;
|
|
7527
|
+
leftUpAtStop.clear();
|
|
6818
7528
|
// Nothing counts as seen live until every socket is up (Codex
|
|
6819
7529
|
// 4321128637): a message sent while the sockets are still coming up
|
|
6820
7530
|
// reaches none of them, so a read stopping before it stops short.
|
|
@@ -6940,33 +7650,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6940
7650
|
// agent not yet known reads as an untagged reply — which the agent
|
|
6941
7651
|
// holding that thread would then answer.
|
|
6942
7652
|
const authenticated = [];
|
|
6943
|
-
for (const config of
|
|
7653
|
+
for (const config of carried) {
|
|
6944
7654
|
if (!known.has(config.agentId)) {
|
|
6945
7655
|
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
6946
7656
|
continue;
|
|
6947
7657
|
}
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
const auth = await web.auth.test();
|
|
6952
|
-
const botUserId = auth.user_id ?? '';
|
|
6953
|
-
const teamId = auth.team_id ?? '';
|
|
6954
|
-
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
6955
|
-
// Recorded before the socket is even built: an app that never
|
|
6956
|
-
// comes up must still be recognizable when somebody names it.
|
|
6957
|
-
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
6958
|
-
authenticated.push({
|
|
6959
|
-
config,
|
|
6960
|
-
web,
|
|
6961
|
-
socket: createSocket(config.appToken),
|
|
6962
|
-
botUserId,
|
|
6963
|
-
teamId,
|
|
6964
|
-
statusWarned: { value: false },
|
|
6965
|
-
});
|
|
6966
|
-
}
|
|
6967
|
-
catch (error) {
|
|
6968
|
-
// One broken app must not take the rest of the fleet down.
|
|
6969
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7658
|
+
const connection = await authenticate(config);
|
|
7659
|
+
if (connection) {
|
|
7660
|
+
authenticated.push(connection);
|
|
6970
7661
|
}
|
|
6971
7662
|
}
|
|
6972
7663
|
settleHomeOwners();
|
|
@@ -6989,51 +7680,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6989
7680
|
}
|
|
6990
7681
|
}
|
|
6991
7682
|
for (const connection of authenticated) {
|
|
6992
|
-
const
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
// socket waits on it rather than guessing how long it takes.
|
|
6996
|
-
const handle = (inbound) => {
|
|
6997
|
-
const handled = inbound.catch((error) => {
|
|
6998
|
-
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6999
|
-
});
|
|
7000
|
-
inflight.add(handled);
|
|
7001
|
-
void handled.finally(() => inflight.delete(handled));
|
|
7002
|
-
return handled;
|
|
7003
|
-
};
|
|
7004
|
-
// The replay's own deliveries never wait on the hold they release.
|
|
7005
|
-
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
7006
|
-
const onEvent = (args) => {
|
|
7007
|
-
const channel = args.event?.channel;
|
|
7008
|
-
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
7009
|
-
if (hold === undefined) {
|
|
7010
|
-
return onReplay(args);
|
|
7011
|
-
}
|
|
7012
|
-
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
7013
|
-
// message that is only waiting its turn; admitted after the hold.
|
|
7014
|
-
void Promise.resolve(args.ack()).catch((error) => {
|
|
7015
|
-
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
7016
|
-
});
|
|
7017
|
-
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
7018
|
-
};
|
|
7019
|
-
socket.on('app_mention', onEvent);
|
|
7020
|
-
socket.on('message', onEvent);
|
|
7021
|
-
listeners.set(config.agentId, onReplay);
|
|
7022
|
-
socket.on('interactive', (args) => {
|
|
7023
|
-
const handled = handleInteractive(connection, args).catch((error) => {
|
|
7024
|
-
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7025
|
-
});
|
|
7026
|
-
track(handled);
|
|
7027
|
-
return handled;
|
|
7028
|
-
});
|
|
7029
|
-
await socket.start();
|
|
7030
|
-
connections.push(connection);
|
|
7031
|
-
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
7032
|
-
}
|
|
7033
|
-
catch (error) {
|
|
7034
|
-
// Authenticated but not serving: it answers nothing, and stays
|
|
7035
|
-
// recognizable so nobody answers in its place.
|
|
7036
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7683
|
+
const onReplay = await connect(connection);
|
|
7684
|
+
if (onReplay) {
|
|
7685
|
+
listeners.set(connection.config.agentId, onReplay);
|
|
7037
7686
|
}
|
|
7038
7687
|
}
|
|
7039
7688
|
const liveMs = now() + LIVE_FROM_MARGIN_MS;
|
|
@@ -7047,11 +7696,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7047
7696
|
track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
|
|
7048
7697
|
},
|
|
7049
7698
|
async stop() {
|
|
7699
|
+
stopRequested = true;
|
|
7050
7700
|
replayStopping?.abort();
|
|
7051
7701
|
releaseAllConversations();
|
|
7052
7702
|
// Stop intake first, so no new INBOUND event can slip into
|
|
7053
|
-
// `inflight` while the drain below runs.
|
|
7054
|
-
|
|
7703
|
+
// `inflight` while the drain below runs. A close that is refused
|
|
7704
|
+
// leaves a socket that may still deliver (card 935), and so does one
|
|
7705
|
+
// still starting that does not come down in time: the stop rejects
|
|
7706
|
+
// naming them, after the drain, and the gateway counts that as not
|
|
7707
|
+
// drained, so a restart exits for the service manager rather than
|
|
7708
|
+
// start a second daemon answering beside them.
|
|
7709
|
+
const closing = Promise.all(connections.map(async (connection) => await disconnect(connection, 'the adapter is stopping') ? undefined : connection.config.agentId));
|
|
7710
|
+
// Whatever is still in here once the wait ends never came up or down.
|
|
7711
|
+
const starting = new Map(socketsStarting);
|
|
7712
|
+
let waited;
|
|
7713
|
+
await Promise.race([
|
|
7714
|
+
Promise.allSettled([...starting.keys()].map((up) => up.finally(() => starting.delete(up)))),
|
|
7715
|
+
new Promise((resolve) => { waited = setTimeout(resolve, stopWaitMs); }),
|
|
7716
|
+
]);
|
|
7717
|
+
clearTimeout(waited);
|
|
7718
|
+
const refused = [...new Set([...(await closing).filter((agentId) => agentId !== undefined), ...leftUpAtStop])];
|
|
7719
|
+
const stillStarting = [...new Set(starting.values())];
|
|
7720
|
+
leftUpAtStop.clear();
|
|
7055
7721
|
// Until it stays empty, not once. The bus subscription is still live
|
|
7056
7722
|
// here — it comes down after the drain, because a turn finishing
|
|
7057
7723
|
// inside the drain still has an outcome to render — so a turn the
|
|
@@ -7085,6 +7751,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7085
7751
|
rendering.clear();
|
|
7086
7752
|
resolvedWhileRendering.clear();
|
|
7087
7753
|
credentialsPosting.clear();
|
|
7754
|
+
const left = [
|
|
7755
|
+
...(refused.length > 0 ? [`the socket for ${refused.join(', ')} would not close`] : []),
|
|
7756
|
+
...(stillStarting.length > 0 ? [`the socket for ${stillStarting.join(', ')} was still starting after ${stopWaitMs}ms`] : []),
|
|
7757
|
+
];
|
|
7758
|
+
if (left.length > 0) {
|
|
7759
|
+
throw new Error(`${left.join('; ')}, so it may still deliver messages`);
|
|
7760
|
+
}
|
|
7088
7761
|
},
|
|
7089
7762
|
};
|
|
7090
7763
|
};
|