Stage 2, and the end of the inversion. The two sync handlers (1,093 lines) ran in the platform's queue, which meant the sidecar reached back over its registration socket to ask the platform to enqueue work, and the credentials travelled through Postgres job metadata to get there. Option (A) from the plan: they run here now, and the Jobs screen is left to the things it actually describes. The handlers moved almost unedited. Their bodies were already a list of steps taking a context, so sync-runner.ts synthesizes that context and runs them; what went away is the JobHandler wrapper and the registration. `job.userId` is the OWNER'S EMAIL rather than a numeric id — the queue's naming — and it resolves the mail store path, so it is called out in the type. That is the same field whose absence made the mailbox read as empty two commits ago; it is set from user.email and checked this time. Deliberately not a queue: one run per account, no persistence, no retry. A failure is picked up by the ten-minute cron like any other, and a sync interrupted by a restart resumes from the stored cursor rather than the beginning. PermanentError survives as a local class — it signalled "do not retry" to the queue and now just carries its message to the sync state. accounts.ts asks the runner whether an account is syncing instead of scanning job rows, and the queue-over-WS shim in index.ts is gone: enqueueViaWs, listJobsViaWs, the pending-response map and the queue branch in the command handler. Nothing but a port crosses that socket now. The three chat channels stop opening the mail store directly. They each carried their own copy of count-rows / enqueue / poll / count-again, coupling three chat bridges to the mail schema — and they enqueued `gmail-sync` unconditionally, the OAuth path, for an app-password account that syncs over IMAP, so the command was already broken. One shared helper calls a new POST /sync-now on the sidecar, which syncs and reports what arrived. queue/handlers/ is now empty; both handlers there were email. The queue is untouched and still serves the Jobs screen. Not moved, and fine where they are: scripts/migrate-emails-to-sqlite.ts and scripts/seed-imap-uids.ts are one-off maintenance scripts that open the store directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
224 lines
7.4 KiB
TypeScript
224 lines
7.4 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 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;
|
|
});
|
|
}
|
|
|
|
import { runEmailSyncCommand } from '../email-sync-command';
|
|
|
|
type CommandContext = {
|
|
content: string;
|
|
channel: SendableChannel;
|
|
userId: number;
|
|
email: string;
|
|
discordId: string;
|
|
};
|
|
|
|
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
|
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<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(() => {});
|
|
}
|
|
}
|