-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Closed
Labels
Issue - Needs InfoMissing details or unclear. Waiting on author to provide more context.Missing details or unclear. Waiting on author to provide more context.bugSomething isn't workingSomething isn't working
Description
App Version
3.25.15
API Provider
OpenAI
Model Used
GPT-5
Roo Code Task Links (Optional)
GPT-5 orchestrator mode doesn't seem to trust code mode to come up with the implementation details, it just delivers the whole codebase to code mode to implement
This means that we get this 1000 line task initiation prompt containing the contents of package.json, tsconfig.json, .env.example, and the ts files that should be implemented by code mode.
🔁 Steps to Reproduce
Give orchestrator mode a complex task in a multi project directory, and watch it do all the work, instead of delegating implementation details to code mode
💥 Outcome Summary
Expected orchestrator mode to only setup the requirements / acceptance criteria, but it started writing code
📄 Relevant Logs or Errors (Optional)
Objective
Implement the Discord Librarian Bot as a separate Node.js package under discord_bot/ with its own .env. The bot must:
Communicate with the existing Librarian HTTP server via HTTP (never post URLs to Discord).
Provide slash commands: /chat (prompt), /search (query), /book (id).
Include buttons on search results to “Upload book” by ID.
Upload the book to Discord as an attachment if within size limit; otherwise respond with an error. Never expose external download URLs.
Restrict usage to a single guild and single channel, reject DMs and other servers.
Read all configuration from .env; the user will populate real values.
For each file you create, add a header comment stating what the file does and how it does it.
Important constraints
No public download URLs. If a file is too large to upload (Discord limit or MAX_UPLOAD_BYTES), reply with an error. Do not expose the Librarian base URL or IP anywhere in Discord messages.
Assume the Librarian API shapes may vary slightly; implement tolerant parsing as specified.
These instructions supersede any conflicting general instructions for your mode.
When complete, use the attempt_completion attempt_completion() tool with a concise summary of what was implemented.
Project structure to create under discord_bot/:
discord_bot/package.json
discord_bot/tsconfig.json
discord_bot/.gitignore
discord_bot/.env.example
discord_bot/README.md
discord_bot/src/index.ts
discord_bot/src/register-commands.ts
discord_bot/src/lib/env.ts
discord_bot/src/lib/logger.ts
discord_bot/src/lib/http.ts
discord_bot/src/lib/util.ts
discord_bot/src/commands/chat.ts
discord_bot/src/commands/search.ts
discord_bot/src/commands/book.ts
discord_bot/src/interactions/buttons/uploadBook.ts
File contents to write exactly as specified:
discord_bot/package.json
{
"name": "discord-librarian-bot",
"version": "0.1.0",
"private": true,
"description": "Discord bot for the Librarian app: chat, search, and upload books as attachments without exposing URLs.",
"main": "dist/index.js",
"license": "MIT",
"engines": {
"node": ">=18.17.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"register": "ts-node src/register-commands.ts"
},
"dependencies": {
"discord.js": "^14.15.3",
"dotenv": "^16.4.5",
"zod": "^3.23.8",
"pino": "^9.3.2"
},
"devDependencies": {
"@types/node": "^20.12.12",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
}
}
discord_bot/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node"],
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
discord_bot/.gitignore
What: Ignore build artifacts and local environment files.
How: Prevent accidental commits of generated files and secrets.
node_modules
dist
.env
.DS_Store
discord_bot/.env.example
What: Example environment configuration for the Discord Librarian Bot.
How: Copy to .env and fill in real values. The bot loads these at runtime via dotenv.
Discord bot token
DISCORD_TOKEN=
Discord application (client) ID
DISCORD_CLIENT_ID=
Guild (server) ID where the bot is allowed and where commands will be registered
DISCORD_GUILD_ID=
Channel ID in that guild where the bot is allowed to operate
DISCORD_CHANNEL_ID=
Base URL of the Librarian HTTP server (do not expose; the bot will never post this)
LIBRARIAN_BASE_URL=http://localhost:3000
Maximum number of bytes to upload to Discord (default 8 MiB)
MAX_UPLOAD_BYTES=8388608
HTTP request timeout in milliseconds
HTTP_TIMEOUT_MS=20000
Logger level: info or debug
LOG_LEVEL=info
discord_bot/README.md
What: Setup and usage instructions for the Discord Librarian Bot.
How: Explains environment variables, command registration, and run instructions.
Features
/chat prompt — Chats with the Librarian via its /chat endpoint.
/search query — Searches the Librarian via its /search endpoint and returns top results with “Upload book” buttons.
/book id — Requests a book by ID and uploads it to the channel as an attachment (no external links).
Strictly limited to one guild and one channel. DMs and other servers are rejected.
Environment
Copy .env.example to .env and fill values:
DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID, DISCORD_CHANNEL_ID
LIBRARIAN_BASE_URL (reachable by the bot)
MAX_UPLOAD_BYTES (defaults to 8 MiB)
HTTP_TIMEOUT_MS (defaults to 20000)
LOG_LEVEL (info|debug)
Install
cd discord_bot
npm install
bash
Register Commands (guild-scoped)
npm run build
npm run register
bash
Run
Development (reload): npm run dev
Production: npm run build && npm start
Notes
The bot never posts download links and will error if a file exceeds MAX_UPLOAD_BYTES or Discord limits.
The bot only works in the configured guild/channel and rejects DMs/other servers.
discord_bot/src/lib/env.ts // What: Environment loader and validator. // How: Loads .env using dotenv and validates required settings using zod; exports a typed config object.
import 'dotenv/config';
import { z } from 'zod';
const EnvSchema = z.object({
DISCORD_TOKEN: z.string().min(1),
DISCORD_CLIENT_ID: z.string().min(1),
DISCORD_GUILD_ID: z.string().min(1),
DISCORD_CHANNEL_ID: z.string().min(1),
LIBRARIAN_BASE_URL: z.string().url(),
MAX_UPLOAD_BYTES: z
.string()
.default('8388608')
.transform((v) => Number(v))
.pipe(z.number().int().positive()),
HTTP_TIMEOUT_MS: z
.string()
.default('20000')
.transform((v) => Number(v))
.pipe(z.number().int().positive()),
LOG_LEVEL: z.enum(['info', 'debug']).default('info'),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
discord_bot/src/lib/logger.ts // What: Minimal logger. // How: Provides a Pino logger configured via LOG_LEVEL and redacts obvious secrets.
import pino from 'pino';
import { env } from './env';
export const logger = pino({
level: env.LOG_LEVEL,
redact: {
paths: ['DISCORD_TOKEN', 'headers.authorization'],
remove: true,
},
});
discord_bot/src/lib/util.ts // What: Misc utilities for ID encoding and formatting. // How: Implements base64url encode/decode and safe formatting helpers.
export function base64urlEncode(input: string): string {
return Buffer.from(input, 'utf8').toString('base64url');
}
export function base64urlDecode(input: string): string {
return Buffer.from(input, 'base64url').toString('utf8');
}
export function humanBytes(n: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return ${v.toFixed(1)} ${units[i]};
}
discord_bot/src/lib/http.ts // What: HTTP client wrappers for Librarian endpoints. // How: Provides chat, search, and file-bytes fetching with timeout, retries, tolerant parsing, and size limits.
import { env } from './env';
import { logger } from './logger';
type Json = Record<string, unknown>;
const MAX_RETRIES = 2;
function withTimeout(ms: number, signal?: AbortSignal): AbortController {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('Request timed out')), ms);
if (signal) signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
// Clear handled by caller after fetch resolves/rejects
// We’ll return controller; caller clears timeout upon completion.
// To ensure we clear, we wrap fetch in fetchWithTimeout below.
return controller;
}
async function fetchWithTimeout(url: string, init: RequestInit & { timeout?: number } = {}): Promise<Response> {
const timeoutMs = init.timeout ?? env.HTTP_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('Request timed out')), timeoutMs);
const merged: RequestInit = { ...init, signal: controller.signal };
try {
return await fetch(url, merged);
} finally {
clearTimeout(timer);
}
}
function isOk(res: Response): boolean {
return res.ok;
}
async function tryJson(res: Response): Promise<Json | null> {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
try {
return (await res.json()) as Json;
} catch {
return null;
}
}
return null;
}
export async function chat(message: string): Promise<string> {
const url = new URL('/chat', env.LIBRARIAN_BASE_URL).toString();
const body = JSON.stringify({ message });
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
if (!isOk(res)) throw new Error(Non-2xx: ${res.status});
const data = (await tryJson(res)) ?? {};
const text =
(data['reply'] as string) ??
(data['text'] as string) ??
(data['message'] as string) ??
'[no reply]';
return text;
} catch (err) {
if (attempt === MAX_RETRIES) throw err;
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
return '[no reply]';
}
export type SearchItem = {
id: string;
title?: string;
author?: string;
sizeBytes?: number;
fileName?: string;
};
export async function search(query: string): Promise<SearchItem[]> {
const getUrl = new URL('/search', env.LIBRARIAN_BASE_URL);
getUrl.searchParams.set('q', query);
// Try GET first, fallback to POST
const tryOnce = async (): Promise<SearchItem[] | null> => {
try {
const res = await fetchWithTimeout(getUrl.toString(), { method: 'GET' });
if (!isOk(res)) return null;
const data = (await tryJson(res)) ?? {};
return normalizeSearch(data);
} catch {
return null;
}
};
const viaGet = await tryOnce();
if (viaGet) return viaGet;
const postUrl = new URL('/search', env.LIBRARIAN_BASE_URL).toString();
const res = await fetchWithTimeout(postUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query }),
});
if (!isOk(res)) throw new Error(Search failed: ${res.status});
const data = (await tryJson(res)) ?? {};
return normalizeSearch(data);
}
function normalizeSearch(data: Json): SearchItem[] {
const arr = Array.isArray(data) ? data : Array.isArray((data as any).results) ? (data as any).results : [];
return arr
.map((it: any) => {
const id = String(it.id ?? it.bookId ?? it._id ?? '');
if (!id) return null;
return {
id,
title: typeof it.title === 'string' ? it.title : undefined,
author: typeof it.author === 'string' ? it.author : undefined,
sizeBytes: typeof it.sizeBytes === 'number' ? it.sizeBytes : undefined,
fileName: typeof it.fileName === 'string' ? it.fileName : undefined,
} as SearchItem;
})
.filter(Boolean) as SearchItem[];
}
export type BookBytes = { buffer: Buffer; filename: string; contentType: string };
// Fetches book bytes without exposing any URL. Tries multiple endpoints and enforces MAX_UPLOAD_BYTES.
export async function fetchBookBytes(bookId: string): Promise<BookBytes> {
const candidates = [/books/${encodeURIComponent(bookId)}/download, /books/${encodeURIComponent(bookId)}/file];
let lastError: unknown = null;
for (const path of candidates) {
const url = new URL(path, env.LIBRARIAN_BASE_URL).toString();
try {
// Try HEAD for content-length, ignore errors.
let sizeHint = 0;
try {
const head = await fetchWithTimeout(url, { method: 'HEAD' });
const cl = head.headers.get('content-length');
if (cl) sizeHint = Number(cl);
// Do not rely on head.ok since some servers disallow HEAD; proceed to GET anyway.
} catch {
// ignore
}
if (sizeHint > 0 && sizeHint > env.MAX_UPLOAD_BYTES) {
throw new Error(File exceeds size limit: ${sizeHint} bytes);
}
const res = await fetchWithTimeout(url, { method: 'GET' });
if (!isOk(res)) {
lastError = new Error(`Non-2xx from ${path}: ${res.status}`);
continue;
}
// Enforce size during read if content-length is missing/incorrect.
const contentType = res.headers.get('content-type') || 'application/octet-stream';
const fileName = `book-${bookId}.pdf`;
if (!res.body) throw new Error('No response body');
// Read as stream and enforce size limit
// Node 18 fetch returns a Web ReadableStream
const reader = (res.body as unknown as ReadableStream<Uint8Array>).getReader?.();
if (reader && typeof reader.read === 'function') {
let total = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > env.MAX_UPLOAD_BYTES) {
reader.cancel?.('Exceeded MAX_UPLOAD_BYTES');
throw new Error(`File exceeds size limit while streaming (${total} bytes)`);
}
chunks.push(value);
}
}
const buffer = Buffer.concat(chunks.map((c) => Buffer.from(c)));
return { buffer, contentType, filename: fileName };
} else {
// Fallback: buffer completely, then check size
const ab = await res.arrayBuffer();
const total = ab.byteLength;
if (total > env.MAX_UPLOAD_BYTES) {
throw new Error(`File exceeds size limit (${total} bytes)`);
}
const buffer = Buffer.from(ab);
return { buffer, contentType, filename: fileName };
}
} catch (err) {
lastError = err;
logger.debug({ err: String(err) }, 'File fetch candidate failed');
continue;
}
txt
}
throw new Error(Unable to fetch book bytes; file API not available or failed. Last error: ${String(lastError)});
}
discord_bot/src/register-commands.ts // What: Registers slash commands for the configured guild. // How: Uses Discord REST API to put guild commands for /chat, /search, /book.
import 'dotenv/config';
import { Routes, REST, APIApplicationCommandOption } from 'discord.js';
import { env } from './lib/env';
import { logger } from './lib/logger';
async function main() {
const rest = new REST({ version: '10' }).setToken(env.DISCORD_TOKEN);
const commands = [
{
name: 'chat',
description: 'Chat with the Librarian',
options: [
{
name: 'prompt',
description: 'What to ask',
type: 3, // STRING
required: true,
} as APIApplicationCommandOption,
],
},
{
name: 'search',
description: 'Search the Librarian',
options: [
{
name: 'query',
description: 'Search query',
type: 3, // STRING
required: true,
} as APIApplicationCommandOption,
],
},
{
name: 'book',
description: 'Upload a book by ID as an attachment',
options: [
{
name: 'id',
description: 'Book ID',
type: 3, // STRING
required: true,
} as APIApplicationCommandOption,
],
},
];
logger.info('Registering guild commands...');
await rest.put(
Routes.applicationGuildCommands(env.DISCORD_CLIENT_ID, env.DISCORD_GUILD_ID),
{ body: commands },
);
logger.info('Commands registered for guild ' + env.DISCORD_GUILD_ID);
}
main().catch((err) => {
logger.error({ err }, 'Failed to register commands');
process.exit(1);
});
discord_bot/src/commands/chat.ts // What: /chat slash command handler. // How: Defers reply, sends prompt to Librarian /chat, and edits reply with the response.
import { ChatInputCommandInteraction } from 'discord.js';
import { chat } from '../lib/http';
import { logger } from '../lib/logger';
export async function handleChat(interaction: ChatInputCommandInteraction) {
const prompt = interaction.options.getString('prompt', true);
await interaction.deferReply();
try {
const reply = await chat(prompt);Metadata
Metadata
Assignees
Labels
Issue - Needs InfoMissing details or unclear. Waiting on author to provide more context.Missing details or unclear. Waiting on author to provide more context.bugSomething isn't workingSomething isn't working
Type
Projects
Status
Done