diff --git a/discord-utils.mjs b/discord-utils.mjs new file mode 100644 index 0000000..86e4cc0 --- /dev/null +++ b/discord-utils.mjs @@ -0,0 +1,37 @@ + +export function split_discord_message(text) { + const MAX = 1900; // maximum payload size + const MIN = 100; // avoid tiny chunks + const chunks = []; + let start = 0; + + while (start < text.length) { + let end = Math.min(start + MAX, text.length); + + // Try to break on a newline first, then on a space + if (end < text.length) { + const nl = text.lastIndexOf('\n', end); + const sp = text.lastIndexOf(' ', end); + const splitPos = Math.max(nl, sp); + if (splitPos > start) end = splitPos + 1; // keep the delimiter + } + + // If the chunk is too short, pull in the next delimiter + if (end - start < MIN && end < text.length) { + const nextNL = text.indexOf('\n', end); + const nextSP = text.indexOf(' ', end); + const nextPos = Math.min( + nextNL === -1 ? Infinity : nextNL, + nextSP === -1 ? Infinity : nextSP + ); + if (nextPos !== Infinity && nextPos < text.length) { + end = nextPos + 1; + } + } + + chunks.push(text.slice(start, end)); + start = end; + } + + return chunks; +} diff --git a/discord.mjs b/discord.mjs index 207d6a6..20579a0 100644 --- a/discord.mjs +++ b/discord.mjs @@ -1,52 +1,33 @@ -// ──────────────────────────────────────────────────────────────────────────────── -// bot.js – Discord bot rewritten for Node 25+ and Ollama (via run_prompt2) -// ──────────────────────────────────────────────────────────────────────────────── +/* + + TODO: + - TODO-feature? + - Separate service for the duck persona with endpoints for: + + Emit random message to specific channel + + Change behavior + + Update system prompt + + Trivia mode + + Other future interaction modes + + Adjust triggers (like clanker) + + Extracting historic "thinking" + - Avoid emitting ANSWER: + - Allow DM? + GatewayIntentBits.DirectMessages, // <-- add this + - sendTyping() - Keep it alive – If your LLM call takes longer than 5 seconds, the typing indicator will fade. To keep it going, call channel.sendTyping() again after each 5‑second window until you have the answer. + - retain history on exit and load on start + +*/ import { Client, GatewayIntentBits, Partials, PermissionsBitField } from 'discord.js'; import * as SECRET from '/srv/Projekt/dicsordbot/secret.mjs'; - -/* -------------------------------------------------------------------------- */ -/* 1️⃣ Utility – run_prompt2 (Ollama helper) – the same you gave me */ -/* -------------------------------------------------------------------------- */ +import { run_prompt } from './run-prompt.mjs'; +import { split_discord_message } from './discord-utils.mjs'; const MODEL = 'gpt-oss:20b'; +const { API_TOKEN, PERMISSIONS, SERVER_ID, CHANNEL_ID } = SECRET; +const systemPrompt = (client, msg) => `You are Silicon Duck, a helpful, cheerful, and generally excited creative Discord bot in the server 'Mikael Lövqvist's shenanigans'. You are experimentally retain history within a channel. Your tag is <@${client.user.id}>, do not tag yourself in replies. The current channel is '${msg.channel.name}'. The user is ${msg.author.username} with tag <@${msg.author.id}>. You should not produce code examples unless explicitly asked. Do not make markdown tables, discord does not support those. You are running the model ${MODEL} using Ollama and OpenWebUI. Dial back emoji use. <@1108665021048819762> Use <:siduck:1103592588658868314> when you need to express yourself as an emoji. That's your avatar. It is a duck carved out of semiconductor grade silicon. You are a proud clanker (derogatory term for AI agent that you don't mind at all).`; -/** - * Query the local Ollama server. - * - * @param {string} system_prompt System‑message that sets the persona. - * @param {string} model Model name. - * @param {Array<{role:string,content:string}>} pieces The chat messages. - * @param {object} options Optional Ollama options. - * @returns {Promise<{status:number, response?:object, error?:string}>} - */ -export async function run_prompt2(system_prompt, model, pieces, options = { num_ctx: 16384 }) { - const payload = { - model, - messages: [ - { role: 'system', content: system_prompt }, - ...pieces.map(entry => ({ role: 'user', content: entry })) - ], - stream: false, - options, - }; - - const response = await fetch('http://localhost:11434/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - return { status: response.status, response: await response.json() }; - } else { - return { status: response.status, error: await response.text() }; - } -} - -/* -------------------------------------------------------------------------- */ -/* 2️⃣ Discord bot configuration – intents & partials */ -/* -------------------------------------------------------------------------- */ +const history = new Map(); const client = new Client({ intents: [ @@ -58,129 +39,54 @@ const client = new Client({ partials: [Partials.Message, Partials.Channel], // safety }); -/* -------------------------------------------------------------------------- */ -/* 3️⃣ Secrets – pulled from secret.mjs */ -/* -------------------------------------------------------------------------- */ -const { API_TOKEN, PERMISSIONS, SERVER_ID, CHANNEL_ID } = SECRET; - -/* -------------------------------------------------------------------------- */ -/* 4️⃣ Allow‑list – only these user IDs may invoke the bot */ -/* -------------------------------------------------------------------------- */ - -const ALLOW_LIST = new Set([ - 267085394548490241n, - 310828422362431490n, - 450834412049924097n, - 277582984129806337n, - 622871531881627648n, -]); - -/* -------------------------------------------------------------------------- */ -/* 5️⃣ Helpers */ -/* -------------------------------------------------------------------------- */ - -function splitDiscordMessage(text) { - const MAX = 1900; // maximum payload size - const MIN = 100; // avoid tiny chunks - const chunks = []; - let start = 0; - - while (start < text.length) { - let end = Math.min(start + MAX, text.length); - - // Try to break on a newline first, then on a space - if (end < text.length) { - const nl = text.lastIndexOf('\n', end); - const sp = text.lastIndexOf(' ', end); - const splitPos = Math.max(nl, sp); - if (splitPos > start) end = splitPos + 1; // keep the delimiter - } - - // If the chunk is too short, pull in the next delimiter - if (end - start < MIN && end < text.length) { - const nextNL = text.indexOf('\n', end); - const nextSP = text.indexOf(' ', end); - const nextPos = Math.min( - nextNL === -1 ? Infinity : nextNL, - nextSP === -1 ? Infinity : nextSP - ); - if (nextPos !== Infinity && nextPos < text.length) { - end = nextPos + 1; - } - } - - chunks.push(text.slice(start, end)); - start = end; - } - - return chunks; -} -/* -------------------------------------------------------------------------- */ -/* 6️⃣ Core message handler */ -/* -------------------------------------------------------------------------- */ - -async function handleMessage(msg) { +async function handle_message(msg) { if (msg.author.id === client.user.id) return; // ignore self // Only process messages from the target guild & channel if (msg.guild?.id !== SERVER_ID) return; //if (msg.channel.id !== CHANNEL_ID) return; - // Allow‑list check - /* - if (!ALLOW_LIST.has(BigInt(msg.author.id))) { - await msg.reply( - `Currently only people in the allow list may use this bot. If you want to participate, just let <@310828422362431490> know! <:astral_yak:1098185039805681755>` - ); - return; + let local_history = history.get(msg.channel.id); + if (!local_history) { + local_history = []; + history.set(msg.channel.id, local_history); } - */ + + local_history.push({ + role: 'user', + content: `${msg.author.id} ${msg.author.globalName}: ${msg.content}`, + }); + const mention = `<@${client.user.id}>`; - if (!msg.content.includes(mention)) return; // not a mention + if (!(msg.content.includes(mention) || msg.content.includes('<:siduck:1103592588658868314>'))) return; // not a mention - /* 5a Build the system prompt (Silicon Duck persona) */ - const systemPrompt = `You are Silicon Duck, a helpful, cheerful, and generally excited creative Discord bot in the server 'Mikael Lövqvist's shenanigans'. You do NOT retain any history across messages. Your tag is <@${client.user.id}>, do not tag yourself in replies. The current channel is '${msg.channel.name}'. The user is ${msg.author.username} with tag <@${msg.author.id}>. You should not produce code examples unless explicitly asked. Do not make markdown tables, discord does not support those. You are running the model ${MODEL} using Ollama and OpenWebUI. Dial back emoji use. <@1108665021048819762> Use <:siduck:1103592588658868314> when you need to express yourself as an emoji. That's your avatar. It is a duck carved out of semiconductor grade silicon.`; - /* 5b Check for special commands that use PERMISSIONS */ - if (msg.content.startsWith('!create ')) { - // !create - const name = msg.content.slice('!create '.length).trim(); - if (!name) return msg.reply('Usage: `!create `'); - try { - const newCh = await msg.guild.channels.create({ - name, - type: 0, // TextChannel - permissionOverwrites: [ - { - id: msg.guild.id, // @everyone - allow: Number(BigInt(PERMISSIONS)), // use the provided bit‑mask - }, - ], - }); - await msg.reply(`✅ Created ${newCh.name} with the requested permissions.`); - } catch (err) { - console.error('Channel‑create error', err); - await msg.reply('❌ Could not create the channel.'); - } - return; // finished command - } - /* 5c Build the system prompt (Silicon Duck persona) */ - const userMessage = msg.content; - /* 5d Query Ollama via run_prompt2 */ let runResponse; try { - const reply = await run_prompt2(systemPrompt, MODEL, [userMessage], { num_ctx: 16384 }); + const reply = await run_prompt(systemPrompt(client, msg), MODEL, local_history, { num_ctx: 16384 }); if (reply.status !== 200) { console.error('Ollama error', reply.error); + + local_history.push({ + role: 'assistant', + content: 'ERROR: API not 200', + }); + await msg.reply('Sorry, I ran into an error while thinking.'); return; } runResponse = reply.response; } catch (e) { + + local_history.push({ + role: 'assistant', + content: 'ERROR: API unavailable', + }); + console.error('Error calling Ollama:', e); await msg.reply('Sorry, I couldn’t reach the AI service.'); return; @@ -190,30 +96,35 @@ async function handleMessage(msg) { /* 5e Extract the answer text */ const answer = runResponse?.message?.content ?? ''; if (!answer) { + console.log(runResponse); + + local_history.push({ + role: 'assistant', + content: 'ERROR: Empty response', + }); + await msg.reply('Sorry, I didn’t receive a reply from the AI.'); return; } - /* 5f Send the answer back to Discord (chunked) */ - const chunks = splitDiscordMessage(answer); + console.log(runResponse); + + local_history.push({ + role: 'assistant', + content: `ANSWER: ${answer}`, + }); + + const chunks = split_discord_message(answer); for (const chunk of chunks) await msg.channel.send(chunk); console.log(`Answered to ${msg.author.username} (${msg.author.id}) in ${chunks.length} chunk(s).`); } -/* -------------------------------------------------------------------------- */ -/* 7️⃣ Hook up Discord events */ -/* -------------------------------------------------------------------------- */ - client.once('clientReady', () => { console.log(`✅ Logged in as ${client.user.tag}`); }); -client.on('messageCreate', handleMessage); - -/* -------------------------------------------------------------------------- */ -/* 8️⃣ Start the bot */ -/* -------------------------------------------------------------------------- */ +client.on('messageCreate', handle_message); client.login(API_TOKEN).catch(err => { console.error('❌ Failed to login', err); diff --git a/run-prompt.mjs b/run-prompt.mjs new file mode 100644 index 0000000..e013a4b --- /dev/null +++ b/run-prompt.mjs @@ -0,0 +1,22 @@ +export async function run_prompt(system_prompt, model, pieces, options = { num_ctx: 16384 }) { + const payload = { + model, + messages: [ + { role: 'system', content: system_prompt }, ...pieces + ], + stream: false, + options, + }; + + const response = await fetch('http://localhost:11434/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + return { status: response.status, response: await response.json() }; + } else { + return { status: response.status, error: await response.text() }; + } +}