@stratusagent/channel-slack 0.13.5 → 0.13.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,8 +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, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
7
- import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
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';
8
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
9
8
  /**
10
9
  * The longest display name the model is shown. Slack caps profile names
@@ -85,6 +84,17 @@ const PLACEHOLDER_TEXT = '…';
85
84
  /** What the loading status says while a turn works, and while it runs a tool. */
86
85
  const THINKING_STATUS = 'is thinking…';
87
86
  const toolStatus = (toolName) => `is running ${toolName}…`;
87
+ /**
88
+ * The same step as a line of its own, for Slack's `loading_messages`
89
+ * ("is running shell.run…" -> "Running shell.run…"). Slack's agent sessions
90
+ * show their own generic text ("Finding answers…") in place of `status`,
91
+ * and rotate these instead. None for the clear: Slack wants one to ten,
92
+ * and the empty status alone takes the indicator down.
93
+ */
94
+ const loadingMessage = (status) => {
95
+ const step = status.replace(/^is /, '');
96
+ return step === '' ? undefined : step.charAt(0).toUpperCase() + step.slice(1);
97
+ };
88
98
  /**
89
99
  * How often a loading status is set again while its turn runs. Slack drops
90
100
  * one after two minutes with no message, and a long turn — a tool, an
@@ -583,13 +593,19 @@ class ReplyRenderer {
583
593
  }
584
594
  this.statusText = text;
585
595
  const thread = this.statusThread;
596
+ const loading = loadingMessage(text);
586
597
  // Not awaited by the caller: the turn never waits on a status. Only
587
598
  // `finalize` does, for the ordering reason on `statusChain`. A refusal
588
599
  // — a workspace or plan without it, a token Slack will not honour for
589
600
  // it — is said once and then left alone; the reply still arrives,
590
601
  // just without the status ahead of it.
591
602
  this.statusChain = this.statusChain
592
- .then(() => setStatus.call(this.web.assistant?.threads, { channel_id: this.channel, thread_ts: thread, status: text }))
603
+ .then(() => setStatus.call(this.web.assistant?.threads, {
604
+ channel_id: this.channel,
605
+ thread_ts: thread,
606
+ status: text,
607
+ ...(loading ? { loading_messages: [loading] } : {}),
608
+ }))
593
609
  .then(() => undefined, (error) => {
594
610
  if (!this.statusWarned.value) {
595
611
  this.statusWarned.value = true;
@@ -1178,7 +1194,13 @@ class ReplyRenderer {
1178
1194
  }
1179
1195
  return this.finalize(`Stopped by <@${by}>.`);
1180
1196
  }
1181
- async fail(message) {
1197
+ /**
1198
+ * `refused` is a turn the host stopped on purpose (`HostRefusalError`), a
1199
+ * spent budget, whose message is already a sentence for the person
1200
+ * reading it. Framed as something having gone wrong, it would read as a
1201
+ * fault to report when it is a limit working.
1202
+ */
1203
+ async fail(message, refused = false) {
1182
1204
  // The same wait `finalize` takes, for the same reason: a lazy turn's
1183
1205
  // first text may be opening its placeholder right now, and a failure
1184
1206
  // read as "before saying anything" would leave that placeholder saying
@@ -1195,7 +1217,7 @@ class ReplyRenderer {
1195
1217
  this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
1196
1218
  return this.finalize('');
1197
1219
  }
1198
- return this.finalize(`Something went wrong: ${message}`);
1220
+ return this.finalize(refused ? message : `Something went wrong: ${message}`);
1199
1221
  }
1200
1222
  /**
1201
1223
  * Claim the placeholder for a turn that finished ahead of this one — the
@@ -1570,12 +1592,53 @@ const attachmentNote = (files, reasons) => {
1570
1592
  };
1571
1593
  /** What Slack calls a file, in a warning about it. */
1572
1594
  const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
