claude-slack-channel-bots 0.18.3 → 0.18.4
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/docs/bots.md +2 -0
- package/docs/troubleshooting.md +3 -0
- package/package.json +1 -1
- package/skills/debug-slack-channel-bots/SKILL.md +7 -1
- package/src/delivery-intake.ts +5 -2
- package/src/server.ts +2 -0
- package/src/tag-wrapper.ts +15 -10
package/docs/bots.md
CHANGED
|
@@ -141,6 +141,8 @@ A message from Slack carries these tag attributes:
|
|
|
141
141
|
| `thread_ts` | The thread's timestamp, for a thread reply. |
|
|
142
142
|
| `attachment_count`, `attachments` | The number of attached files and each file's name, type and size, when files are attached. |
|
|
143
143
|
|
|
144
|
+
A long `attachments` or `user` value can be cut short, ending in `…`; on a macOS host this happens sooner, and a long `channel_name` can be cut too. `chat_id` and `attachment_count` are always whole.
|
|
145
|
+
|
|
144
146
|
Each way a message reaches a persona:
|
|
145
147
|
|
|
146
148
|
| Kind | How it reaches the persona | Tag attributes | `via` | Reminder |
|
package/docs/troubleshooting.md
CHANGED
|
@@ -74,6 +74,9 @@ Each start logs one `[slack] startupSessionManager: complete — …` line count
|
|
|
74
74
|
**Delivery notices or delivery diagnostic lines**
|
|
75
75
|
For *Message not saved*, check the server disk; for *Delivery stuck* or *Message not taken in*, follow the notice's remedy; for *Messages waiting*, resolve the persona's state; for *Queue record unreadable*, preserve its file and ask the senders to resend. Accepted messages stay queued in order, including when [`message-partial-receipt`](../skills/debug-slack-channel-bots/SKILL.md#message-partial-receipt) reports cut-off text. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-delivery-notices-or-delivery-diagnostic-lines).
|
|
76
76
|
|
|
77
|
+
**A persona on a Mac keeps retyping a long message**
|
|
78
|
+
On a macOS host CSCB types a long message in parts of at most about 880 bytes each. A message queued before an upgrade can keep larger parts, and a restart does not split it again: CSCB keeps offering it until the bot takes it in, while later messages wait. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-a-persona-on-a-mac-keeps-retyping-a-long-message).
|
|
79
|
+
|
|
77
80
|
**A persona posts a *Waiting on a prompt* or *Not connected* notice**
|
|
78
81
|
The session runs but is disconnected; accepted messages are kept until it reconnects. A human attaches with `tmux attach -t =slack_bot_<key>`, deals with anything on screen and, if it stays disconnected, types `/mcp reconnect slack-channel-router`, or restarts the server. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-a-persona-posts-a-waiting-on-a-prompt-or-not-connected-notice).
|
|
79
82
|
|
package/package.json
CHANGED
|
@@ -498,7 +498,7 @@ no recovery outcome. See [Message saved after retry](#message-saved-after-retry)
|
|
|
498
498
|
- **Line:** `[slack] message-retyped: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" retype_count=<n>`
|
|
499
499
|
- **Meaning:** CSCB offered the queued message again; it may reach the bot more than once.
|
|
500
500
|
- **Cause:** The transcript has not confirmed the earlier copy.
|
|
501
|
-
- **Fix:** Check **Message not taken in** and **Delivery stuck** below if it persists. Retries continue indefinitely; later messages wait behind it.
|
|
501
|
+
- **Fix:** Check **Message not taken in** and **Delivery stuck** below if it persists. Retries continue indefinitely; later messages wait behind it. For a long message on a Mac host, see [A persona on a Mac keeps retyping a long message](#symptom-a-persona-on-a-mac-keeps-retyping-a-long-message).
|
|
502
502
|
|
|
503
503
|
### `message-not-taken-in`
|
|
504
504
|
|
|
@@ -7250,6 +7250,12 @@ and [Persona diagnostic classes](#persona-diagnostic-classes). Storage failures
|
|
|
7250
7250
|
a disk check; stuck delivery needs the notice's remedy; waiting messages need the
|
|
7251
7251
|
persona's hold or down state resolved. Accepted items remain queued in order.
|
|
7252
7252
|
|
|
7253
|
+
### Symptom: A persona on a Mac keeps retyping a long message
|
|
7254
|
+
|
|
7255
|
+
Claude Code on macOS loses typed input sent as one large burst, so on a macOS host CSCB types a long message as several parts of at most about 880 bytes each, tag included; on other hosts a part can reach about 1,900 bytes. On a Mac, long attachment lists, sender names and channel names in the tag are shortened sooner, each ending in `…`; the channel ID and the attachment count are kept whole.
|
|
7256
|
+
|
|
7257
|
+
CSCB sets a message's parts once, shortly after it is queued, and never changes them; restarting the bot or the server does not split it again. A message queued before an upgrade can therefore keep its larger parts, go on logging `message-retyped` and raise a *Message not taken in* or *Delivery stuck* notice while later messages wait behind it. CSCB keeps offering it until the bot takes it in. [Delivery notices](#delivery-notices) explains each notice and how to attach to the bot's terminal to see what it received.
|
|
7258
|
+
|
|
7253
7259
|
### Symptom: A persona posts a *Waiting on a prompt* or *Not connected* notice
|
|
7254
7260
|
|
|
7255
7261
|
The persona's session runs but is disconnected. Messages sent to it are kept and delivered once it reconnects. What each notice means and what to do: the notice table under [A persona's instance runs but isn't connected](#a-personas-instance-runs-but-isnt-connected).
|
package/src/delivery-intake.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Post-accept immutable fixation shared by fresh and recovered work. */
|
|
2
2
|
import type { WebClient } from '@slack/web-api'
|
|
3
3
|
import type { DeliveryQueue, QueueAccept, QueueItem, QueueEvent } from './delivery-queue.ts'
|
|
4
|
-
import { buildTagParts, NAME_LOOKUP_TIMEOUT_MS, type TagMessage } from './tag-wrapper.ts'
|
|
4
|
+
import { buildTagParts, NAME_LOOKUP_TIMEOUT_MS, TAG_MAX_BYTES, type TagMessage } from './tag-wrapper.ts'
|
|
5
5
|
import { sanitizeFilename } from './lib.ts'
|
|
6
6
|
import { RETYPE_BACKOFF_MS } from './delivery-constants.ts'
|
|
7
7
|
export interface DeliveryPreparationClock {
|
|
@@ -20,6 +20,8 @@ export interface DeliveryPreparationDeps {
|
|
|
20
20
|
/** Called only from post-ack/answer preparation or startup recovery. */
|
|
21
21
|
onReceived?(key: string, item: QueueItem): void
|
|
22
22
|
lookupTimeoutMs?: number
|
|
23
|
+
/** Byte limit for each fixed part; production binds the host's `tagMaxBytesFor`. */
|
|
24
|
+
tagMaxBytes?: number
|
|
23
25
|
clock?: DeliveryPreparationClock
|
|
24
26
|
retryBackoffMs?: readonly number[]
|
|
25
27
|
}
|
|
@@ -40,6 +42,7 @@ export function createDeliveryPreparation(deps: DeliveryPreparationDeps): Delive
|
|
|
40
42
|
const fixedParts = new Map<string, string[]>()
|
|
41
43
|
let stopped = false
|
|
42
44
|
const timeout = deps.lookupTimeoutMs ?? NAME_LOOKUP_TIMEOUT_MS
|
|
45
|
+
const tagMaxBytes = deps.tagMaxBytes ?? TAG_MAX_BYTES
|
|
43
46
|
const clock: DeliveryPreparationClock = deps.clock ?? {
|
|
44
47
|
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
|
|
45
48
|
clearTimeout: handle => globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>),
|
|
@@ -91,7 +94,7 @@ export function createDeliveryPreparation(deps: DeliveryPreparationDeps): Delive
|
|
|
91
94
|
message.attachments = a.files.map(f => `${sanitizeFilename(f.name || 'unnamed')} (${f.mimetype || 'unknown'}, ${f.size || '?'} bytes)`).join('; ')
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
|
-
parts = buildTagParts(message)
|
|
97
|
+
parts = buildTagParts(message, tagMaxBytes)
|
|
95
98
|
fixedParts.set(id, parts)
|
|
96
99
|
}
|
|
97
100
|
await deps.queue.append(key, { v: 1, kind: 'fixed', seq: a.seq, parts })
|
package/src/server.ts
CHANGED
|
@@ -208,6 +208,7 @@ import { createPersonaRouting } from './persona-routing.ts'
|
|
|
208
208
|
import { createDeliveryQueue, type ReplyReminderQueue, type QueueEvent } from './delivery-queue.ts'
|
|
209
209
|
import { createReplyReminders, type ReplyReminders } from './reply-reminder.ts'
|
|
210
210
|
import { createDeliveryPreparation, type DeliveryPreparation } from './delivery-intake.ts'
|
|
211
|
+
import { tagMaxBytesFor } from './tag-wrapper.ts'
|
|
211
212
|
import {
|
|
212
213
|
createPersonaConnectionManager,
|
|
213
214
|
SYSTEM_PERSONA_CONNECTION_CLOCK,
|
|
@@ -2717,6 +2718,7 @@ export async function main(): Promise<void> {
|
|
|
2717
2718
|
clientFor,
|
|
2718
2719
|
wake: key => deliveryQueueWake.wake(key),
|
|
2719
2720
|
onPending: key => deliveryEngines?.wake(key),
|
|
2721
|
+
tagMaxBytes: tagMaxBytesFor(process.platform),
|
|
2720
2722
|
})
|
|
2721
2723
|
|
|
2722
2724
|
// the retired-key record (retired-keys.json in the
|
package/src/tag-wrapper.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Deterministic typed tags, with no I/O or module state. */
|
|
2
2
|
export const TAG_MAX_BYTES = 1900
|
|
3
|
+
/** Claude Code on macOS loses a typed burst over about 900 bytes, wrapper included. */
|
|
4
|
+
export const TAG_MAX_BYTES_MACOS = 880
|
|
3
5
|
export const TAG_MIN_BODY_BYTES = 400
|
|
4
6
|
export const NAME_LOOKUP_TIMEOUT_MS = 2000
|
|
5
7
|
export const TAG_SOURCE = 'slack-channel-router'
|
|
@@ -44,6 +46,8 @@ export function escapeBody(value: string): string {
|
|
|
44
46
|
return out.replace(/[\u0301\u0308\u2028]/g, c => `&#${c.codePointAt(0)};`)
|
|
45
47
|
}
|
|
46
48
|
export function tagByteLength(value: string): number { return new TextEncoder().encode(value).byteLength }
|
|
49
|
+
/** The typed part limit for the host the agent runs on, which is the server's own host. */
|
|
50
|
+
export function tagMaxBytesFor(platform: string): number { return platform === 'darwin' ? TAG_MAX_BYTES_MACOS : TAG_MAX_BYTES }
|
|
47
51
|
function render(attrs: TagAttributes, body: string, part?: string): string {
|
|
48
52
|
const values = { ...attrs, part }
|
|
49
53
|
const attributes = TAG_ATTRIBUTE_ORDER.flatMap(key => values[key] === undefined || values[key] === '' ? [] : [`${key}="${escapeAttr(values[key])}"`])
|
|
@@ -51,10 +55,11 @@ function render(attrs: TagAttributes, body: string, part?: string): string {
|
|
|
51
55
|
}
|
|
52
56
|
/** Unsized reference formatter, useful for wrapper.py parity. */
|
|
53
57
|
export function buildWrapper(message: TagMessage): string { return render(message, escapeBody(message.text ?? ''), message.part) }
|
|
54
|
-
function boundedAttrs(input: TagAttributes, part?: string): TagAttributes {
|
|
58
|
+
function boundedAttrs(input: TagAttributes, maxBytes: number, part?: string): TagAttributes {
|
|
55
59
|
const attrs = { ...input }
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const allowance = maxBytes - TAG_MIN_BODY_BYTES
|
|
61
|
+
for (const key of ['attachments', 'user', 'channel_name'] as const) {
|
|
62
|
+
if (tagByteLength(render(attrs, '', part)) <= allowance) break
|
|
58
63
|
const original = attrs[key]
|
|
59
64
|
if (original === undefined || original === '') continue
|
|
60
65
|
const chars = Array.from(replaceLoneSurrogates(original))
|
|
@@ -62,12 +67,12 @@ function boundedAttrs(input: TagAttributes, part?: string): TagAttributes {
|
|
|
62
67
|
while (low < high) {
|
|
63
68
|
const mid = Math.ceil((low + high) / 2)
|
|
64
69
|
attrs[key] = chars.slice(0, mid).join('') + '…'
|
|
65
|
-
if (tagByteLength(render(attrs, '', part)) <=
|
|
70
|
+
if (tagByteLength(render(attrs, '', part)) <= allowance) low = mid
|
|
66
71
|
else high = mid - 1
|
|
67
72
|
}
|
|
68
73
|
attrs[key] = chars.slice(0, low).join('') + '…'
|
|
69
74
|
}
|
|
70
|
-
if (tagByteLength(render(attrs, '', part)) >
|
|
75
|
+
if (tagByteLength(render(attrs, '', part)) > allowance) throw new Error('Tag attributes exceed the minimum body allowance')
|
|
71
76
|
return attrs
|
|
72
77
|
}
|
|
73
78
|
function cutBody(body: string, budget: number): [string, string] {
|
|
@@ -82,15 +87,15 @@ function cutBody(body: string, budget: number): [string, string] {
|
|
|
82
87
|
return [body.slice(0, cut), body.slice(cut)]
|
|
83
88
|
}
|
|
84
89
|
/** Escape once, then split losslessly, accounting for the widest part index. */
|
|
85
|
-
export function buildTagParts(message: TagMessage): string[] {
|
|
90
|
+
export function buildTagParts(message: TagMessage, maxBytes: number = TAG_MAX_BYTES): string[] {
|
|
86
91
|
const body = escapeBody(message.text ?? '')
|
|
87
|
-
const singleAttrs = boundedAttrs(message)
|
|
88
|
-
if (tagByteLength(render(singleAttrs, body)) <=
|
|
92
|
+
const singleAttrs = boundedAttrs(message, maxBytes)
|
|
93
|
+
if (tagByteLength(render(singleAttrs, body)) <= maxBytes) return [render(singleAttrs, body)]
|
|
89
94
|
let count = 2
|
|
90
95
|
for (;;) {
|
|
91
96
|
const widest = `${count}/${count}`
|
|
92
|
-
const attrs = boundedAttrs(message, widest)
|
|
93
|
-
const budget =
|
|
97
|
+
const attrs = boundedAttrs(message, maxBytes, widest)
|
|
98
|
+
const budget = maxBytes - tagByteLength(render(attrs, '', widest))
|
|
94
99
|
const bodies: string[] = []
|
|
95
100
|
let remaining = body
|
|
96
101
|
do { const [part, rest] = cutBody(remaining, budget); bodies.push(part); remaining = rest } while (remaining !== '')
|