import type { Message as DiscordMessage } from 'discord.js'; import { findUserByIntegrationConfig, readConfigValue } from 'officerdb'; import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await'; import { consumePairingCode } from '../pairing'; import { chunkMessage } from './chunker'; import { listChatModels } from '@@/api/chat/list-models'; import { enqueueJob } from '../../queue/init'; import { readJob } from '@@/queue/storage'; import type { ModelInfo } from '@@/api/chat/types'; import { toShellUsername } from '@@/data-path'; const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/; const TYPING_INTERVAL_MS = 8_000; type SendableChannel = { send: (content: string) => Promise }; type AccessPolicy = { allowedModels: string[] }; const ACCESS_POLICY_KEY = 'chat-access-policy'; async function getVisibleModels(): Promise { const allModels = await listChatModels(); const policy = await readConfigValue(ACCESS_POLICY_KEY, { allowedModels: [] }); const allowed = policy.allowedModels; if (allowed.length === 0) return allModels; const allowedSet = new Set(allowed); const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0])); return allModels.filter((m) => { const key = `${m.provider}:${m.id}`; const isExplicitlyAllowed = allowedSet.has(key); const isFromNewProvider = !allowedProviderSet.has(m.provider); return isExplicitlyAllowed || isFromNewProvider; }); } import { runEmailSyncCommand } from '../email-sync-command'; type CommandContext = { content: string; channel: SendableChannel; userId: number; email: string; discordId: string; }; async function handleEmailSync(ctx: CommandContext): Promise { const { channel, userId } = ctx; await channel.send('Syncing emails...'); const text = await runEmailSyncCommand(userId); for (const chunk of chunkMessage(text)) await channel.send(chunk); } async function handleCommand(ctx: CommandContext): Promise { const { content, channel, discordId } = ctx; const lower = content.toLowerCase(); const helpSections: Record = { models: '**Models:**\n' + '`!model` — show current model\n' + '`!model ` — switch model\n' + '`!models` — list available models', email: '**Email:**\n' + '`!email sync` — sync Gmail and show new emails', }; if (lower === '!help' || lower.startsWith('!help ')) { const topic = content.slice('!help'.length).trim().toLowerCase(); if (topic && topic in helpSections) { await channel.send(helpSections[topic]!); return true; } if (topic) { await channel.send( `Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections) .map((k) => `\`${k}\``) .join(', ')}`, ); return true; } const full = Object.values(helpSections).join('\n\n'); await channel.send(full + '\n\n`!help ` — show commands for a topic'); return true; } if (lower === '!models') { const models = await getVisibleModels(); if (models.length === 0) { await channel.send('No models available.'); return true; } const current = getSessionModel('discord', ctx.userId, discordId); const grouped = new Map(); for (const m of models) { const list = grouped.get(m.provider) ?? []; list.push(m.id === current ? `**${m.id}** (current)` : m.id); grouped.set(m.provider, list); } let text = '**Available models:**\n'; for (const [provider, ids] of grouped) { text += `\n__${provider}__\n${ids.map((id) => ` ${id}`).join('\n')}\n`; } text += '\nUse `!model ` to switch.'; await channel.send(text); return true; } if (lower === '!model') { const current = getSessionModel('discord', ctx.userId, discordId); await channel.send( current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.', ); return true; } if (lower.startsWith('!model ')) { const requested = content.slice('!model '.length).trim(); if (!requested) { const current = getSessionModel('discord', ctx.userId, discordId); await channel.send(current ? `Current model: **${current}**` : 'No active session yet.'); return true; } const models = await getVisibleModels(); const match = models.find((m) => m.id === requested || m.name === requested); if (!match) { await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); return true; } setSessionModel('discord', ctx.userId, discordId, match.id); await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`); return true; } if (lower === '!email sync') { await handleEmailSync(ctx); return true; } // Not a recognized command — pass through to PI return false; } export async function handleDiscordMessage(message: DiscordMessage): Promise { // Ignore bots and non-DM messages if (message.author.bot) return; if (!message.channel.isDMBased() || !('send' in message.channel)) return; const channel = message.channel; const discordId = message.author.id; const content = message.content.trim(); if (!content) return; // Look up linked Officer user const linked = await findUserByIntegrationConfig('discord', 'discordId', discordId); if (!linked) { // Check if this is a pairing code if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) { const result = await consumePairingCode(content.toUpperCase(), discordId); if (result) { await channel.send('Account linked! You can now chat with me.'); return; } await channel.send('Invalid or expired pairing code. Please generate a new one from Officer Settings.'); return; } await channel.send( "I don't recognize your Discord account. To link it:\n" + '1. Go to Officer Settings → Integrations → Discord\n' + '2. Click "Link Discord" to get a pairing code\n' + '3. Send the 6-character code to me here', ); return; } // Handle commands if (content.startsWith('!')) { const handled = await handleCommand({ content, channel, userId: linked.user.id, email: linked.user.email, discordId, }); if (handled) return; } // Start typing indicator with keep-alive const sendTyping = () => { if ('sendTyping' in channel) { (channel as { sendTyping: () => Promise }).sendTyping().catch(() => {}); } }; const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS); sendTyping(); try { const result = await sendAndAwait({ userId: linked.user.id, email: linked.user.email, username: toShellUsername(linked.user.username ?? '', linked.user.email), prompt: content, context: 'discord', contextId: discordId, }); clearInterval(typingInterval); const signature = `\`${result.model}\`\n`; const chunks = chunkMessage(result.text); for (let i = 0; i < chunks.length; i++) { await channel.send(i === 0 ? signature + chunks[i]! : chunks[i]!); } } catch (err) { clearInterval(typingInterval); console.error('[discord] Error handling message:', err); await channel.send('Sorry, something went wrong processing your message.').catch(() => {}); } }