Renames the AI-harness/provider settings router into the chat namespace and clears the remaining "pi" identifiers from the chat stack. - server-settings/pi-mono.ts → chat-providers.ts; piMonoRouter → chatProvidersRouter; route /server-settings/pi-mono → /server-settings/chat-providers (+ all callers) - piId → providerId (PROVIDERS map + AIHarnessesSection UI), PiProvider → ChatProvider, PI_MONO_* query keys → CHAT_PROVIDERS_*, installPiMono → installAgent - data-path: PI_CONFIG_DIR → AGENT_CONFIG_DIR (path ~/.pi/agent unchanged); drop dead getPiMonoDir/getPiMonoSessionDir exports - settings: flip the vestigial defaultProvider literal 'pi' → 'chat' (never read; only defaultModel drives behavior); access-policy config key 'pi-access-policy' → 'chat-access-policy' - misc: ModelSelector fallback label, TaskDefaults model grouping, CapabilityPage chat var, a stale stream-parser comment Intentionally left (genuine external `pi`/opencode references, not ours to rename): the `pi` binary install/version flow (@mariozechner/pi-coding-agent, `which pi`), the ~/.pi/agent config path, PI_TOOLS_DIRS/PI_SEARXNG_URL runtime env-var contract, TOOL.md `targets: pi` metadata, and the "Pi Mono" installer UI label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
9.5 KiB
TypeScript
293 lines
9.5 KiB
TypeScript
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 { openUserEmailDb } from '@@/api/email/email-db';
|
|
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<unknown> };
|
|
|
|
type AccessPolicy = { allowedModels: string[] };
|
|
const ACCESS_POLICY_KEY = 'chat-access-policy';
|
|
|
|
async function getVisibleModels(): Promise<ModelInfo[]> {
|
|
const allModels = await listChatModels();
|
|
const policy = await readConfigValue<AccessPolicy>(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;
|
|
});
|
|
}
|
|
|
|
type CommandContext = {
|
|
content: string;
|
|
channel: SendableChannel;
|
|
userId: number;
|
|
email: string;
|
|
discordId: string;
|
|
};
|
|
|
|
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
|
const { channel, email, userId } = ctx;
|
|
|
|
// Count emails before sync
|
|
let countBefore = 0;
|
|
try {
|
|
const db = await openUserEmailDb(email, userId);
|
|
if (db) {
|
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
|
countBefore = row.count;
|
|
db.close();
|
|
}
|
|
} catch {
|
|
// DB might not exist yet
|
|
}
|
|
|
|
await channel.send('Syncing emails...');
|
|
|
|
const job = await enqueueJob({ lane: 'google-api', type: 'gmail-sync', userId: email });
|
|
|
|
// Poll until done
|
|
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
|
|
for (let i = 0; i < 120; i++) {
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
const current = await readJob(job.id);
|
|
if (!current) return 'failed';
|
|
if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') {
|
|
return current.status;
|
|
}
|
|
}
|
|
return 'failed';
|
|
};
|
|
|
|
const status = await poll();
|
|
|
|
if (status !== 'completed') {
|
|
await channel.send('Email sync failed. Check the queue dashboard for details.');
|
|
return;
|
|
}
|
|
|
|
// Count emails after sync and get newest ones
|
|
try {
|
|
const db = await openUserEmailDb(email, userId);
|
|
if (!db) return;
|
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
|
const countAfter = row.count;
|
|
const newCount = countAfter - countBefore;
|
|
|
|
if (newCount <= 0) {
|
|
db.close();
|
|
await channel.send('Sync complete — no new emails.');
|
|
return;
|
|
}
|
|
|
|
const newest = db
|
|
.query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?')
|
|
.all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
|
db.close();
|
|
|
|
const lines = newest.map((e) => {
|
|
const sender = e.from_name || e.from_address;
|
|
return `- **${sender}**: ${e.subject}`;
|
|
});
|
|
|
|
let text = `Sync complete — **${newCount}** new email${newCount !== 1 ? 's' : ''}`;
|
|
if (newCount > 20) text += ` (showing latest 20)`;
|
|
text += `:\n\n${lines.join('\n')}`;
|
|
|
|
const chunks = chunkMessage(text);
|
|
for (const chunk of chunks) {
|
|
await channel.send(chunk);
|
|
}
|
|
} catch (err) {
|
|
await channel.send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
|
const { content, channel, discordId } = ctx;
|
|
const lower = content.toLowerCase();
|
|
|
|
const helpSections: Record<string, string> = {
|
|
models:
|
|
'**Models:**\n' +
|
|
'`!model` — show current model\n' +
|
|
'`!model <id>` — 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 <topic>` — 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<string, string[]>();
|
|
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 <id>` 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<void> {
|
|
// 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<void> }).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(() => {});
|
|
}
|
|
}
|