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:
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user