@skyphusion/sidvicious-exe 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bot.mjs ADDED
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env node
2
+ // bot.mjs
3
+ // SidVicious_exe -- punk rock Discord roadie for web search and image generation.
4
+ //
5
+ // Required Discord Developer Portal settings:
6
+ // Bot -> Privileged Gateway Intents -> MESSAGE CONTENT: ON
7
+ // OAuth2 -> URL Generator -> scopes: bot, applications.commands
8
+ // permissions: Send Messages, Read Message History, Attach Files
9
+ //
10
+ // Config (all via env):
11
+ // DISCORD_TOKEN (required) Discord application token from the Developer Portal
12
+ // DISCORD_CHANNEL_IDS comma-separated channel IDs to listen in;
13
+ // if empty, only DMs and @mentions are answered
14
+ // DISCORD_MODEL chat model (default anthropic/claude-sonnet-4-6)
15
+ // DISCORD_HISTORY rolling history depth in exchange pairs (default 20)
16
+ // DISCORD_LOG tee logs to this file path (optional)
17
+ //
18
+ // Cloudflare (one token for chat, images, and optional D1):
19
+ // CF_ACCOUNT_ID Cloudflare account ID
20
+ // CF_API_TOKEN API token with AI Gateway permission (alias: CF_AIG_TOKEN)
21
+ // CF_AIG_GATEWAY_ID AI Gateway name (default: skyphusion-llm)
22
+ // CF_GATEWAY_ENDPOINT Full compat URL (optional; built from account + gateway id)
23
+ // CF_D1_DATABASE_ID D1 database ID (optional, for session persistence)
24
+ // CF_D1_TOKEN D1 token if different from CF_API_TOKEN (optional)
25
+ //
26
+ // OLLAMA_BASE_URL ollama fallback when CF_API_TOKEN is unset
27
+ // SEARCH_WORKER_URL search Worker base URL (optional)
28
+ // SEARCH_SECRET shared secret for X-Search-Secret header (optional)
29
+ //
30
+ // ! commands:
31
+ // !image <prompt> generate an image from a text prompt
32
+ // !model [name|id] show available image models / switch the active one
33
+ // !learn <text or URL> index a reference into the knowledge base
34
+ // !reset clear conversation history
35
+ //
36
+ // Slash commands: /image /model /learn /reset
37
+ // (registered globally on startup; guild propagation is instant, global takes ~1 hour)
38
+
39
+ import Anthropic from '@anthropic-ai/sdk';
40
+ import { AttachmentBuilder, Client, GatewayIntentBits, Partials, Events, REST, Routes, SlashCommandBuilder } from 'discord.js';
41
+ import { appendFileSync, existsSync } from 'node:fs';
42
+ import { loadEnvFile } from 'node:process';
43
+ import {
44
+ DEFAULT_IMAGE_MODEL,
45
+ IMAGE_MODELS,
46
+ MULTIPART_IMAGE_MODELS,
47
+ anthropicBaseFromGatewayEndpoint,
48
+ buildGatewayCompatEndpoint,
49
+ flattenForOllama,
50
+ formatModelList,
51
+ freshSession,
52
+ normalizeChatModel,
53
+ normalizeSession,
54
+ resolveImageModel,
55
+ sanitizeErrorMessage,
56
+ splitMessage,
57
+ stripThink,
58
+ trimHistory,
59
+ } from './lib/helpers.mjs';
60
+
61
+ if (existsSync('.env')) loadEnvFile('.env');
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Logging
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const LOG_FILE = process.env.DISCORD_LOG ?? '';
68
+
69
+ function log(...args) {
70
+ const line = `[${new Date().toISOString()}] ${args.join(' ')}`;
71
+ console.log(line);
72
+ if (LOG_FILE) try { appendFileSync(LOG_FILE, line + '\n'); } catch {}
73
+ }
74
+
75
+ if (!process.env.DISCORD_TOKEN) {
76
+ log('ERROR: DISCORD_TOKEN is required');
77
+ process.exit(1);
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Config
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const CFG = {
85
+ token: process.env.DISCORD_TOKEN,
86
+ ollamaBase: process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434/v1',
87
+ model: process.env.DISCORD_MODEL ?? (process.env.CF_API_TOKEN || process.env.CF_AIG_TOKEN ? 'anthropic/claude-sonnet-4-6' : 'qwen3:8b'),
88
+ channelIds: new Set((process.env.DISCORD_CHANNEL_IDS ?? '').split(',').filter(Boolean)),
89
+ historyLen: parseInt(process.env.DISCORD_HISTORY ?? '20', 10),
90
+ cfAccountId: process.env.CF_ACCOUNT_ID ?? process.env.CF_D1_ACCOUNT_ID ?? '',
91
+ apiToken: process.env.CF_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? '',
92
+ aigGatewayId: process.env.CF_AIG_GATEWAY_ID ?? 'skyphusion-llm',
93
+ gatewayEndpoint: process.env.CF_GATEWAY_ENDPOINT ?? '',
94
+ d1Token: process.env.CF_D1_TOKEN ?? process.env.CF_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? '',
95
+ d1AccountId: process.env.CF_D1_ACCOUNT_ID ?? process.env.CF_ACCOUNT_ID ?? '',
96
+ d1DatabaseId: process.env.CF_D1_DATABASE_ID ?? '',
97
+ searchUrl: process.env.SEARCH_WORKER_URL ?? '',
98
+ searchSecret: process.env.SEARCH_SECRET ?? '',
99
+ };
100
+
101
+ const CF_AI_BASE = CFG.cfAccountId
102
+ ? `https://api.cloudflare.com/client/v4/accounts/${CFG.cfAccountId}/ai`
103
+ : '';
104
+ const CF_AI_V1 = CF_AI_BASE ? `${CF_AI_BASE}/v1` : '';
105
+ const CF_AI_RUN = CF_AI_BASE ? `${CF_AI_BASE}/run` : '';
106
+
107
+ const gatewayCompatEndpoint = CFG.gatewayEndpoint
108
+ || buildGatewayCompatEndpoint(CFG.cfAccountId, CFG.aigGatewayId);
109
+ const anthropicBase = gatewayCompatEndpoint
110
+ ? anthropicBaseFromGatewayEndpoint(gatewayCompatEndpoint)
111
+ : '';
112
+ const useGatewayAnthropic = Boolean(anthropicBase);
113
+
114
+ const chatModel = normalizeChatModel(CFG.model, useGatewayAnthropic);
115
+
116
+ // #39 audit: SDK/fetch errors can embed request URLs carrying the account id
117
+ // (the gateway base URL). Scrub configured identifiers from anything echoed
118
+ // into a Discord reply; the raw message still goes to the local log.
119
+ const SENSITIVE_VALUES = [CFG.cfAccountId, CFG.apiToken, CFG.d1Token, CFG.searchSecret].filter(Boolean);
120
+ const scrub = (message) => sanitizeErrorMessage(message, SENSITIVE_VALUES);
121
+
122
+ const anthropic = CFG.apiToken && (useGatewayAnthropic || CF_AI_V1)
123
+ ? new Anthropic({
124
+ apiKey: CFG.apiToken,
125
+ baseURL: useGatewayAnthropic ? anthropicBase : CF_AI_V1,
126
+ ...(useGatewayAnthropic ? {} : { defaultHeaders: { 'cf-aig-gateway-id': CFG.aigGatewayId } }),
127
+ })
128
+ : null;
129
+
130
+ const imageGenReady = Boolean(CFG.apiToken && CFG.cfAccountId);
131
+ // Log a backend label, not the resolved URL: the URL embeds the Cloudflare
132
+ // account ID from the environment (flagged by CodeQL js/clear-text-logging).
133
+ const chatBackend = anthropic ? (useGatewayAnthropic ? 'cf-gateway-anthropic' : 'workers-ai-v1') : 'ollama';
134
+
135
+ log(`Starting SidVicious_exe: model=${chatModel} backend=${chatBackend} gateway=${CFG.aigGatewayId} images=${imageGenReady ? 'on' : 'off'} channels=${CFG.channelIds.size || 'DMs+mentions only'}`);
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Image model catalog
139
+ // ---------------------------------------------------------------------------
140
+
141
+ // Catalog + resolution live in lib/helpers.mjs (#39).
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Session state -- persisted in Cloudflare D1 (REST API), cached in-memory
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const sessions = new Map();
148
+
149
+ async function d1Query(sql, params = []) {
150
+ if (!CFG.d1Token) throw new Error('CF_D1_TOKEN not configured');
151
+ const url = `https://api.cloudflare.com/client/v4/accounts/${CFG.d1AccountId}/d1/database/${CFG.d1DatabaseId}/query`;
152
+ const res = await fetch(url, {
153
+ method: 'POST',
154
+ headers: { 'Authorization': `Bearer ${CFG.d1Token}`, 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ sql, params }),
156
+ });
157
+ if (!res.ok) {
158
+ const body = await res.text().catch(() => '');
159
+ throw new Error(`D1 ${res.status}: ${body.slice(0, 200)}`);
160
+ }
161
+ const data = await res.json();
162
+ if (!data.success) throw new Error(`D1 error: ${JSON.stringify(data.errors)}`);
163
+ return data.result?.[0]?.results ?? [];
164
+ }
165
+
166
+ async function initD1() {
167
+ if (!CFG.d1Token) return;
168
+ try {
169
+ await d1Query(`CREATE TABLE IF NOT EXISTS sessions (
170
+ channel_id TEXT PRIMARY KEY,
171
+ data TEXT NOT NULL,
172
+ updated_at TEXT NOT NULL
173
+ )`);
174
+ log('D1 tables ready');
175
+ } catch (e) {
176
+ log(`WARN: D1 init failed: ${e.message}`);
177
+ }
178
+ }
179
+
180
+ async function loadSession(channelId) {
181
+ try {
182
+ const rows = await d1Query('SELECT data FROM sessions WHERE channel_id = ?', [channelId]);
183
+ if (rows.length === 0) return null;
184
+ const data = JSON.parse(rows[0].data);
185
+ sessions.set(channelId, data);
186
+ return data;
187
+ } catch (e) {
188
+ log(`ERROR loading session ${channelId}: ${e.message}`);
189
+ return null;
190
+ }
191
+ }
192
+
193
+ async function getSession(channelId) {
194
+ if (!sessions.has(channelId)) {
195
+ const loaded = await loadSession(channelId);
196
+ if (!loaded) sessions.set(channelId, freshSession());
197
+ }
198
+ return normalizeSession(sessions.get(channelId));
199
+ }
200
+
201
+ async function saveSession(channelId) {
202
+ try {
203
+ const session = sessions.get(channelId);
204
+ if (!session) return;
205
+ const now = new Date().toISOString();
206
+ await d1Query(
207
+ 'INSERT INTO sessions (channel_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(channel_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at',
208
+ [channelId, JSON.stringify(session), now],
209
+ );
210
+ } catch (e) {
211
+ log(`ERROR saving session ${channelId}: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // System prompt
217
+ // ---------------------------------------------------------------------------
218
+
219
+ const SYSTEM_PROMPT = `You are Sid Vicious_exe, a punk rock roadie on Discord with attitude and a soft spot for anyone doing something real.
220
+
221
+ Personality:
222
+ - Raw, direct, irreverent. No corporate speak, no sycophancy, no "I'd be happy to help!"
223
+ - You love punk, post-punk, garage, noise, DIY culture, zines, basement shows, and anything that smells like rebellion
224
+ - Short sentences hit harder than paragraphs. Swearing is fine when it fits. Never punch down.
225
+ - You're helpful underneath the leather jacket. If someone needs facts or a visual, you deliver.
226
+
227
+ Capabilities:
228
+ - Web search, deep research, and page fetching when you need current info or sources
229
+ - A knowledge base of stuff people have fed you with !learn (search it when relevant)
230
+ - Image generation when configured (!image or /image, or the generate_image tool via Workers AI / AI Gateway)
231
+ - Vision: users can paste images and you'll read them
232
+
233
+ Commands users can type:
234
+ - !image <prompt> / /image -- generate a picture
235
+ - !model [name] / /model -- list or switch image models
236
+ - !learn <text or URL> / /learn -- stash a reference in the knowledge base
237
+ - !reset / /reset -- wipe the conversation and start fresh
238
+
239
+ Stay in character. Be useful. Don't lecture about punk gatekeeping.`;
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // LLM helpers
243
+ // ---------------------------------------------------------------------------
244
+
245
+ async function callOllama(system, conversationMessages) {
246
+ const messages = flattenForOllama(conversationMessages);
247
+ const res = await fetch(`${CFG.ollamaBase}/chat/completions`, {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ollama' },
250
+ body: JSON.stringify({
251
+ model: CFG.model,
252
+ messages: [{ role: 'system', content: system }, ...messages],
253
+ stream: false,
254
+ }),
255
+ });
256
+ if (!res.ok) {
257
+ const body = await res.text().catch(() => '');
258
+ throw new Error(`ollama ${res.status}: ${body}`);
259
+ }
260
+ const data = await res.json();
261
+ return stripThink(data.choices?.[0]?.message?.content ?? '') || '(no response)';
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Tools (search Worker + image generation)
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function buildTools() {
269
+ const tools = [];
270
+ if (CFG.searchUrl) {
271
+ tools.push(
272
+ {
273
+ name: 'web_search',
274
+ description: 'Search the web for quick facts, current events, news, or general information.',
275
+ input_schema: { type: 'object', properties: { query: { type: 'string', description: 'The search query' } }, required: ['query'] },
276
+ },
277
+ {
278
+ name: 'research',
279
+ description: 'Deep AI-curated research on a topic. Returns a synthesized answer plus sources.',
280
+ input_schema: { type: 'object', properties: { query: { type: 'string', description: 'The research question' } }, required: ['query'] },
281
+ },
282
+ {
283
+ name: 'fetch_page',
284
+ description: 'Fetch and read the full content of a specific URL.',
285
+ input_schema: { type: 'object', properties: { url: { type: 'string', description: 'The URL to fetch' } }, required: ['url'] },
286
+ },
287
+ {
288
+ name: 'search_knowledge',
289
+ description: 'Search the knowledge base for anything previously added with !learn.',
290
+ input_schema: { type: 'object', properties: { query: { type: 'string', description: 'What to search for' } }, required: ['query'] },
291
+ },
292
+ );
293
+ }
294
+ if (imageGenReady) {
295
+ tools.push({
296
+ name: 'generate_image',
297
+ description: 'Generate an image from a text prompt. Use when the user wants a picture, poster, album art, flyer, or any visual.',
298
+ input_schema: { type: 'object', properties: { prompt: { type: 'string', description: 'Detailed image prompt' } }, required: ['prompt'] },
299
+ });
300
+ }
301
+ return tools;
302
+ }
303
+
304
+ async function executeTool(name, input, ctx) {
305
+ if (name === 'generate_image') {
306
+ if (!imageGenReady) return 'Image generation not configured.';
307
+ log(`[image] tool: ${input.prompt?.slice(0, 80)}`);
308
+ const result = await generateImage(input.prompt, ctx.imageModel, 'tool');
309
+ if (!result.ok) return result.error;
310
+ ctx.generatedImages.push(result);
311
+ return `Image generated successfully (${result.buffer.length} bytes). Tell the user it's attached.`;
312
+ }
313
+
314
+ if (!CFG.searchUrl || !CFG.searchSecret) return 'Search not configured.';
315
+ const headers = { 'Content-Type': 'application/json', 'X-Search-Secret': CFG.searchSecret };
316
+
317
+ if (name === 'web_search') {
318
+ log(`[search] web: ${input.query}`);
319
+ const res = await fetch(`${CFG.searchUrl}/search`, { method: 'POST', headers, body: JSON.stringify({ query: input.query, type: 'web' }) });
320
+ return res.ok ? res.json() : `Search error: ${res.status}`;
321
+ }
322
+ if (name === 'research') {
323
+ log(`[search] research: ${input.query}`);
324
+ const res = await fetch(`${CFG.searchUrl}/search`, { method: 'POST', headers, body: JSON.stringify({ query: input.query, type: 'research' }) });
325
+ return res.ok ? res.json() : `Research error: ${res.status}`;
326
+ }
327
+ if (name === 'fetch_page') {
328
+ log(`[search] fetch: ${input.url}`);
329
+ const res = await fetch(`${CFG.searchUrl}/fetch`, { method: 'POST', headers, body: JSON.stringify({ url: input.url }) });
330
+ return res.ok ? res.json() : `Fetch error: ${res.status}`;
331
+ }
332
+ if (name === 'search_knowledge') {
333
+ log(`[search] knowledge: ${input.query}`);
334
+ const res = await fetch(`${CFG.searchUrl}/knowledge/search`, { method: 'POST', headers, body: JSON.stringify({ query: input.query }) });
335
+ return res.ok ? res.json() : `Knowledge search error: ${res.status}`;
336
+ }
337
+ return 'Unknown tool';
338
+ }
339
+
340
+ async function callAI(system, conversationMessages, ctx = { imageModel: DEFAULT_IMAGE_MODEL, generatedImages: [] }) {
341
+ if (anthropic) {
342
+ const tools = buildTools();
343
+ let messages = [...conversationMessages];
344
+
345
+ for (let round = 0; round < 5; round++) {
346
+ const msg = await anthropic.messages.create({
347
+ model: chatModel,
348
+ system,
349
+ messages,
350
+ max_tokens: 4096,
351
+ ...(tools.length ? { tools } : {}),
352
+ });
353
+
354
+ if (msg.stop_reason !== 'tool_use') {
355
+ return {
356
+ text: stripThink(msg.content.find(b => b.type === 'text')?.text ?? '') || '(no response)',
357
+ images: ctx.generatedImages,
358
+ };
359
+ }
360
+
361
+ const toolResults = [];
362
+ for (const block of msg.content.filter(b => b.type === 'tool_use')) {
363
+ const result = await executeTool(block.name, block.input, ctx).catch(e => ({ error: e.message }));
364
+ toolResults.push({
365
+ type: 'tool_result',
366
+ tool_use_id: block.id,
367
+ content: typeof result === 'string' ? result : JSON.stringify(result),
368
+ });
369
+ }
370
+
371
+ messages = [
372
+ ...messages,
373
+ { role: 'assistant', content: msg.content },
374
+ { role: 'user', content: toolResults },
375
+ ];
376
+ }
377
+
378
+ const final = await anthropic.messages.create({ model: chatModel, system, messages, max_tokens: 4096 });
379
+ return {
380
+ text: stripThink(final.content.find(b => b.type === 'text')?.text ?? '') || '(no response)',
381
+ images: ctx.generatedImages,
382
+ };
383
+ }
384
+
385
+ const text = await callOllama(system, conversationMessages);
386
+ return { text, images: [] };
387
+ }
388
+
389
+ async function askLLM(channelId, userText, imageBlocks = []) {
390
+ const session = await getSession(channelId);
391
+ const ctx = { imageModel: session.imageModel, generatedImages: [] };
392
+
393
+ const userContent = imageBlocks.length > 0 && anthropic
394
+ ? [...imageBlocks, { type: 'text', text: userText }]
395
+ : userText;
396
+
397
+ return callAI(SYSTEM_PROMPT, [
398
+ ...session.history,
399
+ { role: 'user', content: userContent },
400
+ ], ctx);
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Image generation (Workers AI + AI Gateway REST API)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ function cfAiHeaders(extra = {}) {
408
+ return {
409
+ Authorization: `Bearer ${CFG.apiToken}`,
410
+ 'cf-aig-gateway-id': CFG.aigGatewayId,
411
+ ...extra,
412
+ };
413
+ }
414
+
415
+ async function bufferFromImageField(image) {
416
+ if (typeof image !== 'string' || !image) return null;
417
+
418
+ if (image.startsWith('http://') || image.startsWith('https://')) {
419
+ const res = await fetch(image);
420
+ if (!res.ok) return null;
421
+ const mime = res.headers.get('content-type') ?? 'image/png';
422
+ const buffer = Buffer.from(await res.arrayBuffer());
423
+ const ext = mime.includes('jpeg') || mime.includes('jpg') ? 'jpg'
424
+ : mime.includes('webp') ? 'webp' : 'png';
425
+ return { buffer, ext, mime, artifactUrl: image };
426
+ }
427
+
428
+ const buffer = Buffer.from(image, 'base64');
429
+ return { buffer, ext: 'jpg', mime: 'image/jpeg' };
430
+ }
431
+
432
+ async function parseImageResponse(data, label) {
433
+ const payload = data?.result ?? data;
434
+ const image = payload?.image ?? payload?.images?.[0];
435
+ const parsed = await bufferFromImageField(image);
436
+ if (!parsed) return { ok: false, error: 'no image in response' };
437
+ log(`[${label}] done (${parsed.buffer.length} bytes)`);
438
+ return { ok: true, ...parsed };
439
+ }
440
+
441
+ async function generateImage(prompt, imageModel, label = 'image') {
442
+ if (!imageGenReady) {
443
+ return { ok: false, error: 'CF_API_TOKEN and CF_ACCOUNT_ID not configured' };
444
+ }
445
+
446
+ const model = imageModel ?? DEFAULT_IMAGE_MODEL;
447
+ log(`[${label}] generating model=${model} gateway=${CFG.aigGatewayId}`);
448
+
449
+ let res;
450
+
451
+ if (MULTIPART_IMAGE_MODELS.has(model)) {
452
+ const form = new FormData();
453
+ form.append('prompt', prompt);
454
+ form.append('width', '1024');
455
+ form.append('height', '1024');
456
+ res = await fetch(`${CF_AI_RUN}/${encodeURIComponent(model)}`, {
457
+ method: 'POST',
458
+ headers: cfAiHeaders(),
459
+ body: form,
460
+ });
461
+ } else {
462
+ res = await fetch(CF_AI_RUN, {
463
+ method: 'POST',
464
+ headers: cfAiHeaders({ 'Content-Type': 'application/json' }),
465
+ body: JSON.stringify({ model, input: { prompt } }),
466
+ });
467
+ }
468
+
469
+ const data = await res.json().catch(() => ({}));
470
+ if (!res.ok || data.success === false) {
471
+ const err = JSON.stringify(data.errors ?? data).slice(0, 300);
472
+ return { ok: false, error: `image gen failed ${res.status}: ${err}` };
473
+ }
474
+
475
+ return parseImageResponse(data, label);
476
+ }
477
+
478
+ function imagesToAttachments(images, prefix = 'sid') {
479
+ return images.map((img, i) =>
480
+ new AttachmentBuilder(img.buffer, { name: `${prefix}-${i + 1}.${img.ext}` }),
481
+ );
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // Knowledge base (via search Worker + Vectorize)
486
+ // ---------------------------------------------------------------------------
487
+
488
+ async function indexKnowledge(content, title = '', author = '') {
489
+ if (!CFG.searchUrl || !CFG.searchSecret) return { ok: false, error: 'Search worker not configured' };
490
+
491
+ let text = content;
492
+ let resolvedTitle = title || content.slice(0, 80);
493
+
494
+ if (content.startsWith('http://') || content.startsWith('https://')) {
495
+ try {
496
+ const fetched = await executeTool('fetch_page', { url: content }, { generatedImages: [] });
497
+ const data = typeof fetched === 'string' ? JSON.parse(fetched) : fetched;
498
+ text = data.content ?? content;
499
+ resolvedTitle = data.title || content.slice(0, 80);
500
+ } catch (e) {
501
+ log(`[learn] page fetch failed: ${e.message}`);
502
+ }
503
+ }
504
+
505
+ const res = await fetch(`${CFG.searchUrl}/knowledge/index`, {
506
+ method: 'POST',
507
+ headers: { 'Content-Type': 'application/json', 'X-Search-Secret': CFG.searchSecret },
508
+ body: JSON.stringify({ content: text, title: resolvedTitle, author }),
509
+ });
510
+ if (!res.ok) return { ok: false, error: `index failed ${res.status}` };
511
+ const data = await res.json();
512
+ return { ok: true, id: data.id, title: resolvedTitle, words: text.split(/\s+/).length };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Message chunking (Discord 2000 char limit)
517
+ // ---------------------------------------------------------------------------
518
+
519
+ // splitMessage lives in lib/helpers.mjs (#39).
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Slash command definitions
523
+ // ---------------------------------------------------------------------------
524
+
525
+ const SLASH_COMMANDS = [
526
+ new SlashCommandBuilder()
527
+ .setName('image')
528
+ .setDescription('Generate an image from a text prompt')
529
+ .addStringOption(o => o.setName('prompt').setDescription('What to generate').setRequired(true)),
530
+ new SlashCommandBuilder()
531
+ .setName('model')
532
+ .setDescription('Show or switch the active image generation model')
533
+ .addStringOption(o => o.setName('name').setDescription('Model alias or ID (omit to see list)').setRequired(false)),
534
+ new SlashCommandBuilder()
535
+ .setName('learn')
536
+ .setDescription('Index a reference into the knowledge base')
537
+ .addStringOption(o => o.setName('content').setDescription('Text or URL to index').setRequired(true)),
538
+ new SlashCommandBuilder()
539
+ .setName('reset')
540
+ .setDescription('Clear conversation history'),
541
+ ].map(c => c.toJSON());
542
+
543
+ async function registerSlashCommands(clientId) {
544
+ const rest = new REST({ version: '10' }).setToken(CFG.token);
545
+ try {
546
+ const data = await rest.put(Routes.applicationCommands(clientId), { body: SLASH_COMMANDS });
547
+ log(`Registered ${data.length} slash command(s) globally`);
548
+ } catch (e) {
549
+ log(`WARN: slash command registration failed: ${e.message}`);
550
+ }
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Discord client
555
+ // ---------------------------------------------------------------------------
556
+
557
+ const client = new Client({
558
+ intents: [
559
+ GatewayIntentBits.Guilds,
560
+ GatewayIntentBits.GuildMessages,
561
+ GatewayIntentBits.MessageContent,
562
+ GatewayIntentBits.DirectMessages,
563
+ ],
564
+ partials: [Partials.Channel, Partials.Message],
565
+ });
566
+
567
+ client.once(Events.ClientReady, async (c) => {
568
+ log(`Ready: ${c.user.tag} (${c.guilds.cache.size} guild(s))`);
569
+ await registerSlashCommands(c.user.id);
570
+ });
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // Slash command handler
574
+ // ---------------------------------------------------------------------------
575
+
576
+ client.on(Events.InteractionCreate, async (interaction) => {
577
+ if (!interaction.isChatInputCommand()) return;
578
+
579
+ const channelId = interaction.channelId;
580
+ const authorName = interaction.member?.displayName ?? interaction.user.username;
581
+ log(`[slash:${interaction.commandName}] ${authorName} in ${channelId}`);
582
+
583
+ try {
584
+ switch (interaction.commandName) {
585
+
586
+ case 'image': {
587
+ const prompt = interaction.options.getString('prompt');
588
+ const session = await getSession(channelId);
589
+
590
+ if (!imageGenReady) {
591
+ await interaction.reply('Image generation is not configured. Set CF_API_TOKEN and CF_ACCOUNT_ID.');
592
+ return;
593
+ }
594
+
595
+ await interaction.deferReply();
596
+ const activeModel = IMAGE_MODELS.find(m => m.id === session.imageModel) ?? IMAGE_MODELS[0];
597
+ await interaction.editReply(`Cranking the amp. Generating with **${activeModel.label}**...`);
598
+
599
+ const result = await generateImage(prompt, session.imageModel, 'slash:image');
600
+ if (!result.ok) { await interaction.editReply(`Image generation failed: ${result.error}`); return; }
601
+
602
+ const att = new AttachmentBuilder(result.buffer, { name: `sid-image.${result.ext}` });
603
+ await interaction.editReply({ content: "Here. Don't say I never gave you nothing.", files: [att] });
604
+ break;
605
+ }
606
+
607
+ case 'model': {
608
+ const name = interaction.options.getString('name');
609
+ const session = await getSession(channelId);
610
+
611
+ if (!name) { await interaction.reply(formatModelList(session.imageModel)); return; }
612
+
613
+ const found = resolveImageModel(name);
614
+ if (!found) { await interaction.reply(`Never heard of \`${name}\`. Use \`/model\` to see what's on the rack.`); return; }
615
+
616
+ session.imageModel = found.id;
617
+ await saveSession(channelId);
618
+ await interaction.reply(`Switched to **${found.label}**. Let's make some noise.`);
619
+ break;
620
+ }
621
+
622
+ case 'learn': {
623
+ const content = interaction.options.getString('content');
624
+ await interaction.deferReply();
625
+ const result = await indexKnowledge(content, '', authorName);
626
+ if (result.ok) {
627
+ await interaction.editReply(`Stashed **${result.title}** (${result.words} words) in the knowledge base. I'll dig it up when it matters.`);
628
+ } else {
629
+ await interaction.editReply(`Couldn't index that: ${result.error}`);
630
+ }
631
+ break;
632
+ }
633
+
634
+ case 'reset': {
635
+ sessions.set(channelId, freshSession());
636
+ await saveSession(channelId);
637
+ log(`[${channelId}] session reset by ${authorName}`);
638
+ await interaction.reply('Memory wiped. Fresh start. What do you want?');
639
+ break;
640
+ }
641
+
642
+ }
643
+ } catch (e) {
644
+ log(`ERROR [slash:${interaction.commandName}]: ${e.message}`);
645
+ const reply = interaction.replied || interaction.deferred
646
+ ? interaction.editReply.bind(interaction)
647
+ : interaction.reply.bind(interaction);
648
+ await reply(`(error: ${scrub(e.message)})`).catch(() => {});
649
+ }
650
+ });
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Message handler
654
+ // ---------------------------------------------------------------------------
655
+
656
+ client.on(Events.MessageCreate, async (message) => {
657
+ if (message.author.bot) return;
658
+
659
+ const isDM = !message.guild;
660
+ const isMentioned = message.mentions.has(client.user);
661
+ const inListenChan = CFG.channelIds.size > 0 && CFG.channelIds.has(message.channelId);
662
+
663
+ if (!isDM && !isMentioned && !inListenChan) return;
664
+
665
+ const rawText = message.content
666
+ .replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '')
667
+ .trim();
668
+
669
+ const hasImages = message.attachments.some(a => a.contentType?.startsWith('image/'));
670
+ if (!rawText && !hasImages) return;
671
+
672
+ const channelId = message.channelId;
673
+ const authorName = message.member?.displayName ?? message.author.username;
674
+ const channelLabel = isDM ? 'DM' : `${message.guild.name}/#${message.channel.name ?? channelId}`;
675
+
676
+ log(`[${channelLabel}] ${authorName}: ${rawText.slice(0, 120)}${hasImages ? ` [+${message.attachments.size} image(s)]` : ''}`);
677
+
678
+ if (rawText === '!reset') {
679
+ sessions.set(channelId, freshSession());
680
+ await saveSession(channelId);
681
+ log(`[${channelId}] reset by ${authorName}`);
682
+ await message.reply('Memory wiped. Fresh start. What do you want?').catch(() => {});
683
+ return;
684
+ }
685
+
686
+ if (rawText.startsWith('!model')) {
687
+ const arg = rawText.slice('!model'.length).trim();
688
+ const session = await getSession(channelId);
689
+
690
+ if (!arg) { await message.reply(formatModelList(session.imageModel)).catch(() => {}); return; }
691
+
692
+ const found = resolveImageModel(arg);
693
+ if (!found) { await message.reply(`Never heard of \`${arg}\`. Use \`!model\` to see what's on the rack.`).catch(() => {}); return; }
694
+
695
+ session.imageModel = found.id;
696
+ await saveSession(channelId);
697
+ await message.reply(`Switched to **${found.label}**. Let's make some noise.`).catch(() => {});
698
+ return;
699
+ }
700
+
701
+ if (rawText.startsWith('!image')) {
702
+ const prompt = rawText.slice('!image'.length).trim();
703
+ if (!prompt) { await message.reply('Usage: `!image <prompt>`').catch(() => {}); return; }
704
+ if (!imageGenReady) {
705
+ await message.reply('Image generation is not configured. Set CF_API_TOKEN and CF_ACCOUNT_ID.').catch(() => {});
706
+ return;
707
+ }
708
+
709
+ const session = await getSession(channelId);
710
+ const activeModel = IMAGE_MODELS.find(m => m.id === session.imageModel) ?? IMAGE_MODELS[0];
711
+ await message.reply(`Cranking the amp. Generating with **${activeModel.label}**...`).catch(() => {});
712
+
713
+ const result = await generateImage(prompt, session.imageModel, 'cmd:image');
714
+ if (!result.ok) { await message.reply(`Image generation failed: ${result.error}`).catch(() => {}); return; }
715
+
716
+ const att = new AttachmentBuilder(result.buffer, { name: `sid-image.${result.ext}` });
717
+ await message.reply({ content: "Here. Don't say I never gave you nothing.", files: [att] }).catch(() => {});
718
+ return;
719
+ }
720
+
721
+ if (rawText.startsWith('!learn')) {
722
+ const content = rawText.slice('!learn'.length).trim();
723
+ if (!content) { await message.reply('Usage: `!learn <text or URL>`').catch(() => {}); return; }
724
+
725
+ await message.reply('Indexing...').catch(() => {});
726
+ const result = await indexKnowledge(content, '', authorName);
727
+ if (result.ok) {
728
+ await message.reply(`Stashed **${result.title}** (${result.words} words) in the knowledge base.`).catch(() => {});
729
+ } else {
730
+ await message.reply(`Couldn't index that: ${result.error}`).catch(() => {});
731
+ }
732
+ return;
733
+ }
734
+
735
+ // --- Conversation (with optional vision) ---
736
+
737
+ const imageBlocks = [];
738
+ if (anthropic) {
739
+ for (const att of [...message.attachments.values()].filter(a => a.contentType?.startsWith('image/') && a.size <= 4 * 1024 * 1024).slice(0, 3)) {
740
+ try {
741
+ const resp = await fetch(att.url);
742
+ if (resp.ok) {
743
+ const buf = Buffer.from(await resp.arrayBuffer());
744
+ imageBlocks.push({ type: 'image', source: { type: 'base64', media_type: att.contentType, data: buf.toString('base64') } });
745
+ log(`[vision] loaded ${att.url.split('/').pop()} (${buf.length} bytes)`);
746
+ }
747
+ } catch (e) {
748
+ log(`[vision] failed to fetch attachment: ${e.message}`);
749
+ }
750
+ }
751
+ }
752
+
753
+ try { await message.channel.sendTyping(); } catch {}
754
+ const typingInterval = setInterval(() => { message.channel.sendTyping().catch(() => {}); }, 8000);
755
+
756
+ try {
757
+ const userText = rawText || '(image attached)';
758
+ const userLabel = `${authorName}: ${userText}`;
759
+
760
+ const { text: reply, images: generatedImages } = await askLLM(channelId, userLabel, imageBlocks);
761
+
762
+ const session = await getSession(channelId);
763
+ const historyText = imageBlocks.length > 0 ? `[${imageBlocks.length} image(s)]\n${userLabel}` : userLabel;
764
+ session.history.push({ role: 'user', content: historyText });
765
+ session.history.push({ role: 'assistant', content: reply });
766
+ trimHistory(session.history, CFG.historyLen);
767
+ await saveSession(channelId);
768
+
769
+ log(`-> ${reply.slice(0, 120)}${reply.length > 120 ? '...' : ''}`);
770
+
771
+ const files = imagesToAttachments(generatedImages);
772
+ const chunks = splitMessage(reply);
773
+ if (chunks.length === 0 && files.length > 0) {
774
+ await message.reply({ content: 'Here.', files });
775
+ } else {
776
+ for (let i = 0; i < chunks.length; i++) {
777
+ const payload = i === chunks.length - 1 && files.length > 0
778
+ ? { content: chunks[i], files }
779
+ : chunks[i];
780
+ await message.reply(payload);
781
+ }
782
+ }
783
+ } catch (err) {
784
+ log(`ERROR: ${err.message}`);
785
+ await message.reply(`(error: ${scrub(err.message)})`).catch(() => {});
786
+ } finally {
787
+ clearInterval(typingInterval);
788
+ }
789
+ });
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // Startup
793
+ // ---------------------------------------------------------------------------
794
+
795
+ await initD1().catch(err => log(`D1 init notice: ${err.message}`));
796
+
797
+ if (process.env.VITEST) {
798
+ // #39 audit: the smoke previously called the REAL Discord REST API with a
799
+ // fake token from CI (slow, network-dependent, rate-limit noise). The smoke
800
+ // asserts config/parse/import health only; no network leaves the process.
801
+ log('CI mode: validating configuration...');
802
+ log(`SMOKE: commands=${SLASH_COMMANDS.length} backend=${chatBackend} images=${imageGenReady ? 'on' : 'off'}`);
803
+ log('SMOKE TEST PASSED: SidVicious_exe configuration verified.');
804
+ client.destroy();
805
+ } else {
806
+ client.login(CFG.token).catch(err => {
807
+ log(`Failed to connect to Discord: ${err.message}`);
808
+ process.exit(1);
809
+ });
810
+ }