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
127 lines
3.4 KiB
Rust
127 lines
3.4 KiB
Rust
// 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);
|
|
}
|
|
}
|