@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/.env.example +26 -0
- package/LICENSE +662 -0
- package/NOTICE +3 -0
- package/README.md +230 -0
- package/bot.mjs +810 -0
- package/lib/helpers.mjs +150 -0
- package/package.json +54 -0
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
|
+
}
|