Skip to content

Commit 64746c1

Browse files
rbuteraaltaywtf
andauthored
fix(discord): apply effective maxLinesPerMessage in live replies (#40133)
Merged via squash. Prepared head SHA: 031d032 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
1 parent 56f787e commit 64746c1

10 files changed

+207
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
3636
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
3737
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
3838
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
39+
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
3940

4041
## 2026.3.8
4142

src/discord/accounts.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { resolveDiscordAccount } from "./accounts.js";
2+
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js";
33

44
describe("resolveDiscordAccount allowFrom precedence", () => {
55
it("prefers accounts.default.allowFrom over top-level for default account", () => {
@@ -56,3 +56,62 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
5656
expect(resolved.config.allowFrom).toBeUndefined();
5757
});
5858
});
59+
60+
describe("resolveDiscordMaxLinesPerMessage", () => {
61+
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
62+
const resolved = resolveDiscordMaxLinesPerMessage({
63+
cfg: {
64+
channels: {
65+
discord: {
66+
maxLinesPerMessage: 120,
67+
accounts: {
68+
default: { token: "token-default" },
69+
},
70+
},
71+
},
72+
},
73+
discordConfig: {},
74+
accountId: "default",
75+
});
76+
77+
expect(resolved).toBe(120);
78+
});
79+
80+
it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => {
81+
const resolved = resolveDiscordMaxLinesPerMessage({
82+
cfg: {
83+
channels: {
84+
discord: {
85+
maxLinesPerMessage: 120,
86+
accounts: {
87+
default: { token: "token-default", maxLinesPerMessage: 80 },
88+
},
89+
},
90+
},
91+
},
92+
discordConfig: { maxLinesPerMessage: 55 },
93+
accountId: "default",
94+
});
95+
96+
expect(resolved).toBe(55);
97+
});
98+
99+
it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => {
100+
const resolved = resolveDiscordMaxLinesPerMessage({
101+
cfg: {
102+
channels: {
103+
discord: {
104+
maxLinesPerMessage: 120,
105+
accounts: {
106+
work: { token: "token-work", maxLinesPerMessage: 80 },
107+
},
108+
},
109+
},
110+
},
111+
discordConfig: {},
112+
accountId: "work",
113+
});
114+
115+
expect(resolved).toBe(80);
116+
});
117+
});

src/discord/accounts.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ export function resolveDiscordAccount(params: {
6868
};
6969
}
7070

71+
export function resolveDiscordMaxLinesPerMessage(params: {
72+
cfg: OpenClawConfig;
73+
discordConfig?: DiscordAccountConfig | null;
74+
accountId?: string | null;
75+
}): number | undefined {
76+
if (typeof params.discordConfig?.maxLinesPerMessage === "number") {
77+
return params.discordConfig.maxLinesPerMessage;
78+
}
79+
return resolveDiscordAccount({
80+
cfg: params.cfg,
81+
accountId: params.accountId,
82+
}).config.maxLinesPerMessage;
83+
}
84+
7185
export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] {
7286
return listDiscordAccountIds(cfg)
7387
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))

src/discord/monitor/agent-components.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
readStoreAllowFromForDmPolicy,
4444
resolvePinnedMainDmOwnerFromAllowlist,
4545
} from "../../security/dm-policy-shared.js";
46+
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
4647
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
4748
import {
4849
createDiscordFormModal,
@@ -1017,7 +1018,11 @@ async function dispatchDiscordComponentEvent(params: {
10171018
replyToId,
10181019
replyToMode,
10191020
textLimit,
1020-
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
1021+
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
1022+
cfg: ctx.cfg,
1023+
discordConfig: ctx.discordConfig,
1024+
accountId,
1025+
}),
10211026
tableMode,
10221027
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
10231028
mediaLocalRoots,

src/discord/monitor/message-handler.process.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,38 @@ describe("processDiscordMessage draft streaming", () => {
502502
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
503503
});
504504

505+
it("uses root discord maxLinesPerMessage for preview finalization when runtime config omits it", async () => {
506+
const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n");
507+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
508+
await params?.dispatcher.sendFinalReply({ text: longReply });
509+
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
510+
});
511+
512+
const ctx = await createBaseContext({
513+
cfg: {
514+
messages: { ackReaction: "👀" },
515+
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
516+
channels: {
517+
discord: {
518+
maxLinesPerMessage: 120,
519+
},
520+
},
521+
},
522+
discordConfig: { streamMode: "partial" },
523+
});
524+
525+
// oxlint-disable-next-line typescript/no-explicit-any
526+
await processDiscordMessage(ctx as any);
527+
528+
expect(editMessageDiscord).toHaveBeenCalledWith(
529+
"c1",
530+
"preview-1",
531+
{ content: longReply },
532+
{ rest: {} },
533+
);
534+
expect(deliverDiscordReply).not.toHaveBeenCalled();
535+
});
536+
505537
it("suppresses reasoning payload delivery to Discord", async () => {
506538
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
507539
await processStreamOffDiscordMessage();

src/discord/monitor/message-handler.process.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js";
3232
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
3333
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
3434
import { truncateUtf16Safe } from "../../utils.js";
35+
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
3536
import { chunkDiscordTextWithMode } from "../chunk.js";
3637
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
3738
import { createDiscordDraftStream } from "../draft-stream.js";
@@ -426,6 +427,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
426427
channel: "discord",
427428
accountId,
428429
});
430+
const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({
431+
cfg,
432+
discordConfig,
433+
accountId,
434+
});
429435
const chunkMode = resolveChunkMode(cfg, "discord", accountId);
430436

