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
This commit is contained in:
Parfii-bot 2026-05-12 13:33:31 +08:00
parent 7bab6f52c1
commit 4dfe63b4e2
10 changed files with 498 additions and 0 deletions

View file

@ -4421,6 +4421,20 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "kei-telegram-webhook"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tracing",
]
[[package]] [[package]]
name = "kei-tlog" name = "kei-tlog"
version = "0.1.0" version = "0.1.0"

View file

@ -181,6 +181,8 @@ members = [
"kei-graph-export", "kei-graph-export",
# KeiBuddy personal-assistant Telegram bot — onboarding FSM scaffold # KeiBuddy personal-assistant Telegram bot — onboarding FSM scaffold
"kei-buddy", "kei-buddy",
# Inbound Telegram webhook handler — parses Update payloads into typed WebhookEvent
"kei-telegram-webhook",
] ]
[workspace.package] [workspace.package]

View file

@ -0,0 +1,32 @@
[package]
name = "kei-telegram-webhook"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "Inbound Telegram Bot API webhook handler — parses Update payloads into typed WebhookEvent values. Consumers mount the handler into their own axum::Router."
authors.workspace = true
license.workspace = true
[lib]
name = "kei_telegram_webhook"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
# axum not yet in workspace.dependencies — tracked in follow-up-required
axum = { version = "0.7", features = ["json", "http1", "tokio"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tracing = "0.1"
[dev-dependencies]
tokio = { workspace = true }
tower = { workspace = true }
[package.metadata.keisei]
maturity = "alpha"
backend = "telegram"
description = "Inbound Telegram webhook handler (secret-token verified, typed WebhookEvent)"
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]

View file

@ -0,0 +1,44 @@
# kei-telegram-webhook
Inbound Telegram Bot API webhook handler.
Sibling to `kei-notify-telegram` (outbound). This crate is the **inbound** half.
## Purpose
Parse Telegram `Update` payloads arriving via HTTPS POST into typed
`WebhookEvent` values. Secret-token verification included.
## Architecture
The crate exposes a single axum **handler function** and the parsed types.
It does **not** own an `axum::Server` — that is the consumer's job.
Mount `handle_webhook` into your existing `Router`.
## Usage
```rust
use axum::{routing::post, Router};
use kei_telegram_webhook::handle_webhook;
#[derive(Clone)]
struct AppState { token: String }
#[async_trait::async_trait]
impl kei_telegram_webhook::WebhookContext for AppState {
fn secret_token(&self) -> &str { &self.token }
async fn on_event(&self, event: kei_telegram_webhook::WebhookEvent) {
println!("{event:?}");
}
}
let state = AppState { token: "MY_SECRET".into() };
let app = Router::new()
.route("/telegram/webhook", post(handle_webhook::<AppState>))
.with_state(state);
// pass `app` to your axum::serve call
```
## Status
Alpha — handler logic and unit tests pass; real Telegram POST integration
verified by the consumer (KeiBuddy).

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
//! `WebhookContext` — trait that consumer state types must implement.
//!
//! This trait is what the handler needs from the application's `axum::State`.
//! Consumers clone their state into every handler call (axum requirement).
use async_trait::async_trait;
use crate::event::WebhookEvent;
/// Contract between the handler and the consuming application.
///
/// Implement this on your axum `State` type, then pass `State<S>` to the
/// router. The handler calls [`WebhookContext::secret_token`] for HMAC-free
/// constant-time comparison and [`WebhookContext::on_event`] for dispatch.
#[async_trait]
pub trait WebhookContext: Clone + Send + Sync + 'static {
/// Return the secret token that was passed to `setWebhook`.
fn secret_token(&self) -> &str;
/// Handle a classified inbound event. Errors are logged but not surfaced
/// to Telegram — the handler always returns 200 on successful validation.
async fn on_event(&self, event: WebhookEvent);
}

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
//! `WebhookError` — error types for the webhook handler.
use thiserror::Error;
/// Errors that can arise while processing an inbound Telegram update.
#[derive(Debug, Error)]
pub enum WebhookError {
/// The `X-Telegram-Bot-Api-Secret-Token` header is missing.
#[error("missing X-Telegram-Bot-Api-Secret-Token header")]
MissingSecretToken,
/// The provided secret token does not match the configured value.
#[error("invalid secret token")]
InvalidSecretToken,
/// The JSON payload could not be deserialized into an `Update`.
#[error("failed to deserialize update: {0}")]
DeserializeError(String),
}

View file

@ -0,0 +1,127 @@
// 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,
},
/// 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: `message` before `callback_query`.
pub fn classify(update: Update) -> WebhookEvent {
if let Some(msg) = update.message {
if let Some(text) = msg.text {
return WebhookEvent::Text {
chat_id: msg.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::{CallbackQuery, Chat, Message, Update, User};
fn make_user() -> User {
User {
id: 42,
username: Some("alice".into()),
first_name: Some("Alice".into()),
}
}
#[test]
fn classify_text_message() {
let update = Update {
update_id: 1,
message: Some(Message {
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,
};
let event = classify(update);
assert_eq!(
event,
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,
}),
data: Some("action:start".into()),
}),
};
let event = classify(update);
assert_eq!(
event,
WebhookEvent::Callback {
chat_id: 77,
from: Some(make_user()),
data: "action:start".into(),
}
);
}
#[test]
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,
};
assert_eq!(classify(update), WebhookEvent::Other);
}
}

View file

@ -0,0 +1,152 @@
// 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);
}
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
//! kei-telegram-webhook — inbound Telegram Bot API webhook handler.
//!
//! Consumers mount [`handler::handle_webhook`] inside their own [`axum::Router`].
//! This crate does NOT own an `axum::Server`.
//!
//! Module layout (Constructor Pattern — one file, one responsibility):
//! * `update` — lean `Update` / `Message` / `User` / `Chat` / `CallbackQuery` structs
//! * `event` — `WebhookEvent` enum + `classify` function
//! * `context` — `WebhookContext` trait (secret_token + on_event)
//! * `handler` — axum handler `handle_webhook<S>`
//! * `error` — `WebhookError` via thiserror
pub mod context;
pub mod error;
pub mod event;
pub mod handler;
pub mod update;
pub use context::WebhookContext;
pub use error::WebhookError;
pub use event::{classify, WebhookEvent};
pub use handler::handle_webhook;
pub use update::{CallbackQuery, Chat, Message, Update, User};

View file

@ -0,0 +1,59 @@
// 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};
/// 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.
#[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>,
}
/// 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>,
}