1use 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
46fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
48 ctx.ports
49 .clock
50 .as_ref()
51 .map_or_else(|| SystemClock.now(), |clock| clock.now())
52}
53
54fn now_unix_u32(now: OffsetDateTime) -> u32 {
57 u32::try_from(now.unix_timestamp()).unwrap_or(u32::MAX)
58}
59
60const 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
113fn db_problem(error: &DbError, scope: &Scope) -> Problem {
115 tracing::error!(error = %error, "telegram: a database statement failed");
116 internal(scope)
117}
118
119struct 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
141fn 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
163fn 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
185fn 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
197async 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
213async fn send_text(state: &HandlerState, chat_id: i64, text: &str) {
215 send_message(state, OutgoingMessage::new(chat_id, text)).await;
216}
217
218async 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
230fn sha256_hex(input: &str) -> String {
233 hex::encode(Sha256::digest(input.as_bytes()))
234}
235
236async 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
256async 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 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 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 inbox()
316 .claim(&*db, &update_key, &store::stamp(now))
317 .await
318 .map_err(|error| db_problem(&error, &scope))?;
319 Ok(ok())
320}
321
322async fn dispatch(state: &HandlerState, scope: &Scope, kind: &UpdateKind) -> Result<(), Problem> {
326 match kind {
327 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 UpdateKind::Other => Ok(()),
380 }
381}
382
383#[derive(Debug, Serialize, schemars::JsonSchema)]
388pub struct LinkCodeResponse {
389 pub code: String,
392 pub deep_link: Option<String>,
398 pub expires_at: String,
400}
401
402async 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 Err(_) => Err(Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
423 }
424}
425
426async 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 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
479async 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 tracing::info!(subject = %subject, "telegram: link removed");
497 }
498 Ok(ok())
499}
500
501async 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 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 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 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 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
579fn 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
605async 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 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 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 return Err(internal(scope));
656 };
657
658 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
724async 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 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 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 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 (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 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 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
847async 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 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}