#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#[cfg(feature = "notifications")]
mod channel;
mod handlers;
mod prompt;
mod step_up;
mod store;
mod token;
#[cfg(feature = "notifications")]
pub use crate::channel::TelegramChannel;
pub use crate::handlers::LinkCodeResponse;
pub use crate::prompt::{
ActionError, ActionPrompt, ActionRequest, MAX_ACTION_TTL, send_action_prompt,
};
pub use crate::step_up::StepUp;
pub use crate::token::{
ACTION_TOKEN_PREFIX, Decision, VerifiedToken, action_key, action_token, open_action_token,
};
use std::sync::Arc;
use time::Duration as TimeDuration;
use cratefield_adapter_telegram::{CallbackQuery, ChannelPost, Command, Message, TelegramBot};
use cratefield_core::{
AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
ModuleConfig, ModuleContext, PersonalDataSet, Port, RandomBytes, SignatureVerification,
SqlMigration,
};
pub const MODULE_NAME: &str = "telegram";
pub const WEBHOOK_SECRET_KEY: &str = "TELEGRAM_WEBHOOK_SECRET";
const SECRET_KEY_SUFFIX: &str = "WEBHOOK_SECRET";
pub const BOT_TOKEN_KEY: &str = "TELEGRAM_BOT_TOKEN";
pub const BOT_USERNAME_KEY: &str = "TELEGRAM_BOT_USERNAME";
pub const ACTION_SECRET_KEY: &str = "TELEGRAM_ACTION_SECRET";
pub const APPROVAL_URL_KEY: &str = "TELEGRAM_APPROVAL_URL";
pub const LINK_CODE_TTL: TimeDuration = TimeDuration::seconds(10 * 60);
const TABLES: &[&str] = &[
"telegram_inbox",
"telegram_link_codes",
"telegram_links",
"telegram_actions",
];
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
pub type TelegramHook<T> = Arc<dyn Fn(T) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Linked {
pub subject: String,
pub telegram_user_id: i64,
pub chat_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ActionDecision {
pub action_id: String,
pub subject: String,
pub action: String,
pub decision: Decision,
pub passkey_confirmed: bool,
}
#[derive(Clone, Default)]
pub struct TelegramEvents {
message: Option<TelegramHook<Message>>,
command: Option<TelegramHook<Command>>,
callback: Option<TelegramHook<CallbackQuery>>,
channel_post: Option<TelegramHook<ChannelPost>>,
linked: Option<TelegramHook<Linked>>,
action: Option<TelegramHook<ActionDecision>>,
}
impl std::fmt::Debug for TelegramEvents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelegramEvents")
.field("on_message", &self.message.is_some())
.field("on_command", &self.command.is_some())
.field("on_callback", &self.callback.is_some())
.field("on_channel_post", &self.channel_post.is_some())
.field("on_linked", &self.linked.is_some())
.field("on_action", &self.action.is_some())
.finish()
}
}
impl TelegramEvents {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn on_message(
mut self,
hook: impl Fn(Message) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
) -> Self {
self.message = Some(Arc::new(hook));
self
}
#[must_use]
pub fn on_command(
mut self,
hook: impl Fn(Command) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
) -> Self {
self.command = Some(Arc::new(hook));
self
}
#[must_use]
pub fn on_callback(
mut self,
hook: impl Fn(CallbackQuery) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
) -> Self {
self.callback = Some(Arc::new(hook));
self
}
#[must_use]
pub fn on_channel_post(
mut self,
hook: impl Fn(ChannelPost) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
) -> Self {
self.channel_post = Some(Arc::new(hook));
self
}
#[must_use]
pub fn on_linked(
mut self,
hook: impl Fn(Linked) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync + 'static,
) -> Self {
self.linked = Some(Arc::new(hook));
self
}
#[must_use]
pub fn on_action(
mut self,
hook: impl Fn(ActionDecision) -> BoxFuture<'static, Result<(), AnyError>>
+ Send
+ Sync
+ 'static,
) -> Self {
self.action = Some(Arc::new(hook));
self
}
}
#[derive(Clone)]
pub(crate) struct TelegramState {
pub(crate) events: TelegramEvents,
pub(crate) bot: Option<Arc<dyn TelegramBot>>,
pub(crate) random: Option<Arc<dyn RandomBytes>>,
pub(crate) step_up: Option<Arc<dyn StepUp>>,
}
impl std::fmt::Debug for TelegramState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelegramState")
.field("events", &self.events)
.field("bot", &self.bot.is_some())
.field("random", &self.random.is_some())
.field("step_up", &self.step_up.is_some())
.finish()
}
}
#[derive(Default, Clone)]
pub struct Telegram {
events: TelegramEvents,
bot: Option<Arc<dyn TelegramBot>>,
random: Option<Arc<dyn RandomBytes>>,
step_up: Option<Arc<dyn StepUp>>,
}
impl std::fmt::Debug for Telegram {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Telegram")
.field("events", &self.events)
.field("bot", &self.bot.is_some())
.field("random", &self.random.is_some())
.field("step_up", &self.step_up.is_some())
.finish()
}
}
impl Telegram {
#[must_use]
pub fn new(events: TelegramEvents) -> Self {
Self {
events,
bot: None,
random: None,
step_up: None,
}
}
#[must_use]
pub fn bot(mut self, bot: Arc<dyn TelegramBot>) -> Self {
self.bot = Some(bot);
self
}
#[must_use]
pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
self.random = Some(Arc::new(random));
self
}
#[must_use]
pub fn step_up(mut self, step_up: Arc<dyn StepUp>) -> Self {
self.step_up = Some(step_up);
self
}
fn state(&self) -> TelegramState {
TelegramState {
events: self.events.clone(),
bot: self.bot.clone(),
random: self.random.clone(),
step_up: self.step_up.clone(),
}
}
}
impl Module for Telegram {
fn name(&self) -> &'static str {
MODULE_NAME
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db, Port::Clock]
}
fn optional(&self) -> &'static [Port] {
&[Port::Auth, Port::HttpClient]
}
fn tables(&self) -> &'static [&'static str] {
TABLES
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
const SETS: &[PersonalDataSet] = &[
PersonalDataSet::none(
"telegram_inbox",
"The ids of the Telegram updates this venture has already processed — a dedup \
ledger, so a provider that delivers twice is processed once. It holds update \
ids and timestamps and nothing about a person.",
),
PersonalDataSet {
table: "telegram_link_codes",
subject: "subject",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "One-time codes issued so a person can connect their Telegram \
account, held only as SHA-256 hashes alongside who they were issued to and \
when they expire. Erasing a person's rows deletes their outstanding codes; \
an expired or used code is deleted by nothing here — the row is kept so a \
replay stays refused, and holds no secret.",
redacted: &[],
subject_via: None,
},
PersonalDataSet {
table: "telegram_links",
subject: "subject",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "The link between an account and a Telegram user: the Telegram \
user id and the chat id messages are sent to, and when the link was made. \
Erasing a person's rows unlinks them — nothing more is sent to that chat. \
Messages already delivered to the chat live in Telegram, not here.",
redacted: &[],
subject_via: None,
},
PersonalDataSet {
table: "telegram_actions",
subject: "subject",
kind: DataKind::Usage,
disposition: Disposition::Erase,
description: "The consent requests a person was shown in Telegram: what they \
were asked to approve, whether it moves value, and how it was decided. \
Erasing a person's rows deletes that record of what they were asked and \
answered.",
redacted: &[],
subject_via: None,
},
];
SETS
}
fn emits(&self) -> &'static [&'static str] {
&[]
}
fn signature_verification(&self) -> SignatureVerification {
SignatureVerification::Hmac {
secret: SECRET_KEY_SUFFIX,
}
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations::sqlite(&MIGRATIONS)
}
fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
let module = ModuleConfig::new(MODULE_NAME, cfg);
let mut errors = ConfigError::default();
for (suffix, full, what) in [
(
SECRET_KEY_SUFFIX,
WEBHOOK_SECRET_KEY,
"the webhook shared secret",
),
("BOT_TOKEN", BOT_TOKEN_KEY, "the bot token"),
("BOT_USERNAME", BOT_USERNAME_KEY, "the bot username"),
(
"ACTION_SECRET",
ACTION_SECRET_KEY,
"the action signing secret",
),
("APPROVAL_URL", APPROVAL_URL_KEY, "the approval URL"),
] {
if let Some(value) = cfg.get(&module.key(suffix))
&& value.trim().is_empty()
{
errors.push(format!(
"telegram: {full} is set but blank; unset it or give {what}"
));
}
}
if let Some(secret) = cfg.get(&module.key(SECRET_KEY_SUFFIX))
&& !secret.trim().is_empty()
&& !cratefield_adapter_telegram::webhook::is_valid_secret(&secret)
{
errors.push(
"telegram: TELEGRAM_WEBHOOK_SECRET must be 1-256 characters of A-Z a-z 0-9 _ - \
— Telegram refuses anything else for its secret token"
.to_owned(),
);
}
errors.into_result()
}
fn self_check(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.random.is_none() {
problems.push(
"telegram: no entropy source is configured, so no link code or action id can \
be drawn — add `.random(impl RandomBytes)` to the module"
.to_owned(),
);
}
problems
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(ctx, self.state())
}
fn surface(&self) -> cratefield_core::Surface {
handlers::surface()
}
}