@stratusagent/channel-slack 0.13.6 → 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/README.md +70 -6
- package/dist/index.d.ts +71 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +426 -78
- package/dist/index.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +1 -0
- package/package.json +2 -3
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, {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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
|
-
|
|
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
|
|
1702
|
+
let fetched;
|
|
1650
1703
|
try {
|
|
1651
|
-
|
|
1704
|
+
fetched = await fetchFile(url, botToken, deadline, maxBytes);
|
|
1652
1705
|
}
|
|
1653
1706
|
catch (error) {
|
|
1654
|
-
const
|
|
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
|
-
|
|
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 =
|
|
1666
|
-
if (
|
|
1667
|
-
|
|
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 (
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
|
1683
|
-
if (
|
|
1684
|
-
|
|
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
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1706
|
-
data:
|
|
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
|
|
3613
|
-
*
|
|
3614
|
-
*
|
|
3615
|
-
*
|
|
3616
|
-
*
|
|
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
|
-
|
|
3641
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -4129,7 +4473,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4129
4473
|
return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
|
|
4130
4474
|
}
|
|
4131
4475
|
let landed = [];
|
|
4132
|
-
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy
|
|
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 () => {
|
|
4133
4479
|
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
4134
4480
|
});
|
|
4135
4481
|
return { said, landed };
|
|
@@ -6017,8 +6363,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6017
6363
|
reasons.set(file, 'not-opened');
|
|
6018
6364
|
}
|
|
6019
6365
|
}
|
|
6020
|
-
const { images, unread: notImages } = overhear
|
|
6021
|
-
? { images: [], unread: event.files ?? [] }
|
|
6366
|
+
const { images, unread: notImages, previews } = overhear
|
|
6367
|
+
? { images: [], unread: event.files ?? [], previews: [] }
|
|
6022
6368
|
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
|
|
6023
6369
|
const { texts, saved, unread } = overhear
|
|
6024
6370
|
? { texts: [], saved: [], unread: notImages }
|
|
@@ -6042,7 +6388,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6042
6388
|
// In shared channels the model should know who is speaking; a DM is
|
|
6043
6389
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
6044
6390
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
6045
|
-
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
|
|
6391
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${previewNote(previews)}${attachmentNote(unread, reasons)}`;
|
|
6046
6392
|
// Who sent this, judged against the operator's list — per message,
|
|
6047
6393
|
// never remembered from the first one in the thread. A DM proves
|
|
6048
6394
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -6442,7 +6788,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6442
6788
|
? await renderer.stopped(renderer.stoppedBy)
|
|
6443
6789
|
: refused
|
|
6444
6790
|
? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
|
|
6445
|
-
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
6791
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure), failure instanceof HostRefusalError);
|
|
6446
6792
|
const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
|
|
6447
6793
|
? outcome
|
|
6448
6794
|
: noteReplayOutcome(connection, event.channel, event.ts, outcome);
|
|
@@ -6459,6 +6805,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6459
6805
|
name: 'slack',
|
|
6460
6806
|
resolveOutbound,
|
|
6461
6807
|
readConversation,
|
|
6808
|
+
sessionConversation,
|
|
6809
|
+
createConversation,
|
|
6462
6810
|
// A channel id is unique only within its workspace, so a standing
|
|
6463
6811
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
6464
6812
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|