KeiSeiKit-1.0/_primitives/_rust/kei-telegram-webhook/src/update.rs
Parfii-bot 26dc8c85f7 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

83 lines
2.1 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Lean Telegram `Update` struct hierarchy.
//!
//! Only the fields KeiBuddy needs are modelled.
//! All optional fields use `#[serde(default)]` so missing JSON keys deserialize cleanly.
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.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Update {
pub update_id: i64,
#[serde(default)]
pub message: Option<Message>,
#[serde(default)]
pub callback_query: Option<CallbackQuery>,
}
/// Incoming text message (or voice/audio message).
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Message {
pub message_id: i64,
pub date: i64,
pub chat: Chat,
#[serde(default)]
pub from: Option<User>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub voice: Option<Voice>,
#[serde(default)]
pub audio: Option<Audio>,
}
/// Telegram user or bot.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct User {
pub id: i64,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub first_name: Option<String>,
}
/// Chat where a message was sent.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Chat {
pub id: i64,
#[serde(default)]
pub r#type: Option<String>,
}
/// Inline-keyboard button callback.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct CallbackQuery {
pub id: String,
#[serde(default)]
pub from: Option<User>,
#[serde(default)]
pub message: Option<Message>,
#[serde(default)]
pub data: Option<String>,
}