@stratusagent/channel-slack 0.11.5 → 0.11.6
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 +23 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +191 -21
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts +19 -0
- package/dist/mrkdwn.d.ts.map +1 -1
- package/dist/mrkdwn.js +345 -3
- package/dist/mrkdwn.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +5 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no w
|
|
|
4
4
|
|
|
5
5
|
- **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (DMs: the DM channel id) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. A turn parked on a human when the daemon died is re-asked after the restart, and when it finishes its reply is posted into the thread as a fresh message — the placeholder it would have edited belonged to the old process.
|
|
6
6
|
- **Replies post when they are ready**: by default the reply is posted once, finished, with Slack's loading status (`assistant.threads.setStatus` — "is thinking…", "is running shell.run…") shown while the turn works, so the notification carries the answer. The `stream` reply mode (`slack.replies` in the [config](../../docs/guides/slack.md#how-replies-appear)) keeps the placeholder-and-edit rendering instead: post `…`, edit as deltas arrive (throttled for `chat.update` limits), show `⚙ tool…` status lines, finalize with the full reply. Either way a reply that outgrows one message is split across several.
|
|
7
|
-
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched.
|
|
7
|
+
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. **A pipe table**, which mrkdwn has no syntax for, becomes a code block with its columns padded to line up (alignment colons respected, `**`/`` ` `` markers dropped from cells, since nothing renders inside code); a grid wider than 60 characters — past what a phone shows unwrapped — or one with a link in it (a link inside a code block is not clickable) becomes one line per row, `*Header*: value · *Header*: value`. A one-column table has nothing to line up and becomes a bulleted list under its bold header. A table inside a fence is somebody's text and stays as written, and a grid longer than one message is split like any code block, closed at the cut and reopened in the next, with its header and rule repeated so every part names its columns.
|
|
8
8
|
- **Mention to start, then it stays in the thread.** A mention opens a conversation; every reply in that thread reaches the agent without tagging it again, and free-form DMs are unchanged. Who an untagged reply is for is decided by [Who a message is for](#who-a-message-is-for). Inbound mentions of other users are humanized to `@Display Name` for the model; redeliveries are deduped so a slow turn never runs twice.
|
|
9
9
|
- **Approval buttons** for the gateway's `remote` permission mode: a gated tool call parks the turn and asks in its thread with **Allow once** / **Always allow** / **Deny**. See [Approving tool calls](#approving-tool-calls) — clicks are authorized by *who clicked*, never by who can see the message, and **Always allow** is offered only where the daemon would actually remember it.
|
|
10
10
|
- **Tokens are gateway infrastructure secrets**, stored in the `channels` namespace of `~/.stratus/credentials.json` — never in an agent's credential allowlist:
|
|
@@ -131,11 +131,24 @@ agent's runtime: the Anthropic API, OpenAI-compatible providers, and the
|
|
|
131
131
|
Claude Code runtime send it as image content; the Codex harness takes a
|
|
132
132
|
text prompt, so there the agent is told the image's name and that it cannot
|
|
133
133
|
see it.
|
|
134
|
-
**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
**Text files are read into the message.** Markdown, plain text, CSV, JSON,
|
|
135
|
+
YAML, a log — anything Slack labels `text/*` (HTML aside), a few text
|
|
136
|
+
`application/*` types, or an unlabelled file whose name says it is one of
|
|
137
|
+
those — is downloaded with the same `files:read` scope and set into the
|
|
138
|
+
message after what was said, between `[Attached: plan.md. Its contents
|
|
139
|
+
follow.]` and `[End of plan.md]`. A text file dropped in alone is a
|
|
140
|
+
question, like a screenshot. One file may be 100 KB and one message's text
|
|
141
|
+
files 200 KB together, since read text joins the conversation and is sent
|
|
142
|
+
again every turn; a larger one is never downloaded, and `stratus serve`
|
|
143
|
+
warns with the file's name. A file that is not UTF-8, or that comes back as
|
|
144
|
+
Slack's sign-in page because the scope is missing, is treated as unreadable
|
|
145
|
+
and the warning names the scope. Text files share the message's 30-second
|
|
146
|
+
download deadline with its images.
|
|
147
|
+
**Every other attachment is unreadable** — a PDF, a Word file, an image that
|
|
148
|
+
was too large, a text file over the caps — so the message reaches the agent
|
|
149
|
+
naming those files and saying they cannot be opened, which is what lets it
|
|
150
|
+
answer honestly instead of as though it had read them. Such a file dropped
|
|
151
|
+
in with nothing said is not a question, and gets no reply.
|
|
139
152
|
Sessions are still per agent: an agent hears a thread from the mention
|
|
140
153
|
that brought it in, and what was said before that — to the other agent, or
|
|
141
154
|
by it — is not backfilled ([#147](https://github.com/stratuslabs/agent/issues/147)).
|
|
@@ -189,9 +202,10 @@ editing `credentials.json` by hand.
|
|
|
189
202
|
The manual equivalent, if you prefer:
|
|
190
203
|
|
|
191
204
|
1. https://api.slack.com/apps → **Create New App → From a manifest** → paste `manifest/stratus-agent.manifest.json` with `NAME` replaced by the agent's name.
|
|
192
|
-
The manifest asks for the `channels:history` / `groups:history` / `mpim:history` scopes and the matching `message.*` events, which is what lets an agent [stay in a thread](#who-a-message-is-for) instead of needing a mention every time, and for `files:read`, which is what lets it [see an attached image](#who-a-message-is-for). An app created before those shipped needs them added under **OAuth & Permissions** and **Event Subscriptions** and reinstalled once; leave the history scopes off and it answers mentions and DMs, exactly as it did before; leave `files:read` off and it hears about attachments by name only.
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
The manifest asks for the `channels:history` / `groups:history` / `mpim:history` scopes and the matching `message.*` events, which is what lets an agent [stay in a thread](#who-a-message-is-for) instead of needing a mention every time, and for `files:read`, which is what lets it [see an attached image](#who-a-message-is-for). An app created before those shipped needs them added under **OAuth & Permissions** and **Event Subscriptions** and reinstalled once; leave the history scopes off and it answers mentions and DMs, exactly as it did before; leave `files:read` off and it hears about attachments, images and text files alike, by name only.
|
|
206
|
+
It also turns on the **Messages Tab** under App Home (`features.app_home.messages_tab_enabled`), which Slack creates apps without — until it is on, a DM to the app gets Slack's own *Sending messages to this app has been turned off* and never reaches the adapter. An app created before the manifest carried it needs **App Home → Messages Tab → Allow users to send Slash commands and messages from the messages tab** switched on once; reopen the DM afterwards.
|
|
207
|
+
2. Still on **Basic Information**, scroll to **App-Level Tokens** → **Generate Token and Scopes** → name it anything → **Add Scope** → `connections:write` → **Generate**, and copy the token (that's the `appToken`, `xapp-…`). It is not on **OAuth & Permissions**, where the bot token is.
|
|
208
|
+
3. **Install App** → **Install to** the workspace → **Allow** → copy the **Bot User OAuth Token** (that's the `botToken`, `xoxb-…`).
|
|
195
209
|
4. Upload the agent's avatar under **Display Information**.
|
|
196
210
|
5. Add both tokens under `channels.slack.<agentId>` in `~/.stratus/credentials.json` and restart `stratus serve` — the log will show `slack: <agentId> connected`.
|
|
197
211
|
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
|
|
6
6
|
import { channelSessionKey, } from '@stratusagent/channels';
|
|
7
|
-
import { toSlackMrkdwn } from "./mrkdwn.js";
|
|
7
|
+
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
8
|
/**
|
|
9
9
|
* The longest display name the model is shown. Slack caps profile names
|
|
10
10
|
* well under this; the bound is for a profile that is not a name.
|
|
@@ -309,10 +309,12 @@ class ReplyRenderer {
|
|
|
309
309
|
return;
|
|
310
310
|
}
|
|
311
311
|
this.loading = true;
|
|
312
|
-
// A thread has one status per app, so a turn queued behind another
|
|
313
|
-
// must not publish its own: it would overwrite what the
|
|
314
|
-
// says it is doing. It publishes when it reaches the head
|
|
315
|
-
// or `refreshLoading` once the turn ahead has posted).
|
|
312
|
+
// A thread has one status per app, so a turn queued behind another in
|
|
313
|
+
// the same thread must not publish its own: it would overwrite what the
|
|
314
|
+
// running turn says it is doing. It publishes when it reaches the head
|
|
315
|
+
// (`beginTurn`, or `refreshLoading` once the turn ahead has posted).
|
|
316
|
+
// The caller passes `head` for a queued turn whose status lives on a
|
|
317
|
+
// key of its own — every DM message is — since it overwrites nothing.
|
|
316
318
|
if (head) {
|
|
317
319
|
this.publishLoading();
|
|
318
320
|
}
|
|
@@ -894,9 +896,23 @@ const safeCutIndex = (text, index) => {
|
|
|
894
896
|
const code = text.charCodeAt(index - 1);
|
|
895
897
|
return code >= 0xd800 && code <= 0xdbff ? index - 1 : index;
|
|
896
898
|
};
|
|
899
|
+
/** `index` moved forward, never back, off the middle of a surrogate pair. */
|
|
900
|
+
const pastCodePoint = (text, index) => {
|
|
901
|
+
const code = text.charCodeAt(index - 1);
|
|
902
|
+
return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
|
|
903
|
+
};
|
|
897
904
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
898
905
|
? text
|
|
899
906
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
907
|
+
/**
|
|
908
|
+
* Text as however many messages Slack's limit makes it, never leaving a
|
|
909
|
+
* code block broken across the cut. A cut blind to fences ended one
|
|
910
|
+
* message on an unterminated block and started the next mid-block, where
|
|
911
|
+
* Slack read the rest of somebody's log as prose — every `*` and `_` in it
|
|
912
|
+
* formatting. So a cut that would land inside a code run moves in front of
|
|
913
|
+
* it when that still leaves a message worth sending, and otherwise closes
|
|
914
|
+
* the run at the cut and reopens it in the next.
|
|
915
|
+
*/
|
|
900
916
|
const splitForSlack = (text) => {
|
|
901
917
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
902
918
|
return [text];
|
|
@@ -906,11 +922,49 @@ const splitForSlack = (text) => {
|
|
|
906
922
|
while (rest.length > SLACK_MAX_MESSAGE_CHARS) {
|
|
907
923
|
// Prefer a newline break inside the window; fall back to a hard cut.
|
|
908
924
|
const window = rest.slice(0, SLACK_MAX_MESSAGE_CHARS);
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
925
|
+
const newline = window.lastIndexOf('\n');
|
|
926
|
+
const cut = newline > SLACK_MAX_MESSAGE_CHARS / 2 ? newline : safeCutIndex(rest, SLACK_MAX_MESSAGE_CHARS);
|
|
927
|
+
// Scanned two messages deep, not to the end: rescanning the whole rest
|
|
928
|
+
// for every chunk made a megabyte reply quadratic on the event loop. A
|
|
929
|
+
// fence that closes past the window still reads as open to its end,
|
|
930
|
+
// which is what it is here; an inline span that does is longer than a
|
|
931
|
+
// message, and is cut as text either way.
|
|
932
|
+
const run = codeRunsOf(rest.slice(0, SLACK_MAX_MESSAGE_CHARS * 2))
|
|
933
|
+
.find((candidate) => candidate.start < cut && cut < candidate.end);
|
|
934
|
+
if (run !== undefined && run.start > SLACK_MAX_MESSAGE_CHARS / 4) {
|
|
935
|
+
chunks.push(rest.slice(0, run.start).replace(/\n+$/, ''));
|
|
936
|
+
rest = rest.slice(run.start);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
// The run is most of the message, so it is cut itself — at its last
|
|
940
|
+
// newline that leaves room for the closer, and past its opener so the
|
|
941
|
+
// continuation always makes progress. A continuation reopens with the
|
|
942
|
+
// info string only while it is short: a two-thousand-character info
|
|
943
|
+
// line repeated on every part would crowd out the code it labels.
|
|
944
|
+
const budget = SLACK_MAX_MESSAGE_CHARS - (run?.closer.length ?? 0);
|
|
945
|
+
const floor = run === undefined ? 0 : run.start + run.opener.length;
|
|
946
|
+
const reopen = run === undefined || run.opener.length <= SLACK_MAX_MESSAGE_CHARS / 8
|
|
947
|
+
? run?.opener
|
|
948
|
+
: run.opener.endsWith('\n') ? `${run.closer.slice(1)}\n` : run.opener;
|
|
949
|
+
// A delimiter too long to close and reopen inside one message — a fence
|
|
950
|
+
// of a thousand backticks, an opening line that fills the window — is
|
|
951
|
+
// cut raw, as before this existed. Reopening it would send a message
|
|
952
|
+
// over the limit, and consume a character a message after that.
|
|
953
|
+
// Only a fence is reopened. Its closer and opener sit on lines of their
|
|
954
|
+
// own, so they can never run into a backtick of the code; an inline
|
|
955
|
+
// span's would, and `x` + `` would read as a different delimiter.
|
|
956
|
+
// `floor + 2`: the first cut past the opener has to fit a whole code
|
|
957
|
+
// point, or the step that guarantees progress lands inside an emoji.
|
|
958
|
+
const fence = run !== undefined && run.closer.startsWith('\n');
|
|
959
|
+
if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
|
|
960
|
+
chunks.push(rest.slice(0, cut));
|
|
961
|
+
rest = rest.slice(cut).replace(/^\n+/, '');
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const inner = rest.lastIndexOf('\n', budget - 1);
|
|
965
|
+
const within = inner > Math.max(floor, SLACK_MAX_MESSAGE_CHARS / 2) ? inner : Math.max(safeCutIndex(rest, budget), pastCodePoint(rest, floor + 1));
|
|
966
|
+
chunks.push(`${rest.slice(0, within)}${run.closer}`);
|
|
967
|
+
rest = `${reopen}${rest.slice(within).replace(/^\n/, '')}`;
|
|
914
968
|
}
|
|
915
969
|
if (rest.length > 0) {
|
|
916
970
|
chunks.push(rest);
|
|
@@ -1057,15 +1111,13 @@ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed fi
|
|
|
1057
1111
|
* fix — the warning here names the scope instead, and the file falls back
|
|
1058
1112
|
* to the by-name note like any other attachment the turn cannot open.
|
|
1059
1113
|
*/
|
|
1060
|
-
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
|
|
1114
|
+
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, deadline = AbortSignal.timeout(timeoutMs)) => {
|
|
1061
1115
|
// Decided from the last file back, the way the replay window is spent:
|
|
1062
1116
|
// when a message's images do not all fit, the ones kept are the ones it
|
|
1063
1117
|
// listed last, so intake and replay agree about which survive. Delivered
|
|
1064
1118
|
// in the message's own order, because that is how the person sees them.
|
|
1065
1119
|
const kept = new Map();
|
|
1066
1120
|
const dropped = new Set();
|
|
1067
|
-
// The message's one deadline, shared by every download in it.
|
|
1068
|
-
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1069
1121
|
// Decoded bytes accepted so far. Checked against Slack's reported size
|
|
1070
1122
|
// before a download and the real length after it, because the message
|
|
1071
1123
|
// as a whole has a budget the per-image cap alone cannot keep.
|
|
@@ -1188,6 +1240,114 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1188
1240
|
});
|
|
1189
1241
|
return { images, unread };
|
|
1190
1242
|
};
|
|
1243
|
+
/** The most one text attachment may weigh to be read into the message. */
|
|
1244
|
+
const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
1245
|
+
/**
|
|
1246
|
+
* The most one message's text attachments may weigh together. Read text
|
|
1247
|
+
* joins the transcript and is sent again on every later turn, so the cap
|
|
1248
|
+
* is a cost decision as much as a context one.
|
|
1249
|
+
*/
|
|
1250
|
+
const TEXT_ATTACHMENTS_MAX_TOTAL_BYTES = 200 * 1024;
|
|
1251
|
+
const TEXT_APPLICATION_TYPES = new Set([
|
|
1252
|
+
'application/json',
|
|
1253
|
+
'application/xml',
|
|
1254
|
+
'application/yaml',
|
|
1255
|
+
'application/x-yaml',
|
|
1256
|
+
'application/toml',
|
|
1257
|
+
'application/javascript',
|
|
1258
|
+
'application/x-sh',
|
|
1259
|
+
'application/sql',
|
|
1260
|
+
]);
|
|
1261
|
+
const TEXT_EXTENSIONS = /\.(md|markdown|txt|text|csv|tsv|json|ya?ml|toml|xml|log|ini|conf)$/i;
|
|
1262
|
+
/**
|
|
1263
|
+
* Whether a file is text the model can be handed as it is. Slack's
|
|
1264
|
+
* mimetype comes from the uploader's client and a Markdown file can arrive
|
|
1265
|
+
* as `text/markdown`, `text/plain`, or an octet stream, so an unhelpful
|
|
1266
|
+
* type falls back to the name. HTML is left out: a sign-in page is HTML
|
|
1267
|
+
* too, and a file of it could not be told apart from Slack refusing one.
|
|
1268
|
+
*/
|
|
1269
|
+
const isTextAttachment = (file) => {
|
|
1270
|
+
const mimetype = file.mimetype?.toLowerCase();
|
|
1271
|
+
if (mimetype === 'text/html') {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
if (mimetype !== undefined && (mimetype.startsWith('text/') || TEXT_APPLICATION_TYPES.has(mimetype))) {
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
const name = file.name ?? file.title;
|
|
1278
|
+
return (mimetype === undefined || mimetype === 'application/octet-stream') && name !== undefined && TEXT_EXTENSIONS.test(name);
|
|
1279
|
+
};
|
|
1280
|
+
/**
|
|
1281
|
+
* The text files among a message's attachments, read so the turn gets
|
|
1282
|
+
* their contents rather than their names. A Markdown plan attached in a
|
|
1283
|
+
* DM reached the agent as a filename, and it told the person it had no
|
|
1284
|
+
* way to read Slack at all. Anything that is not UTF-8 text, is over the
|
|
1285
|
+
* caps, or comes back as a page stays in `unread` and is named in the
|
|
1286
|
+
* note, exactly as before; the warning says why.
|
|
1287
|
+
*/
|
|
1288
|
+
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn) => {
|
|
1289
|
+
const texts = [];
|
|
1290
|
+
const unread = [];
|
|
1291
|
+
let total = 0;
|
|
1292
|
+
for (const file of files) {
|
|
1293
|
+
const url = file.url_private_download ?? file.url_private;
|
|
1294
|
+
if (!isTextAttachment(file) || url === undefined) {
|
|
1295
|
+
unread.push(file);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1299
|
+
if (file.size !== undefined && file.size > maxBytes) {
|
|
1300
|
+
warn(`slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
|
|
1301
|
+
unread.push(file);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (deadline.aborted) {
|
|
1305
|
+
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.`);
|
|
1306
|
+
unread.push(file);
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
let download;
|
|
1310
|
+
try {
|
|
1311
|
+
download = await fetchFile(url, botToken, deadline, maxBytes);
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
1315
|
+
? `this message's downloads took longer than ${timeoutMs}ms`
|
|
1316
|
+
: (error instanceof Error ? error.message : String(error));
|
|
1317
|
+
warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
|
|
1318
|
+
unread.push(file);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
// HTML is never a file read here (see isTextAttachment), so an HTML
|
|
1322
|
+
// body is Slack's sign-in page: what a token without files:read gets.
|
|
1323
|
+
const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1324
|
+
const head = download.body.subarray(0, 64).toString('utf8').trimStart().toLowerCase();
|
|
1325
|
+
if (download.status !== 200 || contentType === 'text/html' || head.startsWith('<!doctype html') || head.startsWith('<html')) {
|
|
1326
|
+
warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than the file. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
|
|
1327
|
+
unread.push(file);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (download.truncated === true || download.body.length > maxBytes) {
|
|
1331
|
+
warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes of text this message could still take; the download was abandoned and the turn is told it cannot be read.`);
|
|
1332
|
+
unread.push(file);
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
let text;
|
|
1336
|
+
try {
|
|
1337
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(download.body);
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
warn(`slack: ${fileLabel(file)} is not UTF-8 text; the turn is told it cannot be read.`);
|
|
1341
|
+
unread.push(file);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
total += download.body.length;
|
|
1345
|
+
texts.push({ name: fileLabel(file), text });
|
|
1346
|
+
}
|
|
1347
|
+
return { texts, unread };
|
|
1348
|
+
};
|
|
1349
|
+
/** Read text attachments, set into the message after what was said. */
|
|
1350
|
+
const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attached: ${name}. Its contents follow.]\n${text.replace(/\s+$/, '')}\n[End of ${name}]`).join('');
|
|
1191
1351
|
/**
|
|
1192
1352
|
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
1193
1353
|
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
@@ -2868,20 +3028,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2868
3028
|
// nobody asked for, since they reach the model ahead of the frame
|
|
2869
3029
|
// that marks the message as somebody else's, and a judged message
|
|
2870
3030
|
// names its attachments the way an overheard one does.
|
|
2871
|
-
|
|
3031
|
+
// Images and text files share the message's one download deadline.
|
|
3032
|
+
const deadline = AbortSignal.timeout(fileDownloadTimeoutMs);
|
|
3033
|
+
const { images, unread: notImages } = overhear || judged
|
|
2872
3034
|
? { images: [], unread: event.files ?? [] }
|
|
2873
|
-
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
3035
|
+
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, deadline);
|
|
3036
|
+
const { texts, unread } = overhear || judged
|
|
3037
|
+
? { texts: [], unread: notImages }
|
|
3038
|
+
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn);
|
|
3039
|
+
// An image or a document with nothing said is still a question
|
|
3040
|
+
// ("what's this?"); a file the turn could not open with nothing said
|
|
3041
|
+
// is not one, and gets no reply.
|
|
3042
|
+
if (cleaned.length === 0 && images.length === 0 && texts.length === 0) {
|
|
2878
3043
|
return undefined;
|
|
2879
3044
|
}
|
|
2880
3045
|
const author = await authorFor(connection, userId);
|
|
2881
3046
|
// In shared channels the model should know who is speaking; a DM is
|
|
2882
3047
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
2883
3048
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
2884
|
-
const userMessage = `${spoken}${attachmentNote(unread)}`;
|
|
3049
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread)}`;
|
|
2885
3050
|
// Who sent this, judged against the operator's list — per message,
|
|
2886
3051
|
// never remembered from the first one in the thread. A DM proves
|
|
2887
3052
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -2958,7 +3123,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2958
3123
|
const queue = renderers.get(sessionId) ?? [];
|
|
2959
3124
|
queue.push(renderer);
|
|
2960
3125
|
renderers.set(sessionId, queue);
|
|
2961
|
-
|
|
3126
|
+
// In a DM each message is its own status key, and Slack stops showing
|
|
3127
|
+
// the running turn's once a newer message sits below it — so a
|
|
3128
|
+
// message queued there that stayed dark read as the agent having
|
|
3129
|
+
// stopped until the reply ahead of it posted. In a channel thread the
|
|
3130
|
+
// key is shared, and the running turn's status stays up instead.
|
|
3131
|
+
renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
|
|
2962
3132
|
const turn = gateway.dispatch({
|
|
2963
3133
|
sessionId,
|
|
2964
3134
|
agentId: connection.config.agentId,
|