import { Bot, GrammyError, type Context } from 'grammy' import { InlineKeyboard } from 'grammy' import { ALLOWED_CHAT_ID, SHOW_COST_FOOTER, TELEGRAM_BOT_TOKEN } from './config.js' import { logger } from './logger.js' import { executeEmergencyKill, checkIdleLock, isLocked, isSecurityEnabled, lock, matchesKillPhrase, touchActivity, unlock, } from './security.js' import { redactSecrets, scanForSecrets } from './exfiltration-guard.js' import { runAgentWithRetry } from './agent.js' import { audit, deleteScheduledTask, latestSessionFor, listMissionTasks, listScheduledTasks, listMemories, getMemory, setScheduledTaskMuted, setTaskStatus, upsertMemory, deleteMemory, } from './db.js' import { BUILT_INS, scheduledTaskSummary, type BuiltIn } from './scheduler.js' import { executeMission } from './missions/runner.js' import { cronHuman } from './cron-human.js' import { recall } from './memory.js' import { BACKENDS, availableBackends, dispatchSubagent, } from './subagent/router.js' import { parseDelegation, routeDelegation } from './orchestrator.js' import { discardIdea, listParkedIdeas, openIdea } from './idea-open.js' import { isSurveyActive } from './conversation-state.js' import { handleSurveyReply } from './rituals.js' import { reindexVault } from './vault-indexer.js' import { mirrorThesis } from './thesis-mirror.js' import { routeCapture, type CaptureType } from './capture-router.js' import { computeNudges, formatNudgeHtml, todayFlags } from './evening-nudge.js' import { chatEvents } from './state.js' import { completeTask, pushPendingTasks, shortTaskId, taskRows } from './tasks.js' import { escapeHtml, formatMirrorResultHtml, formatRecallHtml, formatReindexResultHtml, } from './format-telegram.js' import { createRoutine, editRoutine } from './scheduler-ops.js' import { cancelMission, retryMission } from './mission-ops.js' import { buildWelcomeHtml, buildWelcomeKeyboard } from './welcome.js' import { googleAuthConfigured, googleTokenSaved } from './google-auth.js' const MAX_TELEGRAM_TEXT = 4096 const ROUTINE_GROUPS: { title: string; names: string[] }[] = [ { title: 'Daily', names: ['morning-brief', 'morning-ritual', 'evening-nudge', 'evening-tracker'] }, { title: 'Polling', names: ['gmail-poll', 'gmail-classify', 'calendar-poll', 'tasks-poll', 'tasks-push'] }, { title: 'Weekly', names: ['weekly-review', 'venture-review'] }, { title: 'Vault', names: ['vault-reindex'] }, ] const VALID_MEMORY_SCOPES = ['global', 'email_hint', 'task_hint', 'agent_hint', 'capture_hint', 'journal_hint'] as const type MemoryScope = typeof VALID_MEMORY_SCOPES[number] const MEMORY_KEY_RE = /^[a-z0-9_-]{1,64}$/ function isValidMemoryScope(value: unknown): value is MemoryScope { return typeof value === 'string' && VALID_MEMORY_SCOPES.includes(value as MemoryScope) } function memoryUsage(): string { return `usage: /memory | /memory add [scope] | /memory del | /memory show \nscopes: ${VALID_MEMORY_SCOPES.join(', ')}` } function truncateMemoryValue(value: string, max = 80): string { const clean = value.replace(/\s+/g, ' ').trim() return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean } function formatMemoryList(): string { const rows = listMemories() if (rows.length === 0) return 'System memories\nnone' const grouped = new Map() for (const row of rows) { const bucket = grouped.get(row.scope) if (bucket) bucket.push(row) else grouped.set(row.scope, [row]) } const sections = [...grouped.entries()].map(([scope, scopeRows]) => { const lines = scopeRows.map(row => `• ${escapeHtml(row.key)} — ${escapeHtml(truncateMemoryValue(row.value))}` ) return `${escapeHtml(scope)}\n${lines.join('\n')}` }) return `System memories\n\n${sections.join('\n\n')}` } function validateMemoryKey(key: string | undefined): string | null { if (!key || !MEMORY_KEY_RE.test(key)) return null return key } function formatRoutineLine(task: BuiltIn, status: string | undefined): string { const paused = status === 'paused' ? ' (paused)' : '' return `• ${escapeHtml(task.name)} — ${escapeHtml(cronHuman(task.schedule))} — ${escapeHtml(task.description)}${paused}` } function routinesHtml(): string { const statusByName = new Map(listScheduledTasks().map(t => [t.name, t.status] as const)) const builtInByName = new Map(BUILT_INS.map(b => [b.name, b] as const)) const sections = ROUTINE_GROUPS.map(group => { const lines = group.names .map(name => builtInByName.get(name)) .filter((task): task is BuiltIn => Boolean(task)) .map(task => formatRoutineLine(task, statusByName.get(task.name))) .join('\n') return `${group.title}\n${lines}` }) return `Built-in routines\n\n${sections.join('\n\n')}\n\nPause/resume: /schedule pause <name>, /schedule resume <name>\nMute/unmute notifications: /schedule mute <name>, /schedule unmute <name>\nRun now: /mission run <name>` } // Parse: /schedule add "" [json-args...] function parseScheduleAdd(rawText: string): { name: string; cron: string; mission: string; argsJson?: string } | null { // strip /schedule add prefix const rest = rawText.replace(/^\/schedule\s+add\s+/i, '').trim() const nameMatch = rest.match(/^([a-z0-9_-]{1,64})\s+/) if (!nameMatch?.[1]) return null const name = nameMatch[1] const afterName = rest.slice(nameMatch[0].length) // quoted cron const cronMatch = afterName.match(/^"([^"]+)"\s*/) if (!cronMatch?.[1]) return null const cron = cronMatch[1] const afterCron = afterName.slice(cronMatch[0].length).trim() const parts2 = afterCron.split(/\s+/) const mission = parts2[0] if (!mission) return null const argsJson = parts2.slice(1).join(' ').trim() || undefined return { name, cron, mission, argsJson } } type ChatQueue = Array<() => Promise> const queues = new Map() const processing = new Set() function enqueue(chatId: string, job: () => Promise): void { if (!queues.has(chatId)) queues.set(chatId, []) queues.get(chatId)!.push(job) void drain(chatId) } async function drain(chatId: string): Promise { if (processing.has(chatId)) return processing.add(chatId) try { const queue = queues.get(chatId) if (!queue) return while (queue.length > 0) { const job = queue.shift() if (!job) continue try { await job() } catch (err) { logger.error({ err, chatId }, 'queued job failed') } } } finally { processing.delete(chatId) } } export function splitMessage(text: string, limit = MAX_TELEGRAM_TEXT): string[] { if (text.length <= limit) return [text] const chunks: string[] = [] let rest = text while (rest.length > limit) { // Prefer the last paragraph break, then newline, then space, then hard cut. const para = rest.lastIndexOf('\n\n', limit) const line = rest.lastIndexOf('\n', limit) const space = rest.lastIndexOf(' ', limit) const cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit chunks.push(rest.slice(0, cut)) rest = rest.slice(cut).replace(/^\s+/, '') } if (rest.length > 0) chunks.push(rest) return chunks } export function isAuthorised(chatId: string | number): boolean { return String(chatId) === String(ALLOWED_CHAT_ID) } function costFooter(result: { inputTokens: number; outputTokens: number; costUsd?: number; durationMs: number; toolCallsUsed: number; model?: string }): string { switch (SHOW_COST_FOOTER) { case 'off': return '' case 'cost': return result.costUsd !== undefined ? `\n\n— $${result.costUsd.toFixed(4)} (${result.durationMs}ms)` : '' case 'full': return `\n\n— ${result.model ?? 'claude'} · ${result.inputTokens}/${result.outputTokens} tok · ${result.durationMs}ms · tools:${result.toolCallsUsed}${result.costUsd ? ` · $${result.costUsd.toFixed(4)}` : ''}` case 'verbose': return `\n\n— ${result.inputTokens + result.outputTokens} tok · ${(result.durationMs / 1000).toFixed(1)}s` case 'compact': default: return `\n\n— ${Math.round((result.inputTokens + result.outputTokens) / 1000)}k · ${(result.durationMs / 1000).toFixed(1)}s` } } async function handleCommand(ctx: Context, text: string): Promise { const chatId = String(ctx.chat!.id) const parts = text.trim().split(/\s+/) const cmd = parts[0]?.toLowerCase() switch (cmd) { case '/start': await ctx.reply(buildWelcomeHtml(), { parse_mode: 'HTML', reply_markup: buildWelcomeKeyboard(), link_preview_options: { is_disabled: true }, }) return true case '/chatid': await ctx.reply(`chat_id: \`${chatId}\``, { parse_mode: 'MarkdownV2' }).catch(() => ctx.reply(`chat_id: ${chatId}`)) return true case '/status': { const sessionId = latestSessionFor(chatId) ?? '(none)' await ctx.reply( `status:\n` + `• locked: ${isLocked()}\n` + `• security enabled: ${isSecurityEnabled()}\n` + `• latest session: ${sessionId}` ) return true } case '/newchat': // Next user message starts fresh — signaled by absence of resume target. await ctx.reply('new session on next message.') return true case '/lock': lock('user_requested', chatId) await ctx.reply('locked.') return true case '/recall': { const query = parts.slice(1).join(' ').trim() if (!query) { await ctx.reply('usage: /recall ') return true } const hits = await recall(query, { chatId, k: 5 }) await sendHtml(ctx, formatRecallHtml(hits, query)) return true } case '/reindex': { await ctx.reply('reindexing vault…') const result = await reindexVault() await sendHtml(ctx, formatReindexResultHtml(result)) return true } case '/builtins': case '/routine': case '/routines': { await sendHtml(ctx, routinesHtml()) return true } case '/mirror-thesis': { const force = parts.includes('--force') await ctx.reply(`mirroring thesis${force ? ' (force)' : ''}…`) const result = await mirrorThesis({ force }) await sendHtml(ctx, formatMirrorResultHtml(result)) return true } case '/capture': { const body = text.replace(/^\/capture\s*/i, '').trim() if (!body) { await ctx.reply('usage: /capture ') return true } await routeAndReply(ctx, body) return true } case '/memory': { const sub = parts[1]?.toLowerCase() if (!sub || sub === 'list') { await sendHtml(ctx, formatMemoryList()) return true } if (sub === 'add') { const maybeScope = parts[2]?.toLowerCase() let scope: MemoryScope = 'global' let keyIndex = 2 if (isValidMemoryScope(maybeScope)) { scope = maybeScope keyIndex = 3 } const key = validateMemoryKey(parts[keyIndex]) const value = parts.slice(keyIndex + 1).join(' ').trim() if (!key || value.length < 1 || value.length > 4000) { await ctx.reply(memoryUsage()) return true } try { upsertMemory(scope, key, value) audit('memory_upsert', `${scope}:${key}`, { chatId }) await sendHtml(ctx, `saved ${escapeHtml(scope)}:${escapeHtml(key)}`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) await ctx.reply(`memory add failed: ${msg.slice(0, 300)}`) } return true } if (sub === 'del') { const scope = parts[2]?.toLowerCase() const key = validateMemoryKey(parts[3]) if (!isValidMemoryScope(scope) || !key) { await ctx.reply(memoryUsage()) return true } const deleted = deleteMemory(scope, key) audit('memory_delete', `${scope}:${key}`, { chatId }) await ctx.reply(deleted ? `deleted ${scope}:${key}` : `not found: ${scope}:${key}`) return true } if (sub === 'show') { const scope = parts[2]?.toLowerCase() const key = validateMemoryKey(parts[3]) if (!isValidMemoryScope(scope) || !key) { await ctx.reply(memoryUsage()) return true } const row = getMemory(scope, key) if (!row) { await ctx.reply(`not found: ${scope}:${key}`) return true } await sendHtml(ctx, `${escapeHtml(scope)}:${escapeHtml(key)}\n${escapeHtml(row.value)}`) return true } await ctx.reply(memoryUsage()) return true } case '/note': return await forcedCapture(ctx, text, 'note', '/note ') case '/idea': return await forcedCapture(ctx, text, 'idea', '/idea ') case '/task': return await forcedCapture(ctx, text, 'task', '/task ') case '/task-add': { const body = text.replace(/^\/task-add\s*/i, '').trim() if (!body) { await ctx.reply('usage: /task-add ') return true } await routeAndReply(ctx, body, 'task') const result = await pushPendingTasks() if (result.pushed && result.pushed > 0) await ctx.reply(`synced ${result.pushed} Google Task${result.pushed > 1 ? 's' : ''}.`) return true } case '/task-list': { const rows = taskRows(20) const lines = rows.length === 0 ? 'no tasks tracked' : rows.map(r => { const due = r.due_ts ? new Date(r.due_ts).toLocaleDateString('en-IN') : 'no due' return `• ${escapeHtml(shortTaskId(r.id))} ${escapeHtml(r.title)} · ${escapeHtml(r.status)} · ${escapeHtml(due)}` }).join('\n') await sendHtml(ctx, `Google Tasks · ${rows.length}\n${lines}`) return true } case '/task-done': { const id = parts[1] if (!id) { await ctx.reply('usage: /task-done ') return true } const ok = await completeTask(id) await ctx.reply(ok ? `completed ${id}` : `task not found: ${id}`) return true } case '/thesis': return await forcedCapture(ctx, text, 'thesis_fragment', '/thesis ') case '/literature': return await forcedCapture(ctx, text, 'literature', '/literature ') case '/journal': return await forcedCapture(ctx, text, 'journal', '/journal ') case '/nudge': { const flags = await todayFlags() if (!flags) { await ctx.reply('could not load daily note.') return true } await sendHtml(ctx, formatNudgeHtml(computeNudges(flags))) return true } case '/health': { const upSec = Math.floor(process.uptime()) const upStr = upSec < 60 ? `${upSec}s` : upSec < 3600 ? `${Math.floor(upSec / 60)}m` : `${Math.floor(upSec / 3600)}h${Math.floor((upSec % 3600) / 60)}m` const tasks = listScheduledTasks() const active = tasks.filter(t => t.status === 'active').length const paused = tasks.filter(t => t.status === 'paused').length const running = listMissionTasks('running', 5).length const queued = listMissionTasks('queued', 20).length const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0) const failed = listMissionTasks('failed', 50).filter(t => (t.created_at ?? 0) >= todayStart.getTime()).length const gConfigured = googleAuthConfigured() const gToken = googleTokenSaved() // Ollama probe — HEAD /api/tags, 2s timeout let ollamaOk = false try { const ollamaUrl = process.env.OLLAMA_URL ?? 'http://localhost:11434' const controller = new AbortController() const tId = setTimeout(() => controller.abort(), 2000) const res = await fetch(`${ollamaUrl}/api/tags`, { signal: controller.signal, method: 'HEAD' }) clearTimeout(tId) ollamaOk = res.ok || res.status < 500 } catch { ollamaOk = false } // Last error from audit log interface AuditRow { event_type: string; detail: string | null; created_at: number } const { getDb } = await import('./db.js') const lastErr = getDb().prepare( `SELECT event_type, detail, created_at FROM audit_log WHERE blocked = 1 OR event_type LIKE '%error%' OR event_type LIKE '%failed%' ORDER BY id DESC LIMIT 1` ).get() as AuditRow | undefined const errLine = lastErr ? `last error: ${escapeHtml(lastErr.event_type)} — ${escapeHtml((lastErr.detail ?? '').slice(0, 60))} (${new Date(lastErr.created_at).toISOString().slice(11, 19)})` : 'no recent errors' const html = [ `Howl PA health`, `uptime: ${upStr} · pid: ${process.pid}`, `routines: ${active} active · ${paused} paused`, `missions: ${running} running · ${queued} queued · ${failed} failed today`, `google auth: ${gConfigured ? '✔' : '✘'} configured · token: ${gToken ? '✔' : '✘'} saved`, `ollama: ${ollamaOk ? '✔' : '✘'}`, errLine, ].join('\n') await sendHtml(ctx, `
${html}
`) return true } case '/schedule': { const sub = parts[1]?.toLowerCase() if (!sub || sub === 'list') { const rows = scheduledTaskSummary() await sendHtml( ctx, `Scheduled tasks\n${rows.map(r => `${escapeHtml(r)}`).join('\n') || 'none'}` ) return true } if (sub === 'add') { const rawText = ctx.message?.text ?? text const parsed = parseScheduleAdd(rawText) if (!parsed) { await ctx.reply('⚠ usage: /schedule add "" [{"key":"val"}]') return true } let args: unknown if (parsed.argsJson) { try { args = JSON.parse(parsed.argsJson) } catch { await ctx.reply('⚠ invalid JSON args') return true } } const result = await createRoutine({ name: parsed.name, mission: parsed.mission, schedule: parsed.cron, args, }) if (!result.ok) { await sendHtml(ctx, `⚠ ${escapeHtml(result.error)}\nusage: /schedule add <name> "<cron>" <mission> [json-args]`) return true } const fmtTs = new Date(result.next_run).toISOString().slice(0, 19).replace('T', ' ') await sendHtml(ctx, `✔ routine ${escapeHtml(parsed.name)} created — next run: ${escapeHtml(fmtTs)} · mission: ${escapeHtml(parsed.mission)}`) return true } if (sub === 'edit') { const name = parts[2]; const field = parts[3]; const value = parts.slice(4).join(' ').trim() if (!name || !field || !value) { await ctx.reply('⚠ usage: /schedule edit (fields: schedule|priority|args|status)') return true } const result = await editRoutine(name, field, value) if (!result.ok) { await sendHtml(ctx, `⚠ ${escapeHtml(result.error)}`) return true } const nextPart = result.next_run ? ` — next run: ${escapeHtml(new Date(result.next_run).toISOString().slice(0, 19).replace('T', ' '))}` : '' await sendHtml(ctx, `✔ routine ${escapeHtml(name)} updated — ${escapeHtml(result.updated)} → ${escapeHtml(value.slice(0, 80))}${nextPart}`) return true } if (sub === 'pause' && parts[2]) { const ok = setTaskStatus(parts[2], 'paused') await ctx.reply(ok ? `paused ${parts[2]}` : `not found: ${parts[2]}`) return true } if (sub === 'resume' && parts[2]) { const ok = setTaskStatus(parts[2], 'active') await ctx.reply(ok ? `resumed ${parts[2]}` : `not found: ${parts[2]}`) return true } if (sub === 'delete' && parts[2]) { const ok = deleteScheduledTask(parts[2]) await ctx.reply(ok ? `deleted ${parts[2]}` : `not found: ${parts[2]}`) return true } if (sub === 'mute' && parts[2]) { const ok = setScheduledTaskMuted(parts[2], true) await ctx.reply(ok ? `🔇 muted ${parts[2]} (still runs, silent)` : `not found: ${parts[2]}`) return true } if (sub === 'unmute' && parts[2]) { const ok = setScheduledTaskMuted(parts[2], false) await ctx.reply(ok ? `🔔 unmuted ${parts[2]}` : `not found: ${parts[2]}`) return true } await ctx.reply('usage: /schedule list | pause | resume | mute | unmute | delete | add "" [json-args] | edit ') return true } case '/mission': { const sub = parts[1]?.toLowerCase() if (sub === 'cancel') { const id = Number.parseInt(parts[2] ?? '', 10) if (!Number.isFinite(id)) { await ctx.reply('usage: /mission cancel '); return true } const r = cancelMission(id) if (!r.ok) { await ctx.reply(`⚠ ${r.error}`); return true } await ctx.reply(`✔ mission #${id} cancelled`) return true } if (sub === 'retry') { const id = Number.parseInt(parts[2] ?? '', 10) if (!Number.isFinite(id)) { await ctx.reply('usage: /mission retry '); return true } const r = retryMission(id) if (!r.ok) { await ctx.reply(`⚠ ${r.error}`); return true } await ctx.reply(`✔ mission #${id} retried as #${r.newId}`) return true } if (sub === 'run' && parts[2]) { await ctx.reply(`running mission ${parts[2]}…`) try { const result = await executeMission({ mission: parts[2], source: 'telegram', chatId, }) if (!result.ok) throw new Error(result.error ?? `mission failed: ${parts[2]}`) await sendHtml(ctx, `${escapeHtml(parts[2])} · ${escapeHtml(result.summary ?? '')}`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) await ctx.reply(`⚠️ mission error: ${msg.slice(0, 400)}`) } return true } if (!sub || sub === 'list') { const rows = listMissionTasks(undefined, 10) const lines = rows.length === 0 ? 'no missions in queue' : rows .map(r => `${escapeHtml(`${r.id}·${r.status}·${r.assigned_agent}·${r.title}`)}`) .join('\n') await sendHtml(ctx, `Mission queue\n${lines}`) return true } await ctx.reply('usage: /mission list | run | cancel | retry ') return true } case '/brief': { await ctx.reply('composing brief…') try { const result = await executeMission({ mission: 'morning-brief', source: 'telegram', chatId, }) if (!result.ok) throw new Error(result.error ?? 'brief failed') logger.info({ summary: result.summary }, 'manual brief run') } catch (err) { await ctx.reply(`⚠️ brief error: ${err instanceof Error ? err.message : String(err)}`) } return true } case '/help': { await sendHtml( ctx, [ 'Howl PA commands', '/start · /status · /chatid · /newchat · /lock', '/capture <text> · /note · /idea · /task · /task-add · /task-list · /task-done', '/thesis · /literature · /journal', '/recall <query> · /reindex · /mirror-thesis [--force]', '/brief · /nudge · /routines · /health · /schedule list|pause|resume|mute|unmute|delete|add|edit · /mission list|run|cancel|retry', '/memory · /memory add [scope] <key> <value...> · /memory del <scope> <key>', '/ask [backend] <prompt> · /council [aggregator] <prompt> · /backends', ].join('\n') ) return true } case '/ask': { const rest = text.split(/\s+/).slice(1) let backend: string | undefined if (rest[0] && BACKENDS[rest[0]]) { backend = rest.shift() } const prompt = rest.join(' ').trim() if (!prompt) { await ctx.reply('usage: /ask [claude|codex|ollama:] ') return true } await ctx.reply('thinking…') const outcome = await dispatchSubagent( { prompt, chatId, hints: [] }, { mode: 'single', forcedBackend: backend } ) await sendHtml( ctx, `${escapeHtml(outcome.backendsUsed[0] ?? 'agent')} · ${(outcome.durationMs / 1000).toFixed(1)}s\n\n${escapeHtml(outcome.final)}` ) return true } case '/council': { const rest = text.split(/\s+/).slice(1) let aggregator: 'merge' | 'best-of-n' | 'vote' | undefined if (rest[0] && ['merge', 'best-of-n', 'vote'].includes(rest[0])) { aggregator = rest.shift() as typeof aggregator } const prompt = rest.join(' ').trim() if (!prompt) { await ctx.reply('usage: /council [merge|best-of-n|vote] ') return true } const aggregatorLabel = aggregator ?? 'best-of-n' const councilMsg = await ctx.reply(`assembling council (${aggregatorLabel})…`) const councilMsgChatId = councilMsg.chat.id const councilMsgId = councilMsg.message_id const councilStatus = new Map() const councilStart = Date.now() let lastCouncilEdit = 0 const renderCouncilStatus = (): string => { const elapsed = Math.round((Date.now() - councilStart) / 1000) const lines = [...councilStatus.entries()].map(([backend, status]) => `• ${backend} — ${status}`) return [`council (${aggregatorLabel}) · elapsed ${elapsed}s`, ...lines].join('\n') } const editCouncilStatus = async (force = false): Promise => { const now = Date.now() if (!force && now - lastCouncilEdit < 5000) return lastCouncilEdit = now try { await ctx.api.editMessageText(councilMsgChatId, councilMsgId, renderCouncilStatus()) } catch {} } const outcome = await dispatchSubagent( { prompt, chatId, hints: ['reasoning'] }, { mode: 'council', aggregator, onProgress: event => { if (event.kind === 'member_done') { councilStatus.set(event.backend, `done (${(event.durationMs / 1000).toFixed(1)}s)`) } else { councilStatus.set(event.backend, `running… (${Math.round(event.durationMs / 1000)}s)`) } void editCouncilStatus() }, } ) councilStatus.clear() for (const r of outcome.members) { councilStatus.set( r.backend, r.error ? `done ⚠️ ${r.error.slice(0, 80)}` : `done (${(r.durationMs / 1000).toFixed(1)}s)` ) } await editCouncilStatus(true) const memberLines = outcome.members .map(r => `• ${escapeHtml(r.backend)} ${r.error ? `⚠️ ${escapeHtml(r.error.slice(0, 80))}` : `ok (${(r.durationMs / 1000).toFixed(1)}s)`}`) .join('\n') await sendHtml( ctx, `Council · winner ${escapeHtml(outcome.winner ?? '?')} · ${(outcome.durationMs / 1000).toFixed(1)}s\n${memberLines}\n\n${escapeHtml(outcome.final)}` ) return true } case '/backends': { await sendHtml( ctx, `Available backends\n${availableBackends().map(b => `• ${escapeHtml(b)}`).join('\n')}` ) return true } case '/ideas': { const parked = listParkedIdeas() const body = parked.length === 0 ? 'no parked ideas' : parked .map(p => `• ${escapeHtml(p.slug)}${p.title ? ` — ${escapeHtml(p.title)}` : ''}`) .join('\n') await sendHtml(ctx, `Parked ideas · ${parked.length}\n${body}\n\n/open <slug> · /discard <slug>`) return true } case '/open': { const slug = parts[1] const override = parts.slice(2).join(' ').trim() || undefined if (!slug) { await ctx.reply('usage: /open [name override]') return true } try { const outcome = await openIdea(slug, override) await sendHtml( ctx, `Opened · ${escapeHtml(outcome.projectPath)}\nPipeline → Projects ${outcome.projectNumber}. Title: ${escapeHtml(outcome.title)}.` ) } catch (err) { await ctx.reply(`⚠️ open error: ${err instanceof Error ? err.message : String(err)}`) } return true } case '/discard': { const slug = parts[1] if (!slug) { await ctx.reply('usage: /discard ') return true } try { const outcome = await discardIdea(slug) await sendHtml(ctx, `archived ${escapeHtml(outcome.archivedPath)}`) } catch (err) { await ctx.reply(`⚠️ discard error: ${err instanceof Error ? err.message : String(err)}`) } return true } default: return false } } function commandAllowedWhileLocked(text: string): boolean { const cmd = text.trim().split(/\s+/)[0]?.toLowerCase() return cmd === '/start' || cmd === '/chatid' || cmd === '/status' } async function forcedCapture( ctx: Context, text: string, type: CaptureType, usage: string ): Promise { const body = text.split(/\s+/).slice(1).join(' ').trim() if (!body) { await ctx.reply(`usage: ${usage}`) return true } await routeAndReply(ctx, body, type) return true } async function routeAndReply(ctx: Context, body: string, forcedType?: CaptureType): Promise { await ctx.reply(forcedType === 'idea' ? '💡 building rundown…' : '📥 capturing…') try { const outcome = await routeCapture(body, forcedType) if (!outcome) { await ctx.reply('capture failed: classifier returned nothing.') return } if (outcome.type === 'ephemeral') { await sendHtml( ctx, `ephemeral — not written. ${escapeHtml(outcome.classification.slug)}` ) return } await sendHtml( ctx, `Captured as ${escapeHtml(outcome.type)} → ${escapeHtml(outcome.vaultRel)}` + (outcome.classification.title ? `\n${escapeHtml(outcome.classification.title)}` : '') ) } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.error({ err }, 'capture route failed') await ctx.reply(`⚠️ capture error: ${msg.slice(0, 400)}`).catch(() => {}) } } // Send a message as Telegram HTML; fall back to plain text if Telegram rejects. async function sendHtml(ctx: Context, html: string): Promise { for (const chunk of splitMessage(html, 4000)) { try { await ctx.reply(chunk, { parse_mode: 'HTML', link_preview_options: { is_disabled: true } }) } catch (err) { logger.warn({ err: err instanceof Error ? err.message : err }, 'HTML reply failed; falling back to plain') const plain = chunk.replace(/<[^>]+>/g, '') await ctx.reply(plain).catch(() => {}) } } } async function handlePinAttempt(ctx: Context, text: string): Promise { if (!isSecurityEnabled() || !isLocked()) return false const pin = text.trim() // Not a PIN attempt — defer to the locked-mode allowlist gate downstream // so commands like /start and /status remain reachable while locked. if (!/^\d{4,12}$/.test(pin)) return false const chatId = String(ctx.chat!.id) if (unlock(pin, chatId)) { await ctx.reply('✅ unlocked.') } else { await ctx.reply('❌ wrong PIN.') } return true } async function handleKillPhraseCheck(ctx: Context, text: string): Promise { if (!matchesKillPhrase(text)) return false const chatId = String(ctx.chat!.id) await ctx.reply('🛑 kill phrase acknowledged. shutting down.') await executeEmergencyKill(chatId) return true } async function maybeShowWelcome(ctx: Context, chatId: string): Promise { // Welcome fires exactly once per chat across the lifetime of the DB. // Sessions only get created when runAgent fires, so captures/commands/surveys // never produce one — using session presence as the gate caused the welcome // to replay on every reply. Persist a dedicated marker instead. if (getMemory('welcome_shown', chatId) !== null) return false await ctx.reply(buildWelcomeHtml(), { parse_mode: 'HTML', reply_markup: buildWelcomeKeyboard(), link_preview_options: { is_disabled: true }, }) upsertMemory('welcome_shown', chatId, new Date().toISOString()) return false // don't consume the message — let it continue to routing } async function processMessage(ctx: Context, text: string): Promise { const chatId = String(ctx.chat!.id) checkIdleLock() touchActivity() if (await handleKillPhraseCheck(ctx, text)) return if (await handlePinAttempt(ctx, text)) return if (isSecurityEnabled() && isLocked() && !commandAllowedWhileLocked(text)) { audit('blocked', 'message while locked', { chatId, blocked: true }) await ctx.reply('locked. send PIN.') return } // Active survey takes precedence over command parsing — user can still // /cancel by typing `cancel` inside a survey. if (isSurveyActive(chatId) && !text.trim().startsWith('/')) { const send = async (html: string): Promise => { await sendHtml(ctx, html) } const handled = await handleSurveyReply(chatId, text, send) if (handled) return } try { // First-run welcome on any plain message before sessions exist const textForFirst = text.trim() if (!textForFirst.startsWith('/start')) { await maybeShowWelcome(ctx, chatId) } if (await handleCommand(ctx, text)) { audit('command', text.split(/\s+/)[0] ?? '', { chatId }) return } } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.error({ err, chatId, cmd: text.split(/\s+/)[0] }, 'command handler threw') await ctx.reply(`⚠️ command error: ${msg.slice(0, 400)}`).catch(() => {}) audit('command', `error: ${msg.slice(0, 200)}`, { chatId, blocked: true }) return } // Inbound exfil scan (audit only — don't reject user; they may paste their own keys). const inboundHits = scanForSecrets(text) if (inboundHits.length > 0) { audit('exfil_redacted', `inbound hits=${inboundHits.map(h => h.type).join(',')}`, { chatId }) } // @agent: delegation syntax — routes through orchestrator. const delegation = parseDelegation(text) if (delegation) { await ctx.replyWithChatAction('typing').catch(() => {}) try { const outcome = await routeDelegation(delegation, chatId) const redacted = redactSecrets(outcome.text) const header = `@${escapeHtml(delegation.agentId)} · ${outcome.backend ?? '—'} · ${(outcome.durationMs / 1000).toFixed(1)}s` await sendHtml(ctx, `${header}\n\n${escapeHtml(redacted.text)}`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) await ctx.reply(`⚠️ delegation error: ${msg.slice(0, 400)}`).catch(() => {}) } return } await ctx.replyWithChatAction('typing').catch(() => {}) try { const previousSessionId = latestSessionFor(chatId) ?? undefined const eventSessionId = previousSessionId ?? 'pending' chatEvents.emit('message_received', { chatId, sessionId: eventSessionId, text }) chatEvents.emit('agent_started', { chatId, sessionId: eventSessionId, agentId: 'main', backend: 'claude', }) const result = await runAgentWithRetry({ chatId, prompt: text, sessionId: previousSessionId, }) if (!previousSessionId) { chatEvents.emit('session_start', { chatId, sessionId: result.sessionId, agentId: 'main' }) } const redacted = redactSecrets(result.text) if (redacted.matches.length > 0) { audit('exfil_redacted', `outbound blocked=${redacted.matches.map(m => m.type).join(',')}`, { chatId, blocked: true, }) } const body = redacted.text + costFooter(result) for (const chunk of splitMessage(body)) { await ctx.reply(chunk).catch(async err => { logger.error({ err, chunkLen: chunk.length }, 'telegram reply failed') }) } audit('message', `agent reply ok (${result.durationMs}ms)`, { chatId }) chatEvents.emit('agent_completed', { chatId, sessionId: result.sessionId, durationMs: result.durationMs, tokens: result.inputTokens + result.outputTokens, outcome: 'ok', }) } catch (err) { logger.error({ err, chatId }, 'agent run failed') const msg = err instanceof Error ? err.message : String(err) await ctx.reply(`⚠️ agent error: ${msg.slice(0, 400)}`).catch(() => {}) audit('message', `agent error: ${msg.slice(0, 200)}`, { chatId }) chatEvents.emit('chat_error', { chatId, category: 'agent', message: msg.slice(0, 400), }) } } export function createBot(): Bot { const bot = new Bot(TELEGRAM_BOT_TOKEN) bot.on('callback_query:data', async ctx => { const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '') if (!isAuthorised(chatId)) return const data = ctx.callbackQuery.data await ctx.answerCallbackQuery() if (data === 'cmd:routines') { await sendHtml(ctx as unknown as Context, routinesHtml()) } else if (data === 'cmd:health') { await handleCommand(ctx as unknown as Context, '/health') } else if (data === 'cmd:help') { await handleCommand(ctx as unknown as Context, '/help') } }) bot.on('message:text', async ctx => { const chatId = String(ctx.chat.id) if (!isAuthorised(chatId)) { logger.warn({ chatId }, 'drop non-allowlisted sender') return } const text = ctx.message.text enqueue(chatId, () => processMessage(ctx, text)) }) bot.catch(err => { if (err.error instanceof GrammyError) { logger.error({ err: err.error.description }, 'grammy error') } else { logger.error({ err: err.error }, 'unhandled bot error') } }) return bot }