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

View File

@@ -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,

View File

@@ -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) {