Skip to main content

ferogram/
peer_cache.rs

1/*
2 * Copyright (c) 2026 Ankit Chaubey <ankitchaubey.dev@gmail.com>
3 * https://github.com/ankit-chaubey
4 *
5 * Project: ferogram
6 * Website: https://ferogram.dev
7 *
8 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
9 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
10 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
11 * This file may not be copied, modified, or distributed except according
12 * to those terms.
13 */
14
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17
18use ferogram_tl_types as tl;
19
20use crate::errors::InvocationError;
21pub use crate::types::ChannelKind;
22
23impl From<ferogram_session::ChannelKind> for ChannelKind {
24    fn from(k: ferogram_session::ChannelKind) -> Self {
25        match k {
26            ferogram_session::ChannelKind::Broadcast => ChannelKind::Broadcast,
27            ferogram_session::ChannelKind::Megagroup => ChannelKind::Megagroup,
28            ferogram_session::ChannelKind::Gigagroup => ChannelKind::Gigagroup,
29        }
30    }
31}
32
33impl From<ChannelKind> for ferogram_session::ChannelKind {
34    fn from(k: ChannelKind) -> Self {
35        match k {
36            ChannelKind::Broadcast => ferogram_session::ChannelKind::Broadcast,
37            ChannelKind::Megagroup => ferogram_session::ChannelKind::Megagroup,
38            ChannelKind::Gigagroup => ferogram_session::ChannelKind::Gigagroup,
39        }
40    }
41}
42
43/// A batch-scoped, read-only map from channel ID to the raw TL chat object.
44///
45/// Built once per update batch from the `chats` vec and shared (cheaply via
46/// `Arc` refcount) across every `IncomingMessage` produced in that batch.
47/// When the last message is dropped the map is freed automatically.
48pub type PeerMap = Arc<HashMap<i64, tl::enums::Chat>>;
49
50/// Build a `PeerMap` from a slice of TL chat objects.
51///
52/// Silently ignores `Chat::Empty` and any entry without an ID.
53pub fn build_peer_map(chats: &[tl::enums::Chat]) -> Option<PeerMap> {
54    if chats.is_empty() {
55        return None;
56    }
57    let mut map = HashMap::with_capacity(chats.len());
58    for chat in chats {
59        let id = match chat {
60            tl::enums::Chat::Channel(c) => c.id,
61            tl::enums::Chat::ChannelForbidden(c) => c.id,
62            tl::enums::Chat::Chat(c) => c.id,
63            tl::enums::Chat::Forbidden(c) => c.id,
64            tl::enums::Chat::Community(c) => c.id,
65            tl::enums::Chat::CommunityForbidden(c) => c.id,
66            tl::enums::Chat::Empty(_) => continue,
67        };
68        map.insert(id, chat.clone());
69    }
70    if map.is_empty() {
71        None
72    } else {
73        Some(Arc::new(map))
74    }
75}
76
77/// Opt-in experimental behaviours that deviate from strict Telegram spec.
78///
79/// All flags default to `false` (safe / spec-correct).  Enable only what you
80/// need after reading the per-field warnings.
81///
82/// # Example
83/// ```rust,no_run
84/// use ferogram::{Client, ExperimentalFeatures};
85///
86/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
87/// let (client, _sd) = Client::builder()
88///     .api_id(12345)
89///     .api_hash("abc")
90///     .experimental_features(ExperimentalFeatures {
91///         allow_zero_hash: true,   // bot-only; omit for user accounts
92///         ..Default::default()
93///     })
94///     .connect().await?;
95/// # Ok(()) }
96/// ```
97#[derive(Clone, Debug, Default)]
98pub struct ExperimentalFeatures {
99    /// When no `access_hash` is cached for a user or channel, fall back to
100    /// `access_hash = 0` instead of returning [`InvocationError::PeerNotCached`].
101    ///
102    /// **Bot accounts only.** The Telegram spec explicitly permits `hash = 0`
103    /// for bots when only a min-hash is available.  On user accounts this
104    /// produces `USER_ID_INVALID` / `CHANNEL_INVALID`.
105    pub allow_zero_hash: bool,
106
107    /// When resolving a min-user via `InputPeerUserFromMessage`, if the
108    /// containing channel's hash is not cached, proceed with
109    /// `channel access_hash = 0` instead of returning
110    /// [`InvocationError::PeerNotCached`].
111    ///
112    /// Almost always wrong.  The inner `InputPeerChannel { access_hash: 0 }`
113    /// makes the whole `InputPeerUserFromMessage` invalid and Telegram will
114    /// reject it.  Only useful for debugging / testing.
115    pub allow_missing_channel_hash: bool,
116
117    /// When `access_hash` is missing for a channel during `getChannelDifference`,
118    /// call `channels.getChannels` with `access_hash = 0` to fetch it, cache it,
119    /// and retry the diff in the same loop iteration.
120    ///
121    /// When false (the default), the diff is deferred: the entry stays alive and
122    /// the diff retries naturally once the hash arrives via a future update's
123    /// entity list.
124    ///
125    /// **Bot accounts only** for reliable operation. On user accounts
126    /// `channels.getChannels { access_hash: 0 }` succeeds only for public channels
127    /// and channels you are currently a member of.
128    pub auto_resolve_peers: bool,
129
130    /// Enable resumable uploads and downloads.
131    ///
132    /// When `true`, interrupted transfers save a checkpoint under
133    /// `checkpoint_dir` (defaults to `.ferogram-transfers/` next to the
134    /// session file). The next call with the same media / file automatically
135    /// resumes from where it left off.
136    ///
137    /// Upload sessions are valid for ~1 hour on Telegram's side; if more time
138    /// has passed the upload restarts from scratch automatically.
139    ///
140    /// Default: `false`.
141    pub resumable_transfers: bool,
142
143    /// Directory for transfer checkpoints when `resumable_transfers` is enabled.
144    /// If `None`, defaults to `.ferogram-transfers/` next to the session file.
145    pub checkpoint_dir: Option<std::path::PathBuf>,
146
147    /// Cache min-user message contexts (`InputPeerUserFromMessage` entries).
148    ///
149    /// When `false` (the default), users seen with `min=true` are silently
150    /// ignored instead of being stored. This keeps session files lean and is
151    /// the right choice for gateway / proxy servers that never need to address
152    /// min users directly.
153    ///
154    /// Enable only if your application needs to send messages or make API
155    /// calls targeting users that arrive exclusively as min-users (i.e. users
156    /// you have never seen with a full access_hash).
157    ///
158    /// Default: `false`.
159    pub cache_min_peers: bool,
160}
161
162/// Caches access hashes for users and channels so every API call carries the
163/// correct hash without re-resolving peers.
164/// A snapshot of what [`PeerCache`] currently holds.
165///
166/// Returned by [`PeerCache::stats`]. Useful for logging, monitoring, and
167/// deciding whether to call [`PeerCache::clear_min_contexts`].
168#[derive(Clone, Debug)]
169pub struct PeerCacheStats {
170    /// Full users with a valid access_hash.
171    pub users: usize,
172    /// Full channels with a valid access_hash.
173    pub channels: usize,
174    /// Full communities with a valid access_hash.
175    pub communities: usize,
176    /// Regular group chats tracked by existence (no hash needed).
177    pub chats: usize,
178    /// Channels seen with `min=true` (no usable access_hash yet).
179    pub min_channels: usize,
180    /// Min-user message contexts (`InputPeerUserFromMessage` entries).
181    /// Always 0 when `cache_min_peers` is disabled.
182    pub min_contexts: usize,
183    /// Username reverse-index entries.
184    pub usernames: usize,
185    /// Phone number reverse-index entries.
186    pub phones: usize,
187}
188
189/// Discriminates the kind of peer stored in `PeerCache::username_to_peer`.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub enum PeerType {
192    User,
193    Channel,
194    Chat,
195}
196
197///
198/// All fields are `pub` so that `save_session` / `connect` can read/write them
199/// directly, and so that advanced callers can inspect the cache.
200pub struct PeerCache {
201    /// user_id -> access_hash (full users only, min=false)
202    pub users: HashMap<i64, i64>,
203    /// channel_id -> (access_hash, `Option<ChannelKind>`) (full channels only, min=false)
204    pub channels: HashMap<i64, (i64, Option<ChannelKind>)>,
205    /// community_id -> access_hash (full communities only, min=false).
206    /// Kept separate from `channels`: a Community is a distinct Chat variant
207    /// with no `ChannelKind`, even though it resolves to the same
208    /// `InputPeer::Channel` shape on the wire.
209    pub communities: HashMap<i64, i64>,
210    /// Regular group chat IDs (Chat::Chat / ChatForbidden).
211    /// Groups need no access_hash; track existence for peer validation.
212    pub chats: HashSet<i64>,
213    /// Channel IDs seen with min=true. These are real channels but have no
214    /// valid access_hash. Stored separately so they are NEVER confused with
215    /// regular groups. DO NOT put min channels in `chats`. A min channel must
216    /// never become InputPeerChat  - that causes fatal RPC failures.
217    pub channels_min: HashSet<i64>,
218    /// user_id -> (peer_id, msg_id) for min users seen in a message context.
219    /// Min users have an invalid access_hash; they must be referenced via
220    /// InputPeerUserFromMessage using the peer and message where they appeared.
221    pub min_contexts: HashMap<i64, (i64, i32)>,
222    /// Reverse index: lowercase username → (id, PeerType).
223    /// Populated by cache_user / cache_chat; always overwritten on update
224    /// (usernames can change).
225    pub username_to_peer: HashMap<String, (i64, PeerType)>,
226    /// Reverse index: E.164 phone → user_id.
227    pub phone_to_user: HashMap<String, i64>,
228    /// Experimental opt-ins that change error-vs-fallback behaviour.
229    pub(crate) experimental: ExperimentalFeatures,
230}
231
232impl Default for PeerCache {
233    fn default() -> Self {
234        Self::new(ExperimentalFeatures::default())
235    }
236}
237
238impl PeerCache {
239    /// Create a new empty cache with the given experimental-feature flags.
240    pub fn new(experimental: ExperimentalFeatures) -> Self {
241        Self {
242            users: HashMap::new(),
243            channels: HashMap::new(),
244            communities: HashMap::new(),
245            chats: HashSet::new(),
246            channels_min: HashSet::new(),
247            min_contexts: HashMap::new(),
248            username_to_peer: HashMap::new(),
249            phone_to_user: HashMap::new(),
250            experimental,
251        }
252    }
253
254    pub fn cache_user(&mut self, user: &tl::enums::User) {
255        if let tl::enums::User::User(u) = user {
256            if u.min {
257                // min=true: access_hash is not valid; requires a message context.
258            } else if let Some(hash) = u.access_hash {
259                // Never overwrite a valid non-zero hash with zero.
260                if hash != 0 {
261                    self.users.insert(u.id, hash);
262                } else {
263                    self.users.entry(u.id).or_insert(0);
264                }
265                // Full user always supersedes any min context.
266                self.min_contexts.remove(&u.id);
267            }
268            // Reverse indices (update even for min users so username lookup works)
269            if let Some(ref uname) = u.username {
270                self.username_to_peer
271                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
272            }
273            if let Some(ref phone) = u.phone
274                && let Some(normalized) = crate::util::normalize_phone(phone)
275            {
276                self.phone_to_user.insert(normalized, u.id);
277            }
278        }
279    }
280
281    /// Cache a user that arrived in a message context.
282    ///
283    /// For min users (access_hash is invalid), stores the peer+msg context so
284    /// they can later be referenced via `InputPeerUserFromMessage`.
285    ///
286    /// Uses **latest-wins** semantics: a newer message context replaces the
287    /// stored one.  Recent messages are less likely to have been deleted.
288    pub fn cache_user_with_context(&mut self, user: &tl::enums::User, peer_id: i64, msg_id: i32) {
289        if let tl::enums::User::User(u) = user {
290            if u.min {
291                // Never downgrade a cached full user to a min context.
292                if self.experimental.cache_min_peers && !self.users.contains_key(&u.id) {
293                    // Latest-wins: overwrite with the most recent message context.
294                    self.min_contexts.insert(u.id, (peer_id, msg_id));
295                }
296            } else if let Some(hash) = u.access_hash {
297                // Never overwrite a non-zero hash with zero.
298                if hash != 0 {
299                    self.users.insert(u.id, hash);
300                } else {
301                    self.users.entry(u.id).or_insert(0);
302                }
303                self.min_contexts.remove(&u.id);
304            }
305            // Reverse indices
306            if let Some(ref uname) = u.username {
307                self.username_to_peer
308                    .insert(uname.to_lowercase(), (u.id, PeerType::User));
309            }
310            if let Some(ref phone) = u.phone
311                && let Some(normalized) = crate::util::normalize_phone(phone)
312            {
313                self.phone_to_user.insert(normalized, u.id);
314            }
315        }
316    }
317
318    pub fn cache_chat(&mut self, chat: &tl::enums::Chat) {
319        match chat {
320            tl::enums::Chat::Channel(c) => {
321                let kind = if c.megagroup {
322                    Some(ChannelKind::Megagroup)
323                } else if c.gigagroup {
324                    Some(ChannelKind::Gigagroup)
325                } else {
326                    Some(ChannelKind::Broadcast)
327                };
328                if c.min {
329                    // min channel: no access_hash available.
330                    // Store in channels_min; never put in chats (InputPeerChat fails).
331                    if !self.channels.contains_key(&c.id) {
332                        self.channels_min.insert(c.id);
333                    }
334                } else if let Some(hash) = c.access_hash {
335                    // Never overwrite a valid non-zero hash with zero.
336                    if hash != 0 {
337                        self.channels.insert(c.id, (hash, kind));
338                    } else {
339                        self.channels.entry(c.id).or_insert((0, kind));
340                    }
341                    // Full channel supersedes any min tracking.
342                    self.channels_min.remove(&c.id);
343                }
344                // Reverse username index for channels (update regardless of min)
345                if let Some(ref uname) = c.username {
346                    self.username_to_peer
347                        .insert(uname.to_lowercase(), (c.id, PeerType::Channel));
348                }
349            }
350            tl::enums::Chat::ChannelForbidden(c) => {
351                // ChannelForbidden has no flags; treat as Broadcast kind.
352                if c.access_hash != 0 {
353                    self.channels
354                        .insert(c.id, (c.access_hash, Some(ChannelKind::Broadcast)));
355                } else {
356                    self.channels
357                        .entry(c.id)
358                        .or_insert((0, Some(ChannelKind::Broadcast)));
359                }
360                self.channels_min.remove(&c.id);
361            }
362            tl::enums::Chat::Chat(c) => {
363                // Regular groups need no access_hash; track existence only.
364                self.chats.insert(c.id);
365            }
366            tl::enums::Chat::Forbidden(c) => {
367                self.chats.insert(c.id);
368            }
369            tl::enums::Chat::Community(c) => {
370                if c.min {
371                    // min community: no usable access_hash yet. Unlike channels,
372                    // there is no separate min-tracking set for communities;
373                    // a min community that has never had a full hash simply
374                    // stays absent from `communities` until one arrives.
375                } else if let Some(hash) = c.access_hash {
376                    // Never overwrite a valid non-zero hash with zero.
377                    if hash != 0 {
378                        self.communities.insert(c.id, hash);
379                    } else {
380                        self.communities.entry(c.id).or_insert(0);
381                    }
382                }
383            }
384            tl::enums::Chat::CommunityForbidden(c) => {
385                if let Some(hash) = c.access_hash {
386                    if hash != 0 {
387                        self.communities.insert(c.id, hash);
388                    } else {
389                        self.communities.entry(c.id).or_insert(0);
390                    }
391                }
392            }
393            _ => {}
394        }
395    }
396
397    /// Look up the cached [`ChannelKind`] for a channel ID.
398    ///
399    /// Returns `None` when the channel is not in the cache or was loaded from a
400    /// pre-v6 session file that predates kind tracking.
401    pub fn channel_kind_of(&self, channel_id: i64) -> Option<ChannelKind> {
402        self.channels.get(&channel_id).and_then(|&(_, k)| k)
403    }
404
405    pub fn cache_users(&mut self, users: &[tl::enums::User]) {
406        for u in users {
407            self.cache_user(u);
408        }
409    }
410
411    pub fn cache_chats(&mut self, chats: &[tl::enums::Chat]) {
412        for c in chats {
413            self.cache_chat(c);
414        }
415    }
416
417    /// Store an already-resolved `InputPeer`'s access hash into the cache.
418    ///
419    /// Called when a caller provides a `PeerRef::Input` so that the subsequent
420    /// `peer_to_input` lookup succeeds without an RPC.
421    pub fn cache_input_peer(&mut self, ip: &tl::enums::InputPeer) {
422        match ip {
423            tl::enums::InputPeer::User(u) => {
424                if u.access_hash != 0 {
425                    self.users.insert(u.user_id, u.access_hash);
426                } else {
427                    self.users.entry(u.user_id).or_insert(0);
428                }
429                self.min_contexts.remove(&u.user_id);
430            }
431            tl::enums::InputPeer::Channel(c) => {
432                if c.access_hash != 0 {
433                    self.channels
434                        .entry(c.channel_id)
435                        .and_modify(|e| e.0 = c.access_hash)
436                        .or_insert((c.access_hash, None));
437                } else {
438                    self.channels.entry(c.channel_id).or_insert((0, None));
439                }
440                self.channels_min.remove(&c.channel_id);
441            }
442            tl::enums::InputPeer::Chat(c) => {
443                self.chats.insert(c.chat_id);
444            }
445            // UserFromMessage: cache the container peer's hash AND record the
446            // min_context so peer_to_input() can rebuild InputPeerUserFromMessage.
447            tl::enums::InputPeer::UserFromMessage(u) => {
448                // Cache the container peer's access hash
449                self.cache_input_peer(&u.peer);
450                // Extract container peer_id for the min_context entry
451                let container_peer_id = match &u.peer {
452                    tl::enums::InputPeer::Channel(c) => Some(c.channel_id),
453                    tl::enums::InputPeer::Chat(c) => Some(c.chat_id),
454                    tl::enums::InputPeer::User(pu) => Some(pu.user_id),
455                    tl::enums::InputPeer::PeerSelf => Some(0i64),
456                    _ => None,
457                };
458                if let Some(peer_id) = container_peer_id {
459                    // Only set min_context if there is no full hash cached yet.
460                    if self.experimental.cache_min_peers && !self.users.contains_key(&u.user_id) {
461                        self.min_contexts.insert(u.user_id, (peer_id, u.msg_id));
462                    }
463                }
464            }
465            // ChannelFromMessage: cache the container peer hash and channel entry.
466            tl::enums::InputPeer::ChannelFromMessage(c) => {
467                self.cache_input_peer(&c.peer);
468                // The channel itself has no standalone hash here; mark as known
469                // via channels_min so we don't lose track of it.
470                self.channels_min.insert(c.channel_id);
471            }
472            tl::enums::InputPeer::Empty | tl::enums::InputPeer::PeerSelf => {}
473        }
474    }
475
476    /// Remove stale cache entries when Telegram rejects them with
477    /// `PEER_ID_INVALID`, `CHANNEL_INVALID`, `USER_ID_INVALID`, or
478    /// `CHANNEL_PRIVATE`.  The caller should then retry the operation.
479    pub fn invalidate_peer(&mut self, peer: &tl::enums::Peer) {
480        match peer {
481            tl::enums::Peer::User(u) => {
482                self.users.remove(&u.user_id);
483                self.min_contexts.remove(&u.user_id);
484            }
485            tl::enums::Peer::Channel(c) => {
486                self.channels.remove(&c.channel_id);
487                self.channels_min.remove(&c.channel_id);
488            }
489            tl::enums::Peer::Chat(_) => {} // basic groups have no hash to invalidate
490        }
491    }
492
493    pub(crate) fn user_input_peer(
494        &self,
495        user_id: i64,
496    ) -> Result<tl::enums::InputPeer, InvocationError> {
497        if user_id == 0 {
498            return Ok(tl::enums::InputPeer::PeerSelf);
499        }
500
501        // Full hash: best case.
502        if let Some(&hash) = self.users.get(&user_id) {
503            return Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
504                user_id,
505                access_hash: hash,
506            }));
507        }
508
509        // Min user: resolve via the message context where they were seen.
510        if let Some(&(peer_id, msg_id)) = self.min_contexts.get(&user_id) {
511            // The containing peer can be a channel, a basic group, or a DM user.
512            // Build the correct InputPeer variant for each case.
513            let container = if let Some(&(hash, _)) = self.channels.get(&peer_id) {
514                tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
515                    channel_id: peer_id,
516                    access_hash: hash,
517                })
518            } else if self.channels_min.contains(&peer_id) {
519                if self.experimental.allow_missing_channel_hash {
520                    tracing::warn!(
521                        "[ferogram::peer_cache] channel {peer_id} is a min peer \
522                         (seen inside message for user {user_id}), using access_hash=0. \
523                         This will likely cause CHANNEL_INVALID on user accounts. \
524                         Call client.resolve_peer() to get a full access_hash first."
525                    );
526                    tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
527                        channel_id: peer_id,
528                        access_hash: 0,
529                    })
530                } else {
531                    return Err(InvocationError::PeerNotCached(format!(
532                        "min user {user_id} was seen in channel {peer_id}, \
533                         but that channel is only known as a min channel (no access_hash). \
534                         Resolve the channel first, or enable \
535                         ExperimentalFeatures::allow_missing_channel_hash."
536                    )));
537                }
538            } else if self.chats.contains(&peer_id) {
539                // Basic group: no access_hash needed.
540                tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: peer_id })
541            } else if let Some(&hash) = self.users.get(&peer_id) {
542                // DM: min user was seen in a direct message with another user.
543                tl::enums::InputPeer::User(tl::types::InputPeerUser {
544                    user_id: peer_id,
545                    access_hash: hash,
546                })
547            } else {
548                return Err(InvocationError::PeerNotCached(format!(
549                    "min user {user_id} was seen in peer {peer_id}, \
550                     but that peer is not cached (not a known channel, chat, or user). \
551                     Ensure the containing chat flows through the update loop first."
552                )));
553            };
554            return Ok(tl::enums::InputPeer::UserFromMessage(Box::new(
555                tl::types::InputPeerUserFromMessage {
556                    peer: container,
557                    msg_id,
558                    user_id,
559                },
560            )));
561        }
562
563        // No hash at all.
564        if self.experimental.allow_zero_hash {
565            tracing::warn!(
566                "[ferogram::peer_cache] no access_hash cached for user {user_id}, using 0. \
567                 This is valid for bots but will cause USER_ID_INVALID on user accounts. \
568                 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
569            );
570            Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
571                user_id,
572                access_hash: 0,
573            }))
574        } else {
575            Err(InvocationError::PeerNotCached(format!(
576                "no access_hash cached for user {user_id}. \
577                 Ensure at least one message from this user flows through the \
578                 update loop before using them as a peer, or call \
579                 client.resolve_peer() first."
580            )))
581        }
582    }
583
584    fn channel_input_peer(&self, channel_id: i64) -> Result<tl::enums::InputPeer, InvocationError> {
585        if let Some(&(hash, _)) = self.channels.get(&channel_id) {
586            return Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
587                channel_id,
588                access_hash: hash,
589            }));
590        }
591
592        if self.experimental.allow_zero_hash {
593            tracing::warn!(
594                "[ferogram::peer_cache] no access_hash cached for channel {channel_id}, using 0. \
595                 This is valid for bots but will cause CHANNEL_INVALID on user accounts. \
596                 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
597            );
598            Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
599                channel_id,
600                access_hash: 0,
601            }))
602        } else {
603            Err(InvocationError::PeerNotCached(format!(
604                "no access_hash cached for channel {channel_id}. \
605                 Ensure the channel flows through the update loop before using \
606                 it as a peer, or call client.resolve_peer() first."
607            )))
608        }
609    }
610
611    /// Resolve a cached community ID to an `InputPeer`.
612    ///
613    /// A Community has no dedicated `InputPeer` variant on the wire, it is
614    /// addressed exactly like a channel (`InputPeer::Channel`), just tracked
615    /// in a separate cache bucket so it is never mistaken for one. Called
616    /// from [`PeerCache::peer_to_input`] as the fallback for a `Peer::Channel`
617    /// whose ID isn't in `channels` - a bare numeric ID always decodes to
618    /// `Peer::Channel` since there is no `Peer::Community`, so a community
619    /// looks exactly like an uncached channel until this check runs.
620    pub(crate) fn community_input_peer(
621        &self,
622        community_id: i64,
623    ) -> Result<tl::enums::InputPeer, InvocationError> {
624        if let Some(&hash) = self.communities.get(&community_id) {
625            return Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
626                channel_id: community_id,
627                access_hash: hash,
628            }));
629        }
630
631        if self.experimental.allow_zero_hash {
632            tracing::warn!(
633                "[ferogram::peer_cache] no access_hash cached for community {community_id}, using 0. \
634                 This is valid for bots but will cause CHANNEL_INVALID on user accounts. \
635                 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
636            );
637            Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
638                channel_id: community_id,
639                access_hash: 0,
640            }))
641        } else {
642            Err(InvocationError::PeerNotCached(format!(
643                "no access_hash cached for community {community_id}. \
644                 Ensure the community flows through the update loop before using \
645                 it as a peer, or call client.resolve_peer() first."
646            )))
647        }
648    }
649
650    /// Drop all cached min-user contexts.
651    ///
652    /// Safe to call at any time. After this, any min user that has no full
653    /// access_hash returns [`InvocationError::PeerNotCached`] until they
654    /// appear again in an update (and `cache_min_peers` is enabled) or until
655    /// `resolve_peer()` fetches their full hash.
656    ///
657    /// Call this periodically on gateway servers to bound memory growth from
658    /// min-user entries that accumulate over long uptimes.
659    pub fn clear_min_contexts(&mut self) {
660        self.min_contexts.clear();
661    }
662
663    /// A point-in-time snapshot of entry counts in this cache.
664    ///
665    /// Cheap to call, no allocation beyond the returned struct itself.
666    pub fn stats(&self) -> PeerCacheStats {
667        PeerCacheStats {
668            users: self.users.len(),
669            channels: self.channels.len(),
670            communities: self.communities.len(),
671            chats: self.chats.len(),
672            min_channels: self.channels_min.len(),
673            min_contexts: self.min_contexts.len(),
674            usernames: self.username_to_peer.len(),
675            phones: self.phone_to_user.len(),
676        }
677    }
678
679    pub fn peer_to_input(
680        &self,
681        peer: &tl::enums::Peer,
682    ) -> Result<tl::enums::InputPeer, InvocationError> {
683        match peer {
684            tl::enums::Peer::User(u) => self.user_input_peer(u.user_id),
685            tl::enums::Peer::Chat(c) => Ok(tl::enums::InputPeer::Chat(tl::types::InputPeerChat {
686                chat_id: c.chat_id,
687            })),
688            // `Peer::Channel` is what a bare numeric ID always decodes to
689            // (there is no `Peer::Community`), so a community and a channel
690            // are indistinguishable at this point. Check the community
691            // bucket before falling into `channel_input_peer`'s zero-hash
692            // fallback, otherwise a cached community hash would never be
693            // reached on accounts with `allow_zero_hash` enabled.
694            tl::enums::Peer::Channel(c) => {
695                if self.channels.contains_key(&c.channel_id) {
696                    self.channel_input_peer(c.channel_id)
697                } else if self.communities.contains_key(&c.channel_id) {
698                    self.community_input_peer(c.channel_id)
699                } else {
700                    self.channel_input_peer(c.channel_id)
701                }
702            }
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    fn community(id: i64, min: bool, access_hash: Option<i64>) -> tl::enums::Chat {
712        tl::enums::Chat::Community(tl::types::Community {
713            creator: false,
714            left: false,
715            min,
716            collapsed_in_dialogs: false,
717            id,
718            access_hash,
719            title: format!("community-{id}"),
720            photo: tl::enums::ChatPhoto::Empty,
721            date: 0,
722            admin_rights: None,
723            default_banned_rights: None,
724        })
725    }
726
727    fn community_forbidden(id: i64, access_hash: Option<i64>) -> tl::enums::Chat {
728        tl::enums::Chat::CommunityForbidden(tl::types::CommunityForbidden {
729            id,
730            access_hash,
731            title: format!("community-{id}"),
732        })
733    }
734
735    #[test]
736    fn cache_chat_stores_full_community() {
737        let mut cache = PeerCache::default();
738        cache.cache_chat(&community(100, false, Some(0xabc)));
739        assert_eq!(cache.communities.get(&100), Some(&0xabc));
740    }
741
742    #[test]
743    fn cache_chat_ignores_min_community() {
744        let mut cache = PeerCache::default();
745        cache.cache_chat(&community(101, true, None));
746        assert!(!cache.communities.contains_key(&101));
747    }
748
749    #[test]
750    fn cache_chat_never_downgrades_community_hash_to_zero() {
751        let mut cache = PeerCache::default();
752        cache.cache_chat(&community(102, false, Some(0xdead)));
753        cache.cache_chat(&community(102, false, Some(0)));
754        assert_eq!(cache.communities.get(&102), Some(&0xdead));
755    }
756
757    #[test]
758    fn cache_chat_stores_community_forbidden() {
759        let mut cache = PeerCache::default();
760        cache.cache_chat(&community_forbidden(103, Some(0x111)));
761        assert_eq!(cache.communities.get(&103), Some(&0x111));
762    }
763
764    #[test]
765    fn community_input_peer_resolves_cached_hash() {
766        let mut cache = PeerCache::default();
767        cache.cache_chat(&community(200, false, Some(0x999)));
768
769        let input = cache.community_input_peer(200).unwrap();
770        match input {
771            tl::enums::InputPeer::Channel(c) => {
772                assert_eq!(c.channel_id, 200);
773                assert_eq!(c.access_hash, 0x999);
774            }
775            other => panic!("expected InputPeer::Channel, got {other:?}"),
776        }
777    }
778
779    #[test]
780    fn community_input_peer_errors_when_uncached() {
781        let cache = PeerCache::default();
782        assert!(matches!(
783            cache.community_input_peer(999),
784            Err(InvocationError::PeerNotCached(_))
785        ));
786    }
787
788    #[test]
789    fn community_input_peer_falls_back_to_zero_hash_when_enabled() {
790        let cache = PeerCache::new(ExperimentalFeatures {
791            allow_zero_hash: true,
792            ..Default::default()
793        });
794        let input = cache.community_input_peer(321).unwrap();
795        match input {
796            tl::enums::InputPeer::Channel(c) => {
797                assert_eq!(c.channel_id, 321);
798                assert_eq!(c.access_hash, 0);
799            }
800            other => panic!("expected InputPeer::Channel, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn stats_reports_community_count() {
806        let mut cache = PeerCache::default();
807        cache.cache_chat(&community(400, false, Some(0x1)));
808        cache.cache_chat(&community(401, false, Some(0x2)));
809        assert_eq!(cache.stats().communities, 2);
810    }
811}