Chunk long final answers in Discord editMessage instead of failing

A final answer over 2000 chars edited straight into the tracked progress
message was rejected by Discord (content[BASE_TYPE_MAX_LENGTH]), leaving a
failed edit + stale progress stub before the send fallback. editMessage
now edits the tracked message with the first 2000-char chunk and sends the
remainder as follow-up messages, so long finals land cleanly in order.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-25 17:55:57 +09:00
parent 89d68927c6
commit 80df025672
2 changed files with 129 additions and 1 deletions

View File

@@ -1050,6 +1050,57 @@ describe('sendMessage', () => {
}); });
}); });
// --- editMessage ---
describe('editMessage', () => {
it('edits in place when text is within the 2000 char limit', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockMsg = { edit: vi.fn().mockResolvedValue(undefined) };
const mockChannel = {
messages: { fetch: vi.fn().mockResolvedValue(mockMsg) },
send: vi.fn().mockResolvedValue(undefined),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.editMessage('dc:1234567890123456', 'msg-1', 'short final');
expect(mockMsg.edit).toHaveBeenCalledWith('short final');
expect(mockChannel.send).not.toHaveBeenCalled();
});
it('edits the first chunk and sends the remainder when text exceeds 2000 chars', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockMsg = { edit: vi.fn().mockResolvedValue(undefined) };
const mockChannel = {
messages: { fetch: vi.fn().mockResolvedValue(mockMsg) },
send: vi.fn().mockResolvedValue(undefined),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
const longText = 'y'.repeat(4500);
await channel.editMessage('dc:1234567890123456', 'msg-1', longText);
// First 2000 chars replace the tracked message, remainder as follow-ups.
expect(mockMsg.edit).toHaveBeenCalledTimes(1);
expect(mockMsg.edit).toHaveBeenCalledWith('y'.repeat(2000));
expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
content: 'y'.repeat(2000),
flags: 1 << 2,
});
expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
content: 'y'.repeat(500),
flags: 1 << 2,
});
});
});
// --- ownsJid --- // --- ownsJid ---
describe('ownsJid', () => { describe('ownsJid', () => {
@@ -1159,4 +1210,50 @@ describe('channel properties', () => {
const channel = new DiscordChannel('test-token', createTestOpts()); const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.name).toBe('discord'); expect(channel.name).toBe('discord');
}); });
it('fetches missing channel metadata individually after a guild batch', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const firstGuild = {
channels: {
fetch: vi.fn().mockResolvedValue(
new Map([
[
'111',
{
name: 'first-chat',
position: 1,
parent: { name: 'First Guild', position: 1 },
},
],
]),
),
},
};
currentClient().channels.fetch.mockImplementation(async (id: string) => {
if (id === '111') {
return {
name: 'first-chat',
position: 1,
parent: { name: 'First Guild', position: 1 },
guild: firstGuild,
};
}
if (id === '222') {
return {
name: 'second-chat',
position: 2,
parent: { name: 'Second Guild', position: 2 },
guild: { channels: { fetch: vi.fn() } },
};
}
return null;
});
const metadata = await channel.getChannelMeta(['dc:111', 'dc:222']);
expect(metadata.get('dc:111')?.name).toBe('first-chat');
expect(metadata.get('dc:222')?.name).toBe('second-chat');
});
}); });

View File

@@ -675,6 +675,21 @@ export class DiscordChannel implements Channel {
categoryPosition: channel.parent?.position ?? 999, categoryPosition: channel.parent?.position ?? 999,
}); });
} }
for (const jid of dcJids) {
if (result.has(jid)) continue;
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (channel && 'position' in channel) {
const tc = channel as TextChannel;
result.set(jid, {
name: tc.name,
position: tc.position,
category: tc.parent?.name || '',
categoryPosition: tc.parent?.position ?? 999,
});
}
}
} catch { } catch {
// Fallback: individual fetches // Fallback: individual fetches
for (const jid of dcJids) { for (const jid of dcJids) {
@@ -769,7 +784,23 @@ export class DiscordChannel implements Channel {
throw new Error(`Discord channel not found or not editable: ${jid}`); throw new Error(`Discord channel not found or not editable: ${jid}`);
} }
const msg = await (channel as TextChannel).messages.fetch(messageId); const msg = await (channel as TextChannel).messages.fetch(messageId);
// Discord caps a single message (edits included) at 2000 chars. A long
// final answer edited straight in is rejected with
// content[BASE_TYPE_MAX_LENGTH]; edit the tracked message with the first
// chunk and send the remainder as follow-up messages so nothing is
// dropped and no failed-edit fallback is needed.
const MAX_LENGTH = 2000;
if (text.length <= MAX_LENGTH) {
await msg.edit(text); await msg.edit(text);
} else {
await msg.edit(text.slice(0, MAX_LENGTH));
for (let i = MAX_LENGTH; i < text.length; i += MAX_LENGTH) {
await (channel as TextChannel).send({
content: text.slice(i, i + MAX_LENGTH),
flags: MessageFlags.SuppressEmbeds,
});
}
}
logger.info( logger.info(
{ {
jid, jid,