feat(DIS-154): discord rich features � reactions, file responses, attachment inbox #64
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)
|
||||
- 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
|
||||
|
||||
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 { splitForDiscord } from "./split.js";
|
||||
import { childLogger } from "../runtime/logger.js";
|
||||
|
|
@ -5,26 +7,113 @@ import { childLogger } from "../runtime/logger.js";
|
|||
const log = childLogger("send-response");
|
||||
|
||||
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.
|
||||
* - 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.
|
||||
*/
|
||||
export async function sendResponse(
|
||||
channel: TextChannel,
|
||||
text: string,
|
||||
_triggerMessage: Message
|
||||
_triggerMessage: Message,
|
||||
workspacePath?: string
|
||||
): Promise<void> {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length > FILE_THRESHOLD) {
|
||||
log.debug({ length: text.length }, "Response exceeds threshold — sending as file");
|
||||
// Parse [ATTACH: ...] markers if we have a workspace context
|
||||
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({
|
||||
files: [
|
||||
new AttachmentBuilder(Buffer.from(text, "utf-8"), {
|
||||
new AttachmentBuilder(Buffer.from(cleanText, "utf-8"), {
|
||||
name: "response.md",
|
||||
}),
|
||||
],
|
||||
|
|
@ -32,7 +121,7 @@ export async function sendResponse(
|
|||
return;
|
||||
}
|
||||
|
||||
const chunks = splitForDiscord(text);
|
||||
const chunks = splitForDiscord(cleanText);
|
||||
for (const chunk of chunks) {
|
||||
await channel.send(chunk);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export async function routeMessage(
|
|||
// Temporarily patch channel.send for sendResponse
|
||||
(channel as unknown as { send: typeof patchedSend }).send = patchedSend;
|
||||
try {
|
||||
await sendResponse(channel, sanitizedResponse, message);
|
||||
await sendResponse(channel, sanitizedResponse, message, workspace.workspace_path);
|
||||
} finally {
|
||||
(channel as unknown as { send: typeof originalSend }).send = originalSend;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,30 @@ Aktuell senden Agents nur Plaintext. Discord bietet viel mehr:
|
|||
## Abhängigkeiten
|
||||
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
|
||||
Reactions, Embeds, Threads und Polls (voller SF-004-Scope) können in Folge-Issues
|
||||
ausgebaut werden. Dieses Issue implementiert den Kern-QoL-Teil.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { sendResponse } from "../../src/discord/send-response";
|
||||
import * as fs from "node:fs";
|
||||
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";
|
||||
|
||||
// Minimal stubs
|
||||
function makeChannel() {
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
|
||||
|
|
@ -13,6 +15,69 @@ function makeMessage() {
|
|||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -20,45 +85,18 @@ describe("sendResponse", () => {
|
|||
|
||||
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
|
||||
const channel = makeChannel();
|
||||
const text = "Hello, world!";
|
||||
await sendResponse(channel, text, makeMessage());
|
||||
|
||||
await sendResponse(channel, "Hello, world!", 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());
|
||||
|
||||
await sendResponse(channel, "x".repeat(4001), 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);
|
||||
});
|
||||
|
||||
|
|
@ -67,4 +105,55 @@ describe("sendResponse", () => {
|
|||
await sendResponse(channel, "", makeMessage());
|
||||
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