1#![forbid(unsafe_code)]
34#![doc = include_str!("../README.md")]
35
36#[cfg(feature = "notifications")]
37mod channel;
38mod handlers;
39mod prompt;
40mod step_up;
41mod store;
42mod token;
43
44#[cfg(feature = "notifications")]
45pub use crate::channel::TelegramChannel;
46pub use crate::handlers::LinkCodeResponse;
47pub use crate::prompt::{
48 ActionError, ActionPrompt, ActionRequest, MAX_ACTION_TTL, send_action_prompt,
49};
50pub use crate::step_up::StepUp;
51pub use crate::token::{
52 ACTION_TOKEN_PREFIX, Decision, VerifiedToken, action_key, action_token, open_action_token,
53};
54
55use std::sync::Arc;
56
57use time::Duration as TimeDuration;
58
59use cratefield_adapter_telegram::{CallbackQuery, ChannelPost, Command, Message, TelegramBot};
60use cratefield_core::{
61 AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
62 ModuleConfig, ModuleContext, PersonalDataSet, Port, RandomBytes, SignatureVerification,
63 SqlMigration,
64};
65
66pub const MODULE_NAME: &str = "telegram";
69
70pub const WEBHOOK_SECRET_KEY: &str = "TELEGRAM_WEBHOOK_SECRET";
75
76const SECRET_KEY_SUFFIX: &str = "WEBHOOK_SECRET";
80
81pub const BOT_TOKEN_KEY: &str = "TELEGRAM_BOT_TOKEN";
85
86pub const BOT_USERNAME_KEY: &str = "TELEGRAM_BOT_USERNAME";
91
92pub const ACTION_SECRET_KEY: &str = "TELEGRAM_ACTION_SECRET";
96
97pub const APPROVAL_URL_KEY: &str = "TELEGRAM_APPROVAL_URL";
102
103pub const LINK_CODE_TTL: TimeDuration = TimeDuration::seconds(10 * 60);
107
108const TABLES: &[&str] = &[
110 "telegram_inbox",
111 "telegram_link_codes",
112 "telegram_links",
113 "telegram_actions",
114];
115
116const MIGRATION_INIT: SqlMigration = SqlMigration::new(
118 "0001",
119 "init",
120 include_str!("../migrations/sqlite/0001_init.sql"),
121);
122
123pub type TelegramHook<T> = Arc<dyn Fn(T) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync>;
127
128#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub struct Linked {
132 pub subject: String,
134 pub telegram_user_id: i64,
136 pub chat_id: i64,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152pub struct ActionDecision {
153 pub action_id: String,
156 pub subject: String,
158 pub action: String,
160 pub decision: Decision,
162 pub passkey_confirmed: bool,
165}
166
167#[derive(Clone, Default)]
173pub struct TelegramEvents {
174 message: Option<TelegramHook<Message>>,
175 command: Option<TelegramHook<Command>>,
176 callback: Option<TelegramHook<CallbackQuery>>,
177 channel_post: Option<TelegramHook<ChannelPost>>,
178 linked: Option<TelegramHook<Linked>>,
179 action: Option<TelegramHook<ActionDecision>>,
180}
181
182impl std::fmt::Debug for TelegramEvents {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("TelegramEvents")
187 .field("on_message", &self.message.is_some())
188 .field("on_command", &self.command.is_some())
189 .field("on_callback", &self.callback.is_some())
190 .field("on_channel_post", &self.channel_post.is_some())
191 .field("on_linked", &self.linked.is_some())
192 .field("on_action", &self.action.is_some())
193 .finish()
194 }
195}
196
197impl TelegramEvents {
198 #[must_use]
199 pub fn new() -> Self {
200 Self::default()
201 }
202
203 #[must_use]
207 pub fn on_message(
208 mut self,
209 hook: impl Fn(Message) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
210 ) -> Self {
211 self.message = Some(Arc::new(hook));
212 self
213 }
214
215 #[must_use]
219 pub fn on_command(
220 mut self,
221 hook: impl Fn(Command) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
222 ) -> Self {
223 self.command = Some(Arc::new(hook));
224 self
225 }
226
227 #[must_use]
230 pub fn on_callback(
231 mut self,
232 hook: impl Fn(CallbackQuery) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
233 ) -> Self {
234 self.callback = Some(Arc::new(hook));
235 self
236 }
237
238 #[must_use]
240 pub fn on_channel_post(
241 mut self,
242 hook: impl Fn(ChannelPost) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
243 ) -> Self {
244 self.channel_post = Some(Arc::new(hook));
245 self
246 }
247
248 #[must_use]
251 pub fn on_linked(
252 mut self,
253 hook: impl Fn(Linked) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
254 ) -> Self {
255 self.linked = Some(Arc::new(hook));
256 self
257 }
258
259 #[must_use]
264 pub fn on_action(
265 mut self,
266 hook: impl Fn(ActionDecision) -> BoxFuture<'static, Result<(), AnyError>>
267 + Send
268 + Sync
269 + 'static,
270 ) -> Self {
271 self.action = Some(Arc::new(hook));
272 self
273 }
274}
275
276#[derive(Clone)]
279pub(crate) struct TelegramState {
280 pub(crate) events: TelegramEvents,
281 pub(crate) bot: Option<Arc<dyn TelegramBot>>,
282 pub(crate) random: Option<Arc<dyn RandomBytes>>,
283 pub(crate) step_up: Option<Arc<dyn StepUp>>,
284}
285
286impl std::fmt::Debug for TelegramState {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_struct("TelegramState")
290 .field("events", &self.events)
291 .field("bot", &self.bot.is_some())
292 .field("random", &self.random.is_some())
293 .field("step_up", &self.step_up.is_some())
294 .finish()
295 }
296}
297
298#[derive(Default, Clone)]
301pub struct Telegram {
302 events: TelegramEvents,
303 bot: Option<Arc<dyn TelegramBot>>,
304 random: Option<Arc<dyn RandomBytes>>,
305 step_up: Option<Arc<dyn StepUp>>,
306}
307
308impl std::fmt::Debug for Telegram {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 f.debug_struct("Telegram")
312 .field("events", &self.events)
313 .field("bot", &self.bot.is_some())
314 .field("random", &self.random.is_some())
315 .field("step_up", &self.step_up.is_some())
316 .finish()
317 }
318}
319
320impl Telegram {
321 #[must_use]
324 pub fn new(events: TelegramEvents) -> Self {
325 Self {
326 events,
327 bot: None,
328 random: None,
329 step_up: None,
330 }
331 }
332
333 #[must_use]
340 pub fn bot(mut self, bot: Arc<dyn TelegramBot>) -> Self {
341 self.bot = Some(bot);
342 self
343 }
344
345 #[must_use]
349 pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
350 self.random = Some(Arc::new(random));
351 self
352 }
353
354 #[must_use]
358 pub fn step_up(mut self, step_up: Arc<dyn StepUp>) -> Self {
359 self.step_up = Some(step_up);
360 self
361 }
362
363 fn state(&self) -> TelegramState {
364 TelegramState {
365 events: self.events.clone(),
366 bot: self.bot.clone(),
367 random: self.random.clone(),
368 step_up: self.step_up.clone(),
369 }
370 }
371}
372
373impl Module for Telegram {
374 fn name(&self) -> &'static str {
375 MODULE_NAME
376 }
377
378 fn version(&self) -> &'static str {
379 env!("CARGO_PKG_VERSION")
380 }
381
382 fn requires(&self) -> &'static [Port] {
383 &[Port::Db, Port::Clock]
386 }
387
388 fn optional(&self) -> &'static [Port] {
389 &[Port::Auth, Port::HttpClient]
394 }
395
396 fn tables(&self) -> &'static [&'static str] {
397 TABLES
398 }
399
400 fn personal_data(&self) -> &'static [PersonalDataSet] {
401 const SETS: &[PersonalDataSet] = &[
402 PersonalDataSet::none(
403 "telegram_inbox",
404 "The ids of the Telegram updates this venture has already processed — a dedup \
405 ledger, so a provider that delivers twice is processed once. It holds update \
406 ids and timestamps and nothing about a person.",
407 ),
408 PersonalDataSet {
409 table: "telegram_link_codes",
410 subject: "subject",
411 kind: DataKind::Identifier,
412 disposition: Disposition::Erase,
413 description: "One-time codes issued so a person can connect their Telegram \
414 account, held only as SHA-256 hashes alongside who they were issued to and \
415 when they expire. Erasing a person's rows deletes their outstanding codes; \
416 an expired or used code is deleted by nothing here — the row is kept so a \
417 replay stays refused, and holds no secret.",
418 redacted: &[],
419 subject_via: None,
420 },
421 PersonalDataSet {
422 table: "telegram_links",
423 subject: "subject",
424 kind: DataKind::Identifier,
425 disposition: Disposition::Erase,
426 description: "The link between an account and a Telegram user: the Telegram \
427 user id and the chat id messages are sent to, and when the link was made. \
428 Erasing a person's rows unlinks them — nothing more is sent to that chat. \
429 Messages already delivered to the chat live in Telegram, not here.",
430 redacted: &[],
431 subject_via: None,
432 },
433 PersonalDataSet {
434 table: "telegram_actions",
435 subject: "subject",
436 kind: DataKind::Usage,
437 disposition: Disposition::Erase,
438 description: "The consent requests a person was shown in Telegram: what they \
439 were asked to approve, whether it moves value, and how it was decided. \
440 Erasing a person's rows deletes that record of what they were asked and \
441 answered.",
442 redacted: &[],
443 subject_via: None,
444 },
445 ];
446 SETS
447 }
448
449 fn emits(&self) -> &'static [&'static str] {
450 &[]
451 }
452
453 fn signature_verification(&self) -> SignatureVerification {
456 SignatureVerification::Hmac {
457 secret: SECRET_KEY_SUFFIX,
458 }
459 }
460
461 fn migrations(&self) -> Migrations {
462 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
463 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
464 Migrations::sqlite(&MIGRATIONS)
465 }
466
467 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
468 let module = ModuleConfig::new(MODULE_NAME, cfg);
473 let mut errors = ConfigError::default();
474
475 for (suffix, full, what) in [
476 (
477 SECRET_KEY_SUFFIX,
478 WEBHOOK_SECRET_KEY,
479 "the webhook shared secret",
480 ),
481 ("BOT_TOKEN", BOT_TOKEN_KEY, "the bot token"),
482 ("BOT_USERNAME", BOT_USERNAME_KEY, "the bot username"),
483 (
484 "ACTION_SECRET",
485 ACTION_SECRET_KEY,
486 "the action signing secret",
487 ),
488 ("APPROVAL_URL", APPROVAL_URL_KEY, "the approval URL"),
489 ] {
490 if let Some(value) = cfg.get(&module.key(suffix))
491 && value.trim().is_empty()
492 {
493 errors.push(format!(
494 "telegram: {full} is set but blank; unset it or give {what}"
495 ));
496 }
497 }
498
499 if let Some(secret) = cfg.get(&module.key(SECRET_KEY_SUFFIX))
503 && !secret.trim().is_empty()
504 && !cratefield_adapter_telegram::webhook::is_valid_secret(&secret)
505 {
506 errors.push(
507 "telegram: TELEGRAM_WEBHOOK_SECRET must be 1-256 characters of A-Z a-z 0-9 _ - \
508 — Telegram refuses anything else for its secret token"
509 .to_owned(),
510 );
511 }
512
513 errors.into_result()
514 }
515
516 fn self_check(&self) -> Vec<String> {
519 let mut problems = Vec::new();
520 if self.random.is_none() {
521 problems.push(
522 "telegram: no entropy source is configured, so no link code or action id can \
523 be drawn — add `.random(impl RandomBytes)` to the module"
524 .to_owned(),
525 );
526 }
527 problems
528 }
529
530 fn router(&self, ctx: ModuleContext) -> axum::Router {
531 handlers::router(ctx, self.state())
532 }
533
534 fn surface(&self) -> cratefield_core::Surface {
535 handlers::surface()
536 }
537}