KeiSeiKit-1.0/_primitives/_rust/kei-telegram-webhook/src/event.rs
Parfii-bot 87d7b1c5c4 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-логируется и игнорируется)
2026-05-12 17:49:06 +08:00

187 lines
5.5 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! `WebhookEvent` — typed summary of an inbound Telegram update.
use crate::update::{Update, User};
/// Typed classification of a Telegram `Update`.
#[derive(Debug, Clone, PartialEq)]
pub enum WebhookEvent {
/// Incoming text message.
Text {
chat_id: i64,
from: Option<User>,
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.
Callback {
chat_id: i64,
from: Option<User>,
data: String,
},
/// Any update type not modelled above.
Other,
}
/// Extract a typed [`WebhookEvent`] from a raw [`Update`].
///
/// Classification priority: voice/audio before text, text before callback.
pub fn classify(update: Update) -> WebhookEvent {
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 {
return WebhookEvent::Text { chat_id, from: msg.from, text };
}
}
if let Some(cb) = update.callback_query {
if let Some(data) = cb.data {
let chat_id = cb.message.as_ref().map(|m| m.chat.id).unwrap_or(0);
return WebhookEvent::Callback { chat_id, from: cb.from, data };
}
}
WebhookEvent::Other
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update::{Audio, CallbackQuery, Chat, Message, Update, User, Voice};
fn make_user() -> User {
User {
id: 42,
username: Some("alice".into()),
first_name: Some("Alice".into()),
}
}
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]
fn classify_text_message() {
let update = Update {
update_id: 1,
message: Some(text_msg(99, "hello")),
callback_query: None,
};
assert_eq!(
classify(update),
WebhookEvent::Text { chat_id: 99, from: Some(make_user()), text: "hello".into() }
);
}
#[test]
fn classify_callback_query() {
let update = Update {
update_id: 2,
message: None,
callback_query: Some(CallbackQuery {
id: "cb1".into(),
from: Some(make_user()),
message: Some(Message {
message_id: 20,
date: 1_700_000_001,
chat: Chat { id: 77, r#type: None },
from: None,
text: None,
voice: None,
audio: None,
}),
data: Some("action:start".into()),
}),
};
assert_eq!(
classify(update),
WebhookEvent::Callback { chat_id: 77, from: Some(make_user()), data: "action:start".into() }
);
}
#[test]
fn classify_other_returns_other() {
let update = Update { update_id: 3, message: None, callback_query: None };
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(),
}
);
}
}