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:
Codex
2026-06-03 22:10:26 +09:00
parent f28399021a
commit c001905678
3 changed files with 75 additions and 4 deletions

View File

@@ -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 --- // --- ownsJid ---
describe('ownsJid', () => { describe('ownsJid', () => {

View File

@@ -948,7 +948,23 @@ export class DiscordChannel implements Channel {
// Dashboard callers opt out with rawMarkdown: true to preserve // Dashboard callers opt out with rawMarkdown: true to preserve
// intentional bold/italic section headers. // intentional bold/italic section headers.
const cleaned = options.rawMarkdown ? text : formatOutbound(text); 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); 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( logger.info(
{ {
jid, jid,

View File

@@ -596,15 +596,19 @@ export class MessageTurnController {
return; 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 ( if (
!this.progressMessageId || !this.progressMessageId ||
!this.options.channel.editMessage || !this.options.channel.editMessage ||
!this.latestProgressText !progressText
) { ) {
return; return;
} }
const rendered = this.renderProgressMessage(this.latestProgressText); const rendered = this.renderProgressMessage(progressText);
try { try {
await this.options.channel.editMessage( await this.options.channel.editMessage(
@@ -616,7 +620,7 @@ export class MessageTurnController {
this.progressEditFailCount = 0; this.progressEditFailCount = 0;
this.logOutboundAudit('progress-edit', { this.logOutboundAudit('progress-edit', {
messageId: this.progressMessageId, messageId: this.progressMessageId,
textLength: this.latestProgressText.length, textLength: progressText.length,
renderedLength: rendered.length, renderedLength: rendered.length,
}); });
} catch (err) { } catch (err) {