Files
platform/src/servers/channels/discord/handler.ts
T
pastilhasandClaude Opus 5 e9e144962d email: the sidecar schedules its own syncs
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>
2026-07-31 13:33:46 +00:00

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(() => {});
}
}