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}