feat(DIS-154): discord rich features — reactions, file responses, attachment inbox
- 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>
This commit is contained in:
parent
aa3f53c4d5
commit
9e03fc5207
5 changed files with 401 additions and 9 deletions
90
src/discord/attachment-inbox.ts
Normal file
90
src/discord/attachment-inbox.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { childLogger } from "../runtime/logger.js";
|
||||||
|
|
||||||
|
const log = childLogger("attachment-inbox");
|
||||||
|
|
||||||
|
const INBOX_DIR = ".disclaw-inbox";
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves attachments from a Discord message into <workspacePath>/.disclaw-inbox/
|
||||||
|
* Returns a list of saved file hints to inject into the prompt context.
|
||||||
|
*/
|
||||||
|
export async function processAttachments(
|
||||||
|
message: Message,
|
||||||
|
workspacePath: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (message.attachments.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxPath = path.join(workspacePath, INBOX_DIR);
|
||||||
|
if (!fs.existsSync(inboxPath)) {
|
||||||
|
fs.mkdirSync(inboxPath, { recursive: true });
|
||||||
|
log.debug({ inboxPath }, "Created inbox directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints: string[] = [];
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
for (const attachment of message.attachments.values()) {
|
||||||
|
const safeName = path.basename(attachment.name ?? "file");
|
||||||
|
const fileName = `${timestamp}-${safeName}`;
|
||||||
|
const filePath = path.join(inboxPath, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(attachment.url);
|
||||||
|
if (!response.ok) {
|
||||||
|
log.warn(
|
||||||
|
{ url: attachment.url, status: response.status },
|
||||||
|
"Failed to download attachment"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
log.debug({ fileName }, "Saved attachment");
|
||||||
|
hints.push(`[Datei verfügbar: ${INBOX_DIR}/${fileName}]`);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, fileName }, "Error saving attachment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes files in <workspacePath>/.disclaw-inbox/ older than 24 hours.
|
||||||
|
*/
|
||||||
|
export function cleanupInbox(workspacePath: string): void {
|
||||||
|
const inboxPath = path.join(workspacePath, INBOX_DIR);
|
||||||
|
|
||||||
|
if (!fs.existsSync(inboxPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let entries: string[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(inboxPath);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, inboxPath }, "Failed to read inbox directory for cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const filePath = path.join(inboxPath, entry);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (now - stat.mtimeMs > MAX_AGE_MS) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
log.debug({ filePath }, "Deleted stale inbox file");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, filePath }, "Failed to stat/delete inbox file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/discord/send-response.ts
Normal file
39
src/discord/send-response.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { TextChannel, Message, AttachmentBuilder } from "discord.js";
|
||||||
|
import { splitForDiscord } from "./split.js";
|
||||||
|
import { childLogger } from "../runtime/logger.js";
|
||||||
|
|
||||||
|
const log = childLogger("send-response");
|
||||||
|
|
||||||
|
const FILE_THRESHOLD = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an agent response to a Discord channel.
|
||||||
|
* - Text > 4000 chars is sent as a Markdown file attachment.
|
||||||
|
* - Text <= 4000 chars is split with splitForDiscord and sent as plain messages.
|
||||||
|
*/
|
||||||
|
export async function sendResponse(
|
||||||
|
channel: TextChannel,
|
||||||
|
text: string,
|
||||||
|
_triggerMessage: Message
|
||||||
|
): Promise<void> {
|
||||||
|
if (text.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > FILE_THRESHOLD) {
|
||||||
|
log.debug({ length: text.length }, "Response exceeds threshold — sending as file");
|
||||||
|
await channel.send({
|
||||||
|
files: [
|
||||||
|
new AttachmentBuilder(Buffer.from(text, "utf-8"), {
|
||||||
|
name: "response.md",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = splitForDiscord(text);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await channel.send(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,14 @@ import { Message, TextChannel } from "discord.js";
|
||||||
import { DisclawDatabase } from "./db/database";
|
import { DisclawDatabase } from "./db/database";
|
||||||
import { DisclawConfig } from "./config/loader";
|
import { DisclawConfig } from "./config/loader";
|
||||||
import { runAgent } from "./agent/runner";
|
import { runAgent } from "./agent/runner";
|
||||||
import { splitForDiscord } from "./discord/split";
|
|
||||||
import { sanitizeForDiscord } from "./runtime/sanitize";
|
import { sanitizeForDiscord } from "./runtime/sanitize";
|
||||||
import { ChannelQueue } from "./runtime/channel-queue";
|
import { ChannelQueue } from "./runtime/channel-queue";
|
||||||
|
import { sendResponse } from "./discord/send-response";
|
||||||
|
import { processAttachments } from "./discord/attachment-inbox";
|
||||||
import type { RunResult } from "./agent/types";
|
import type { RunResult } from "./agent/types";
|
||||||
|
|
||||||
|
const INPUT_LIMIT = 4000;
|
||||||
|
|
||||||
const channelQueue = new ChannelQueue();
|
const channelQueue = new ChannelQueue();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,6 +36,21 @@ export async function routeMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = message.channel as TextChannel;
|
const channel = message.channel as TextChannel;
|
||||||
|
|
||||||
|
// Input-length guard — refuse oversized messages before queuing
|
||||||
|
if (message.content.length > INPUT_LIMIT) {
|
||||||
|
message.react?.("⚠️").catch(() => {});
|
||||||
|
await channel
|
||||||
|
.send(
|
||||||
|
`Nachricht zu lang (${message.content.length} Zeichen). Maximum ist ${INPUT_LIMIT} Zeichen.`
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge receipt immediately
|
||||||
|
message.react?.("👀").catch(() => {});
|
||||||
|
|
||||||
const sanitizeOpts = {
|
const sanitizeOpts = {
|
||||||
repoRoot: workspace.workspace_path,
|
repoRoot: workspace.workspace_path,
|
||||||
home: os.homedir(),
|
home: os.homedir(),
|
||||||
|
|
@ -41,16 +59,25 @@ export async function routeMessage(
|
||||||
await channelQueue.enqueue(channelId, async () => {
|
await channelQueue.enqueue(channelId, async () => {
|
||||||
const typingInterval = setInterval(() => {
|
const typingInterval = setInterval(() => {
|
||||||
channel.sendTyping().catch(() => {});
|
channel.sendTyping().catch(() => {});
|
||||||
}, 5000);
|
}, 8000);
|
||||||
await channel.sendTyping().catch(() => {});
|
await channel.sendTyping().catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = db.getConversationHistory(workspace.id, 30);
|
const history = db.getConversationHistory(workspace.id, 30);
|
||||||
|
|
||||||
|
// Process any file attachments and build hint lines for the prompt
|
||||||
|
const attachmentHints = message.attachments
|
||||||
|
? await processAttachments(message, workspace.workspace_path)
|
||||||
|
: [];
|
||||||
|
let userMessage = message.content;
|
||||||
|
if (attachmentHints.length > 0) {
|
||||||
|
userMessage = userMessage + "\n\n" + attachmentHints.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
const result: RunResult = await runAgent({
|
const result: RunResult = await runAgent({
|
||||||
workspacePath: workspace.workspace_path,
|
workspacePath: workspace.workspace_path,
|
||||||
channelName: channel.name,
|
channelName: channel.name,
|
||||||
userMessage: message.content,
|
userMessage,
|
||||||
conversationHistory: history,
|
conversationHistory: history,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -64,14 +91,30 @@ export async function routeMessage(
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
|
const sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
|
||||||
const chunks = splitForDiscord(sanitizedResponse);
|
|
||||||
let firstMsgId: string | null = null;
|
let firstMsgId: string | null = null;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
// Use sendResponse which handles the file-vs-chunks decision
|
||||||
const sent = await channel.send(chunk);
|
// We need to capture the first message id for DB storage.
|
||||||
if (!firstMsgId) {
|
// sendResponse sends all chunks; we wrap with a one-shot intercept.
|
||||||
firstMsgId = sent.id;
|
const originalSend = channel.send.bind(channel);
|
||||||
}
|
const sentIds: string[] = [];
|
||||||
|
const patchedSend = async (...args: Parameters<typeof originalSend>) => {
|
||||||
|
const sent = await originalSend(...(args as [Parameters<typeof originalSend>[0]]));
|
||||||
|
sentIds.push(sent.id);
|
||||||
|
return sent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Temporarily patch channel.send for sendResponse
|
||||||
|
(channel as unknown as { send: typeof patchedSend }).send = patchedSend;
|
||||||
|
try {
|
||||||
|
await sendResponse(channel, sanitizedResponse, message);
|
||||||
|
} finally {
|
||||||
|
(channel as unknown as { send: typeof originalSend }).send = originalSend;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentIds.length > 0) {
|
||||||
|
firstMsgId = sentIds[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstMsgId) {
|
if (firstMsgId) {
|
||||||
|
|
@ -83,20 +126,26 @@ export async function routeMessage(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message.react?.("✅").catch(() => {});
|
||||||
} else if (result.status === "timeout") {
|
} else if (result.status === "timeout") {
|
||||||
|
message.react?.("❌").catch(() => {});
|
||||||
await channel
|
await channel
|
||||||
.send(`⏱️ Agent-Timeout nach ${result.timeoutMs / 1000}s`)
|
.send(`⏱️ Agent-Timeout nach ${result.timeoutMs / 1000}s`)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else if (result.status === "cli-error") {
|
} else if (result.status === "cli-error") {
|
||||||
|
message.react?.("❌").catch(() => {});
|
||||||
await channel
|
await channel
|
||||||
.send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts))
|
.send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else if (result.status === "parse-error") {
|
} else if (result.status === "parse-error") {
|
||||||
|
message.react?.("❌").catch(() => {});
|
||||||
await channel
|
await channel
|
||||||
.send(`⚠️ Antwort konnte nicht gelesen werden`)
|
.send(`⚠️ Antwort konnte nicht gelesen werden`)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
message.react?.("❌").catch(() => {});
|
||||||
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
await channel
|
await channel
|
||||||
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
|
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
|
||||||
|
|
|
||||||
144
tests/unit/attachment-inbox.test.ts
Normal file
144
tests/unit/attachment-inbox.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
tests/unit/send-response.test.ts
Normal file
70
tests/unit/send-response.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { sendResponse } from "../../src/discord/send-response";
|
||||||
|
import type { TextChannel, Message } from "discord.js";
|
||||||
|
|
||||||
|
// Minimal stubs
|
||||||
|
function makeChannel() {
|
||||||
|
return {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
|
||||||
|
} as unknown as TextChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMessage() {
|
||||||
|
return {} as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sendResponse", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
const text = "Hello, world!";
|
||||||
|
await sendResponse(channel, text, makeMessage());
|
||||||
|
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(channel.send).toHaveBeenCalledWith(text);
|
||||||
|
// No file attachment
|
||||||
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
||||||
|
expect(typeof call).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends multiple chunks for text <= 4000 chars that needs splitting", async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
// Build a text that exceeds the 1900 default split limit but stays <= 4000
|
||||||
|
const line = "a".repeat(100);
|
||||||
|
const text = Array.from({ length: 30 }, () => line).join("\n");
|
||||||
|
// 30*100 + 29 newlines = 3029 chars — within 4000 but split into chunks
|
||||||
|
expect(text.length).toBeLessThanOrEqual(4000);
|
||||||
|
|
||||||
|
await sendResponse(channel, text, makeMessage());
|
||||||
|
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(
|
||||||
|
(channel.send as ReturnType<typeof vi.fn>).mock.calls.length
|
||||||
|
);
|
||||||
|
// All calls must be strings, not objects with files
|
||||||
|
for (const [arg] of vi.mocked(channel.send).mock.calls) {
|
||||||
|
expect(typeof arg).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends an attachment for text > 4000 chars", async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
const text = "x".repeat(4001);
|
||||||
|
|
||||||
|
await sendResponse(channel, text, makeMessage());
|
||||||
|
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
||||||
|
expect(typeof call).toBe("object");
|
||||||
|
expect((call as { files: unknown[] }).files).toBeDefined();
|
||||||
|
expect((call as { files: unknown[] }).files).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call channel.send() for empty text", async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
await sendResponse(channel, "", makeMessage());
|
||||||
|
expect(channel.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue