- Status reactions on messages: 👀 on enqueue, ✅ on success, ❌ on any error - Typing interval raised from 5s to 8s - src/discord/send-response.ts: text >4000 chars sent as response.md attachment - src/discord/attachment-inbox.ts: downloads Discord attachments into .disclaw-inbox/, cleanupInbox() removes files older than 24h - src/router.ts: input limit guard at 4000 chars (⚠️ reaction + hint message), processAttachments hints injected into prompt - Unit tests: send-response.test.ts, attachment-inbox.test.ts (107 tests, all green) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import type { Message, Collection, Attachment } from "discord.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeMessage(attachments: Attachment[] = []): Message {
|
|
const col = new Map(attachments.map((a) => [a.id, a]));
|
|
return {
|
|
attachments: {
|
|
size: attachments.length,
|
|
values: () => col.values(),
|
|
},
|
|
} as unknown as Message;
|
|
}
|
|
|
|
function makeAttachment(id: string, name: string, url: string): Attachment {
|
|
return { id, name, url } as unknown as Attachment;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("processAttachments", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-inbox-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns an empty array when message has no attachments", async () => {
|
|
const { processAttachments } = await import("../../src/discord/attachment-inbox");
|
|
const message = makeMessage([]);
|
|
const hints = await processAttachments(message, tmpDir);
|
|
expect(hints).toEqual([]);
|
|
// No inbox directory created
|
|
expect(fs.existsSync(path.join(tmpDir, ".disclaw-inbox"))).toBe(false);
|
|
});
|
|
|
|
it("saves an attachment to .disclaw-inbox/ and returns a hint", async () => {
|
|
// Mock global fetch
|
|
const fileContent = "print('hello')\n";
|
|
const contentBytes = Buffer.from(fileContent, "utf-8");
|
|
// Allocate a clean ArrayBuffer (not the Node shared pool) so Buffer.from() round-trips correctly
|
|
const cleanArrayBuffer = contentBytes.buffer.slice(
|
|
contentBytes.byteOffset,
|
|
contentBytes.byteOffset + contentBytes.byteLength
|
|
);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => cleanArrayBuffer,
|
|
})
|
|
);
|
|
|
|
const { processAttachments } = await import("../../src/discord/attachment-inbox");
|
|
const attachment = makeAttachment("att-1", "example.py", "https://cdn.discord.com/example.py");
|
|
const message = makeMessage([attachment]);
|
|
|
|
const hints = await processAttachments(message, tmpDir);
|
|
|
|
expect(hints).toHaveLength(1);
|
|
expect(hints[0]).toMatch(/^\[Datei verfügbar: \.disclaw-inbox\//);
|
|
expect(hints[0]).toContain("example.py");
|
|
|
|
// File must exist in inbox
|
|
const inboxDir = path.join(tmpDir, ".disclaw-inbox");
|
|
const files = fs.readdirSync(inboxDir);
|
|
expect(files).toHaveLength(1);
|
|
expect(files[0]).toMatch(/example\.py$/);
|
|
|
|
// File content must match
|
|
const savedContent = fs.readFileSync(path.join(inboxDir, files[0]), "utf-8");
|
|
expect(savedContent).toBe(fileContent);
|
|
});
|
|
|
|
it("skips an attachment when fetch fails", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 403,
|
|
})
|
|
);
|
|
|
|
const { processAttachments } = await import("../../src/discord/attachment-inbox");
|
|
const attachment = makeAttachment("att-1", "secret.txt", "https://cdn.discord.com/secret.txt");
|
|
const message = makeMessage([attachment]);
|
|
|
|
const hints = await processAttachments(message, tmpDir);
|
|
expect(hints).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("cleanupInbox", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-cleanup-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("does nothing when inbox directory does not exist", async () => {
|
|
const { cleanupInbox } = await import("../../src/discord/attachment-inbox");
|
|
expect(() => cleanupInbox(tmpDir)).not.toThrow();
|
|
});
|
|
|
|
it("deletes files older than 24 hours and keeps newer files", async () => {
|
|
const { cleanupInbox } = await import("../../src/discord/attachment-inbox");
|
|
|
|
const inboxDir = path.join(tmpDir, ".disclaw-inbox");
|
|
fs.mkdirSync(inboxDir);
|
|
|
|
const oldFile = path.join(inboxDir, "old-file.txt");
|
|
const newFile = path.join(inboxDir, "new-file.txt");
|
|
|
|
fs.writeFileSync(oldFile, "old");
|
|
fs.writeFileSync(newFile, "new");
|
|
|
|
// Backdate old file by 25 hours
|
|
const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
|
fs.utimesSync(oldFile, oldTime, oldTime);
|
|
|
|
cleanupInbox(tmpDir);
|
|
|
|
expect(fs.existsSync(oldFile)).toBe(false);
|
|
expect(fs.existsSync(newFile)).toBe(true);
|
|
});
|
|
});
|