Skip to main content

toptl_teloxide/
lib.rs

1//! Teloxide plugin for [TOP.TL](https://top.tl) — autopost stats, gate
2//! handlers behind votes, handle vote webhooks.
3//!
4//! # Quick start
5//!
6//! ```rust,no_run
7//! use std::time::Duration;
8//! use toptl::TopTL;
9//! use toptl_teloxide::TopTLPlugin;
10//!
11//! #[tokio::main]
12//! async fn main() {
13//!     let client = TopTL::new("toptl_xxx");
14//!     let plugin = TopTLPlugin::new(client, "mybot");
15//!     plugin.start(Duration::from_secs(30 * 60));
16//!
17//!     // Call plugin.record(...) from your handlers, or use
18//!     // record_update(&plugin, &msg).await when the "teloxide"
19//!     // feature is enabled.
20//! }
21//! ```
22
23use std::collections::HashSet;
24use std::sync::Arc;
25use tokio::sync::Mutex;
26use tokio::time::{self, Duration};
27
28pub use toptl::{StatsPayload, TopTL};
29
30/// Tracks unique users / groups / channels and autoposts counts to TOP.TL.
31#[derive(Clone)]
32pub struct TopTLPlugin {
33    client: Arc<TopTL>,
34    username: String,
35    state: Arc<Mutex<PluginState>>,
36}
37
38#[derive(Default)]
39struct PluginState {
40    user_ids: HashSet<i64>,
41    group_ids: HashSet<i64>,
42    channel_ids: HashSet<i64>,
43}
44
45/// Chat kind for [`TopTLPlugin::record`].
46#[derive(Debug, Clone, Copy)]
47pub enum ChatKind {
48    Private,
49    Group,
50    Supergroup,
51    Channel,
52}
53
54impl TopTLPlugin {
55    pub fn new(client: TopTL, username: impl Into<String>) -> Self {
56        Self {
57            client: Arc::new(client),
58            username: username.into(),
59            state: Arc::new(Mutex::new(PluginState::default())),
60        }
61    }
62
63    /// Record one update's IDs into the plugin's counters. Call from
64    /// your handler, or use [`record_update`] when the `teloxide`
65    /// feature is enabled.
66    pub async fn record(&self, user_id: Option<i64>, chat: Option<(i64, ChatKind)>) {
67        let mut state = self.state.lock().await;
68        if let Some(uid) = user_id {
69            state.user_ids.insert(uid);
70        }
71        if let Some((cid, kind)) = chat {
72            match kind {
73                ChatKind::Group | ChatKind::Supergroup => {
74                    state.group_ids.insert(cid);
75                }
76                ChatKind::Channel => {
77                    state.channel_ids.insert(cid);
78                }
79                ChatKind::Private => { /* private chat → already counted as user */ }
80            }
81        }
82    }
83
84    /// Spawn a background task that flushes stats to TOP.TL every
85    /// `interval`. Keep the plugin alive for the lifetime of your bot.
86    pub fn start(&self, interval: Duration) {
87        let client = self.client.clone();
88        let username = self.username.clone();
89        let state = self.state.clone();
90
91        tokio::spawn(async move {
92            let mut ticker = time::interval(interval);
93            // Skip the immediate tick — let the bot collect at least
94            // one update before flushing.
95            ticker.tick().await;
96            loop {
97                ticker.tick().await;
98                let payload = {
99                    let s = state.lock().await;
100                    StatsPayload {
101                        member_count: Some(s.user_ids.len() as u64),
102                        group_count: Some(s.group_ids.len() as u64),
103                        channel_count: Some(s.channel_ids.len() as u64),
104                        bot_serves: None,
105                    }
106                };
107                match client.post_stats(&username, &payload).await {
108                    Ok(_) => log::debug!("toptl: posted stats for @{username}"),
109                    Err(e) => log::warn!("toptl: post_stats for @{username} failed: {e}"),
110                }
111            }
112        });
113    }
114
115    /// Has `user_id` voted for this bot on TOP.TL?
116    /// Network / auth errors fall through as `false` so vote gates
117    /// never block your bot — they're also logged at warn level.
118    pub async fn has_voted(&self, user_id: i64) -> bool {
119        match self.client.has_voted(&self.username, user_id as u64).await {
120            Ok(check) => check.voted,
121            Err(e) => {
122                log::warn!("toptl: has_voted({user_id}) failed: {e}");
123                false
124            }
125        }
126    }
127
128    /// Flush current counts immediately. Useful from a shutdown hook.
129    pub async fn post_now(&self) -> Result<(), toptl::Error> {
130        let payload = {
131            let s = self.state.lock().await;
132            StatsPayload {
133                member_count: Some(s.user_ids.len() as u64),
134                group_count: Some(s.group_ids.len() as u64),
135                channel_count: Some(s.channel_ids.len() as u64),
136                bot_serves: None,
137            }
138        };
139        self.client.post_stats(&self.username, &payload).await?;
140        Ok(())
141    }
142}
143
144/// Helper that records every teloxide `Message` into the plugin.
145///
146/// ```rust,no_run
147/// use teloxide::prelude::*;
148/// use toptl_teloxide::{record_update, TopTLPlugin};
149///
150/// async fn handler(plugin: TopTLPlugin, msg: Message) {
151///     record_update(&plugin, &msg).await;
152///     // your handler logic …
153/// }
154/// ```
155#[cfg(feature = "teloxide")]
156pub async fn record_update(plugin: &TopTLPlugin, msg: &teloxide::types::Message) {
157    let user_id = msg.from.as_ref().map(|u| u.id.0 as i64);
158    let kind = match msg.chat.kind {
159        teloxide::types::ChatKind::Private(_) => ChatKind::Private,
160        teloxide::types::ChatKind::Public(ref p) => match p.kind {
161            teloxide::types::PublicChatKind::Group(_) => ChatKind::Group,
162            teloxide::types::PublicChatKind::Supergroup(_) => ChatKind::Supergroup,
163            teloxide::types::PublicChatKind::Channel(_) => ChatKind::Channel,
164        },
165    };
166    plugin.record(user_id, Some((msg.chat.id.0, kind))).await;
167}