Skip to main content

ferogram/
peer_cache.rs

1// Copyright (c) Ankit Chaubey <ankitchaubey.dev@gmail.com>
2//
3// ferogram: async Telegram MTProto client in Rust
4// https://github.com/ankit-chaubey/ferogram
5//
6// Licensed under either the MIT License or the Apache License 2.0.
7// See the LICENSE-MIT or LICENSE-APACHE file in this repository:
8// https://github.com/ankit-chaubey/ferogram
9//
10// Feel free to use, modify, and share this code.
11// Please keep this notice when redistributing.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15
16use ferogram_tl_types as tl;
17
18use crate::errors::InvocationError;
19pub use crate::types::ChannelKind;
20
21impl From<ferogram_session::ChannelKind> for ChannelKind {
22    fn from(k: ferogram_session::ChannelKind) -> Self {
23        match k {
24            ferogram_session::ChannelKind::Broadcast => ChannelKind::Broadcast,
25            ferogram_session::ChannelKind::Megagroup => ChannelKind::Megagroup,
26            ferogram_session::ChannelKind::Gigagroup => ChannelKind::Gigagroup,
27        }
28    }
29}
30
31impl From<ChannelKind> for ferogram_session::ChannelKind {
32    fn from(k: ChannelKind) -> Self {
33        match k {
34            ChannelKind::Broadcast => ferogram_session::ChannelKind::Broadcast,
35            ChannelKind::Megagroup => ferogram_session::ChannelKind::Megagroup,
36            ChannelKind::Gigagroup => ferogram_session::ChannelKind::Gigagroup,
37        }
38    }
39}
40
41/// A batch-scoped, read-only map from channel ID to the raw TL chat object.
42///
43/// Built once per update batch from the `chats` vec and shared (cheaply via
44/// `Arc` refcount) across every `IncomingMessage` produced in that batch.
45/// When the last message is dropped the map is freed automatically.
46pub type PeerMap = Arc<HashMap<i64, tl::enums::Chat>>;
47
48/// Build a `PeerMap` from a slice of TL chat objects.
49///
50/// Silently ignores `Chat::Empty` and any entry without an ID.
51pub fn build_peer_map(chats: &[tl::enums::Chat]) -> Option<PeerMap> {
52    if chats.is_empty() {
53        return None;
54    }
55    let mut map = HashMap::with_capacity(chats.len());
56    for chat in chats {
57        let id = match chat {
58            tl::enums::Chat::Channel(c) => c.id,
59            tl::enums::Chat::ChannelForbidden(c) => c.id,
60            tl::enums::Chat::Chat(c) => c.id,
61            tl::enums::Chat::Forbidden(c) => c.id,
62            tl::enums::Chat::Empty(_) => continue,
63        };
64        map.insert(id, chat.clone());
65    }
66    if map.is_empty() {
67        None
68    } else {
69        Some(Arc::new(map))
70    }
71}
72
73/// Opt-in experimental behaviours that deviate from strict Telegram spec.
74///
75/// All flags default to `false` (safe / spec-correct).  Enable only what you
76/// need after reading the per-field warnings.
77///
78/// # Example
79/// ```rust,no_run
80/// use ferogram::{Client, ExperimentalFeatures};
81///
82/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
83/// let (client, _sd) = Client::builder()
84///     .api_id(12345)
85///     .api_hash("abc")
86///     .experimental_features(ExperimentalFeatures {
87///         allow_zero_hash: true,   // bot-only; omit for user accounts
88///         ..Default::default()
89///     })
90///     .connect().await?;
91/// # Ok(()) }
92/// ```
93#[derive(Clone, Debug, Default)]
94pub struct ExperimentalFeatures {
95    /// When no `access_hash` is cached for a user or channel, fall back to
96    /// `access_hash = 0` instead of returning [`InvocationError::PeerNotCached`].
97    ///
98    /// **Bot accounts only.** The Telegram spec explicitly permits `hash = 0`
99    /// for bots when only a min-hash is available.  On user accounts this
100    /// produces `USER_ID_INVALID` / `CHANNEL_INVALID`.
101    pub allow_zero_hash: bool,
102
103    /// When resolving a min-user via `InputPeerUserFromMessage`, if the
104    /// containing channel's hash is not cached, proceed with
105    /// `channel access_hash = 0` instead of returning
106    /// [`InvocationError::PeerNotCached`].
107    ///
108    /// Almost always wrong.  The inner `InputPeerChannel { access_hash: 0 }`
109    /// makes the whole `InputPeerUserFromMessage` invalid and Telegram will
110    /// reject it.  Only useful for debugging / testing.
111    pub allow_missing_channel_hash: bool,
112
113    /// When `access_hash` is missing for a channel during `getChannelDifference`,
114    /// call `channels.getChannels` with `access_hash = 0` to fetch it, cache it,
115    /// and retry the diff in the same loop iteration.
116    ///
117    /// When false (the default), the diff is deferred: the entry stays alive and
118    /// the diff retries naturally once the hash arrives via a future update's
119    /// entity list.
120    ///
121    /// **Bot accounts only** for reliable operation. On user accounts
122    /// `channels.getChannels { access_hash: 0 }` succeeds only for public channels
123    /// and channels you are currently a member of.
124    pub auto_resolve_peers: bool,
125
126    /// Enable resumable uploads and downloads.
127    ///
128    /// When `true`, interrupted transfers save a checkpoint under
129    /// `checkpoint_dir` (defaults to `.ferogram-transfers/` next to the
130    /// session file). The next call with the same media / file automatically
131    /// resumes from where it left off.
132    ///
133    /// Upload sessions are valid for ~1 hour on Telegram's side; if more time
134    /// has passed the upload restarts from scratch automatically.
135    ///
136    /// Default: `false`.
137    pub resumable_transfers: bool,
138
139    /// Directory for transfer checkpoints when `resumable_transfers` is enabled.
140    /// If `None`, defaults to `.ferogram-transfers/` next to the session file.
141    pub checkpoint_dir: Option<std::path::PathBuf>,
142}
143
144/// Caches access hashes for users and channels so every API call carries the
145/// correct hash without re-resolving peers.
146/// Discriminates the kind of peer stored in `PeerCache::username_to_peer`.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub enum PeerType {
149    User,
150    Channel,
151    Chat,
152}
153
154///
155/// All fields are `pub` so that `save_session` / `connect` can read/write them
156/// directly, and so that advanced callers can inspect the cache.
157pub struct PeerCache {
158    /// user_id -> access_hash (full users only, min=false)
159    pub users: HashMap<i64, i64>,
160    /// channel_id -> (access_hash, `Option<ChannelKind>`) (full channels only, min=false)
161    pub channels: HashMap<i64, (i64, Option<ChannelKind>)>,
162    /// Regular group chat IDs (Chat::Chat / ChatForbidden).
163    /// Groups need no access_hash; track existence for peer validation.
164    pub chats: HashSet<i64>,
165    /// Channel IDs seen with min=true. These are real channels but have no
166    /// valid access_hash. Stored separately so they are NEVER confused with
167    /// regular groups. DO NOT put min channels in `chats`. A min channel must
168    /// never become InputPeerChat  - that causes fatal RPC failures.
169    pub channels_min: HashSet<i64>,
170    /// user_id -> (peer_id, msg_id) for min users seen in a message context.
171    /// Min users have an invalid access_hash; they must be referenced via
172    /// InputPeerUserFromMessage using the peer and message where they appeared.
173    pub min_contexts: HashMap<i64, (i64, i32)>,
174    /// Reverse index: lowercase username → (id, PeerType).
175    /// Populated by cache_user / cache_chat; always overwritten on update
176    /// (usernames can change).
177    pub username_to_peer: HashMap<String, (i64, PeerType)>,
178    /// Reverse index: E.164 phone → user_id.
179    pub phone_to_user: HashMap<String, i64>,
180    /// Experimental opt-ins that change error-vs-fallback behaviour.
181    pub(crate) experimental: ExperimentalFeatures,
182}
183
184impl Default for PeerCache {
185    fn default() -> Self {
186        Self::new(ExperimentalFeatures::default())
187    }
188}
189
190impl PeerCache {
191    /// Create a new empty cache with the given experimental-feature flags.
192    pub fn new(experimental: ExperimentalFeatures) -> Self {
193        Self {
194            users: HashMap::new(),
195            channels: HashMap::new(),
196            chats: HashSet::new(),
197            channels_min: HashSet::new(),
198            min_contexts: HashMap::new(),
199            username_to_peer: HashMap::new(),
200            phone_to_user: HashMap::new(),
201            experimental,
202        }
203    }
204
205    pub fn cache_user(&mut self, user: &tl::enums::User) {
206        if let tl::enums::User::User(u) = user {
207            if u.min {
208                // min=true: access_hash is not valid; requires a message context.
209            } else if let Some(hash) = u.access_hash {
210                // Never overwrite a valid non-zero hash with zero.
211                if hash != 0 {
212                    self.users.insert(u.id, hash);
213                } else {
214                    self.users.entry(u.id).or_insert(0);
215                }
216                // Full user always supersedes any min context.
217                self.min_contexts.remove(&u.id);
218            }
219            // Reverse indices (update even for min users so username lookup works)
220            if let Some(ref uname) = u.username {
221                self.username_to_peer
222                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
223            }
224            if let Some(ref phone) = u.phone {
225                self.phone_to_user.insert(phone.clone(), u.id);
226            }
227        }
228    }
229
230    /// Cache a user that arrived in a message context.
231    ///
232    /// For min users (access_hash is invalid), stores the peer+msg context so
233    /// they can later be referenced via `InputPeerUserFromMessage`.
234    ///
235    /// Uses **latest-wins** semantics: a newer message context replaces the
236    /// stored one.  Recent messages are less likely to have been deleted.
237    pub fn cache_user_with_context(&mut self, user: &tl::enums::User, peer_id: i64, msg_id: i32) {
238        if let tl::enums::User::User(u) = user {
239            if u.min {
240                // Never downgrade a cached full user to a min context.
241                if !self.users.contains_key(&u.id) {
242                    // Latest-wins: overwrite with the most recent message context.
243                    self.min_contexts.insert(u.id, (peer_id, msg_id));
244                }
245            } else if let Some(hash) = u.access_hash {
246                // Never overwrite a non-zero hash with zero.
247                if hash != 0 {
248                    self.users.insert(u.id, hash);
249                } else {
250                    self.users.entry(u.id).or_insert(0);
251                }
252                self.min_contexts.remove(&u.id);
253            }
254            // Reverse indices
255            if let Some(ref uname) = u.username {
256                self.username_to_peer
257                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
258            }
259            if let Some(ref phone) = u.phone {
260                self.phone_to_user.insert(phone.clone(), u.id);
261            }
262        }
263    }
264
265    pub fn cache_chat(&mut self, chat: &tl::enums::Chat) {
266        match chat {
267            tl::enums::Chat::Channel(c) => {
268                let kind = if c.megagroup {
269                    Some(ChannelKind::Megagroup)
270                } else if c.gigagroup {
271                    Some(ChannelKind::Gigagroup)
272                } else {
273                    Some(ChannelKind::Broadcast)
274                };
275                if c.min {
276                    // min channel: no access_hash available.
277                    // Store in channels_min; never put in chats (InputPeerChat fails).
278                    if !self.channels.contains_key(&c.id) {
279                        self.channels_min.insert(c.id);
280                    }
281                } else if let Some(hash) = c.access_hash {
282                    // Never overwrite a valid non-zero hash with zero.
283                    if hash != 0 {
284                        self.channels.insert(c.id, (hash, kind));
285                    } else {
286                        self.channels.entry(c.id).or_insert((0, kind));
287                    }
288                    // Full channel supersedes any min tracking.
289                    self.channels_min.remove(&c.id);
290                }
291                // Reverse username index for channels (update regardless of min)
292                if let Some(ref uname) = c.username {
293                    self.username_to_peer
294                        .insert(uname.to_lowercase(), (c.id, PeerType::Channel));
295                }
296            }
297            tl::enums::Chat::ChannelForbidden(c) => {
298                // ChannelForbidden has no flags; treat as Broadcast kind.
299                if c.access_hash != 0 {
300                    self.channels
301                        .insert(c.id, (c.access_hash, Some(ChannelKind::Broadcast)));
302                } else {
303                    self.channels
304                        .entry(c.id)
305                        .or_insert((0, Some(ChannelKind::Broadcast)));
306                }
307                self.channels_min.remove(&c.id);
308            }
309            tl::enums::Chat::Chat(c) => {
310                // Regular groups need no access_hash; track existence only.
311                self.chats.insert(c.id);
312            }
313            tl::enums::Chat::Forbidden(c) => {
314                self.chats.insert(c.id);
315            }
316            _ => {}
317        }
318    }
319
320    /// Look up the cached [`ChannelKind`] for a channel ID.
321    ///
322    /// Returns `None` when the channel is not in the cache or was loaded from a
323    /// pre-v6 session file that predates kind tracking.
324    pub fn channel_kind_of(&self, channel_id: i64) -> Option<ChannelKind> {
325        self.channels.get(&channel_id).and_then(|&(_, k)| k)
326    }
327
328    pub fn cache_users(&mut self, users: &[tl::enums::User]) {
329        for u in users {
330            self.cache_user(u);
331        }
332    }
333
334    pub fn cache_chats(&mut self, chats: &[tl::enums::Chat]) {
335        for c in chats {
336            self.cache_chat(c);
337        }
338    }
339
340    /// Store an already-resolved `InputPeer`'s access hash into the cache.
341    ///
342    /// Called when a caller provides a `PeerRef::Input` so that the subsequent
343    /// `peer_to_input` lookup succeeds without an RPC.
344    pub fn cache_input_peer(&mut self, ip: &tl::enums::InputPeer) {
345        match ip {
346            tl::enums::InputPeer::User(u) => {
347                if u.access_hash != 0 {
348                    self.users.insert(u.user_id, u.access_hash);
349                } else {
350                    self.users.entry(u.user_id).or_insert(0);
351                }
352                self.min_contexts.remove(&u.user_id);
353            }
354            tl::enums::InputPeer::Channel(c) => {
355                if c.access_hash != 0 {
356                    self.channels
357                        .entry(c.channel_id)
358                        .and_modify(|e| e.0 = c.access_hash)
359                        .or_insert((c.access_hash, None));
360                } else {
361                    self.channels.entry(c.channel_id).or_insert((0, None));
362                }
363                self.channels_min.remove(&c.channel_id);
364            }
365            tl::enums::InputPeer::Chat(c) => {
366                self.chats.insert(c.chat_id);
367            }
368            // UserFromMessage: cache the container peer's hash AND record the
369            // min_context so peer_to_input() can rebuild InputPeerUserFromMessage.
370            tl::enums::InputPeer::UserFromMessage(u) => {
371                // Cache the container peer's access hash
372                self.cache_input_peer(&u.peer);
373                // Extract container peer_id for the min_context entry
374                let container_peer_id = match &u.peer {
375                    tl::enums::InputPeer::Channel(c) => Some(c.channel_id),
376                    tl::enums::InputPeer::Chat(c) => Some(c.chat_id),
377                    tl::enums::InputPeer::User(pu) => Some(pu.user_id),
378                    tl::enums::InputPeer::PeerSelf => Some(0i64),
379                    _ => None,
380                };
381                if let Some(peer_id) = container_peer_id {
382                    // Only set min_context if there is no full hash cached yet.
383                    if !self.users.contains_key(&u.user_id) {
384                        self.min_contexts.insert(u.user_id, (peer_id, u.msg_id));
385                    }
386                }
387            }
388            // ChannelFromMessage: cache the container peer hash and channel entry.
389            tl::enums::InputPeer::ChannelFromMessage(c) => {
390                self.cache_input_peer(&c.peer);
391                // The channel itself has no standalone hash here; mark as known
392                // via channels_min so we don't lose track of it.
393                self.channels_min.insert(c.channel_id);
394            }
395            tl::enums::InputPeer::Empty | tl::enums::InputPeer::PeerSelf => {}
396        }
397    }
398
399    /// Remove stale cache entries when Telegram rejects them with
400    /// `PEER_ID_INVALID`, `CHANNEL_INVALID`, `USER_ID_INVALID`, or
401    /// `CHANNEL_PRIVATE`.  The caller should then retry the operation.
402    pub fn invalidate_peer(&mut self, peer: &tl::enums::Peer) {
403        match peer {
404            tl::enums::Peer::User(u) => {
405                self.users.remove(&u.user_id);
406                self.min_contexts.remove(&u.user_id);
407            }
408            tl::enums::Peer::Channel(c) => {
409                self.channels.remove(&c.channel_id);
410                self.channels_min.remove(&c.channel_id);
411            }
412            tl::enums::Peer::Chat(_) => {} // basic groups have no hash to invalidate
413        }
414    }
415
416    pub(crate) fn user_input_peer(
417        &self,
418        user_id: i64,
419    ) -> Result<tl::enums::InputPeer, InvocationError> {
420        if user_id == 0 {
421            return Ok(tl::enums::InputPeer::PeerSelf);
422        }
423
424        // Full hash: best case.
425        if let Some(&hash) = self.users.get(&user_id) {
426            return Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
427                user_id,
428                access_hash: hash,
429            }));
430        }
431
432        // Min user: resolve via the message context where they were seen.
433        if let Some(&(peer_id, msg_id)) = self.min_contexts.get(&user_id) {
434            // The containing peer can be a channel, a basic group, or a DM user.
435            // Build the correct InputPeer variant for each case.
436            let container = if let Some(&(hash, _)) = self.channels.get(&peer_id) {
437                tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
438                    channel_id: peer_id,
439                    access_hash: hash,
440                })
441            } else if self.channels_min.contains(&peer_id) {
442                if self.experimental.allow_missing_channel_hash {
443                    tracing::warn!(
444                        "[ferogram::peer_cache] channel {peer_id} is a min peer \
445                         (seen inside message for user {user_id}), using access_hash=0. \
446                         This will likely cause CHANNEL_INVALID on user accounts. \
447                         Call client.resolve_peer() to get a full access_hash first."
448                    );
449                    tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
450                        channel_id: peer_id,
451                        access_hash: 0,
452                    })
453                } else {
454                    return Err(InvocationError::PeerNotCached(format!(
455                        "min user {user_id} was seen in channel {peer_id}, \
456                         but that channel is only known as a min channel (no access_hash). \
457                         Resolve the channel first, or enable \
458                         ExperimentalFeatures::allow_missing_channel_hash."
459                    )));
460                }
461            } else if self.chats.contains(&peer_id) {
462                // Basic group: no access_hash needed.
463                tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: peer_id })
464            } else if let Some(&hash) = self.users.get(&peer_id) {
465                // DM: min user was seen in a direct message with another user.
466                tl::enums::InputPeer::User(tl::types::InputPeerUser {
467                    user_id: peer_id,
468                    access_hash: hash,
469                })
470            } else {
471                return Err(InvocationError::PeerNotCached(format!(
472                    "min user {user_id} was seen in peer {peer_id}, \
473                     but that peer is not cached (not a known channel, chat, or user). \
474                     Ensure the containing chat flows through the update loop first."
475                )));
476            };
477            return Ok(tl::enums::InputPeer::UserFromMessage(Box::new(
478                tl::types::InputPeerUserFromMessage {
479                    peer: container,
480                    msg_id,
481                    user_id,
482                },
483            )));
484        }
485
486        // No hash at all.
487        if self.experimental.allow_zero_hash {
488            tracing::warn!(
489                "[ferogram::peer_cache] no access_hash cached for user {user_id}, using 0. \
490                 This is valid for bots but will cause USER_ID_INVALID on user accounts. \
491                 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
492            );
493            Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
494                user_id,
495                access_hash: 0,
496            }))
497        } else {
498            Err(InvocationError::PeerNotCached(format!(
499                "no access_hash cached for user {user_id}. \
500                 Ensure at least one message from this user flows through the \
501                 update loop before using them as a peer, or call \
502                 client.resolve_peer() first."
503            )))
504        }
505    }
506
507    fn channel_input_peer(&self, channel_id: i64) -> Result<tl::enums::InputPeer, InvocationError> {
508        if let Some(&(hash, _)) = self.channels.get(&channel_id) {
509            return Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
510                channel_id,
511                access_hash: hash,
512            }));
513        }
514
515        if self.experimental.allow_zero_hash {
516            tracing::warn!(
517                "[ferogram::peer_cache] no access_hash cached for channel {channel_id}, using 0. \
518                 This is valid for bots but will cause CHANNEL_INVALID on user accounts. \
519                 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
520            );
521            Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
522                channel_id,
523                access_hash: 0,
524            }))
525        } else {
526            Err(InvocationError::PeerNotCached(format!(
527                "no access_hash cached for channel {channel_id}. \
528                 Ensure the channel flows through the update loop before using \
529                 it as a peer, or call client.resolve_peer() first."
530            )))
531        }
532    }
533
534    pub fn peer_to_input(
535        &self,
536        peer: &tl::enums::Peer,
537    ) -> Result<tl::enums::InputPeer, InvocationError> {
538        match peer {
539            tl::enums::Peer::User(u) => self.user_input_peer(u.user_id),
540            tl::enums::Peer::Chat(c) => Ok(tl::enums::InputPeer::Chat(tl::types::InputPeerChat {
541                chat_id: c.chat_id,
542            })),
543            tl::enums::Peer::Channel(c) => self.channel_input_peer(c.channel_id),
544        }
545    }
546}