joel momoent
This commit is contained in:
110
src/commands/definitions/channel.ts
Normal file
110
src/commands/definitions/channel.ts
Normal 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.
3
src/database/drizzle/0005_add_channel_restriction.sql
Normal file
3
src/database/drizzle/0005_add_channel_restriction.sql
Normal 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;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
315
src/services/ai/vision.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user