fix(discord): chunk long editMessage payloads and guard progress null race
- editMessage now splits >2000-char text: edits the tracked message with the first chunk and appends the rest as follow-up messages, instead of throwing Discord "Invalid Form Body … Must be 2000 or fewer" when promoting a long owner result over a progress message. - syncTrackedProgressMessage captures latestProgressText in a local before the editMessage await, fixing a race where a concurrent resetProgressState() nulled it and crashed with "null is not an object (this.latestProgressText.length)". - Add editMessage regression tests (in-place edit vs. chunked overflow). Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -939,6 +939,57 @@ describe('DiscordChannel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- editMessage ---
|
||||
|
||||
describe('editMessage', () => {
|
||||
it('edits in place when text fits in one message', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = { edit: vi.fn().mockResolvedValue(undefined) };
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
messages: { fetch: vi.fn().mockResolvedValue(msg) },
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.editMessage('dc:1234567890123456', 'msg_1', 'short text');
|
||||
|
||||
expect(msg.edit).toHaveBeenCalledTimes(1);
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('edits first chunk and appends the rest when text exceeds 2000 chars', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = { edit: vi.fn().mockResolvedValue(undefined) };
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
messages: { fetch: vi.fn().mockResolvedValue(msg) },
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
const longText = 'x'.repeat(3000);
|
||||
await channel.editMessage('dc:1234567890123456', 'msg_1', longText);
|
||||
|
||||
// First chunk goes into the existing (tracked) message via edit.
|
||||
expect(msg.edit).toHaveBeenCalledTimes(1);
|
||||
expect((msg.edit.mock.calls[0][0] as string).length).toBeLessThanOrEqual(
|
||||
2000,
|
||||
);
|
||||
// The overflow is appended as a follow-up message (one message cannot
|
||||
// hold >2000 chars), so the edit never sends an oversized body.
|
||||
expect(mockChannel.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: 'x'.repeat(1000),
|
||||
flags: 1 << 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
|
||||
@@ -948,7 +948,23 @@ export class DiscordChannel implements Channel {
|
||||
// Dashboard callers opt out with rawMarkdown: true to preserve
|
||||
// intentional bold/italic section headers.
|
||||
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
|
||||
// Discord rejects edits/messages longer than 2000 chars with
|
||||
// "Invalid Form Body … Must be 2000 or fewer". A single message cannot
|
||||
// hold the overflow, so edit the tracked message with the first chunk
|
||||
// and append the remaining chunks as follow-up messages.
|
||||
const MAX_LENGTH = 2000;
|
||||
if (cleaned.length <= MAX_LENGTH) {
|
||||
await msg.edit(cleaned);
|
||||
} else {
|
||||
const chunks = chunkForDiscord(cleaned, MAX_LENGTH);
|
||||
await msg.edit(chunks[0]);
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
await (channel as TextChannel).send({
|
||||
content: chunks[i],
|
||||
flags: MessageFlags.SuppressEmbeds,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
jid,
|
||||
|
||||
@@ -596,15 +596,19 @@ export class MessageTurnController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the value before the await: a concurrent resetProgressState()
|
||||
// can null `latestProgressText` while editMessage is in flight, which used
|
||||
// to throw "null is not an object (evaluating 'this.latestProgressText.length')".
|
||||
const progressText = this.latestProgressText;
|
||||
if (
|
||||
!this.progressMessageId ||
|
||||
!this.options.channel.editMessage ||
|
||||
!this.latestProgressText
|
||||
!progressText
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = this.renderProgressMessage(this.latestProgressText);
|
||||
const rendered = this.renderProgressMessage(progressText);
|
||||
|
||||
try {
|
||||
await this.options.channel.editMessage(
|
||||
@@ -616,7 +620,7 @@ export class MessageTurnController {
|
||||
this.progressEditFailCount = 0;
|
||||
this.logOutboundAudit('progress-edit', {
|
||||
messageId: this.progressMessageId,
|
||||
textLength: this.latestProgressText.length,
|
||||
textLength: progressText.length,
|
||||
renderedLength: rendered.length,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user