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:
parent
7bab6f52c1
commit
4dfe63b4e2
10 changed files with 498 additions and 0 deletions
14
_primitives/_rust/Cargo.lock
generated
14
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
32
_primitives/_rust/kei-telegram-webhook/Cargo.toml
Normal file
32
_primitives/_rust/kei-telegram-webhook/Cargo.toml
Normal 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>"]
|
||||||
44
_primitives/_rust/kei-telegram-webhook/README.md
Normal file
44
_primitives/_rust/kei-telegram-webhook/README.md
Normal 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).
|
||||||
24
_primitives/_rust/kei-telegram-webhook/src/context.rs
Normal file
24
_primitives/_rust/kei-telegram-webhook/src/context.rs
Normal 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);
|
||||||
|
}
|
||||||
20
_primitives/_rust/kei-telegram-webhook/src/error.rs
Normal file
20
_primitives/_rust/kei-telegram-webhook/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
127
_primitives/_rust/kei-telegram-webhook/src/event.rs
Normal file
127
_primitives/_rust/kei-telegram-webhook/src/event.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
_primitives/_rust/kei-telegram-webhook/src/handler.rs
Normal file
152
_primitives/_rust/kei-telegram-webhook/src/handler.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
_primitives/_rust/kei-telegram-webhook/src/lib.rs
Normal file
24
_primitives/_rust/kei-telegram-webhook/src/lib.rs
Normal 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};
|
||||||
59
_primitives/_rust/kei-telegram-webhook/src/update.rs
Normal file
59
_primitives/_rust/kei-telegram-webhook/src/update.rs
Normal 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>,
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue