fix(discord): apply effective maxLinesPerMessage in live replies (#40133)
Merged via squash. Prepared head SHA: 031d0325347abd11892fbd5f44328f6b3c043902 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js";
|
||||
|
||||
describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
it("prefers accounts.default.allowFrom over top-level for default account", () => {
|
||||
@@ -56,3 +56,62 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
expect(resolved.config.allowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordMaxLinesPerMessage", () => {
|
||||
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
|
||||
const resolved = resolveDiscordMaxLinesPerMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
maxLinesPerMessage: 120,
|
||||
accounts: {
|
||||
default: { token: "token-default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: {},
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved).toBe(120);
|
||||
});
|
||||
|
||||
it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => {
|
||||
const resolved = resolveDiscordMaxLinesPerMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
maxLinesPerMessage: 120,
|
||||
accounts: {
|
||||
default: { token: "token-default", maxLinesPerMessage: 80 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: { maxLinesPerMessage: 55 },
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved).toBe(55);
|
||||
});
|
||||
|
||||
it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => {
|
||||
const resolved = resolveDiscordMaxLinesPerMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
maxLinesPerMessage: 120,
|
||||
accounts: {
|
||||
work: { token: "token-work", maxLinesPerMessage: 80 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: {},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,20 @@ export function resolveDiscordAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiscordMaxLinesPerMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig?: DiscordAccountConfig | null;
|
||||
accountId?: string | null;
|
||||
}): number | undefined {
|
||||
if (typeof params.discordConfig?.maxLinesPerMessage === "number") {
|
||||
return params.discordConfig.maxLinesPerMessage;
|
||||
}
|
||||
return resolveDiscordAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).config.maxLinesPerMessage;
|
||||
}
|
||||
|
||||
export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] {
|
||||
return listDiscordAccountIds(cfg)
|
||||
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "../../security/dm-policy-shared.js";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
||||
import {
|
||||
createDiscordFormModal,
|
||||
@@ -1017,7 +1018,11 @@ async function dispatchDiscordComponentEvent(params: {
|
||||
replyToId,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId,
|
||||
}),
|
||||
tableMode,
|
||||
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -502,6 +502,38 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses root discord maxLinesPerMessage for preview finalization when runtime config omits it", async () => {
|
||||
const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n");
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: longReply });
|
||||
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
|
||||
});
|
||||
|
||||
const ctx = await createBaseContext({
|
||||
cfg: {
|
||||
messages: { ackReaction: "👀" },
|
||||
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
maxLinesPerMessage: 120,
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: { streamMode: "partial" },
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
expect(editMessageDiscord).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"preview-1",
|
||||
{ content: longReply },
|
||||
{ rest: {} },
|
||||
);
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payload delivery to Discord", async () => {
|
||||
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
|
||||
await processStreamOffDiscordMessage();
|
||||
|
||||
@@ -32,6 +32,7 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
||||
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
|
||||
import { createDiscordDraftStream } from "../draft-stream.js";
|
||||
@@ -426,6 +427,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
channel: "discord",
|
||||
accountId,
|
||||
});
|
||||
const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "discord", accountId);
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
@@ -484,7 +490,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
const formatted = convertMarkdownTables(text, tableMode);
|
||||
const chunks = chunkDiscordTextWithMode(formatted, {
|
||||
maxChars: draftMaxChars,
|
||||
maxLines: discordConfig?.maxLinesPerMessage,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && formatted) {
|
||||
@@ -687,7 +693,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
replyToId,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
maxLinesPerMessage,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
|
||||
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../config/types.discord.js";
|
||||
import * as pluginCommandsModule from "../../plugins/commands.js";
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
import {
|
||||
@@ -49,7 +50,7 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createCommand(cfg: OpenClawConfig) {
|
||||
function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) {
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
@@ -58,7 +59,7 @@ function createCommand(cfg: OpenClawConfig) {
|
||||
return createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
discordConfig: discordConfig ?? cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
@@ -79,10 +80,11 @@ function createDispatchSpy() {
|
||||
async function runGuildSlashCommand(params?: {
|
||||
userId?: string;
|
||||
mutateConfig?: (cfg: OpenClawConfig) => void;
|
||||
runtimeDiscordConfig?: DiscordAccountConfig;
|
||||
}) {
|
||||
const cfg = createConfig();
|
||||
params?.mutateConfig?.(cfg);
|
||||
const command = createCommand(cfg);
|
||||
const command = createCommand(cfg, params?.runtimeDiscordConfig);
|
||||
const interaction = createInteraction({ userId: params?.userId });
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
@@ -164,4 +166,41 @@ describe("Discord native slash commands with commands.allowFrom", () => {
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expectUnauthorizedReply(interaction);
|
||||
});
|
||||
|
||||
it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => {
|
||||
const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n");
|
||||
const { interaction } = await runGuildSlashCommand({
|
||||
mutateConfig: (cfg) => {
|
||||
cfg.channels = {
|
||||
...cfg.channels,
|
||||
discord: {
|
||||
...cfg.channels?.discord,
|
||||
maxLinesPerMessage: 120,
|
||||
},
|
||||
};
|
||||
},
|
||||
runtimeDiscordConfig: {
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"345678901234567890": {
|
||||
channels: {
|
||||
"234567890123456789": {
|
||||
allow: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock
|
||||
.calls[0]?.[0] as
|
||||
| Parameters<typeof dispatcherModule.dispatchReplyWithDispatcher>[0]
|
||||
| undefined;
|
||||
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
|
||||
|
||||
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply }));
|
||||
expect(interaction.followUp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { chunkItems } from "../../utils/chunk-items.js";
|
||||
import { withTimeout } from "../../utils/with-timeout.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import {
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
@@ -1571,7 +1572,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||
fallbackLimit: 2000,
|
||||
}),
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
|
||||
preferFollowUp,
|
||||
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||
});
|
||||
@@ -1706,7 +1707,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||
fallbackLimit: 2000,
|
||||
}),
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
|
||||
preferFollowUp: preferFollowUp || didReply,
|
||||
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||
});
|
||||
|
||||
@@ -256,6 +256,29 @@ describe("deliverDiscordReply", () => {
|
||||
expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789");
|
||||
});
|
||||
|
||||
it("passes maxLinesPerMessage and chunkMode through the fast path", async () => {
|
||||
const fakeRest = {} as import("@buape/carbon").RequestClient;
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }],
|
||||
target: "channel:789",
|
||||
token: "token",
|
||||
rest: fakeRest,
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
maxLinesPerMessage: 120,
|
||||
chunkMode: "newline",
|
||||
});
|
||||
|
||||
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
||||
expect(sendDiscordTextMock).toHaveBeenCalledTimes(1);
|
||||
const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0];
|
||||
const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? [];
|
||||
|
||||
expect(maxLinesPerMessageArg).toBe(120);
|
||||
expect(chunkModeArg).toBe("newline");
|
||||
});
|
||||
|
||||
it("falls back to sendMessageDiscord when rest is not provided", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "single chunk" }],
|
||||
|
||||
@@ -130,9 +130,11 @@ async function sendDiscordChunkWithFallback(params: {
|
||||
text: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
maxLinesPerMessage?: number;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
binding?: DiscordThreadBindingLookupRecord;
|
||||
chunkMode?: ChunkMode;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
/** Pre-resolved channel ID to bypass redundant resolution per chunk. */
|
||||
@@ -169,7 +171,18 @@ async function sendDiscordChunkWithFallback(params: {
|
||||
if (params.channelId && params.request && params.rest) {
|
||||
const { channelId, request, rest } = params;
|
||||
await sendWithRetry(
|
||||
() => sendDiscordText(rest, channelId, text, params.replyTo, request),
|
||||
() =>
|
||||
sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
params.replyTo,
|
||||
request,
|
||||
params.maxLinesPerMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
params.chunkMode,
|
||||
),
|
||||
params.retryConfig,
|
||||
);
|
||||
return;
|
||||
@@ -294,8 +307,10 @@ export async function deliverDiscordReply(params: {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
maxLinesPerMessage: params.maxLinesPerMessage,
|
||||
replyTo,
|
||||
binding,
|
||||
chunkMode: params.chunkMode,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
channelId,
|
||||
@@ -329,8 +344,10 @@ export async function deliverDiscordReply(params: {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
maxLinesPerMessage: params.maxLinesPerMessage,
|
||||
replyTo: resolveReplyTo(),
|
||||
binding,
|
||||
chunkMode: params.chunkMode,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
channelId,
|
||||
|
||||
Reference in New Issue
Block a user