@stratusagent/channel-slack 0.13.6 → 0.14.0
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 +74 -7
- package/dist/index.d.ts +71 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +448 -84
- 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;
|
|
@@ -2122,12 +2222,19 @@ const CREDENTIAL_REASON_LIMIT = 300;
|
|
|
2122
2222
|
const credentialWhose = (agentName, scope) => scope === 'agent'
|
|
2123
2223
|
? `for ${agentName} only`
|
|
2124
2224
|
: `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
|
|
2225
|
+
/**
|
|
2226
|
+
* What answering means when the agent asked for the key in a variable,
|
|
2227
|
+
* said before anything is typed: the approval covers the binding too.
|
|
2228
|
+
* `agentName` arrives escaped; the variable passed the gateway's rule, so
|
|
2229
|
+
* it is letters, digits and underscores.
|
|
2230
|
+
*/
|
|
2231
|
+
const credentialShellLine = (agentName, env) => `${agentName}'s shell commands will be able to read this as \`${env}\`, so ${agentName} can see the value.`;
|
|
2125
2232
|
/**
|
|
2126
2233
|
* The request as it appears in the conversation. What it says about the
|
|
2127
2234
|
* value is the promise the form keeps: straight to the credential store,
|
|
2128
2235
|
* never into the thread, and never over a key already there.
|
|
2129
2236
|
*/
|
|
2130
|
-
const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false) => {
|
|
2237
|
+
const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false, env) => {
|
|
2131
2238
|
const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
|
|
2132
2239
|
? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
|
|
2133
2240
|
: reason;
|
|
@@ -2144,6 +2251,9 @@ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, repl
|
|
|
2144
2251
|
...(shortReason !== undefined
|
|
2145
2252
|
? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
|
|
2146
2253
|
: []),
|
|
2254
|
+
...(env !== undefined
|
|
2255
|
+
? [{ type: 'section', text: { type: 'mrkdwn', text: `*${credentialShellLine(escapeSlackText(agentName), escapeSlackText(env))}*` } }]
|
|
2256
|
+
: []),
|
|
2147
2257
|
{
|
|
2148
2258
|
type: 'context',
|
|
2149
2259
|
elements: [{
|
|
@@ -2182,11 +2292,12 @@ const credentialModal = (post, requestId) => ({
|
|
|
2182
2292
|
type: 'section',
|
|
2183
2293
|
text: {
|
|
2184
2294
|
type: 'mrkdwn',
|
|
2185
|
-
text: post.replace === true
|
|
2295
|
+
text: (post.replace === true
|
|
2186
2296
|
? `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
|
|
2187
2297
|
+ 'The new value replaces the stored one once it saves; it never appears in the conversation.'
|
|
2188
2298
|
: `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
|
|
2189
|
-
+ `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation
|
|
2299
|
+
+ `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`)
|
|
2300
|
+
+ (post.env !== undefined ? ` ${credentialShellLine(escapeSlackText(post.agentName), escapeSlackText(post.env))}` : ''),
|
|
2190
2301
|
},
|
|
2191
2302
|
},
|
|
2192
2303
|
{
|
|
@@ -2220,6 +2331,26 @@ const credentialModal = (post, requestId) => ({
|
|
|
2220
2331
|
*/
|
|
2221
2332
|
/** Uncached author names one `message.read` looks up; the rest keep their id. */
|
|
2222
2333
|
const READ_NAME_LOOKUPS = 10;
|
|
2334
|
+
/** Slack's longest channel name. */
|
|
2335
|
+
const CHANNEL_NAME_LIMIT = 80;
|
|
2336
|
+
/** Slack's longest channel topic, and its longest purpose. */
|
|
2337
|
+
const CHANNEL_TEXT_LIMIT = 250;
|
|
2338
|
+
/** The most users one `conversations.invite` call takes. */
|
|
2339
|
+
const CHANNEL_INVITE_LIMIT = 1000;
|
|
2340
|
+
/** A bookmark title longer than this is cut, with an ellipsis. */
|
|
2341
|
+
const BOOKMARK_TITLE_LIMIT = 200;
|
|
2342
|
+
/** A bookmark link longer than this is replaced by `BOOKMARK_LINK_TOO_LONG`. */
|
|
2343
|
+
const BOOKMARK_LINK_LIMIT = 2000;
|
|
2344
|
+
const BOOKMARK_LINK_TOO_LONG = '(this link is longer than 2,000 characters, so it is not shown)';
|
|
2345
|
+
/**
|
|
2346
|
+
* The error code of a failed Web API call (`missing_scope`, `ratelimited`),
|
|
2347
|
+
* which `@slack/web-api` puts on the error's `data.error`. Undefined for a
|
|
2348
|
+
* failure that never reached Slack.
|
|
2349
|
+
*/
|
|
2350
|
+
const slackErrorCode = (error) => {
|
|
2351
|
+
const code = error?.data?.error;
|
|
2352
|
+
return typeof code === 'string' && code.length > 0 ? code : undefined;
|
|
2353
|
+
};
|
|
2223
2354
|
/**
|
|
2224
2355
|
* Orders two Slack message timestamps ("seconds.micros"). Compared as
|
|
2225
2356
|
* integers, not floats: a current ts has more digits than a double holds.
|
|
@@ -3606,14 +3737,76 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3606
3737
|
}
|
|
3607
3738
|
return kind;
|
|
3608
3739
|
};
|
|
3740
|
+
/**
|
|
3741
|
+
* The bookmarks a first-page read returns beside the messages. Never
|
|
3742
|
+
* throws: a failure is a sentence for the agent, and the messages come
|
|
3743
|
+
* back either way.
|
|
3744
|
+
*/
|
|
3745
|
+
const listBookmarks = async (web, channel) => {
|
|
3746
|
+
if (!web.bookmarks) {
|
|
3747
|
+
return {};
|
|
3748
|
+
}
|
|
3749
|
+
let listed;
|
|
3750
|
+
try {
|
|
3751
|
+
listed = await web.bookmarks.list({ channel_id: channel });
|
|
3752
|
+
}
|
|
3753
|
+
catch (error) {
|
|
3754
|
+
const code = slackErrorCode(error);
|
|
3755
|
+
return {
|
|
3756
|
+
bookmarksUnavailable: code === 'missing_scope'
|
|
3757
|
+
? '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.'
|
|
3758
|
+
: `The channel's bookmarks could not be listed (${code ?? (error instanceof Error ? error.message : String(error))}).`,
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
const bookmarks = [];
|
|
3762
|
+
for (const entry of Array.isArray(listed?.bookmarks) ? listed.bookmarks : []) {
|
|
3763
|
+
const link = typeof entry?.link === 'string' ? entry.link.trim() : '';
|
|
3764
|
+
if (!link) {
|
|
3765
|
+
continue;
|
|
3766
|
+
}
|
|
3767
|
+
const title = (typeof entry.title === 'string' && entry.title.trim()) || link;
|
|
3768
|
+
bookmarks.push({
|
|
3769
|
+
title: title.length > BOOKMARK_TITLE_LIMIT ? `${title.slice(0, BOOKMARK_TITLE_LIMIT - 1)}…` : title,
|
|
3770
|
+
// A cut link is a broken one, so an over-long link is not shown.
|
|
3771
|
+
link: link.length > BOOKMARK_LINK_LIMIT ? BOOKMARK_LINK_TOO_LONG : link,
|
|
3772
|
+
});
|
|
3773
|
+
}
|
|
3774
|
+
return bookmarks.length > 0 ? { bookmarks } : {};
|
|
3775
|
+
};
|
|
3776
|
+
/**
|
|
3777
|
+
* The conversation a session lives in, read off its id, for this
|
|
3778
|
+
* agent's connected app only: exactly `slack:<agent>:<this app's
|
|
3779
|
+
* team>:<conversation>`, or that plus one thread `ts`, the keys this
|
|
3780
|
+
* adapter opens sessions under. Anything longer (a delegation's
|
|
3781
|
+
* `:delegate:…`, an archive's `:rolledover:…`) or of another shape (a
|
|
3782
|
+
* schedule's) is no session of a conversation here. No lookup: the id
|
|
3783
|
+
* is the identity check, and only the gateway names it.
|
|
3784
|
+
*/
|
|
3785
|
+
const SLACK_TS = /^\d+\.\d+$/;
|
|
3786
|
+
const sessionConversation = (agentId, sessionId) => {
|
|
3787
|
+
const connection = connectionFor(agentId);
|
|
3788
|
+
if (!connection?.teamId) {
|
|
3789
|
+
return undefined;
|
|
3790
|
+
}
|
|
3791
|
+
const prefix = channelSessionKey({ channel: 'slack', agentId, team: connection.teamId, conversation: '' });
|
|
3792
|
+
if (!sessionId.startsWith(prefix)) {
|
|
3793
|
+
return undefined;
|
|
3794
|
+
}
|
|
3795
|
+
const [conversation, thread, ...rest] = sessionId.slice(prefix.length).split(':');
|
|
3796
|
+
if (!conversation || rest.length > 0 || (thread !== undefined && !SLACK_TS.test(thread))) {
|
|
3797
|
+
return undefined;
|
|
3798
|
+
}
|
|
3799
|
+
return conversation;
|
|
3800
|
+
};
|
|
3609
3801
|
/**
|
|
3610
3802
|
* The read side of the same boundary `resolveOutbound` draws: a channel
|
|
3611
3803
|
* the agent's own app is a member of, public or private. DMs and group
|
|
3612
|
-
* DMs are refused
|
|
3613
|
-
*
|
|
3614
|
-
*
|
|
3615
|
-
*
|
|
3616
|
-
*
|
|
3804
|
+
* DMs are refused, member or not, except the one the reading session
|
|
3805
|
+
* lives in (`sessionConversation`). Their only members are the people
|
|
3806
|
+
* in them, and a read from a conversation elsewhere — a public thread
|
|
3807
|
+
* above all — would carry what they said to people who were never in
|
|
3808
|
+
* the room; from inside, it is what they already said to this agent. A
|
|
3809
|
+
* thread in a channel is fine for the same reason the channel is.
|
|
3617
3810
|
*/
|
|
3618
3811
|
const readConversation = async (request) => {
|
|
3619
3812
|
const channel = request.conversation.trim();
|
|
@@ -3637,10 +3830,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3637
3830
|
if (!info?.id) {
|
|
3638
3831
|
throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
|
|
3639
3832
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3833
|
+
const direct = info.is_im === true || info.is_mpim === true;
|
|
3834
|
+
if (direct && (request.sessionId === undefined || sessionConversation(request.agentId, request.sessionId) !== info.id)) {
|
|
3835
|
+
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
3836
|
}
|
|
3643
|
-
|
|
3837
|
+
// A one-to-one IM states no membership; the session living in it is
|
|
3838
|
+
// the proof. A group DM does say, and an app that left one cannot read
|
|
3839
|
+
// it however its stale session is named.
|
|
3840
|
+
if (info.is_im !== true && info.is_member !== true) {
|
|
3644
3841
|
throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
|
|
3645
3842
|
}
|
|
3646
3843
|
const { history, replies } = connection.web.conversations;
|
|
@@ -3650,6 +3847,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3650
3847
|
if (!fetchPage) {
|
|
3651
3848
|
throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
|
|
3652
3849
|
}
|
|
3850
|
+
// On the first page of the top level only, so paging costs no extra
|
|
3851
|
+
// calls, and only once the checks above have said the read may happen.
|
|
3852
|
+
// Asked beside the history, and settled without throwing.
|
|
3853
|
+
const listing = request.thread === undefined && request.after === undefined && request.before === undefined
|
|
3854
|
+
? listBookmarks(connection.web, info.id)
|
|
3855
|
+
: undefined;
|
|
3653
3856
|
const base = {
|
|
3654
3857
|
channel: info.id,
|
|
3655
3858
|
inclusive: false,
|
|
@@ -3728,7 +3931,159 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3728
3931
|
...(files.length > 0 ? { files } : {}),
|
|
3729
3932
|
});
|
|
3730
3933
|
}
|
|
3731
|
-
return { messages, more };
|
|
3934
|
+
return { messages, more, ...(listing ? await listing : {}) };
|
|
3935
|
+
};
|
|
3936
|
+
/**
|
|
3937
|
+
* Creates a channel with the asking agent's own app — `channel.create`.
|
|
3938
|
+
* Everything Slack would refuse once the channel exists is refused here
|
|
3939
|
+
* first, so a bad request leaves no channel behind. After the create
|
|
3940
|
+
* nothing throws: a topic, purpose or invitation that fails is reported,
|
|
3941
|
+
* and the channel stays. Slack names are unique, so an agent that
|
|
3942
|
+
* retries a create gets `name_taken`, never a second channel.
|
|
3943
|
+
*/
|
|
3944
|
+
const createConversation = async (request) => {
|
|
3945
|
+
const name = request.name.trim().replace(/^#/, '');
|
|
3946
|
+
if (!name) {
|
|
3947
|
+
throw new Error('slack: a channel needs a name.');
|
|
3948
|
+
}
|
|
3949
|
+
if (!/^[a-z0-9_-]+$/.test(name)) {
|
|
3950
|
+
throw new Error(`slack: "${name}" can't be a channel name: Slack names use only lowercase letters, numbers, hyphens and underscores. Nothing was created.`);
|
|
3951
|
+
}
|
|
3952
|
+
if (name.length > CHANNEL_NAME_LIMIT) {
|
|
3953
|
+
throw new Error(`slack: a channel name is at most ${CHANNEL_NAME_LIMIT} characters; this one is ${name.length}. Nothing was created.`);
|
|
3954
|
+
}
|
|
3955
|
+
for (const field of ['topic', 'purpose']) {
|
|
3956
|
+
const length = [...(request[field] ?? '')].length;
|
|
3957
|
+
if (length > CHANNEL_TEXT_LIMIT) {
|
|
3958
|
+
throw new Error(`slack: the ${field} is ${length} characters; Slack allows ${CHANNEL_TEXT_LIMIT}. Nothing was created.`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
const invite = request.invite ?? [];
|
|
3962
|
+
if (invite.length > CHANNEL_INVITE_LIMIT) {
|
|
3963
|
+
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.`);
|
|
3964
|
+
}
|
|
3965
|
+
const connection = connectionFor(request.agentId);
|
|
3966
|
+
if (!connection) {
|
|
3967
|
+
throw new Error(configuredAgents.has(request.agentId)
|
|
3968
|
+
? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot create a channel.`
|
|
3969
|
+
: `slack: agent ${request.agentId} has no Slack app, so it cannot create a Slack channel.`);
|
|
3970
|
+
}
|
|
3971
|
+
const { create, setTopic, setPurpose, invite: inviteUsers } = connection.web.conversations;
|
|
3972
|
+
if (!create || !setTopic || !setPurpose || !inviteUsers) {
|
|
3973
|
+
throw new Error('slack: this Slack client cannot create channels.');
|
|
3974
|
+
}
|
|
3975
|
+
// Who each entry is, before anything exists: agents on this host become
|
|
3976
|
+
// the user their app posts as, in the same workspace only.
|
|
3977
|
+
const invited = [];
|
|
3978
|
+
const notInvited = [];
|
|
3979
|
+
const toInvite = new Map();
|
|
3980
|
+
for (const who of invite) {
|
|
3981
|
+
const identity = botIdentities.get(who);
|
|
3982
|
+
if (who === request.agentId || identity?.botUserId === connection.botUserId) {
|
|
3983
|
+
invited.push(who);
|
|
3984
|
+
}
|
|
3985
|
+
else if (configuredAgents.has(who) && !identity?.botUserId) {
|
|
3986
|
+
notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
|
|
3987
|
+
}
|
|
3988
|
+
else if (identity) {
|
|
3989
|
+
if (identity.teamId !== connection.teamId) {
|
|
3990
|
+
notInvited.push({ who, reason: `Agent ${who}'s Slack app is in another Slack workspace.` });
|
|
3991
|
+
}
|
|
3992
|
+
else {
|
|
3993
|
+
toInvite.set(who, identity.botUserId);
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
else if (/^[UW][A-Z0-9]+$/.test(who)) {
|
|
3997
|
+
toInvite.set(who, who);
|
|
3998
|
+
}
|
|
3999
|
+
else {
|
|
4000
|
+
notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
const kind = request.private === true ? 'private' : 'public';
|
|
4004
|
+
let created;
|
|
4005
|
+
try {
|
|
4006
|
+
created = await create({ name, is_private: request.private === true });
|
|
4007
|
+
}
|
|
4008
|
+
catch (error) {
|
|
4009
|
+
const code = slackErrorCode(error);
|
|
4010
|
+
if (code === 'missing_scope') {
|
|
4011
|
+
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.`);
|
|
4012
|
+
}
|
|
4013
|
+
if (code === 'name_taken') {
|
|
4014
|
+
throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
|
|
4015
|
+
}
|
|
4016
|
+
throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
|
|
4017
|
+
}
|
|
4018
|
+
const conversation = created.channel?.id;
|
|
4019
|
+
if (!conversation) {
|
|
4020
|
+
throw new Error(`slack: Slack answered the create of #${name} without a channel id, so whether it exists is unknown. Read the channel list before trying again.`);
|
|
4021
|
+
}
|
|
4022
|
+
const notSet = [];
|
|
4023
|
+
const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
|
|
4024
|
+
if (request.topic) {
|
|
4025
|
+
await setTopic({ channel: conversation, topic: request.topic })
|
|
4026
|
+
.catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
|
|
4027
|
+
}
|
|
4028
|
+
if (request.purpose) {
|
|
4029
|
+
await setPurpose({ channel: conversation, purpose: request.purpose })
|
|
4030
|
+
.catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
|
|
4031
|
+
}
|
|
4032
|
+
if (toInvite.size > 0) {
|
|
4033
|
+
// Slack's per-id failures, by user id: on the answer, or on the error
|
|
4034
|
+
// the SDK throws for an ok: false answer.
|
|
4035
|
+
const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
|
|
4036
|
+
let refused;
|
|
4037
|
+
let failure;
|
|
4038
|
+
try {
|
|
4039
|
+
refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
|
|
4040
|
+
}
|
|
4041
|
+
catch (error) {
|
|
4042
|
+
refused = refusedOf(error?.data?.errors);
|
|
4043
|
+
failure = describe(error);
|
|
4044
|
+
}
|
|
4045
|
+
// A failed call that does not name everyone leaves the rest unknown,
|
|
4046
|
+
// so the channel's members decide; who cannot be checked is not claimed.
|
|
4047
|
+
let members;
|
|
4048
|
+
let membersFailure;
|
|
4049
|
+
if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
|
|
4050
|
+
try {
|
|
4051
|
+
members = new Set();
|
|
4052
|
+
let cursor;
|
|
4053
|
+
do {
|
|
4054
|
+
const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
4055
|
+
for (const member of page.members ?? []) {
|
|
4056
|
+
members.add(member);
|
|
4057
|
+
}
|
|
4058
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4059
|
+
} while (cursor);
|
|
4060
|
+
}
|
|
4061
|
+
catch (error) {
|
|
4062
|
+
members = undefined;
|
|
4063
|
+
membersFailure = describe(error);
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
for (const [who, user] of toInvite) {
|
|
4067
|
+
const code = refused.get(user);
|
|
4068
|
+
if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
|
|
4069
|
+
invited.push(who);
|
|
4070
|
+
}
|
|
4071
|
+
else if (code !== undefined || members !== undefined) {
|
|
4072
|
+
notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
|
|
4073
|
+
}
|
|
4074
|
+
else {
|
|
4075
|
+
notInvited.push({
|
|
4076
|
+
who,
|
|
4077
|
+
reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
|
|
4078
|
+
});
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
const order = new Map(invite.map((who, index) => [who, index]));
|
|
4083
|
+
const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
|
|
4084
|
+
invited.sort(byRequest);
|
|
4085
|
+
notInvited.sort((left, right) => byRequest(left.who, right.who));
|
|
4086
|
+
return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
|
|
3732
4087
|
};
|
|
3733
4088
|
/**
|
|
3734
4089
|
* Renders one parked call as buttons and remembers where it went.
|
|
@@ -4129,7 +4484,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4129
4484
|
return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
|
|
4130
4485
|
}
|
|
4131
4486
|
let landed = [];
|
|
4132
|
-
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy
|
|
4487
|
+
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy !== undefined
|
|
4488
|
+
? `Stopped by <@${stoppedBy}>.`
|
|
4489
|
+
: event.refused ? event.error : `Something went wrong: ${event.error}`), async () => {
|
|
4133
4490
|
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
4134
4491
|
});
|
|
4135
4492
|
return { said, landed };
|
|
@@ -4287,7 +4644,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4287
4644
|
channel,
|
|
4288
4645
|
text: escapeSlackText(`${agentName} is asking ${request.replace === true ? 'to replace' : 'for'} a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
|
|
4289
4646
|
...(thread ? { thread_ts: thread } : {}),
|
|
4290
|
-
blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true),
|
|
4647
|
+
blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true, request.env),
|
|
4291
4648
|
});
|
|
4292
4649
|
}
|
|
4293
4650
|
catch (error) {
|
|
@@ -4309,6 +4666,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4309
4666
|
name: request.name,
|
|
4310
4667
|
scope: request.scope,
|
|
4311
4668
|
...(request.replace === true ? { replace: true } : {}),
|
|
4669
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
4312
4670
|
approvers,
|
|
4313
4671
|
});
|
|
4314
4672
|
};
|
|
@@ -4322,14 +4680,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4322
4680
|
? `for ${escapeSlackText(post.agentName)} only`
|
|
4323
4681
|
: `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
|
|
4324
4682
|
const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
|
|
4325
|
-
const
|
|
4683
|
+
const shell = post.env !== undefined && event.grantError === undefined
|
|
4684
|
+
? ` Its shell commands read it as \`${escapeSlackText(post.env)}\`.`
|
|
4685
|
+
: '';
|
|
4686
|
+
const text = (event.replace === true
|
|
4326
4687
|
? `Replaced \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}. `
|
|
4327
4688
|
+ `${escapeSlackText(post.agentName)} uses the new value from its next reply; it is in the credential store, not here.`
|
|
4328
4689
|
: event.grantError === undefined
|
|
4329
4690
|
? `Added \`${escapeSlackText(post.name)}\` ${whose}${by}. ${escapeSlackText(post.agentName)} can use it from its next reply; the value is in the credential store, not here.`
|
|
4330
4691
|
: `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
|
|
4331
4692
|
+ `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
|
|
4332
|
-
+ 'Add the name to the credentials list in its soul on the machine.';
|
|
4693
|
+
+ 'Add the name to the credentials list in its soul on the machine.') + shell;
|
|
4333
4694
|
try {
|
|
4334
4695
|
await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
|
|
4335
4696
|
}
|
|
@@ -6017,8 +6378,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6017
6378
|
reasons.set(file, 'not-opened');
|
|
6018
6379
|
}
|
|
6019
6380
|
}
|
|
6020
|
-
const { images, unread: notImages } = overhear
|
|
6021
|
-
? { images: [], unread: event.files ?? [] }
|
|
6381
|
+
const { images, unread: notImages, previews } = overhear
|
|
6382
|
+
? { images: [], unread: event.files ?? [], previews: [] }
|
|
6022
6383
|
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
|
|
6023
6384
|
const { texts, saved, unread } = overhear
|
|
6024
6385
|
? { texts: [], saved: [], unread: notImages }
|
|
@@ -6042,7 +6403,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6042
6403
|
// In shared channels the model should know who is speaking; a DM is
|
|
6043
6404
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
6044
6405
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
6045
|
-
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
|
|
6406
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${previewNote(previews)}${attachmentNote(unread, reasons)}`;
|
|
6046
6407
|
// Who sent this, judged against the operator's list — per message,
|
|
6047
6408
|
// never remembered from the first one in the thread. A DM proves
|
|
6048
6409
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -6442,7 +6803,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6442
6803
|
? await renderer.stopped(renderer.stoppedBy)
|
|
6443
6804
|
: refused
|
|
6444
6805
|
? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
|
|
6445
|
-
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
6806
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure), failure instanceof HostRefusalError);
|
|
6446
6807
|
const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
|
|
6447
6808
|
? outcome
|
|
6448
6809
|
: noteReplayOutcome(connection, event.channel, event.ts, outcome);
|
|
@@ -6459,12 +6820,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6459
6820
|
name: 'slack',
|
|
6460
6821
|
resolveOutbound,
|
|
6461
6822
|
readConversation,
|
|
6823
|
+
sessionConversation,
|
|
6824
|
+
createConversation,
|
|
6462
6825
|
// A channel id is unique only within its workspace, so a standing
|
|
6463
6826
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
6464
6827
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
6465
6828
|
conversationKindFor,
|
|
6466
6829
|
requestCredential,
|
|
6467
6830
|
credentialReplaceForms: true,
|
|
6831
|
+
credentialEnvForms: true,
|
|
6468
6832
|
async start(gateway) {
|
|
6469
6833
|
gatewayRef = gateway;
|
|
6470
6834
|
// Nothing counts as seen live until every socket is up (Codex
|