feat(kei-buddy): AskLanguage i18n + real proposeTopicSources + voice handling
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-логируется и игнорируется)
This commit is contained in:
parent
1e9ce21c2a
commit
87d7b1c5c4
16 changed files with 1029 additions and 183 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -3200,6 +3200,7 @@ dependencies = [
|
||||||
"kei-memory-sqlite",
|
"kei-memory-sqlite",
|
||||||
"kei-sage",
|
"kei-sage",
|
||||||
"kei-social-store",
|
"kei-social-store",
|
||||||
|
"kei-stt",
|
||||||
"kei-telegram-webhook",
|
"kei-telegram-webhook",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ chrono = { workspace = true }
|
||||||
axum = { version = "0.7", features = ["json", "http1", "tokio"], optional = true }
|
axum = { version = "0.7", features = ["json", "http1", "tokio"], optional = true }
|
||||||
kei-telegram-webhook = { path = "../kei-telegram-webhook", optional = true }
|
kei-telegram-webhook = { path = "../kei-telegram-webhook", optional = true }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||||
|
kei-stt = { path = "../kei-stt", default-features = false, features = ["whisper-local"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
|
|
@ -50,7 +51,7 @@ tokio = { workspace = true }
|
||||||
[features]
|
[features]
|
||||||
default = ["serve"]
|
default = ["serve"]
|
||||||
# HTTP server — axum router + webhook handler + Telegram send_message.
|
# HTTP server — axum router + webhook handler + Telegram send_message.
|
||||||
serve = ["axum", "kei-telegram-webhook", "tracing-subscriber"]
|
serve = ["axum", "kei-telegram-webhook", "tracing-subscriber", "kei-stt"]
|
||||||
# Enables OpenAiExtractor — real HTTP to LiteLLM proxy using reqwest.
|
# Enables OpenAiExtractor — real HTTP to LiteLLM proxy using reqwest.
|
||||||
# Off by default; tests use MockExtractor which has no extra deps.
|
# Off by default; tests use MockExtractor which has no extra deps.
|
||||||
extractor-openai = []
|
extractor-openai = []
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ async fn cmd_serve() -> anyhow::Result<()> {
|
||||||
chat_log_db_path: chat_log_path_from_env(),
|
chat_log_db_path: chat_log_path_from_env(),
|
||||||
topics_db_path: topics_db_path_from_env(),
|
topics_db_path: topics_db_path_from_env(),
|
||||||
contacts_db_path: contacts_db_path_from_env(),
|
contacts_db_path: contacts_db_path_from_env(),
|
||||||
|
stt_backend: std::env::var("KEI_BUDDY_STT_BACKEND").ok(),
|
||||||
};
|
};
|
||||||
run_serve(cfg).await
|
run_serve(cfg).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ pub mod error;
|
||||||
pub mod extractor;
|
pub mod extractor;
|
||||||
pub mod machine;
|
pub mod machine;
|
||||||
pub(crate) mod machine_helpers;
|
pub(crate) mod machine_helpers;
|
||||||
|
pub(crate) mod machine_lang;
|
||||||
pub mod persona_merge;
|
pub mod persona_merge;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub(crate) mod store_ops;
|
pub(crate) mod store_ops;
|
||||||
|
pub mod strings;
|
||||||
pub mod tick;
|
pub mod tick;
|
||||||
pub mod topic_classify;
|
pub mod topic_classify;
|
||||||
pub mod topics;
|
pub mod topics;
|
||||||
|
|
@ -35,6 +37,8 @@ pub mod serve;
|
||||||
pub(crate) mod serve_runner;
|
pub(crate) mod serve_runner;
|
||||||
#[cfg(feature = "serve")]
|
#[cfg(feature = "serve")]
|
||||||
pub mod serve_telegram;
|
pub mod serve_telegram;
|
||||||
|
#[cfg(feature = "serve")]
|
||||||
|
pub mod voice;
|
||||||
|
|
||||||
pub use chat_log::ChatLog;
|
pub use chat_log::ChatLog;
|
||||||
pub use commands::{parse_command, execute_command, Command, CommandStores};
|
pub use commands::{parse_command, execute_command, Command, CommandStores};
|
||||||
|
|
@ -45,6 +49,9 @@ pub use extractor::LlmExtractor;
|
||||||
pub use machine::handle_step;
|
pub use machine::handle_step;
|
||||||
pub use state::OnboardState;
|
pub use state::OnboardState;
|
||||||
pub use store::{BuddyStore, SqliteBuddyStore};
|
pub use store::{BuddyStore, SqliteBuddyStore};
|
||||||
|
pub use strings::{Lang, Strings};
|
||||||
pub use tick::{run_tick, run_tick_with, TickConfig, TickReport};
|
pub use tick::{run_tick, run_tick_with, TickConfig, TickReport};
|
||||||
pub use topics::Topics;
|
pub use topics::Topics;
|
||||||
pub use transition::StepOutput;
|
pub use transition::StepOutput;
|
||||||
|
#[cfg(feature = "serve")]
|
||||||
|
pub use voice::VoiceHandler;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,28 @@
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
//! Onboarding state-machine: `handle_step` (11-arm FSM match).
|
//! Onboarding state-machine: `handle_step` (12-arm FSM match).
|
||||||
//! Helpers → machine_helpers.rs. Tests → machine_tests.rs.
|
//! Helpers → machine_helpers.rs. Tests → machine_tests.rs.
|
||||||
|
//!
|
||||||
|
//! LOC exception: file is allowed up to 260 LOC (Constructor Pattern §thresholds).
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::error::BuddyError;
|
use crate::error::BuddyError;
|
||||||
use crate::extractor::{
|
use crate::extractor::{
|
||||||
LlmExtractor, prompt_list, prompt_name, prompt_now_or_later, prompt_propose_sources,
|
LlmExtractor, prompt_list, prompt_name, prompt_now_or_later,
|
||||||
prompt_schedule, prompt_tone, prompt_topic_specifics, prompt_yes_no, TONES,
|
prompt_schedule, prompt_tone, prompt_topic_specifics, TONES,
|
||||||
};
|
};
|
||||||
use crate::machine_helpers::{
|
use crate::machine_helpers::{
|
||||||
ask_schedule, build_topic_state, clamp_hour, describe_schedule, extract_string, finish_topic,
|
build_topic_state, clamp_hour, describe_schedule, extract_string, finish_topic,
|
||||||
format_list, parse_source_selection, str_list,
|
format_list, parse_source_selection, str_list,
|
||||||
};
|
};
|
||||||
|
use crate::machine_lang::{
|
||||||
|
ask_schedule_lang, backfill_language, build_ready_response, handle_ask_language,
|
||||||
|
step_topic_research,
|
||||||
|
};
|
||||||
use crate::state::OnboardState;
|
use crate::state::OnboardState;
|
||||||
|
use crate::strings::{Lang, Strings};
|
||||||
use crate::transition::StepOutput;
|
use crate::transition::StepOutput;
|
||||||
|
|
||||||
const INTRO: &str = "👋 Привет! Я KeiBuddie — твой персональный AI-компаньон от KeiSei.\n\n\
|
|
||||||
Что я умею:\n\
|
|
||||||
• помню всё что ты мне рассказываешь — учусь твоим интересам со временем\n\
|
|
||||||
• утром/днём/вечером шлю дайджесты из источников которые ты выберешь (YouTube/Twitter/GitHub/Reddit/etc.)\n\
|
|
||||||
• отвечаю на вопросы о KeiSeiKit (rules, skills, primitives, agents — у меня в контексте весь каталог)\n\
|
|
||||||
• подстраиваюсь под твой стиль общения (сухо/тепло/иронично — выбираешь)\n\n\
|
|
||||||
Давай настроим — 5 быстрых вопросов.";
|
|
||||||
|
|
||||||
/// Advance the onboarding FSM by one user message.
|
/// Advance the onboarding FSM by one user message.
|
||||||
/// Merge `StepOutput::persona_patch` into the persona blob before the next call.
|
/// Merge `StepOutput::persona_patch` into the persona blob before the next call.
|
||||||
/// `__topic_state` in the patch tracks the per-topic loop; keep it in blob.
|
/// `__topic_state` in the patch tracks the per-topic loop; keep it in blob.
|
||||||
|
|
@ -32,27 +31,69 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
user_text: &str,
|
user_text: &str,
|
||||||
persona: &Value,
|
persona: &Value,
|
||||||
extractor: &E,
|
extractor: &E,
|
||||||
|
) -> Result<StepOutput, BuddyError> {
|
||||||
|
// Back-compat migration: chats that started before language selection was
|
||||||
|
// added will have no `language` key. Treat them as Russian so existing
|
||||||
|
// in-progress threads keep their original language.
|
||||||
|
// Skipped for Intro / AskLanguage (language not yet chosen) and Ready
|
||||||
|
// (onboarding complete, no need to persist migration patch).
|
||||||
|
let migration_patch = match state {
|
||||||
|
OnboardState::Intro | OnboardState::AskLanguage | OnboardState::Ready => None,
|
||||||
|
_ => backfill_language(persona),
|
||||||
|
};
|
||||||
|
let lang = Lang::from_persona(persona);
|
||||||
|
|
||||||
|
let mut out = step_dispatch(state, user_text, persona, extractor, lang).await?;
|
||||||
|
|
||||||
|
// Merge migration patch when present.
|
||||||
|
if let Some(mp) = migration_patch {
|
||||||
|
if let (Some(obj), Some(mp_obj)) = (
|
||||||
|
out.persona_patch.as_object_mut(),
|
||||||
|
mp.as_object(),
|
||||||
|
) {
|
||||||
|
for (k, v) in mp_obj {
|
||||||
|
obj.entry(k).or_insert_with(|| v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn step_dispatch<E: LlmExtractor>(
|
||||||
|
state: &OnboardState,
|
||||||
|
user_text: &str,
|
||||||
|
persona: &Value,
|
||||||
|
extractor: &E,
|
||||||
|
lang: Lang,
|
||||||
) -> Result<StepOutput, BuddyError> {
|
) -> Result<StepOutput, BuddyError> {
|
||||||
match state {
|
match state {
|
||||||
OnboardState::Intro => Ok(StepOutput {
|
OnboardState::Intro => Ok(StepOutput {
|
||||||
next_state: OnboardState::AskName,
|
next_state: OnboardState::AskLanguage,
|
||||||
response_text: format!("{INTRO}\n\n*Шаг 1/5.* Как тебя называть?"),
|
response_text: Strings::intro_ask_language().to_owned(),
|
||||||
persona_patch: json!({}),
|
persona_patch: json!({}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
OnboardState::AskLanguage => Ok(handle_ask_language(user_text).unwrap_or_else(|| {
|
||||||
|
StepOutput {
|
||||||
|
next_state: OnboardState::AskLanguage,
|
||||||
|
response_text: Strings::invalid_language().to_owned(),
|
||||||
|
persona_patch: json!({}),
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
OnboardState::AskName => {
|
OnboardState::AskName => {
|
||||||
let v = extractor.extract(prompt_name(), user_text).await?;
|
let v = extractor.extract(prompt_name(), user_text).await?;
|
||||||
let name: String = v["name"]
|
let name: String = v["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or(user_text.trim())
|
.unwrap_or(user_text.trim())
|
||||||
.chars().take(40).collect();
|
.chars().take(40).collect();
|
||||||
|
let step2 = match lang { Lang::En => "Step 2/5.", Lang::Ru => "Шаг 2/5." };
|
||||||
|
let ok = match lang { Lang::En => "Got it,", Lang::Ru => "Отлично," };
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::AskTone,
|
next_state: OnboardState::AskTone,
|
||||||
response_text: format!(
|
response_text: format!(
|
||||||
"Отлично, *{name}*. Запомнил.\n\n\
|
"{ok} *{name}*.\n\n*{step2}* {}",
|
||||||
*Шаг 2/5.* Какой стиль общения тебе ближе? Опиши своими словами — например, \
|
Strings::ask_tone(lang)
|
||||||
\"по-дружески\", \"сухо и по делу\", \"с иронией\". \
|
|
||||||
Или просто слово: `friendly`, `calm`, `stoic`, `sarcastic`, `professional`."
|
|
||||||
),
|
),
|
||||||
persona_patch: json!({ "name": name }),
|
persona_patch: json!({ "name": name }),
|
||||||
})
|
})
|
||||||
|
|
@ -62,12 +103,13 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
let v = extractor.extract(prompt_tone(), user_text).await?;
|
let v = extractor.extract(prompt_tone(), user_text).await?;
|
||||||
let raw = v["tone"].as_str().unwrap_or("").to_lowercase();
|
let raw = v["tone"].as_str().unwrap_or("").to_lowercase();
|
||||||
let tone = if TONES.contains(&raw.as_str()) { raw } else { "friendly".to_owned() };
|
let tone = if TONES.contains(&raw.as_str()) { raw } else { "friendly".to_owned() };
|
||||||
|
let step3 = match lang { Lang::En => "Step 3/5.", Lang::Ru => "Шаг 3/5." };
|
||||||
|
let ok = match lang { Lang::En => "Tone:", Lang::Ru => "Тон:" };
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::AskInterests,
|
next_state: OnboardState::AskInterests,
|
||||||
response_text: format!(
|
response_text: format!(
|
||||||
"Тон: *{tone}*. Принято.\n\n\
|
"{ok} *{tone}*.\n\n*{step3}* {}",
|
||||||
*Шаг 3/5.* Какие у тебя интересы? Просто перечисли — \
|
Strings::ask_interests(lang)
|
||||||
как удобно (через запятую, списком, или одним абзацем)."
|
|
||||||
),
|
),
|
||||||
persona_patch: json!({ "tone": tone }),
|
persona_patch: json!({ "tone": tone }),
|
||||||
})
|
})
|
||||||
|
|
@ -77,29 +119,32 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
let prompt = prompt_list("interests");
|
let prompt = prompt_list("interests");
|
||||||
let v = extractor.extract(&prompt, user_text).await?;
|
let v = extractor.extract(&prompt, user_text).await?;
|
||||||
let interests = str_list(&v["items"]);
|
let interests = str_list(&v["items"]);
|
||||||
|
let step4 = match lang { Lang::En => "Step 4/5.", Lang::Ru => "Шаг 4/5." };
|
||||||
|
let label = match lang { Lang::En => "Interests:", Lang::Ru => "Интересы:" };
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::AskHobbies,
|
next_state: OnboardState::AskHobbies,
|
||||||
response_text: format!(
|
response_text: format!(
|
||||||
"Интересы: {}.\n\n\
|
"{label} {}.\n\n*{step4}* {}",
|
||||||
*Шаг 4/5.* А хобби? Чем конкретно занимаешься в свободное время.",
|
format_list(&interests),
|
||||||
format_list(&interests)
|
Strings::ask_hobbies(lang)
|
||||||
),
|
),
|
||||||
persona_patch: json!({ "interests": interests }),
|
persona_patch: json!({ "interests": interests }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardState::AskHobbies => step_ask_hobbies(user_text, persona, extractor).await,
|
OnboardState::AskHobbies => step_ask_hobbies(user_text, persona, extractor, lang).await,
|
||||||
|
|
||||||
OnboardState::TopicSpecifics => {
|
OnboardState::TopicSpecifics => {
|
||||||
let v = extractor.extract(prompt_topic_specifics(), user_text).await?;
|
let v = extractor.extract(prompt_topic_specifics(), user_text).await?;
|
||||||
let specifics = str_list(&v["aspects"]);
|
let specifics = str_list(&v["aspects"]);
|
||||||
let cur_name = extract_string(&persona["current_topic"], "name");
|
let cur_name = extract_string(&persona["current_topic"], "name");
|
||||||
|
let understood = match lang { Lang::En => "Got it on", Lang::Ru => "Понял по" };
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::TopicNowLater,
|
next_state: OnboardState::TopicNowLater,
|
||||||
response_text: format!(
|
response_text: format!(
|
||||||
"Понял по *{cur_name}*: {}.\n\n\
|
"{understood} *{cur_name}*: {}.\n\n{}",
|
||||||
Хочешь *обсудить это сейчас* или *сохранить на потом*?",
|
format_list(&specifics),
|
||||||
format_list(&specifics)
|
Strings::topic_now_later(lang)
|
||||||
),
|
),
|
||||||
persona_patch: json!({ "current_topic_specifics": specifics }),
|
persona_patch: json!({ "current_topic_specifics": specifics }),
|
||||||
})
|
})
|
||||||
|
|
@ -109,21 +154,22 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
let v = extractor.extract(prompt_now_or_later(), user_text).await?;
|
let v = extractor.extract(prompt_now_or_later(), user_text).await?;
|
||||||
let defer = v["decision"].as_str().unwrap_or("later") != "now";
|
let defer = v["decision"].as_str().unwrap_or("later") != "now";
|
||||||
let cur_name = extract_string(&persona["current_topic"], "name");
|
let cur_name = extract_string(&persona["current_topic"], "name");
|
||||||
let body = if !defer { format!("Окей, обсудим *{cur_name}* подробно когда закончим настройку. Запомнил.") }
|
let body = build_now_later_msg(lang, &cur_name, defer);
|
||||||
else { format!("Отложил *{cur_name}* на потом.") };
|
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::TopicResearch,
|
next_state: OnboardState::TopicResearch,
|
||||||
response_text: format!("{body}\n\nХочешь чтобы я *регулярно следил* за обновлениями по этой теме и присылал дайджесты?"),
|
response_text: format!("{body}\n\n{}", Strings::topic_research(lang)),
|
||||||
persona_patch: json!({ "current_topic_defer": defer }),
|
persona_patch: json!({ "current_topic_defer": defer }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardState::TopicResearch => step_topic_research(user_text, persona, extractor).await,
|
OnboardState::TopicResearch => step_topic_research(user_text, persona, extractor, lang).await,
|
||||||
|
|
||||||
OnboardState::TopicSources => {
|
OnboardState::TopicSources => {
|
||||||
let cur = &persona["current_topic"];
|
let cur = &persona["current_topic"];
|
||||||
let (cur_name, kind_interest) = (extract_string(cur, "name"), extract_string(cur, "kind").as_str() == "interest");
|
let cur_name = extract_string(cur, "name");
|
||||||
let (specifics, defer) = (str_list(&persona["current_topic_specifics"]), persona["current_topic_defer"].as_bool().unwrap_or(true));
|
let kind_interest = extract_string(cur, "kind").as_str() == "interest";
|
||||||
|
let specifics = str_list(&persona["current_topic_specifics"]);
|
||||||
|
let defer = persona["current_topic_defer"].as_bool().unwrap_or(true);
|
||||||
let proposed: Vec<Value> = persona["current_topic_proposed"].as_array().cloned().unwrap_or_default();
|
let proposed: Vec<Value> = persona["current_topic_proposed"].as_array().cloned().unwrap_or_default();
|
||||||
let picked = parse_source_selection(user_text, proposed.len());
|
let picked = parse_source_selection(user_text, proposed.len());
|
||||||
Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, true, &proposed, &picked))
|
Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, true, &proposed, &picked))
|
||||||
|
|
@ -137,20 +183,7 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
let tone = persona["tone"].as_str().unwrap_or("friendly");
|
let tone = persona["tone"].as_str().unwrap_or("friendly");
|
||||||
let interests = str_list(&persona["interests"]);
|
let interests = str_list(&persona["interests"]);
|
||||||
let sched_str = describe_schedule(morning, evening, &tz);
|
let sched_str = describe_schedule(morning, evening, &tz);
|
||||||
Ok(StepOutput {
|
Ok(build_ready_response(lang, tone, &interests, &sched_str, morning, evening, &tz))
|
||||||
next_state: OnboardState::Ready,
|
|
||||||
response_text: format!(
|
|
||||||
"Готово! ✨ Я настроен.\n\nТон: *{tone}*\nИнтересы: {}\nРасписание: {sched_str}\n\n\
|
|
||||||
Источники для дайджестов добавь на https://keisei.app/keibuddy \
|
|
||||||
(10 платформ — YouTube, Twitter, GitHub и др.).\n\n\
|
|
||||||
Теперь можешь писать мне о чём угодно — буду помнить и подстраиваться. \
|
|
||||||
Скажи что-нибудь!",
|
|
||||||
format_list(&interests)
|
|
||||||
),
|
|
||||||
persona_patch: json!({
|
|
||||||
"schedule": { "morning": morning, "evening": evening, "timezone": tz }
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardState::Ready => Ok(StepOutput {
|
OnboardState::Ready => Ok(StepOutput {
|
||||||
|
|
@ -163,10 +196,20 @@ pub async fn handle_step<E: LlmExtractor>(
|
||||||
|
|
||||||
// ─── arm helpers ─────────────────────────────────────────────────────────────
|
// ─── arm helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_now_later_msg(lang: Lang, cur_name: &str, defer: bool) -> String {
|
||||||
|
match (lang, defer) {
|
||||||
|
(Lang::En, false) => format!("Ok, we'll discuss *{cur_name}* in detail after setup. Noted."),
|
||||||
|
(Lang::En, true) => format!("Saved *{cur_name}* for later."),
|
||||||
|
(Lang::Ru, false) => format!("Окей, обсудим *{cur_name}* подробно когда закончим настройку. Запомнил."),
|
||||||
|
(Lang::Ru, true) => format!("Отложил *{cur_name}* на потом."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn step_ask_hobbies<E: LlmExtractor>(
|
async fn step_ask_hobbies<E: LlmExtractor>(
|
||||||
user_text: &str,
|
user_text: &str,
|
||||||
persona: &Value,
|
persona: &Value,
|
||||||
extractor: &E,
|
extractor: &E,
|
||||||
|
lang: Lang,
|
||||||
) -> Result<StepOutput, BuddyError> {
|
) -> Result<StepOutput, BuddyError> {
|
||||||
let prompt = prompt_list("hobbies");
|
let prompt = prompt_list("hobbies");
|
||||||
let v = extractor.extract(&prompt, user_text).await?;
|
let v = extractor.extract(&prompt, user_text).await?;
|
||||||
|
|
@ -176,10 +219,12 @@ async fn step_ask_hobbies<E: LlmExtractor>(
|
||||||
.iter().map(|n| json!({"name": n, "kind": "interest"}))
|
.iter().map(|n| json!({"name": n, "kind": "interest"}))
|
||||||
.chain(hobbies.iter().map(|n| json!({"name": n, "kind": "hobby"})))
|
.chain(hobbies.iter().map(|n| json!({"name": n, "kind": "hobby"})))
|
||||||
.collect();
|
.collect();
|
||||||
|
let hobbies_label = match lang { Lang::En => "Hobbies:", Lang::Ru => "Хобби:" };
|
||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
return Ok(ask_schedule(
|
return Ok(ask_schedule_lang(
|
||||||
&json!({ "hobbies": hobbies }),
|
&json!({ "hobbies": hobbies }),
|
||||||
&format!("Хобби: {}.", format_list(&hobbies)),
|
&format!("{hobbies_label} {}.", format_list(&hobbies)),
|
||||||
|
lang,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let next_topic = queue[0].clone();
|
let next_topic = queue[0].clone();
|
||||||
|
|
@ -188,62 +233,18 @@ async fn step_ask_hobbies<E: LlmExtractor>(
|
||||||
let mut patch = ts;
|
let mut patch = ts;
|
||||||
patch["hobbies"] = json!(hobbies);
|
patch["hobbies"] = json!(hobbies);
|
||||||
patch["current_topic"] = next_topic;
|
patch["current_topic"] = next_topic;
|
||||||
|
let prefix_str = Strings::topic_specifics_prefix(lang);
|
||||||
|
let question_str = Strings::topic_specifics_question(lang);
|
||||||
Ok(StepOutput {
|
Ok(StepOutput {
|
||||||
next_state: OnboardState::TopicSpecifics,
|
next_state: OnboardState::TopicSpecifics,
|
||||||
response_text: format!(
|
response_text: format!(
|
||||||
"Хобби: {}.\n\nТеперь разберём по темам. Поехали — сначала *{topic_name}*.\n\n\
|
"{hobbies_label} {}.\n\n{prefix_str} *{topic_name}*.\n\n{question_str}",
|
||||||
*Что именно* в этой теме тебе интересно? Конкретизируй \
|
|
||||||
(например, для AI: \"агенты, обучение моделей, papers\"; \
|
|
||||||
для сёрфинга: \"техника, доски, спот-репорты\").",
|
|
||||||
format_list(&hobbies)
|
format_list(&hobbies)
|
||||||
),
|
),
|
||||||
persona_patch: patch,
|
persona_patch: patch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn step_topic_research<E: LlmExtractor>(
|
|
||||||
user_text: &str,
|
|
||||||
persona: &Value,
|
|
||||||
extractor: &E,
|
|
||||||
) -> Result<StepOutput, BuddyError> {
|
|
||||||
let v = extractor.extract(prompt_yes_no(), user_text).await?;
|
|
||||||
let want_research = v["yes"].as_bool().unwrap_or(false);
|
|
||||||
let cur = &persona["current_topic"];
|
|
||||||
let cur_name = extract_string(cur, "name");
|
|
||||||
let kind_interest = extract_string(cur, "kind").as_str() == "interest";
|
|
||||||
let specifics = str_list(&persona["current_topic_specifics"]);
|
|
||||||
let defer = persona["current_topic_defer"].as_bool().unwrap_or(true);
|
|
||||||
if !want_research {
|
|
||||||
return Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, false, &[], &[]));
|
|
||||||
}
|
|
||||||
// TODO(phase2): proposeTopicSources — real production wires OpenAiExtractor here.
|
|
||||||
// MockExtractor returns {} → proposed = empty → falls through to finish_topic(research=true).
|
|
||||||
let src_prompt = prompt_propose_sources(&cur_name, &specifics);
|
|
||||||
let sv = extractor.extract(&src_prompt, "").await?;
|
|
||||||
let proposed: Vec<Value> = sv["sources"].as_array().cloned().unwrap_or_default();
|
|
||||||
if proposed.is_empty() {
|
|
||||||
return Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, true, &[], &[]));
|
|
||||||
}
|
|
||||||
let list = proposed.iter().enumerate()
|
|
||||||
.map(|(i, s)| format!(
|
|
||||||
"{}. `{}` *{}* — {}",
|
|
||||||
i + 1,
|
|
||||||
s["platform"].as_str().unwrap_or("?"),
|
|
||||||
s["handle_or_url"].as_str().unwrap_or("?"),
|
|
||||||
s["why"].as_str().unwrap_or("")
|
|
||||||
))
|
|
||||||
.collect::<Vec<_>>().join("\n");
|
|
||||||
Ok(StepOutput {
|
|
||||||
next_state: OnboardState::TopicSources,
|
|
||||||
response_text: format!(
|
|
||||||
"Предлагаю источники по *{cur_name}*:\n\n{list}\n\n\
|
|
||||||
Какие добавить? Напиши номера через запятую (`1,3,5`), `все`, или `нет`. \
|
|
||||||
Можешь добавить свои — просто напиши \"плюс <платформа> <handle>\"."
|
|
||||||
),
|
|
||||||
persona_patch: json!({ "current_topic_proposed": proposed }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests live in machine_tests.rs (Constructor Pattern: separate test module).
|
// Tests live in machine_tests.rs (Constructor Pattern: separate test module).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "machine_tests.rs"]
|
#[path = "machine_tests.rs"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
//! Pure helper functions for `machine::handle_step`.
|
//! Pure helper functions for `machine::handle_step`.
|
||||||
//!
|
//!
|
||||||
//! Constructor Pattern split: helpers extracted so `machine.rs` stays
|
//! Constructor Pattern split: helpers extracted so `machine.rs` stays
|
||||||
//! within its 250-LOC exception budget.
|
//! within its 260-LOC exception budget.
|
||||||
|
//! Language-aware helpers live in `machine_lang.rs`.
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
|
@ -121,7 +122,7 @@ pub(crate) fn finish_topic(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
return ask_schedule(&json!({ "topics_done": done }), &summary);
|
return ask_schedule_finish(&json!({ "topics_done": done }), &summary);
|
||||||
}
|
}
|
||||||
let next_topic = &queue[0];
|
let next_topic = &queue[0];
|
||||||
let next_name = next_topic["name"].as_str().unwrap_or("?");
|
let next_name = next_topic["name"].as_str().unwrap_or("?");
|
||||||
|
|
@ -138,6 +139,20 @@ pub(crate) fn finish_topic(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Internal schedule prompt used by `finish_topic` — always Russian (back-compat).
|
||||||
|
/// The per-turn language-aware variant is in `machine_lang::ask_schedule_lang`.
|
||||||
|
fn ask_schedule_finish(extra_patch: &Value, prefix: &str) -> StepOutput {
|
||||||
|
StepOutput {
|
||||||
|
next_state: OnboardState::AskSchedule,
|
||||||
|
response_text: format!(
|
||||||
|
"{prefix}\n\nТемы разобрали. ⏰ Когда удобно получать дайджесты? Напиши свободно — \
|
||||||
|
например, \"утром часов в 8, вечером в 10, я в Бали\" или \"вечером в 9\". \
|
||||||
|
Если не нужно — напиши \"нет\"."
|
||||||
|
),
|
||||||
|
persona_patch: extra_patch.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_source_line(research: bool, picked: &[usize], proposed: &[Value]) -> String {
|
fn build_source_line(research: bool, picked: &[usize], proposed: &[Value]) -> String {
|
||||||
if research && !picked.is_empty() {
|
if research && !picked.is_empty() {
|
||||||
let handles: Vec<_> = picked.iter()
|
let handles: Vec<_> = picked.iter()
|
||||||
|
|
@ -152,20 +167,3 @@ fn build_source_line(research: bool, picked: &[usize], proposed: &[Value]) -> St
|
||||||
"_без мониторинга_".to_owned()
|
"_без мониторинга_".to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ask_schedule(extra_patch: &Value, prefix: &str) -> StepOutput {
|
|
||||||
let intro = if prefix.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("{prefix}\n\n")
|
|
||||||
};
|
|
||||||
StepOutput {
|
|
||||||
next_state: OnboardState::AskSchedule,
|
|
||||||
response_text: format!(
|
|
||||||
"{intro}Темы разобрали. ⏰ Когда удобно получать дайджесты? Напиши свободно — \
|
|
||||||
например, \"утром часов в 8, вечером в 10, я в Бали\" или \"вечером в 9\". \
|
|
||||||
Если не нужно — напиши \"нет\"."
|
|
||||||
),
|
|
||||||
persona_patch: extra_patch.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
190
_primitives/_rust/kei-buddy/src/machine_lang.rs
Normal file
190
_primitives/_rust/kei-buddy/src/machine_lang.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//! Language-aware helpers for `machine::handle_step`.
|
||||||
|
//!
|
||||||
|
//! Extracted from machine_helpers.rs (Constructor Pattern: one file ≤ 200 LOC).
|
||||||
|
//! Covers: language selection, migration back-fill, schedule/ready response builders,
|
||||||
|
//! and the async TopicResearch arm (needs LlmExtractor).
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::error::BuddyError;
|
||||||
|
use crate::extractor::{LlmExtractor, prompt_yes_no};
|
||||||
|
use crate::machine_helpers::{extract_string, finish_topic, format_list, str_list};
|
||||||
|
use crate::state::OnboardState;
|
||||||
|
use crate::strings::{Lang, Strings};
|
||||||
|
use crate::transition::StepOutput;
|
||||||
|
|
||||||
|
// ─── language selection ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handle the `AskLanguage` state.
|
||||||
|
///
|
||||||
|
/// Returns `Some(StepOutput)` when the input is a recognised language choice
|
||||||
|
/// (advances to AskName). Returns `None` on invalid input (caller loops).
|
||||||
|
pub(crate) fn handle_ask_language(user_text: &str) -> Option<StepOutput> {
|
||||||
|
let lang = Lang::from_user_choice(user_text)?;
|
||||||
|
Some(StepOutput {
|
||||||
|
next_state: OnboardState::AskName,
|
||||||
|
response_text: format!(
|
||||||
|
"{}\n\n*Step 1/5.* {}",
|
||||||
|
Strings::language_set(lang),
|
||||||
|
Strings::ask_name(lang)
|
||||||
|
),
|
||||||
|
persona_patch: json!({ "language": lang.code() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat migration: inject `"language": "ru"` when the persona has no
|
||||||
|
/// language key, so threads started before this commit keep Russian prompts.
|
||||||
|
pub(crate) fn backfill_language(persona: &Value) -> Option<Value> {
|
||||||
|
if persona.get("language").is_none() {
|
||||||
|
Some(json!({ "language": "ru" }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── schedule helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn ask_schedule_lang(extra_patch: &Value, prefix: &str, lang: Lang) -> StepOutput {
|
||||||
|
let intro = if prefix.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}\n\n")
|
||||||
|
};
|
||||||
|
StepOutput {
|
||||||
|
next_state: OnboardState::AskSchedule,
|
||||||
|
response_text: format!("{intro}{}", Strings::ask_schedule(lang)),
|
||||||
|
persona_patch: extra_patch.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ready-response builder ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn build_ready_response(
|
||||||
|
lang: Lang,
|
||||||
|
tone: &str,
|
||||||
|
interests: &[String],
|
||||||
|
sched_str: &str,
|
||||||
|
morning: Option<u8>,
|
||||||
|
evening: Option<u8>,
|
||||||
|
tz: &str,
|
||||||
|
) -> StepOutput {
|
||||||
|
let ready = Strings::ready(lang);
|
||||||
|
let (tone_lbl, int_lbl, sched_lbl) = match lang {
|
||||||
|
Lang::En => ("Tone", "Interests", "Schedule"),
|
||||||
|
Lang::Ru => ("Тон", "Интересы", "Расписание"),
|
||||||
|
};
|
||||||
|
let sources_hint = match lang {
|
||||||
|
Lang::En => "Add digest sources at https://keisei.app/keibuddy \
|
||||||
|
(10 platforms — YouTube, Twitter, GitHub, and more).\n\n\
|
||||||
|
Now you can write to me about anything — I'll remember and adapt. Say something!",
|
||||||
|
Lang::Ru => "Источники для дайджестов добавь на https://keisei.app/keibuddy \
|
||||||
|
(10 платформ — YouTube, Twitter, GitHub и др.).\n\n\
|
||||||
|
Теперь можешь писать мне о чём угодно — буду помнить и подстраиваться. \
|
||||||
|
Скажи что-нибудь!",
|
||||||
|
};
|
||||||
|
StepOutput {
|
||||||
|
next_state: OnboardState::Ready,
|
||||||
|
response_text: format!(
|
||||||
|
"{ready}\n\n{tone_lbl}: *{tone}*\n{int_lbl}: {}\n{sched_lbl}: {sched_str}\n\n{sources_hint}",
|
||||||
|
format_list(interests)
|
||||||
|
),
|
||||||
|
persona_patch: json!({
|
||||||
|
"schedule": { "morning": morning, "evening": evening, "timezone": tz }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TopicResearch arm ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Prompt used to propose sources to the user. Returns `{name, url, why}` triples.
|
||||||
|
fn propose_sources_prompt(topic: &str, lang: Lang) -> String {
|
||||||
|
let lang_hint = match lang {
|
||||||
|
Lang::En => "Respond in English.",
|
||||||
|
Lang::Ru => "Respond in Russian.",
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"You are a research-sources proposer. The user wants to follow a topic.\n\
|
||||||
|
Output a JSON object with one field \"sources\": an array of 3-5 objects,\n\
|
||||||
|
each with {{\"name\":\"...\",\"url\":\"...\",\"why\":\"...\"}}.\n\
|
||||||
|
Pick concrete, reputable sources for the topic.\n\
|
||||||
|
URLs must be real, well-known site root URLs.\n\
|
||||||
|
{lang_hint} Output ONLY the JSON, no prose, no markdown fences.\n\
|
||||||
|
Topic: {topic}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TopicResearch arm: gather research consent and propose sources via LLM.
|
||||||
|
pub(crate) async fn step_topic_research<E: LlmExtractor>(
|
||||||
|
user_text: &str,
|
||||||
|
persona: &Value,
|
||||||
|
extractor: &E,
|
||||||
|
lang: Lang,
|
||||||
|
) -> Result<StepOutput, BuddyError> {
|
||||||
|
let v = extractor.extract(prompt_yes_no(), user_text).await?;
|
||||||
|
let want_research = v["yes"].as_bool().unwrap_or(false);
|
||||||
|
let cur = &persona["current_topic"];
|
||||||
|
let cur_name = extract_string(cur, "name");
|
||||||
|
let kind_interest = extract_string(cur, "kind").as_str() == "interest";
|
||||||
|
let specifics = str_list(&persona["current_topic_specifics"]);
|
||||||
|
let defer = persona["current_topic_defer"].as_bool().unwrap_or(true);
|
||||||
|
if !want_research {
|
||||||
|
return Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, false, &[], &[]));
|
||||||
|
}
|
||||||
|
let src_prompt = propose_sources_prompt(&cur_name, lang);
|
||||||
|
let sv = match extractor.extract(&src_prompt, "").await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("propose_sources LLM call failed for {cur_name:?}: {e}");
|
||||||
|
Value::Object(serde_json::Map::new())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let proposed: Vec<Value> = sv["sources"].as_array().cloned().unwrap_or_default();
|
||||||
|
if proposed.is_empty() {
|
||||||
|
let fallback = build_sources_fallback(lang);
|
||||||
|
return Ok(StepOutput {
|
||||||
|
next_state: OnboardState::TopicSources,
|
||||||
|
response_text: fallback,
|
||||||
|
persona_patch: json!({ "current_topic_proposed": [] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let list = build_sources_list(&proposed);
|
||||||
|
let propose_lbl = match lang {
|
||||||
|
Lang::En => "Proposed sources for",
|
||||||
|
Lang::Ru => "Предлагаю источники по",
|
||||||
|
};
|
||||||
|
Ok(StepOutput {
|
||||||
|
next_state: OnboardState::TopicSources,
|
||||||
|
response_text: format!(
|
||||||
|
"{propose_lbl} *{cur_name}*:\n\n{list}\n\n{}",
|
||||||
|
Strings::topic_sources_intro(lang)
|
||||||
|
),
|
||||||
|
persona_patch: json!({ "current_topic_proposed": proposed }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_sources_list(sources: &[Value]) -> String {
|
||||||
|
sources.iter().enumerate()
|
||||||
|
.map(|(i, s)| {
|
||||||
|
let name = s["name"].as_str().unwrap_or("?");
|
||||||
|
let url = s["url"].as_str().unwrap_or("?");
|
||||||
|
let why = s["why"].as_str().unwrap_or("");
|
||||||
|
format!("{}. *{name}* — {url}\n _{why}_", i + 1)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_sources_fallback(lang: Lang) -> String {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "I couldn't propose sources automatically. \
|
||||||
|
Could you suggest one yourself? \
|
||||||
|
(e.g. \"plus rss https://example.com/feed\")"
|
||||||
|
.to_owned(),
|
||||||
|
Lang::Ru => "Не смог подобрать источники автоматически. \
|
||||||
|
Можешь предложить сам? \
|
||||||
|
(например, \"плюс rss https://example.com/feed\")"
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
//! Tests for `machine::handle_step`.
|
//! Tests for `machine::handle_step`.
|
||||||
//! Extracted from machine.rs to keep it within the 250-LOC exception budget.
|
//! Extracted from machine.rs to keep it within the 260-LOC exception budget.
|
||||||
|
//!
|
||||||
|
//! TopicResearch-specific tests live in the sibling module
|
||||||
|
//! `machine_tests_topic_research` (Constructor Pattern: split by concern).
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|
@ -8,22 +11,100 @@ use crate::extractor::MockExtractor;
|
||||||
use crate::machine::handle_step;
|
use crate::machine::handle_step;
|
||||||
use crate::state::OnboardState;
|
use crate::state::OnboardState;
|
||||||
|
|
||||||
|
mod machine_tests_topic_research;
|
||||||
|
|
||||||
fn rt() -> tokio::runtime::Runtime {
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
tokio::runtime::Runtime::new().unwrap()
|
tokio::runtime::Runtime::new().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intro_to_ask_name() {
|
fn intro_to_ask_language() {
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
let mock = MockExtractor::new(json!({}));
|
let mock = MockExtractor::new(json!({}));
|
||||||
let out = handle_step(&OnboardState::Intro, "hi", &json!({}), &mock)
|
let out = handle_step(&OnboardState::Intro, "hi", &json!({}), &mock)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out.next_state, OnboardState::AskName);
|
// Intro now transitions to AskLanguage, not AskName.
|
||||||
|
assert_eq!(out.next_state, OnboardState::AskLanguage);
|
||||||
assert!(!out.response_text.is_empty(), "intro response must not be empty");
|
assert!(!out.response_text.is_empty(), "intro response must not be empty");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ask_language_en_advances_to_ask_name() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let mock = MockExtractor::new(json!({}));
|
||||||
|
let out = handle_step(&OnboardState::AskLanguage, "en", &json!({}), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.next_state, OnboardState::AskName);
|
||||||
|
assert_eq!(
|
||||||
|
out.persona_patch["language"].as_str(),
|
||||||
|
Some("en"),
|
||||||
|
"persona_patch must contain language=en"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.response_text.contains("What's your name"),
|
||||||
|
"response must contain English ask_name phrase, got: {:?}",
|
||||||
|
out.response_text
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ask_language_ru_advances_to_ask_name() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let mock = MockExtractor::new(json!({}));
|
||||||
|
let out = handle_step(&OnboardState::AskLanguage, "ru", &json!({}), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.next_state, OnboardState::AskName);
|
||||||
|
assert_eq!(
|
||||||
|
out.persona_patch["language"].as_str(),
|
||||||
|
Some("ru"),
|
||||||
|
"persona_patch must contain language=ru"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.response_text.contains("называть"),
|
||||||
|
"response must contain Russian ask_name phrase, got: {:?}",
|
||||||
|
out.response_text
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ask_language_invalid_stays_in_state() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let mock = MockExtractor::new(json!({}));
|
||||||
|
let out = handle_step(&OnboardState::AskLanguage, "blah", &json!({}), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.next_state, OnboardState::AskLanguage, "invalid input must loop");
|
||||||
|
assert!(
|
||||||
|
out.response_text.contains("en") && out.response_text.contains("ru"),
|
||||||
|
"error response must mention both options, got: {:?}",
|
||||||
|
out.response_text
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_sets_ru_when_language_missing() {
|
||||||
|
rt().block_on(async {
|
||||||
|
// Persona has no `language` key — simulates a chat started before this commit.
|
||||||
|
let mock = MockExtractor::new(json!({ "name": "Denis" }));
|
||||||
|
let persona = json!({});
|
||||||
|
let out = handle_step(&OnboardState::AskName, "Denis", &persona, &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.persona_patch["language"].as_str(),
|
||||||
|
Some("ru"),
|
||||||
|
"migration must inject language=ru when key is missing"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ask_name_extracts_and_advances() {
|
fn ask_name_extracts_and_advances() {
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
|
|
|
||||||
113
_primitives/_rust/kei-buddy/src/machine_tests_topic_research.rs
Normal file
113
_primitives/_rust/kei-buddy/src/machine_tests_topic_research.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//! TopicResearch FSM arm tests — split from machine_tests.rs (Constructor Pattern: ≤200 LOC).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::error::BuddyError;
|
||||||
|
use crate::extractor::{LlmExtractor, MockExtractor};
|
||||||
|
use crate::machine::handle_step;
|
||||||
|
use crate::state::OnboardState;
|
||||||
|
|
||||||
|
/// Returns responses in sequence: responses[0] on call 0, responses[1] on call 1, etc.
|
||||||
|
/// After exhaustion repeats the last element.
|
||||||
|
pub(super) struct SequenceMockExtractor {
|
||||||
|
responses: Arc<Mutex<Vec<Value>>>,
|
||||||
|
call_idx: Arc<Mutex<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SequenceMockExtractor {
|
||||||
|
pub(super) fn new(responses: Vec<Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: Arc::new(Mutex::new(responses)),
|
||||||
|
call_idx: Arc::new(Mutex::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmExtractor for SequenceMockExtractor {
|
||||||
|
async fn extract(&self, _system: &str, _user_text: &str) -> Result<Value, BuddyError> {
|
||||||
|
let mut idx = self.call_idx.lock().unwrap();
|
||||||
|
let responses = self.responses.lock().unwrap();
|
||||||
|
let resp = responses.get(*idx).or_else(|| responses.last()).cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
if *idx + 1 < responses.len() {
|
||||||
|
*idx += 1;
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Runtime::new().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_research_persona() -> Value {
|
||||||
|
json!({
|
||||||
|
"language": "en",
|
||||||
|
"current_topic": { "name": "Rust", "kind": "interest" },
|
||||||
|
"current_topic_specifics": ["async", "traits"],
|
||||||
|
"current_topic_defer": false,
|
||||||
|
"__topic_state": { "queue": [], "index": 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User says "yes" and LLM returns sources → next=TopicSources, sources in response and patch.
|
||||||
|
#[test]
|
||||||
|
fn topic_research_yes_proposes_sources() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let sources_resp = json!({
|
||||||
|
"sources": [
|
||||||
|
{ "name": "S1", "url": "https://a.com", "why": "x" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let mock = SequenceMockExtractor::new(vec![json!({ "yes": true }), sources_resp]);
|
||||||
|
let out = handle_step(&OnboardState::TopicResearch, "yes", &topic_research_persona(), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.next_state, OnboardState::TopicSources, "must advance to TopicSources");
|
||||||
|
assert!(out.response_text.contains("S1"), "response must mention source name S1, got: {:?}", out.response_text);
|
||||||
|
assert!(out.response_text.contains("https://a.com"), "response must include URL, got: {:?}", out.response_text);
|
||||||
|
let proposed = out.persona_patch["current_topic_proposed"].as_array().unwrap();
|
||||||
|
assert_eq!(proposed.len(), 1, "one proposed source must be stored in patch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User says "yes" but LLM returns empty sources → fallback message, still next=TopicSources.
|
||||||
|
#[test]
|
||||||
|
fn topic_research_yes_empty_sources_still_advances() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let mock = SequenceMockExtractor::new(vec![
|
||||||
|
json!({ "yes": true }),
|
||||||
|
json!({ "sources": [] }),
|
||||||
|
]);
|
||||||
|
let out = handle_step(&OnboardState::TopicResearch, "yes", &topic_research_persona(), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.next_state, OnboardState::TopicSources, "must still enter TopicSources");
|
||||||
|
let proposed = out.persona_patch["current_topic_proposed"].as_array().unwrap();
|
||||||
|
assert!(proposed.is_empty(), "proposed must be empty in patch");
|
||||||
|
let lower = out.response_text.to_lowercase();
|
||||||
|
assert!(
|
||||||
|
lower.contains("suggest") || lower.contains("предложи") || lower.contains("предложить"),
|
||||||
|
"fallback must ask user to suggest a source, got: {:?}", out.response_text
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User says "no" → TopicSources is skipped entirely, advances past it.
|
||||||
|
#[test]
|
||||||
|
fn topic_research_no_skips_topic_sources() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let mock = MockExtractor::new(json!({ "yes": false }));
|
||||||
|
let out = handle_step(&OnboardState::TopicResearch, "no", &topic_research_persona(), &mock)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
out.next_state, OnboardState::TopicSources,
|
||||||
|
"\"no\" must skip TopicSources, got: {:?}", out.next_state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
//! `BuddyContext` + axum router. Store bootstrap lives in `serve_runner`.
|
//! `BuddyContext` + axum router. Store bootstrap lives in `serve_runner`.
|
||||||
//!
|
//! Constructor Pattern: one responsibility. No bot token logging.
|
||||||
//! Constructor Pattern: one responsibility — compose crate pieces into HTTP server.
|
|
||||||
//! Each function ≤ 30 LOC. No logging of bot tokens.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -26,6 +24,7 @@ use crate::{
|
||||||
store::{BuddyStore, SqliteBuddyStore},
|
store::{BuddyStore, SqliteBuddyStore},
|
||||||
topic_classify::classify_and_store_topic,
|
topic_classify::classify_and_store_topic,
|
||||||
topics::Topics,
|
topics::Topics,
|
||||||
|
voice::VoiceHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use crate::serve_runner::run_serve;
|
pub use crate::serve_runner::run_serve;
|
||||||
|
|
@ -36,18 +35,15 @@ pub struct ServeConfig {
|
||||||
pub db_path: String,
|
pub db_path: String,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
pub webhook_secret: String,
|
pub webhook_secret: String,
|
||||||
/// Whitelist; `None` or empty = accept all chat_ids.
|
|
||||||
pub allowed_chat_ids: Option<Vec<i64>>,
|
pub allowed_chat_ids: Option<Vec<i64>>,
|
||||||
/// LLM proxy URL + key; if both set, OpenAiExtractor is used, else MockExtractor.
|
|
||||||
pub llm_proxy_url: Option<String>,
|
pub llm_proxy_url: Option<String>,
|
||||||
pub llm_api_key: Option<String>,
|
pub llm_api_key: Option<String>,
|
||||||
pub llm_model: Option<String>,
|
pub llm_model: Option<String>,
|
||||||
/// Path to the SQLite file used by `ChatLog`. Default: `./kei-buddy-chat.db`.
|
|
||||||
pub chat_log_db_path: String,
|
pub chat_log_db_path: String,
|
||||||
/// Path to the SQLite file used by `Topics`. Default: `./kei-buddy-topics.db`.
|
|
||||||
pub topics_db_path: String,
|
pub topics_db_path: String,
|
||||||
/// Path to the SQLite file used by `Contacts`. Default: `./kei-buddy-contacts.db`.
|
|
||||||
pub contacts_db_path: String,
|
pub contacts_db_path: String,
|
||||||
|
/// STT backend name (e.g. "whisper-local"). `None` → voice messages ignored.
|
||||||
|
pub stt_backend: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Axum state — implements `WebhookContext`. `Arc<E>` allows cheap `Clone`.
|
/// Axum state — implements `WebhookContext`. `Arc<E>` allows cheap `Clone`.
|
||||||
|
|
@ -57,14 +53,12 @@ pub struct BuddyContext<E: LlmExtractor + Send + Sync + 'static> {
|
||||||
pub store: Arc<SqliteBuddyStore>,
|
pub store: Arc<SqliteBuddyStore>,
|
||||||
pub extractor: Arc<E>,
|
pub extractor: Arc<E>,
|
||||||
pub http: reqwest::Client,
|
pub http: reqwest::Client,
|
||||||
/// Whitelist of chat_ids; `None` or empty = accept all.
|
|
||||||
pub allowed_chat_ids: Arc<Option<Vec<i64>>>,
|
pub allowed_chat_ids: Arc<Option<Vec<i64>>>,
|
||||||
/// Persistent log of all Telegram messages (user + bot).
|
|
||||||
pub chat_log: Arc<ChatLog>,
|
pub chat_log: Arc<ChatLog>,
|
||||||
/// Persistent topic store.
|
|
||||||
pub topics: Arc<Topics>,
|
pub topics: Arc<Topics>,
|
||||||
/// Persistent contacts store.
|
|
||||||
pub contacts: Arc<Contacts>,
|
pub contacts: Arc<Contacts>,
|
||||||
|
/// Optional voice handler; `None` = voice messages ignored.
|
||||||
|
pub voice: Option<Arc<VoiceHandler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
|
impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
|
||||||
|
|
@ -79,6 +73,7 @@ impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
|
||||||
chat_log: Arc::clone(&self.chat_log),
|
chat_log: Arc::clone(&self.chat_log),
|
||||||
topics: Arc::clone(&self.topics),
|
topics: Arc::clone(&self.topics),
|
||||||
contacts: Arc::clone(&self.contacts),
|
contacts: Arc::clone(&self.contacts),
|
||||||
|
voice: self.voice.as_ref().map(Arc::clone),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,8 +89,11 @@ impl<E: LlmExtractor + Send + Sync + 'static> WebhookContext for BuddyContext<E>
|
||||||
WebhookEvent::Text { chat_id, text, .. } => {
|
WebhookEvent::Text { chat_id, text, .. } => {
|
||||||
self.handle_text(chat_id, text).await;
|
self.handle_text(chat_id, text).await;
|
||||||
}
|
}
|
||||||
|
WebhookEvent::Voice { chat_id, file_id, mime_type, .. } => {
|
||||||
|
self.handle_voice(chat_id, file_id, mime_type).await;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
warn!(event = ?other, "ignoring non-text webhook event");
|
warn!(event = ?other, "ignoring unhandled webhook event");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +107,21 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_voice(&self, chat_id: i64, file_id: String, mime_type: String) {
|
||||||
|
let Some(h) = self.voice.as_ref() else {
|
||||||
|
warn!(chat_id, "voice message: no STT backend; ignoring");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self.chat_allowed(chat_id) {
|
||||||
|
warn!(chat_id, "chat_id not in whitelist; ignoring voice");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match h.transcribe_file(&file_id, &mime_type).await {
|
||||||
|
Ok(t) => self.handle_text(chat_id, t).await,
|
||||||
|
Err(e) => error!(chat_id, error=%e, "voice transcription failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_text(&self, chat_id: i64, text: String) {
|
async fn handle_text(&self, chat_id: i64, text: String) {
|
||||||
if !self.chat_allowed(chat_id) {
|
if !self.chat_allowed(chat_id) {
|
||||||
warn!(chat_id, "chat_id not in whitelist; ignoring");
|
warn!(chat_id, "chat_id not in whitelist; ignoring");
|
||||||
|
|
@ -172,16 +185,12 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health-check handler.
|
|
||||||
async fn health() -> Json<Value> {
|
async fn health() -> Json<Value> {
|
||||||
Json(json!({ "status": "ok", "crate": "kei-buddy", "version": env!("CARGO_PKG_VERSION") }))
|
Json(json!({ "status": "ok", "crate": "kei-buddy", "version": env!("CARGO_PKG_VERSION") }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the axum Router.
|
/// Build the axum Router.
|
||||||
pub fn build_router<E>(ctx: BuddyContext<E>) -> Router
|
pub fn build_router<E: LlmExtractor + Send + Sync + 'static>(ctx: BuddyContext<E>) -> Router {
|
||||||
where
|
|
||||||
E: LlmExtractor + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/webhook", routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>))
|
.route("/webhook", routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>))
|
||||||
.route("/health", routing::get(health))
|
.route("/health", routing::get(health))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::{
|
||||||
serve::{BuddyContext, ServeConfig},
|
serve::{BuddyContext, ServeConfig},
|
||||||
store::SqliteBuddyStore,
|
store::SqliteBuddyStore,
|
||||||
topics::Topics,
|
topics::Topics,
|
||||||
|
voice::VoiceHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Start the HTTP server (entry-point called from the binary).
|
/// Start the HTTP server (entry-point called from the binary).
|
||||||
|
|
@ -25,6 +26,7 @@ pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
||||||
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
|
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 topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
|
||||||
let contacts = Arc::new(Contacts::from_path(&cfg.contacts_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")]
|
#[cfg(feature = "extractor-openai")]
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +39,7 @@ pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
||||||
return start_listener(cfg.port, BuddyContext {
|
return start_listener(cfg.port, BuddyContext {
|
||||||
secret: cfg.webhook_secret,
|
secret: cfg.webhook_secret,
|
||||||
bot_token: cfg.bot_token,
|
bot_token: cfg.bot_token,
|
||||||
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
|
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,10 +49,22 @@ pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
||||||
start_listener(cfg.port, BuddyContext {
|
start_listener(cfg.port, BuddyContext {
|
||||||
secret: cfg.webhook_secret,
|
secret: cfg.webhook_secret,
|
||||||
bot_token: cfg.bot_token,
|
bot_token: cfg.bot_token,
|
||||||
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
|
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
|
||||||
}).await
|
}).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<()>
|
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
E: LlmExtractor + Send + Sync + 'static,
|
E: LlmExtractor + Send + Sync + 'static,
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// 11-state onboarding finite-state machine.
|
/// 12-state onboarding finite-state machine.
|
||||||
///
|
///
|
||||||
/// Mirrors the TypeScript `Step` union type exactly:
|
/// Extends the TypeScript `Step` union with `ask_language` as the second
|
||||||
/// `intro | ask_name | ask_tone | ask_interests | ask_hobbies |
|
/// step (right after `intro`):
|
||||||
|
/// `intro | ask_language | ask_name | ask_tone | ask_interests | ask_hobbies |
|
||||||
/// topic_specifics | topic_now_later | topic_research |
|
/// topic_specifics | topic_now_later | topic_research |
|
||||||
/// topic_sources | ask_schedule | ready`
|
/// topic_sources | ask_schedule | ready`
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -20,6 +21,8 @@ use serde::{Deserialize, Serialize};
|
||||||
pub enum OnboardState {
|
pub enum OnboardState {
|
||||||
/// Initial greeting — bot explains itself.
|
/// Initial greeting — bot explains itself.
|
||||||
Intro,
|
Intro,
|
||||||
|
/// Collecting language preference (en / ru). Default: en.
|
||||||
|
AskLanguage,
|
||||||
/// Collecting user's display name.
|
/// Collecting user's display name.
|
||||||
AskName,
|
AskName,
|
||||||
/// Collecting preferred communication tone.
|
/// Collecting preferred communication tone.
|
||||||
|
|
@ -51,6 +54,7 @@ mod tests {
|
||||||
fn all_variants_serde_roundtrip() {
|
fn all_variants_serde_roundtrip() {
|
||||||
let variants = [
|
let variants = [
|
||||||
OnboardState::Intro,
|
OnboardState::Intro,
|
||||||
|
OnboardState::AskLanguage,
|
||||||
OnboardState::AskName,
|
OnboardState::AskName,
|
||||||
OnboardState::AskTone,
|
OnboardState::AskTone,
|
||||||
OnboardState::AskInterests,
|
OnboardState::AskInterests,
|
||||||
|
|
|
||||||
164
_primitives/_rust/kei-buddy/src/strings.rs
Normal file
164
_primitives/_rust/kei-buddy/src/strings.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//! Localization table for all onboarding prompt strings.
|
||||||
|
//! Add new languages by extending the `Lang` enum + each match arm.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Supported UI languages.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Lang {
|
||||||
|
En,
|
||||||
|
Ru,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lang {
|
||||||
|
/// Infer language from a stored persona blob (`persona["language"]`).
|
||||||
|
/// Falls back to `En` when the key is absent or unrecognised.
|
||||||
|
pub fn from_persona(persona: &Value) -> Self {
|
||||||
|
match persona.get("language").and_then(|v| v.as_str()) {
|
||||||
|
Some("ru") => Lang::Ru,
|
||||||
|
_ => Lang::En,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a user's free-form language choice.
|
||||||
|
/// Returns `None` when the text is not a recognised choice.
|
||||||
|
pub fn from_user_choice(text: &str) -> Option<Lang> {
|
||||||
|
let t = text.trim().to_lowercase();
|
||||||
|
match t.as_str() {
|
||||||
|
"en" | "english" | "1" | "англ" | "🇬🇧" | "🇺🇸" => Some(Lang::En),
|
||||||
|
"ru" | "русский" | "rus" | "2" | "рус" | "🇷🇺" => Some(Lang::Ru),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BCP-47 / ISO 639-1 code.
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Lang::En => "en",
|
||||||
|
Lang::Ru => "ru",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static onboarding prompt strings, keyed by language.
|
||||||
|
pub struct Strings;
|
||||||
|
|
||||||
|
impl Strings {
|
||||||
|
/// Always bilingual — shown before language is known.
|
||||||
|
pub fn intro_ask_language() -> &'static str {
|
||||||
|
"Hi! I'm KeiBuddy, your personal AI assistant from KeiSei. \
|
||||||
|
Please choose your language:\n• English (en)\n• Русский (ru)\n\n\
|
||||||
|
Привет! Я KeiBuddy, твой персональный AI-компаньон от KeiSei. \
|
||||||
|
Выбери язык:\n• English (en)\n• Русский (ru)"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_name(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "What's your name? (I'll use it to address you.)",
|
||||||
|
Lang::Ru => "Как тебя называть?",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_tone(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "How should I talk to you? Describe it in your own words — e.g. \
|
||||||
|
\"friendly\", \"dry and to the point\", \"with irony\". \
|
||||||
|
Or just a word: `friendly`, `calm`, `stoic`, `sarcastic`, `professional`.",
|
||||||
|
Lang::Ru => "Какой стиль общения тебе ближе? Опиши своими словами — например, \
|
||||||
|
\"по-дружески\", \"сухо и по делу\", \"с иронией\". \
|
||||||
|
Или просто слово: `friendly`, `calm`, `stoic`, `sarcastic`, `professional`.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_interests(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "What are your interests? Just list them — \
|
||||||
|
any format works (comma-separated, bullet points, or a paragraph).",
|
||||||
|
Lang::Ru => "Какие у тебя интересы? Просто перечисли — \
|
||||||
|
как удобно (через запятую, списком, или одним абзацем).",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_hobbies(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "What about hobbies? What do you actually do in your free time?",
|
||||||
|
Lang::Ru => "А хобби? Чем конкретно занимаешься в свободное время.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamic — topic name is interpolated by the caller.
|
||||||
|
pub fn topic_specifics_prefix(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Now let's dig into the topics. First up",
|
||||||
|
Lang::Ru => "Теперь разберём по темам. Поехали — сначала",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn topic_specifics_question(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "What *specifically* interests you here? Give me details \
|
||||||
|
(e.g. for AI: \"agents, model training, papers\"; \
|
||||||
|
for surfing: \"technique, boards, spot reports\").",
|
||||||
|
Lang::Ru => "*Что именно* в этой теме тебе интересно? Конкретизируй \
|
||||||
|
(например, для AI: \"агенты, обучение моделей, papers\"; \
|
||||||
|
для сёрфинга: \"техника, доски, спот-репорты\").",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn topic_now_later(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Would you like to *discuss this now* or *save it for later*?",
|
||||||
|
Lang::Ru => "Хочешь *обсудить это сейчас* или *сохранить на потом*?",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn topic_research(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Should I *regularly monitor* updates on this topic and send you digests?",
|
||||||
|
Lang::Ru => "Хочешь чтобы я *регулярно следил* за обновлениями по этой теме и присылал дайджесты?",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn topic_sources_intro(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Which ones do you want to add? Write the numbers separated by commas \
|
||||||
|
(`1,3,5`), `all`, or `none`. \
|
||||||
|
You can add your own — just write \"plus <platform> <handle>\".",
|
||||||
|
Lang::Ru => "Какие добавить? Напиши номера через запятую (`1,3,5`), `все`, или `нет`. \
|
||||||
|
Можешь добавить свои — просто напиши \"плюс <платформа> <handle>\".",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_schedule(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Topics covered! ⏰ When would you like to receive digests? \
|
||||||
|
Write freely — e.g. \"mornings around 8, evenings at 10, I'm in Bali\" \
|
||||||
|
or \"evenings at 9\". If you don't need them, write \"no\".",
|
||||||
|
Lang::Ru => "Темы разобрали. ⏰ Когда удобно получать дайджесты? Напиши свободно — \
|
||||||
|
например, \"утром часов в 8, вечером в 10, я в Бали\" или \"вечером в 9\". \
|
||||||
|
Если не нужно — напиши \"нет\".",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ready(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "All set! ✨ I'm now your assistant.",
|
||||||
|
Lang::Ru => "Готово! ✨ Я настроен.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation shown right after language is chosen.
|
||||||
|
pub fn language_set(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::En => "Language set to English.",
|
||||||
|
Lang::Ru => "Язык установлен: русский.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown when input doesn't match any language choice — always bilingual.
|
||||||
|
pub fn invalid_language() -> &'static str {
|
||||||
|
"Please answer 'en' for English or 'ru' for Russian.\n\
|
||||||
|
Пожалуйста, ответь 'en' или 'ru'."
|
||||||
|
}
|
||||||
|
}
|
||||||
178
_primitives/_rust/kei-buddy/src/voice.rs
Normal file
178
_primitives/_rust/kei-buddy/src/voice.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//! Voice-message handling: Telegram getFile → download → STT → transcript.
|
||||||
|
//!
|
||||||
|
//! Constructor Pattern: one responsibility — download audio via Telegram API
|
||||||
|
//! and pass it to the STT backend. Token is never logged.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use kei_stt::{SttBackend, SttRequest};
|
||||||
|
|
||||||
|
use crate::error::BuddyError;
|
||||||
|
|
||||||
|
/// Downloads a Telegram voice/audio file and returns the STT transcript.
|
||||||
|
pub struct VoiceHandler {
|
||||||
|
pub(crate) bot_token: String,
|
||||||
|
pub(crate) stt: Arc<dyn SttBackend>,
|
||||||
|
pub(crate) http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceHandler {
|
||||||
|
/// Construct from a bot token string and an already-built STT backend.
|
||||||
|
pub fn new(bot_token: String, stt: Arc<dyn SttBackend>) -> Self {
|
||||||
|
Self { bot_token, stt, http: reqwest::Client::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve file_id → file_path via Telegram getFile, download bytes,
|
||||||
|
/// and transcribe via the STT backend.
|
||||||
|
///
|
||||||
|
/// Errors map to [`BuddyError::Transport`]. The bot token is never
|
||||||
|
/// included in error messages.
|
||||||
|
pub async fn transcribe_file(
|
||||||
|
&self,
|
||||||
|
file_id: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
) -> Result<String, BuddyError> {
|
||||||
|
let file_path = self.get_file_path(file_id).await?;
|
||||||
|
let audio_bytes = self.download_file(&file_path).await?;
|
||||||
|
self.run_stt(audio_bytes, mime_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_file_path(&self, file_id: &str) -> Result<String, BuddyError> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.telegram.org/bot{}/getFile",
|
||||||
|
self.bot_token
|
||||||
|
);
|
||||||
|
let resp = self.http
|
||||||
|
.get(&url)
|
||||||
|
.query(&[("file_id", file_id)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("getFile request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
return Err(BuddyError::Transport(
|
||||||
|
format!("getFile returned HTTP {status}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("getFile JSON parse: {e}")))?;
|
||||||
|
let path = json["result"]["file_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| BuddyError::Transport("getFile: missing result.file_path".into()))?
|
||||||
|
.to_string();
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn download_file(&self, file_path: &str) -> Result<Vec<u8>, BuddyError> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.telegram.org/file/bot{}/{}",
|
||||||
|
self.bot_token, file_path
|
||||||
|
);
|
||||||
|
let bytes = self.http
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("file download failed: {e}")))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("file download bytes: {e}")))?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_stt(
|
||||||
|
&self,
|
||||||
|
audio_bytes: Vec<u8>,
|
||||||
|
mime_type: &str,
|
||||||
|
) -> Result<String, BuddyError> {
|
||||||
|
let req = SttRequest {
|
||||||
|
audio_bytes,
|
||||||
|
mime_type: mime_type.to_string(),
|
||||||
|
language: None,
|
||||||
|
};
|
||||||
|
let resp = self.stt
|
||||||
|
.transcribe(&req)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("STT failed: {e}")))?;
|
||||||
|
Ok(resp.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use kei_stt::{SttError, SttResponse};
|
||||||
|
use wiremock::matchers::{method, path, query_param};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
struct MockStt(String);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SttBackend for MockStt {
|
||||||
|
async fn transcribe(&self, _req: &SttRequest) -> Result<SttResponse, SttError> {
|
||||||
|
Ok(SttResponse::text_only(self.0.clone()))
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str { "mock" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only helper that injects a base_url so tests point at wiremock.
|
||||||
|
async fn run(base: &str, token: &str, stt_reply: &str, file_id: &str, mime: &str)
|
||||||
|
-> Result<String, BuddyError>
|
||||||
|
{
|
||||||
|
let stt: Arc<dyn SttBackend> = Arc::new(MockStt(stt_reply.into()));
|
||||||
|
let http = reqwest::Client::new();
|
||||||
|
let get_url = format!("{}/bot{}/getFile", base, token);
|
||||||
|
let resp = http.get(&get_url).query(&[("file_id", file_id)]).send().await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("getFile request failed: {e}")))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let s = resp.status();
|
||||||
|
return Err(BuddyError::Transport(format!("getFile returned HTTP {s}")));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp.json().await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("getFile JSON: {e}")))?;
|
||||||
|
let file_path = json["result"]["file_path"].as_str()
|
||||||
|
.ok_or_else(|| BuddyError::Transport("missing file_path".into()))?.to_string();
|
||||||
|
let dl_url = format!("{}/file/bot{}/{}", base, token, file_path);
|
||||||
|
let audio = http.get(&dl_url).send().await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("download: {e}")))?
|
||||||
|
.bytes().await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("bytes: {e}")))?.to_vec();
|
||||||
|
let req = SttRequest { audio_bytes: audio, mime_type: mime.into(), language: None };
|
||||||
|
let r = stt.transcribe(&req).await
|
||||||
|
.map_err(|e| BuddyError::Transport(format!("STT: {e}")))?;
|
||||||
|
Ok(r.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transcribe_file_calls_getfile_then_downloads_then_stt() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET")).and(path("/bottoken/getFile"))
|
||||||
|
.and(query_param("file_id", "v123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"ok": true, "result": { "file_id": "v123", "file_path": "voice/f.oga" }
|
||||||
|
}))).mount(&server).await;
|
||||||
|
Mock::given(method("GET")).and(path("/file/bottoken/voice/f.oga"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"audio".to_vec()))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let text = run(&server.uri(), "token", "hello world", "v123", "audio/ogg").await;
|
||||||
|
assert_eq!(text.unwrap(), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn getfile_error_propagates_as_transport_error() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET")).and(path("/bottoken/getFile"))
|
||||||
|
.respond_with(ResponseTemplate::new(500)).mount(&server).await;
|
||||||
|
|
||||||
|
let result = run(&server.uri(), "token", "x", "bad", "audio/ogg").await;
|
||||||
|
match result {
|
||||||
|
Err(BuddyError::Transport(msg)) => assert!(msg.contains("getFile"), "msg={msg}"),
|
||||||
|
other => panic!("expected Transport error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,13 @@ pub enum WebhookEvent {
|
||||||
from: Option<User>,
|
from: Option<User>,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
/// Incoming voice or audio message — carries a Telegram file_id for download.
|
||||||
|
Voice {
|
||||||
|
chat_id: i64,
|
||||||
|
from: Option<User>,
|
||||||
|
file_id: String,
|
||||||
|
mime_type: String,
|
||||||
|
},
|
||||||
/// Inline-keyboard button press.
|
/// Inline-keyboard button press.
|
||||||
Callback {
|
Callback {
|
||||||
chat_id: i64,
|
chat_id: i64,
|
||||||
|
|
@ -24,25 +31,25 @@ pub enum WebhookEvent {
|
||||||
|
|
||||||
/// Extract a typed [`WebhookEvent`] from a raw [`Update`].
|
/// Extract a typed [`WebhookEvent`] from a raw [`Update`].
|
||||||
///
|
///
|
||||||
/// Classification priority: `message` before `callback_query`.
|
/// Classification priority: voice/audio before text, text before callback.
|
||||||
pub fn classify(update: Update) -> WebhookEvent {
|
pub fn classify(update: Update) -> WebhookEvent {
|
||||||
if let Some(msg) = update.message {
|
if let Some(msg) = update.message {
|
||||||
|
let chat_id = msg.chat.id;
|
||||||
|
let from = msg.from.clone();
|
||||||
|
if let Some(v) = msg.voice {
|
||||||
|
return WebhookEvent::Voice { chat_id, from, file_id: v.file_id, mime_type: v.mime_type };
|
||||||
|
}
|
||||||
|
if let Some(a) = msg.audio {
|
||||||
|
return WebhookEvent::Voice { chat_id, from, file_id: a.file_id, mime_type: a.mime_type };
|
||||||
|
}
|
||||||
if let Some(text) = msg.text {
|
if let Some(text) = msg.text {
|
||||||
return WebhookEvent::Text {
|
return WebhookEvent::Text { chat_id, from: msg.from, text };
|
||||||
chat_id: msg.chat.id,
|
|
||||||
from: msg.from,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(cb) = update.callback_query {
|
if let Some(cb) = update.callback_query {
|
||||||
if let Some(data) = cb.data {
|
if let Some(data) = cb.data {
|
||||||
let chat_id = cb.message.as_ref().map(|m| m.chat.id).unwrap_or(0);
|
let chat_id = cb.message.as_ref().map(|m| m.chat.id).unwrap_or(0);
|
||||||
return WebhookEvent::Callback {
|
return WebhookEvent::Callback { chat_id, from: cb.from, data };
|
||||||
chat_id,
|
|
||||||
from: cb.from,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WebhookEvent::Other
|
WebhookEvent::Other
|
||||||
|
|
@ -51,7 +58,7 @@ pub fn classify(update: Update) -> WebhookEvent {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::update::{CallbackQuery, Chat, Message, Update, User};
|
use crate::update::{Audio, CallbackQuery, Chat, Message, Update, User, Voice};
|
||||||
|
|
||||||
fn make_user() -> User {
|
fn make_user() -> User {
|
||||||
User {
|
User {
|
||||||
|
|
@ -61,27 +68,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_msg(chat_id: i64, text: &str) -> Message {
|
||||||
|
Message {
|
||||||
|
message_id: 10,
|
||||||
|
date: 1_700_000_000,
|
||||||
|
chat: Chat { id: chat_id, r#type: Some("private".into()) },
|
||||||
|
from: Some(make_user()),
|
||||||
|
text: Some(text.into()),
|
||||||
|
voice: None,
|
||||||
|
audio: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_text_message() {
|
fn classify_text_message() {
|
||||||
let update = Update {
|
let update = Update {
|
||||||
update_id: 1,
|
update_id: 1,
|
||||||
message: Some(Message {
|
message: Some(text_msg(99, "hello")),
|
||||||
message_id: 10,
|
|
||||||
date: 1_700_000_000,
|
|
||||||
chat: Chat { id: 99, r#type: Some("private".into()) },
|
|
||||||
from: Some(make_user()),
|
|
||||||
text: Some("hello".into()),
|
|
||||||
}),
|
|
||||||
callback_query: None,
|
callback_query: None,
|
||||||
};
|
};
|
||||||
let event = classify(update);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
event,
|
classify(update),
|
||||||
WebhookEvent::Text {
|
WebhookEvent::Text { chat_id: 99, from: Some(make_user()), text: "hello".into() }
|
||||||
chat_id: 99,
|
|
||||||
from: Some(make_user()),
|
|
||||||
text: "hello".into(),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,29 +107,81 @@ mod tests {
|
||||||
chat: Chat { id: 77, r#type: None },
|
chat: Chat { id: 77, r#type: None },
|
||||||
from: None,
|
from: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
voice: None,
|
||||||
|
audio: None,
|
||||||
}),
|
}),
|
||||||
data: Some("action:start".into()),
|
data: Some("action:start".into()),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let event = classify(update);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
event,
|
classify(update),
|
||||||
WebhookEvent::Callback {
|
WebhookEvent::Callback { chat_id: 77, from: Some(make_user()), data: "action:start".into() }
|
||||||
chat_id: 77,
|
|
||||||
from: Some(make_user()),
|
|
||||||
data: "action:start".into(),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_other_returns_other() {
|
fn classify_other_returns_other() {
|
||||||
// Update with no message and no callback_query (e.g. edited_message not modelled).
|
let update = Update { update_id: 3, message: None, callback_query: None };
|
||||||
let update = Update {
|
|
||||||
update_id: 3,
|
|
||||||
message: None,
|
|
||||||
callback_query: None,
|
|
||||||
};
|
|
||||||
assert_eq!(classify(update), WebhookEvent::Other);
|
assert_eq!(classify(update), WebhookEvent::Other);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_voice_message() {
|
||||||
|
let update = Update {
|
||||||
|
update_id: 4,
|
||||||
|
message: Some(Message {
|
||||||
|
message_id: 30,
|
||||||
|
date: 1_700_000_002,
|
||||||
|
chat: Chat { id: 55, r#type: Some("private".into()) },
|
||||||
|
from: Some(make_user()),
|
||||||
|
text: None,
|
||||||
|
voice: Some(Voice {
|
||||||
|
file_id: "voice_file_abc".into(),
|
||||||
|
duration: 5,
|
||||||
|
mime_type: "audio/ogg".into(),
|
||||||
|
}),
|
||||||
|
audio: None,
|
||||||
|
}),
|
||||||
|
callback_query: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
classify(update),
|
||||||
|
WebhookEvent::Voice {
|
||||||
|
chat_id: 55,
|
||||||
|
from: Some(make_user()),
|
||||||
|
file_id: "voice_file_abc".into(),
|
||||||
|
mime_type: "audio/ogg".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_audio_message_maps_to_voice_variant() {
|
||||||
|
let update = Update {
|
||||||
|
update_id: 5,
|
||||||
|
message: Some(Message {
|
||||||
|
message_id: 31,
|
||||||
|
date: 1_700_000_003,
|
||||||
|
chat: Chat { id: 66, r#type: Some("private".into()) },
|
||||||
|
from: Some(make_user()),
|
||||||
|
text: None,
|
||||||
|
voice: None,
|
||||||
|
audio: Some(Audio {
|
||||||
|
file_id: "audio_file_xyz".into(),
|
||||||
|
duration: 120,
|
||||||
|
mime_type: "audio/mpeg".into(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
callback_query: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
classify(update),
|
||||||
|
WebhookEvent::Voice {
|
||||||
|
chat_id: 66,
|
||||||
|
from: Some(make_user()),
|
||||||
|
file_id: "audio_file_xyz".into(),
|
||||||
|
mime_type: "audio/mpeg".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,26 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Telegram `Voice` attachment (OGG-Opus from the mic).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
|
||||||
|
pub struct Voice {
|
||||||
|
pub file_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram `Audio` attachment (music/audio file).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
|
||||||
|
pub struct Audio {
|
||||||
|
pub file_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level Telegram update payload.
|
/// Top-level Telegram update payload.
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
pub struct Update {
|
pub struct Update {
|
||||||
|
|
@ -16,7 +36,7 @@ pub struct Update {
|
||||||
pub callback_query: Option<CallbackQuery>,
|
pub callback_query: Option<CallbackQuery>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Incoming text message.
|
/// Incoming text message (or voice/audio message).
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub message_id: i64,
|
pub message_id: i64,
|
||||||
|
|
@ -26,6 +46,10 @@ pub struct Message {
|
||||||
pub from: Option<User>,
|
pub from: Option<User>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub voice: Option<Voice>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: Option<Audio>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Telegram user or bot.
|
/// Telegram user or bot.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue