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};
14
15use ferogram_tl_types as tl;
16
17use crate::errors::InvocationError;
18
19/// Opt-in experimental behaviours that deviate from strict Telegram spec.
20///
21/// All flags default to `false` (safe / spec-correct).  Enable only what you
22/// need after reading the per-field warnings.
23///
24/// # Example
25/// ```rust,no_run
26/// use ferogram::{Client, ExperimentalFeatures};
27///
28/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
29/// let (client, _sd) = Client::builder()
30///     .api_id(12345)
31///     .api_hash("abc")
32///     .experimental_features(ExperimentalFeatures {
33///         allow_zero_hash: true,   // bot-only; omit for user accounts
34///         ..Default::default()
35///     })
36///     .connect().await?;
37/// # Ok(()) }
38/// ```
39#[derive(Clone, Debug, Default)]
40pub struct ExperimentalFeatures {
41    /// When no `access_hash` is cached for a user or channel, fall back to
42    /// `access_hash = 0` instead of returning [`InvocationError::PeerNotCached`].
43    ///
44    /// **Bot accounts only.** The Telegram spec explicitly permits `hash = 0`
45    /// for bots when only a min-hash is available.  On user accounts this
46    /// produces `USER_ID_INVALID` / `CHANNEL_INVALID`.
47    pub allow_zero_hash: bool,
48
49    /// When resolving a min-user via `InputPeerUserFromMessage`, if the
50    /// containing channel's hash is not cached, proceed with
51    /// `channel access_hash = 0` instead of returning
52    /// [`InvocationError::PeerNotCached`].
53    ///
54    /// Almost always wrong.  The inner `InputPeerChannel { access_hash: 0 }`
55    /// makes the whole `InputPeerUserFromMessage` invalid and Telegram will
56    /// reject it.  Only useful for debugging / testing.
57    pub allow_missing_channel_hash: bool,
58
59    /// *(Reserved  - not yet implemented.)*
60    ///
61    /// When set, a cache miss would automatically call `users.getUsers` /
62    /// `channels.getChannels` to fetch a fresh `access_hash` before
63    /// constructing the `InputPeer`.  Currently has no effect.
64    pub auto_resolve_peers: bool,
65}
66
67/// Caches access hashes for users and channels so every API call carries the
68/// correct hash without re-resolving peers.
69/// Discriminates the kind of peer stored in `PeerCache::username_to_peer`.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum PeerType {
72    User,
73    Channel,
74    Chat,
75}
76
77///
78/// All fields are `pub` so that `save_session` / `connect` can read/write them
79/// directly, and so that advanced callers can inspect the cache.
80pub struct PeerCache {
81    /// user_id -> access_hash (full users only, min=false)
82    pub users: HashMap<i64, i64>,
83    /// channel_id -> access_hash (full channels only, min=false)
84    pub channels: HashMap<i64, i64>,
85    /// Regular group chat IDs (Chat::Chat / ChatForbidden).
86    /// Groups need no access_hash; track existence for peer validation.
87    pub chats: HashSet<i64>,
88    /// Channel IDs seen with min=true. These are real channels but have no
89    /// valid access_hash. Stored separately so they are NEVER confused with
90    /// regular groups. DO NOT put min channels in `chats`. A min channel must
91    /// never become InputPeerChat  - that causes fatal RPC failures.
92    pub channels_min: HashSet<i64>,
93    /// user_id -> (peer_id, msg_id) for min users seen in a message context.
94    /// Min users have an invalid access_hash; they must be referenced via
95    /// InputPeerUserFromMessage using the peer and message where they appeared.
96    pub min_contexts: HashMap<i64, (i64, i32)>,
97    /// Reverse index: lowercase username → (id, PeerType).
98    /// Populated by cache_user / cache_chat; always overwritten on update
99    /// (usernames can change).
100    pub username_to_peer: HashMap<String, (i64, PeerType)>,
101    /// Reverse index: E.164 phone → user_id.
102    pub phone_to_user: HashMap<String, i64>,
103    /// Experimental opt-ins that change error-vs-fallback behaviour.
104    experimental: ExperimentalFeatures,
105}
106
107impl Default for PeerCache {
108    fn default() -> Self {
109        Self::new(ExperimentalFeatures::default())
110    }
111}
112
113impl PeerCache {
114    /// Create a new empty cache with the given experimental-feature flags.
115    pub fn new(experimental: ExperimentalFeatures) -> Self {
116        Self {
117            users: HashMap::new(),
118            channels: HashMap::new(),
119            chats: HashSet::new(),
120            channels_min: HashSet::new(),
121            min_contexts: HashMap::new(),
122            username_to_peer: HashMap::new(),
123            phone_to_user: HashMap::new(),
124            experimental,
125        }
126    }
127
128    pub fn cache_user(&mut self, user: &tl::enums::User) {
129        if let tl::enums::User::User(u) = user {
130            if u.min {
131                // min=true: access_hash is not valid; requires a message context.
132            } else if let Some(hash) = u.access_hash {
133                // Never overwrite a valid non-zero hash with zero.
134                if hash != 0 {
135                    self.users.insert(u.id, hash);
136                } else {
137                    self.users.entry(u.id).or_insert(0);
138                }
139                // Full user always supersedes any min context.
140                self.min_contexts.remove(&u.id);
141            }
142            // Reverse indices (update even for min users so username lookup works)
143            if let Some(ref uname) = u.username {
144                self.username_to_peer
145                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
146            }
147            if let Some(ref phone) = u.phone {
148                self.phone_to_user.insert(phone.clone(), u.id);
149            }
150        }
151    }
152
153    /// Cache a user that arrived in a message context.
154    ///
155    /// For min users (access_hash is invalid), stores the peer+msg context so
156    /// they can later be referenced via `InputPeerUserFromMessage`.
157    ///
158    /// Uses **latest-wins** semantics: a newer message context replaces the
159    /// stored one.  Recent messages are less likely to have been deleted.
160    pub fn cache_user_with_context(&mut self, user: &tl::enums::User, peer_id: i64, msg_id: i32) {
161        if let tl::enums::User::User(u) = user {
162            if u.min {
163                // Never downgrade a cached full user to a min context.
164                if !self.users.contains_key(&u.id) {
165                    // Latest-wins: overwrite with the most recent message context.
166                    self.min_contexts.insert(u.id, (peer_id, msg_id));
167                }
168            } else if let Some(hash) = u.access_hash {
169                // Never overwrite a non-zero hash with zero.
170                if hash != 0 {
171                    self.users.insert(u.id, hash);
172                } else {
173                    self.users.entry(u.id).or_insert(0);
174                }
175                self.min_contexts.remove(&u.id);
176            }
177            // Reverse indices
178            if let Some(ref uname) = u.username {
179                self.username_to_peer
180                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
181            }
182            if let Some(ref phone) = u.phone {
183                self.phone_to_user.insert(phone.clone(), u.id);
184            }
185        }
186    }
187
188    pub fn cache_chat(&mut self, chat: &tl::enums::Chat) {
189        match chat {
190            tl::enums::Chat::Channel(c) => {
191                if c.min {
192                    // min channel: no access_hash available.
193                    // Store in channels_min; never put in chats (InputPeerChat fails).
194                    if !self.channels.contains_key(&c.id) {
195                        self.channels_min.insert(c.id);
196                    }
197                } else if let Some(hash) = c.access_hash {
198                    // Never overwrite a valid non-zero hash with zero.
199                    if hash != 0 {
200                        self.channels.insert(c.id, hash);
201                    } else {
202                        self.channels.entry(c.id).or_insert(0);
203                    }
204                    // Full channel supersedes any min tracking.
205                    self.channels_min.remove(&c.id);
206                }
207                // Reverse username index for channels (update regardless of min)
208                if let Some(ref uname) = c.username {
209                    self.username_to_peer
210                        .insert(uname.to_lowercase(), (c.id, PeerType::Channel));
211                }
212            }
213            tl::enums::Chat::ChannelForbidden(c) => {
214                // Only store if the hash is non-zero.
215                if c.access_hash != 0 {
216                    self.channels.insert(c.id, c.access_hash);
217                } else {
218                    self.channels.entry(c.id).or_insert(0);
219                }
220                self.channels_min.remove(&c.id);
221            }
222            tl::enums::Chat::Chat(c) => {
223                // Regular groups need no access_hash; track existence only.
224                self.chats.insert(c.id);
225            }
226            tl::enums::Chat::Forbidden(c) => {
227                self.chats.insert(c.id);
228            }
229            _ => {}
230        }
231    }
232
233    pub fn cache_users(&mut self, users: &[tl::enums::User]) {
234        for u in users {
235            self.cache_user(u);
236        }
237    }
238
239    pub fn cache_chats(&mut self, chats: &[tl::enums::Chat]) {
240        for c in chats {
241            self.cache_chat(c);
242        }
243    }
244
245    /// Store an already-resolved `InputPeer`'s access hash into the cache.
246    ///
247    /// Called when a caller provides a `PeerRef::Input` so that the subsequent
248    /// `peer_to_input` lookup succeeds without an RPC.
249    pub fn cache_input_peer(&mut self, ip: &tl::enums::InputPeer) {
250        match ip {
251            tl::enums::InputPeer::User(u) => {
252                if u.access_hash != 0 {
253                    self.users.insert(u.user_id, u.access_hash);
254                } else {
255                    self.users.entry(u.user_id).or_insert(0);
256                }
257                self.min_contexts.remove(&u.user_id);
258            }
259            tl::enums::InputPeer::Channel(c) => {
260                if c.access_hash != 0 {
261                    self.channels.insert(c.channel_id, c.access_hash);
262                } else {
263                    self.channels.entry(c.channel_id).or_insert(0);
264                }
265                self.channels_min.remove(&c.channel_id);
266            }
267            tl::enums::InputPeer::Chat(c) => {
268                self.chats.insert(c.chat_id);
269            }
270            // UserFromMessage: cache the container peer's hash AND record the
271            // min_context so peer_to_input() can rebuild InputPeerUserFromMessage.
272            tl::enums::InputPeer::UserFromMessage(u) => {
273                // Cache the container peer's access hash
274                self.cache_input_peer(&u.peer);
275                // Extract container peer_id for the min_context entry
276                let container_peer_id = match &u.peer {
277                    tl::enums::InputPeer::Channel(c) => Some(c.channel_id),
278                    tl::enums::InputPeer::Chat(c) => Some(c.chat_id),
279                    tl::enums::InputPeer::User(pu) => Some(pu.user_id),
280                    tl::enums::InputPeer::PeerSelf => Some(0i64),
281                    _ => None,
282                };
283                if let Some(peer_id) = container_peer_id {
284                    // Only set min_context if there is no full hash cached yet.
285                    if !self.users.contains_key(&u.user_id) {
286                        self.min_contexts.insert(u.user_id, (peer_id, u.msg_id));
287                    }
288                }
289            }
290            // ChannelFromMessage: cache the container peer hash and channel entry.
291            tl::enums::InputPeer::ChannelFromMessage(c) => {
292                self.cache_input_peer(&c.peer);
293                // The channel itself has no standalone hash here; mark as known
294                // via channels_min so we don't lose track of it.
295                self.channels_min.insert(c.channel_id);
296            }
297            tl::enums::InputPeer::Empty | tl::enums::InputPeer::PeerSelf => {}
298        }
299    }
300
301    /// Remove stale cache entries when Telegram rejects them with
302    /// `PEER_ID_INVALID`, `CHANNEL_INVALID`, `USER_ID_INVALID`, or
303    /// `CHANNEL_PRIVATE`.  The caller should then retry the operation.
304    pub fn invalidate_peer(&mut self, peer: &tl::enums::Peer) {
305        match peer {
306            tl::enums::Peer::User(u) => {
307                self.users.remove(&u.user_id);
308                self.min_contexts.remove(&u.user_id);
309            }
310            tl::enums::Peer::Channel(c) => {
311                self.channels.remove(&c.channel_id);
312                self.channels_min.remove(&c.channel_id);
313            }
314            tl::enums::Peer::Chat(_) => {} // basic groups have no hash to invalidate
315        }
316    }
317
318    pub(crate) fn user_input_peer(
319        &self,
320        user_id: i64,
321    ) -> Result<tl::enums::InputPeer, InvocationError> {
322        if user_id == 0 {
323            return Ok(tl::enums::InputPeer::PeerSelf);
324        }
325
326        // Full hash: best case.
327        if let Some(&hash) = self.users.get(&user_id) {
328            return Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
329                user_id,
330                access_hash: hash,
331            }));
332        }
333
334        // Min user: resolve via the message context where they were seen.
335        if let Some(&(peer_id, msg_id)) = self.min_contexts.get(&user_id) {
336            // The containing peer can be a channel, a basic group, or a DM user.
337            // Build the correct InputPeer variant for each case.
338            let container = if let Some(&hash) = self.channels.get(&peer_id) {
339                tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
340                    channel_id: peer_id,
341                    access_hash: hash,
342                })
343            } else if self.channels_min.contains(&peer_id) {
344                if self.experimental.allow_missing_channel_hash {
345                    tracing::warn!(
346                        "[ferogram] PeerCache: channel {peer_id} is a min channel \
347                         (contains min user {user_id}), using hash=0. \
348                         This will likely cause CHANNEL_INVALID. \
349                         Resolve the channel first."
350                    );
351                    tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
352                        channel_id: peer_id,
353                        access_hash: 0,
354                    })
355                } else {
356                    return Err(InvocationError::PeerNotCached(format!(
357                        "min user {user_id} was seen in channel {peer_id}, \
358                         but that channel is only known as a min channel (no access_hash). \
359                         Resolve the channel first, or enable \
360                         ExperimentalFeatures::allow_missing_channel_hash."
361                    )));
362                }
363            } else if self.chats.contains(&peer_id) {
364                // Basic group: no access_hash needed.
365                tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: peer_id })
366            } else if let Some(&hash) = self.users.get(&peer_id) {
367                // DM: min user was seen in a direct message with another user.
368                tl::enums::InputPeer::User(tl::types::InputPeerUser {
369                    user_id: peer_id,
370                    access_hash: hash,
371                })
372            } else {
373                return Err(InvocationError::PeerNotCached(format!(
374                    "min user {user_id} was seen in peer {peer_id}, \
375                     but that peer is not cached (not a known channel, chat, or user). \
376                     Ensure the containing chat flows through the update loop first."
377                )));
378            };
379            return Ok(tl::enums::InputPeer::UserFromMessage(Box::new(
380                tl::types::InputPeerUserFromMessage {
381                    peer: container,
382                    msg_id,
383                    user_id,
384                },
385            )));
386        }
387
388        // No hash at all.
389        if self.experimental.allow_zero_hash {
390            tracing::warn!(
391                "[ferogram] PeerCache: no access_hash for user {user_id}, using 0. \
392                 Valid for bots only (Telegram spec). On user accounts this will \
393                 cause USER_ID_INVALID. Resolve the peer first or disable \
394                 ExperimentalFeatures::allow_zero_hash."
395            );
396            Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
397                user_id,
398                access_hash: 0,
399            }))
400        } else {
401            Err(InvocationError::PeerNotCached(format!(
402                "no access_hash cached for user {user_id}. \
403                 Ensure at least one message from this user flows through the \
404                 update loop before using them as a peer, or call \
405                 client.resolve_peer() first."
406            )))
407        }
408    }
409
410    fn channel_input_peer(&self, channel_id: i64) -> Result<tl::enums::InputPeer, InvocationError> {
411        if let Some(&hash) = self.channels.get(&channel_id) {
412            return Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
413                channel_id,
414                access_hash: hash,
415            }));
416        }
417
418        if self.experimental.allow_zero_hash {
419            tracing::warn!(
420                "[ferogram] PeerCache: no access_hash for channel {channel_id}, using 0. \
421                 Valid for bots only (Telegram spec). On user accounts this will \
422                 cause CHANNEL_INVALID. Resolve the peer first or disable \
423                 ExperimentalFeatures::allow_zero_hash."
424            );
425            Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
426                channel_id,
427                access_hash: 0,
428            }))
429        } else {
430            Err(InvocationError::PeerNotCached(format!(
431                "no access_hash cached for channel {channel_id}. \
432                 Ensure the channel flows through the update loop before using \
433                 it as a peer, or call client.resolve_peer() first."
434            )))
435        }
436    }
437
438    pub fn peer_to_input(
439        &self,
440        peer: &tl::enums::Peer,
441    ) -> Result<tl::enums::InputPeer, InvocationError> {
442        match peer {
443            tl::enums::Peer::User(u) => self.user_input_peer(u.user_id),
444            tl::enums::Peer::Chat(c) => Ok(tl::enums::InputPeer::Chat(tl::types::InputPeerChat {
445                chat_id: c.chat_id,
446            })),
447            tl::enums::Peer::Channel(c) => self.channel_input_peer(c.channel_id),
448        }
449    }
450}