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

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

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

## Memory binding (task #5)

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

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

## State-machine port (task #7)

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

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

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

## Serve binary (task #6)

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

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

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

## Verify-before-commit

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

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

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

128 lines
4.5 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Thin Telegram Bot API HTTP helpers.
//!
//! Constructor Pattern: three focused async fns + one error surface.
//! No logging of bot tokens; errors logged at call site.
#[cfg(feature = "serve")]
use crate::error::BuddyError;
/// Send a plain-text message to a Telegram chat.
///
/// Never logs `token` — redacted in error messages.
#[cfg(feature = "serve")]
pub async fn send_message(
token: &str,
chat_id: i64,
text: &str,
http: &reqwest::Client,
) -> Result<(), BuddyError> {
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let body = serde_json::json!({"chat_id": chat_id, "text": text});
let resp = http
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| BuddyError::Transport(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
Err(BuddyError::Transport(format!("sendMessage HTTP {status}")))
}
}
/// Register a webhook URL with Telegram.
#[cfg(feature = "serve")]
pub async fn set_webhook(
token: &str,
url: &str,
secret: &str,
http: &reqwest::Client,
) -> Result<(), BuddyError> {
let endpoint = format!("https://api.telegram.org/bot{token}/setWebhook");
let body = serde_json::json!({
"url": url,
"secret_token": secret,
"drop_pending_updates": true
});
let resp = http
.post(&endpoint)
.json(&body)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| BuddyError::Transport(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
Err(BuddyError::Transport(format!("setWebhook HTTP {status}")))
}
}
/// Delete the registered webhook (reset to polling mode).
#[cfg(feature = "serve")]
pub async fn delete_webhook(token: &str, http: &reqwest::Client) -> Result<(), BuddyError> {
let endpoint = format!("https://api.telegram.org/bot{token}/deleteWebhook");
let resp = http
.post(&endpoint)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| BuddyError::Transport(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
Err(BuddyError::Transport(format!("deleteWebhook HTTP {status}")))
}
}
// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────
#[cfg(all(test, feature = "serve"))]
mod tests {
use serde_json::Value;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Verifies that set_webhook sends a POST body with url, secret_token,
/// and drop_pending_updates fields.
#[tokio::test]
async fn set_webhook_builds_correct_request() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/botTOKEN/setWebhook"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let token = "TOKEN";
let url_arg = "https://example.com/webhook";
let secret = "MY_SECRET";
let endpoint = format!("{}/bot{token}/setWebhook", server.uri());
let body = serde_json::json!({
"url": url_arg,
"secret_token": secret,
"drop_pending_updates": true
});
let resp = http.post(&endpoint).json(&body).send().await.unwrap();
assert!(resp.status().is_success());
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let body_val: Value =
serde_json::from_slice(&received[0].body).expect("parse body");
assert_eq!(body_val["url"], url_arg);
assert_eq!(body_val["secret_token"], secret);
assert_eq!(body_val["drop_pending_updates"], true);
}
}