1595
+ /**
1596
+ * Slack's scaled copies of an image, largest first. Slack makes these for
1597
+ * every image it hosts — a 14 MB, 2600×1800 PNG came back with thumbnails
1598
+ * from 64 to 1024 pixels a side, each a PNG under 3 MB — so an image too
1599
+ * big for the model still has a copy it can take, with no image library
1600
+ * here to make one. Smaller than 360 is too small to read anything in.
1601
+ */
1602
+ const SLACK_PREVIEW_KEYS = ['thumb_1024', 'thumb_960', 'thumb_800', 'thumb_720', 'thumb_480', 'thumb_360'];
1603
+ /** The largest preview Slack lists for a file, if it lists one. */
1604
+ const slackPreviewUrl = (file) => SLACK_PREVIEW_KEYS.map((key) => file[key]).find((url) => typeof url === 'string' && url.length > 0);
1605
+ /**
1606
+ * What an image's own bytes say it is. A preview's type is not in Slack's
1607
+ * metadata — only the original's is — so it is read from the file itself,
1608
+ * and anything that is none of the four the API takes is not an image here.
1609
+ */
1610
+ const imageMediaTypeOf = (bytes) => {
1611
+ const ascii = (at, length) => bytes.subarray(at, at + length).toString('latin1');
1612
+ if (bytes.length >= 8 && bytes[0] === 0x89 && ascii(1, 3) === 'PNG') {
1613
+ return 'image/png';
1614
+ }
1615
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
1616
+ return 'image/jpeg';
1617
+ }
1618
+ if (ascii(0, 4) === 'GIF8') {
1619
+ return 'image/gif';
1620
+ }
1621
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
1622
+ return 'image/webp';
1623
+ }
1624
+ return undefined;
1625
+ };
1626
+ const MIB = 1024 * 1024;
1627
+ /**
1628
+ * Says which images the model sees scaled down. Without it the agent reads
1629
+ * a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
1630
+ * what the small text says when it could only guess at it.
1631
+ */
1632
+ const previewNote = (previews) => previews.map(({ name, width, height, original }) => `\n[${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.]`).join('');
1573
1633
  /**
1574
1634
  * A message's attachments, sorted into the images the model will be shown
1575
1635
  * and everything else. An image is fetched only when Slack's own metadata
1576
1636
  * says it is one the API accepts and small enough to send; that keeps a
1577
1637
  * 40 MB PNG from being downloaded just to be dropped, and a PDF from being
1578
- * fetched at all. What comes back is checked as well: without `files:read`
1638
+ * fetched at all. An image over the per-image limits — 5 MB, or 8000
1639
+ * pixels a side — is sent as Slack's own largest preview of it instead,
1640
+ * when Slack made one, and the turn is told it is a preview. What comes
1641
+ * back is checked as well: without `files:read`
1579
1642
  * Slack serves a sign-in page with a 200, and sending that to the model as
1580
1643
  * an image would fail the turn with an error naming nothing the person can
1581
1644
  * fix — the warning here names the scope instead, and the file falls back
@@ -1587,6 +1650,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1587
1650
  // listed last, so intake and replay agree about which survive. Delivered
1588
1651
  // in the message's own order, because that is how the person sees them.
1589
1652
  const kept = new Map();
1653
+ const previewed = new Map();
1590
1654
  const dropped = new Set();
1591
1655
  // Recorded beside the drop, so the note says why and never guesses. A
1592
1656
  // non-image goes on to the text reader with no reason: it decides.
@@ -1604,121 +1668,157 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1604
1668
  // not slip in past a larger newer one that was left out.
1605
1669
  let windowClosed = false;
1606
1670
  // Why an image is being left out, or nothing when it fits. One place for
1607
- // both limits so the warning and the fallback cannot disagree.
1671
+ // both limits so the warning and the fallback cannot disagree. Over the
1672
+ // per-image cap, the image can still be sent as Slack's preview; over
1673
+ // what is left of the message's budget, it is not sent at all.
1608
1674
  const overLimit = (label, bytes) => {
1609
1675
  if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
1610
- return `slack: ${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at; the turn is told it cannot be read.`;
1676
+ const why = `${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at`;
1677
+ return {
1678
+ reason: 'too-large',
1679
+ warning: `slack: ${why}; the turn is told it cannot be read.`,
1680
+ oversized: { why, original: `is ${(bytes / MIB).toFixed(1)} MB, over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB a model can be sent` },
1681
+ };
1611
1682
  }
1612
1683
  if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
1613
1684
  windowClosed = true;
1614
- return `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.`;
1685
+ return { reason: 'too-large', warning: `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.` };
1615
1686
  }
1616
1687
  return undefined;
1617
1688
  };
1618
- for (let position = files.length - 1; position >= 0; position -= 1) {
1619
- const file = files[position];
1620
- const url = file.url_private_download ?? file.url_private;
1621
- if (!isImageAttachmentMediaType(file.mimetype)) {
1622
- dropped.add(position);
1623
- continue;
1624
- }
1625
- if (url === undefined) {
1626
- drop(position, 'failed');
1627
- continue;
1628
- }
1629
- if (windowClosed) {
1630
- warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
1631
- drop(position, 'too-large');
1632
- continue;
1633
- }
1634
- const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
1635
- if (tooBig !== undefined) {
1636
- warn(tooBig);
1637
- drop(position, 'too-large');
1638
- continue;
1639
- }
1689
+ // One download, checked the same way for an original and for a preview:
1690
+ // the image and its size, or why not. `oversized` marks a refusal for the
1691
+ // per-image limits alone — the only kind a smaller copy can get past.
1692
+ // `mediaType` is Slack's word for an original; a preview's is read from
1693
+ // its own bytes, since Slack's metadata describes only the original.
1694
+ const download = async (url, label, mediaType) => {
1640
1695
  // What this image may weigh: the per-image cap, or what is left of the
1641
1696
  // message's budget if that is less. Handed to the fetcher so an
1642
1697
  // oversized response is abandoned mid-body rather than buffered whole.
1643
1698
  const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1644
1699
  if (deadline.aborted) {
1645
- warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
1646
- drop(position, 'timed-out');
1647
- continue;
1700
+ return { reason: 'timed-out', warning: `slack: ${label} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.` };
1648
1701
  }
1649
- let download;
1702
+ let fetched;
1650
1703
  try {
1651
- download = await fetchFile(url, botToken, deadline, maxBytes);
1704
+ fetched = await fetchFile(url, botToken, deadline, maxBytes);
1652
1705
  }
1653
1706
  catch (error) {
1654
- const reason = error instanceof Error && error.name === 'TimeoutError'
1707
+ const timedOut = error instanceof Error && error.name === 'TimeoutError';
1708
+ const cause = timedOut
1655
1709
  ? `this message's downloads took longer than ${timeoutMs}ms`
1656
1710
  : (error instanceof Error ? error.message : String(error));
1657
- warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1658
- drop(position, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
1659
- continue;
1711
+ return { reason: timedOut ? 'timed-out' : 'failed', warning: `slack: could not download ${label}: ${cause}. The turn is told it cannot be read.` };
1660
1712
  }
1661
1713
  // Refused on what Slack sends when it will not serve the file — a
1662
1714
  // non-200, or a 200 whose body is a page — rather than on the label of
1663
1715
  // what it does serve: a download URL may be typed as the image or as
1664
1716
  // a plain octet stream, and either is the bytes Slack said it was.
1665
- const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1666
- if (download.status !== 200 || contentType.startsWith('text/')) {
1667
- warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
1668
- drop(position, 'failed');
1669
- continue;
1717
+ const contentType = fetched.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1718
+ if (fetched.status !== 200 || contentType.startsWith('text/')) {
1719
+ return { reason: 'failed', warning: `slack: ${label} came back as ${contentType || 'no content type'} (HTTP ${fetched.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.` };
1670
1720
  }
1671
- if (download.truncated === true) {
1721
+ if (fetched.truncated === true) {
1722
+ const why = `${label} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned`;
1672
1723
  // Cut off at what was left of the message's budget, rather than at
1673
1724
  // the per-image cap: the same overflow the size check closes the
1674
1725
  // window on, learned from the bytes instead of the metadata.
1675
1726
  if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
1676
1727
  windowClosed = true;
1728
+ return { reason: 'too-large', warning: `slack: ${why} and the turn is told it cannot be read.` };
1677
1729
  }
1678
- warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned and the turn is told it cannot be read.`);
1679
- drop(position, 'too-large');
1680
- continue;
1730
+ return {
1731
+ reason: 'too-large',
1732
+ warning: `slack: ${why} and the turn is told it cannot be read.`,
1733
+ oversized: { why, original: `is over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB a model can be sent` },
1734
+ };
1681
1735
  }
1682
- const stillTooBig = overLimit(fileLabel(file), download.body.length);
1683
- if (stillTooBig !== undefined) {
1684
- warn(stillTooBig);
1685
- drop(position, 'too-large');
1686
- continue;
1736
+ const tooBig = overLimit(label, fetched.body.length);
1737
+ if (tooBig !== undefined) {
1738
+ return tooBig;
1687
1739
  }
1688
1740
  // Read from the bytes, not from Slack's metadata: an image the model
1689
1741
  // API refuses fails the turn it arrives on and, once stored, every turn
1690
1742
  // after — so what is stored has to be what the API takes.
1691
- const dimensions = imageDimensions(download.body, file.mimetype);
1692
- if (dimensions === undefined) {
1693
- warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
1694
- drop(position, 'unreadable');
1695
- continue;
1743
+ const type = mediaType ?? imageMediaTypeOf(fetched.body);
1744
+ const dimensions = type === undefined ? undefined : imageDimensions(fetched.body, type);
1745
+ if (type === undefined || dimensions === undefined) {
1746
+ return { reason: 'unreadable', warning: `slack: ${label} is not a complete ${type ?? 'image'} — its header or trailer is missing; the turn is told it cannot be read.` };
1696
1747
  }
1697
1748
  if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1698
- warn(`slack: ${fileLabel(file)} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take; the turn is told it cannot be read.`);
1749
+ const why = `${label} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take`;
1750
+ return {
1751
+ reason: 'too-large',
1752
+ warning: `slack: ${why}; the turn is told it cannot be read.`,
1753
+ oversized: { why, original: `is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION} pixels a side a model can be sent` },
1754
+ };
1755
+ }
1756
+ return { body: fetched.body, mediaType: type, ...dimensions };
1757
+ };
1758
+ for (let position = files.length - 1; position >= 0; position -= 1) {
1759
+ const file = files[position];
1760
+ const url = file.url_private_download ?? file.url_private;
1761
+ if (!isImageAttachmentMediaType(file.mimetype)) {
1762
+ dropped.add(position);
1763
+ continue;
1764
+ }
1765
+ if (url === undefined) {
1766
+ drop(position, 'failed');
1767
+ continue;
1768
+ }
1769
+ if (windowClosed) {
1770
+ warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
1699
1771
  drop(position, 'too-large');
1700
1772
  continue;
1701
1773
  }
1702
- total += download.body.length;
1774
+ // Slack's own size is trusted before any bytes move: an original it
1775
+ // says is too big is never fetched — only, when that is the per-image
1776
+ // cap, its preview.
1777
+ let result = (file.size === undefined ? undefined : overLimit(fileLabel(file), file.size))
1778
+ ?? await download(url, fileLabel(file), file.mimetype);
1779
+ const previewUrl = 'reason' in result && result.oversized !== undefined ? slackPreviewUrl(file) : undefined;
1780
+ if ('reason' in result && result.oversized !== undefined && previewUrl !== undefined) {
1781
+ const { why, original } = result.oversized;
1782
+ const preview = await download(previewUrl, `Slack's preview of ${fileLabel(file)}`, undefined);
1783
+ if ('reason' in preview) {
1784
+ warn(`slack: ${why}. Slack's smaller preview of it could not be used either: ${preview.warning.replace(/^slack: /, '')}`);
1785
+ drop(position, result.reason);
1786
+ continue;
1787
+ }
1788
+ warn(`slack: ${why}; sent Slack's ${preview.width}×${preview.height} preview of it instead, and the turn is told it is a preview.`);
1789
+ previewed.set(position, { name: fileLabel(file), width: preview.width, height: preview.height, original });
1790
+ result = preview;
1791
+ }
1792
+ if ('reason' in result) {
1793
+ warn(result.warning);
1794
+ drop(position, result.reason);
1795
+ continue;
1796
+ }
1797
+ total += result.body.length;
1703
1798
  const name = file.name ?? file.title;
1704
1799
  kept.set(position, {
1705
- mediaType: file.mimetype,
1706
- data: download.body.toString('base64'),
1800
+ mediaType: result.mediaType,
1801
+ data: result.body.toString('base64'),
1707
1802
  ...(name !== undefined ? { name } : {}),
1708
1803
  });
1709
1804
  }
1710
1805
  const images = [];
1711
1806
  const unread = [];
1807
+ const previews = [];
1712
1808
  files.forEach((file, position) => {
1713
1809
  const image = kept.get(position);
1714
1810
  if (image !== undefined) {
1715
1811
  images.push(image);
1812
+ const preview = previewed.get(position);
1813
+ if (preview !== undefined) {
1814
+ previews.push(preview);
1815
+ }
1716
1816
  }
1717
1817
  else if (dropped.has(position)) {
1718
1818
  unread.push(file);
1719
1819
  }
1720
1820
  });
1721
- return { images, unread };
1821
+ return { images, unread, previews };
1722
1822
  };
1723
1823
  /** The most one text attachment may weigh to be read into the message. */
1724
1824
  const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
@@ -2220,6 +2320,26 @@ const credentialModal = (post, requestId) => ({
2220
2320
  */
2221
2321
  /** Uncached author names one `message.read` looks up; the rest keep their id. */
2222
2322
  const READ_NAME_LOOKUPS = 10;
2323
+ /** Slack's longest channel name. */
2324
+ const CHANNEL_NAME_LIMIT = 80;
2325
+ /** Slack's longest channel topic, and its longest purpose. */
2326
+ const CHANNEL_TEXT_LIMIT = 250;
2327
+ /** The most users one `conversations.invite` call takes. */
2328
+ const CHANNEL_INVITE_LIMIT = 1000;
2329
+ /** A bookmark title longer than this is cut, with an ellipsis. */
2330
+ const BOOKMARK_TITLE_LIMIT = 200;
2331
+ /** A bookmark link longer than this is replaced by `BOOKMARK_LINK_TOO_LONG`. */
2332
+ const BOOKMARK_LINK_LIMIT = 2000;
2333
+ const BOOKMARK_LINK_TOO_LONG = '(this link is longer than 2,000 characters, so it is not shown)';
2334
+ /**
2335
+ * The error code of a failed Web API call (`missing_scope`, `ratelimited`),
2336
+ * which `@slack/web-api` puts on the error's `data.error`. Undefined for a
2337
+ * failure that never reached Slack.
2338
+ */
2339
+ const slackErrorCode = (error) => {
2340
+ const code = error?.data?.error;
2341
+ return typeof code === 'string' && code.length > 0 ? code : undefined;
2342
+ };
2223
2343
  /**
2224
2344
  * Orders two Slack message timestamps ("seconds.micros"). Compared as
2225
2345
  * integers, not floats: a current ts has more digits than a double holds.
@@ -3606,14 +3726,76 @@ export const createSlackChannelAdapter = (options) => {
3606
3726
  }
3607
3727
  return kind;
3608
3728
  };
3729
+ /**
3730
+ * The bookmarks a first-page read returns beside the messages. Never
3731
+ * throws: a failure is a sentence for the agent, and the messages come
3732
+ * back either way.
3733
+ */
3734
+ const listBookmarks = async (web, channel) => {
3735
+ if (!web.bookmarks) {
3736
+ return {};
3737
+ }
3738
+ let listed;
3739
+ try {
3740
+ listed = await web.bookmarks.list({ channel_id: channel });
3741
+ }
3742
+ catch (error) {
3743
+ const code = slackErrorCode(error);
3744
+ return {
3745
+ bookmarksUnavailable: code === 'missing_scope'
3746
+ ? 'This Slack app can\'t read bookmarks because it lacks the bookmarks:read scope. Add it to the app\'s manifest and reinstall the app.'
3747
+ : `The channel's bookmarks could not be listed (${code ?? (error instanceof Error ? error.message : String(error))}).`,
3748
+ };
3749
+ }
3750
+ const bookmarks = [];
3751
+ for (const entry of Array.isArray(listed?.bookmarks) ? listed.bookmarks : []) {
3752
+ const link = typeof entry?.link === 'string' ? entry.link.trim() : '';
3753
+ if (!link) {
3754
+ continue;
3755
+ }
3756
+ const title = (typeof entry.title === 'string' && entry.title.trim()) || link;
3757
+ bookmarks.push({
3758
+ title: title.length > BOOKMARK_TITLE_LIMIT ? `${title.slice(0, BOOKMARK_TITLE_LIMIT - 1)}…` : title,
3759
+ // A cut link is a broken one, so an over-long link is not shown.
3760
+ link: link.length > BOOKMARK_LINK_LIMIT ? BOOKMARK_LINK_TOO_LONG : link,
3761
+ });
3762
+ }
3763
+ return bookmarks.length > 0 ? { bookmarks } : {};
3764
+ };
3765
+ /**
3766
+ * The conversation a session lives in, read off its id, for this
3767
+ * agent's connected app only: exactly `slack:<agent>:<this app's
3768
+ * team>:<conversation>`, or that plus one thread `ts`, the keys this
3769
+ * adapter opens sessions under. Anything longer (a delegation's
3770
+ * `:delegate:…`, an archive's `:rolledover:…`) or of another shape (a
3771
+ * schedule's) is no session of a conversation here. No lookup: the id
3772
+ * is the identity check, and only the gateway names it.
3773
+ */
3774
+ const SLACK_TS = /^\d+\.\d+$/;
3775
+ const sessionConversation = (agentId, sessionId) => {
3776
+ const connection = connectionFor(agentId);
3777
+ if (!connection?.teamId) {
3778
+ return undefined;
3779
+ }
3780
+ const prefix = channelSessionKey({ channel: 'slack', agentId, team: connection.teamId, conversation: '' });
3781
+ if (!sessionId.startsWith(prefix)) {
3782
+ return undefined;
3783
+ }
3784
+ const [conversation, thread, ...rest] = sessionId.slice(prefix.length).split(':');
3785
+ if (!conversation || rest.length > 0 || (thread !== undefined && !SLACK_TS.test(thread))) {
3786
+ return undefined;
3787
+ }
3788
+ return conversation;
3789
+ };
3609
3790
  /**
3610
3791
  * The read side of the same boundary `resolveOutbound` draws: a channel
3611
3792
  * the agent's own app is a member of, public or private. DMs and group
3612
- * DMs are refused outright, member or not. Their only members are the
3613
- * people in them, and a read from a conversation elsewhere — a public
3614
- * thread above all — would carry what they said to people who were never
3615
- * in the room. A thread in a channel is fine for the same reason the
3616
- * channel is.
3793
+ * DMs are refused, member or not, except the one the reading session
3794
+ * lives in (`sessionConversation`). Their only members are the people
3795
+ * in them, and a read from a conversation elsewhere — a public thread
3796
+ * above all — would carry what they said to people who were never in
3797
+ * the room; from inside, it is what they already said to this agent. A
3798
+ * thread in a channel is fine for the same reason the channel is.
3617
3799
  */
3618
3800
  const readConversation = async (request) => {
3619
3801
  const channel = request.conversation.trim();
@@ -3637,10 +3819,14 @@ export const createSlackChannelAdapter = (options) => {
3637
3819
  if (!info?.id) {
3638
3820
  throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
3639
3821
  }
3640
- if (info.is_im === true || info.is_mpim === true) {
3641
- throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them. Approval cannot unlock this; read a channel instead.`);
3822
+ const direct = info.is_im === true || info.is_mpim === true;
3823
+ if (direct && (request.sessionId === undefined || sessionConversation(request.agentId, request.sessionId) !== info.id)) {
3824
+ throw new Error(`slack: ${channel} is a direct message this conversation is not in, and a direct message is read only from inside it. Approval cannot unlock this; read a channel instead.`);
3642
3825
  }
3643
- if (info.is_member !== true) {
3826
+ // A one-to-one IM states no membership; the session living in it is
3827
+ // the proof. A group DM does say, and an app that left one cannot read
3828
+ // it however its stale session is named.
3829
+ if (info.is_im !== true && info.is_member !== true) {
3644
3830
  throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
3645
3831
  }
3646
3832
  const { history, replies } = connection.web.conversations;
@@ -3650,6 +3836,12 @@ export const createSlackChannelAdapter = (options) => {
3650
3836
  if (!fetchPage) {
3651
3837
  throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
3652
3838
  }
3839
+ // On the first page of the top level only, so paging costs no extra
3840
+ // calls, and only once the checks above have said the read may happen.
3841
+ // Asked beside the history, and settled without throwing.
3842
+ const listing = request.thread === undefined && request.after === undefined && request.before === undefined
3843
+ ? listBookmarks(connection.web, info.id)
3844
+ : undefined;
3653
3845
  const base = {
3654
3846
  channel: info.id,
3655
3847
  inclusive: false,
@@ -3728,7 +3920,159 @@ export const createSlackChannelAdapter = (options) => {
3728
3920
  ...(files.length > 0 ? { files } : {}),
3729
3921
  });
3730
3922
  }
3731
- return { messages, more };
3923
+ return { messages, more, ...(listing ? await listing : {}) };
3924
+ };
3925
+ /**
3926
+ * Creates a channel with the asking agent's own app — `channel.create`.
3927
+ * Everything Slack would refuse once the channel exists is refused here
3928
+ * first, so a bad request leaves no channel behind. After the create
3929
+ * nothing throws: a topic, purpose or invitation that fails is reported,
3930
+ * and the channel stays. Slack names are unique, so an agent that
3931
+ * retries a create gets `name_taken`, never a second channel.
3932
+ */
3933
+ const createConversation = async (request) => {
3934
+ const name = request.name.trim().replace(/^#/, '');
3935
+ if (!name) {
3936
+ throw new Error('slack: a channel needs a name.');
3937
+ }
3938
+ if (!/^[a-z0-9_-]+$/.test(name)) {
3939
+ throw new Error(`slack: "${name}" can't be a channel name: Slack names use only lowercase letters, numbers, hyphens and underscores. Nothing was created.`);
3940
+ }
3941
+ if (name.length > CHANNEL_NAME_LIMIT) {
3942
+ throw new Error(`slack: a channel name is at most ${CHANNEL_NAME_LIMIT} characters; this one is ${name.length}. Nothing was created.`);
3943
+ }
3944
+ for (const field of ['topic', 'purpose']) {
3945
+ const length = [...(request[field] ?? '')].length;
3946
+ if (length > CHANNEL_TEXT_LIMIT) {
3947
+ throw new Error(`slack: the ${field} is ${length} characters; Slack allows ${CHANNEL_TEXT_LIMIT}. Nothing was created.`);
3948
+ }
3949
+ }
3950
+ const invite = request.invite ?? [];
3951
+ if (invite.length > CHANNEL_INVITE_LIMIT) {
3952
+ throw new Error(`slack: that invites ${invite.length.toLocaleString('en-US')} people; Slack invites at most ${CHANNEL_INVITE_LIMIT.toLocaleString('en-US')} at once. Nothing was created.`);
3953
+ }
3954
+ const connection = connectionFor(request.agentId);
3955
+ if (!connection) {
3956
+ throw new Error(configuredAgents.has(request.agentId)
3957
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot create a channel.`
3958
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot create a Slack channel.`);
3959
+ }
3960
+ const { create, setTopic, setPurpose, invite: inviteUsers } = connection.web.conversations;
3961
+ if (!create || !setTopic || !setPurpose || !inviteUsers) {
3962
+ throw new Error('slack: this Slack client cannot create channels.');
3963
+ }
3964
+ // Who each entry is, before anything exists: agents on this host become
3965
+ // the user their app posts as, in the same workspace only.
3966
+ const invited = [];
3967
+ const notInvited = [];
3968
+ const toInvite = new Map();
3969
+ for (const who of invite) {
3970
+ const identity = botIdentities.get(who);
3971
+ if (who === request.agentId || identity?.botUserId === connection.botUserId) {
3972
+ invited.push(who);
3973
+ }
3974
+ else if (configuredAgents.has(who) && !identity?.botUserId) {
3975
+ notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
3976
+ }
3977
+ else if (identity) {
3978
+ if (identity.teamId !== connection.teamId) {
3979
+ notInvited.push({ who, reason: `Agent ${who}'s Slack app is in another Slack workspace.` });
3980
+ }
3981
+ else {
3982
+ toInvite.set(who, identity.botUserId);
3983
+ }
3984
+ }
3985
+ else if (/^[UW][A-Z0-9]+$/.test(who)) {
3986
+ toInvite.set(who, who);
3987
+ }
3988
+ else {
3989
+ notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
3990
+ }
3991
+ }
3992
+ const kind = request.private === true ? 'private' : 'public';
3993
+ let created;
3994
+ try {
3995
+ created = await create({ name, is_private: request.private === true });
3996
+ }
3997
+ catch (error) {
3998
+ const code = slackErrorCode(error);
3999
+ if (code === 'missing_scope') {
4000
+ 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.`);
4001
+ }
4002
+ if (code === 'name_taken') {
4003
+ throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
4004
+ }
4005
+ throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4006
+ }
4007
+ const conversation = created.channel?.id;
4008
+ if (!conversation) {
4009
+ 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.`);
4010
+ }
4011
+ const notSet = [];
4012
+ const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
4013
+ if (request.topic) {
4014
+ await setTopic({ channel: conversation, topic: request.topic })
4015
+ .catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
4016
+ }
4017
+ if (request.purpose) {
4018
+ await setPurpose({ channel: conversation, purpose: request.purpose })
4019
+ .catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
4020
+ }
4021
+ if (toInvite.size > 0) {
4022
+ // Slack's per-id failures, by user id: on the answer, or on the error
4023
+ // the SDK throws for an ok: false answer.
4024
+ const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
4025
+ let refused;
4026
+ let failure;
4027
+ try {
4028
+ refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
4029
+ }
4030
+ catch (error) {
4031
+ refused = refusedOf(error?.data?.errors);
4032
+ failure = describe(error);
4033
+ }
4034
+ // A failed call that does not name everyone leaves the rest unknown,
4035
+ // so the channel's members decide; who cannot be checked is not claimed.
4036
+ let members;
4037
+ let membersFailure;
4038
+ if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
4039
+ try {
4040
+ members = new Set();
4041
+ let cursor;
4042
+ do {
4043
+ const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
4044
+ for (const member of page.members ?? []) {
4045
+ members.add(member);
4046
+ }
4047
+ cursor = page.response_metadata?.next_cursor || undefined;
4048
+ } while (cursor);
4049
+ }
4050
+ catch (error) {
4051
+ members = undefined;
4052
+ membersFailure = describe(error);
4053
+ }
4054
+ }
4055
+ for (const [who, user] of toInvite) {
4056
+ const code = refused.get(user);
4057
+ if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
4058
+ invited.push(who);
4059
+ }
4060
+ else if (code !== undefined || members !== undefined) {
4061
+ notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
4062
+ }
4063
+ else {
4064
+ notInvited.push({
4065
+ who,
4066
+ reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
4067
+ });
4068
+ }
4069
+ }
4070
+ }
4071
+ const order = new Map(invite.map((who, index) => [who, index]));
4072
+ const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
4073
+ invited.sort(byRequest);
4074
+ notInvited.sort((left, right) => byRequest(left.who, right.who));
4075
+ return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
3732
4076
  };
3733
4077
  /**
3734
4078
  * Renders one parked call as buttons and remembers where it went.
@@ -3754,6 +4098,21 @@ export const createSlackChannelAdapter = (options) => {
3754
4098
  const origin = event.delegatedFrom;
3755
4099
  const viaOrigin = origin ? connectionFor(origin.agentId) : undefined;
3756
4100
  const connection = viaOrigin ?? connectionFor(event.agentId);
4101
+ // A call whose conversation is another channel's (a Discord thread), or
4102
+ // whose schedule reports through another channel, is that channel's to
4103
+ // ask: it renders the request where the work was asked for. Asked or
4104
+ // denied here, it would be refused before the person who can answer it
4105
+ // ever saw it. An approval channel is the operator naming where to ask
4106
+ // whatever the conversation, so it still applies, which needs a live
4107
+ // connection; with none, this adapter has nothing to say about it.
4108
+ const home = (origin ? origin.metadata : event.metadata) ?? {};
4109
+ const homeChannel = typeof home.channel === 'string' ? home.channel.trim().toLowerCase() : undefined;
4110
+ const reportsElsewhere = event.scheduleDestination !== undefined
4111
+ && event.scheduleDestination.channel.trim().toLowerCase() !== 'slack';
4112
+ const anotherChannels = (homeChannel !== undefined && homeChannel !== 'slack') || (homeChannel === undefined && reportsElsewhere);
4113
+ if (anotherChannels && !connection?.config.approvalChannel) {
4114
+ return;
4115
+ }
3757
4116
  if (!connection) {
3758
4117
  // Two different situations, and only one of them is this adapter's
3759
4118
  // to answer.
@@ -4114,7 +4473,9 @@ export const createSlackChannelAdapter = (options) => {
4114
4473
  return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
4115
4474
  }
4116
4475
  let landed = [];
4117
- const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy === undefined ? `Something went wrong: ${event.error}` : `Stopped by <@${stoppedBy}>.`), async () => {
4476
+ const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy !== undefined
4477
+ ? `Stopped by <@${stoppedBy}>.`
4478
+ : event.refused ? event.error : `Something went wrong: ${event.error}`), async () => {
4118
4479
  landed = await uploadUnrenderedFiles(connection, channel, thread, files);
4119
4480
  });
4120
4481
  return { said, landed };
@@ -6002,8 +6363,8 @@ export const createSlackChannelAdapter = (options) => {
6002
6363
  reasons.set(file, 'not-opened');
6003
6364
  }
6004
6365
  }
6005
- const { images, unread: notImages } = overhear
6006
- ? { images: [], unread: event.files ?? [] }
6366
+ const { images, unread: notImages, previews } = overhear
6367
+ ? { images: [], unread: event.files ?? [], previews: [] }
6007
6368
  : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
6008
6369
  const { texts, saved, unread } = overhear
6009
6370
  ? { texts: [], saved: [], unread: notImages }
@@ -6027,7 +6388,7 @@ export const createSlackChannelAdapter = (options) => {
6027
6388
  // In shared channels the model should know who is speaking; a DM is
6028
6389
  // unambiguous. A bare image in a channel still says who sent it.
6029
6390
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
6030
- const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
6391
+ const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${previewNote(previews)}${attachmentNote(unread, reasons)}`;
6031
6392
  // Who sent this, judged against the operator's list — per message,
6032
6393
  // never remembered from the first one in the thread. A DM proves
6033
6394
  // nothing about who is typing, so a DM from an unlisted member is
@@ -6427,7 +6788,7 @@ export const createSlackChannelAdapter = (options) => {
6427
6788
  ? await renderer.stopped(renderer.stoppedBy)
6428
6789
  : refused
6429
6790
  ? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
6430
- : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
6791
+ : await renderer.fail(failure instanceof Error ? failure.message : String(failure), failure instanceof HostRefusalError);
6431
6792
  const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
6432
6793
  ? outcome
6433
6794
  : noteReplayOutcome(connection, event.channel, event.ts, outcome);
@@ -6444,6 +6805,8 @@ export const createSlackChannelAdapter = (options) => {
6444
6805
  name: 'slack',
6445
6806
  resolveOutbound,
6446
6807
  readConversation,
6808
+ sessionConversation,
6809
+ createConversation,
6447
6810
  // A channel id is unique only within its workspace, so a standing
6448
6811
  // grant on one is bound to the team the agent's app authenticated in.
6449
6812
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,