Files
household-bot/apps/bot/src/config.ts
whekin f38ee499ae feat(bot): unified topic processor replacing router+interpreter stack
Replace 3-layer architecture (gpt-5-nano router + gpt-4o-mini interpreter) with
single unified topic processor (gpt-4o-mini) for simplified message handling.

New components:
- HouseholdContextCache: TTL-based caching (5 min) for household config data
- TopicProcessor: Unified classification + parsing with structured JSON output

Key changes:
- Renamed ASSISTANT_ROUTER_MODEL → TOPIC_PROCESSOR_MODEL
- Added TOPIC_PROCESSOR_TIMEOUT_MS (default 10s)
- Refactored save() → saveWithInterpretation() for pre-parsed interpretations
- Removed deprecated createOpenAiTopicMessageRouter and ~300 lines legacy code
- Fixed typing indicator to only start when needed (purchase routes)
- Fixed amount formatting: convert minor units to major for rawText

Routes: silent, chat_reply, purchase, purchase_clarification, payment,
payment_clarification, topic_helper, dismiss_workflow

All 212 bot tests pass. Typecheck, lint, format, build clean.
2026-03-14 13:33:57 +04:00

184 lines
5.5 KiB
TypeScript

export interface BotRuntimeConfig {
port: number
logLevel: 'debug' | 'info' | 'warn' | 'error'
telegramBotToken: string
telegramWebhookSecret: string
telegramWebhookPath: string
databaseUrl?: string
purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean
anonymousFeedbackEnabled: boolean
assistantEnabled: boolean
miniAppAllowedOrigins: readonly string[]
miniAppAuthEnabled: boolean
schedulerSharedSecret?: string
schedulerOidcAllowedEmails: readonly string[]
reminderJobsEnabled: boolean
openaiApiKey?: string
purchaseParserModel: string
assistantModel: string
topicProcessorModel: string
topicProcessorTimeoutMs: number
assistantTimeoutMs: number
assistantMemoryMaxTurns: number
assistantRateLimitBurst: number
assistantRateLimitBurstWindowMs: number
assistantRateLimitRolling: number
assistantRateLimitRollingWindowMs: number
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) {
return 3000
}
const parsed = Number(raw)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
throw new Error(`Invalid PORT value: ${raw}`)
}
return parsed
}
function parseLogLevel(raw: string | undefined): 'debug' | 'info' | 'warn' | 'error' {
if (raw === undefined) {
return 'info'
}
const normalized = raw.trim().toLowerCase()
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized
}
throw new Error(`Invalid LOG_LEVEL value: ${raw}`)
}
function requireValue(value: string | undefined, key: string): string {
if (!value || value.trim().length === 0) {
throw new Error(`${key} environment variable is required`)
}
return value
}
function parseOptionalValue(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed && trimmed.length > 0 ? trimmed : undefined
}
function parseOptionalCsv(value: string | undefined): readonly string[] {
const trimmed = value?.trim()
if (!trimmed) {
return []
}
return trimmed
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number {
if (raw === undefined) {
return fallback
}
const parsed = Number(raw)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid ${key} value: ${raw}`)
}
return parsed
}
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
const purchaseTopicIngestionEnabled = databaseUrl !== undefined
const financeCommandsEnabled = databaseUrl !== undefined
const anonymousFeedbackEnabled = databaseUrl !== undefined
const assistantEnabled = databaseUrl !== undefined
const miniAppAuthEnabled = databaseUrl !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled =
databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
const runtime: BotRuntimeConfig = {
port: parsePort(env.PORT),
logLevel: parseLogLevel(env.LOG_LEVEL),
telegramBotToken: requireValue(env.TELEGRAM_BOT_TOKEN, 'TELEGRAM_BOT_TOKEN'),
telegramWebhookSecret: requireValue(env.TELEGRAM_WEBHOOK_SECRET, 'TELEGRAM_WEBHOOK_SECRET'),
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled,
financeCommandsEnabled,
anonymousFeedbackEnabled,
assistantEnabled,
miniAppAllowedOrigins,
miniAppAuthEnabled,
schedulerOidcAllowedEmails,
reminderJobsEnabled,
purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini',
assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini',
topicProcessorModel: env.TOPIC_PROCESSOR_MODEL?.trim() || 'gpt-4o-mini',
topicProcessorTimeoutMs: parsePositiveInteger(
env.TOPIC_PROCESSOR_TIMEOUT_MS,
10_000,
'TOPIC_PROCESSOR_TIMEOUT_MS'
),
assistantTimeoutMs: parsePositiveInteger(
env.ASSISTANT_TIMEOUT_MS,
20_000,
'ASSISTANT_TIMEOUT_MS'
),
assistantMemoryMaxTurns: parsePositiveInteger(
env.ASSISTANT_MEMORY_MAX_TURNS,
12,
'ASSISTANT_MEMORY_MAX_TURNS'
),
assistantRateLimitBurst: parsePositiveInteger(
env.ASSISTANT_RATE_LIMIT_BURST,
5,
'ASSISTANT_RATE_LIMIT_BURST'
),
assistantRateLimitBurstWindowMs: parsePositiveInteger(
env.ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS,
60_000,
'ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS'
),
assistantRateLimitRolling: parsePositiveInteger(
env.ASSISTANT_RATE_LIMIT_ROLLING,
50,
'ASSISTANT_RATE_LIMIT_ROLLING'
),
assistantRateLimitRollingWindowMs: parsePositiveInteger(
env.ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS,
86_400_000,
'ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS'
)
}
if (databaseUrl !== undefined) {
runtime.databaseUrl = databaseUrl
}
if (schedulerSharedSecret !== undefined) {
runtime.schedulerSharedSecret = schedulerSharedSecret
}
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
if (openaiApiKey !== undefined) {
runtime.openaiApiKey = openaiApiKey
}
return runtime
}