diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index d469483..4f7274b 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -4421,6 +4421,20 @@ dependencies = [ "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]] name = "kei-tlog" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index d7b6572..a7e8de2 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -181,6 +181,8 @@ members = [ "kei-graph-export", # KeiBuddy personal-assistant Telegram bot — onboarding FSM scaffold "kei-buddy", + # Inbound Telegram webhook handler — parses Update payloads into typed WebhookEvent + "kei-telegram-webhook", ] [workspace.package] diff --git a/_primitives/_rust/kei-telegram-webhook/Cargo.toml b/_primitives/_rust/kei-telegram-webhook/Cargo.toml new file mode 100644 index 0000000..842d335 --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/Cargo.toml @@ -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 "] diff --git a/_primitives/_rust/kei-telegram-webhook/README.md b/_primitives/_rust/kei-telegram-webhook/README.md new file mode 100644 index 0000000..4bcaa79 --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/README.md @@ -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::)) + .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). diff --git a/_primitives/_rust/kei-telegram-webhook/src/context.rs b/_primitives/_rust/kei-telegram-webhook/src/context.rs new file mode 100644 index 0000000..17de62c --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/context.rs @@ -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` 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); +} diff --git a/_primitives/_rust/kei-telegram-webhook/src/error.rs b/_primitives/_rust/kei-telegram-webhook/src/error.rs new file mode 100644 index 0000000..089e32c --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/error.rs @@ -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), +} diff --git a/_primitives/_rust/kei-telegram-webhook/src/event.rs b/_primitives/_rust/kei-telegram-webhook/src/event.rs new file mode 100644 index 0000000..374edf0 --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/event.rs @@ -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, + text: String, + }, + /// Inline-keyboard button press. + Callback { + chat_id: i64, + from: Option, + 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); + } +} diff --git a/_primitives/_rust/kei-telegram-webhook/src/handler.rs b/_primitives/_rust/kei-telegram-webhook/src/handler.rs new file mode 100644 index 0000000..18c824c --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/handler.rs @@ -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::)) +//! ``` + +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( + State(state): State, + headers: HeaderMap, + Json(update): Json, +) -> Result +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>>, + } + + 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::)) + .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); + } +} diff --git a/_primitives/_rust/kei-telegram-webhook/src/lib.rs b/_primitives/_rust/kei-telegram-webhook/src/lib.rs new file mode 100644 index 0000000..e35e42d --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/lib.rs @@ -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` +//! * `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}; diff --git a/_primitives/_rust/kei-telegram-webhook/src/update.rs b/_primitives/_rust/kei-telegram-webhook/src/update.rs new file mode 100644 index 0000000..6545a04 --- /dev/null +++ b/_primitives/_rust/kei-telegram-webhook/src/update.rs @@ -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, + #[serde(default)] + pub callback_query: Option, +} + +/// 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, + #[serde(default)] + pub text: Option, +} + +/// Telegram user or bot. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct User { + pub id: i64, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub first_name: Option, +} + +/// Chat where a message was sent. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Chat { + pub id: i64, + #[serde(default)] + pub r#type: Option, +} + +/// Inline-keyboard button callback. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct CallbackQuery { + pub id: String, + #[serde(default)] + pub from: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub data: Option, +}