@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/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { lstat, open, readFile } from 'node:fs/promises';
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
- if (file.size !== undefined && file.size > maxBytes) {
1710
- warn(`slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
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, maxBytes);
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 > maxBytes) {
1741
- warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes of text this message could still take; the download was abandoned and the turn is told it cannot be read.`);
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 (roadmap 31's open item) — so such a
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 heard = routing.reply !== undefined && thread !== undefined
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
- return tied ? undefined : latest?.agentId;
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. DMs are one conversation per
4079
- // peer, unkeyed by thread.
4080
- const thread = isDm ? undefined : event.thread_ts ?? event.ts;
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
- ...(thread !== undefined && room.kind !== 'direct' ? { thread: true, threadRoot: thread } : {}),
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 (!isDm && thread !== undefined) {
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
- ...(thread !== undefined
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
- if (repeat() === 'live') {
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;