Discord
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { Client, GatewayIntentBits, Partials, Events } from 'discord.js';
|
||||
import { getServerIntegration } from 'officerdb';
|
||||
import { handleDiscordMessage } from './handler';
|
||||
|
||||
let client: Client | null = null;
|
||||
|
||||
export async function startDiscordBot(token: string): Promise<void> {
|
||||
if (client) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildPresences,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
|
||||
client.on(Events.MessageCreate, (message) => {
|
||||
handleDiscordMessage(message).catch((err) => {
|
||||
console.error('[discord] Unhandled error in message handler:', err);
|
||||
});
|
||||
});
|
||||
|
||||
client.once(Events.ClientReady, (c) => {
|
||||
console.log(`[discord] Bot logged in as ${c.user.tag}`);
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
}
|
||||
|
||||
export async function stopDiscordBot(): Promise<void> {
|
||||
if (client) {
|
||||
client.destroy();
|
||||
client = null;
|
||||
console.log('[discord] Bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
export function isDiscordBotRunning(): boolean {
|
||||
return client !== null && client.isReady();
|
||||
}
|
||||
|
||||
export function getDiscordBotUsername(): string | null {
|
||||
return client?.user?.tag ?? null;
|
||||
}
|
||||
|
||||
type DiscordConfig = {
|
||||
botToken: string;
|
||||
};
|
||||
|
||||
export async function startDiscordBotIfConfigured(): Promise<void> {
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration?.enabled) return;
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
const botToken = config.botToken as string | undefined;
|
||||
if (!botToken) return;
|
||||
|
||||
await startDiscordBot(botToken);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
export function chunkMessage(text: string): string[] {
|
||||
if (text.length <= MAX_LENGTH) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
const paragraphs = text.split('\n\n');
|
||||
|
||||
let current = '';
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.length > MAX_LENGTH) {
|
||||
// Flush current chunk
|
||||
if (current) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
// Split long paragraph on newlines
|
||||
const lines = paragraph.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.length > MAX_LENGTH) {
|
||||
// Flush current
|
||||
if (current) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
// Hard-split long line
|
||||
for (let i = 0; i < line.length; i += MAX_LENGTH) {
|
||||
chunks.push(line.slice(i, i + MAX_LENGTH));
|
||||
}
|
||||
} else if (current.length + 1 + line.length > MAX_LENGTH) {
|
||||
chunks.push(current.trim());
|
||||
current = line;
|
||||
} else {
|
||||
current += (current ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
} else if (current.length + 2 + paragraph.length > MAX_LENGTH) {
|
||||
chunks.push(current.trim());
|
||||
current = paragraph;
|
||||
} else {
|
||||
current += (current ? '\n\n' : '') + paragraph;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
chunks.push(current.trim());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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 { listPiModels } from '@@/api/pi/list-models';
|
||||
import { enqueue } from '@@/queue/engine';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
|
||||
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 = 'pi-access-policy';
|
||||
|
||||
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||
const allModels = await listPiModels();
|
||||
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 } = ctx;
|
||||
|
||||
// Count emails before sync
|
||||
let countBefore = 0;
|
||||
try {
|
||||
const db = openEmailDb(email);
|
||||
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 enqueue({ 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 = openEmailDb(email);
|
||||
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', 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', 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', 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', 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: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ChannelProvider } from './types';
|
||||
import { upsertUserIntegration } from 'officerdb';
|
||||
|
||||
type PairingEntry = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const pairingCodes = new Map<string, PairingEntry>();
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I ambiguity
|
||||
|
||||
function generateCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]!;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function cleanupExpiredCodes(): void {
|
||||
const now = Date.now();
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.expiresAt <= now) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePairingCode(userId: number, email: string, provider: ChannelProvider): string {
|
||||
cleanupExpiredCodes();
|
||||
|
||||
// Revoke any existing code for this user+provider
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.userId === userId && entry.provider === provider) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
|
||||
let code: string;
|
||||
do {
|
||||
code = generateCode();
|
||||
} while (pairingCodes.has(code));
|
||||
|
||||
pairingCodes.set(code, {
|
||||
userId,
|
||||
email,
|
||||
provider,
|
||||
expiresAt: Date.now() + CODE_TTL_MS,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
type PairingResult = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
};
|
||||
|
||||
export async function consumePairingCode(code: string, channelUserId: string): Promise<PairingResult | null> {
|
||||
const entry = pairingCodes.get(code.toUpperCase());
|
||||
if (!entry || entry.expiresAt <= Date.now()) return null;
|
||||
|
||||
pairingCodes.delete(code.toUpperCase());
|
||||
|
||||
await upsertUserIntegration({
|
||||
userId: entry.userId,
|
||||
provider: entry.provider,
|
||||
config: { discordId: channelUserId },
|
||||
});
|
||||
|
||||
return { userId: entry.userId, email: entry.email, provider: entry.provider };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import {
|
||||
getServerIntegration,
|
||||
upsertServerIntegration,
|
||||
getUserIntegration,
|
||||
deleteUserIntegration,
|
||||
} from 'officerdb';
|
||||
import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot';
|
||||
import { generatePairingCode } from './pairing';
|
||||
|
||||
export const channelsRouter = createRouter();
|
||||
|
||||
// ── Admin: Discord config ──
|
||||
|
||||
channelsRouter.get('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration) return ctx.json({ configured: false });
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
const botToken = config.botToken as string | undefined;
|
||||
|
||||
return ctx.json({
|
||||
configured: !!botToken,
|
||||
enabled: integration.enabled,
|
||||
botToken: botToken ? `${botToken.slice(0, 8)}...${botToken.slice(-4)}` : null,
|
||||
serverInvite: (config.serverInvite as string) ?? null,
|
||||
botHandle: (config.botHandle as string) ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
channelsRouter.put('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const botToken = body.botToken as string | undefined;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
const serverInvite = body.serverInvite as string | undefined;
|
||||
const botHandle = body.botHandle as string | undefined;
|
||||
|
||||
if (!botToken && enabled === undefined && serverInvite === undefined && botHandle === undefined) {
|
||||
return ctx.json({ error: 'At least one field required' }, 400);
|
||||
}
|
||||
|
||||
const existing = await getServerIntegration('discord');
|
||||
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>;
|
||||
const newConfig = { ...existingConfig };
|
||||
if (botToken) newConfig.botToken = botToken;
|
||||
if (serverInvite !== undefined) newConfig.serverInvite = serverInvite;
|
||||
if (botHandle !== undefined) newConfig.botHandle = botHandle;
|
||||
|
||||
await upsertServerIntegration('discord', newConfig, enabled ?? existing?.enabled ?? true);
|
||||
|
||||
// Restart bot if running or if we have a token and it's enabled
|
||||
const shouldRun = enabled ?? existing?.enabled ?? true;
|
||||
const token = (botToken ?? existingConfig.botToken) as string | undefined;
|
||||
|
||||
if (token && shouldRun) {
|
||||
try {
|
||||
await startDiscordBot(token);
|
||||
} catch (err) {
|
||||
console.error('[channels] Failed to start Discord bot:', err);
|
||||
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||
}
|
||||
return ctx.json({ success: true, botStarted: true });
|
||||
}
|
||||
|
||||
if (!shouldRun) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
return ctx.json({ success: true, botStarted: false });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/status', async (ctx) => {
|
||||
const integration = await getServerIntegration('discord');
|
||||
const config = (integration?.config ?? {}) as Record<string, unknown>;
|
||||
|
||||
return ctx.json({
|
||||
configured: !!config.botToken,
|
||||
enabled: integration?.enabled ?? false,
|
||||
running: isDiscordBotRunning(),
|
||||
botUsername: getDiscordBotUsername(),
|
||||
serverInvite: (config.serverInvite as string) ?? null,
|
||||
botHandle: (config.botHandle as string) ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── User: Discord pairing ──
|
||||
|
||||
channelsRouter.post('/discord/pair', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const code = generatePairingCode(user.id, user.email, 'discord');
|
||||
return ctx.json({ code, expiresIn: 600 });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const integration = await getUserIntegration(user.id, 'discord');
|
||||
if (!integration) return ctx.json({ linked: false });
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
return ctx.json({
|
||||
linked: true,
|
||||
discordId: config.discordId,
|
||||
});
|
||||
});
|
||||
|
||||
channelsRouter.delete('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const deleted = await deleteUserIntegration(user.id, 'discord');
|
||||
return ctx.json({ success: deleted });
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { join } from 'path';
|
||||
import type { PiEvent, MessageCost, Message } from '@@/api/pi/types';
|
||||
import { sessionManager } from '@@/api/pi/session-manager';
|
||||
import * as storage from '@@/api/pi/storage';
|
||||
import * as piBridge from '@@/api/pi/pi-bridge';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { logger } from '@@/api/pi/logger';
|
||||
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type SendAndAwaitParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
context: string;
|
||||
contextId: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type SendAndAwaitResult = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
// Per-session mutex to serialize concurrent prompts
|
||||
const sessionLocks = new Map<string, Promise<void>>();
|
||||
|
||||
// Per-session callback — swapped each time a new prompt is sent
|
||||
type EventCallback = (event: PiEvent) => void;
|
||||
const sessionCallbacks = new Map<string, EventCallback>();
|
||||
|
||||
// Channel model overrides — survive session eviction/recreation
|
||||
const channelModelOverrides = new Map<string, string>();
|
||||
|
||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
const chat = settings?.chat as Record<string, unknown> | undefined;
|
||||
return (chat?.defaultModel as string) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionModel(context: string, contextId: string): string | null {
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return session?.model ?? channelModelOverrides.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function setSessionModel(context: string, contextId: string, model: string): void {
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
// Store override independently of session — survives idle eviction
|
||||
channelModelOverrides.set(sessionId, model);
|
||||
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session) {
|
||||
session.model = model;
|
||||
session.meta.model = model;
|
||||
// Kill existing PI process so it respawns with the new model
|
||||
if (session.piProcess) {
|
||||
piBridge.killPi(session.piProcess);
|
||||
session.piProcess = null;
|
||||
}
|
||||
logger.info('Channel model switched', { sessionId, model, killedProcess: true });
|
||||
} else {
|
||||
logger.info('Channel model override stored (no active session)', { sessionId, model });
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
|
||||
const { context, contextId } = params;
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
|
||||
// Serialize per session — if two messages arrive at once, second waits for first
|
||||
const existing = sessionLocks.get(sessionId) ?? Promise.resolve();
|
||||
let releaseLock: () => void;
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
sessionLocks.set(sessionId, existing.then(() => lockPromise));
|
||||
|
||||
await existing;
|
||||
|
||||
try {
|
||||
return await doSend(sessionId, params);
|
||||
} finally {
|
||||
releaseLock!();
|
||||
if (sessionLocks.get(sessionId) === existing.then(() => lockPromise)) {
|
||||
sessionLocks.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent event dispatcher — registered once at spawn time, delegates to current callback
|
||||
function createDispatcher(sessionId: string): (event: PiEvent) => void {
|
||||
return (event: PiEvent) => {
|
||||
const cb = sessionCallbacks.get(sessionId);
|
||||
if (cb) cb(event);
|
||||
};
|
||||
}
|
||||
|
||||
async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
|
||||
const { userId, email, username, prompt, context, contextId } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
const cwd = homeDir;
|
||||
|
||||
// Resolve model: explicit param > channel override > existing session model > user default > system default
|
||||
const existingSession = sessionManager.getSession(sessionId);
|
||||
let model = params.model ?? channelModelOverrides.get(sessionId) ?? existingSession?.model;
|
||||
if (!model) {
|
||||
const userDefault = await getUserDefaultModel(userId);
|
||||
model = userDefault ?? DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId);
|
||||
session.model = model;
|
||||
session.meta.model = model;
|
||||
session.userId = userId;
|
||||
logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess });
|
||||
|
||||
return new Promise<SendAndAwaitResult>((resolve, reject) => {
|
||||
let resultText = '';
|
||||
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
session.isGenerating = false;
|
||||
sessionCallbacks.delete(sessionId);
|
||||
reject(new Error('sendAndAwait timed out after 5 minutes'));
|
||||
}
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
const settle = () => {
|
||||
sessionCallbacks.delete(sessionId);
|
||||
clearTimeout(timeout);
|
||||
settled = true;
|
||||
};
|
||||
|
||||
// Register per-prompt callback — the persistent dispatcher will call this
|
||||
sessionCallbacks.set(sessionId, (event: PiEvent) => {
|
||||
if (settled) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta': {
|
||||
session.streamBuffer += event.text;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const text = event.text || session.streamBuffer;
|
||||
if (text) {
|
||||
resultText += (resultText ? '\n\n' : '') + text;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start': {
|
||||
if (session.streamBuffer) {
|
||||
resultText += (resultText ? '\n\n' : '') + session.streamBuffer;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
const toolMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
};
|
||||
session.messages.push(toolMsg);
|
||||
session.meta.messageCount += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:result': {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i]!;
|
||||
if (m.role === 'tool' && m.toolCallId === event.toolCallId) {
|
||||
m.output = event.output;
|
||||
m.isError = event.isError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
if (session.streamBuffer) {
|
||||
resultText += (resultText ? '\n\n' : '') + session.streamBuffer;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
cost: event.cost,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
cost.inputTokens = event.cost.inputTokens;
|
||||
cost.outputTokens = event.cost.outputTokens;
|
||||
cost.totalUSD = event.cost.totalUSD;
|
||||
|
||||
storage.saveSession(homeDir, sessionId, session.meta, session.messages).catch((err) => {
|
||||
logger.error('Failed to save channel session', { sessionId, error: String(err) });
|
||||
});
|
||||
|
||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
|
||||
settle();
|
||||
resolve({ text: resultText || '(no response)', sessionId, model: model!, cost });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
session.isGenerating = false;
|
||||
settle();
|
||||
reject(new Error(event.message));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stopped': {
|
||||
session.isGenerating = false;
|
||||
settle();
|
||||
resolve({ text: resultText || '(stopped)', sessionId, model: model!, cost });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn Pi process if not running
|
||||
(async () => {
|
||||
try {
|
||||
if (!session.piProcess) {
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
// Remap host path to container path
|
||||
const containerHome = `/home/${username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
}
|
||||
}
|
||||
|
||||
const dispatcher = createDispatcher(sessionId);
|
||||
const sandbox = { userId, username, email, homeDir };
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions);
|
||||
session.sandboxed = true;
|
||||
|
||||
const proc = session.piProcess;
|
||||
proc.exited.then(() => {
|
||||
if (session.piProcess === proc) {
|
||||
session.piProcess = null;
|
||||
logger.info('Channel Pi process exited', { sessionId });
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi for channel session', { sessionId, model, context });
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
text: prompt,
|
||||
};
|
||||
session.messages.push(userMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
if (!session.meta.title) {
|
||||
session.meta.title = prompt.slice(0, 100);
|
||||
}
|
||||
|
||||
session.isGenerating = true;
|
||||
piBridge.sendPrompt(session.piProcess, prompt, randomUUID());
|
||||
} catch (err) {
|
||||
settle();
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ChannelProvider = 'discord' | 'telegram' | 'whatsapp';
|
||||
|
||||
export type ChannelBot = {
|
||||
provider: ChannelProvider;
|
||||
start: (token: string) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user