feat(DIS-154): agent file sending via [ATTACH: path] convention
- parseAttachments() extracts [ATTACH: path] markers from response text - validateAttachPath() enforces workspace boundary + readability + 8MB limit - sendResponse() sends validated files as Discord attachments before text - CLAUDE.md template teaches agents the [ATTACH: ...] syntax - Path-traversal guard: only files within workspace are allowed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3070406d0f
commit
00f7909e6b
5 changed files with 260 additions and 39 deletions
|
|
@ -108,6 +108,25 @@ ${role}
|
||||||
- Be concise in your responses -- they are displayed in Discord (2000 char limit per message)
|
- Be concise in your responses -- they are displayed in Discord (2000 char limit per message)
|
||||||
- If a task is outside your area of expertise, say so clearly
|
- If a task is outside your area of expertise, say so clearly
|
||||||
|
|
||||||
|
## Sending Files to Discord
|
||||||
|
|
||||||
|
To send a file from your workspace back to the user in Discord, include this marker anywhere in your response:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
[ATTACH: path/to/file.jpg]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- The path can be relative to your workspace root or absolute
|
||||||
|
- Only files within your workspace directory are allowed
|
||||||
|
- Maximum file size: 8 MB
|
||||||
|
- Multiple files are supported — one marker per file
|
||||||
|
- The marker is removed from the text before display; only the file is sent
|
||||||
|
|
||||||
|
Example: If a user asks "send me the image", respond with:
|
||||||
|
\`[ATTACH: images/result.png]\`
|
||||||
|
|
||||||
|
Do **not** use this for files outside your workspace or for sensitive files.
|
||||||
|
|
||||||
## Workspace Contents
|
## Workspace Contents
|
||||||
|
|
||||||
Add project-specific context below. Describe the files, conventions,
|
Add project-specific context below. Describe the files, conventions,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
import { TextChannel, Message, AttachmentBuilder } from "discord.js";
|
import { TextChannel, Message, AttachmentBuilder } from "discord.js";
|
||||||
import { splitForDiscord } from "./split.js";
|
import { splitForDiscord } from "./split.js";
|
||||||
import { childLogger } from "../runtime/logger.js";
|
import { childLogger } from "../runtime/logger.js";
|
||||||
|
|
@ -5,26 +7,113 @@ import { childLogger } from "../runtime/logger.js";
|
||||||
const log = childLogger("send-response");
|
const log = childLogger("send-response");
|
||||||
|
|
||||||
const FILE_THRESHOLD = 4000;
|
const FILE_THRESHOLD = 4000;
|
||||||
|
const MAX_ATTACH_BYTES = 8 * 1024 * 1024; // 8 MB — Discord limit for regular bots
|
||||||
|
|
||||||
|
const ATTACH_PATTERN = /\[ATTACH:\s*([^\]]+)\]/g;
|
||||||
|
|
||||||
|
export interface ParsedResponse {
|
||||||
|
text: string;
|
||||||
|
attachPaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts [ATTACH: path] markers from agent response text.
|
||||||
|
* Returns cleaned text and a list of resolved absolute paths.
|
||||||
|
* Paths are resolved relative to workspacePath if not absolute.
|
||||||
|
*/
|
||||||
|
export function parseAttachments(raw: string, workspacePath: string): ParsedResponse {
|
||||||
|
const attachPaths: string[] = [];
|
||||||
|
const text = raw.replace(ATTACH_PATTERN, (_match, rawPath: string) => {
|
||||||
|
const trimmed = rawPath.trim();
|
||||||
|
const resolved = path.isAbsolute(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: path.resolve(workspacePath, trimmed);
|
||||||
|
attachPaths.push(resolved);
|
||||||
|
return "";
|
||||||
|
}).trim();
|
||||||
|
return { text, attachPaths };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an attach path: must exist, be readable, be within workspacePath,
|
||||||
|
* and be no larger than MAX_ATTACH_BYTES.
|
||||||
|
* Returns an error string if invalid, null if OK.
|
||||||
|
*/
|
||||||
|
export function validateAttachPath(filePath: string, workspacePath: string): string | null {
|
||||||
|
const workspaceRoot = path.resolve(workspacePath);
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
|
||||||
|
// Path-traversal guard
|
||||||
|
if (!resolved.startsWith(workspaceRoot + path.sep) && resolved !== workspaceRoot) {
|
||||||
|
return `Datei liegt außerhalb des Workspace: \`${path.basename(resolved)}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existence + read permission
|
||||||
|
try {
|
||||||
|
fs.accessSync(resolved, fs.constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
return `Datei nicht lesbar oder nicht vorhanden: \`${path.basename(resolved)}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size check
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (stat.size > MAX_ATTACH_BYTES) {
|
||||||
|
const mb = (stat.size / (1024 * 1024)).toFixed(1);
|
||||||
|
return `Datei zu groß (${mb} MB, Maximum 8 MB): \`${path.basename(resolved)}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an agent response to a Discord channel.
|
* Sends an agent response to a Discord channel.
|
||||||
* - Text > 4000 chars is sent as a Markdown file attachment.
|
* - Parses [ATTACH: path] markers and sends valid files as attachments.
|
||||||
|
* - Text > 4000 chars (after stripping markers) is sent as a Markdown file attachment.
|
||||||
* - Text <= 4000 chars is split with splitForDiscord and sent as plain messages.
|
* - Text <= 4000 chars is split with splitForDiscord and sent as plain messages.
|
||||||
*/
|
*/
|
||||||
export async function sendResponse(
|
export async function sendResponse(
|
||||||
channel: TextChannel,
|
channel: TextChannel,
|
||||||
text: string,
|
text: string,
|
||||||
_triggerMessage: Message
|
_triggerMessage: Message,
|
||||||
|
workspacePath?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.length > FILE_THRESHOLD) {
|
// Parse [ATTACH: ...] markers if we have a workspace context
|
||||||
log.debug({ length: text.length }, "Response exceeds threshold — sending as file");
|
let cleanText = text;
|
||||||
|
let attachPaths: string[] = [];
|
||||||
|
if (workspacePath) {
|
||||||
|
const parsed = parseAttachments(text, workspacePath);
|
||||||
|
cleanText = parsed.text;
|
||||||
|
attachPaths = parsed.attachPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send file attachments
|
||||||
|
for (const filePath of attachPaths) {
|
||||||
|
const err = validateAttachPath(filePath, workspacePath!);
|
||||||
|
if (err) {
|
||||||
|
log.warn({ filePath }, "Attachment rejected: " + err);
|
||||||
|
await channel.send(`⚠️ ${err}`).catch(() => {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log.debug({ filePath }, "Sending file attachment");
|
||||||
|
await channel.send({
|
||||||
|
files: [new AttachmentBuilder(filePath, { name: path.basename(filePath) })],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send text portion
|
||||||
|
if (cleanText.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanText.length > FILE_THRESHOLD) {
|
||||||
|
log.debug({ length: cleanText.length }, "Response exceeds threshold — sending as file");
|
||||||
await channel.send({
|
await channel.send({
|
||||||
files: [
|
files: [
|
||||||
new AttachmentBuilder(Buffer.from(text, "utf-8"), {
|
new AttachmentBuilder(Buffer.from(cleanText, "utf-8"), {
|
||||||
name: "response.md",
|
name: "response.md",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
@ -32,7 +121,7 @@ export async function sendResponse(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = splitForDiscord(text);
|
const chunks = splitForDiscord(cleanText);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await channel.send(chunk);
|
await channel.send(chunk);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export async function routeMessage(
|
||||||
// Temporarily patch channel.send for sendResponse
|
// Temporarily patch channel.send for sendResponse
|
||||||
(channel as unknown as { send: typeof patchedSend }).send = patchedSend;
|
(channel as unknown as { send: typeof patchedSend }).send = patchedSend;
|
||||||
try {
|
try {
|
||||||
await sendResponse(channel, sanitizedResponse, message);
|
await sendResponse(channel, sanitizedResponse, message, workspace.workspace_path);
|
||||||
} finally {
|
} finally {
|
||||||
(channel as unknown as { send: typeof originalSend }).send = originalSend;
|
(channel as unknown as { send: typeof originalSend }).send = originalSend;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,30 @@ Aktuell senden Agents nur Plaintext. Discord bietet viel mehr:
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
Phase 1 abgeschlossen (splitForDiscord, RunResult).
|
Phase 1 abgeschlossen (splitForDiscord, RunResult).
|
||||||
|
|
||||||
|
### 6. Agent sendet Dateien zurück (`src/discord/send-response.ts`)
|
||||||
|
- Agent schreibt `[ATTACH: pfad/zur/datei.ext]` in seine Antwort
|
||||||
|
- Bot parst alle `[ATTACH: ...]`-Marker heraus, entfernt sie aus dem Text
|
||||||
|
- Sicherheitscheck vor dem Senden:
|
||||||
|
- Datei muss existieren und lesbar sein (`fs.accessSync(path, fs.constants.R_OK)`)
|
||||||
|
- Pfad darf nicht außerhalb des Workspace liegen (Path-Traversal-Schutz via `path.resolve`)
|
||||||
|
- Max. Dateigröße: **8 MB** (Discord-Limit für reguläre Bots)
|
||||||
|
- Erlaubte Pfade: relativ zum Workspace-Root oder absolut innerhalb des Workspace
|
||||||
|
- Dateien werden als Discord-Attachments gesendet, verbleibender Text normal
|
||||||
|
- CLAUDE.md-Template informiert den Agent über die `[ATTACH: ...]`-Konvention
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [ ] Reactions `👀` / `✅` / `❌` werden korrekt gesetzt
|
||||||
|
- [ ] Typing-Intervall 8s, endet mit Agent-Antwort
|
||||||
|
- [ ] Antworten > 4000 Zeichen kommen als `.md`-Attachment
|
||||||
|
- [ ] Eingehende Dateien landen im `.disclaw-inbox/`-Verzeichnis
|
||||||
|
- [ ] Input > 4000 Zeichen → `⚠️` + Hinweis, kein Agent-Aufruf
|
||||||
|
- [ ] Agent kann mit `[ATTACH: pfad]` Dateien zurücksenden
|
||||||
|
- [ ] Path-Traversal-Schutz: nur Dateien im Workspace erlaubt
|
||||||
|
- [ ] Dateien > 8 MB → Fehlermeldung an Channel statt Absturz
|
||||||
|
- [ ] CLAUDE.md-Template enthält Hinweis auf `[ATTACH: ...]`
|
||||||
|
- [ ] Unit-Tests: `send-response`, `attachment-inbox`
|
||||||
|
- [ ] `npm run build && npm test` grün
|
||||||
|
|
||||||
## Hinweis
|
## Hinweis
|
||||||
Reactions, Embeds, Threads und Polls (voller SF-004-Scope) können in Folge-Issues
|
Reactions, Embeds, Threads und Polls (voller SF-004-Scope) können in Folge-Issues
|
||||||
ausgebaut werden. Dieses Issue implementiert den Kern-QoL-Teil.
|
ausgebaut werden. Dieses Issue implementiert den Kern-QoL-Teil.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import * as fs from "node:fs";
|
||||||
import { sendResponse } from "../../src/discord/send-response";
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { sendResponse, parseAttachments, validateAttachPath } from "../../src/discord/send-response";
|
||||||
import type { TextChannel, Message } from "discord.js";
|
import type { TextChannel, Message } from "discord.js";
|
||||||
|
|
||||||
// Minimal stubs
|
|
||||||
function makeChannel() {
|
function makeChannel() {
|
||||||
return {
|
return {
|
||||||
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
|
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
|
||||||
|
|
@ -13,6 +15,69 @@ function makeMessage() {
|
||||||
return {} as Message;
|
return {} as Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("parseAttachments", () => {
|
||||||
|
it("returns empty attachPaths and original text when no markers", () => {
|
||||||
|
const result = parseAttachments("Hello world", "/workspace");
|
||||||
|
expect(result.text).toBe("Hello world");
|
||||||
|
expect(result.attachPaths).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a single relative [ATTACH: ...] marker", () => {
|
||||||
|
const result = parseAttachments("Here is the file [ATTACH: images/cat.jpg]", "/workspace");
|
||||||
|
expect(result.text).toBe("Here is the file");
|
||||||
|
expect(result.attachPaths).toHaveLength(1);
|
||||||
|
expect(result.attachPaths[0]).toBe(path.resolve("/workspace", "images/cat.jpg"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts an absolute [ATTACH: ...] marker unchanged", () => {
|
||||||
|
const abs = path.resolve("/workspace/file.txt");
|
||||||
|
const result = parseAttachments(`See [ATTACH: ${abs}]`, "/workspace");
|
||||||
|
expect(result.attachPaths[0]).toBe(abs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts multiple markers", () => {
|
||||||
|
const result = parseAttachments(
|
||||||
|
"Files: [ATTACH: a.txt] and [ATTACH: b.png]",
|
||||||
|
"/workspace"
|
||||||
|
);
|
||||||
|
expect(result.attachPaths).toHaveLength(2);
|
||||||
|
expect(result.text).toBe("Files: and");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateAttachPath", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let tmpFile: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-"));
|
||||||
|
tmpFile = path.join(tmpDir, "file.txt");
|
||||||
|
fs.writeFileSync(tmpFile, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a valid readable file within workspace", () => {
|
||||||
|
expect(validateAttachPath(tmpFile, tmpDir)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects paths outside workspace (path traversal)", () => {
|
||||||
|
const outsideFile = path.join(os.tmpdir(), "outside.txt");
|
||||||
|
fs.writeFileSync(outsideFile, "secret");
|
||||||
|
const err = validateAttachPath(outsideFile, tmpDir);
|
||||||
|
expect(err).toMatch(/außerhalb/);
|
||||||
|
fs.unlinkSync(outsideFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-existent files", () => {
|
||||||
|
const missing = path.join(tmpDir, "ghost.txt");
|
||||||
|
const err = validateAttachPath(missing, tmpDir);
|
||||||
|
expect(err).toMatch(/nicht lesbar/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sendResponse", () => {
|
describe("sendResponse", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -20,45 +85,18 @@ describe("sendResponse", () => {
|
||||||
|
|
||||||
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
|
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
|
||||||
const channel = makeChannel();
|
const channel = makeChannel();
|
||||||
const text = "Hello, world!";
|
await sendResponse(channel, "Hello, world!", makeMessage());
|
||||||
await sendResponse(channel, text, makeMessage());
|
|
||||||
|
|
||||||
expect(channel.send).toHaveBeenCalledTimes(1);
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.send).toHaveBeenCalledWith(text);
|
|
||||||
// No file attachment
|
|
||||||
const call = vi.mocked(channel.send).mock.calls[0][0];
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
||||||
expect(typeof call).toBe("string");
|
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 () => {
|
it("sends an attachment for text > 4000 chars", async () => {
|
||||||
const channel = makeChannel();
|
const channel = makeChannel();
|
||||||
const text = "x".repeat(4001);
|
await sendResponse(channel, "x".repeat(4001), makeMessage());
|
||||||
|
|
||||||
await sendResponse(channel, text, makeMessage());
|
|
||||||
|
|
||||||
expect(channel.send).toHaveBeenCalledTimes(1);
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
const call = vi.mocked(channel.send).mock.calls[0][0];
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
||||||
expect(typeof call).toBe("object");
|
expect(typeof call).toBe("object");
|
||||||
expect((call as { files: unknown[] }).files).toBeDefined();
|
|
||||||
expect((call as { files: unknown[] }).files).toHaveLength(1);
|
expect((call as { files: unknown[] }).files).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,4 +105,55 @@ describe("sendResponse", () => {
|
||||||
await sendResponse(channel, "", makeMessage());
|
await sendResponse(channel, "", makeMessage());
|
||||||
expect(channel.send).not.toHaveBeenCalled();
|
expect(channel.send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends [ATTACH: ...] file as Discord attachment", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-attach-"));
|
||||||
|
const filePath = path.join(tmpDir, "result.txt");
|
||||||
|
fs.writeFileSync(filePath, "data");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = makeChannel();
|
||||||
|
await sendResponse(channel, `Here it is [ATTACH: result.txt]`, makeMessage(), tmpDir);
|
||||||
|
|
||||||
|
// One call for the file, one for the text
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(2);
|
||||||
|
const fileCall = vi.mocked(channel.send).mock.calls[0][0] as { files: unknown[] };
|
||||||
|
expect(fileCall.files).toHaveLength(1);
|
||||||
|
const textCall = vi.mocked(channel.send).mock.calls[1][0];
|
||||||
|
expect(typeof textCall).toBe("string");
|
||||||
|
expect(textCall as string).toContain("Here it is");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends warning message for file outside workspace", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-ws-"));
|
||||||
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-out-"));
|
||||||
|
const outsideFile = path.join(outsideDir, "secret.txt");
|
||||||
|
fs.writeFileSync(outsideFile, "secret");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = makeChannel();
|
||||||
|
await sendResponse(channel, `[ATTACH: ${outsideFile}]`, makeMessage(), tmpDir);
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
|
const call = vi.mocked(channel.send).mock.calls[0][0] as string;
|
||||||
|
expect(call).toMatch(/⚠️/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only sends text when no [ATTACH: ...] present and workspacePath given", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-plain-"));
|
||||||
|
try {
|
||||||
|
const channel = makeChannel();
|
||||||
|
await sendResponse(channel, "Plain text response", makeMessage(), tmpDir);
|
||||||
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(channel.send).mock.calls[0][0]).toBe("Plain text response");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue