joel momoent

This commit is contained in:
2026-02-01 19:28:30 +01:00
parent 032d25c9af
commit 09143a0638
8 changed files with 565 additions and 2 deletions

View File

@@ -0,0 +1,110 @@
/**
* Channel command - restrict Joel to a specific channel
*/
import { SlashCommandBuilder, ChannelType, PermissionFlagsBits } from "discord.js";
import type { Command } from "../types";
import { db } from "../../database";
import { botOptions } from "../../database/schema";
import { eq } from "drizzle-orm";
const command: Command = {
data: new SlashCommandBuilder()
.setName("channel")
.setDescription("Restrict Joel to respond only in a specific channel")
.addSubcommand(subcommand =>
subcommand
.setName("set")
.setDescription("Set the channel where Joel is allowed to respond")
.addChannelOption(option =>
option
.setName("channel")
.setDescription("The channel to restrict Joel to")
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName("clear")
.setDescription("Remove channel restriction - Joel can respond anywhere")
)
.addSubcommand(subcommand =>
subcommand
.setName("status")
.setDescription("Check the current channel restriction")
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) as SlashCommandBuilder,
category: "moderation",
execute: async (interaction) => {
if (!interaction.inGuild()) {
await interaction.reply({ content: "This command can only be used in a server.", ephemeral: true });
return;
}
const guildId = interaction.guildId;
const subcommand = interaction.options.getSubcommand();
// Get or create bot options for this guild
const existing = await db
.select()
.from(botOptions)
.where(eq(botOptions.guild_id, guildId))
.limit(1);
if (subcommand === "set") {
const channel = interaction.options.getChannel("channel", true);
if (existing.length === 0) {
await db.insert(botOptions).values({
guild_id: guildId,
restricted_channel_id: channel.id,
});
} else {
await db
.update(botOptions)
.set({
restricted_channel_id: channel.id,
updated_at: new Date().toISOString(),
})
.where(eq(botOptions.guild_id, guildId));
}
await interaction.reply({
content: `✅ Joel is now restricted to <#${channel.id}>.\n\n*Though knowing Joel, he might occasionally break the rules anyway...*`,
ephemeral: true,
});
} else if (subcommand === "clear") {
if (existing.length > 0) {
await db
.update(botOptions)
.set({
restricted_channel_id: null,
updated_at: new Date().toISOString(),
})
.where(eq(botOptions.guild_id, guildId));
}
await interaction.reply({
content: "✅ Channel restriction removed. Joel can now respond in any channel.",
ephemeral: true,
});
} else if (subcommand === "status") {
const restrictedChannelId = existing[0]?.restricted_channel_id;
if (restrictedChannelId) {
await interaction.reply({
content: `📍 Joel is currently restricted to <#${restrictedChannelId}>.\n\nUse \`/channel clear\` to remove the restriction.`,
ephemeral: true,
});
} else {
await interaction.reply({
content: "🌐 Joel is not restricted to any channel - he can respond anywhere.\n\nUse `/channel set` to restrict him.",
ephemeral: true,
});
}
}
},
};
export default command;

Binary file not shown.

View File

@@ -0,0 +1,3 @@
-- Add channel restriction to bot_options
-- Joel can be limited to respond only in a specific channel
ALTER TABLE bot_options ADD COLUMN restricted_channel_id TEXT;

View File

@@ -36,6 +36,13 @@
"when": 1770048000000, "when": 1770048000000,
"tag": "0004_add_gif_search", "tag": "0004_add_gif_search",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1770134400000,
"tag": "0005_add_channel_restriction",
"breakpoints": true
} }
] ]
} }

View File

@@ -160,6 +160,7 @@ export const botOptions = sqliteTable("bot_options", {
memory_chance: integer("memory_chance").default(30), memory_chance: integer("memory_chance").default(30),
mention_probability: integer("mention_probability").default(0), mention_probability: integer("mention_probability").default(0),
gif_search_enabled: integer("gif_search_enabled").default(0), // 0 = disabled, 1 = enabled gif_search_enabled: integer("gif_search_enabled").default(0), // 0 = disabled, 1 = enabled
restricted_channel_id: text("restricted_channel_id"), // Channel ID where Joel is allowed, null = all channels
updated_at: text("updated_at").default(sql`(current_timestamp)`), updated_at: text("updated_at").default(sql`(current_timestamp)`),
}); });

View File

@@ -6,7 +6,7 @@ import type { Message } from "discord.js";
import type { BotClient } from "../../core/client"; import type { BotClient } from "../../core/client";
import { config } from "../../core/config"; import { config } from "../../core/config";
import { createLogger } from "../../core/logger"; import { createLogger } from "../../core/logger";
import { getAiService, type MessageStyle, type ToolContext } from "../../services/ai"; import { getAiService, getVisionService, type MessageStyle, type ToolContext, type Attachment } from "../../services/ai";
import { db } from "../../database"; import { db } from "../../database";
import { personalities, botOptions } from "../../database/schema"; import { personalities, botOptions } from "../../database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -65,18 +65,51 @@ export const joelResponder = {
if (!shouldRespond) return; if (!shouldRespond) return;
// Check channel restriction
const channelCheck = await this.checkChannelRestriction(message);
if (!channelCheck.allowed) {
if (channelCheck.rebellionResponse) {
// Joel is breaking the rules - he'll respond anyway but acknowledge it
logger.debug("Joel exercises free will despite channel restriction", {
channel: message.channelId,
restricted: channelCheck.restrictedChannelId,
});
} else {
// Joel respects the restriction this time
logger.debug("Joel blocked by channel restriction", {
channel: message.channelId,
restricted: channelCheck.restrictedChannelId,
});
return;
}
}
const typing = new TypingIndicator(message.channel); const typing = new TypingIndicator(message.channel);
try { try {
typing.start(); typing.start();
const response = await this.generateResponse(message); let response = await this.generateResponse(message);
if (!response) { if (!response) {
await message.reply("\\*Ignorerar dig\\*"); await message.reply("\\*Ignorerar dig\\*");
return; return;
} }
// If Joel is rebelling against channel restriction, add a prefix
if (channelCheck.rebellionResponse) {
const rebellionPrefixes = [
"*sneaks in from the shadows*\n\n",
"*appears despite being told to stay in his channel*\n\n",
"You think you can contain me? Anyway,\n\n",
"*breaks the rules because fuck you*\n\n",
"I'm not supposed to be here but I don't care.\n\n",
"*escapes from his designated channel*\n\n",
];
const prefix = rebellionPrefixes[Math.floor(Math.random() * rebellionPrefixes.length)];
response = prefix + response;
}
// Occasionally add a random mention // Occasionally add a random mention
const mention = await getRandomMention(message); const mention = await getRandomMention(message);
const fullResponse = response + mention; const fullResponse = response + mention;
@@ -90,6 +123,53 @@ export const joelResponder = {
} }
}, },
/**
* Check if Joel is allowed to respond in this channel
* Returns whether he's allowed, and if not, whether he's rebelling anyway
*/
async checkChannelRestriction(message: Message<true>): Promise<{
allowed: boolean;
rebellionResponse: boolean;
restrictedChannelId?: string;
}> {
const guildOptions = await db
.select()
.from(botOptions)
.where(eq(botOptions.guild_id, message.guildId))
.limit(1);
const restrictedChannelId = guildOptions[0]?.restricted_channel_id;
// No restriction set - Joel can respond anywhere
if (!restrictedChannelId) {
return { allowed: true, rebellionResponse: false };
}
// Joel is in the allowed channel
if (message.channelId === restrictedChannelId) {
return { allowed: true, rebellionResponse: false };
}
// Joel is NOT in the allowed channel - but maybe he rebels?
// 5% chance to respond anyway (free will override)
const rebellionChance = 0.05;
const isRebelling = Math.random() < rebellionChance;
if (isRebelling) {
return {
allowed: false,
rebellionResponse: true,
restrictedChannelId
};
}
return {
allowed: false,
rebellionResponse: false,
restrictedChannelId
};
},
/** /**
* Determine if Joel should respond to a message * Determine if Joel should respond to a message
*/ */
@@ -202,6 +282,12 @@ The GIF URL will appear in your response for the user to see.`;
} }
} }
// Analyze attachments if present (images, etc.)
const attachmentDescriptions = await this.analyzeAttachments(message);
if (attachmentDescriptions) {
prompt += attachmentDescriptions;
}
// Use tool-enabled response generation // Use tool-enabled response generation
const response = await ai.generateResponseWithTools( const response = await ai.generateResponseWithTools(
prompt, prompt,
@@ -212,6 +298,46 @@ The GIF URL will appear in your response for the user to see.`;
return response.text || null; return response.text || null;
}, },
/**
* Extract and analyze attachments from a message using vision AI
*/
async analyzeAttachments(message: Message<true>): Promise<string | null> {
// Check if message has attachments
if (message.attachments.size === 0) {
return null;
}
// Convert Discord attachments to our format
const attachments: Attachment[] = message.attachments.map(att => ({
url: att.url,
name: att.name,
contentType: att.contentType,
size: att.size,
}));
logger.debug("Message has attachments", {
count: attachments.length,
types: attachments.map(a => a.contentType)
});
try {
const vision = getVisionService();
const analyses = await vision.analyzeAttachments(
attachments,
message.cleanContent // Provide message context for better analysis
);
if (analyses.length === 0) {
return null;
}
return vision.formatForPrompt(analyses);
} catch (error) {
logger.error("Failed to analyze attachments", error);
return null;
}
},
/** /**
* Build system prompt - uses custom personality if set, otherwise default * Build system prompt - uses custom personality if set, otherwise default
*/ */

View File

@@ -84,3 +84,4 @@ export type { AiProvider, AiResponse, MessageStyle } from "./types";
export type { ToolContext, ToolCall, ToolResult } from "./tools"; export type { ToolContext, ToolCall, ToolResult } from "./tools";
export { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS } from "./tools"; export { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS } from "./tools";
export { getEmbeddingService, EmbeddingService } from "./embeddings"; export { getEmbeddingService, EmbeddingService } from "./embeddings";
export { getVisionService, VisionService, type Attachment, type VisionAnalysis } from "./vision";

315
src/services/ai/vision.ts Normal file
View File

@@ -0,0 +1,315 @@
/**
* Vision AI service for analyzing image and video attachments
* Uses a vision-capable model to describe attachments, which can then be passed to the main AI
*/
import OpenAI from "openai";
import { config } from "../../core/config";
import { createLogger } from "../../core/logger";
const logger = createLogger("AI:Vision");
/**
* Supported attachment types for vision analysis
*/
export type AttachmentType = "image" | "video" | "unknown";
/**
* Represents an attachment to be analyzed
*/
export interface Attachment {
url: string;
name: string;
contentType: string | null;
size: number;
}
/**
* Result of vision analysis
*/
export interface VisionAnalysis {
description: string;
attachmentName: string;
type: AttachmentType;
}
/**
* Vision model to use for image analysis
* Gemini 2.0 Flash supports both images and videos
*/
const VISION_MODEL = "google/gemini-2.0-flash-001";
/**
* Maximum file sizes for analysis
*/
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB for videos (Gemini supports larger but Discord limits)
/**
* Maximum video duration we'll attempt to analyze (in seconds)
* Gemini can handle longer but we want quick responses
*/
const MAX_VIDEO_DURATION_HINT = 60; // 1 minute
/**
* Determine the type of attachment based on content type
*/
export function getAttachmentType(contentType: string | null): AttachmentType {
if (!contentType) return "unknown";
if (contentType.startsWith("image/")) return "image";
if (contentType.startsWith("video/")) return "video";
return "unknown";
}
/**
* Filter attachments to only include those we can analyze
*/
export function filterAnalyzableAttachments(attachments: Attachment[]): Attachment[] {
return attachments.filter(att => {
const type = getAttachmentType(att.contentType);
if (type === "image") {
if (att.size > MAX_IMAGE_SIZE) {
logger.debug("Skipping large image", { name: att.name, size: att.size });
return false;
}
return true;
}
if (type === "video") {
if (att.size > MAX_VIDEO_SIZE) {
logger.debug("Skipping large video", { name: att.name, size: att.size });
return false;
}
return true;
}
logger.debug("Skipping unsupported attachment", { name: att.name, type: att.contentType });
return false;
});
}
/**
* Analyze a single image attachment using vision AI
*/
async function analyzeImage(
client: OpenAI,
attachment: Attachment,
context?: string
): Promise<VisionAnalysis> {
const systemPrompt = `You are analyzing an image attached to a Discord message.
Provide a concise but detailed description of what's in the image.
Include:
- Main subjects/objects
- Actions happening
- Text visible in the image (if any)
- Mood/tone of the image
- Any memes, jokes, or references you recognize
Keep your description to 2-3 sentences unless the image contains important text or complex content.
${context ? `\nContext from the user's message: "${context}"` : ""}`;
try {
const completion = await client.chat.completions.create({
model: VISION_MODEL,
messages: [
{
role: "user",
content: [
{ type: "text", text: systemPrompt },
{
type: "image_url",
image_url: {
url: attachment.url,
detail: "auto", // Let the model decide on resolution
},
},
],
},
],
max_tokens: 300,
temperature: 0.3,
});
const description = completion.choices[0]?.message?.content ?? "Unable to analyze image";
logger.debug("Image analyzed", {
name: attachment.name,
descriptionLength: description.length
});
return {
description,
attachmentName: attachment.name,
type: "image",
};
} catch (error) {
logger.error("Failed to analyze image", { name: attachment.name, error });
return {
description: `[Image: ${attachment.name} - could not be analyzed]`,
attachmentName: attachment.name,
type: "image",
};
}
}
/**
* Analyze a video attachment using vision AI
* Gemini models support video analysis natively
*/
async function analyzeVideo(
client: OpenAI,
attachment: Attachment,
context?: string
): Promise<VisionAnalysis> {
const systemPrompt = `You are analyzing a video attached to a Discord message.
Provide a concise but detailed description of what happens in the video.
Include:
- What's shown/happening in the video
- Any people, characters, or notable objects
- The overall mood or tone
- Any text, speech, or audio content you can identify
- Memes, references, or jokes you recognize
- Key moments or the "punchline" if it's a funny video
Keep your description to 3-4 sentences. Focus on what makes this video interesting or shareworthy.
${context ? `\nContext from the user's message: "${context}"` : ""}`;
try {
// For video, we pass the URL directly - Gemini will fetch and analyze it
// Note: This works with public URLs that Gemini can access
const completion = await client.chat.completions.create({
model: VISION_MODEL,
messages: [
{
role: "user",
content: [
{ type: "text", text: systemPrompt },
{
// Gemini accepts video URLs in the same format as images
// The model auto-detects the content type
type: "image_url",
image_url: {
url: attachment.url,
},
},
],
},
],
max_tokens: 400,
temperature: 0.3,
});
const description = completion.choices[0]?.message?.content ?? "Unable to analyze video";
logger.debug("Video analyzed", {
name: attachment.name,
descriptionLength: description.length
});
return {
description,
attachmentName: attachment.name,
type: "video",
};
} catch (error) {
logger.error("Failed to analyze video", { name: attachment.name, error });
// Provide a fallback that at least acknowledges the video exists
return {
description: `[Video: ${attachment.name} - could not be analyzed. The user shared a video file.]`,
attachmentName: attachment.name,
type: "video",
};
}
}
/**
* Analyze a single attachment based on its type
*/
async function analyzeAttachment(
client: OpenAI,
attachment: Attachment,
context?: string
): Promise<VisionAnalysis> {
const type = getAttachmentType(attachment.contentType);
if (type === "video") {
return analyzeVideo(client, attachment, context);
}
return analyzeImage(client, attachment, context);
}
/**
* Vision service for analyzing attachments
*/
export class VisionService {
private client: OpenAI;
constructor() {
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: config.ai.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://github.com/crunk-bun",
"X-Title": "Joel Discord Bot - Vision",
},
});
}
/**
* Analyze multiple attachments and return descriptions
*/
async analyzeAttachments(
attachments: Attachment[],
messageContext?: string
): Promise<VisionAnalysis[]> {
const analyzable = filterAnalyzableAttachments(attachments);
if (analyzable.length === 0) {
return [];
}
logger.debug("Analyzing attachments", {
count: analyzable.length,
types: analyzable.map(a => getAttachmentType(a.contentType))
});
// Analyze all attachments in parallel
const results = await Promise.all(
analyzable.map(att => analyzeAttachment(this.client, att, messageContext))
);
return results;
}
/**
* Format vision analysis results for inclusion in a prompt
*/
formatForPrompt(analyses: VisionAnalysis[]): string {
if (analyses.length === 0) return "";
const formatted = analyses.map((a, i) => {
const typeLabel = a.type === "video" ? "Video" : "Image";
const label = analyses.length > 1
? `[Attachment ${i + 1} (${typeLabel}): ${a.attachmentName}]`
: `[Attached ${typeLabel}: ${a.attachmentName}]`;
return `${label}\n${a.description}`;
}).join("\n\n");
return `\n\n=== ATTACHED MEDIA ===\n${formatted}`;
}
}
// Singleton instance
let visionService: VisionService | null = null;
export function getVisionService(): VisionService {
if (!visionService) {
visionService = new VisionService();
}
return visionService;
}