KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/serve.rs
Parfii-bot 7414d14cc7 feat(kei-buddy): functional MVP — store + state-machine port + serve binary
Three atoms landed in one commit (memory binding, state machine port,
real serve binary). Tracked separately in TaskList (#5 #7 #6).

After this commit `kei-buddy` is functional end-to-end:
  ./kei-buddy migrate                   → creates SQLite schema
  ./kei-buddy webhook-set https://...   → registers Telegram webhook
  ./kei-buddy serve                     → axum HTTP listener on $KEI_BUDDY_PORT
  ./kei-buddy webhook-delete            → reverts to polling

20 tests pass across 5 modules. Binary builds clean (default + extractor-openai).

## Memory binding (task #5)

New files:
  * src/schema.rs (56)        — buddy_state table DDL, idempotent
  * src/store.rs (164)        — BuddyStore trait + SqliteBuddyStore
  * src/store_ops.rs (107)    — pub(crate) sync SQL helpers behind spawn_blocking

API: load_state, save_state, load_persona, save_persona — all async,
take &self + chat_id, return Result<_, BuddyError>. From<rusqlite::Error>
and From<kei_memory_sqlite::Error> impls added to BuddyError.

## State-machine port (task #7)

New files:
  * src/transition.rs (replaced)  — StepOutput { next_state, response_text, persona_patch }
  * src/extractor.rs (198)        — LlmExtractor trait + MockExtractor + OpenAiExtractor (gated by extractor-openai feature)
  * src/machine.rs (250)          — handle_step async fn, 11-arm state machine
  * src/machine_helpers.rs (171)  — per-state helper fns
  * src/machine_tests.rs (103)    — 7 FSM tests with MockExtractor

Each TS branch from chat-onboard.ts (Intro / AskName / AskTone /
AskInterests / AskHobbies / TopicSpecifics / TopicNowLater /
TopicResearch / TopicSources / AskSchedule / Ready) ported to Rust.
Russian-language responses preserved verbatim. Topic queue stored in
persona_patch.__topic_state for caller round-tripping.

machine.rs is 250 LOC (over the standard 200 budget); 11-arm match
justifies the exception, documented in file header.

## Serve binary (task #6)

New files:
  * src/persona_merge.rs (85)     — JSON deep-merge helper
  * src/serve_telegram.rs (128)   — sendMessage / setWebhook / deleteWebhook HTTP helpers
  * src/serve.rs (162)            — axum Router, BuddyContext impl, run_serve
  * src/bin/kei-buddy.rs (rewritten, 120) — clap 4-subcommand CLI

Env: TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, KEI_BUDDY_PORT
(default 8080), KEI_BUDDY_DB_PATH (default ./kei-buddy.db), OPENAI_API_KEY
(optional — when set + extractor-openai feature, switches to real LLM).

axum + tracing-subscriber gated behind `serve` feature (default ON). Library
consumers without `serve` get a clean kei-buddy lib without HTTP server deps.

## Verify-before-commit

  * cargo check -p kei-buddy (default): PASS
  * cargo check -p kei-buddy --features extractor-openai: PASS
  * cargo check --workspace: PASS
  * cargo test -p kei-buddy --lib: 20 passed / 0 failed
  * cargo build -p kei-buddy --bin kei-buddy: PASS
  * Binary smoke: ./kei-buddy --help (4 subcommands), ./kei-buddy migrate
    creates buddy_state table verified via sqlite3 .tables

## Follow-up (deferred, non-blocking)

  * Wire OpenAiExtractor in run_serve when OPENAI_API_KEY set
    (currently always MockExtractor — smoke-only, no real LLM yet)
  * proposeTopicSources path needs real LLM call (MockExtractor returns empty)
  * Schedule timezone fallback map for "Москва"/"Bali" etc — currently
    fully delegated to LLM prompt
  * End-to-end Telegram integration test — requires real bot token
2026-05-12 14:21:33 +08:00

162 lines
4.8 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! `run_serve` — axum router builder + BuddyContext impl.
//!
//! Constructor Pattern: one responsibility — compose crate pieces into HTTP server.
//! Each function ≤ 30 LOC. No logging of bot tokens.
use std::sync::Arc;
use async_trait::async_trait;
use axum::{routing, Json, Router};
use serde_json::{json, Value};
use tracing::{error, warn};
use kei_telegram_webhook::{WebhookContext, WebhookEvent};
use crate::{
error::BuddyError,
extractor::LlmExtractor,
machine::handle_step,
persona_merge::deep_merge,
serve_telegram::send_message,
state::OnboardState,
store::{BuddyStore, SqliteBuddyStore},
};
/// Configuration passed from the binary to `run_serve`.
pub struct ServeConfig {
pub port: u16,
pub db_path: String,
pub bot_token: String,
pub webhook_secret: String,
}
/// Axum state — implements `WebhookContext` for the webhook handler.
///
/// `Arc<E>` provides cheap `Clone` without requiring `E: Clone`.
pub struct BuddyContext<E: LlmExtractor + Send + Sync + 'static> {
pub secret: String,
pub bot_token: String,
pub store: Arc<SqliteBuddyStore>,
pub extractor: Arc<E>,
pub http: reqwest::Client,
}
impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
fn clone(&self) -> Self {
Self {
secret: self.secret.clone(),
bot_token: self.bot_token.clone(),
store: Arc::clone(&self.store),
extractor: Arc::clone(&self.extractor),
http: self.http.clone(),
}
}
}
#[async_trait]
impl<E: LlmExtractor + Send + Sync + 'static> WebhookContext for BuddyContext<E> {
fn secret_token(&self) -> &str {
&self.secret
}
async fn on_event(&self, event: WebhookEvent) {
match event {
WebhookEvent::Text { chat_id, text, .. } => {
self.handle_text(chat_id, text).await;
}
other => {
warn!(event = ?other, "ignoring non-text webhook event");
}
}
}
}
impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
async fn handle_text(&self, chat_id: i64, text: String) {
if let Err(e) = self.process_text(chat_id, &text).await {
error!(chat_id, error = %e, "failed to process text event");
}
}
async fn process_text(&self, chat_id: i64, text: &str) -> Result<(), BuddyError> {
let state = self
.store
.load_state(chat_id)
.await?
.unwrap_or(OnboardState::Intro);
let persona = self
.store
.load_persona(chat_id)
.await?
.unwrap_or_else(|| serde_json::json!({}));
let output = handle_step(&state, text, &persona, self.extractor.as_ref()).await?;
self.store.save_state(chat_id, &output.next_state).await?;
self.apply_persona_patch(chat_id, output.persona_patch).await?;
send_message(&self.bot_token, chat_id, &output.response_text, &self.http).await?;
Ok(())
}
async fn apply_persona_patch(&self, chat_id: i64, patch: Value) -> Result<(), BuddyError> {
if patch == json!({}) {
return Ok(());
}
let base = self
.store
.load_persona(chat_id)
.await?
.unwrap_or_else(|| json!({}));
let merged = deep_merge(base, patch);
self.store.save_persona(chat_id, &merged).await
}
}
/// Health-check handler.
async fn health() -> Json<Value> {
Json(json!({
"status": "ok",
"crate": "kei-buddy",
"version": env!("CARGO_PKG_VERSION")
}))
}
/// Build the axum Router.
pub fn build_router<E>(ctx: BuddyContext<E>) -> Router
where
E: LlmExtractor + Send + Sync + 'static,
{
Router::new()
.route(
"/webhook",
routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>),
)
.route("/health", routing::get(health))
.with_state(ctx)
}
/// Start the HTTP server.
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
init_tracing();
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
let ctx = BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store,
extractor,
http: reqwest::Client::new(),
};
let router = build_router(ctx);
let addr = format!("0.0.0.0:{}", cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "kei-buddy listening");
axum::serve(listener, router).await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
}