src/bot/messageHistory.test.ts

import { test } from "bun:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { appConfig } from "../config.ts"; import { LLMService } from "../llm.ts"; import { MemoryManager } from "../memory/memoryManager.ts"; import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import type { BotContext } from "./botContext.ts"; import { composeMessageContentForHistory, getConversationHistoryForPrompt, getImageInputs, getVideoInputs, isLikelyImageUrl, parseHistoryImageReference, recordReactionHistoryEvent, syncMessageSnapshot } from "./messageHistory.ts";

async function withTempHistoryContext(run: (ctx: BotContext & { store: Store }) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-bot-message-history-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

const llm = new LLMService({ appConfig, store }); const memory = new MemoryManager({ store, llm, memoryFilePath: path.join(dir, "memory.md") }); const ctx: BotContext & { store: Store } = { appConfig, store, llm, memory, client: { user: { id: "bot-1" }, guilds: { cache: new Map() } }, botUserId: "bot-1" };

try { await run(ctx); } finally { store.close(); await rmTempDir(dir); } }

function createAttachmentCollection( attachments: Array<{ url?: string; proxyURL?: string; name?: string; contentType?: string; }> ) { return { size: attachments.length, *values() { for (const attachment of attachments) { yield attachment; } } }; }

function createReactionCollection( reactions: Array<{ count?: number; emoji?: { id?: string; name?: string; } | null; }> ) { return { size: reactions.length, *values() { for (const reaction of reactions) { yield reaction; } } }; }

function recordStoreMessage( store: Store, { messageId, createdAt, guildId = "guild-1", channelId = "chan-1", authorId, authorName, isBot = false, content }: { messageId: string; createdAt: number; guildId?: string; channelId?: string; authorId: string; authorName: string; isBot?: boolean; content: string; } ) { store.recordMessage({ messageId, createdAt, guildId, channelId, authorId, authorName, isBot, content }); }

test("composeMessageContentForHistory appends attachments embeds and reaction summary", () => { const content = composeMessageContentForHistory( { attachments: createAttachmentCollection([ { url: "https://cdn.example.com/cat.png" } ]), embeds: [ { video: { url: "https://video.example.com/demo.mp4" } }, { url: "https://example.com/post" } ], reactions: { cache: createReactionCollection([ { count: 2, emoji: { name: "party" } } ]) } }, " hello there " );

assert.equal( content, "hello there https://cdn.example.com/cat.png https://video.example.com/demo.mp4 https://example.com/post [reactions: partyx2]" ); });

test("getImageInputs keeps only images and caps the list at three", () => { const images = getImageInputs({ attachments: createAttachmentCollection([ { url: "https://cdn.example.com/readme.pdf", name: "readme.pdf", contentType: "application/pdf" }, { url: "https://cdn.example.com/one.png", name: "one.png" }, { url: "https://cdn.example.com/two", name: "two", contentType: "image/jpeg" }, { url: "https://cdn.example.com/three.webp?size=400", name: "three.webp" }, { url: "https://cdn.example.com/four.gif", name: "four.gif" } ]) });

assert.deepEqual(images, [ { url: "https://cdn.example.com/one.png", filename: "one.png", contentType: "" }, { url: "https://cdn.example.com/two", filename: "two", contentType: "image/jpeg" }, { url: "https://cdn.example.com/three.webp?size=400", filename: "three.webp", contentType: "" } ]); });

test("GIF attachments route through video inputs instead of still-image inputs", () => { const message = { attachments: createAttachmentCollection([ { url: "https://cdn.example.com/spin.gif", name: "spin.gif", contentType: "image/gif" } ]) };

assert.deepEqual(getImageInputs(message), []); assert.deepEqual(getVideoInputs(message), [ { url: "https://cdn.example.com/spin.gif", filename: "spin.gif", contentType: "image/gif" } ]); });

test("getVideoInputs keeps only videos and caps the list at three", () => { const videos = getVideoInputs({ attachments: createAttachmentCollection([ { url: "https://cdn.example.com/readme.pdf", name: "readme.pdf", contentType: "application/pdf" }, { url: "https://cdn.example.com/clip.mp4", name: "clip.mp4" }, { url: "https://cdn.example.com/stream", name: "stream", contentType: "video/webm" }, { url: "https://cdn.example.com/trailer.mov?download=1", name: "trailer.mov" }, { url: "https://cdn.example.com/four.mkv", name: "four.mkv" } ]), embeds: [ { video: { url: "https://video.example.com/embed-preview.mp4" } } ] });

assert.deepEqual(videos, [ { url: "https://cdn.example.com/clip.mp4", filename: "clip.mp4", contentType: "" }, { url: "https://cdn.example.com/stream", filename: "stream", contentType: "video/webm" }, { url: "https://cdn.example.com/trailer.mov?download=1", filename: "trailer.mov", contentType: "" } ]); });

test("image reference helpers detect format-param images and decode filenames", () => { assert.equal(isLikelyImageUrl("https://cdn.example.com/render?id=1&format=webp"), true); assert.equal(isLikelyImageUrl("not a url"), false); assert.deepEqual( parseHistoryImageReference("https://cdn.example.com/path/My%20Photo?format=png"), { filename: "My Photo", contentType: "image/png" } ); });

test("getConversationHistoryForPrompt searches normalized conversation windows", async () => { await withTempHistoryContext(async (ctx) => { const baseTime = Date.now() - 10 * 60 * 1000;

recordStoreMessage(ctx.store, {
  messageId: "m1",
  createdAt: baseTime,
  authorId: "user-1",
  authorName: "alice",
  content: "can you check nvidia stock price today"
});
recordStoreMessage(ctx.store, {
  messageId: "m2",
  createdAt: baseTime + 1000,
  authorId: "bot-1",
  authorName: "clanky",
  isBot: true,
  content: "NVDA was around 181 earlier."
});
recordStoreMessage(ctx.store, {
  messageId: "m3",
  createdAt: baseTime + 2000,
  authorId: "user-1",
  authorName: "alice",
  content: "what do you think about that nvidia stock price"
});

const windows = await getConversationHistoryForPrompt(ctx, {
  guildId: "guild-1",
  channelId: "chan-1",
  queryText: "   that nvidia stock price   ",
  limit: 2,
  maxAgeHours: 24,
  before: 1,
  after: 1
});

assert.equal(windows.length, 1);
assert.equal(
  windows[0]?.messages?.some((row) => row?.content === "NVDA was around 181 earlier."),
  true
);

}); });

test("syncMessageSnapshot stores normalized message content and references", async () => { await withTempHistoryContext(async (ctx) => { await syncMessageSnapshot(ctx, { id: "msg-1", createdTimestamp: 1_710_000_000_000, guildId: "guild-1", channelId: "chan-1", content: " hello world ", attachments: createAttachmentCollection([ { url: "https://cdn.example.com/cat.png" } ]), author: { id: "user-1", username: "alice", bot: false }, member: { displayName: "Alice" }, reference: { messageId: "msg-0" } });

const stored = ctx.store.db
  .prepare(
    `SELECT author_name, content, referenced_message_id
     FROM messages
     WHERE message_id = ?`
  )
  .get("msg-1") as {
  author_name: string;
  content: string;
  referenced_message_id: string | null;
};

assert.equal(stored.author_name, "Alice");
assert.equal(stored.content, "hello world https://cdn.example.com/cat.png");
assert.equal(stored.referenced_message_id, "msg-0");

}); });

test("recordReactionHistoryEvent records user reactions to bot-authored messages", async () => { await withTempHistoryContext(async (ctx) => { await recordReactionHistoryEvent( ctx, { emoji: { id: "emoji-1", name: "wave" }, message: { id: "bot-message-1", guildId: "guild-1", channelId: "chan-1", content: "this is a fairly long bot message that should still be summarized cleanly for reaction history", author: { id: "bot-1", username: "bot" }, member: { displayName: "Clanker" }, guild: { members: { cache: new Map([ [ "user-2", { displayName: "Alice" } ] ]) } } } }, { id: "user-2", username: "alice_user" } );

const stored = ctx.store.db
  .prepare(
    `SELECT author_id, author_name, content, referenced_message_id
     FROM messages
     WHERE referenced_message_id = ?
     ORDER BY created_at DESC
     LIMIT 1`
  )
  .get("bot-message-1") as {
  author_id: string;
  author_name: string;
  content: string;
  referenced_message_id: string;
};

assert.equal(stored.author_id, "user-2");
assert.equal(stored.author_name, "Alice");
assert.equal(
  stored.content,
  `Alice reacted with :wave: to Clanker's message: "this is a fairly long bot message that should still be summarized cleanly for..."`
);
assert.equal(stored.referenced_message_id, "bot-message-1");

}); });