@stratusagent/channel-slack 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +377 -29
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import path from 'node:path';
|
|
5
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, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
@@ -92,6 +93,12 @@ const NO_REPLY_TEXT = '(no reply)';
|
|
|
92
93
|
const EARLIER_MESSAGE_LIMIT = 40;
|
|
93
94
|
/** The most pages of 200 read from a thread for that: 4,000 replies. */
|
|
94
95
|
const EARLIER_THREAD_PAGES = 20;
|
|
96
|
+
/**
|
|
97
|
+
* The holder-record name for a bot this adapter does not serve: an agent on
|
|
98
|
+
* another gateway, or any other app. The colon keeps it from ever equaling
|
|
99
|
+
* an agent id, so the holder rule reads it as "somebody else's".
|
|
100
|
+
*/
|
|
101
|
+
const FOREIGN_SPEAKER_PREFIX = 'slack-bot:';
|
|
95
102
|
/**
|
|
96
103
|
* At the top of a channel, how many of its most recent messages a mention
|
|
97
104
|
* opens with, and how many are read to find them.
|
|
@@ -1646,6 +1653,136 @@ const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
|
1646
1653
|
* is a cost decision as much as a context one.
|
|
1647
1654
|
*/
|
|
1648
1655
|
const TEXT_ATTACHMENTS_MAX_TOTAL_BYTES = 200 * 1024;
|
|
1656
|
+
/**
|
|
1657
|
+
* The most a text attachment may weigh to be saved to the agent's
|
|
1658
|
+
* workspace when it is too large to read into the message. A 251 KB
|
|
1659
|
+
* Markdown spec reached an agent as a filename and it had no way to open
|
|
1660
|
+
* it; saved, the agent reads it with its file tools, in parts.
|
|
1661
|
+
*/
|
|
1662
|
+
const TEXT_ATTACHMENT_SAVE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1663
|
+
/** The default bound on one agent's saved attachments, all together. */
|
|
1664
|
+
const SAVED_ATTACHMENTS_MAX_BYTES = 256 * 1024 * 1024;
|
|
1665
|
+
/**
|
|
1666
|
+
* The most saved attachments one agent keeps, whatever they weigh. A file
|
|
1667
|
+
* crowded out of a full message is saved however small it is, and each save
|
|
1668
|
+
* is a ledger record, so the count is bounded as well as the bytes.
|
|
1669
|
+
*/
|
|
1670
|
+
const SAVED_ATTACHMENTS_MAX_FILES = 1_000;
|
|
1671
|
+
/** Under the agent's workspace, where saved attachments go. */
|
|
1672
|
+
const SAVED_ATTACHMENTS_DIR = path.join('attachments', 'slack');
|
|
1673
|
+
/**
|
|
1674
|
+
* A file name safe to join under a directory: Slack's name is whatever the
|
|
1675
|
+
* uploader's client sent, so separators, `..`, and control characters are
|
|
1676
|
+
* replaced rather than trusted. Stamped, so the same file shared twice is
|
|
1677
|
+
* two saves rather than a name already taken.
|
|
1678
|
+
*/
|
|
1679
|
+
const savedAttachmentName = (file, now) => {
|
|
1680
|
+
const base = ((file.name ?? file.title ?? '').split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '').slice(0, 120);
|
|
1681
|
+
const id = (file.id ?? '').replace(/[^A-Za-z0-9]+/g, '').slice(0, 32);
|
|
1682
|
+
return [now.toString(36), ...(id.length > 0 ? [id] : []), base.length > 0 ? base : 'attachment'].join('-');
|
|
1683
|
+
};
|
|
1684
|
+
/**
|
|
1685
|
+
* Refuses a save that would take the directory's regular files past
|
|
1686
|
+
* `maxBytes` together, or its entries past `SAVED_ATTACHMENTS_MAX_FILES`. Refused rather than making room: deleting on a
|
|
1687
|
+
* Slack member's say is a worse failure than a full directory, and with
|
|
1688
|
+
* nothing removed every ledger record still names a file that exists.
|
|
1689
|
+
*/
|
|
1690
|
+
const ensureRoom = async (dir, incoming, maxBytes) => {
|
|
1691
|
+
let total = 0;
|
|
1692
|
+
const names = await readdir(dir);
|
|
1693
|
+
for (const name of names) {
|
|
1694
|
+
const info = await lstat(path.join(dir, name)).catch(() => undefined);
|
|
1695
|
+
total += info?.isFile() ? info.size : 0;
|
|
1696
|
+
}
|
|
1697
|
+
if (names.length >= SAVED_ATTACHMENTS_MAX_FILES) {
|
|
1698
|
+
throw new Error(`${dir} already holds ${SAVED_ATTACHMENTS_MAX_FILES} saved attachments; remove files there to save more`);
|
|
1699
|
+
}
|
|
1700
|
+
if (total + incoming > maxBytes) {
|
|
1701
|
+
throw new Error(`saved attachments in ${dir} would pass their ${maxBytes}-byte bound; remove files there to save more`);
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
/**
|
|
1705
|
+
* One save at a time per directory, across messages and sessions: two
|
|
1706
|
+
* concurrent saves would each measure the same free space and both write.
|
|
1707
|
+
*/
|
|
1708
|
+
const saveChains = new Map();
|
|
1709
|
+
const serialized = (key, run) => {
|
|
1710
|
+
const previous = saveChains.get(key) ?? Promise.resolve();
|
|
1711
|
+
const next = previous.catch(() => undefined).then(run);
|
|
1712
|
+
const settled = next.catch(() => undefined);
|
|
1713
|
+
saveChains.set(key, settled);
|
|
1714
|
+
void settled.then(() => {
|
|
1715
|
+
if (saveChains.get(key) === settled) {
|
|
1716
|
+
saveChains.delete(key);
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
return next;
|
|
1720
|
+
};
|
|
1721
|
+
/**
|
|
1722
|
+
* The saver for one agent and one sender. The directory is canonical
|
|
1723
|
+
* because the ledger is keyed the way `fs.read` looks a path up, through
|
|
1724
|
+
* `realpath`. The path is recorded before the bytes land, so a crash
|
|
1725
|
+
* between the two leaves a labelled path with no file, never a file with
|
|
1726
|
+
* no label; and the file is created exclusively, so a link planted at the
|
|
1727
|
+
* name fails the save rather than carrying the bytes somewhere the ledger
|
|
1728
|
+
* never saw.
|
|
1729
|
+
*/
|
|
1730
|
+
const createAttachmentSaver = (store, agentId, trust, now) => async (file, body) => {
|
|
1731
|
+
const workspace = store.workspaces.prepare(agentId);
|
|
1732
|
+
const lexical = path.join(workspace, SAVED_ATTACHMENTS_DIR);
|
|
1733
|
+
// One component at a time, never through a link: a recursive mkdir would
|
|
1734
|
+
// follow a link planted at `attachments` and create `slack` wherever it
|
|
1735
|
+
// points before the check below could refuse it.
|
|
1736
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
1737
|
+
let at = workspace;
|
|
1738
|
+
for (const part of SAVED_ATTACHMENTS_DIR.split(path.sep)) {
|
|
1739
|
+
at = path.join(at, part);
|
|
1740
|
+
await mkdir(at, { mode: 0o700 }).catch((error) => {
|
|
1741
|
+
if (error.code !== 'EEXIST') {
|
|
1742
|
+
throw error;
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
const info = await lstat(at);
|
|
1746
|
+
if (!info.isDirectory()) {
|
|
1747
|
+
throw new Error(`${at} is not a directory of the workspace's own`);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
// The directory must be exactly where the workspace says, through no link
|
|
1751
|
+
// of its own: one already planted at `attachments` or `attachments/slack`
|
|
1752
|
+
// would put the file outside the workspace, where the agent's file tools
|
|
1753
|
+
// cannot read it and nothing should be written on a Slack member's say.
|
|
1754
|
+
const dir = await realpath(lexical);
|
|
1755
|
+
if (dir !== path.join(await realpath(workspace), SAVED_ATTACHMENTS_DIR)) {
|
|
1756
|
+
throw new Error(`${lexical} is a link, not the workspace's own directory`);
|
|
1757
|
+
}
|
|
1758
|
+
return serialized(dir, async () => {
|
|
1759
|
+
await ensureRoom(dir, body.length, store.maxBytes ?? SAVED_ATTACHMENTS_MAX_BYTES);
|
|
1760
|
+
const target = path.join(dir, savedAttachmentName(file, now()));
|
|
1761
|
+
await store.ledger.recordWrite(agentId, target, trust);
|
|
1762
|
+
// O_EXCL and O_NOFOLLOW guard only the last component. A directory on the
|
|
1763
|
+
// way swapped for a link after the record would send the create somewhere
|
|
1764
|
+
// else, so the open file is checked against the recorded name before a
|
|
1765
|
+
// byte is written (the same check as the plugins package's
|
|
1766
|
+
// `nameIdentifiesHandle`). A refused save leaves the empty file rather
|
|
1767
|
+
// than unlinking through a name that just proved it can move.
|
|
1768
|
+
const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
1769
|
+
try {
|
|
1770
|
+
const [canonical, atName, held] = await Promise.all([
|
|
1771
|
+
realpath(target).catch(() => undefined),
|
|
1772
|
+
lstat(target).catch(() => undefined),
|
|
1773
|
+
handle.stat(),
|
|
1774
|
+
]);
|
|
1775
|
+
if (canonical !== target || atName === undefined || atName.dev !== held.dev || atName.ino !== held.ino) {
|
|
1776
|
+
throw new Error(`${target} moved between its provenance record and its write`);
|
|
1777
|
+
}
|
|
1778
|
+
await handle.writeFile(body);
|
|
1779
|
+
}
|
|
1780
|
+
finally {
|
|
1781
|
+
await handle.close();
|
|
1782
|
+
}
|
|
1783
|
+
return target;
|
|
1784
|
+
});
|
|
1785
|
+
};
|
|
1649
1786
|
const TEXT_APPLICATION_TYPES = new Set([
|
|
1650
1787
|
'application/json',
|
|
1651
1788
|
'application/xml',
|
|
@@ -1683,8 +1820,9 @@ const isTextAttachment = (file) => {
|
|
|
1683
1820
|
* caps, or comes back as a page stays in `unread` and is named in the
|
|
1684
1821
|
* note, exactly as before; the warning says why.
|
|
1685
1822
|
*/
|
|
1686
|
-
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons) => {
|
|
1823
|
+
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons, save) => {
|
|
1687
1824
|
const texts = [];
|
|
1825
|
+
const saved = [];
|
|
1688
1826
|
const unread = [];
|
|
1689
1827
|
const skip = (file, reason) => {
|
|
1690
1828
|
unread.push(file);
|
|
@@ -1706,8 +1844,13 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1706
1844
|
continue;
|
|
1707
1845
|
}
|
|
1708
1846
|
const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1709
|
-
|
|
1710
|
-
|
|
1847
|
+
// Over the inline cap, a file is still downloaded when it can be saved
|
|
1848
|
+
// to the workspace instead; only over that cap too is it a name.
|
|
1849
|
+
const limit = save === undefined ? maxBytes : Math.max(maxBytes, TEXT_ATTACHMENT_SAVE_MAX_BYTES);
|
|
1850
|
+
if (file.size !== undefined && file.size > limit) {
|
|
1851
|
+
warn(save === undefined
|
|
1852
|
+
? `slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`
|
|
1853
|
+
: `slack: ${fileLabel(file)} is ${file.size} bytes, over the ${limit} bytes a text attachment may be saved at; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
|
|
1711
1854
|
skip(file, 'too-large');
|
|
1712
1855
|
continue;
|
|
1713
1856
|
}
|
|
@@ -1718,7 +1861,7 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1718
1861
|
}
|
|
1719
1862
|
let download;
|
|
1720
1863
|
try {
|
|
1721
|
-
download = await fetchFile(url, botToken, deadline,
|
|
1864
|
+
download = await fetchFile(url, botToken, deadline, limit);
|
|
1722
1865
|
}
|
|
1723
1866
|
catch (error) {
|
|
1724
1867
|
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
@@ -1737,8 +1880,8 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1737
1880
|
skip(file, 'failed');
|
|
1738
1881
|
continue;
|
|
1739
1882
|
}
|
|
1740
|
-
if (download.truncated === true || download.body.length >
|
|
1741
|
-
warn(`slack: ${fileLabel(file)} is larger than the ${
|
|
1883
|
+
if (download.truncated === true || download.body.length > limit) {
|
|
1884
|
+
warn(`slack: ${fileLabel(file)} is larger than the ${limit} bytes ${save === undefined ? 'of text this message could still take' : 'a text attachment may be saved at'}; the download was abandoned and the turn is told it cannot be read.`);
|
|
1742
1885
|
skip(file, 'too-large');
|
|
1743
1886
|
continue;
|
|
1744
1887
|
}
|
|
@@ -1751,11 +1894,24 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1751
1894
|
skip(file, 'unreadable');
|
|
1752
1895
|
continue;
|
|
1753
1896
|
}
|
|
1897
|
+
if (download.body.length > maxBytes) {
|
|
1898
|
+
// Only reachable with a saver: `limit` is `maxBytes` without one.
|
|
1899
|
+
try {
|
|
1900
|
+
saved.push({ name: fileLabel(file), path: await save(file, download.body), bytes: download.body.length });
|
|
1901
|
+
}
|
|
1902
|
+
catch (error) {
|
|
1903
|
+
warn(`slack: could not save ${fileLabel(file)} to the agent's workspace: ${error instanceof Error ? error.message : String(error)}. The turn is told it cannot be read.`);
|
|
1904
|
+
skip(file, 'too-large');
|
|
1905
|
+
}
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1754
1908
|
total += download.body.length;
|
|
1755
1909
|
texts.push({ name: fileLabel(file), text });
|
|
1756
1910
|
}
|
|
1757
|
-
return { texts, unread };
|
|
1911
|
+
return { texts, saved, unread };
|
|
1758
1912
|
};
|
|
1913
|
+
/** Saved attachments, named with where each is, after what was said. */
|
|
1914
|
+
const savedAttachmentBlocks = (saved) => saved.map(({ name, path: at, bytes }) => `\n[Attached: ${name}, ${Math.ceil(bytes / 1024)} KB, too large to include here. It is saved in full at ${at}. Read it with your file tools, in parts if it is long.]`).join('');
|
|
1759
1915
|
/** Read text attachments, set into the message after what was said. */
|
|
1760
1916
|
const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attached: ${name}. Its contents follow.]\n${text.replace(/\s+$/, '')}\n[End of ${name}]`).join('');
|
|
1761
1917
|
/**
|
|
@@ -2080,6 +2236,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2080
2236
|
const botIdentities = new Map();
|
|
2081
2237
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
2082
2238
|
// same id the buttons carry back.
|
|
2239
|
+
//
|
|
2240
|
+
// In memory only, and that is an open gap: a prompt posted by a process
|
|
2241
|
+
// that died is never retracted unless someone clicks it (a click on a
|
|
2242
|
+
// prompt this process never posted rewrites it from the click's own
|
|
2243
|
+
// coordinates). Retracting the ones nobody clicks needs a durable index
|
|
2244
|
+
// of these posts that survives the process that made them.
|
|
2083
2245
|
const approvalPosts = new Map();
|
|
2084
2246
|
// Credential requests whose post is in flight: Slack can show the message,
|
|
2085
2247
|
// and take a click on it, before postMessage resolves here. The same
|
|
@@ -3306,7 +3468,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3306
3468
|
// "Said nothing" as the session can tell it: no reply saved for the
|
|
3307
3469
|
// turn. A line the old process had streamed into a placeholder and
|
|
3308
3470
|
// never saved is invisible here — the session records what was
|
|
3309
|
-
// said, not what Slack took (
|
|
3471
|
+
// said, not what Slack took (the open delivery-record item at the
|
|
3472
|
+
// gateway's lastSpokeAt) — so such a
|
|
3310
3473
|
// turn is failed without a note under that line. Taken over the
|
|
3311
3474
|
// alternative on purpose: a note for every judged turn a restart
|
|
3312
3475
|
// caught turns a silent turn, the common case, into the
|
|
@@ -3374,9 +3537,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3374
3537
|
return { said: routing.unaddressed === true, landed };
|
|
3375
3538
|
}
|
|
3376
3539
|
// A reply the thread's other agents hear like any other, taking their
|
|
3377
|
-
// place for it before the post, as `handleInbound` does.
|
|
3540
|
+
// place for it before the post, as `handleInbound` does. A DM thread
|
|
3541
|
+
// has nobody else in it to hear, as on the live path.
|
|
3378
3542
|
const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
|
|
3379
|
-
const
|
|
3543
|
+
const conversation = metadata[CONVERSATION_METADATA_KEY];
|
|
3544
|
+
const isDm = typeof conversation === 'object' && conversation !== null && !Array.isArray(conversation)
|
|
3545
|
+
&& conversation.kind === 'direct';
|
|
3546
|
+
const heard = routing.reply !== undefined && thread !== undefined && !isDm
|
|
3380
3547
|
? overhearReply(connection, channel, thread, routing.reply, routing, published)
|
|
3381
3548
|
: undefined;
|
|
3382
3549
|
const delivered = await published;
|
|
@@ -3799,6 +3966,142 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3799
3966
|
}
|
|
3800
3967
|
return undefined;
|
|
3801
3968
|
};
|
|
3969
|
+
/**
|
|
3970
|
+
* Whether `user` is one of this adapter's own agents in `team`. Their
|
|
3971
|
+
* replies reach the holder record through `ReplyOutcome.spokeAt`, and
|
|
3972
|
+
* their sessions answer for them after a restart.
|
|
3973
|
+
*/
|
|
3974
|
+
const isFleetBot = (team, user) => user !== undefined && [...botIdentities.values()].some((identity) => identity.teamId === team && identity.botUserId === user);
|
|
3975
|
+
/**
|
|
3976
|
+
* Records a bot this adapter does not serve as the thread's holder from
|
|
3977
|
+
* the message it posted. An agent on another gateway answers in the same
|
|
3978
|
+
* threads, and nothing in this process's sessions knows it spoke: without
|
|
3979
|
+
* this, an agent here that spoke earlier keeps taking every untagged reply,
|
|
3980
|
+
* and the reply meant for the other agent is answered twice. Only who
|
|
3981
|
+
* spoke and when is kept, never what was said; the bot's message itself is
|
|
3982
|
+
* still dropped by `admit`. Any app counts, an integration included: an
|
|
3983
|
+
* untagged reply right after another app posted is not plainly this
|
|
3984
|
+
* agent's, and naming the agent hands the thread back.
|
|
3985
|
+
*/
|
|
3986
|
+
const noteForeignSpeaker = (connection, args) => {
|
|
3987
|
+
const event = args.event;
|
|
3988
|
+
if (!event || event.type !== 'message' || event.channel_type === 'im') {
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
3991
|
+
if (event.bot_id === undefined && event.subtype !== 'bot_message') {
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
|
|
3995
|
+
// An edit, a deletion, a join: not somebody speaking.
|
|
3996
|
+
return;
|
|
3997
|
+
}
|
|
3998
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
3999
|
+
const speaker = event.user ?? event.bot_id;
|
|
4000
|
+
if (speaker === undefined || isFleetBot(team, event.user)) {
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
const thread = event.thread_ts ?? event.ts;
|
|
4004
|
+
rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
|
|
4005
|
+
};
|
|
4006
|
+
/**
|
|
4007
|
+
* The durable half of `noteForeignSpeaker`, for a thread this process has
|
|
4008
|
+
* no record of: where each of `candidates` last held the thread, and where
|
|
4009
|
+
* a bot this adapter does not serve last spoke, read from the thread
|
|
4010
|
+
* itself. Held means an agent's own message there or a person's message
|
|
4011
|
+
* naming it that it would admit, by position in the thread (its session's
|
|
4012
|
+
* `lastSpokeAt` only when its bot user id is unknown). Undefined whenever
|
|
4013
|
+
* it cannot tell: no thread read, a failed read, or a thread too long to
|
|
4014
|
+
* reach its newest replies. Then the reply is routed as it was before
|
|
4015
|
+
* this check.
|
|
4016
|
+
*/
|
|
4017
|
+
const threadPositions = async (candidates, parts, ts) => {
|
|
4018
|
+
const connection = connections.find((candidate) => candidate.config.agentId === candidates[0]?.agentId)
|
|
4019
|
+
?? connections.find((candidate) => candidate.teamId === parts.team);
|
|
4020
|
+
const replies = connection?.web.conversations.replies;
|
|
4021
|
+
if (!connection || !replies) {
|
|
4022
|
+
return undefined;
|
|
4023
|
+
}
|
|
4024
|
+
// Positions in the thread, as the live record keeps them: a reply that
|
|
4025
|
+
// filled a placeholder sits where the placeholder was posted, above
|
|
4026
|
+
// anything posted while it was being written, though the transcript
|
|
4027
|
+
// dates it later. `lastSpokeAt` stands in only when an agent's own
|
|
4028
|
+
// messages can't be told apart in the read.
|
|
4029
|
+
const held = new Map();
|
|
4030
|
+
const byBotUser = new Map();
|
|
4031
|
+
for (const candidate of candidates) {
|
|
4032
|
+
const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
|
|
4033
|
+
if (botUserId !== undefined) {
|
|
4034
|
+
byBotUser.set(botUserId, candidate.agentId);
|
|
4035
|
+
held.set(candidate.agentId, 0);
|
|
4036
|
+
continue;
|
|
4037
|
+
}
|
|
4038
|
+
const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
|
|
4039
|
+
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : 0);
|
|
4040
|
+
}
|
|
4041
|
+
const place = (agentId, at) => {
|
|
4042
|
+
held.set(agentId, Math.max(held.get(agentId) ?? 0, at));
|
|
4043
|
+
};
|
|
4044
|
+
let foreign = 0;
|
|
4045
|
+
try {
|
|
4046
|
+
let cursor;
|
|
4047
|
+
let pages = 0;
|
|
4048
|
+
do {
|
|
4049
|
+
const page = await replies.call(connection.web.conversations, {
|
|
4050
|
+
channel: parts.conversation,
|
|
4051
|
+
ts: parts.thread,
|
|
4052
|
+
latest: ts,
|
|
4053
|
+
inclusive: false,
|
|
4054
|
+
limit: 200,
|
|
4055
|
+
...(cursor ? { cursor } : {}),
|
|
4056
|
+
});
|
|
4057
|
+
pages += 1;
|
|
4058
|
+
for (const message of page.messages ?? []) {
|
|
4059
|
+
const at = Number(message.ts);
|
|
4060
|
+
if (!Number.isFinite(at) || !(at < Number(ts))) {
|
|
4061
|
+
continue;
|
|
4062
|
+
}
|
|
4063
|
+
const own = message.user === undefined ? undefined : byBotUser.get(message.user);
|
|
4064
|
+
if (own !== undefined) {
|
|
4065
|
+
place(own, at);
|
|
4066
|
+
continue;
|
|
4067
|
+
}
|
|
4068
|
+
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
4069
|
+
if (bot) {
|
|
4070
|
+
// A bot's mention hands nothing over live (admit ignores bots),
|
|
4071
|
+
// so here it is only another app speaking, or a peer's words.
|
|
4072
|
+
if (!isFleetBot(parts.team, message.user)) {
|
|
4073
|
+
foreign = Math.max(foreign, at);
|
|
4074
|
+
}
|
|
4075
|
+
continue;
|
|
4076
|
+
}
|
|
4077
|
+
// A person naming an agent takes the thread for it only if that
|
|
4078
|
+
// agent would admit them, as the live handover does.
|
|
4079
|
+
const sender = message.user;
|
|
4080
|
+
if (sender === undefined) {
|
|
4081
|
+
continue;
|
|
4082
|
+
}
|
|
4083
|
+
const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
4084
|
+
if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
|
|
4085
|
+
place(named, at);
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4089
|
+
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
4090
|
+
if (cursor) {
|
|
4091
|
+
return undefined;
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
catch (error) {
|
|
4095
|
+
warn(`slack: could not read thread ${parts.thread} in ${parts.conversation} to see who spoke last, so it is routed as before: ${error instanceof Error ? error.message : String(error)}`);
|
|
4096
|
+
return undefined;
|
|
4097
|
+
}
|
|
4098
|
+
return { held, foreign };
|
|
4099
|
+
};
|
|
4100
|
+
/** Whether another app spoke in the thread after `agentId` last held it. */
|
|
4101
|
+
const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
|
|
4102
|
+
const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
|
|
4103
|
+
return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
|
|
4104
|
+
};
|
|
3802
4105
|
/**
|
|
3803
4106
|
* Which of this process's agents a message names, or undefined if it
|
|
3804
4107
|
* names none of them.
|
|
@@ -3959,13 +4262,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3959
4262
|
}
|
|
3960
4263
|
for (const [agentId, identity] of botIdentities) {
|
|
3961
4264
|
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
3962
|
-
return agentId;
|
|
4265
|
+
return await foreignSpokeSince(agentId, parts, ts, undefined) ? undefined : agentId;
|
|
3963
4266
|
}
|
|
3964
4267
|
}
|
|
3965
4268
|
return undefined;
|
|
3966
4269
|
}
|
|
3967
4270
|
if (engaged.length === 1) {
|
|
3968
|
-
return first.agentId;
|
|
4271
|
+
return await foreignSpokeSince(first.agentId, parts, ts, first.lastSpokeAt) ? undefined : first.agentId;
|
|
3969
4272
|
}
|
|
3970
4273
|
let latest;
|
|
3971
4274
|
let tied = false;
|
|
@@ -3985,7 +4288,34 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3985
4288
|
tied = true;
|
|
3986
4289
|
}
|
|
3987
4290
|
}
|
|
3988
|
-
|
|
4291
|
+
const bySession = tied ? undefined : latest?.agentId;
|
|
4292
|
+
// Sessions date a reply by when it finished, the thread by where its
|
|
4293
|
+
// placeholder went up, and an agent on another gateway may have spoken
|
|
4294
|
+
// since: only the thread can say who holds it. When it can't be read,
|
|
4295
|
+
// the sessions decide, as before.
|
|
4296
|
+
const read = await threadPositions(engaged, parts, ts);
|
|
4297
|
+
if (!read) {
|
|
4298
|
+
return bySession;
|
|
4299
|
+
}
|
|
4300
|
+
let holder;
|
|
4301
|
+
let tiedAt = false;
|
|
4302
|
+
for (const entry of engaged) {
|
|
4303
|
+
const at = read.held.get(entry.agentId) ?? 0;
|
|
4304
|
+
if (at <= 0) {
|
|
4305
|
+
continue;
|
|
4306
|
+
}
|
|
4307
|
+
if (!holder || at > holder.at) {
|
|
4308
|
+
holder = { agentId: entry.agentId, at };
|
|
4309
|
+
tiedAt = false;
|
|
4310
|
+
}
|
|
4311
|
+
else if (at === holder.at) {
|
|
4312
|
+
tiedAt = true;
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
if (!holder) {
|
|
4316
|
+
return read.foreign > 0 ? undefined : bySession;
|
|
4317
|
+
}
|
|
4318
|
+
return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
|
|
3989
4319
|
};
|
|
3990
4320
|
/**
|
|
3991
4321
|
* `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
|
|
@@ -4060,6 +4390,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4060
4390
|
*/
|
|
4061
4391
|
const admit = (connection, args) => {
|
|
4062
4392
|
const event = args.event;
|
|
4393
|
+
if (gatewayRef) {
|
|
4394
|
+
noteForeignSpeaker(connection, args);
|
|
4395
|
+
}
|
|
4063
4396
|
if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
|
|
4064
4397
|
return undefined;
|
|
4065
4398
|
}
|
|
@@ -4075,9 +4408,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4075
4408
|
const isDm = event.channel_type === 'im';
|
|
4076
4409
|
const team = args.body?.team_id ?? connection.teamId;
|
|
4077
4410
|
// A top-level mention has no thread_ts; its own ts roots the reply
|
|
4078
|
-
// thread that becomes the conversation.
|
|
4079
|
-
// peer, unkeyed by thread
|
|
4080
|
-
|
|
4411
|
+
// thread that becomes the conversation. A DM's top level is one
|
|
4412
|
+
// conversation per peer, unkeyed by thread; a thread inside the DM is a
|
|
4413
|
+
// conversation of its own, answered in that thread.
|
|
4414
|
+
const thread = isDm ? event.thread_ts : event.thread_ts ?? event.ts;
|
|
4081
4415
|
// Threads only: a DM has one agent by construction.
|
|
4082
4416
|
const threadKey = thread !== undefined && !isDm
|
|
4083
4417
|
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
@@ -4400,20 +4734,22 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4400
4734
|
const { images, unread: notImages } = overhear || judged
|
|
4401
4735
|
? { images: [], unread: event.files ?? [] }
|
|
4402
4736
|
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
|
|
4403
|
-
const { texts, unread } = overhear || judged
|
|
4404
|
-
? { texts: [], unread: notImages }
|
|
4405
|
-
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons
|
|
4737
|
+
const { texts, saved, unread } = overhear || judged
|
|
4738
|
+
? { texts: [], saved: [], unread: notImages }
|
|
4739
|
+
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons, options.attachmentStore
|
|
4740
|
+
? createAttachmentSaver(options.attachmentStore, connection.config.agentId, senderTrustFor(connection.config, userId), options.now ?? Date.now)
|
|
4741
|
+
: undefined);
|
|
4406
4742
|
// An image or a document with nothing said is still a question
|
|
4407
4743
|
// ("what's this?"); a file the turn could not open with nothing said
|
|
4408
4744
|
// is not one, and gets no reply.
|
|
4409
|
-
if (cleaned.length === 0 && images.length === 0 && texts.length === 0) {
|
|
4745
|
+
if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
|
|
4410
4746
|
return undefined;
|
|
4411
4747
|
}
|
|
4412
4748
|
const author = await authorFor(connection, userId);
|
|
4413
4749
|
// In shared channels the model should know who is speaking; a DM is
|
|
4414
4750
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
4415
4751
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
4416
|
-
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread, reasons)}`;
|
|
4752
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
|
|
4417
4753
|
// Who sent this, judged against the operator's list — per message,
|
|
4418
4754
|
// never remembered from the first one in the thread. A DM proves
|
|
4419
4755
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -4432,7 +4768,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4432
4768
|
: {
|
|
4433
4769
|
...room,
|
|
4434
4770
|
...(room.kind === 'direct' && senderTrust === 'user' ? { with: author } : {}),
|
|
4435
|
-
|
|
4771
|
+
// A DM thread is a thread too, with its own session; core takes
|
|
4772
|
+
// no root for a direct room, since nothing posts to one by id.
|
|
4773
|
+
...(thread !== undefined ? { thread: true, ...(room.kind !== 'direct' ? { threadRoot: thread } : {}) } : {}),
|
|
4436
4774
|
// People outside this workspace read a Slack Connect channel, and
|
|
4437
4775
|
// the event says so itself, even when the lookup failed.
|
|
4438
4776
|
...(room.kind !== 'direct' && args.body?.is_ext_shared_channel === true ? { shared: true } : {}),
|
|
@@ -4478,7 +4816,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4478
4816
|
// said before this. Inside the chain, before the placeholder, so the
|
|
4479
4817
|
// read cannot reorder this message against the next one.
|
|
4480
4818
|
let earlier = [];
|
|
4481
|
-
if (
|
|
4819
|
+
if (thread !== undefined) {
|
|
4482
4820
|
// A top-level mention roots its own session, so it is new but for
|
|
4483
4821
|
// a redelivery, which the gateway answers without reading this.
|
|
4484
4822
|
let exists = false;
|
|
@@ -4512,7 +4850,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4512
4850
|
// while the tool it preceded is still running. Under the session's
|
|
4513
4851
|
// label as stored, lowered by anything that tainted it during this
|
|
4514
4852
|
// turn, and the least trusted label where it cannot be read.
|
|
4515
|
-
|
|
4853
|
+
// A DM thread has nobody else in it to hear.
|
|
4854
|
+
...(thread !== undefined && !isDm
|
|
4516
4855
|
? {
|
|
4517
4856
|
onInterim: (heard, landed) => {
|
|
4518
4857
|
// Tracked, so a shutdown drains it like any other reaction:
|
|
@@ -4557,6 +4896,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4557
4896
|
// key is shared, and the running turn's status stays up instead.
|
|
4558
4897
|
renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
|
|
4559
4898
|
let repeat;
|
|
4899
|
+
let absorbed = false;
|
|
4560
4900
|
const turn = gateway.dispatch({
|
|
4561
4901
|
sessionId,
|
|
4562
4902
|
agentId: connection.config.agentId,
|
|
@@ -4573,8 +4913,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4573
4913
|
onRepeat: (kind) => {
|
|
4574
4914
|
repeat = kind;
|
|
4575
4915
|
},
|
|
4916
|
+
// Sent while a turn is running, this message is answered by the
|
|
4917
|
+
// next turn together with everything else that queued (card 423);
|
|
4918
|
+
// the first of them renders that answer.
|
|
4919
|
+
onAbsorbed: () => {
|
|
4920
|
+
absorbed = true;
|
|
4921
|
+
},
|
|
4576
4922
|
});
|
|
4577
|
-
return { renderer, turn, repeat: () => repeat };
|
|
4923
|
+
return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
|
|
4578
4924
|
});
|
|
4579
4925
|
const started = await intake;
|
|
4580
4926
|
if (!started) {
|
|
@@ -4592,7 +4938,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4592
4938
|
}
|
|
4593
4939
|
return;
|
|
4594
4940
|
}
|
|
4595
|
-
const { renderer, turn, repeat } = started;
|
|
4941
|
+
const { renderer, turn, repeat, absorbed } = started;
|
|
4596
4942
|
const removeFromQueue = () => {
|
|
4597
4943
|
const current = renderers.get(sessionId);
|
|
4598
4944
|
if (current) {
|
|
@@ -4620,7 +4966,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4620
4966
|
// The turn is a live earlier delivery's, and that delivery's renderer
|
|
4621
4967
|
// posts its answer, or its failure: this one takes down whatever it put
|
|
4622
4968
|
// up and says nothing.
|
|
4623
|
-
|
|
4969
|
+
// Likewise a message absorbed into a merged turn: the turn's leading
|
|
4970
|
+
// message's renderer answers for all of them.
|
|
4971
|
+
if (repeat() === 'live' || absorbed()) {
|
|
4624
4972
|
await renderer.withdraw();
|
|
4625
4973
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
4626
4974
|
return;
|
|
@@ -4693,7 +5041,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4693
5041
|
replyLanded = outcome.published;
|
|
4694
5042
|
return true;
|
|
4695
5043
|
}, () => true);
|
|
4696
|
-
const heard = thread !== undefined
|
|
5044
|
+
const heard = thread !== undefined && !isDm
|
|
4697
5045
|
? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
|
|
4698
5046
|
: undefined;
|
|
4699
5047
|
const { spoke, spokeAt } = await finalized;
|