Skip to main content

cratefield_module_telegram/
lib.rs

1//! `cratefield-module-telegram`: Telegram in the harness — the
2//! secret-token webhook, account linking, and consent buttons that never
3//! move value without a passkey (issue #764).
4//!
5//! ```no_run
6//! use cratefield_module_telegram::{Telegram, TelegramEvents};
7//!
8//! let module = Telegram::new(TelegramEvents::new().on_message(|message| {
9//!     Box::pin(async move { Ok(()) })
10//! }));
11//! ```
12//!
13//! The module mounts at `/v1/telegram` with four routes:
14//!
15//! - `POST /webhook` — one verified Telegram delivery in. The
16//!   `X-Telegram-Bot-Api-Secret-Token` header is checked against
17//!   [`WEBHOOK_SECRET_KEY`] over the raw body before any JSON is read;
18//!   without the secret configured, every delivery is refused with a
19//!   `503`. Deduplication runs through the
20//!   [`Inbox`](cratefield_core::Inbox) ledger on the update id, so a
21//!   redelivery is a `200` no-op.
22//! - `POST /link-codes` and `DELETE /link` — the signed-in app issues a
23//!   one-time code (stored only as a SHA-256 hash) and can unlink again.
24//! - `POST /actions/{action_id}/confirm` — the web app's passkey
25//!   confirmation, the only thing that can finish a value-moving
26//!   approval a Telegram tap armed.
27//!
28//! The security model of the buttons is in one sentence: **a tap in
29//! Telegram can deny anything, approve anything harmless, and only arm
30//! — never finish — anything that moves value.** See
31//! [`send_action_prompt`] and [`StepUp`].
32
33#![forbid(unsafe_code)]
34#![doc = include_str!("../README.md")]
35
36#[cfg(feature = "notifications")]
37mod channel;
38mod handlers;
39mod prompt;
40mod step_up;
41mod store;
42mod token;
43
44#[cfg(feature = "notifications")]
45pub use crate::channel::TelegramChannel;
46pub use crate::handlers::LinkCodeResponse;
47pub use crate::prompt::{
48    ActionError, ActionPrompt, ActionRequest, MAX_ACTION_TTL, send_action_prompt,
49};
50pub use crate::step_up::StepUp;
51pub use crate::token::{
52    ACTION_TOKEN_PREFIX, Decision, VerifiedToken, action_key, action_token, open_action_token,
53};
54
55use std::sync::Arc;
56
57use time::Duration as TimeDuration;
58
59use cratefield_adapter_telegram::{CallbackQuery, ChannelPost, Command, Message, TelegramBot};
60use cratefield_core::{
61    AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
62    ModuleConfig, ModuleContext, PersonalDataSet, Port, RandomBytes, SignatureVerification,
63    SqlMigration,
64};
65
66/// The module's name: mounted at `/v1/telegram`, config keys prefixed
67/// `TELEGRAM_`.
68pub const MODULE_NAME: &str = "telegram";
69
70/// The config key holding the webhook's shared secret
71/// (`X-Telegram-Bot-Api-Secret-Token`). Absent fails closed — every
72/// delivery answers `503` rather than trusting unverified bytes; set but
73/// blank fails [`Module::validate_config`].
74pub const WEBHOOK_SECRET_KEY: &str = "TELEGRAM_WEBHOOK_SECRET";
75
76/// The config key suffix [`WEBHOOK_SECRET_KEY`] composes from: what
77/// [`SignatureVerification::Hmac`] declares and `validate_config` reads,
78/// so the two cannot drift apart.
79const SECRET_KEY_SUFFIX: &str = "WEBHOOK_SECRET";
80
81/// The config key holding the bot token. Read only when no bot was
82/// injected with [`Telegram::bot`]; the token is never logged, and a
83/// missing one means replies are skipped with a warning — never a crash.
84pub const BOT_TOKEN_KEY: &str = "TELEGRAM_BOT_TOKEN";
85
86/// The config key holding the bot's `@username`. When set,
87/// `POST /link-codes` answers a ready-to-open `https://t.me/<username>?start=<code>`
88/// deep link alongside the code; when absent, the code is still issued
89/// and the deep link is simply omitted.
90pub const BOT_USERNAME_KEY: &str = "TELEGRAM_BOT_USERNAME";
91
92/// The config key holding the HMAC key the action buttons' tokens are
93/// signed with. Absent or blank fails closed: [`send_action_prompt`]
94/// cannot issue and every tap is refused.
95pub const ACTION_SECRET_KEY: &str = "TELEGRAM_ACTION_SECRET";
96
97/// The config key holding the web app's base URL — where a value-moving
98/// action's passkey confirmation happens. Telegram is told it as a URL
99/// button with the action id appended to its query string (`?action=…`,
100/// joined with `&` when the URL already carries a query).
101pub const APPROVAL_URL_KEY: &str = "TELEGRAM_APPROVAL_URL";
102
103/// How long a link code works: ten minutes. Long enough to switch from
104/// the web app to Telegram and press send; short enough that a code
105/// pasted into a group chat hours ago is dead paper.
106pub const LINK_CODE_TTL: TimeDuration = TimeDuration::seconds(10 * 60);
107
108/// The four tables the module owns (issue #764).
109const TABLES: &[&str] = &[
110    "telegram_inbox",
111    "telegram_link_codes",
112    "telegram_links",
113    "telegram_actions",
114];
115
116/// The one migration: all four tables, created together.
117const MIGRATION_INIT: SqlMigration = SqlMigration::new(
118    "0001",
119    "init",
120    include_str!("../migrations/sqlite/0001_init.sql"),
121);
122
123/// A venture hook: called inline as part of handling a delivery (or a
124/// confirm request). Its error is the delivery's `5xx` — which is what
125/// makes Telegram retry, and what keeps the dedup key unclaimed.
126pub type TelegramHook<T> = Arc<dyn Fn(T) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync>;
127
128/// A Telegram account has been linked to a subject — the end of a
129/// successful `/start <code>` in a private chat.
130#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub struct Linked {
132    /// The subject the account is now linked to.
133    pub subject: String,
134    /// The Telegram user id that pressed `/start`.
135    pub telegram_user_id: i64,
136    /// The private chat the link was made in — where this subject's
137    /// prompts and notifications go.
138    pub chat_id: i64,
139}
140
141/// A consent decision, reported to the venture's `on_action` hook.
142///
143/// The two paths that produce one:
144///
145/// - a Telegram tap — Deny, or Approve of a non-value-moving action;
146///   [`passkey_confirmed`](Self::passkey_confirmed) is `false`;
147/// - the web app's confirm route finishing a value-moving action —
148///   always [`Decision::Approve`] with
149///   [`passkey_confirmed`](Self::passkey_confirmed) `true`, because a
150///   fresh passkey ceremony stood behind it.
151#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152pub struct ActionDecision {
153    /// The id [`send_action_prompt`] reported, and the `{action_id}` of
154    /// the confirm route — stable across every path that names it.
155    pub action_id: String,
156    /// The account the action belonged to.
157    pub subject: String,
158    /// The app-defined label the request carried, verbatim.
159    pub action: String,
160    /// Which way it was decided.
161    pub decision: Decision,
162    /// Whether a fresh passkey ceremony stood behind the approval
163    /// (`true` only on the confirm route's path).
164    pub passkey_confirmed: bool,
165}
166
167/// The venture's hooks: `TelegramEvents::new().on_message(|message| ...)`.
168///
169/// Every hook is optional. An update kind with no hook is still handled
170/// — linking, the action buttons and the dedup ledger all run; only the
171/// venture's part is a no-op.
172#[derive(Clone, Default)]
173pub struct TelegramEvents {
174    message: Option<TelegramHook<Message>>,
175    command: Option<TelegramHook<Command>>,
176    callback: Option<TelegramHook<CallbackQuery>>,
177    channel_post: Option<TelegramHook<ChannelPost>>,
178    linked: Option<TelegramHook<Linked>>,
179    action: Option<TelegramHook<ActionDecision>>,
180}
181
182// Closures are not `Debug`; whether a hook is wired is, and the hook
183// itself never is.
184impl std::fmt::Debug for TelegramEvents {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct("TelegramEvents")
187            .field("on_message", &self.message.is_some())
188            .field("on_command", &self.command.is_some())
189            .field("on_callback", &self.callback.is_some())
190            .field("on_channel_post", &self.channel_post.is_some())
191            .field("on_linked", &self.linked.is_some())
192            .field("on_action", &self.action.is_some())
193            .finish()
194    }
195}
196
197impl TelegramEvents {
198    #[must_use]
199    pub fn new() -> Self {
200        Self::default()
201    }
202
203    /// Calls `hook` with every plain message. A hook error fails the
204    /// delivery with a `5xx` and leaves the dedup key unclaimed, so
205    /// Telegram's redelivery re-runs it.
206    #[must_use]
207    pub fn on_message(
208        mut self,
209        hook: impl Fn(Message) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
210    ) -> Self {
211        self.message = Some(Arc::new(hook));
212        self
213    }
214
215    /// Calls `hook` with every `/command` — including `/start` when it
216    /// arrives without a code, or from a group chat rather than the
217    /// private conversation linking happens in.
218    #[must_use]
219    pub fn on_command(
220        mut self,
221        hook: impl Fn(Command) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
222    ) -> Self {
223        self.command = Some(Arc::new(hook));
224        self
225    }
226
227    /// Calls `hook` with every button press whose `data` is not one of
228    /// this module's action tokens — the venture's own keyboard buttons.
229    #[must_use]
230    pub fn on_callback(
231        mut self,
232        hook: impl Fn(CallbackQuery) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
233    ) -> Self {
234        self.callback = Some(Arc::new(hook));
235        self
236    }
237
238    /// Calls `hook` with every channel post (edits included, flagged).
239    #[must_use]
240    pub fn on_channel_post(
241        mut self,
242        hook: impl Fn(ChannelPost) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
243    ) -> Self {
244        self.channel_post = Some(Arc::new(hook));
245        self
246    }
247
248    /// Calls `hook` when a `/start <code>` links an account. After this
249    /// fires, the subject's prompts and notifications go to the chat.
250    #[must_use]
251    pub fn on_linked(
252        mut self,
253        hook: impl Fn(Linked) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
254    ) -> Self {
255        self.linked = Some(Arc::new(hook));
256        self
257    }
258
259    /// Calls `hook` with every finished consent decision — a Telegram
260    /// tap (`passkey_confirmed: false`) or the web app's passkey
261    /// confirmation (`true`). Value-moving approvals fired **only** from
262    /// the confirm route never reach Telegram taps.
263    #[must_use]
264    pub fn on_action(
265        mut self,
266        hook: impl Fn(ActionDecision) -> BoxFuture<'static, Result<(), AnyError>>
267        + Send
268        + Sync
269        + 'static,
270    ) -> Self {
271        self.action = Some(Arc::new(hook));
272        self
273    }
274}
275
276/// What the handlers hold, once the module is composed: the hooks plus
277/// the three injected seams.
278#[derive(Clone)]
279pub(crate) struct TelegramState {
280    pub(crate) events: TelegramEvents,
281    pub(crate) bot: Option<Arc<dyn TelegramBot>>,
282    pub(crate) random: Option<Arc<dyn RandomBytes>>,
283    pub(crate) step_up: Option<Arc<dyn StepUp>>,
284}
285
286// Closures and ports are not `Debug`; which seams are wired is.
287impl std::fmt::Debug for TelegramState {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        f.debug_struct("TelegramState")
290            .field("events", &self.events)
291            .field("bot", &self.bot.is_some())
292            .field("random", &self.random.is_some())
293            .field("step_up", &self.step_up.is_some())
294            .finish()
295    }
296}
297
298/// The Telegram module: the webhook, account linking, and the consent
299/// buttons.
300#[derive(Default, Clone)]
301pub struct Telegram {
302    events: TelegramEvents,
303    bot: Option<Arc<dyn TelegramBot>>,
304    random: Option<Arc<dyn RandomBytes>>,
305    step_up: Option<Arc<dyn StepUp>>,
306}
307
308// Ports and closures are not `Debug`; which seams are wired is.
309impl std::fmt::Debug for Telegram {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("Telegram")
312            .field("events", &self.events)
313            .field("bot", &self.bot.is_some())
314            .field("random", &self.random.is_some())
315            .field("step_up", &self.step_up.is_some())
316            .finish()
317    }
318}
319
320impl Telegram {
321    /// A module with the given venture hooks; `TelegramEvents::new()` for
322    /// none.
323    #[must_use]
324    pub fn new(events: TelegramEvents) -> Self {
325        Self {
326            events,
327            bot: None,
328            random: None,
329            step_up: None,
330        }
331    }
332
333    /// Injects the bot the module talks to — a
334    /// [`FakeTelegramBot`](cratefield_adapter_telegram::fake::FakeTelegramBot)
335    /// in tests, or the adapter's `HttpTelegramBot` in production.
336    /// Without one (and without `TELEGRAM_BOT_TOKEN` to build one from),
337    /// replies and prompts are skipped with a warning; the webhook keeps
338    /// working.
339    #[must_use]
340    pub fn bot(mut self, bot: Arc<dyn TelegramBot>) -> Self {
341        self.bot = Some(bot);
342        self
343    }
344
345    /// Injects the entropy source link codes and action ids are drawn
346    /// from. Without one, `POST /link-codes` answers `500` and no prompt
347    /// can be issued; [`Module::self_check`] names the missing argument.
348    #[must_use]
349    pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
350        self.random = Some(Arc::new(random));
351        self
352    }
353
354    /// Injects the step-up verifier the confirm route requires before a
355    /// value-moving action may finish. Without one, the route answers
356    /// `403` — fail closed, never open.
357    #[must_use]
358    pub fn step_up(mut self, step_up: Arc<dyn StepUp>) -> Self {
359        self.step_up = Some(step_up);
360        self
361    }
362
363    fn state(&self) -> TelegramState {
364        TelegramState {
365            events: self.events.clone(),
366            bot: self.bot.clone(),
367            random: self.random.clone(),
368            step_up: self.step_up.clone(),
369        }
370    }
371}
372
373impl Module for Telegram {
374    fn name(&self) -> &'static str {
375        MODULE_NAME
376    }
377
378    fn version(&self) -> &'static str {
379        env!("CARGO_PKG_VERSION")
380    }
381
382    fn requires(&self) -> &'static [Port] {
383        // Clock: every expiry — link codes, action prompts, token
384        // expiries — is judged against it.
385        &[Port::Db, Port::Clock]
386    }
387
388    fn optional(&self) -> &'static [Port] {
389        // Auth: `/link-codes`, `/link` and the confirm route work for a
390        // signed-in caller and refuse everyone else; without the port
391        // they refuse everyone, which is the honest answer.
392        // HttpClient: the bot is built from it when none was injected.
393        &[Port::Auth, Port::HttpClient]
394    }
395
396    fn tables(&self) -> &'static [&'static str] {
397        TABLES
398    }
399
400    fn personal_data(&self) -> &'static [PersonalDataSet] {
401        const SETS: &[PersonalDataSet] = &[
402            PersonalDataSet::none(
403                "telegram_inbox",
404                "The ids of the Telegram updates this venture has already processed — a dedup \
405                 ledger, so a provider that delivers twice is processed once. It holds update \
406                 ids and timestamps and nothing about a person.",
407            ),
408            PersonalDataSet {
409                table: "telegram_link_codes",
410                subject: "subject",
411                kind: DataKind::Identifier,
412                disposition: Disposition::Erase,
413                description: "One-time codes issued so a person can connect their Telegram \
414                    account, held only as SHA-256 hashes alongside who they were issued to and \
415                    when they expire. Erasing a person's rows deletes their outstanding codes; \
416                    an expired or used code is deleted by nothing here — the row is kept so a \
417                    replay stays refused, and holds no secret.",
418                redacted: &[],
419                subject_via: None,
420            },
421            PersonalDataSet {
422                table: "telegram_links",
423                subject: "subject",
424                kind: DataKind::Identifier,
425                disposition: Disposition::Erase,
426                description: "The link between an account and a Telegram user: the Telegram \
427                    user id and the chat id messages are sent to, and when the link was made. \
428                    Erasing a person's rows unlinks them — nothing more is sent to that chat. \
429                    Messages already delivered to the chat live in Telegram, not here.",
430                redacted: &[],
431                subject_via: None,
432            },
433            PersonalDataSet {
434                table: "telegram_actions",
435                subject: "subject",
436                kind: DataKind::Usage,
437                disposition: Disposition::Erase,
438                description: "The consent requests a person was shown in Telegram: what they \
439                    were asked to approve, whether it moves value, and how it was decided. \
440                    Erasing a person's rows deletes that record of what they were asked and \
441                    answered.",
442                redacted: &[],
443                subject_via: None,
444            },
445        ];
446        SETS
447    }
448
449    fn emits(&self) -> &'static [&'static str] {
450        &[]
451    }
452
453    /// The route is a provider webhook guarded by Telegram's shared
454    /// secret token.
455    fn signature_verification(&self) -> SignatureVerification {
456        SignatureVerification::Hmac {
457            secret: SECRET_KEY_SUFFIX,
458        }
459    }
460
461    fn migrations(&self) -> Migrations {
462        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
463        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
464        Migrations::sqlite(&MIGRATIONS)
465    }
466
467    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
468        // Absent stays legal for every key: the webhook fails closed per
469        // request, the app routes answer without their niceties, and a
470        // venture that mounts the module but never wires Telegram is not
471        // unbootable. Set-but-blank is a typo, not a choice.
472        let module = ModuleConfig::new(MODULE_NAME, cfg);
473        let mut errors = ConfigError::default();
474
475        for (suffix, full, what) in [
476            (
477                SECRET_KEY_SUFFIX,
478                WEBHOOK_SECRET_KEY,
479                "the webhook shared secret",
480            ),
481            ("BOT_TOKEN", BOT_TOKEN_KEY, "the bot token"),
482            ("BOT_USERNAME", BOT_USERNAME_KEY, "the bot username"),
483            (
484                "ACTION_SECRET",
485                ACTION_SECRET_KEY,
486                "the action signing secret",
487            ),
488            ("APPROVAL_URL", APPROVAL_URL_KEY, "the approval URL"),
489        ] {
490            if let Some(value) = cfg.get(&module.key(suffix))
491                && value.trim().is_empty()
492            {
493                errors.push(format!(
494                    "telegram: {full} is set but blank; unset it or give {what}"
495                ));
496            }
497        }
498
499        // Telegram itself refuses a secret-token outside this shape, so a
500        // secret the adapter's verifier accepts and Telegram rejects would
501        // silently accept nothing.
502        if let Some(secret) = cfg.get(&module.key(SECRET_KEY_SUFFIX))
503            && !secret.trim().is_empty()
504            && !cratefield_adapter_telegram::webhook::is_valid_secret(&secret)
505        {
506            errors.push(
507                "telegram: TELEGRAM_WEBHOOK_SECRET must be 1-256 characters of A-Z a-z 0-9 _ - \
508                 — Telegram refuses anything else for its secret token"
509                    .to_owned(),
510            );
511        }
512
513        errors.into_result()
514    }
515
516    /// The composition a build should hear about. Nothing here comes
517    /// from the environment; the fix is in the composition.
518    fn self_check(&self) -> Vec<String> {
519        let mut problems = Vec::new();
520        if self.random.is_none() {
521            problems.push(
522                "telegram: no entropy source is configured, so no link code or action id can \
523                 be drawn — add `.random(impl RandomBytes)` to the module"
524                    .to_owned(),
525            );
526        }
527        problems
528    }
529
530    fn router(&self, ctx: ModuleContext) -> axum::Router {
531        handlers::router(ctx, self.state())
532    }
533
534    fn surface(&self) -> cratefield_core::Surface {
535        handlers::surface()
536    }
537}