Modularization, some rudimentary cleanup
This commit is contained in:
37
discord-utils.mjs
Normal file
37
discord-utils.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
219
discord.mjs
219
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 { Client, GatewayIntentBits, Partials, PermissionsBitField } from 'discord.js';
|
||||||
import * as SECRET from '/srv/Projekt/dicsordbot/secret.mjs';
|
import * as SECRET from '/srv/Projekt/dicsordbot/secret.mjs';
|
||||||
|
import { run_prompt } from './run-prompt.mjs';
|
||||||
/* -------------------------------------------------------------------------- */
|
import { split_discord_message } from './discord-utils.mjs';
|
||||||
/* 1️⃣ Utility – run_prompt2 (Ollama helper) – the same you gave me */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const MODEL = 'gpt-oss:20b';
|
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).`;
|
||||||
|
|
||||||
/**
|
const history = new Map();
|
||||||
* 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 client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -58,129 +39,54 @@ const client = new Client({
|
|||||||
partials: [Partials.Message, Partials.Channel], // safety
|
partials: [Partials.Message, Partials.Channel], // safety
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 3️⃣ Secrets – pulled from secret.mjs */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const { API_TOKEN, PERMISSIONS, SERVER_ID, CHANNEL_ID } = SECRET;
|
async function handle_message(msg) {
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 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) {
|
|
||||||
if (msg.author.id === client.user.id) return; // ignore self
|
if (msg.author.id === client.user.id) return; // ignore self
|
||||||
|
|
||||||
// Only process messages from the target guild & channel
|
// Only process messages from the target guild & channel
|
||||||
if (msg.guild?.id !== SERVER_ID) return;
|
if (msg.guild?.id !== SERVER_ID) return;
|
||||||
//if (msg.channel.id !== CHANNEL_ID) return;
|
//if (msg.channel.id !== CHANNEL_ID) return;
|
||||||
|
|
||||||
// Allow‑list check
|
let local_history = history.get(msg.channel.id);
|
||||||
/*
|
if (!local_history) {
|
||||||
if (!ALLOW_LIST.has(BigInt(msg.author.id))) {
|
local_history = [];
|
||||||
await msg.reply(
|
history.set(msg.channel.id, local_history);
|
||||||
`Currently only people in the allow list may use this bot. If you want to participate, just let <@310828422362431490> know! <:astral_yak:1098185039805681755>`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
local_history.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `${msg.author.id} ${msg.author.globalName}: ${msg.content}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const mention = `<@${client.user.id}>`;
|
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 <channel‑name>
|
|
||||||
const name = msg.content.slice('!create '.length).trim();
|
|
||||||
if (!name) return msg.reply('Usage: `!create <channel-name>`');
|
|
||||||
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;
|
let runResponse;
|
||||||
try {
|
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) {
|
if (reply.status !== 200) {
|
||||||
console.error('Ollama error', reply.error);
|
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.');
|
await msg.reply('Sorry, I ran into an error while thinking.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runResponse = reply.response;
|
runResponse = reply.response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
|
local_history.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'ERROR: API unavailable',
|
||||||
|
});
|
||||||
|
|
||||||
console.error('Error calling Ollama:', e);
|
console.error('Error calling Ollama:', e);
|
||||||
await msg.reply('Sorry, I couldn’t reach the AI service.');
|
await msg.reply('Sorry, I couldn’t reach the AI service.');
|
||||||
return;
|
return;
|
||||||
@@ -190,30 +96,35 @@ async function handleMessage(msg) {
|
|||||||
/* 5e Extract the answer text */
|
/* 5e Extract the answer text */
|
||||||
const answer = runResponse?.message?.content ?? '';
|
const answer = runResponse?.message?.content ?? '';
|
||||||
if (!answer) {
|
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.');
|
await msg.reply('Sorry, I didn’t receive a reply from the AI.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 5f Send the answer back to Discord (chunked) */
|
console.log(runResponse);
|
||||||
const chunks = splitDiscordMessage(answer);
|
|
||||||
|
local_history.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: `ANSWER: ${answer}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = split_discord_message(answer);
|
||||||
for (const chunk of chunks) await msg.channel.send(chunk);
|
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).`);
|
console.log(`Answered to ${msg.author.username} (${msg.author.id}) in ${chunks.length} chunk(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 7️⃣ Hook up Discord events */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
client.once('clientReady', () => {
|
client.once('clientReady', () => {
|
||||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('messageCreate', handleMessage);
|
client.on('messageCreate', handle_message);
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 8️⃣ Start the bot */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
client.login(API_TOKEN).catch(err => {
|
client.login(API_TOKEN).catch(err => {
|
||||||
console.error('❌ Failed to login', err);
|
console.error('❌ Failed to login', err);
|
||||||
|
|||||||
22
run-prompt.mjs
Normal file
22
run-prompt.mjs
Normal file
@@ -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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user