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 ---
|
// --- ownsJid ---
|
||||||
|
|
||||||
describe('ownsJid', () => {
|
describe('ownsJid', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user