@stratusagent/channel-slack 0.11.4 → 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 +28 -13
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +418 -361
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts +62 -0
- package/dist/mrkdwn.d.ts.map +1 -0
- package/dist/mrkdwn.js +1081 -0
- package/dist/mrkdwn.js.map +1 -0
- package/manifest/stratus-agent.manifest.json +5 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4,6 +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 { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
7
8
|
/**
|
|
8
9
|
* The longest display name the model is shown. Slack caps profile names
|
|
9
10
|
* well under this; the bound is for a profile that is not a name.
|
|
@@ -51,6 +52,15 @@ const THREAD_HANDOVER_DEPTH = 32;
|
|
|
51
52
|
*/
|
|
52
53
|
const MAX_UNRENDERED_FILES = 20;
|
|
53
54
|
const PLACEHOLDER_TEXT = '…';
|
|
55
|
+
/** What the loading status says while a turn works, and while it runs a tool. */
|
|
56
|
+
const THINKING_STATUS = 'is thinking…';
|
|
57
|
+
const toolStatus = (toolName) => `is running ${toolName}…`;
|
|
58
|
+
/**
|
|
59
|
+
* How often a loading status is set again while its turn runs. Slack drops
|
|
60
|
+
* one after two minutes with no message, and a long turn — a tool, an
|
|
61
|
+
* approval wait — is exactly the one whose status must not vanish.
|
|
62
|
+
*/
|
|
63
|
+
const STATUS_REFRESH_MS = 60_000;
|
|
54
64
|
/**
|
|
55
65
|
* How long an agent that judges stays attentive after it last answered a
|
|
56
66
|
* message that addressed it: this many messages, or this many minutes,
|
|
@@ -156,10 +166,13 @@ const uploadPlace = (result, channel) => {
|
|
|
156
166
|
return undefined;
|
|
157
167
|
};
|
|
158
168
|
/**
|
|
159
|
-
* Renders one turn's reply into Slack
|
|
160
|
-
*
|
|
161
|
-
* lines into throttled edits, and finalize with the
|
|
162
|
-
* (
|
|
169
|
+
* Renders one turn's reply into Slack, one of two ways. Streaming (the
|
|
170
|
+
* `stream` reply mode): post `…` immediately, fold streaming deltas and
|
|
171
|
+
* tool status lines into throttled edits, and finalize with the
|
|
172
|
+
* authoritative reply. Otherwise (`final`, the default): no placeholder —
|
|
173
|
+
* Slack's loading status says the agent is working, and the finished reply
|
|
174
|
+
* is posted once, so the notification carries the answer rather than a
|
|
175
|
+
* `…`. Either way a reply that outgrows one message is split.
|
|
163
176
|
*/
|
|
164
177
|
class ReplyRenderer {
|
|
165
178
|
/**
|
|
@@ -225,6 +238,47 @@ class ReplyRenderer {
|
|
|
225
238
|
* had nothing to add is a message the decision cannot take back.
|
|
226
239
|
*/
|
|
227
240
|
lazy;
|
|
241
|
+
/**
|
|
242
|
+
* Whether this turn streams into a placeholder. When it does not, there
|
|
243
|
+
* is never a placeholder: `finalize` finds none and posts the reply as
|
|
244
|
+
* messages of its own, and a handover has nothing to hand over and says
|
|
245
|
+
* so (`untaken`), so the recovery's reply is posted ahead of this one
|
|
246
|
+
* the ordinary way — both already the paths for a placeholder that
|
|
247
|
+
* could not be opened.
|
|
248
|
+
*/
|
|
249
|
+
streaming;
|
|
250
|
+
/**
|
|
251
|
+
* The thread the loading status belongs to: the reply's thread, or the
|
|
252
|
+
* message itself in a DM, whose reply has no thread to key one on.
|
|
253
|
+
*/
|
|
254
|
+
statusThread;
|
|
255
|
+
/** Shared by a connection's renderers, so a status Slack refuses is said once, not per message. */
|
|
256
|
+
statusWarned;
|
|
257
|
+
loading = false;
|
|
258
|
+
/**
|
|
259
|
+
* Status calls, in order, so the last one Slack applies is the last one
|
|
260
|
+
* sent — and so `finalize` can wait for them: a status that reached
|
|
261
|
+
* Slack after the reply would stand over a finished turn until Slack's
|
|
262
|
+
* own timeout, since the post that clears it came first.
|
|
263
|
+
*/
|
|
264
|
+
statusChain = Promise.resolve();
|
|
265
|
+
/**
|
|
266
|
+
* The turn this one was queued behind has posted everything it will
|
|
267
|
+
* (see `posted`). A streaming turn holds its place with the placeholder
|
|
268
|
+
* it opened at intake; a `final` one has no placeholder, so it waits for
|
|
269
|
+
* this before putting text or files in the thread — or a quick answer
|
|
270
|
+
* would land above the slow one ahead of it.
|
|
271
|
+
*/
|
|
272
|
+
after;
|
|
273
|
+
settle;
|
|
274
|
+
/** Resolves once this turn has put in the thread everything it will. */
|
|
275
|
+
posted = new Promise((resolve) => {
|
|
276
|
+
this.settle = resolve;
|
|
277
|
+
});
|
|
278
|
+
statusText;
|
|
279
|
+
statusTimer;
|
|
280
|
+
/** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
|
|
281
|
+
runningTool;
|
|
228
282
|
web;
|
|
229
283
|
channel;
|
|
230
284
|
threadTs;
|
|
@@ -239,6 +293,85 @@ class ReplyRenderer {
|
|
|
239
293
|
this.warn = warn;
|
|
240
294
|
this.lazy = options.lazy ?? false;
|
|
241
295
|
this.now = options.now ?? Date.now;
|
|
296
|
+
this.streaming = options.streaming ?? true;
|
|
297
|
+
this.statusThread = options.statusThread ?? threadTs;
|
|
298
|
+
this.statusWarned = options.statusWarned ?? { value: false };
|
|
299
|
+
this.after = options.after ?? Promise.resolve();
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Show Slack's loading status for this turn, and keep it up until the
|
|
303
|
+
* turn is finalized. Not for a streaming turn, which has its placeholder,
|
|
304
|
+
* nor for one nobody asked for: "is thinking…" over a turn that may
|
|
305
|
+
* decide to say nothing is the interruption silence exists to avoid.
|
|
306
|
+
*/
|
|
307
|
+
showLoading(head) {
|
|
308
|
+
if (this.streaming || this.lazy || this.loading || this.finalized) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.loading = true;
|
|
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.
|
|
318
|
+
if (head) {
|
|
319
|
+
this.publishLoading();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
publishLoading() {
|
|
323
|
+
if (this.statusTimer || this.finalized) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.setStatus(this.statusText ?? THINKING_STATUS);
|
|
327
|
+
this.statusTimer = setInterval(() => this.refreshLoading(), STATUS_REFRESH_MS);
|
|
328
|
+
this.statusTimer.unref?.();
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Set the status again. Slack clears a thread's status whenever the app
|
|
332
|
+
* posts in it, so a reply or file from the turn ahead of this one — or
|
|
333
|
+
* this turn's own upload — takes it down while this turn is still
|
|
334
|
+
* working; the adapter calls this once that post has landed.
|
|
335
|
+
*/
|
|
336
|
+
refreshLoading() {
|
|
337
|
+
if (!this.loading || this.finalized) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!this.statusTimer) {
|
|
341
|
+
// Queued until now: this is the renderer at the head at last.
|
|
342
|
+
this.publishLoading();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.setStatus(this.statusText ?? THINKING_STATUS);
|
|
346
|
+
}
|
|
347
|
+
stopLoading() {
|
|
348
|
+
if (this.statusTimer) {
|
|
349
|
+
clearInterval(this.statusTimer);
|
|
350
|
+
this.statusTimer = undefined;
|
|
351
|
+
}
|
|
352
|
+
this.loading = false;
|
|
353
|
+
}
|
|
354
|
+
setStatus(text) {
|
|
355
|
+
const setStatus = this.web.assistant?.threads.setStatus;
|
|
356
|
+
if (!setStatus || this.statusThread === undefined) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.statusText = text;
|
|
360
|
+
const thread = this.statusThread;
|
|
361
|
+
// Not awaited by the caller: the turn never waits on a status. Only
|
|
362
|
+
// `finalize` does, for the ordering reason on `statusChain`. A refusal
|
|
363
|
+
// — a workspace or plan without it, a token Slack will not honour for
|
|
364
|
+
// it — is said once and then left alone; the reply still arrives,
|
|
365
|
+
// just without the status ahead of it.
|
|
366
|
+
this.statusChain = this.statusChain
|
|
367
|
+
.then(() => setStatus.call(this.web.assistant?.threads, { channel_id: this.channel, thread_ts: thread, status: text }))
|
|
368
|
+
.then(() => undefined, (error) => {
|
|
369
|
+
if (!this.statusWarned.value) {
|
|
370
|
+
this.statusWarned.value = true;
|
|
371
|
+
this.warn(`slack: could not show a loading status (${error instanceof Error ? error.message : String(error)}); `
|
|
372
|
+
+ 'replies still post when they are ready, without one');
|
|
373
|
+
}
|
|
374
|
+
});
|
|
242
375
|
}
|
|
243
376
|
/**
|
|
244
377
|
* The gateway has begun this renderer's turn. Whatever was streamed here
|
|
@@ -255,8 +388,17 @@ class ReplyRenderer {
|
|
|
255
388
|
}
|
|
256
389
|
this.buffer = '';
|
|
257
390
|
this.toolLine = undefined;
|
|
391
|
+
this.runningTool = undefined;
|
|
258
392
|
this.turnBreakPending = false;
|
|
259
393
|
this.generation += 1;
|
|
394
|
+
// Queued until now, this turn publishes its status as it starts; one
|
|
395
|
+
// already showing may be showing a tool the turn ahead of it ran.
|
|
396
|
+
if (this.loading && !this.statusTimer) {
|
|
397
|
+
this.publishLoading();
|
|
398
|
+
}
|
|
399
|
+
else if (this.loading && this.statusText !== THINKING_STATUS) {
|
|
400
|
+
this.setStatus(THINKING_STATUS);
|
|
401
|
+
}
|
|
260
402
|
}
|
|
261
403
|
async open() {
|
|
262
404
|
const posted = await this.web.chat.postMessage({
|
|
@@ -305,6 +447,7 @@ class ReplyRenderer {
|
|
|
305
447
|
// the text after it — without it they fuse in the live message.
|
|
306
448
|
this.turnBreakPending = true;
|
|
307
449
|
this.toolLine = `⚙ ${event.call.toolName}…`;
|
|
450
|
+
this.runningTool = event.call.toolName;
|
|
308
451
|
this.scheduleEdit();
|
|
309
452
|
return;
|
|
310
453
|
}
|
|
@@ -314,12 +457,14 @@ class ReplyRenderer {
|
|
|
314
457
|
// message would keep claiming a refused tool is running, and the
|
|
315
458
|
// model's follow-up would fuse with the text before the attempt.
|
|
316
459
|
this.toolLine = undefined;
|
|
460
|
+
this.runningTool = undefined;
|
|
317
461
|
this.turnBreakPending = true;
|
|
318
462
|
this.scheduleEdit();
|
|
319
463
|
return;
|
|
320
464
|
}
|
|
321
465
|
if (event.type === 'tool.completed') {
|
|
322
466
|
this.toolLine = undefined;
|
|
467
|
+
this.runningTool = undefined;
|
|
323
468
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
324
469
|
// execution settles as tool.completed without ever having emitted
|
|
325
470
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -353,8 +498,10 @@ class ReplyRenderer {
|
|
|
353
498
|
// it, and an upload that then waited for that handover would be a
|
|
354
499
|
// cycle nothing breaks (see `queueEdit`).
|
|
355
500
|
const handover = this.handover;
|
|
501
|
+
const after = this.streaming ? undefined : this.after;
|
|
356
502
|
this.uploadChain = this.uploadChain
|
|
357
503
|
.then(() => handover)
|
|
504
|
+
.then(() => after)
|
|
358
505
|
// Read as file DATA inside the chain: the Web API takes contents,
|
|
359
506
|
// not a path, and a missing file surfaces as this upload's own
|
|
360
507
|
// failure instead of an unhandled stream error.
|
|
@@ -377,6 +524,9 @@ class ReplyRenderer {
|
|
|
377
524
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
378
525
|
});
|
|
379
526
|
this.uploaded = true;
|
|
527
|
+
// The upload is a post of the app's in this thread, which takes
|
|
528
|
+
// the status down with it while the turn is still working.
|
|
529
|
+
this.refreshLoading();
|
|
380
530
|
const placed = uploadPlace(result, this.channel) ?? before;
|
|
381
531
|
if (this.uploadedAt === undefined || placed > this.uploadedAt) {
|
|
382
532
|
this.uploadedAt = placed;
|
|
@@ -389,6 +539,15 @@ class ReplyRenderer {
|
|
|
389
539
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
390
540
|
}
|
|
391
541
|
scheduleEdit() {
|
|
542
|
+
if (!this.streaming) {
|
|
543
|
+
// Nothing to edit: what the turn is doing goes into the status line,
|
|
544
|
+
// and only once the turn is this renderer's own (see `beginTurn`).
|
|
545
|
+
const status = this.runningTool !== undefined ? toolStatus(this.runningTool) : THINKING_STATUS;
|
|
546
|
+
if (this.loading && this.statusTimer && this.turnStarted && !this.finalized && status !== this.statusText) {
|
|
547
|
+
this.setStatus(status);
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
392
551
|
// Not gated on having a placeholder: during a handover there is none
|
|
393
552
|
// for a moment, and the edit reads the fresh one when its turn comes.
|
|
394
553
|
if (this.pendingEdit) {
|
|
@@ -469,8 +628,25 @@ class ReplyRenderer {
|
|
|
469
628
|
* refused sentence is still the last thing said, for the thread rule.
|
|
470
629
|
*/
|
|
471
630
|
async finalize(reply) {
|
|
631
|
+
try {
|
|
632
|
+
return await this.finalizeInOrder(reply);
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
this.settle();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async finalizeInOrder(reply) {
|
|
472
639
|
await this.handover;
|
|
640
|
+
// Shown, not merely wanted: a turn still queued behind another never
|
|
641
|
+
// published one, and a clear from it would take down the running
|
|
642
|
+
// turn's status instead.
|
|
643
|
+
const hadStatus = this.statusTimer !== undefined;
|
|
644
|
+
this.stopLoading();
|
|
473
645
|
this.finalized = true;
|
|
646
|
+
if (!this.streaming) {
|
|
647
|
+
await this.after;
|
|
648
|
+
await this.statusChain;
|
|
649
|
+
}
|
|
474
650
|
if (this.pendingEdit) {
|
|
475
651
|
clearTimeout(this.pendingEdit);
|
|
476
652
|
this.pendingEdit = undefined;
|
|
@@ -528,6 +704,15 @@ class ReplyRenderer {
|
|
|
528
704
|
this.placeText(this.ref?.ts);
|
|
529
705
|
}
|
|
530
706
|
}
|
|
707
|
+
// A reply posted clears the status itself; one Slack refused would
|
|
708
|
+
// leave "is thinking…" standing over a turn that has ended.
|
|
709
|
+
if (hadStatus && !landed) {
|
|
710
|
+
this.setStatus('');
|
|
711
|
+
// Awaited: the turn queued behind this one re-shows its own status
|
|
712
|
+
// once this renderer is done, and a clear that reached Slack after
|
|
713
|
+
// that would take the next turn's status down with it.
|
|
714
|
+
await this.statusChain;
|
|
715
|
+
}
|
|
531
716
|
return this.outcome(published, landed || this.edited || this.uploaded || stranded);
|
|
532
717
|
}
|
|
533
718
|
placeText(ts) {
|
|
@@ -711,346 +896,23 @@ const safeCutIndex = (text, index) => {
|
|
|
711
896
|
const code = text.charCodeAt(index - 1);
|
|
712
897
|
return code >= 0xd800 && code <= 0xdbff ? index - 1 : index;
|
|
713
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
|
+
};
|
|
714
904
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
715
905
|
? text
|
|
716
906
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
717
907
|
/**
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
for (let at = text.indexOf(run, from); at !== -1 && at + length <= limit; at = text.indexOf(run, at + 1)) {
|
|
726
|
-
if (text[at - 1] !== '`' && text[at + length] !== '`') {
|
|
727
|
-
return at;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
return -1;
|
|
731
|
-
};
|
|
732
|
-
/**
|
|
733
|
-
* The text as alternating prose and code — even indices prose, odd code.
|
|
734
|
-
*
|
|
735
|
-
* Code is the one place a reader means the characters themselves: `**` in a
|
|
736
|
-
* shell snippet is the snippet, so nothing below rewrites it. A run of N
|
|
737
|
-
* backticks opens code and the next run of exactly N closes it, which is
|
|
738
|
-
* the same rule for a span and for a fence and is why a longer delimiter
|
|
739
|
-
* can carry a shorter one inside it.
|
|
740
|
-
*
|
|
741
|
-
* A span may close on a later line, and is protected the whole way. That
|
|
742
|
-
* looks wasteful — two stray backticks paragraphs apart become one long
|
|
743
|
-
* "span" whose contents are left unconverted — and it was briefly changed
|
|
744
|
-
* to end a span at its line for exactly that reason. That was wrong, and
|
|
745
|
-
* the reason is the rule this whole file answers to: what Slack does with
|
|
746
|
-
* the message decides, not what Markdown says. Slack pairs those backticks
|
|
747
|
-
* too, so the text between them is what it renders as code, and rewriting
|
|
748
|
-
* a `**bold**` in there would alter the contents of somebody's snippet.
|
|
749
|
-
* Leaving prose unconverted is a cosmetic loss; changing what a reader is
|
|
750
|
-
* told is code is not, so the doubt resolves toward protecting more.
|
|
751
|
-
*
|
|
752
|
-
* A fence with no closer runs to the end on purpose: a model that forgets
|
|
753
|
-
* to close one, and every partially streamed block on its way to being
|
|
754
|
-
* closed, would otherwise have its contents rewritten as prose. One or two
|
|
755
|
-
* unmatched backticks are the opposite case — literal text, since "use the
|
|
756
|
-
* ` character" must not swallow what follows.
|
|
757
|
-
*/
|
|
758
|
-
const proseAndCode = (text) => {
|
|
759
|
-
const segments = [];
|
|
760
|
-
let prose = 0;
|
|
761
|
-
let cursor = 0;
|
|
762
|
-
while (cursor < text.length) {
|
|
763
|
-
const open = text.indexOf('`', cursor);
|
|
764
|
-
if (open === -1) {
|
|
765
|
-
break;
|
|
766
|
-
}
|
|
767
|
-
let length = 1;
|
|
768
|
-
while (text[open + length] === '`') {
|
|
769
|
-
length += 1;
|
|
770
|
-
}
|
|
771
|
-
const close = closingBacktickRun(text, open + length, length, text.length);
|
|
772
|
-
if (close === -1) {
|
|
773
|
-
if (length < 3) {
|
|
774
|
-
cursor = open + length;
|
|
775
|
-
continue;
|
|
776
|
-
}
|
|
777
|
-
segments.push(text.slice(prose, open), text.slice(open));
|
|
778
|
-
return segments;
|
|
779
|
-
}
|
|
780
|
-
segments.push(text.slice(prose, open), text.slice(open, close + length));
|
|
781
|
-
prose = close + length;
|
|
782
|
-
cursor = prose;
|
|
783
|
-
}
|
|
784
|
-
segments.push(text.slice(prose));
|
|
785
|
-
return segments;
|
|
786
|
-
};
|
|
787
|
-
/**
|
|
788
|
-
* The spellings that differ, applied to prose only, in an order that matters.
|
|
789
|
-
*
|
|
790
|
-
* Single-asterisk emphasis goes first and is barred from crossing a `*`, so
|
|
791
|
-
* `**bold**` cannot be read as an italic run that happens to begin with one.
|
|
792
|
-
* Bold then rewrites what is left, and its output is never rescanned.
|
|
793
|
-
*
|
|
794
|
-
* Each delimiter has to hug its content — `3 * 4 * 5` is arithmetic, not
|
|
795
|
-
* emphasis — and none may span a line, so one stray marker cannot italicize
|
|
796
|
-
* the rest of a message.
|
|
797
|
-
*/
|
|
798
|
-
const MRKDWN_REWRITES = [
|
|
799
|
-
// Both at once, before either rule can take half of it and leave the
|
|
800
|
-
// other half as literal asterisks. Its output is why the italic rule
|
|
801
|
-
// below refuses content that already opens with an underscore.
|
|
802
|
-
[/\*\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*\*/g, '*_$1_*'],
|
|
803
|
-
[/(^|[^*])\*([^\s*_][^*\n]*[^\s*]|[^\s*_])\*(?!\*)/g, '$1_$2_'],
|
|
804
|
-
[/\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*/g, '*$1*'],
|
|
805
|
-
[/(^|[^_])__([^\s_][^_\n]*[^\s_]|[^\s_])__(?!_)/g, '$1*$2*'],
|
|
806
|
-
[/~~([^\s~][^~\n]*[^\s~]|[^\s~])~~/g, '~$1~'],
|
|
807
|
-
// The one construct Slack spells backwards. A scheme is required: a
|
|
808
|
-
// bare `[1](2)` in prose is not a link anybody meant to follow.
|
|
809
|
-
[/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^()\s]+)\)/g, '<$2|$1>'],
|
|
810
|
-
];
|
|
811
|
-
/**
|
|
812
|
-
* `## Heading`, which mrkdwn has no spelling for at all.
|
|
813
|
-
*
|
|
814
|
-
* The optional closing hashes have to be spaced off the text, as Markdown
|
|
815
|
-
* requires: without that, `# C#` is a heading whose name loses its last
|
|
816
|
-
* character.
|
|
817
|
-
*
|
|
818
|
-
* What they and any trailing spaces cover is captured rather than dropped,
|
|
819
|
-
* because dropping is only right when they are the heading's own syntax. A
|
|
820
|
-
* heading that opens a code span reaches its end inside one, and there the
|
|
821
|
-
* same characters are the snippet: `` # show `value # `` has a hash that
|
|
822
|
-
* belongs to whoever wrote the code.
|
|
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.
|
|
823
915
|
*/
|
|
824
|
-
const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)((?:[ \t]+#+)?[ \t]*)$/gm;
|
|
825
|
-
const convertInline = (segment) => {
|
|
826
|
-
let converted = segment;
|
|
827
|
-
for (const [pattern, replacement] of MRKDWN_REWRITES) {
|
|
828
|
-
converted = converted.replace(pattern, replacement);
|
|
829
|
-
}
|
|
830
|
-
return converted;
|
|
831
|
-
};
|
|
832
|
-
/**
|
|
833
|
-
* Headings, found on the text's own lines rather than inside each prose
|
|
834
|
-
* fragment.
|
|
835
|
-
*
|
|
836
|
-
* A fragment is not a line: inline code cuts one in two, and the halves
|
|
837
|
-
* begin wherever the code ended. Matched per fragment, `^` then lied in
|
|
838
|
-
* both directions — the tail of `` `status` # not a heading `` looked like a
|
|
839
|
-
* line of its own and lost its hash, and ``## Run `npm test` now`` was only
|
|
840
|
-
* ever half a line, so only the half before the code was wrapped.
|
|
841
|
-
*
|
|
842
|
-
* So the whole text is matched instead, with the code spans it now contains
|
|
843
|
-
* passed in: a `#` whose line begins inside one is a comment in somebody's
|
|
844
|
-
* snippet, not a heading. A heading that merely *contains* code is a
|
|
845
|
-
* heading, and its code goes along untouched.
|
|
846
|
-
*
|
|
847
|
-
* Emphasis inside is already converted by here, so the line is only wrapped
|
|
848
|
-
* when nothing in it can pair with the wrapper. Slack has one bold
|
|
849
|
-
* delimiter and no way to nest it: wrapping `*Run* the thing` again yields
|
|
850
|
-
* `**Run* the thing*`, which it renders as literal asterisks rather than as
|
|
851
|
-
* anything bold. A heading that already carries emphasis therefore keeps
|
|
852
|
-
* exactly the emphasis it has — `## **Title**` is bold by now and needs
|
|
853
|
-
* nothing more, and `# glob *.ts` keeps an asterisk that was never a marker
|
|
854
|
-
* at all. An unbolded heading reads fine; a heading full of stray asterisks
|
|
855
|
-
* does not, and one with characters removed to make room is worse than both.
|
|
856
|
-
*
|
|
857
|
-
* An asterisk inside a code span is not one of those: Slack does not read
|
|
858
|
-
* markup there, so it cannot pair with anything, and ``## Match `*.ts` files``
|
|
859
|
-
* is bolded like any other heading.
|
|
860
|
-
*
|
|
861
|
-
* Both walks are cursors rather than searches: `replace` reports matches in
|
|
862
|
-
* increasing offset, and the spans were collected in that order too, so
|
|
863
|
-
* neither has to be looked at twice. The same work as a scan per heading,
|
|
864
|
-
* without its quadratic on a reply that is mostly headings and snippets.
|
|
865
|
-
*/
|
|
866
|
-
/** What could pair with a heading's wrapper, or with something out of sight. */
|
|
867
|
-
const PAIRABLE_MARKER = /[*`]/g;
|
|
868
|
-
const convertHeadings = (text, code) => {
|
|
869
|
-
let span = 0;
|
|
870
|
-
const startsInside = (position, from) => position >= (code[from]?.[0] ?? Number.POSITIVE_INFINITY);
|
|
871
|
-
return text.replace(MARKDOWN_HEADING, (line, body, tail, offset) => {
|
|
872
|
-
while (span < code.length && (code[span]?.[1] ?? 0) <= offset) {
|
|
873
|
-
span += 1;
|
|
874
|
-
}
|
|
875
|
-
// A `#` whose line begins inside a span is a comment in somebody's code.
|
|
876
|
-
if (startsInside(offset, span)) {
|
|
877
|
-
return line;
|
|
878
|
-
}
|
|
879
|
-
const heading = body.trim();
|
|
880
|
-
if (heading.length === 0) {
|
|
881
|
-
return line;
|
|
882
|
-
}
|
|
883
|
-
// Where everything this would drop from the line's end begins: the
|
|
884
|
-
// closing hashes and spaces the pattern captured, and whatever else
|
|
885
|
-
// `trimEnd` would take with them. The two are not the same set — the
|
|
886
|
-
// pattern knows only spaces and tabs, while trimming also takes a
|
|
887
|
-
// no-break space, an ideographic space, and the rest of what
|
|
888
|
-
// ECMAScript counts as whitespace — and the difference is exactly
|
|
889
|
-
// where a character goes missing.
|
|
890
|
-
//
|
|
891
|
-
// Dropping any of it is the heading's own syntax being removed, but
|
|
892
|
-
// only while it is the heading's: a line that opens a span reaches its
|
|
893
|
-
// end inside one, and there the same characters are somebody's
|
|
894
|
-
// snippet. Nothing on this line is safe to strip then, so it is left
|
|
895
|
-
// exactly as written. (The front needs no such care: the body starts
|
|
896
|
-
// where the hashes stop, which this line has already been shown to
|
|
897
|
-
// reach in prose.)
|
|
898
|
-
const dropped = tail.length + (body.length - body.trimEnd().length);
|
|
899
|
-
if (dropped > 0) {
|
|
900
|
-
const droppedAt = offset + line.length - dropped;
|
|
901
|
-
let over = span;
|
|
902
|
-
while (over < code.length && (code[over]?.[1] ?? 0) <= droppedAt) {
|
|
903
|
-
over += 1;
|
|
904
|
-
}
|
|
905
|
-
if (startsInside(droppedAt, over)) {
|
|
906
|
-
return line;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
// Anything left on the line that a delimiter could pair with, walked
|
|
910
|
-
// from where the heading cursor already stands rather than from the
|
|
911
|
-
// beginning. An asterisk is the obvious one — it would pair with the
|
|
912
|
-
// wrapper. A backtick counts too: one this side found no partner for is
|
|
913
|
-
// a literal character here, but Slack parses the message itself and may
|
|
914
|
-
// pair it with a backtick further down, and the wrapper's closing `*`
|
|
915
|
-
// would then be written inside what Slack reads as code, where it is
|
|
916
|
-
// ignored — leaving the opening one with nothing to close it. Both are
|
|
917
|
-
// spent characters inside a span, which is why the ranges are consulted
|
|
918
|
-
// rather than the text alone.
|
|
919
|
-
let within = span;
|
|
920
|
-
for (const marker of line.matchAll(PAIRABLE_MARKER)) {
|
|
921
|
-
const position = offset + (marker.index ?? 0);
|
|
922
|
-
while (within < code.length && (code[within]?.[1] ?? 0) <= position) {
|
|
923
|
-
within += 1;
|
|
924
|
-
}
|
|
925
|
-
if (!startsInside(position, within)) {
|
|
926
|
-
return heading;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
// Both markers have to land in prose, not just the opening one. A fence
|
|
930
|
-
// opened on this line — by a model mid-answer, or by a block still being
|
|
931
|
-
// streamed — runs past its end, so the closing `*` would be written
|
|
932
|
-
// inside the code and ignored there, leaving the opening one unpaired.
|
|
933
|
-
// The line began in prose or this never ran, so it is the end that is
|
|
934
|
-
// in question: where the marker would go, one past the line's last
|
|
935
|
-
// character, and not that character itself — a span that closes exactly
|
|
936
|
-
// at the line's end leaves the marker just outside it, which is fine.
|
|
937
|
-
const closer = offset + line.length;
|
|
938
|
-
while (within < code.length && (code[within]?.[1] ?? 0) <= closer) {
|
|
939
|
-
within += 1;
|
|
940
|
-
}
|
|
941
|
-
return startsInside(closer, within) ? heading : `*${heading}*`;
|
|
942
|
-
});
|
|
943
|
-
};
|
|
944
|
-
/**
|
|
945
|
-
* A stand-in for one code segment while the prose around it is rewritten.
|
|
946
|
-
*
|
|
947
|
-
* Emphasis was converted one prose fragment at a time until this existed,
|
|
948
|
-
* and a fragment is not a run: inline code cuts one in two, so the `**`
|
|
949
|
-
* opening a bold run and the `**` closing it land in different fragments
|
|
950
|
-
* whenever a snippet sits between them, and neither half ever finds its
|
|
951
|
-
* partner. ``**the `fs.read` tool**`` reached a reader as four literal
|
|
952
|
-
* asterisks around a snippet, and so did every link, italic and
|
|
953
|
-
* strikethrough written the same way. It is the defect #142 found in
|
|
954
|
-
* headings, in the rule right next to it, and it has the same answer:
|
|
955
|
-
* rewrite the whole text, with code held out of the rewrite rather than
|
|
956
|
-
* cut out of the text.
|
|
957
|
-
*
|
|
958
|
-
* A mask names its segment rather than merely standing in for one, because
|
|
959
|
-
* a rewrite is allowed to move it: the link rule writes its two captures
|
|
960
|
-
* back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
|
|
961
|
-
* snippet to the label and the label's to the URL, and a snippet that
|
|
962
|
-
* arrives somewhere else is the same defect as one that is rewritten. Named,
|
|
963
|
-
* it comes back to the place holding its own name. No rewrite can damage
|
|
964
|
-
* the name itself: none of the delimiters they look for appear in it, and
|
|
965
|
-
* every rule copies what it matched through to its replacement.
|
|
966
|
-
*
|
|
967
|
-
* A mask also carries whether its segment holds whitespace, because three
|
|
968
|
-
* rules refuse to cross it — a destination is `[^()\s]+`, a label and an
|
|
969
|
-
* emphasis run stop at a newline — and a name alone has none to stop them.
|
|
970
|
-
* `[label](https://host/`a b`)` is not a destination and was never meant to
|
|
971
|
-
* convert; masked shapelessly it did, and came back rearranged into a link
|
|
972
|
-
* Slack cannot render. Only whitespace is carried, and only ever so a rule
|
|
973
|
-
* refuses: a `*` in a snippet must still not act like one. Both ends of a
|
|
974
|
-
* mask stay the mask character, so a run that has to hug its content still
|
|
975
|
-
* hugs it — a segment opens and closes with a backtick, so that is not a
|
|
976
|
-
* lie about its ends.
|
|
977
|
-
*
|
|
978
|
-
* The character is chosen against the text rather than fixed, because a
|
|
979
|
-
* reply that already contained it would have a snippet spliced in where
|
|
980
|
-
* the writer's own character stood — the one thing this conversion must
|
|
981
|
-
* never do. A reply holding all 6400 of the private-use characters has no
|
|
982
|
-
* mask to be given and is left in Markdown instead: prose that reads as it
|
|
983
|
-
* was written is a cosmetic loss, and a snippet spliced somewhere else is
|
|
984
|
-
* not.
|
|
985
|
-
*/
|
|
986
|
-
const maskFor = (text) => {
|
|
987
|
-
for (let point = 0xe000; point <= 0xf8ff; point += 1) {
|
|
988
|
-
const candidate = String.fromCharCode(point);
|
|
989
|
-
if (!text.includes(candidate)) {
|
|
990
|
-
return candidate;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return undefined;
|
|
994
|
-
};
|
|
995
|
-
/**
|
|
996
|
-
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
997
|
-
*
|
|
998
|
-
* Slack's mrkdwn is not Markdown, and the gap is not cosmetic: `**bold**`
|
|
999
|
-
* reaches a reader as four literal asterisks, and `## Heading` as a line
|
|
1000
|
-
* that starts with two hashes. Models write Markdown because that is what
|
|
1001
|
-
* every other surface here renders, and a soul should not have to know
|
|
1002
|
-
* which channel is carrying it, so the translation happens at the edge that
|
|
1003
|
-
* does know.
|
|
1004
|
-
*
|
|
1005
|
-
* Only what differs is touched. Lists, block quotes, and inline code
|
|
1006
|
-
* already mean in mrkdwn what they mean in Markdown; rewriting them would
|
|
1007
|
-
* add ways to be wrong and fix nothing.
|
|
1008
|
-
*/
|
|
1009
|
-
const toSlackMrkdwn = (text) => {
|
|
1010
|
-
const mask = maskFor(text);
|
|
1011
|
-
if (mask === undefined) {
|
|
1012
|
-
return text;
|
|
1013
|
-
}
|
|
1014
|
-
const segments = proseAndCode(text);
|
|
1015
|
-
const code = [];
|
|
1016
|
-
let masked = '';
|
|
1017
|
-
for (const [index, segment] of segments.entries()) {
|
|
1018
|
-
if (index % 2 === 1) {
|
|
1019
|
-
const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
|
|
1020
|
-
masked += `${mask}${code.length}${shape}${mask}`;
|
|
1021
|
-
code.push(segment);
|
|
1022
|
-
continue;
|
|
1023
|
-
}
|
|
1024
|
-
masked += segment;
|
|
1025
|
-
}
|
|
1026
|
-
// A code segment that ends the text is the unterminated fence: every
|
|
1027
|
-
// closed run is followed by a prose slice, even an empty one, so only an
|
|
1028
|
-
// unclosed one leaves the segments ending on a code index. Named, not
|
|
1029
|
-
// last: a rewrite may have moved it, and it is the segment that ran to
|
|
1030
|
-
// the text's end when it was cut, wherever it now sits.
|
|
1031
|
-
const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
|
|
1032
|
-
// Where each code segment ends up in the converted text, which is not
|
|
1033
|
-
// where it started: the prose around it changes length as it is
|
|
1034
|
-
// rewritten, and the masks are the only record of where it went. Split
|
|
1035
|
-
// on the names, so the pieces alternate prose and the name of the
|
|
1036
|
-
// segment that follows it.
|
|
1037
|
-
const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
|
|
1038
|
-
const spans = [];
|
|
1039
|
-
let converted = pieces[0] ?? '';
|
|
1040
|
-
for (let piece = 1; piece < pieces.length; piece += 2) {
|
|
1041
|
-
const named = Number(pieces[piece]);
|
|
1042
|
-
const segment = code[named] ?? '';
|
|
1043
|
-
// The unterminated fence has no end to be past, so nothing may be
|
|
1044
|
-
// appended after it either — recording the end as it looks would say a
|
|
1045
|
-
// marker placed at the text's end was safely outside.
|
|
1046
|
-
const ends = named === unterminated
|
|
1047
|
-
? Number.POSITIVE_INFINITY
|
|
1048
|
-
: converted.length + segment.length;
|
|
1049
|
-
spans.push([converted.length, ends]);
|
|
1050
|
-
converted += `${segment}${pieces[piece + 1] ?? ''}`;
|
|
1051
|
-
}
|
|
1052
|
-
return convertHeadings(converted, spans);
|
|
1053
|
-
};
|
|
1054
916
|
const splitForSlack = (text) => {
|
|
1055
917
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
1056
918
|
return [text];
|
|
@@ -1060,11 +922,49 @@ const splitForSlack = (text) => {
|
|
|
1060
922
|
while (rest.length > SLACK_MAX_MESSAGE_CHARS) {
|
|
1061
923
|
// Prefer a newline break inside the window; fall back to a hard cut.
|
|
1062
924
|
const window = rest.slice(0, SLACK_MAX_MESSAGE_CHARS);
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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/, '')}`;
|
|
1068
968
|
}
|
|
1069
969
|
if (rest.length > 0) {
|
|
1070
970
|
chunks.push(rest);
|
|
@@ -1211,15 +1111,13 @@ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed fi
|
|
|
1211
1111
|
* fix — the warning here names the scope instead, and the file falls back
|
|
1212
1112
|
* to the by-name note like any other attachment the turn cannot open.
|
|
1213
1113
|
*/
|
|
1214
|
-
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
|
|
1114
|
+
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, deadline = AbortSignal.timeout(timeoutMs)) => {
|
|
1215
1115
|
// Decided from the last file back, the way the replay window is spent:
|
|
1216
1116
|
// when a message's images do not all fit, the ones kept are the ones it
|
|
1217
1117
|
// listed last, so intake and replay agree about which survive. Delivered
|
|
1218
1118
|
// in the message's own order, because that is how the person sees them.
|
|
1219
1119
|
const kept = new Map();
|
|
1220
1120
|
const dropped = new Set();
|
|
1221
|
-
// The message's one deadline, shared by every download in it.
|
|
1222
|
-
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1223
1121
|
// Decoded bytes accepted so far. Checked against Slack's reported size
|
|
1224
1122
|
// before a download and the real length after it, because the message
|
|
1225
1123
|
// as a whole has a budget the per-image cap alone cannot keep.
|
|
@@ -1342,6 +1240,114 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
|
|
|
1342
1240
|
});
|
|
1343
1241
|
return { images, unread };
|
|
1344
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('');
|
|
1345
1351
|
/**
|
|
1346
1352
|
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
1347
1353
|
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
@@ -1457,7 +1463,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
1457
1463
|
* Session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>`
|
|
1458
1464
|
* (DMs: the DM channel id alone), so threads are resumable conversations
|
|
1459
1465
|
* and two agents sharing a thread keep fully separate sessions. Replies
|
|
1460
|
-
*
|
|
1466
|
+
* post once, finished, behind Slack's loading status — or, in the `stream`
|
|
1467
|
+
* reply mode, stream via placeholder-then-edit, throttled for chat.update
|
|
1468
|
+
* limits.
|
|
1461
1469
|
*
|
|
1462
1470
|
* A mention starts a thread conversation; inside one, the agent keeps
|
|
1463
1471
|
* listening without being tagged again — see `couldBeFollowUp` and
|
|
@@ -1485,6 +1493,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1485
1493
|
// to the FIRST registered renderer (the running turn), never a later
|
|
1486
1494
|
// message's placeholder. Each handler removes exactly its own renderer.
|
|
1487
1495
|
const renderers = new Map();
|
|
1496
|
+
/**
|
|
1497
|
+
* Per session, what the newest `final` reply has left to post. A `final`
|
|
1498
|
+
* turn holds no place in the thread until it posts, so each waits for
|
|
1499
|
+
* the one queued ahead of it — see `ReplyRenderer.after`.
|
|
1500
|
+
*/
|
|
1501
|
+
const replyOrder = new Map();
|
|
1488
1502
|
/**
|
|
1489
1503
|
* Files a turn nobody here rendered produced — a recovery's screenshot,
|
|
1490
1504
|
* say — keyed by session, waiting for the turn's outcome to be posted so
|
|
@@ -3014,20 +3028,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3014
3028
|
// nobody asked for, since they reach the model ahead of the frame
|
|
3015
3029
|
// that marks the message as somebody else's, and a judged message
|
|
3016
3030
|
// names its attachments the way an overheard one does.
|
|
3017
|
-
|
|
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
|
|
3018
3034
|
? { images: [], unread: event.files ?? [] }
|
|
3019
|
-
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
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) {
|
|
3024
3043
|
return undefined;
|
|
3025
3044
|
}
|
|
3026
3045
|
const author = await authorFor(connection, userId);
|
|
3027
3046
|
// In shared channels the model should know who is speaking; a DM is
|
|
3028
3047
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
3029
3048
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
3030
|
-
const userMessage = `${spoken}${attachmentNote(unread)}`;
|
|
3049
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread)}`;
|
|
3031
3050
|
// Who sent this, judged against the operator's list — per message,
|
|
3032
3051
|
// never remembered from the first one in the thread. A DM proves
|
|
3033
3052
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -3068,9 +3087,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3068
3087
|
};
|
|
3069
3088
|
}
|
|
3070
3089
|
// A turn nobody asked for opens its placeholder on its first text,
|
|
3071
|
-
// if it ever has any — see `ReplyRenderer.lazy`.
|
|
3072
|
-
|
|
3073
|
-
|
|
3090
|
+
// if it ever has any — see `ReplyRenderer.lazy`. One the agent's
|
|
3091
|
+
// reply mode does not stream opens none at all, and shows the
|
|
3092
|
+
// loading status instead — see `ReplyRenderer.streaming`.
|
|
3093
|
+
const streaming = connection.config.replies === 'stream';
|
|
3094
|
+
const ahead = streaming ? undefined : replyOrder.get(sessionId);
|
|
3095
|
+
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
|
|
3096
|
+
lazy: judged,
|
|
3097
|
+
now,
|
|
3098
|
+
streaming,
|
|
3099
|
+
statusThread: thread ?? event.ts,
|
|
3100
|
+
statusWarned: connection.statusWarned,
|
|
3101
|
+
...(ahead ? { after: ahead } : {}),
|
|
3102
|
+
});
|
|
3103
|
+
if (!streaming) {
|
|
3104
|
+
replyOrder.set(sessionId, renderer.posted);
|
|
3105
|
+
void renderer.posted.then(() => {
|
|
3106
|
+
if (replyOrder.get(sessionId) === renderer.posted) {
|
|
3107
|
+
replyOrder.delete(sessionId);
|
|
3108
|
+
}
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
if (!judged && streaming) {
|
|
3074
3112
|
try {
|
|
3075
3113
|
await renderer.open();
|
|
3076
3114
|
}
|
|
@@ -3085,6 +3123,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3085
3123
|
const queue = renderers.get(sessionId) ?? [];
|
|
3086
3124
|
queue.push(renderer);
|
|
3087
3125
|
renderers.set(sessionId, queue);
|
|
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);
|
|
3088
3132
|
const turn = gateway.dispatch({
|
|
3089
3133
|
sessionId,
|
|
3090
3134
|
agentId: connection.config.agentId,
|
|
@@ -3165,6 +3209,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3165
3209
|
// running, which the other may therefore still take.
|
|
3166
3210
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3167
3211
|
}
|
|
3212
|
+
// The reply just posted took the thread's status down with it, and a
|
|
3213
|
+
// turn queued behind this one is still working.
|
|
3214
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3168
3215
|
await heard;
|
|
3169
3216
|
}
|
|
3170
3217
|
else {
|
|
@@ -3173,6 +3220,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3173
3220
|
// A file it posted before breaking is still the last thing said.
|
|
3174
3221
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3175
3222
|
}
|
|
3223
|
+
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
3176
3224
|
}
|
|
3177
3225
|
};
|
|
3178
3226
|
return {
|
|
@@ -3189,7 +3237,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3189
3237
|
return;
|
|
3190
3238
|
}
|
|
3191
3239
|
if (event.type === 'tool.approval-resolved') {
|
|
3192
|
-
|
|
3240
|
+
// The question was posted into the turn's thread, which took the
|
|
3241
|
+
// loading status down with it; the turn works on once answered.
|
|
3242
|
+
track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
|
|
3193
3243
|
return;
|
|
3194
3244
|
}
|
|
3195
3245
|
// A turn ends here in one of two hands. One this adapter started
|
|
@@ -3260,12 +3310,18 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3260
3310
|
// connection — never leaves the queued reply stalled behind it.
|
|
3261
3311
|
if (event.type === 'session.failed' && !rendered) {
|
|
3262
3312
|
const claim = head?.reserveHandover();
|
|
3263
|
-
track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() =>
|
|
3313
|
+
track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
|
|
3314
|
+
claim?.release();
|
|
3315
|
+
head?.refreshLoading();
|
|
3316
|
+
}));
|
|
3264
3317
|
return;
|
|
3265
3318
|
}
|
|
3266
3319
|
if (event.type === 'session.completed' && !rendered) {
|
|
3267
3320
|
const claim = head?.reserveHandover();
|
|
3268
|
-
track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() =>
|
|
3321
|
+
track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
|
|
3322
|
+
claim?.release();
|
|
3323
|
+
head?.refreshLoading();
|
|
3324
|
+
}));
|
|
3269
3325
|
return;
|
|
3270
3326
|
}
|
|
3271
3327
|
if ('sessionId' in event) {
|
|
@@ -3300,6 +3356,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3300
3356
|
socket: createSocket(config.appToken),
|
|
3301
3357
|
botUserId,
|
|
3302
3358
|
teamId,
|
|
3359
|
+
statusWarned: { value: false },
|
|
3303
3360
|
});
|
|
3304
3361
|
}
|
|
3305
3362
|
catch (error) {
|