@stratusagent/channel-slack 0.14.0 → 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 +99 -4
- package/dist/index.d.ts +49 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +810 -153
- 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
|
/**
|
|
@@ -2351,6 +2489,33 @@ const slackErrorCode = (error) => {
|
|
|
2351
2489
|
const code = error?.data?.error;
|
|
2352
2490
|
return typeof code === 'string' && code.length > 0 ? code : undefined;
|
|
2353
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.';
|
|
2354
2519
|
/**
|
|
2355
2520
|
* Orders two Slack message timestamps ("seconds.micros"). Compared as
|
|
2356
2521
|
* integers, not floats: a current ts has more digits than a double holds.
|
|
@@ -2377,13 +2542,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2377
2542
|
const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
|
|
2378
2543
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
2379
2544
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
2380
|
-
const fetchFile = options.fetchFile ?? defaultFetchFile;
|
|
2545
|
+
const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
|
|
2381
2546
|
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
2547
|
+
const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
|
|
2382
2548
|
const now = options.now ?? Date.now;
|
|
2383
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;
|
|
2384
2553
|
// Every agent this adapter was asked to carry, connected or not — the
|
|
2385
2554
|
// difference between "mine and broken" and "not mine".
|
|
2386
|
-
const configuredAgents = new Set(
|
|
2555
|
+
const configuredAgents = new Set(carried.map((agent) => agent.agentId));
|
|
2387
2556
|
// Renderers queue per session: two messages in one thread share a
|
|
2388
2557
|
// session id, and the gateway serializes their turns — so events route
|
|
2389
2558
|
// to the FIRST registered renderer (the running turn), never a later
|
|
@@ -2577,8 +2746,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2577
2746
|
* is the correct outcome there, and it is also the visible one — the same
|
|
2578
2747
|
* thing a mention has always done when an app is down.
|
|
2579
2748
|
*
|
|
2580
|
-
* Insertion order is `options.agents
|
|
2581
|
-
* `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.
|
|
2582
2752
|
*/
|
|
2583
2753
|
const botIdentities = new Map();
|
|
2584
2754
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
@@ -2607,6 +2777,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2607
2777
|
const resolvedWhileRendering = new Map();
|
|
2608
2778
|
let unsubscribe;
|
|
2609
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();
|
|
2610
2790
|
// Everything the adapter owes Slack goes through here, so stop() drains
|
|
2611
2791
|
// it: an approval question or its retraction must reach the workspace
|
|
2612
2792
|
// before the sockets close, or a message keeps offering buttons that
|
|
@@ -3361,7 +3541,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3361
3541
|
};
|
|
3362
3542
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
3363
3543
|
/** An agent's configuration, whether or not its socket came up. */
|
|
3364
|
-
const agentConfigFor = (agentId) =>
|
|
3544
|
+
const agentConfigFor = (agentId) => carried.find((candidate) => candidate.agentId === agentId);
|
|
3365
3545
|
/**
|
|
3366
3546
|
* Which agent each home channel belongs to, keyed `team:channel` because
|
|
3367
3547
|
* a channel id is unique only within its workspace. Settled at start,
|
|
@@ -3377,7 +3557,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3377
3557
|
const settleHomeOwners = () => {
|
|
3378
3558
|
homeOwners.clear();
|
|
3379
3559
|
const claims = new Map();
|
|
3380
|
-
for (const agent of
|
|
3560
|
+
for (const agent of carried) {
|
|
3381
3561
|
const teamId = botIdentities.get(agent.agentId)?.teamId;
|
|
3382
3562
|
if (teamId === undefined) {
|
|
3383
3563
|
continue;
|
|
@@ -3908,6 +4088,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3908
4088
|
names.set(user, await name);
|
|
3909
4089
|
}
|
|
3910
4090
|
const messages = [];
|
|
4091
|
+
const sources = [];
|
|
3911
4092
|
for (const message of raw) {
|
|
3912
4093
|
if (!message.ts) {
|
|
3913
4094
|
continue;
|
|
@@ -3920,7 +4101,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3920
4101
|
|| (message.user ? names.get(message.user) : undefined);
|
|
3921
4102
|
const at = Number.parseFloat(message.ts);
|
|
3922
4103
|
const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
|
|
3923
|
-
|
|
4104
|
+
const read = {
|
|
3924
4105
|
id: message.ts,
|
|
3925
4106
|
author,
|
|
3926
4107
|
text: plainSlackText(connection, message.text ?? '', names),
|
|
@@ -3929,7 +4110,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3929
4110
|
...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
|
|
3930
4111
|
...(message.reply_count ? { replies: message.reply_count } : {}),
|
|
3931
4112
|
...(files.length > 0 ? { files } : {}),
|
|
3932
|
-
}
|
|
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);
|
|
3933
4122
|
}
|
|
3934
4123
|
return { messages, more, ...(listing ? await listing : {}) };
|
|
3935
4124
|
};
|
|
@@ -3972,15 +4161,60 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3972
4161
|
if (!create || !setTopic || !setPurpose || !inviteUsers) {
|
|
3973
4162
|
throw new Error('slack: this Slack client cannot create channels.');
|
|
3974
4163
|
}
|
|
3975
|
-
// Who each entry is, before anything exists
|
|
3976
|
-
//
|
|
3977
|
-
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 = [];
|
|
3978
4212
|
const notInvited = [];
|
|
3979
4213
|
const toInvite = new Map();
|
|
3980
4214
|
for (const who of invite) {
|
|
3981
4215
|
const identity = botIdentities.get(who);
|
|
3982
|
-
if (who ===
|
|
3983
|
-
|
|
4216
|
+
if (who === agentId || identity?.botUserId === connection.botUserId) {
|
|
4217
|
+
own.push(who);
|
|
3984
4218
|
}
|
|
3985
4219
|
else if (configuredAgents.has(who) && !identity?.botUserId) {
|
|
3986
4220
|
notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
|
|
@@ -4000,90 +4234,316 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4000
4234
|
notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
|
|
4001
4235
|
}
|
|
4002
4236
|
}
|
|
4003
|
-
|
|
4004
|
-
|
|
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;
|
|
4005
4261
|
try {
|
|
4006
|
-
|
|
4262
|
+
refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
|
|
4007
4263
|
}
|
|
4008
4264
|
catch (error) {
|
|
4009
4265
|
const code = slackErrorCode(error);
|
|
4010
|
-
if (code
|
|
4011
|
-
|
|
4012
|
-
}
|
|
4013
|
-
if (code === 'name_taken') {
|
|
4014
|
-
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4266
|
+
if (code !== undefined && refusesWhole(code)) {
|
|
4267
|
+
return { invited, notInvited, refusedWith: code };
|
|
4015
4268
|
}
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
const conversation = created.channel?.id;
|
|
4019
|
-
if (!conversation) {
|
|
4020
|
-
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.`);
|
|
4021
|
-
}
|
|
4022
|
-
const notSet = [];
|
|
4023
|
-
const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
4024
|
-
if (request.topic) {
|
|
4025
|
-
await setTopic({ channel: conversation, topic: request.topic })
|
|
4026
|
-
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
|
|
4269
|
+
refused = refusedOf(error?.data?.errors);
|
|
4270
|
+
failure = describeSlackError(error);
|
|
4027
4271
|
}
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
if (toInvite.
|
|
4033
|
-
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4034
|
-
// the SDK throws for an ok: false answer.
|
|
4035
|
-
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4036
|
-
let refused;
|
|
4037
|
-
let failure;
|
|
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))) {
|
|
4038
4277
|
try {
|
|
4039
|
-
|
|
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);
|
|
4040
4287
|
}
|
|
4041
4288
|
catch (error) {
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
}
|
|
4045
|
-
// A failed call that does not name everyone leaves the rest unknown,
|
|
4046
|
-
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4047
|
-
let members;
|
|
4048
|
-
let membersFailure;
|
|
4049
|
-
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4050
|
-
try {
|
|
4051
|
-
members = new Set();
|
|
4052
|
-
let cursor;
|
|
4053
|
-
do {
|
|
4054
|
-
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4055
|
-
for (const member of page.members ?? []) {
|
|
4056
|
-
members.add(member);
|
|
4057
|
-
}
|
|
4058
|
-
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4059
|
-
} while (cursor);
|
|
4060
|
-
}
|
|
4061
|
-
catch (error) {
|
|
4062
|
-
members = undefined;
|
|
4063
|
-
membersFailure = describe(error);
|
|
4064
|
-
}
|
|
4289
|
+
members = undefined;
|
|
4290
|
+
membersFailure = describeSlackError(error);
|
|
4065
4291
|
}
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
})
|
|
4079
|
-
}
|
|
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
|
+
});
|
|
4080
4306
|
}
|
|
4081
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) => {
|
|
4082
4312
|
const order = new Map(invite.map((who, index) => [who, index]));
|
|
4083
4313
|
const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
|
|
4084
4314
|
invited.sort(byRequest);
|
|
4085
4315
|
notInvited.sort((left, right) => byRequest(left.who, right.who));
|
|
4086
|
-
|
|
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 };
|
|
4087
4547
|
};
|
|
4088
4548
|
/**
|
|
4089
4549
|
* Renders one parked call as buttons and remembers where it went.
|
|
@@ -6816,12 +7276,244 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6816
7276
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
6817
7277
|
}
|
|
6818
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
|
+
};
|
|
6819
7507
|
return {
|
|
6820
7508
|
name: 'slack',
|
|
7509
|
+
...(options.loadAgents !== undefined ? { reload } : {}),
|
|
6821
7510
|
resolveOutbound,
|
|
6822
7511
|
readConversation,
|
|
6823
7512
|
sessionConversation,
|
|
6824
7513
|
createConversation,
|
|
7514
|
+
joinConversation,
|
|
7515
|
+
inviteToConversation,
|
|
7516
|
+
archiveConversation,
|
|
6825
7517
|
// A channel id is unique only within its workspace, so a standing
|
|
6826
7518
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
6827
7519
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
@@ -6831,6 +7523,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6831
7523
|
credentialEnvForms: true,
|
|
6832
7524
|
async start(gateway) {
|
|
6833
7525
|
gatewayRef = gateway;
|
|
7526
|
+
stopRequested = false;
|
|
7527
|
+
leftUpAtStop.clear();
|
|
6834
7528
|
// Nothing counts as seen live until every socket is up (Codex
|
|
6835
7529
|
// 4321128637): a message sent while the sockets are still coming up
|
|
6836
7530
|
// reaches none of them, so a read stopping before it stops short.
|
|
@@ -6956,33 +7650,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6956
7650
|
// agent not yet known reads as an untagged reply — which the agent
|
|
6957
7651
|
// holding that thread would then answer.
|
|
6958
7652
|
const authenticated = [];
|
|
6959
|
-
for (const config of
|
|
7653
|
+
for (const config of carried) {
|
|
6960
7654
|
if (!known.has(config.agentId)) {
|
|
6961
7655
|
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
6962
7656
|
continue;
|
|
6963
7657
|
}
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
const auth = await web.auth.test();
|
|
6968
|
-
const botUserId = auth.user_id ?? '';
|
|
6969
|
-
const teamId = auth.team_id ?? '';
|
|
6970
|
-
checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
|
|
6971
|
-
// Recorded before the socket is even built: an app that never
|
|
6972
|
-
// comes up must still be recognizable when somebody names it.
|
|
6973
|
-
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
6974
|
-
authenticated.push({
|
|
6975
|
-
config,
|
|
6976
|
-
web,
|
|
6977
|
-
socket: createSocket(config.appToken),
|
|
6978
|
-
botUserId,
|
|
6979
|
-
teamId,
|
|
6980
|
-
statusWarned: { value: false },
|
|
6981
|
-
});
|
|
6982
|
-
}
|
|
6983
|
-
catch (error) {
|
|
6984
|
-
// One broken app must not take the rest of the fleet down.
|
|
6985
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7658
|
+
const connection = await authenticate(config);
|
|
7659
|
+
if (connection) {
|
|
7660
|
+
authenticated.push(connection);
|
|
6986
7661
|
}
|
|
6987
7662
|
}
|
|
6988
7663
|
settleHomeOwners();
|
|
@@ -7005,51 +7680,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7005
7680
|
}
|
|
7006
7681
|
}
|
|
7007
7682
|
for (const connection of authenticated) {
|
|
7008
|
-
const
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
// socket waits on it rather than guessing how long it takes.
|
|
7012
|
-
const handle = (inbound) => {
|
|
7013
|
-
const handled = inbound.catch((error) => {
|
|
7014
|
-
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7015
|
-
});
|
|
7016
|
-
inflight.add(handled);
|
|
7017
|
-
void handled.finally(() => inflight.delete(handled));
|
|
7018
|
-
return handled;
|
|
7019
|
-
};
|
|
7020
|
-
// The replay's own deliveries never wait on the hold they release.
|
|
7021
|
-
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
7022
|
-
const onEvent = (args) => {
|
|
7023
|
-
const channel = args.event?.channel;
|
|
7024
|
-
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
7025
|
-
if (hold === undefined) {
|
|
7026
|
-
return onReplay(args);
|
|
7027
|
-
}
|
|
7028
|
-
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
7029
|
-
// message that is only waiting its turn; admitted after the hold.
|
|
7030
|
-
void Promise.resolve(args.ack()).catch((error) => {
|
|
7031
|
-
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
7032
|
-
});
|
|
7033
|
-
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
7034
|
-
};
|
|
7035
|
-
socket.on('app_mention', onEvent);
|
|
7036
|
-
socket.on('message', onEvent);
|
|
7037
|
-
listeners.set(config.agentId, onReplay);
|
|
7038
|
-
socket.on('interactive', (args) => {
|
|
7039
|
-
const handled = handleInteractive(connection, args).catch((error) => {
|
|
7040
|
-
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7041
|
-
});
|
|
7042
|
-
track(handled);
|
|
7043
|
-
return handled;
|
|
7044
|
-
});
|
|
7045
|
-
await socket.start();
|
|
7046
|
-
connections.push(connection);
|
|
7047
|
-
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
7048
|
-
}
|
|
7049
|
-
catch (error) {
|
|
7050
|
-
// Authenticated but not serving: it answers nothing, and stays
|
|
7051
|
-
// recognizable so nobody answers in its place.
|
|
7052
|
-
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7683
|
+
const onReplay = await connect(connection);
|
|
7684
|
+
if (onReplay) {
|
|
7685
|
+
listeners.set(connection.config.agentId, onReplay);
|
|
7053
7686
|
}
|
|
7054
7687
|
}
|
|
7055
7688
|
const liveMs = now() + LIVE_FROM_MARGIN_MS;
|
|
@@ -7063,11 +7696,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7063
7696
|
track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
|
|
7064
7697
|
},
|
|
7065
7698
|
async stop() {
|
|
7699
|
+
stopRequested = true;
|
|
7066
7700
|
replayStopping?.abort();
|
|
7067
7701
|
releaseAllConversations();
|
|
7068
7702
|
// Stop intake first, so no new INBOUND event can slip into
|
|
7069
|
-
// `inflight` while the drain below runs.
|
|
7070
|
-
|
|
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();
|
|
7071
7721
|
// Until it stays empty, not once. The bus subscription is still live
|
|
7072
7722
|
// here — it comes down after the drain, because a turn finishing
|
|
7073
7723
|
// inside the drain still has an outcome to render — so a turn the
|
|
@@ -7101,6 +7751,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7101
7751
|
rendering.clear();
|
|
7102
7752
|
resolvedWhileRendering.clear();
|
|
7103
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
|
+
}
|
|
7104
7761
|
},
|
|
7105
7762
|
};
|
|
7106
7763
|
};
|