Three follow-up atomics on top of the contacts/topics/sync wave.
## 1. AskLanguage state + ru/en localisation (default en)
New state `AskLanguage` inserted between `Intro` and `AskName`. Intro now
sends a bilingual greeting + language picker. AskLanguage parses
en/english/1/ru/русский/2/etc → persona_patch{"language":"<code>"} →
transitions to AskName with that language's prompt.
All later prompts (AskName / AskTone / AskInterests / AskHobbies /
TopicSpecifics / TopicNowLater / TopicResearch / AskSchedule / Ready)
read persona.language via Lang::from_persona and dispatch through
Strings::* helpers — two language tables, no fallthrough.
Back-compat migration: existing chats without `language` key (like the
user currently in topic_now_later) get an implicit "ru" patch on next
turn so their Russian onboarding continues without regression.
New files: strings.rs (164), machine_lang.rs (145).
Modified: state.rs (+AskLanguage variant), machine.rs (Intro→AskLanguage,
AskLanguage arm, migration guard), machine_helpers.rs, machine_tests.rs.
5 new tests (intro_to_ask_language, ask_language_en, ask_language_ru,
ask_language_invalid, migration_sets_ru_when_language_missing).
## 2. Real proposeTopicSources — removed TODO(phase2) stub
machine_lang.rs::step_topic_research now calls
extractor.extract(prompt, topic_title) with a {name, url, why} schema.
Parses JSON, formats numbered source list, transitions to TopicSources.
Failure paths (LLM error, empty array): graceful fallback prompt asking
user to suggest their own — still transitions to TopicSources so flow
doesn't deadlock.
3 new tests in machine_tests_topic_research.rs:
topic_research_yes_proposes_sources,
topic_research_yes_empty_sources_still_advances,
topic_research_no_skips_topic_sources.
## 3. Voice-message handling (Telegram voice/audio → STT → text pipeline)
kei-telegram-webhook: added Voice/Audio sub-structs on Message and
WebhookEvent::Voice variant. classify() detects message.voice OR
message.audio. 2 new tests in event.rs.
kei-buddy/src/voice.rs (178 LOC):
VoiceHandler { bot_token, stt: Arc<dyn SttBackend>, http }
transcribe_file(file_id, mime_type) does:
1. GET https://api.telegram.org/bot{token}/getFile?file_id=...
2. GET https://api.telegram.org/file/bot{token}/{file_path}
3. SttRequest { audio_bytes, mime_type, language: None } → backend.transcribe
4. Returns transcript text.
2 wiremock tests (download chain + 500 error mapping).
serve.rs adds voice: Option<Arc<VoiceHandler>> to BuddyContext;
on_event Voice arm: whitelist check → transcribe → handle_text (same
pipeline as if user typed). Voice unavailable: warn + ignore.
serve_runner.rs builds VoiceHandler from KEI_BUDDY_STT_BACKEND env.
kei-stt added as optional dep gated by serve feature. Default backend
whisper-local (no extra build deps).
TTS reply path deferred (next atomic).
## Verify
* cargo check --workspace: PASS
* cargo test -p kei-buddy --lib: 55 passed / 0 failed (was 41 → 50 → 53 → 55)
* cargo test -p kei-telegram-webhook --lib: 7 passed (was 5, +2 voice)
* cargo build -p kei-buddy --release: PASS (23.7s)
NOT deployed yet — three new things to roll out next:
* новые миграции (нет — БД без изменений)
* новые env: KEI_BUDDY_STT_BACKEND (optional)
* установка faster-whisper / piper-tts на сервер для STT
(без него Voice event просто warn-логируется и игнорируется)
83 lines
3.1 KiB
Rust
83 lines
3.1 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
//! `run_serve` — store construction + HTTP listener bootstrap.
|
|
//! Extracted from serve.rs to keep both files ≤ 200 LOC.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::json;
|
|
use tracing::warn;
|
|
|
|
use crate::{
|
|
chat_log::ChatLog,
|
|
contacts::Contacts,
|
|
extractor::LlmExtractor,
|
|
serve::{BuddyContext, ServeConfig},
|
|
store::SqliteBuddyStore,
|
|
topics::Topics,
|
|
voice::VoiceHandler,
|
|
};
|
|
|
|
/// Start the HTTP server (entry-point called from the binary).
|
|
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
|
init_tracing();
|
|
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
|
|
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
|
|
let http = reqwest::Client::new();
|
|
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
|
|
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
|
|
let contacts = Arc::new(Contacts::from_path(&cfg.contacts_db_path)?);
|
|
let voice = build_voice_handler(cfg.stt_backend.as_deref(), &cfg.bot_token);
|
|
|
|
#[cfg(feature = "extractor-openai")]
|
|
{
|
|
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
|
|
let model = cfg.llm_model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
|
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
|
|
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
|
|
proxy, key, model,
|
|
));
|
|
return start_listener(cfg.port, BuddyContext {
|
|
secret: cfg.webhook_secret,
|
|
bot_token: cfg.bot_token,
|
|
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
|
|
}).await;
|
|
}
|
|
}
|
|
|
|
warn!("no LLM extractor configured — using MockExtractor");
|
|
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
|
|
start_listener(cfg.port, BuddyContext {
|
|
secret: cfg.webhook_secret,
|
|
bot_token: cfg.bot_token,
|
|
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
|
|
}).await
|
|
}
|
|
|
|
fn build_voice_handler(stt_backend: Option<&str>, bot_token: &str) -> Option<Arc<VoiceHandler>> {
|
|
let name = stt_backend?;
|
|
std::env::set_var("KEI_STT_BACKEND", name);
|
|
match kei_stt::from_env() {
|
|
Ok(stt) => Some(Arc::new(VoiceHandler::new(bot_token.to_string(), Arc::from(stt)))),
|
|
Err(e) => {
|
|
tracing::warn!(backend = name, error = %e, "STT init failed; voice disabled");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
|
|
where
|
|
E: LlmExtractor + Send + Sync + 'static,
|
|
{
|
|
let router = crate::serve::build_router(ctx);
|
|
let addr = format!("0.0.0.0:{}", 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();
|
|
}
|