Skip to main content

cratefield_module_telegram/
handlers.rs

1//! The module's four routes.
2//!
3//! `POST /webhook` is Telegram's: it verifies the
4//! `X-Telegram-Bot-Api-Secret-Token` header over the raw body before any
5//! JSON is read, deduplicates on the update id, then dispatches to the
6//! venture's hooks and to the two flows this module owns — account
7//! linking and the action buttons. A hook that fails is a `5xx` with the
8//! dedup key unclaimed, and the step that already committed — the
9//! decision, the spent link code — is rolled back first, so Telegram's
10//! redelivery re-runs the whole step rather than finding it done.
11//!
12//! The other three are the venture's app-side routes: `POST /link-codes`
13//! and `DELETE /link` act for the signed-in caller, and
14//! `POST /actions/{action_id}/confirm` is the web app's passkey
15//! confirmation that — and only that — can move a value-moving action
16//! past `awaiting_passkey`.
17
18use std::sync::Arc;
19
20use axum::extract::{Path, State};
21use axum::http::{HeaderMap, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{delete, post};
24use base64::Engine as _;
25use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL;
26use serde::Serialize;
27use serde_json::json;
28use sha2::{Digest, Sha256};
29use time::OffsetDateTime;
30
31use cratefield_adapter_telegram::webhook::{self, WebhookError};
32use cratefield_adapter_telegram::{
33    CallbackQuery, ChatKind, Command, InlineButton, OutgoingMessage, TelegramBot, UpdateKind,
34};
35use cratefield_core::{
36    Action, Audience, Clock, DbError, Inbox, Json, ModuleContext, Outcome, Problem, ProblemDef,
37    RoutePolicy, Scope, Surface, SystemClock,
38};
39
40use crate::store;
41use crate::token::{self, Decision, VerifiedToken};
42use crate::{
43    APPROVAL_URL_KEY, BOT_USERNAME_KEY, Linked, TelegramHook, TelegramState, WEBHOOK_SECRET_KEY,
44};
45
46/// "Now" through the Clock port when present, `SystemClock` otherwise.
47fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
48    ctx.ports
49        .clock
50        .as_ref()
51        .map_or_else(|| SystemClock.now(), |clock| clock.now())
52}
53
54/// Now, as the Unix seconds an action token carries: past 2106 becomes
55/// the far future, which reads as "already expired" — fail closed.
56fn now_unix_u32(now: OffsetDateTime) -> u32 {
57    u32::try_from(now.unix_timestamp()).unwrap_or(u32::MAX)
58}
59
60// ---------------------------------------------------------------------------
61// Problem definitions
62
63/// The one refusal for a delivery that does not prove itself.
64const UNVERIFIED: ProblemDef = ProblemDef {
65    slug: "telegram/unverified",
66    status: StatusCode::UNAUTHORIZED,
67    title: "Unverified delivery",
68    description: "The delivery carried no secret token this deployment could verify, or one \
69                  that did not hold.",
70};
71
72const UNAUTHENTICATED: ProblemDef = ProblemDef {
73    slug: "telegram/unauthenticated",
74    status: StatusCode::UNAUTHORIZED,
75    title: "Sign in first",
76    description: "This route is available only to a signed-in user.",
77};
78
79const NO_SUCH_ACTION: ProblemDef = ProblemDef {
80    slug: "telegram/no-such-action",
81    status: StatusCode::NOT_FOUND,
82    title: "No such action",
83    description: "No action with that id exists.",
84};
85
86const NOT_YOURS: ProblemDef = ProblemDef {
87    slug: "telegram/action-not-yours",
88    status: StatusCode::FORBIDDEN,
89    title: "Not your action",
90    description: "Actions are confirmed by the account they were requested for.",
91};
92
93const NOT_CONFIRMABLE: ProblemDef = ProblemDef {
94    slug: "telegram/action-not-confirmable",
95    status: StatusCode::CONFLICT,
96    title: "Action cannot be confirmed",
97    description: "The action is not awaiting passkey confirmation, or it has expired, or it \
98                  was already confirmed.",
99};
100
101const STEP_UP_REQUIRED: ProblemDef = ProblemDef {
102    slug: "telegram/passkey-required",
103    status: StatusCode::FORBIDDEN,
104    title: "Passkey confirmation required",
105    description: "Approving a value-moving action needs a fresh passkey ceremony, and this \
106                  request did not carry one this deployment could verify.",
107};
108
109fn internal(scope: &Scope) -> Problem {
110    Problem::internal().instance(&scope.request_id)
111}
112
113/// A database failure is ours and is logged, not described to the caller.
114fn db_problem(error: &DbError, scope: &Scope) -> Problem {
115    tracing::error!(error = %error, "telegram: a database statement failed");
116    internal(scope)
117}
118
119// ---------------------------------------------------------------------------
120// State and wiring
121
122/// What every handler holds: the module context and the composed
123/// settings, together so no handler re-pairs them.
124struct HandlerState {
125    ctx: Arc<ModuleContext>,
126    settings: TelegramState,
127}
128
129pub(crate) fn router(ctx: cratefield_core::ModuleContext, settings: TelegramState) -> axum::Router {
130    axum::Router::new()
131        .route("/webhook", post(receive))
132        .route("/link-codes", post(create_link_code))
133        .route("/link", delete(unlink))
134        .route("/actions/{action_id}/confirm", post(confirm))
135        .with_state(Arc::new(HandlerState {
136            ctx: Arc::new(ctx),
137            settings,
138        }))
139}
140
141/// The dedup ledger: one row per Telegram update id.
142fn inbox() -> Inbox {
143    Inbox::new("telegram_inbox")
144}
145
146pub(crate) fn surface() -> Surface {
147    Surface::new()
148        .action(Action::post("webhook", "/webhook").policy(RoutePolicy::Signature))
149        .action(
150            Action::post("create-link-code", "/link-codes")
151                .audience(Audience::Subject)
152                .outcome(Outcome::Json)
153                .output::<LinkCodeResponse>(),
154        )
155        .action(Action::delete("delete-link", "/link").audience(Audience::Subject))
156        .action(
157            Action::post("confirm-action", "/actions/{action_id}/confirm")
158                .audience(Audience::Subject)
159                .outcome(Outcome::Json),
160        )
161}
162
163/// The bot the module talks through: the injected one, or an
164/// [`HttpTelegramBot`](cratefield_adapter_telegram::HttpTelegramBot) built
165/// per request from the `HttpClient` and `Clock` ports and
166/// `TELEGRAM_BOT_TOKEN`. `None` — nothing injected, no ports, no token —
167/// means replies are skipped with a warning; it never means a crash, and
168/// the token never reaches a log line.
169fn bot_of(state: &HandlerState) -> Option<Arc<dyn TelegramBot>> {
170    if let Some(bot) = state.settings.bot.clone() {
171        return Some(bot);
172    }
173    let http = state.ctx.ports.http.clone()?;
174    let clock = state.ctx.ports.clock.clone()?;
175    let token = state
176        .ctx
177        .config
178        .get(crate::BOT_TOKEN_KEY)
179        .filter(|token| !token.trim().is_empty())?;
180    Some(Arc::new(cratefield_adapter_telegram::HttpTelegramBot::new(
181        http, clock, token,
182    )))
183}
184
185/// The `TELEGRAM_ACTION_SECRET` bytes; absent or blank means `None`,
186/// which both issuing and tapping treat as "refuse, and say so in the
187/// log".
188fn action_key_of(state: &HandlerState) -> Option<Vec<u8>> {
189    state
190        .ctx
191        .config
192        .get(crate::ACTION_SECRET_KEY)
193        .filter(|secret| !secret.trim().is_empty())
194        .map(String::into_bytes)
195}
196
197/// Sends a message when there is a bot to send it with. Best-effort: a
198/// failed reply is a warning, never a failed request — the effects are
199/// already in the database, and a `5xx` would only buy a duplicate.
200async fn send_message(state: &HandlerState, message: OutgoingMessage) {
201    let Some(bot) = bot_of(state) else {
202        tracing::warn!(
203            "telegram: no bot configured (inject .bot(..) or set TELEGRAM_BOT_TOKEN); \
204             the reply is skipped"
205        );
206        return;
207    };
208    if let Err(error) = bot.send_message(&message).await {
209        tracing::warn!(error = %error, "telegram: the reply could not be sent");
210    }
211}
212
213/// A plain text reply.
214async fn send_text(state: &HandlerState, chat_id: i64, text: &str) {
215    send_message(state, OutgoingMessage::new(chat_id, text)).await;
216}
217
218/// Acknowledges a callback query when there is a bot — what stops the
219/// client's spinner. Best-effort, like a reply.
220async fn answer_callback(state: &HandlerState, callback_query_id: &str, text: &str) {
221    let Some(bot) = bot_of(state) else {
222        tracing::warn!("telegram: no bot configured; the callback was not acknowledged");
223        return;
224    };
225    if let Err(error) = bot.answer_callback(callback_query_id, Some(text)).await {
226        tracing::warn!(error = %error, "telegram: answerCallbackQuery failed");
227    }
228}
229
230/// The lower-case SHA-256 hex of a link code — the only form a code ever
231/// takes in the database.
232fn sha256_hex(input: &str) -> String {
233    hex::encode(Sha256::digest(input.as_bytes()))
234}
235
236/// Calls one venture hook. Its error is the delivery's (or the route
237/// response's) `5xx`, logged here because the problem the caller sees
238/// never describes it.
239async fn run_hook<T: Send + 'static>(
240    hook: Option<&TelegramHook<T>>,
241    scope: &Scope,
242    payload: T,
243) -> Result<(), Problem> {
244    let Some(hook) = hook else {
245        return Ok(());
246    };
247    match hook(payload).await {
248        Ok(()) => Ok(()),
249        Err(error) => {
250            tracing::warn!(error = %error, "telegram: a venture hook failed");
251            Err(internal(scope))
252        }
253    }
254}
255
256// ---------------------------------------------------------------------------
257// POST /v1/telegram/webhook
258
259/// `POST /v1/telegram/webhook` — one verified delivery in. Effects run
260/// **before** the [`inbox()`] claim commits: a hook failure is a `5xx`
261/// with the key unclaimed, so Telegram's retry re-runs the delivery —
262/// at-least-once (a crash between effect and claim can double-run) beats
263/// at-most-once (a swallowed update).
264async fn receive(
265    State(state): State<Arc<HandlerState>>,
266    scope: Scope,
267    headers: HeaderMap,
268    body: axum::body::Bytes,
269) -> Result<Response, Problem> {
270    let Some(secret) = state
271        .ctx
272        .config
273        .get(WEBHOOK_SECRET_KEY)
274        .filter(|secret| !secret.trim().is_empty())
275    else {
276        // Fail closed: without the shared token nothing can be verified,
277        // and pretending otherwise would serve every forged delivery.
278        tracing::error!("telegram: no webhook secret configured; every delivery is refused");
279        return Err(
280            Problem::not_ready("telegram has no webhook secret configured")
281                .instance(&scope.request_id),
282        );
283    };
284    let Some(db) = state.ctx.ports.db.clone() else {
285        return Err(internal(&scope));
286    };
287    let now = now_of(&state.ctx);
288
289    // Verify over the raw bytes before any JSON is read; the adapter owns
290    // the scheme and the parse.
291    let update = webhook::parse_verified(&secret, &headers, &body, now.unix_timestamp()).map_err(
292        |error| match error {
293            WebhookError::Unverified => Problem::new(&UNVERIFIED).instance(&scope.request_id),
294            WebhookError::Malformed => {
295                Problem::validation_failed("the body is not a well-formed Telegram update")
296                    .instance(&scope.request_id)
297            }
298        },
299    )?;
300    let update_key = update.update_id.to_string();
301
302    if inbox()
303        .seen(&*db, &update_key)
304        .await
305        .map_err(|error| db_problem(&error, &scope))?
306    {
307        return Ok(ok());
308    }
309
310    dispatch(&state, &scope, &update.kind).await?;
311
312    // Commits last: reaching here means every fallible step succeeded, so
313    // a `false` (a concurrent delivery won the race) only means the same
314    // work ran twice — still a `200`.
315    inbox()
316        .claim(&*db, &update_key, &store::stamp(now))
317        .await
318        .map_err(|error| db_problem(&error, &scope))?;
319    Ok(ok())
320}
321
322/// Runs the flow one update kind belongs to. `Ok` means the update may be
323/// claimed; an `Err` is the `5xx` that keeps the key unclaimed for the
324/// redelivery.
325async fn dispatch(state: &HandlerState, scope: &Scope, kind: &UpdateKind) -> Result<(), Problem> {
326    match kind {
327        // `/start <code>` from a private chat is the account-linking
328        // flow; the same command anywhere else is the venture's to
329        // answer, through the command hook.
330        UpdateKind::Command(command)
331            if command.name == "start"
332                && !command.args.is_empty()
333                && command.message.chat.kind == ChatKind::Private =>
334        {
335            link(state, scope, command).await
336        }
337        UpdateKind::Command(command) => {
338            run_hook(
339                state.settings.events.command.as_ref(),
340                scope,
341                command.clone(),
342            )
343            .await
344        }
345        UpdateKind::CallbackQuery(callback) => {
346            let is_action = callback
347                .data
348                .as_deref()
349                .is_some_and(|data| data.starts_with(token::ACTION_TOKEN_PREFIX));
350            if is_action {
351                tap(state, scope, callback).await
352            } else {
353                run_hook(
354                    state.settings.events.callback.as_ref(),
355                    scope,
356                    callback.clone(),
357                )
358                .await
359            }
360        }
361        UpdateKind::Message(message) => {
362            run_hook(
363                state.settings.events.message.as_ref(),
364                scope,
365                message.clone(),
366            )
367            .await
368        }
369        UpdateKind::ChannelPost(posted) => {
370            run_hook(
371                state.settings.events.channel_post.as_ref(),
372                scope,
373                posted.clone(),
374            )
375            .await
376        }
377        // `edited_message` and every payload the adapter does not model:
378        // claimed without an effect, so the redelivery is a no-op.
379        UpdateKind::Other => Ok(()),
380    }
381}
382
383// ---------------------------------------------------------------------------
384// POST /v1/telegram/link-codes
385
386/// The response of `POST /link-codes`.
387#[derive(Debug, Serialize, schemars::JsonSchema)]
388pub struct LinkCodeResponse {
389    /// The one-time code, 43 base64url characters — inside Telegram's
390    /// 64-character `start` parameter with room to spare.
391    pub code: String,
392    /// `https://t.me/<bot>?start=<code>`, ready to open. Present only
393    /// when `TELEGRAM_BOT_USERNAME` is configured, because the deep link
394    /// names the bot; a venture that has not named it gets no deep link
395    /// rather than a broken one, and the code pastes into `/start`
396    /// either way.
397    pub deep_link: Option<String>,
398    /// When the code stops working, RFC 3339.
399    pub expires_at: String,
400}
401
402/// Identifies the caller, or answers a 401 problem. An anonymous caller
403/// is refused; a credential that did not verify is an error.
404async fn require_subject(
405    ctx: &ModuleContext,
406    headers: &HeaderMap,
407    scope: &Scope,
408) -> Result<String, Problem> {
409    let auth = ctx
410        .ports
411        .auth
412        .clone()
413        .ok_or_else(|| Problem::new(&UNAUTHENTICATED).instance(&scope.request_id))?;
414    match auth.identify(headers).await {
415        Ok(caller) => caller
416            .id()
417            .map(str::to_owned)
418            .ok_or_else(|| Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
419        // Anonymous, any other caller shape, and a failing authenticator
420        // are all the same answer: this module has nothing to say
421        // without a session, and it does not distinguish them.
422        Err(_) => Err(Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
423    }
424}
425
426/// `POST /v1/telegram/link-codes` — issues a one-time code for the
427/// signed-in caller. The code exists only in the response (and the deep
428/// link it is pasted into); the database holds its SHA-256 hash, so a
429/// database read can never mint a link.
430async fn create_link_code(
431    scope: Scope,
432    State(state): State<Arc<HandlerState>>,
433    headers: HeaderMap,
434) -> Result<Response, Problem> {
435    let subject = require_subject(&state.ctx, &headers, &scope).await?;
436    let Some(db) = state.ctx.ports.db.clone() else {
437        return Err(internal(&scope));
438    };
439    // Fail closed, and loudly: a composition that cannot draw entropy
440    // issues nothing. `self_check` names the missing builder argument.
441    let Some(random) = state.settings.random.clone() else {
442        tracing::error!("telegram: no entropy source configured; no link code can be drawn");
443        return Err(internal(&scope));
444    };
445
446    let mut bytes = [0u8; 32];
447    random.fill(&mut bytes).map_err(|error| {
448        tracing::error!(error = %error, "telegram: the entropy source failed");
449        internal(&scope)
450    })?;
451    let code = BASE64URL.encode(bytes);
452
453    let now = now_of(&state.ctx);
454    let expires_at = now + crate::LINK_CODE_TTL;
455    store::insert_code(
456        &*db,
457        &sha256_hex(&code),
458        &subject,
459        &store::stamp(expires_at),
460    )
461    .await
462    .map_err(|error| db_problem(&error, &scope))?;
463
464    let deep_link = state
465        .ctx
466        .config
467        .get(BOT_USERNAME_KEY)
468        .filter(|username| !username.trim().is_empty())
469        .map(|username| format!("https://t.me/{username}?start={code}"));
470    let body = LinkCodeResponse {
471        code,
472        deep_link,
473        expires_at: store::stamp(expires_at),
474    };
475    let value = serde_json::to_value(body).unwrap_or_default();
476    Ok(Json(value).into_response())
477}
478
479/// `DELETE /v1/telegram/link` — removes the caller's link. Idempotent: a
480/// subject without one still gets `200`.
481async fn unlink(
482    scope: Scope,
483    State(state): State<Arc<HandlerState>>,
484    headers: HeaderMap,
485) -> Result<Response, Problem> {
486    let subject = require_subject(&state.ctx, &headers, &scope).await?;
487    let Some(db) = state.ctx.ports.db.clone() else {
488        return Err(internal(&scope));
489    };
490    let deleted = store::delete_link(&*db, &subject)
491        .await
492        .map_err(|error| db_problem(&error, &scope))?;
493    if deleted > 0 {
494        // The id, never the Telegram identifiers — this line can land in
495        // an operator's logs.
496        tracing::info!(subject = %subject, "telegram: link removed");
497    }
498    Ok(ok())
499}
500
501/// `POST /v1/telegram/actions/{action_id}/confirm` — the web app's half
502/// of a value-moving approval. Telegram put the action into
503/// `awaiting_passkey`; this route is the only thing that can take it
504/// further, and only when the configured [`StepUp`](crate::StepUp)
505/// proves a fresh passkey ceremony for the owning subject.
506async fn confirm(
507    scope: Scope,
508    State(state): State<Arc<HandlerState>>,
509    headers: HeaderMap,
510    Path(action_id): Path<String>,
511) -> Result<Response, Problem> {
512    let subject = require_subject(&state.ctx, &headers, &scope).await?;
513    // Fail closed: no configured step-up means no confirmation, ever.
514    let Some(step_up) = state.settings.step_up.clone() else {
515        return Err(Problem::new(&STEP_UP_REQUIRED).instance(&scope.request_id));
516    };
517    let Some(db) = state.ctx.ports.db.clone() else {
518        return Err(internal(&scope));
519    };
520    let Some(row) = store::action_by_id(&*db, &action_id)
521        .await
522        .map_err(|error| db_problem(&error, &scope))?
523    else {
524        return Err(Problem::new(&NO_SUCH_ACTION).instance(&scope.request_id));
525    };
526    if row.subject != subject {
527        return Err(Problem::new(&NOT_YOURS).instance(&scope.request_id));
528    }
529    let now = now_of(&state.ctx);
530    let now_stamp = store::stamp(now);
531    // RFC 3339 with whole seconds compares lexicographically the way it
532    // compares chronologically — the format every expiry in this module
533    // is written in.
534    if row.status != "awaiting_passkey" || row.expires_at.as_str() <= now_stamp.as_str() {
535        return Err(Problem::new(&NOT_CONFIRMABLE).instance(&scope.request_id));
536    }
537    if !step_up.passkey_verified(&headers, &subject).await {
538        return Err(Problem::new(&STEP_UP_REQUIRED).instance(&scope.request_id));
539    }
540
541    // Single-use: the update that sees one row did the confirming. The
542    // conditional update is the gate — a retry that arrives while the row
543    // is already `approved` is refused below, no matter how many times
544    // the hook is asked.
545    let confirmed = store::confirm_action(&*db, &action_id, &now_stamp)
546        .await
547        .map_err(|error| db_problem(&error, &scope))?;
548    if confirmed != 1 {
549        return Err(Problem::new(&NOT_CONFIRMABLE).instance(&scope.request_id));
550    }
551
552    // The hook runs after the commit. If it fails, the approval is rolled
553    // back — but only while the row still carries the status this request
554    // set — and the `5xx` goes out, so the caller's retry starts again
555    // from `awaiting_passkey` and the decision can actually happen. A
556    // concurrent retry that lost the conditional update was refused above
557    // and touched nothing, so the rollback never fights a winner.
558    let reported = run_hook(
559        state.settings.events.action.as_ref(),
560        &scope,
561        crate::ActionDecision {
562            action_id: action_id.clone(),
563            subject: subject.clone(),
564            action: row.action.clone(),
565            decision: Decision::Approve,
566            passkey_confirmed: true,
567        },
568    )
569    .await;
570    if let Err(error) = reported {
571        let undone = store::revert_confirm(&*db, &action_id).await;
572        log_roll_back(&action_id, "awaiting_passkey", undone);
573        return Err(error);
574    }
575
576    Ok(Json(json!({ "ok": true, "status": "approved" })).into_response())
577}
578
579/// Logs the outcome of the best-effort rollback that follows a failed
580/// hook. The rollback's own failure is logged and the original hook error
581/// still goes out — there is nothing more this request can do, and a
582/// `5xx` either way is the honest answer.
583fn log_roll_back(action_id: &str, prior: &str, undone: Result<u64, DbError>) {
584    match undone {
585        Ok(1) => tracing::warn!(
586            action_id = %action_id,
587            "telegram: a failed hook rolled the action back to {prior}"
588        ),
589        Ok(0) => tracing::warn!(
590            action_id = %action_id,
591            "telegram: a failed hook could not roll the action back; the row had already moved"
592        ),
593        Ok(_) => tracing::error!(
594            action_id = %action_id,
595            "telegram: a rollback touched more than one row"
596        ),
597        Err(error) => tracing::error!(
598            action_id = %action_id,
599            error = %error,
600            "telegram: the rollback after a failed hook failed too"
601        ),
602    }
603}
604
605// ---------------------------------------------------------------------------
606// The linking flow: a `/start <code>` from a private chat
607
608async fn link(state: &HandlerState, scope: &Scope, command: &Command) -> Result<(), Problem> {
609    let Some(db) = state.ctx.ports.db.clone() else {
610        return Err(internal(scope));
611    };
612    let now_stamp = store::stamp(now_of(&state.ctx));
613    // A private chat usually names its sender; an absent one means the
614    // message is not answerable by the person who must be linked.
615    // Claimed with a polite reply rather than a `5xx` — nothing a retry
616    // fixes.
617    let Some(user) = command.message.from.as_ref() else {
618        send_text(
619            state,
620            command.message.chat.id,
621            "I could not tell who sent that. Open a private chat with me and try again.",
622        )
623        .await;
624        return Ok(());
625    };
626
627    let code_hash = sha256_hex(command.args.trim());
628    let consumed = store::consume_code(&*db, &code_hash, &now_stamp)
629        .await
630        .map_err(|error| db_problem(&error, scope))?;
631    if consumed != 1 {
632        send_text(
633            state,
634            command.message.chat.id,
635            "That link code has expired or was already used. Start again from the app.",
636        )
637        .await;
638        return Ok(());
639    }
640
641    // From here the code is spent. Any failure below — a database
642    // statement, or the venture's `on_linked` — puts it back before the
643    // `5xx` leaves, so Telegram's redelivery runs the whole flow again.
644    // The link row is deliberately not undone: the upsert may have
645    // replaced a previous link, which no rollback could restore, and the
646    // retry's upsert writes the same values again — the "already linked
647    // elsewhere" check passes, because the user now links this very
648    // subject — so re-running the flow is clean.
649    let flow = async {
650        let Some(subject) = store::code_subject(&*db, &code_hash)
651            .await
652            .map_err(|error| db_problem(&error, scope))?
653        else {
654            // Unreachable in practice: the code existed a statement ago.
655            return Err(internal(scope));
656        };
657
658        // One Telegram user stands behind at most one account. A user already
659        // linked elsewhere is refused, in the chat — nobody silently
660        // repoints someone else's account by forwarding a deep link.
661        let existing = store::subject_of_telegram_user(&*db, user.id)
662            .await
663            .map_err(|error| db_problem(&error, scope))?;
664        if existing.is_some_and(|linked| linked != subject) {
665            tracing::warn!(
666                subject = %subject,
667                "telegram: a deep link for an already-linked Telegram user was refused"
668            );
669            send_text(
670                state,
671                command.message.chat.id,
672                "This Telegram account is already linked to a different account. \
673                 Unlink it there first, then try again.",
674            )
675            .await;
676            return Ok(());
677        }
678
679        store::upsert_link(&*db, &subject, user.id, command.message.chat.id, &now_stamp)
680            .await
681            .map_err(|error| db_problem(&error, scope))?;
682        send_text(state, command.message.chat.id, "linked").await;
683
684        run_hook(
685            state.settings.events.linked.as_ref(),
686            scope,
687            Linked {
688                subject,
689                telegram_user_id: user.id,
690                chat_id: command.message.chat.id,
691            },
692        )
693        .await
694    };
695
696    match flow.await {
697        Ok(()) => Ok(()),
698        Err(error) => {
699            let undone = store::unconsume_code(&*db, &code_hash).await;
700            match undone {
701                Ok(1) => {
702                    tracing::warn!(
703                        "telegram: a failed linking step put the link code back for the redelivery"
704                    );
705                }
706                Ok(_) => {
707                    tracing::warn!(
708                        "telegram: a failed linking step could not put the code back; it had \
709                         already moved"
710                    );
711                }
712                Err(rollback_error) => {
713                    tracing::error!(
714                        error = %rollback_error,
715                        "telegram: putting the link code back after a failed step failed too"
716                    );
717                }
718            }
719            Err(error)
720        }
721    }
722}
723
724// ---------------------------------------------------------------------------
725// The action-button flow: a callback query whose data carries a token
726
727async fn tap(state: &HandlerState, scope: &Scope, callback: &CallbackQuery) -> Result<(), Problem> {
728    let Some(db) = state.ctx.ports.db.clone() else {
729        return Err(internal(scope));
730    };
731    let Some(key) = action_key_of(state) else {
732        // Fail closed: without the HMAC key no token this deployment
733        // minted could verify, and one that verifies anyway is forged.
734        tracing::error!("telegram: no action secret configured; every tap is refused");
735        answer_callback(
736            state,
737            &callback.id,
738            "This button is not available right now.",
739        )
740        .await;
741        return Ok(());
742    };
743    let now = now_of(&state.ctx);
744    let now_stamp = store::stamp(now);
745
746    let opened = callback
747        .data
748        .as_deref()
749        .and_then(|data| token::open_action_token(&key, data, callback.from.id, now_unix_u32(now)));
750    let Some(VerifiedToken {
751        action_id,
752        decision,
753        ..
754    }) = opened
755    else {
756        // Tampered, expired, or pressed by someone the button was not
757        // minted for (a forwarded button). Refused and acknowledged,
758        // deliberately without saying which.
759        tracing::warn!("telegram: a callback token did not verify; the tap is refused");
760        answer_callback(state, &callback.id, "This button is no longer valid.").await;
761        return Ok(());
762    };
763
764    let Some(row) = store::action_by_id(&*db, &action_id)
765        .await
766        .map_err(|error| db_problem(&error, scope))?
767    else {
768        answer_callback(state, &callback.id, "This request no longer exists.").await;
769        return Ok(());
770    };
771    // The tag already bound the intended Telegram user; this re-checks
772    // against the link as it stands **now**, so a tap by a user who has
773    // since been re-pointed to another account cannot decide this row.
774    // It also resolves the chat the prompt was sent to.
775    let linked = store::link_of_subject(&*db, &row.subject)
776        .await
777        .map_err(|error| db_problem(&error, scope))?;
778    let Some((_, chat_id)) = linked.filter(|(user_id, _)| *user_id == callback.from.id) else {
779        answer_callback(state, &callback.id, "This request is not yours to decide.").await;
780        return Ok(());
781    };
782
783    let target = match (decision, row.value_moving) {
784        (Decision::Deny, _) => "denied",
785        (Decision::Approve, false) => "approved",
786        // A Telegram tap alone never authorises a value-moving action:
787        // it only arms the web app's passkey confirmation, and fires no
788        // hook.
789        (Decision::Approve, true) => "awaiting_passkey",
790    };
791    let decided = store::decide_action(&*db, &action_id, target, &now_stamp)
792        .await
793        .map_err(|error| db_problem(&error, scope))?;
794    if decided != 1 {
795        // A replay, the other button after a decision, or an expired
796        // prompt: the spinner stops, nothing else moves.
797        answer_callback(
798            state,
799            &callback.id,
800            "This request was already decided or has expired.",
801        )
802        .await;
803        return Ok(());
804    }
805
806    if target == "awaiting_passkey" {
807        arm_passkey(state, &callback.id, chat_id, &action_id).await;
808        return Ok(());
809    }
810
811    answer_callback(
812        state,
813        &callback.id,
814        match decision {
815            Decision::Approve => "Approved.",
816            Decision::Deny => "Denied.",
817        },
818    )
819    .await;
820
821    // The hook runs after the decision commits. If it fails, the decision
822    // is rolled back — but only while the row still carries the status
823    // this tap set — and the `5xx` keeps the update unclaimed, so
824    // Telegram's redelivery runs the tap again and the hook again. A
825    // racing replay that lost the conditional update was answered above
826    // and touched nothing, so the rollback never fights a winner.
827    let reported = run_hook(
828        state.settings.events.action.as_ref(),
829        scope,
830        crate::ActionDecision {
831            action_id: action_id.clone(),
832            subject: row.subject.clone(),
833            action: row.action.clone(),
834            decision,
835            passkey_confirmed: false,
836        },
837    )
838    .await;
839    if let Err(error) = reported {
840        let undone = store::revert_tap(&*db, &action_id, target).await;
841        log_roll_back(&action_id, "pending", undone);
842        return Err(error);
843    }
844    Ok(())
845}
846
847/// The value-moving arm of an approve tap: the row is already
848/// `awaiting_passkey` — committed by the caller — and this sends the
849/// instructions. The web app's confirmation URL is a button when
850/// `TELEGRAM_APPROVAL_URL` is set, plain text when it is not (logged as
851/// an error: a broken link is a deployment bug, not a decision to
852/// forgive); the action stays armed either way, and nothing here fires
853/// the venture's hook — only `/confirm` can.
854async fn arm_passkey(state: &HandlerState, callback_query_id: &str, chat_id: i64, action_id: &str) {
855    let instructions = "Approved here — one more step: confirm in the web app with your \
856                        passkey. A tap in Telegram alone never moves value.";
857    if let Some(url) = state
858        .ctx
859        .config
860        .get(APPROVAL_URL_KEY)
861        .filter(|url| !url.trim().is_empty())
862    {
863        // The action id rides the query string: a configured URL that
864        // already carries one is joined with `&`, not a second `?`.
865        let separator = if url.contains('?') { "&" } else { "?" };
866        send_message(
867            state,
868            OutgoingMessage::new(chat_id, instructions).row(vec![InlineButton::url(
869                "Confirm in the web app",
870                format!("{url}{separator}action={action_id}"),
871            )]),
872        )
873        .await;
874    } else {
875        tracing::error!(
876            "telegram: TELEGRAM_APPROVAL_URL is not set; the passkey instructions \
877             were sent without a button"
878        );
879        send_text(state, chat_id, instructions).await;
880    }
881    answer_callback(state, callback_query_id, "Now confirm in the web app.").await;
882}
883
884fn ok() -> Response {
885    Json(json!({ "ok": true })).into_response()
886}