Skip to main content

cratefield_module_telegram/
prompt.rs

1//! The issuing half of the action buttons: [`send_action_prompt`], the
2//! call a venture makes when it needs a consent decision.
3//!
4//! Everything the tap side checks is set up here: the row starts
5//! `pending`, the two buttons each carry their own signed token (see
6//! [`token`](crate::token)), and the tokens are bound to the Telegram
7//! user the prompt is sent to — not to the chat, which forwards.
8
9use std::time::Duration as StdDuration;
10
11use time::Duration as TimeDuration;
12use time::OffsetDateTime;
13
14use cratefield_adapter_telegram::{InlineButton, OutgoingMessage, TelegramBot, TelegramError};
15use cratefield_core::{Clock, Database, RandomBytes, RandomError};
16
17use crate::store;
18use crate::token::{Decision, action_token};
19
20/// How long a prompt may live at most: an hour. A consent request that
21/// outlives the conversation it started in is one nobody remembers
22/// making.
23pub const MAX_ACTION_TTL: TimeDuration = TimeDuration::seconds(60 * 60);
24
25/// One consent request, as the venture files it.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ActionRequest {
28    /// The account the action belongs to — the prompt goes to whatever
29    /// Telegram account this subject has linked.
30    pub subject: String,
31    /// The app-defined label of what is being approved. Reported back
32    /// verbatim in the [`crate::ActionDecision`] the venture's `on_action` hook
33    /// receives; this module never interprets it.
34    pub action: String,
35    /// What the person sees in the chat, above the buttons.
36    pub text: String,
37    /// Whether approving moves value (money, access, anything hard to
38    /// take back). A value-moving prompt can only be **approved for
39    /// passkey confirmation** from Telegram: the tap puts it into
40    /// `awaiting_passkey` and the web app's `/confirm` route — a fresh
41    /// passkey ceremony, via the `StepUp` port — finishes it.
42    pub value_moving: bool,
43    /// How long the buttons work. Capped at [`MAX_ACTION_TTL`], floored
44    /// at one second.
45    pub ttl: StdDuration,
46}
47
48/// A prompt Telegram accepted.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ActionPrompt {
51    /// The action's id — the same string the later decision reports, and
52    /// the `{action_id}` of the web app's confirm route when the action
53    /// is value-moving.
54    pub action_id: String,
55    /// The chat the prompt landed in.
56    pub chat_id: i64,
57    /// When the buttons stop working.
58    pub expires_at: OffsetDateTime,
59}
60
61/// Why a prompt could not be issued.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum ActionError {
65    /// The subject has no linked Telegram account. Nobody to ask.
66    NotLinked,
67    /// The entropy source failed; no action id could be drawn.
68    Random(RandomError),
69    /// A database statement failed.
70    Db(cratefield_core::DbError),
71    /// Telegram refused the send.
72    Telegram(TelegramError),
73}
74
75impl std::fmt::Display for ActionError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::NotLinked => f.write_str("the subject has no linked Telegram account"),
79            Self::Random(error) => write!(f, "entropy source failed: {error}"),
80            Self::Db(error) => write!(f, "a database statement failed: {error}"),
81            Self::Telegram(error) => write!(f, "telegram refused the prompt: {error}"),
82        }
83    }
84}
85
86impl std::error::Error for ActionError {}
87
88/// Sends one action prompt to the subject's linked chat: the request's
89/// text with an Approve and a Deny button under it.
90///
91/// The row is inserted before the message is sent, so a Telegram failure
92/// (rate limit, chat gone) leaves a `pending` row that simply expires —
93/// and a venture that retries the call mints a fresh action rather than
94/// re-sending the same one.
95///
96/// # Errors
97///
98/// [`ActionError::NotLinked`] when the subject has no linked account;
99/// [`ActionError::Random`], [`ActionError::Db`] and
100/// [`ActionError::Telegram`] for the failures their names say.
101pub async fn send_action_prompt(
102    db: &dyn Database,
103    clock: &dyn Clock,
104    random: &dyn RandomBytes,
105    bot: &dyn TelegramBot,
106    key: &[u8],
107    request: ActionRequest,
108) -> Result<ActionPrompt, ActionError> {
109    let Some((telegram_user_id, chat_id)) = store::link_of_subject(db, &request.subject)
110        .await
111        .map_err(ActionError::Db)?
112    else {
113        return Err(ActionError::NotLinked);
114    };
115
116    let mut bytes = [0u8; 16];
117    random.fill(&mut bytes).map_err(ActionError::Random)?;
118    let action_id = crate::token::b64url(&bytes);
119
120    let now = clock.now();
121    let ttl = request
122        .ttl
123        .try_into()
124        .unwrap_or(MAX_ACTION_TTL)
125        .clamp(TimeDuration::seconds(1), MAX_ACTION_TTL);
126    let expires_at = now + ttl;
127    // A timestamp past 2106 wraps the u32 the token carries; the clamp
128    // below turns that into "already expired" rather than "lives long".
129    let exp_unix = u32::try_from(expires_at.unix_timestamp()).unwrap_or(0);
130
131    store::insert_action(
132        db,
133        &action_id,
134        &request.subject,
135        &request.action,
136        request.value_moving,
137        &store::stamp(expires_at),
138        &store::stamp(now),
139    )
140    .await
141    .map_err(ActionError::Db)?;
142
143    let approve = action_token(
144        key,
145        &action_id,
146        Decision::Approve,
147        exp_unix,
148        telegram_user_id,
149    );
150    let deny = action_token(key, &action_id, Decision::Deny, exp_unix, telegram_user_id);
151    let message = OutgoingMessage::new(chat_id, request.text.clone()).row(vec![
152        InlineButton::callback("Approve", approve).map_err(ActionError::Telegram)?,
153        InlineButton::callback("Deny", deny).map_err(ActionError::Telegram)?,
154    ]);
155    bot.send_message(&message)
156        .await
157        .map_err(ActionError::Telegram)?;
158
159    Ok(ActionPrompt {
160        action_id,
161        chat_id,
162        expires_at,
163    })
164}