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}