Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/fix-discord-history-keeps-host-blocks.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- The Discord module's edits and history sync no longer erase what the host added to a stored message. A message is now stored with its own text first, `''` when it has none, followed by its images, inlined file text and attachment notes, and its row's metadata carries `discordTextFirst: true`. An edit, live or found by the history sync, replaces that first block alone. Before, an edit rewrote the message as Discord's plain text, dropping its images, attachment notes and inlined file text. The history sync, which runs when the client becomes ready, did the same to any message with a note or an inlined file, since it compared Discord's text with all of the stored text at once, and a message whose only content was such a note or file then read as nothing at all. An update that leaves the text as it was now changes nothing, and an edit that removes a message's text, leaving its attachment, now reaches a message stored from now on. A live edit leaves a row stored before this change alone unless Discord's text is there and differs from the row's first block. It then puts the text in that block's place when the block is text, and before it when it isn't, and keeps the rest. Before, it rewrote the row as the text alone. A message that had no text of its own and was stored beginning with a file's contents or a note loses that block, where the rewrite lost everything, until the history sync stores it again.
- A live edit or delete now reaches a message that arrived live. Both looked the row up in the module context's external-id index, which holds only rows a module stored through its context, such as the history sync's, so they did nothing for any other message.
- A message the history sync missed is now stored as the live path stores it, with its images, small text files and attachment notes, where only its text was stored before. The sync also stores a row again through the live conversion when it doesn't hold what that conversion gives its message now: an attachment-only message an earlier sync blanked reads again, one it stripped of its notes or files gets them back, and an attachment removed from the message goes. One whose text changed while the client was away keeps its files. A row stored before this change for a message without text, whose first block is text, is stored again once, since that block may be a caption the message lost while the client was away. A rebuild whose text file can't be fetched writes nothing over a row holding other text, which may be that file's, so the row keeps what it holds and the next sync tries again; a row missing its file gets the notice that it wasn't fetched. A row the live path stored before the sync began is neither rewritten nor fetched again; one it stores while the sync fetches history may have its attachments fetched again, and is then left as the live path wrote it.
- The history sync now leaves alone a message the live path stored, changed or was told was deleted while it ran; an update that changed nothing doesn't count. It runs the conversions it needs first, then reads the store again and writes with no await between. A message the live path stored while it ran, which its fetch couldn't hold, is no longer removed as deleted. A row another writer removed while it ran isn't stored again. Before, a live edit made while the sync fetched history was written over with the fetch's older text. It writes nothing once the module has stopped or started again. A message a context strategy stored as a body, in shards, is kept as stored by the sync and by a live edit, since a body can't be edited in place, so an edit to it isn't taken in, and a live one writes a console line saying so. Before, the sync threw on a shard and stopped at it on every pass, and a live edit that found a shard threw too.
- `fetch_history` lists each message's attachments by filename, type and size, without their URLs, which work for anyone holding them until they expire.
- A send that posted its message but whose thread wasn't started now counts that message as the one the bot last sent, so the agent's thoughts are edited into it. Before, the message wasn't recorded, and the thoughts went into the message the bot sent before it, or into none. The send's result is the same error as before.
- Host authors: a Discord message stored from now on has its own text as its first content block, `''` when it has none; a row stored before keeps its layout until the history sync stores it again. The request builder drops an empty text block, as it already did, but a UI that renders stored content should expect one, and the history module's `raw` format shows it. `HistoryMessage` gains an optional `attachments`, which a custom `DiscordClientInterface` can fill; left out, the sync can't compare a message's attachments, so it rebuilds no row, though it still changes a row's text as a live edit does. `MessageQuery` gains `externalIds`, which context-manager already accepted. A `DiscordClientInterface`'s `sendMessage` may resolve with `threadFailure`, why the thread it was asked for wasn't started; one that throws instead works as before, and the message it sent isn't counted as the bot's last.
36 changes: 26 additions & 10 deletions src/modules/discord/discord-js-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export interface DiscordJsClientConfig {
channelIds?: string[];
}

/** An attachment as both a live message and history carry it. */
function toAttachment(a: Attachment): DiscordAttachment {
return {
id: a.id,
filename: a.name ?? 'unknown',
url: a.url,
contentType: a.contentType ?? undefined,
size: a.size,
};
}

export class DiscordJsClient implements DiscordClientInterface {
private client: Client;
private token: string;
Expand Down Expand Up @@ -68,7 +79,11 @@ export class DiscordJsClient implements DiscordClientInterface {
});

this.client.on('messageUpdate', (_oldMessage, newMessage) => {
if (!newMessage.content || !this.editHandler) return;
// content is null for a partial message, one discord.js hadn't cached
// and whose update carried no text, so there's no text to compare; a
// cached message keeps the text it had. An edit that leaves no text,
// beside an attachment, is '' and is still an edit.
if (typeof newMessage.content !== 'string' || !this.editHandler) return;
this.editHandler(newMessage.id, newMessage.content);
});

Expand Down Expand Up @@ -114,13 +129,7 @@ export class DiscordJsClient implements DiscordClientInterface {
}

private convertMessage(message: Message): DiscordMessageData {
const attachments: DiscordAttachment[] = message.attachments.map((a: Attachment) => ({
id: a.id,
filename: a.name ?? 'unknown',
url: a.url,
contentType: a.contentType ?? undefined,
size: a.size,
}));
const attachments = message.attachments.map(toAttachment);

// Extract mentioned user IDs
const mentionedUserIds = message.mentions.users.map((u: User) => u.id);
Expand Down Expand Up @@ -160,7 +169,7 @@ export class DiscordJsClient implements DiscordClientInterface {
channelId: string,
content: string,
options?: { replyTo?: string; createThread?: { name: string } }
): Promise<{ messageId: string }> {
): Promise<{ messageId: string; threadFailure?: string }> {
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) {
throw new Error(`Channel ${channelId} not found or not a text channel`);
Expand All @@ -173,7 +182,13 @@ export class DiscordJsClient implements DiscordClientInterface {
});

if (options?.createThread && 'threads' in textChannel) {
await message.startThread({ name: options.createThread.name });
try {
await message.startThread({ name: options.createThread.name });
} catch (error) {
// The message is out, so the caller counts it as sent before it
// reports why its thread wasn't started.
return { messageId: message.id, threadFailure: error instanceof Error ? error.message : String(error) };
}
}

return { messageId: message.id };
Expand Down Expand Up @@ -341,6 +356,7 @@ export class DiscordJsClient implements DiscordClientInterface {
authorName: m.author.username,
isBot: m.author.bot,
content: m.content,
attachments: m.attachments.map(toAttachment),
timestamp: m.createdAt,
replyTo: m.reference
? {
Expand Down
Loading
Loading