431437
const typingCallbacks = createTypingCallbacks({
@@ -484,7 +490,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
484490
const formatted = convertMarkdownTables(text, tableMode);
485491
const chunks = chunkDiscordTextWithMode(formatted, {
486492
maxChars: draftMaxChars,
487-
maxLines: discordConfig?.maxLinesPerMessage,
493+
maxLines: maxLinesPerMessage,
488494
chunkMode,
489495
});
490496
if (!chunks.length && formatted) {
@@ -687,7 +693,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
687693
replyToId,
688694
replyToMode,
689695
textLimit,
690-
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
696+
maxLinesPerMessage,
691697
tableMode,
692698
chunkMode,
693699
sessionKey: ctxPayload.SessionKey,

src/discord/monitor/native-command.commands-allowfrom.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
44
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
55
import type { OpenClawConfig } from "../../config/config.js";
6+
import type { DiscordAccountConfig } from "../../config/types.discord.js";
67
import * as pluginCommandsModule from "../../plugins/commands.js";
78
import { createDiscordNativeCommand } from "./native-command.js";
89
import {
@@ -49,7 +50,7 @@ function createConfig(): OpenClawConfig {
4950
} as OpenClawConfig;
5051
}
5152

52-
function createCommand(cfg: OpenClawConfig) {
53+
function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) {
5354
const commandSpec: NativeCommandSpec = {
5455
name: "status",
5556
description: "Status",
@@ -58,7 +59,7 @@ function createCommand(cfg: OpenClawConfig) {
5859
return createDiscordNativeCommand({
5960
command: commandSpec,
6061
cfg,
61-
discordConfig: cfg.channels?.discord ?? {},
62+
discordConfig: discordConfig ?? cfg.channels?.discord ?? {},
6263
accountId: "default",
6364
sessionPrefix: "discord:slash",
6465
ephemeralDefault: true,
@@ -79,10 +80,11 @@ function createDispatchSpy() {
7980
async function runGuildSlashCommand(params?: {
8081
userId?: string;
8182
mutateConfig?: (cfg: OpenClawConfig) => void;
83+
runtimeDiscordConfig?: DiscordAccountConfig;
8284
}) {
8385
const cfg = createConfig();
8486
params?.mutateConfig?.(cfg);
85-
const command = createCommand(cfg);
87+
const command = createCommand(cfg, params?.runtimeDiscordConfig);
8688
const interaction = createInteraction({ userId: params?.userId });
8789
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
8890
const dispatchSpy = createDispatchSpy();
@@ -164,4 +166,41 @@ describe("Discord native slash commands with commands.allowFrom", () => {
164166
expect(dispatchSpy).not.toHaveBeenCalled();
165167
expectUnauthorizedReply(interaction);
166168
});
169+
170+
it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => {
171+
const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n");
172+
const { interaction } = await runGuildSlashCommand({
173+
mutateConfig: (cfg) => {
174+
cfg.channels = {
175+
...cfg.channels,
176+
discord: {
177+
...cfg.channels?.discord,
178+
maxLinesPerMessage: 120,
179+
},
180+
};
181+
},
182+
runtimeDiscordConfig: {
183+
groupPolicy: "allowlist",
184+
guilds: {
185+
"345678901234567890": {
186+
channels: {
187+
"234567890123456789": {
188+
allow: true,
189+
requireMention: false,
190+
},
191+
},
192+
},
193+
},
194+
},
195+
});
196+
197+
const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock
198+
.calls[0]?.[0] as
199+
| Parameters<typeof dispatcherModule.dispatchReplyWithDispatcher>[0]
200+
| undefined;
201+
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
202+
203+
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply }));
204+
expect(interaction.followUp).not.toHaveBeenCalled();
205+
});
167206
});

src/discord/monitor/native-command.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
5656
import { chunkItems } from "../../utils/chunk-items.js";
5757
import { withTimeout } from "../../utils/with-timeout.js";
5858
import { loadWebMedia } from "../../web/media.js";
59+
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
5960
import { chunkDiscordTextWithMode } from "../chunk.js";
6061
import {
6162
isDiscordGroupAllowedByPolicy,
@@ -1571,7 +1572,7 @@ async function dispatchDiscordCommandInteraction(params: {
15711572
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
15721573
fallbackLimit: 2000,
15731574
}),
1574-
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
1575+
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
15751576
preferFollowUp,
15761577
chunkMode: resolveChunkMode(cfg, "discord", accountId),
15771578
});
@@ -1706,7 +1707,7 @@ async function dispatchDiscordCommandInteraction(params: {
17061707
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
17071708
fallbackLimit: 2000,
17081709
}),
1709-
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
1710+
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
17101711
preferFollowUp: preferFollowUp || didReply,
17111712
chunkMode: resolveChunkMode(cfg, "discord", accountId),
17121713
});

src/discord/monitor/reply-delivery.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,29 @@ describe("deliverDiscordReply", () => {
256256
expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789");
257257
});
258258

259+
it("passes maxLinesPerMessage and chunkMode through the fast path", async () => {
260+
const fakeRest = {} as import("@buape/carbon").RequestClient;
261+
262+
await deliverDiscordReply({
263+
replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }],
264+
target: "channel:789",
265+
token: "token",
266+
rest: fakeRest,
267+
runtime,
268+
textLimit: 2000,
269+
maxLinesPerMessage: 120,
270+
chunkMode: "newline",
271+
});
272+
273+
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
274+
expect(sendDiscordTextMock).toHaveBeenCalledTimes(1);
275+
const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0];
276+
const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? [];
277+
278+
expect(maxLinesPerMessageArg).toBe(120);
279+
expect(chunkModeArg).toBe("newline");
280+
});
281+
259282
it("falls back to sendMessageDiscord when rest is not provided", async () => {
260283
await deliverDiscordReply({
261284
replies: [{ text: "single chunk" }],

src/discord/monitor/reply-delivery.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,11 @@ async function sendDiscordChunkWithFallback(params: {
130130
text: string;
131131
token: string;
132132
accountId?: string;
133+
maxLinesPerMessage?: number;
133134
rest?: RequestClient;
134135
replyTo?: string;
135136
binding?: DiscordThreadBindingLookupRecord;
137+
chunkMode?: ChunkMode;
136138
username?: string;
137139
avatarUrl?: string;
138140
/** Pre-resolved channel ID to bypass redundant resolution per chunk. */
@@ -169,7 +171,18 @@ async function sendDiscordChunkWithFallback(params: {
169171
if (params.channelId && params.request && params.rest) {
170172
const { channelId, request, rest } = params;
171173
await sendWithRetry(
172-
() => sendDiscordText(rest, channelId, text, params.replyTo, request),
174+
() =>
175+
sendDiscordText(
176+
rest,
177+
channelId,
178+
text,
179+
params.replyTo,
180+
request,
181+
params.maxLinesPerMessage,
182+
undefined,
183+
undefined,
184+
params.chunkMode,
185+
),
173186
params.retryConfig,
174187
);
175188
return;
@@ -294,8 +307,10 @@ export async function deliverDiscordReply(params: {
294307
token: params.token,
295308
rest: params.rest,
296309
accountId: params.accountId,
310+
maxLinesPerMessage: params.maxLinesPerMessage,
297311
replyTo,
298312
binding,
313+
chunkMode: params.chunkMode,
299314
username: persona.username,
300315
avatarUrl: persona.avatarUrl,
301316
channelId,
@@ -329,8 +344,10 @@ export async function deliverDiscordReply(params: {
329344
token: params.token,
330345
rest: params.rest,
331346
accountId: params.accountId,
347+
maxLinesPerMessage: params.maxLinesPerMessage,
332348
replyTo: resolveReplyTo(),
333349
binding,
350+
chunkMode: params.chunkMode,
334351
username: persona.username,
335352
avatarUrl: persona.avatarUrl,
336353
channelId,

0 commit comments

Comments
 (0)