use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, post};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL;
use serde::Serialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use cratefield_adapter_telegram::webhook::{self, WebhookError};
use cratefield_adapter_telegram::{
CallbackQuery, ChatKind, Command, InlineButton, OutgoingMessage, TelegramBot, UpdateKind,
};
use cratefield_core::{
Action, Audience, Clock, DbError, Inbox, Json, ModuleContext, Outcome, Problem, ProblemDef,
RoutePolicy, Scope, Surface, SystemClock,
};
use crate::store;
use crate::token::{self, Decision, VerifiedToken};
use crate::{
APPROVAL_URL_KEY, BOT_USERNAME_KEY, Linked, TelegramHook, TelegramState, WEBHOOK_SECRET_KEY,
};
fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
fn now_unix_u32(now: OffsetDateTime) -> u32 {
u32::try_from(now.unix_timestamp()).unwrap_or(u32::MAX)
}
const UNVERIFIED: ProblemDef = ProblemDef {
slug: "telegram/unverified",
status: StatusCode::UNAUTHORIZED,
title: "Unverified delivery",
description: "The delivery carried no secret token this deployment could verify, or one \
that did not hold.",
};
const UNAUTHENTICATED: ProblemDef = ProblemDef {
slug: "telegram/unauthenticated",
status: StatusCode::UNAUTHORIZED,
title: "Sign in first",
description: "This route is available only to a signed-in user.",
};
const NO_SUCH_ACTION: ProblemDef = ProblemDef {
slug: "telegram/no-such-action",
status: StatusCode::NOT_FOUND,
title: "No such action",
description: "No action with that id exists.",
};
const NOT_YOURS: ProblemDef = ProblemDef {
slug: "telegram/action-not-yours",
status: StatusCode::FORBIDDEN,
title: "Not your action",
description: "Actions are confirmed by the account they were requested for.",
};
const NOT_CONFIRMABLE: ProblemDef = ProblemDef {
slug: "telegram/action-not-confirmable",
status: StatusCode::CONFLICT,
title: "Action cannot be confirmed",
description: "The action is not awaiting passkey confirmation, or it has expired, or it \
was already confirmed.",
};
const STEP_UP_REQUIRED: ProblemDef = ProblemDef {
slug: "telegram/passkey-required",
status: StatusCode::FORBIDDEN,
title: "Passkey confirmation required",
description: "Approving a value-moving action needs a fresh passkey ceremony, and this \
request did not carry one this deployment could verify.",
};
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn db_problem(error: &DbError, scope: &Scope) -> Problem {
tracing::error!(error = %error, "telegram: a database statement failed");
internal(scope)
}
struct HandlerState {
ctx: Arc<ModuleContext>,
settings: TelegramState,
}
pub(crate) fn router(ctx: cratefield_core::ModuleContext, settings: TelegramState) -> axum::Router {
axum::Router::new()
.route("/webhook", post(receive))
.route("/link-codes", post(create_link_code))
.route("/link", delete(unlink))
.route("/actions/{action_id}/confirm", post(confirm))
.with_state(Arc::new(HandlerState {
ctx: Arc::new(ctx),
settings,
}))
}
fn inbox() -> Inbox {
Inbox::new("telegram_inbox")
}
pub(crate) fn surface() -> Surface {
Surface::new()
.action(Action::post("webhook", "/webhook").policy(RoutePolicy::Signature))
.action(
Action::post("create-link-code", "/link-codes")
.audience(Audience::Subject)
.outcome(Outcome::Json)
.output::<LinkCodeResponse>(),
)
.action(Action::delete("delete-link", "/link").audience(Audience::Subject))
.action(
Action::post("confirm-action", "/actions/{action_id}/confirm")
.audience(Audience::Subject)
.outcome(Outcome::Json),
)
}
fn bot_of(state: &HandlerState) -> Option<Arc<dyn TelegramBot>> {
if let Some(bot) = state.settings.bot.clone() {
return Some(bot);
}
let http = state.ctx.ports.http.clone()?;
let clock = state.ctx.ports.clock.clone()?;
let token = state
.ctx
.config
.get(crate::BOT_TOKEN_KEY)
.filter(|token| !token.trim().is_empty())?;
Some(Arc::new(cratefield_adapter_telegram::HttpTelegramBot::new(
http, clock, token,
)))
}
fn action_key_of(state: &HandlerState) -> Option<Vec<u8>> {
state
.ctx
.config
.get(crate::ACTION_SECRET_KEY)
.filter(|secret| !secret.trim().is_empty())
.map(String::into_bytes)
}
async fn send_message(state: &HandlerState, message: OutgoingMessage) {
let Some(bot) = bot_of(state) else {
tracing::warn!(
"telegram: no bot configured (inject .bot(..) or set TELEGRAM_BOT_TOKEN); \
the reply is skipped"
);
return;
};
if let Err(error) = bot.send_message(&message).await {
tracing::warn!(error = %error, "telegram: the reply could not be sent");
}
}
async fn send_text(state: &HandlerState, chat_id: i64, text: &str) {
send_message(state, OutgoingMessage::new(chat_id, text)).await;
}
async fn answer_callback(state: &HandlerState, callback_query_id: &str, text: &str) {
let Some(bot) = bot_of(state) else {
tracing::warn!("telegram: no bot configured; the callback was not acknowledged");
return;
};
if let Err(error) = bot.answer_callback(callback_query_id, Some(text)).await {
tracing::warn!(error = %error, "telegram: answerCallbackQuery failed");
}
}
fn sha256_hex(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes()))
}
async fn run_hook<T: Send + 'static>(
hook: Option<&TelegramHook<T>>,
scope: &Scope,
payload: T,
) -> Result<(), Problem> {
let Some(hook) = hook else {
return Ok(());
};
match hook(payload).await {
Ok(()) => Ok(()),
Err(error) => {
tracing::warn!(error = %error, "telegram: a venture hook failed");
Err(internal(scope))
}
}
}
async fn receive(
State(state): State<Arc<HandlerState>>,
scope: Scope,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, Problem> {
let Some(secret) = state
.ctx
.config
.get(WEBHOOK_SECRET_KEY)
.filter(|secret| !secret.trim().is_empty())
else {
tracing::error!("telegram: no webhook secret configured; every delivery is refused");
return Err(
Problem::not_ready("telegram has no webhook secret configured")
.instance(&scope.request_id),
);
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let now = now_of(&state.ctx);
let update = webhook::parse_verified(&secret, &headers, &body, now.unix_timestamp()).map_err(
|error| match error {
WebhookError::Unverified => Problem::new(&UNVERIFIED).instance(&scope.request_id),
WebhookError::Malformed => {
Problem::validation_failed("the body is not a well-formed Telegram update")
.instance(&scope.request_id)
}
},
)?;
let update_key = update.update_id.to_string();
if inbox()
.seen(&*db, &update_key)
.await
.map_err(|error| db_problem(&error, &scope))?
{
return Ok(ok());
}
dispatch(&state, &scope, &update.kind).await?;
inbox()
.claim(&*db, &update_key, &store::stamp(now))
.await
.map_err(|error| db_problem(&error, &scope))?;
Ok(ok())
}
async fn dispatch(state: &HandlerState, scope: &Scope, kind: &UpdateKind) -> Result<(), Problem> {
match kind {
UpdateKind::Command(command)
if command.name == "start"
&& !command.args.is_empty()
&& command.message.chat.kind == ChatKind::Private =>
{
link(state, scope, command).await
}
UpdateKind::Command(command) => {
run_hook(
state.settings.events.command.as_ref(),
scope,
command.clone(),
)
.await
}
UpdateKind::CallbackQuery(callback) => {
let is_action = callback
.data
.as_deref()
.is_some_and(|data| data.starts_with(token::ACTION_TOKEN_PREFIX));
if is_action {
tap(state, scope, callback).await
} else {
run_hook(
state.settings.events.callback.as_ref(),
scope,
callback.clone(),
)
.await
}
}
UpdateKind::Message(message) => {
run_hook(
state.settings.events.message.as_ref(),
scope,
message.clone(),
)
.await
}
UpdateKind::ChannelPost(posted) => {
run_hook(
state.settings.events.channel_post.as_ref(),
scope,
posted.clone(),
)
.await
}
UpdateKind::Other => Ok(()),
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct LinkCodeResponse {
pub code: String,
pub deep_link: Option<String>,
pub expires_at: String,
}
async fn require_subject(
ctx: &ModuleContext,
headers: &HeaderMap,
scope: &Scope,
) -> Result<String, Problem> {
let auth = ctx
.ports
.auth
.clone()
.ok_or_else(|| Problem::new(&UNAUTHENTICATED).instance(&scope.request_id))?;
match auth.identify(headers).await {
Ok(caller) => caller
.id()
.map(str::to_owned)
.ok_or_else(|| Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
Err(_) => Err(Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
}
}
async fn create_link_code(
scope: Scope,
State(state): State<Arc<HandlerState>>,
headers: HeaderMap,
) -> Result<Response, Problem> {
let subject = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let Some(random) = state.settings.random.clone() else {
tracing::error!("telegram: no entropy source configured; no link code can be drawn");
return Err(internal(&scope));
};
let mut bytes = [0u8; 32];
random.fill(&mut bytes).map_err(|error| {
tracing::error!(error = %error, "telegram: the entropy source failed");
internal(&scope)
})?;
let code = BASE64URL.encode(bytes);
let now = now_of(&state.ctx);
let expires_at = now + crate::LINK_CODE_TTL;
store::insert_code(
&*db,
&sha256_hex(&code),
&subject,
&store::stamp(expires_at),
)
.await
.map_err(|error| db_problem(&error, &scope))?;
let deep_link = state
.ctx
.config
.get(BOT_USERNAME_KEY)
.filter(|username| !username.trim().is_empty())
.map(|username| format!("https://t.me/{username}?start={code}"));
let body = LinkCodeResponse {
code,
deep_link,
expires_at: store::stamp(expires_at),
};
let value = serde_json::to_value(body).unwrap_or_default();
Ok(Json(value).into_response())
}
async fn unlink(
scope: Scope,
State(state): State<Arc<HandlerState>>,
headers: HeaderMap,
) -> Result<Response, Problem> {
let subject = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let deleted = store::delete_link(&*db, &subject)
.await
.map_err(|error| db_problem(&error, &scope))?;
if deleted > 0 {
tracing::info!(subject = %subject, "telegram: link removed");
}
Ok(ok())
}
async fn confirm(
scope: Scope,
State(state): State<Arc<HandlerState>>,
headers: HeaderMap,
Path(action_id): Path<String>,
) -> Result<Response, Problem> {
let subject = require_subject(&state.ctx, &headers, &scope).await?;
let Some(step_up) = state.settings.step_up.clone() else {
return Err(Problem::new(&STEP_UP_REQUIRED).instance(&scope.request_id));
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let Some(row) = store::action_by_id(&*db, &action_id)
.await
.map_err(|error| db_problem(&error, &scope))?
else {
return Err(Problem::new(&NO_SUCH_ACTION).instance(&scope.request_id));
};
if row.subject != subject {
return Err(Problem::new(&NOT_YOURS).instance(&scope.request_id));
}
let now = now_of(&state.ctx);
let now_stamp = store::stamp(now);
if row.status != "awaiting_passkey" || row.expires_at.as_str() <= now_stamp.as_str() {
return Err(Problem::new(&NOT_CONFIRMABLE).instance(&scope.request_id));
}
if !step_up.passkey_verified(&headers, &subject).await {
return Err(Problem::new(&STEP_UP_REQUIRED).instance(&scope.request_id));
}
let confirmed = store::confirm_action(&*db, &action_id, &now_stamp)
.await
.map_err(|error| db_problem(&error, &scope))?;
if confirmed != 1 {
return Err(Problem::new(&NOT_CONFIRMABLE).instance(&scope.request_id));
}
let reported = run_hook(
state.settings.events.action.as_ref(),
&scope,
crate::ActionDecision {
action_id: action_id.clone(),
subject: subject.clone(),
action: row.action.clone(),
decision: Decision::Approve,
passkey_confirmed: true,
},
)
.await;
if let Err(error) = reported {
let undone = store::revert_confirm(&*db, &action_id).await;
log_roll_back(&action_id, "awaiting_passkey", undone);
return Err(error);
}
Ok(Json(json!({ "ok": true, "status": "approved" })).into_response())
}
fn log_roll_back(action_id: &str, prior: &str, undone: Result<u64, DbError>) {
match undone {
Ok(1) => tracing::warn!(
action_id = %action_id,
"telegram: a failed hook rolled the action back to {prior}"
),
Ok(0) => tracing::warn!(
action_id = %action_id,
"telegram: a failed hook could not roll the action back; the row had already moved"
),
Ok(_) => tracing::error!(
action_id = %action_id,
"telegram: a rollback touched more than one row"
),
Err(error) => tracing::error!(
action_id = %action_id,
error = %error,
"telegram: the rollback after a failed hook failed too"
),
}
}
async fn link(state: &HandlerState, scope: &Scope, command: &Command) -> Result<(), Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(scope));
};
let now_stamp = store::stamp(now_of(&state.ctx));
let Some(user) = command.message.from.as_ref() else {
send_text(
state,
command.message.chat.id,
"I could not tell who sent that. Open a private chat with me and try again.",
)
.await;
return Ok(());
};
let code_hash = sha256_hex(command.args.trim());
let consumed = store::consume_code(&*db, &code_hash, &now_stamp)
.await
.map_err(|error| db_problem(&error, scope))?;
if consumed != 1 {
send_text(
state,
command.message.chat.id,
"That link code has expired or was already used. Start again from the app.",
)
.await;
return Ok(());
}
let flow = async {
let Some(subject) = store::code_subject(&*db, &code_hash)
.await
.map_err(|error| db_problem(&error, scope))?
else {
return Err(internal(scope));
};
let existing = store::subject_of_telegram_user(&*db, user.id)
.await
.map_err(|error| db_problem(&error, scope))?;
if existing.is_some_and(|linked| linked != subject) {
tracing::warn!(
subject = %subject,
"telegram: a deep link for an already-linked Telegram user was refused"
);
send_text(
state,
command.message.chat.id,
"This Telegram account is already linked to a different account. \
Unlink it there first, then try again.",
)
.await;
return Ok(());
}
store::upsert_link(&*db, &subject, user.id, command.message.chat.id, &now_stamp)
.await
.map_err(|error| db_problem(&error, scope))?;
send_text(state, command.message.chat.id, "linked").await;
run_hook(
state.settings.events.linked.as_ref(),
scope,
Linked {
subject,
telegram_user_id: user.id,
chat_id: command.message.chat.id,
},
)
.await
};
match flow.await {
Ok(()) => Ok(()),
Err(error) => {
let undone = store::unconsume_code(&*db, &code_hash).await;
match undone {
Ok(1) => {
tracing::warn!(
"telegram: a failed linking step put the link code back for the redelivery"
);
}
Ok(_) => {
tracing::warn!(
"telegram: a failed linking step could not put the code back; it had \
already moved"
);
}
Err(rollback_error) => {
tracing::error!(
error = %rollback_error,
"telegram: putting the link code back after a failed step failed too"
);
}
}
Err(error)
}
}
}
async fn tap(state: &HandlerState, scope: &Scope, callback: &CallbackQuery) -> Result<(), Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(scope));
};
let Some(key) = action_key_of(state) else {
tracing::error!("telegram: no action secret configured; every tap is refused");
answer_callback(
state,
&callback.id,
"This button is not available right now.",
)
.await;
return Ok(());
};
let now = now_of(&state.ctx);
let now_stamp = store::stamp(now);
let opened = callback
.data
.as_deref()
.and_then(|data| token::open_action_token(&key, data, callback.from.id, now_unix_u32(now)));
let Some(VerifiedToken {
action_id,
decision,
..
}) = opened
else {
tracing::warn!("telegram: a callback token did not verify; the tap is refused");
answer_callback(state, &callback.id, "This button is no longer valid.").await;
return Ok(());
};
let Some(row) = store::action_by_id(&*db, &action_id)
.await
.map_err(|error| db_problem(&error, scope))?
else {
answer_callback(state, &callback.id, "This request no longer exists.").await;
return Ok(());
};
let linked = store::link_of_subject(&*db, &row.subject)
.await
.map_err(|error| db_problem(&error, scope))?;
let Some((_, chat_id)) = linked.filter(|(user_id, _)| *user_id == callback.from.id) else {
answer_callback(state, &callback.id, "This request is not yours to decide.").await;
return Ok(());
};
let target = match (decision, row.value_moving) {
(Decision::Deny, _) => "denied",
(Decision::Approve, false) => "approved",
(Decision::Approve, true) => "awaiting_passkey",
};
let decided = store::decide_action(&*db, &action_id, target, &now_stamp)
.await
.map_err(|error| db_problem(&error, scope))?;
if decided != 1 {
answer_callback(
state,
&callback.id,
"This request was already decided or has expired.",
)
.await;
return Ok(());
}
if target == "awaiting_passkey" {
arm_passkey(state, &callback.id, chat_id, &action_id).await;
return Ok(());
}
answer_callback(
state,
&callback.id,
match decision {
Decision::Approve => "Approved.",
Decision::Deny => "Denied.",
},
)
.await;
let reported = run_hook(
state.settings.events.action.as_ref(),
scope,
crate::ActionDecision {
action_id: action_id.clone(),
subject: row.subject.clone(),
action: row.action.clone(),
decision,
passkey_confirmed: false,
},
)
.await;
if let Err(error) = reported {
let undone = store::revert_tap(&*db, &action_id, target).await;
log_roll_back(&action_id, "pending", undone);
return Err(error);
}
Ok(())
}
async fn arm_passkey(state: &HandlerState, callback_query_id: &str, chat_id: i64, action_id: &str) {
let instructions = "Approved here — one more step: confirm in the web app with your \
passkey. A tap in Telegram alone never moves value.";
if let Some(url) = state
.ctx
.config
.get(APPROVAL_URL_KEY)
.filter(|url| !url.trim().is_empty())
{
let separator = if url.contains('?') { "&" } else { "?" };
send_message(
state,
OutgoingMessage::new(chat_id, instructions).row(vec![InlineButton::url(
"Confirm in the web app",
format!("{url}{separator}action={action_id}"),
)]),
)
.await;
} else {
tracing::error!(
"telegram: TELEGRAM_APPROVAL_URL is not set; the passkey instructions \
were sent without a button"
);
send_text(state, chat_id, instructions).await;
}
answer_callback(state, callback_query_id, "Now confirm in the web app.").await;
}
fn ok() -> Response {
Json(json!({ "ok": true })).into_response()
}