fix(discord): cap lines per message
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
- Gateway/CLI: include gateway target/source details in close/timeout errors and verbose health/status output.
|
- Gateway/CLI: include gateway target/source details in close/timeout errors and verbose health/status output.
|
||||||
- Gateway/CLI: honor `gateway.auth.password` for local CLI calls when env is unset. Thanks @jeffersonwarrior for PR #301.
|
- Gateway/CLI: honor `gateway.auth.password` for local CLI calls when env is unset. Thanks @jeffersonwarrior for PR #301.
|
||||||
- Discord: format slow listener logs in seconds to match shared duration style.
|
- Discord: format slow listener logs in seconds to match shared duration style.
|
||||||
|
- Discord: split tall replies by line count to avoid client clipping; add `discord.maxLinesPerMessage` + docs. Thanks @jdrhyne for PR #371.
|
||||||
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
|
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
|
||||||
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
|
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
|
||||||
- Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418)
|
- Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418)
|
||||||
|
|||||||
@@ -455,4 +455,5 @@ Thanks to all clawtributors:
|
|||||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
|
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
|
||||||
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="ManuelHettich" title="ManuelHettich"/></a>
|
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="ManuelHettich" title="ManuelHettich"/></a>
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||||
|
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="jdrhyne" title="jdrhyne"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Legend:
|
|||||||
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`.
|
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`.
|
||||||
- `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
- `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||||
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
|
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
|
||||||
|
- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||||
|
|
||||||
**Boundary semantics:**
|
**Boundary semantics:**
|
||||||
- `text_end`: stream blocks as soon as chunker emits; flush on each `text_end`.
|
- `text_end`: stream blocks as soon as chunker emits; flush on each `text_end`.
|
||||||
|
|||||||
@@ -689,6 +689,8 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
historyLimit: 20, // include last N guild messages as context
|
historyLimit: 20, // include last N guild messages as context
|
||||||
|
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
||||||
|
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
||||||
retry: { // outbound retry policy
|
retry: { // outbound retry policy
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
minDelayMs: 500,
|
minDelayMs: 500,
|
||||||
@@ -706,6 +708,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
|
Outbound text is chunked by `discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `slack` (socket mode)
|
### `slack` (socket mode)
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ Notes:
|
|||||||
|
|
||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit.
|
- Typing indicators sent best-effort; message chunking uses `discord.textChunkLimit` (default 2000) and splits tall replies by line count (`discord.maxLinesPerMessage`, default 17).
|
||||||
- File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB).
|
- File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@@ -244,6 +244,8 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
|
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||||
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||||
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ Groups:
|
|||||||
- Optional attachment ingestion via `imessage.includeAttachments`.
|
- Optional attachment ingestion via `imessage.includeAttachments`.
|
||||||
- Media cap via `imessage.mediaMaxMb`.
|
- Media cap via `imessage.mediaMaxMb`.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- Outbound text is chunked to `imessage.textChunkLimit` (default 4000).
|
||||||
|
- Media uploads are capped by `imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
Prefer `chat_id` for stable routing:
|
Prefer `chat_id` for stable routing:
|
||||||
- `chat_id:123` (preferred)
|
- `chat_id:123` (preferred)
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ Groups:
|
|||||||
- Replies always route back to the same number or group.
|
- Replies always route back to the same number or group.
|
||||||
|
|
||||||
## Media + limits
|
## Media + limits
|
||||||
|
- Outbound text is chunked to `signal.textChunkLimit` (default 4000).
|
||||||
- Attachments supported (base64 fetched from `signal-cli`).
|
- Attachments supported (base64 fetched from `signal-cli`).
|
||||||
- Default cap: `signal.mediaMaxMb`.
|
- Default media cap: `signal.mediaMaxMb` (default 8).
|
||||||
- Use `signal.ignoreAttachments` to skip downloading media.
|
- Use `signal.ignoreAttachments` to skip downloading media.
|
||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ Tokens can also be supplied via env vars:
|
|||||||
Ack reactions are controlled globally via `messages.ackReaction` +
|
Ack reactions are controlled globally via `messages.ackReaction` +
|
||||||
`messages.ackReactionScope`.
|
`messages.ackReactionScope`.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- Outbound text is chunked to `slack.textChunkLimit` (default 4000).
|
||||||
|
- Media uploads are capped by `slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
Slack supports optional threaded replies via tags:
|
Slack supports optional threaded replies via tags:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message.
|
- `[[reply_to_current]]` — reply to the triggering message.
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ Multi-account support: use `telegram.accounts` with per-account tokens and optio
|
|||||||
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
||||||
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- Outbound text is chunked to `telegram.textChunkLimit` (default 4000).
|
||||||
|
- Media downloads/uploads are capped by `telegram.mediaMaxMb` (default 5).
|
||||||
|
|
||||||
## Group activation modes
|
## Group activation modes
|
||||||
|
|
||||||
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior:
|
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior:
|
||||||
|
|||||||
@@ -126,9 +126,13 @@ Recommended for personal numbers:
|
|||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
- Tool gating: `whatsapp.actions.reactions` (default: enabled).
|
- Tool gating: `whatsapp.actions.reactions` (default: enabled).
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000).
|
||||||
|
- Media items are capped by `agent.mediaMaxMb` (default 5 MB).
|
||||||
|
|
||||||
## Outbound send (text + media)
|
## Outbound send (text + media)
|
||||||
- Uses active web listener; error if gateway not running.
|
- Uses active web listener; error if gateway not running.
|
||||||
- Text chunking: 4k max per message.
|
- Text chunking: 4k max per message (configurable via `whatsapp.textChunkLimit`).
|
||||||
- Media:
|
- Media:
|
||||||
- Image/video/audio/document supported.
|
- Image/video/audio/document supported.
|
||||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||||
|
|||||||
@@ -212,7 +212,11 @@ describe("config identity defaults", () => {
|
|||||||
routing: {},
|
routing: {},
|
||||||
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
|
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
|
||||||
telegram: { enabled: true, textChunkLimit: 3333 },
|
telegram: { enabled: true, textChunkLimit: 3333 },
|
||||||
discord: { enabled: true, textChunkLimit: 1999 },
|
discord: {
|
||||||
|
enabled: true,
|
||||||
|
textChunkLimit: 1999,
|
||||||
|
maxLinesPerMessage: 17,
|
||||||
|
},
|
||||||
signal: { enabled: true, textChunkLimit: 2222 },
|
signal: { enabled: true, textChunkLimit: 2222 },
|
||||||
imessage: { enabled: true, textChunkLimit: 1111 },
|
imessage: { enabled: true, textChunkLimit: 1111 },
|
||||||
},
|
},
|
||||||
@@ -229,6 +233,7 @@ describe("config identity defaults", () => {
|
|||||||
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
|
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
|
||||||
expect(cfg.telegram?.textChunkLimit).toBe(3333);
|
expect(cfg.telegram?.textChunkLimit).toBe(3333);
|
||||||
expect(cfg.discord?.textChunkLimit).toBe(1999);
|
expect(cfg.discord?.textChunkLimit).toBe(1999);
|
||||||
|
expect(cfg.discord?.maxLinesPerMessage).toBe(17);
|
||||||
expect(cfg.signal?.textChunkLimit).toBe(2222);
|
expect(cfg.signal?.textChunkLimit).toBe(2222);
|
||||||
expect(cfg.imessage?.textChunkLimit).toBe(1111);
|
expect(cfg.imessage?.textChunkLimit).toBe(1111);
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
|
"discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
|
||||||
"discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
"discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
||||||
"discord.retry.jitter": "Discord Retry Jitter",
|
"discord.retry.jitter": "Discord Retry Jitter",
|
||||||
|
"discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
||||||
"slack.dm.policy": "Slack DM Policy",
|
"slack.dm.policy": "Slack DM Policy",
|
||||||
"discord.token": "Discord Bot Token",
|
"discord.token": "Discord Bot Token",
|
||||||
"slack.botToken": "Slack Bot Token",
|
"slack.botToken": "Slack Bot Token",
|
||||||
@@ -193,6 +194,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Maximum retry delay cap in ms for Discord outbound calls.",
|
"Maximum retry delay cap in ms for Discord outbound calls.",
|
||||||
"discord.retry.jitter":
|
"discord.retry.jitter":
|
||||||
"Jitter factor (0-1) applied to Discord retry delays.",
|
"Jitter factor (0-1) applied to Discord retry delays.",
|
||||||
|
"discord.maxLinesPerMessage":
|
||||||
|
"Soft max line count per Discord message (default: 17).",
|
||||||
"slack.dm.policy":
|
"slack.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -411,6 +411,12 @@ export type DiscordAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 2000. */
|
/** Outbound text chunk size (chars). Default: 2000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/**
|
||||||
|
* Soft max line count per Discord message.
|
||||||
|
* Discord clients can clip/collapse very tall messages; splitting by lines
|
||||||
|
* keeps replies readable in-channel. Default: 17.
|
||||||
|
*/
|
||||||
|
maxLinesPerMessage?: number;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Retry policy for outbound Discord API calls. */
|
/** Retry policy for outbound Discord API calls. */
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ const DiscordAccountSchema = z.object({
|
|||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
retry: RetryConfigSchema,
|
retry: RetryConfigSchema,
|
||||||
|
|||||||
70
src/discord/chunk.test.ts
Normal file
70
src/discord/chunk.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
|
|
||||||
|
function countLines(text: string) {
|
||||||
|
return text.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBalancedFences(chunk: string) {
|
||||||
|
let open: { markerChar: string; markerLen: number } | null = null;
|
||||||
|
for (const line of chunk.split("\n")) {
|
||||||
|
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
const marker = match[2];
|
||||||
|
if (!open) {
|
||||||
|
open = { markerChar: marker[0], markerLen: marker.length };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (open.markerChar === marker[0] && marker.length >= open.markerLen) {
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return open === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chunkDiscordText", () => {
|
||||||
|
it("splits tall messages even when under 2000 chars", () => {
|
||||||
|
const text = Array.from({ length: 45 }, (_, i) => `line-${i + 1}`).join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
expect(text.length).toBeLessThan(2000);
|
||||||
|
|
||||||
|
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 20 });
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
expect(countLines(chunk)).toBeLessThanOrEqual(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fenced code blocks balanced across chunks", () => {
|
||||||
|
const body = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `console.log(${i});`,
|
||||||
|
).join("\n");
|
||||||
|
const text = `Here is code:\n\n\`\`\`js\n${body}\n\`\`\`\n\nDone.`;
|
||||||
|
|
||||||
|
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 10 });
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
expect(hasBalancedFences(chunk)).toBe(true);
|
||||||
|
expect(chunk.length).toBeLessThanOrEqual(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(chunks[0]).toContain("```js");
|
||||||
|
expect(chunks.at(-1)).toContain("Done.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves space for closing fences when chunking", () => {
|
||||||
|
const body = "a".repeat(120);
|
||||||
|
const text = `\`\`\`txt\n${body}\n\`\`\``;
|
||||||
|
|
||||||
|
const chunks = chunkDiscordText(text, { maxChars: 50, maxLines: 50 });
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
expect(chunk.length).toBeLessThanOrEqual(50);
|
||||||
|
expect(hasBalancedFences(chunk)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
191
src/discord/chunk.ts
Normal file
191
src/discord/chunk.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
export type ChunkDiscordTextOpts = {
|
||||||
|
/** Max characters per Discord message. Default: 2000. */
|
||||||
|
maxChars?: number;
|
||||||
|
/**
|
||||||
|
* Soft max line count per message. Default: 17.
|
||||||
|
*
|
||||||
|
* Discord clients can clip/collapse very tall messages in the UI; splitting
|
||||||
|
* by lines keeps long multi-paragraph replies readable.
|
||||||
|
*/
|
||||||
|
maxLines?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenFence = {
|
||||||
|
indent: string;
|
||||||
|
markerChar: string;
|
||||||
|
markerLen: number;
|
||||||
|
openLine: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_CHARS = 2000;
|
||||||
|
const DEFAULT_MAX_LINES = 17;
|
||||||
|
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
||||||
|
|
||||||
|
function countLines(text: string) {
|
||||||
|
if (!text) return 0;
|
||||||
|
return text.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFenceLine(line: string): OpenFence | null {
|
||||||
|
const match = line.match(FENCE_RE);
|
||||||
|
if (!match) return null;
|
||||||
|
const indent = match[1] ?? "";
|
||||||
|
const marker = match[2] ?? "";
|
||||||
|
return {
|
||||||
|
indent,
|
||||||
|
markerChar: marker[0] ?? "`",
|
||||||
|
markerLen: marker.length,
|
||||||
|
openLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFenceLine(openFence: OpenFence) {
|
||||||
|
return `${openFence.indent}${openFence.markerChar.repeat(
|
||||||
|
openFence.markerLen,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFenceIfNeeded(text: string, openFence: OpenFence | null) {
|
||||||
|
if (!openFence) return text;
|
||||||
|
const closeLine = closeFenceLine(openFence);
|
||||||
|
if (!text) return closeLine;
|
||||||
|
if (!text.endsWith("\n")) return `${text}\n${closeLine}`;
|
||||||
|
return `${text}${closeLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLongLine(
|
||||||
|
line: string,
|
||||||
|
maxChars: number,
|
||||||
|
opts: { preserveWhitespace: boolean },
|
||||||
|
): string[] {
|
||||||
|
const limit = Math.max(1, Math.floor(maxChars));
|
||||||
|
if (line.length <= limit) return [line];
|
||||||
|
const out: string[] = [];
|
||||||
|
let remaining = line;
|
||||||
|
while (remaining.length > limit) {
|
||||||
|
if (opts.preserveWhitespace) {
|
||||||
|
out.push(remaining.slice(0, limit));
|
||||||
|
remaining = remaining.slice(limit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const window = remaining.slice(0, limit);
|
||||||
|
let breakIdx = -1;
|
||||||
|
for (let i = window.length - 1; i >= 0; i--) {
|
||||||
|
if (/\s/.test(window[i])) {
|
||||||
|
breakIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (breakIdx <= 0) breakIdx = limit;
|
||||||
|
out.push(remaining.slice(0, breakIdx));
|
||||||
|
const brokeOnSeparator =
|
||||||
|
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||||
|
remaining = remaining.slice(breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||||
|
}
|
||||||
|
if (remaining.length) out.push(remaining);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunks outbound Discord text by both character count and (soft) line count,
|
||||||
|
* while keeping fenced code blocks balanced across chunks.
|
||||||
|
*/
|
||||||
|
export function chunkDiscordText(
|
||||||
|
text: string,
|
||||||
|
opts: ChunkDiscordTextOpts = {},
|
||||||
|
): string[] {
|
||||||
|
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS));
|
||||||
|
const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DEFAULT_MAX_LINES));
|
||||||
|
|
||||||
|
const body = text ?? "";
|
||||||
|
if (!body) return [];
|
||||||
|
|
||||||
|
const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines;
|
||||||
|
if (alreadyOk) return [body];
|
||||||
|
|
||||||
|
const lines = body.split("\n");
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
let current = "";
|
||||||
|
let currentLines = 0;
|
||||||
|
let openFence: OpenFence | null = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!current) return;
|
||||||
|
const payload = closeFenceIfNeeded(current, openFence);
|
||||||
|
if (payload.trim().length) chunks.push(payload);
|
||||||
|
current = "";
|
||||||
|
currentLines = 0;
|
||||||
|
if (openFence) {
|
||||||
|
current = openFence.openLine;
|
||||||
|
currentLines = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const originalLine of lines) {
|
||||||
|
const fenceInfo = parseFenceLine(originalLine);
|
||||||
|
const wasInsideFence = openFence !== null;
|
||||||
|
let nextOpenFence: OpenFence | null = openFence;
|
||||||
|
if (fenceInfo) {
|
||||||
|
if (!openFence) {
|
||||||
|
nextOpenFence = fenceInfo;
|
||||||
|
} else if (
|
||||||
|
openFence.markerChar === fenceInfo.markerChar &&
|
||||||
|
fenceInfo.markerLen >= openFence.markerLen
|
||||||
|
) {
|
||||||
|
nextOpenFence = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reserveChars = nextOpenFence
|
||||||
|
? closeFenceLine(nextOpenFence).length + 1
|
||||||
|
: 0;
|
||||||
|
const reserveLines = nextOpenFence ? 1 : 0;
|
||||||
|
const effectiveMaxChars = maxChars - reserveChars;
|
||||||
|
const effectiveMaxLines = maxLines - reserveLines;
|
||||||
|
const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
|
||||||
|
const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
|
||||||
|
const prefixLen = current.length > 0 ? current.length + 1 : 0;
|
||||||
|
const segmentLimit = Math.max(1, charLimit - prefixLen);
|
||||||
|
const segments = splitLongLine(originalLine, segmentLimit, {
|
||||||
|
preserveWhitespace: wasInsideFence,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
||||||
|
const segment = segments[segIndex];
|
||||||
|
const isLineContinuation = segIndex > 0;
|
||||||
|
const delimiter = isLineContinuation
|
||||||
|
? ""
|
||||||
|
: current.length > 0
|
||||||
|
? "\n"
|
||||||
|
: "";
|
||||||
|
const addition = `${delimiter}${segment}`;
|
||||||
|
const nextLen = current.length + addition.length;
|
||||||
|
const nextLines = currentLines + (isLineContinuation ? 0 : 1);
|
||||||
|
|
||||||
|
const wouldExceedChars = nextLen > charLimit;
|
||||||
|
const wouldExceedLines = nextLines > lineLimit;
|
||||||
|
|
||||||
|
if ((wouldExceedChars || wouldExceedLines) && current.length > 0) {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
current += addition;
|
||||||
|
if (!isLineContinuation) currentLines += 1;
|
||||||
|
} else {
|
||||||
|
current = segment;
|
||||||
|
currentLines = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openFence = nextOpenFence;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length) {
|
||||||
|
const payload = closeFenceIfNeeded(current, openFence);
|
||||||
|
if (payload.trim().length) chunks.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
@@ -17,10 +17,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
|||||||
import type { APIAttachment } from "discord-api-types/v10";
|
import type { APIAttachment } from "discord-api-types/v10";
|
||||||
import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10";
|
import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10";
|
||||||
|
|
||||||
import {
|
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
chunkMarkdownText,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
} from "../auto-reply/chunk.js";
|
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandText,
|
||||||
@@ -63,6 +60,7 @@ import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
import { fetchDiscordApplicationId } from "./probe.js";
|
import { fetchDiscordApplicationId } from "./probe.js";
|
||||||
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
@@ -1009,6 +1007,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
@@ -1485,7 +1484,8 @@ function createDiscordNativeCommand(params: {
|
|||||||
await deliverDiscordInteractionReply({
|
await deliverDiscordInteractionReply({
|
||||||
interaction,
|
interaction,
|
||||||
payload,
|
payload,
|
||||||
textLimit: resolveTextChunkLimit(cfg, "discord"),
|
textLimit: resolveTextChunkLimit(cfg, "discord", accountId),
|
||||||
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
preferFollowUp: didReply,
|
preferFollowUp: didReply,
|
||||||
});
|
});
|
||||||
didReply = true;
|
didReply = true;
|
||||||
@@ -1517,13 +1517,21 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
interaction: CommandInteraction;
|
interaction: CommandInteraction;
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
maxLinesPerMessage?: number;
|
||||||
preferFollowUp: boolean;
|
preferFollowUp: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { interaction, payload, textLimit, preferFollowUp } = params;
|
const {
|
||||||
|
interaction,
|
||||||
|
payload,
|
||||||
|
textLimit,
|
||||||
|
maxLinesPerMessage,
|
||||||
|
preferFollowUp,
|
||||||
|
} = params;
|
||||||
const mediaList =
|
const mediaList =
|
||||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
|
|
||||||
|
let hasReplied = false;
|
||||||
const sendMessage = async (
|
const sendMessage = async (
|
||||||
content: string,
|
content: string,
|
||||||
files?: { name: string; data: Buffer }[],
|
files?: { name: string; data: Buffer }[],
|
||||||
@@ -1541,11 +1549,13 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: { content };
|
: { content };
|
||||||
if (!preferFollowUp) {
|
if (!preferFollowUp && !hasReplied) {
|
||||||
await interaction.reply(payload);
|
await interaction.reply(payload);
|
||||||
|
hasReplied = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await interaction.followUp(payload);
|
await interaction.followUp(payload);
|
||||||
|
hasReplied = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
@@ -1558,21 +1568,26 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const caption = text.length > textLimit ? text.slice(0, textLimit) : text;
|
const chunks = chunkDiscordText(text, {
|
||||||
|
maxChars: textLimit,
|
||||||
|
maxLines: maxLinesPerMessage,
|
||||||
|
});
|
||||||
|
const caption = chunks[0] ?? "";
|
||||||
await sendMessage(caption, media);
|
await sendMessage(caption, media);
|
||||||
if (text.length > textLimit) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
const remaining = text.slice(textLimit).trim();
|
if (!chunk.trim()) continue;
|
||||||
if (remaining) {
|
|
||||||
for (const chunk of chunkMarkdownText(remaining, textLimit)) {
|
|
||||||
await interaction.followUp({ content: chunk });
|
await interaction.followUp({ content: chunk });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
for (const chunk of chunkMarkdownText(text, textLimit)) {
|
const chunks = chunkDiscordText(text, {
|
||||||
|
maxChars: textLimit,
|
||||||
|
maxLines: maxLinesPerMessage,
|
||||||
|
});
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (!chunk.trim()) continue;
|
||||||
await sendMessage(chunk);
|
await sendMessage(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1585,6 +1600,7 @@ async function deliverDiscordReply(params: {
|
|||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
maxLinesPerMessage?: number;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
}) {
|
}) {
|
||||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||||
@@ -1595,7 +1611,10 @@ async function deliverDiscordReply(params: {
|
|||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of chunkDiscordText(text, {
|
||||||
|
maxChars: chunkLimit,
|
||||||
|
maxLines: params.maxLinesPerMessage,
|
||||||
|
})) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageDiscord(params.target, trimmed, {
|
await sendMessageDiscord(params.target, trimmed, {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Routes,
|
Routes,
|
||||||
} from "discord-api-types/v10";
|
} from "discord-api-types/v10";
|
||||||
|
|
||||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { RetryConfig } from "../infra/retry.js";
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
} from "../polls.js";
|
} from "../polls.js";
|
||||||
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
|
||||||
const DISCORD_TEXT_LIMIT = 2000;
|
const DISCORD_TEXT_LIMIT = 2000;
|
||||||
@@ -425,6 +425,7 @@ async function sendDiscordText(
|
|||||||
text: string,
|
text: string,
|
||||||
replyTo: string | undefined,
|
replyTo: string | undefined,
|
||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
|
maxLinesPerMessage?: number,
|
||||||
) {
|
) {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
throw new Error("Message must be non-empty for Discord sends");
|
throw new Error("Message must be non-empty for Discord sends");
|
||||||
@@ -432,17 +433,20 @@ async function sendDiscordText(
|
|||||||
const messageReference = replyTo
|
const messageReference = replyTo
|
||||||
? { message_id: replyTo, fail_if_not_exists: false }
|
? { message_id: replyTo, fail_if_not_exists: false }
|
||||||
: undefined;
|
: undefined;
|
||||||
if (text.length <= DISCORD_TEXT_LIMIT) {
|
const chunks = chunkDiscordText(text, {
|
||||||
|
maxChars: DISCORD_TEXT_LIMIT,
|
||||||
|
maxLines: maxLinesPerMessage,
|
||||||
|
});
|
||||||
|
if (chunks.length === 1) {
|
||||||
const res = (await request(
|
const res = (await request(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: { content: text, message_reference: messageReference },
|
body: { content: chunks[0], message_reference: messageReference },
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"text",
|
"text",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT);
|
|
||||||
let last: { id: string; channel_id: string } | null = null;
|
let last: { id: string; channel_id: string } | null = null;
|
||||||
let isFirst = true;
|
let isFirst = true;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
@@ -471,10 +475,16 @@ async function sendDiscordMedia(
|
|||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
replyTo: string | undefined,
|
replyTo: string | undefined,
|
||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
|
maxLinesPerMessage?: number,
|
||||||
) {
|
) {
|
||||||
const media = await loadWebMedia(mediaUrl);
|
const media = await loadWebMedia(mediaUrl);
|
||||||
const caption =
|
const chunks = text
|
||||||
text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text;
|
? chunkDiscordText(text, {
|
||||||
|
maxChars: DISCORD_TEXT_LIMIT,
|
||||||
|
maxLines: maxLinesPerMessage,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const caption = chunks[0] ?? "";
|
||||||
const messageReference = replyTo
|
const messageReference = replyTo
|
||||||
? { message_id: replyTo, fail_if_not_exists: false }
|
? { message_id: replyTo, fail_if_not_exists: false }
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -494,11 +504,16 @@ async function sendDiscordMedia(
|
|||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"media",
|
"media",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
if (text.length > DISCORD_TEXT_LIMIT) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
|
if (!chunk.trim()) continue;
|
||||||
if (remaining) {
|
await sendDiscordText(
|
||||||
await sendDiscordText(rest, channelId, remaining, undefined, request);
|
rest,
|
||||||
}
|
channelId,
|
||||||
|
chunk,
|
||||||
|
undefined,
|
||||||
|
request,
|
||||||
|
maxLinesPerMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -534,6 +549,10 @@ export async function sendMessageDiscord(
|
|||||||
opts: DiscordSendOpts = {},
|
opts: DiscordSendOpts = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const accountInfo = resolveDiscordAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
@@ -549,6 +568,7 @@ export async function sendMessageDiscord(
|
|||||||
opts.mediaUrl,
|
opts.mediaUrl,
|
||||||
opts.replyTo,
|
opts.replyTo,
|
||||||
request,
|
request,
|
||||||
|
accountInfo.config.maxLinesPerMessage,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await sendDiscordText(
|
result = await sendDiscordText(
|
||||||
@@ -557,6 +577,7 @@ export async function sendMessageDiscord(
|
|||||||
text,
|
text,
|
||||||
opts.replyTo,
|
opts.replyTo,
|
||||||
request,
|
request,
|
||||||
|
accountInfo.config.maxLinesPerMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user