KeiSeiKit-1.0/_primitives/_rust/kei-telegram-webhook/src/handler.rs
Parfii-bot 4dfe63b4e2 feat(kei-telegram-webhook): inbound Telegram webhook handler
Sibling to kei-notify-telegram (outbound only). This crate is the inbound
half of the Telegram Bot API integration — receives POST /webhook from
Telegram, verifies secret token, parses Update, emits typed WebhookEvent.

Architecture: handler-only. The crate exposes `handle_webhook` and the
parsed types; the consumer owns the axum::Router and the HTTP server.
This keeps kei-telegram-webhook composable into kei-buddy, kei-gateway,
or any other consumer without forcing a server topology.

Files (9 new, 484 LOC total, all under 200/file):
  * src/update.rs — lean Telegram Update / Message / User / Chat /
    CallbackQuery structs (only fields KeiBuddy needs: chat_id, from,
    text, message_id, date, callback_data; #[serde(default)] on optionals)
  * src/event.rs — WebhookEvent enum (Text / Callback / Other) +
    classify(update) -> WebhookEvent
  * src/handler.rs — axum handler with X-Telegram-Bot-Api-Secret-Token
    header verification (mismatch → 401)
  * src/context.rs — WebhookContext trait (consumer provides
    secret_token() + on_event())
  * src/error.rs — WebhookError via thiserror
  * src/lib.rs — module declarations + re-exports
  * Cargo.toml — workspace member, maturity = "alpha"
  * README.md — usage example (axum Router mount, 10-line snippet)

Tests (5 in src/event.rs + src/handler.rs, all pass):
  * classify_text_message — text Update → WebhookEvent::Text
  * classify_callback_query — callback Update → WebhookEvent::Callback
  * classify_other_returns_other — edited_message-only Update → Other
  * bad_secret_token_returns_401 — wrong header → 401 UNAUTHORIZED
  * good_secret_token_returns_200 — matching header → 200 OK

Verify-before-commit (RULE 0.13 §):
  * cargo check --offline -p kei-telegram-webhook: PASS
  * cargo test --offline -p kei-telegram-webhook --lib: 5 passed / 0 failed
  * cargo check --workspace --offline: PASS (no new warnings)

STATUS-TRUTH from agent: shipped=functional, stubs=0, behaviour-verified=yes.

Follow-up (deferred, not blocking):
  * axum is direct dep "0.7" in this crate + kei-cortex + kei-forge —
    workspace should adopt axum in [workspace.dependencies] for version
    unification (separate consolidation wave)
  * Unmodelled Telegram fields (edited_message, inline_query, photo,
    document, reply_markup) — extend when KeiBuddy needs them
2026-05-12 13:33:31 +08:00

152 lines
4.4 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Axum handler for the Telegram webhook endpoint.
//!
//! Mount in your router with:
//! ```ignore
//! router.route("/telegram/webhook", axum::routing::post(handle_webhook::<MyState>))
//! ```
use axum::{
extract::{Json, State},
http::{HeaderMap, StatusCode},
};
use tracing::debug;
use crate::{
context::WebhookContext,
event::classify,
update::Update,
};
const TELEGRAM_TOKEN_HEADER: &str = "x-telegram-bot-api-secret-token";
/// Validate the secret token from the request headers.
///
/// Returns `Ok(())` on match, `Err(StatusCode::UNAUTHORIZED)` on mismatch or
/// absent header.
fn verify_token(headers: &HeaderMap, expected: &str) -> Result<(), StatusCode> {
let provided = headers
.get(TELEGRAM_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if provided == expected {
Ok(())
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
/// Axum POST handler for inbound Telegram `Update` payloads.
///
/// 1. Validates `X-Telegram-Bot-Api-Secret-Token` — returns 401 on mismatch.
/// 2. Parses the JSON body into [`Update`] — axum returns 400 on bad JSON.
/// 3. Calls [`classify`] and dispatches to [`WebhookContext::on_event`].
/// 4. Returns 200.
pub async fn handle_webhook<S>(
State(state): State<S>,
headers: HeaderMap,
Json(update): Json<Update>,
) -> Result<StatusCode, StatusCode>
where
S: WebhookContext,
{
debug!(update_id = update.update_id, "received telegram update");
verify_token(&headers, state.secret_token())?;
let event = classify(update);
state.on_event(event).await;
Ok(StatusCode::OK)
}
// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{
body::Body,
http::{self, Request},
routing::post,
Router,
};
use std::sync::Arc;
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use crate::event::WebhookEvent;
#[derive(Clone)]
struct MockCtx {
token: String,
captured: Arc<Mutex<Vec<WebhookEvent>>>,
}
impl MockCtx {
fn new(token: &str) -> Self {
Self {
token: token.into(),
captured: Arc::new(Mutex::new(vec![])),
}
}
}
#[async_trait]
impl WebhookContext for MockCtx {
fn secret_token(&self) -> &str {
&self.token
}
async fn on_event(&self, event: WebhookEvent) {
self.captured.lock().await.push(event);
}
}
fn minimal_update_json() -> &'static str {
r#"{"update_id":1,"message":{"message_id":1,"date":0,"chat":{"id":10},"text":"hi"}}"#
}
fn build_app(ctx: MockCtx) -> Router {
Router::new()
.route("/webhook", post(handle_webhook::<MockCtx>))
.with_state(ctx)
}
#[tokio::test]
async fn bad_secret_token_returns_401() {
let ctx = MockCtx::new("RIGHT");
let app = build_app(ctx);
let req = Request::builder()
.method(http::Method::POST)
.uri("/webhook")
.header(TELEGRAM_TOKEN_HEADER, "WRONG")
.header("content-type", "application/json")
.body(Body::from(minimal_update_json()))
.expect("build request");
let resp = app.oneshot(req).await.expect("call handler");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn good_secret_token_returns_200() {
let ctx = MockCtx::new("RIGHT");
let app = build_app(ctx);
let req = Request::builder()
.method(http::Method::POST)
.uri("/webhook")
.header(TELEGRAM_TOKEN_HEADER, "RIGHT")
.header("content-type", "application/json")
.body(Body::from(minimal_update_json()))
.expect("build request");
let resp = app.oneshot(req).await.expect("call handler");
assert_eq!(resp.status(), StatusCode::OK);
}
}