telegram and whatsapp channel integrations, validate bot tokens before saving
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import type { Message as WAMessage } from 'whatsapp-web.js';
|
||||
import { findUserByIntegrationConfig, readConfigValue } from 'officerdb';
|
||||
import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await';
|
||||
import { consumePairingCode } from '../pairing';
|
||||
import { getWhatsAppClient } from './bot';
|
||||
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 = 5_000;
|
||||
|
||||
type SendFn = (text: string) => Promise<unknown>;
|
||||
|
||||
type AccessPolicy = { allowedModels: string[] };
|
||||
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
||||
|
||||
function extractPhone(waId: string): string {
|
||||
// WhatsApp ID format: 5511999999999@c.us → 5511999999999
|
||||
return waId.split('@')[0]!;
|
||||
}
|
||||
|
||||
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;
|
||||
send: SendFn;
|
||||
userId: number;
|
||||
email: string;
|
||||
whatsappId: string;
|
||||
};
|
||||
|
||||
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||
const { send, email } = ctx;
|
||||
|
||||
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 send('Syncing emails...');
|
||||
|
||||
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
|
||||
|
||||
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 send('Email sync failed. Check the queue dashboard for details.');
|
||||
return;
|
||||
}
|
||||
|
||||
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 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')}`;
|
||||
|
||||
await send(text);
|
||||
} catch (err) {
|
||||
await send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
||||
const { content, send, whatsappId } = 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 send(helpSections[topic]!);
|
||||
return true;
|
||||
}
|
||||
if (topic) {
|
||||
await send(
|
||||
`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections)
|
||||
.map((k) => `\`${k}\``)
|
||||
.join(', ')}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const full = Object.values(helpSections).join('\n\n');
|
||||
await 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 send('No models available.');
|
||||
return true;
|
||||
}
|
||||
const current = getSessionModel('whatsapp', whatsappId);
|
||||
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 send(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower === '!model') {
|
||||
const current = getSessionModel('whatsapp', whatsappId);
|
||||
await 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('whatsapp', whatsappId);
|
||||
await 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 send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
|
||||
return true;
|
||||
}
|
||||
setSessionModel('whatsapp', whatsappId, match.id);
|
||||
await 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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleWhatsAppMessage(msg: WAMessage): Promise<void> {
|
||||
const waClient = getWhatsAppClient();
|
||||
if (!waClient) return;
|
||||
|
||||
// Ignore group chats, status broadcasts, own messages
|
||||
if (msg.from.endsWith('@g.us')) return;
|
||||
if (msg.from === 'status@broadcast') return;
|
||||
if (msg.fromMe) return;
|
||||
if (!msg.body) return;
|
||||
|
||||
const phone = extractPhone(msg.from);
|
||||
const content = msg.body.trim();
|
||||
if (!content) return;
|
||||
|
||||
const send: SendFn = (text: string) => waClient.sendMessage(msg.from, text);
|
||||
|
||||
// Look up linked Officer user
|
||||
const linked = await findUserByIntegrationConfig('whatsapp', 'whatsappId', phone);
|
||||
|
||||
if (!linked) {
|
||||
if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) {
|
||||
const result = await consumePairingCode(content.toUpperCase(), phone);
|
||||
if (result) {
|
||||
await send('Account linked! You can now chat with me.');
|
||||
return;
|
||||
}
|
||||
await send('Invalid or expired pairing code. Please generate a new one from Officer Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
await send(
|
||||
"I don't recognize your WhatsApp number. To link it:\n" +
|
||||
'1. Go to Officer Settings → Integrations → WhatsApp\n' +
|
||||
'2. Click "Link WhatsApp" 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,
|
||||
send,
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
whatsappId: phone,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Typing indicator
|
||||
const sendTyping = async () => {
|
||||
try {
|
||||
const chat = await msg.getChat();
|
||||
await chat.sendStateTyping();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
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: 'whatsapp',
|
||||
contextId: phone,
|
||||
});
|
||||
|
||||
clearInterval(typingInterval);
|
||||
|
||||
// WhatsApp has 65k char limit — no chunking needed
|
||||
const signature = `\`${result.model}\`\n`;
|
||||
await send(signature + result.text);
|
||||
} catch (err) {
|
||||
clearInterval(typingInterval);
|
||||
console.error('[whatsapp] Error handling message:', err);
|
||||
await send('Sorry, something went wrong processing your message.').catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user