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::Community(c) => c.id,
63 tl::enums::Chat::CommunityForbidden(c) => c.id,
64 tl::enums::Chat::Empty(_) => continue,
65 };
66 map.insert(id, chat.clone());
67 }
68 if map.is_empty() {
69 None
70 } else {
71 Some(Arc::new(map))
72 }
73}
74
75/// Opt-in experimental behaviours that deviate from strict Telegram spec.
76///
77/// All flags default to `false` (safe / spec-correct). Enable only what you
78/// need after reading the per-field warnings.
79///
80/// # Example
81/// ```rust,no_run
82/// use ferogram::{Client, ExperimentalFeatures};
83///
84/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
85/// let (client, _sd) = Client::builder()
86/// .api_id(12345)
87/// .api_hash("abc")
88/// .experimental_features(ExperimentalFeatures {
89/// allow_zero_hash: true, // bot-only; omit for user accounts
90/// ..Default::default()
91/// })
92/// .connect().await?;
93/// # Ok(()) }
94/// ```
95#[derive(Clone, Debug, Default)]
96pub struct ExperimentalFeatures {
97 /// When no `access_hash` is cached for a user or channel, fall back to
98 /// `access_hash = 0` instead of returning [`InvocationError::PeerNotCached`].
99 ///
100 /// **Bot accounts only.** The Telegram spec explicitly permits `hash = 0`
101 /// for bots when only a min-hash is available. On user accounts this
102 /// produces `USER_ID_INVALID` / `CHANNEL_INVALID`.
103 pub allow_zero_hash: bool,
104
105 /// When resolving a min-user via `InputPeerUserFromMessage`, if the
106 /// containing channel's hash is not cached, proceed with
107 /// `channel access_hash = 0` instead of returning
108 /// [`InvocationError::PeerNotCached`].
109 ///
110 /// Almost always wrong. The inner `InputPeerChannel { access_hash: 0 }`
111 /// makes the whole `InputPeerUserFromMessage` invalid and Telegram will
112 /// reject it. Only useful for debugging / testing.
113 pub allow_missing_channel_hash: bool,
114
115 /// When `access_hash` is missing for a channel during `getChannelDifference`,
116 /// call `channels.getChannels` with `access_hash = 0` to fetch it, cache it,
117 /// and retry the diff in the same loop iteration.
118 ///
119 /// When false (the default), the diff is deferred: the entry stays alive and
120 /// the diff retries naturally once the hash arrives via a future update's
121 /// entity list.
122 ///
123 /// **Bot accounts only** for reliable operation. On user accounts
124 /// `channels.getChannels { access_hash: 0 }` succeeds only for public channels
125 /// and channels you are currently a member of.
126 pub auto_resolve_peers: bool,
127
128 /// Enable resumable uploads and downloads.
129 ///
130 /// When `true`, interrupted transfers save a checkpoint under
131 /// `checkpoint_dir` (defaults to `.ferogram-transfers/` next to the
132 /// session file). The next call with the same media / file automatically
133 /// resumes from where it left off.
134 ///
135 /// Upload sessions are valid for ~1 hour on Telegram's side; if more time
136 /// has passed the upload restarts from scratch automatically.
137 ///
138 /// Default: `false`.
139 pub resumable_transfers: bool,
140
141 /// Directory for transfer checkpoints when `resumable_transfers` is enabled.
142 /// If `None`, defaults to `.ferogram-transfers/` next to the session file.
143 pub checkpoint_dir: Option<std::path::PathBuf>,
144
145 /// Cache min-user message contexts (`InputPeerUserFromMessage` entries).
146 ///
147 /// When `false` (the default), users seen with `min=true` are silently
148 /// ignored instead of being stored. This keeps session files lean and is
149 /// the right choice for gateway / proxy servers that never need to address
150 /// min users directly.
151 ///
152 /// Enable only if your application needs to send messages or make API
153 /// calls targeting users that arrive exclusively as min-users (i.e. users
154 /// you have never seen with a full access_hash).
155 ///
156 /// Default: `false`.
157 pub cache_min_peers: bool,
158}
159
160/// Caches access hashes for users and channels so every API call carries the
161/// correct hash without re-resolving peers.
162/// A snapshot of what [`PeerCache`] currently holds.
163///
164/// Returned by [`PeerCache::stats`]. Useful for logging, monitoring, and
165/// deciding whether to call [`PeerCache::clear_min_contexts`].
166#[derive(Clone, Debug)]
167pub struct PeerCacheStats {
168 /// Full users with a valid access_hash.
169 pub users: usize,
170 /// Full channels with a valid access_hash.
171 pub channels: usize,
172 /// Regular group chats tracked by existence (no hash needed).
173 pub chats: usize,
174 /// Channels seen with `min=true` (no usable access_hash yet).
175 pub min_channels: usize,
176 /// Min-user message contexts (`InputPeerUserFromMessage` entries).
177 /// Always 0 when `cache_min_peers` is disabled.
178 pub min_contexts: usize,
179 /// Username reverse-index entries.
180 pub usernames: usize,
181 /// Phone number reverse-index entries.
182 pub phones: usize,
183}
184
185/// Discriminates the kind of peer stored in `PeerCache::username_to_peer`.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub enum PeerType {
188 User,
189 Channel,
190 Chat,
191}
192
193///
194/// All fields are `pub` so that `save_session` / `connect` can read/write them
195/// directly, and so that advanced callers can inspect the cache.
196pub struct PeerCache {
197 /// user_id -> access_hash (full users only, min=false)
198 pub users: HashMap<i64, i64>,
199 /// channel_id -> (access_hash, `Option<ChannelKind>`) (full channels only, min=false)
200 pub channels: HashMap<i64, (i64, Option<ChannelKind>)>,
201 /// Regular group chat IDs (Chat::Chat / ChatForbidden).
202 /// Groups need no access_hash; track existence for peer validation.
203 pub chats: HashSet<i64>,
204 /// Channel IDs seen with min=true. These are real channels but have no
205 /// valid access_hash. Stored separately so they are NEVER confused with
206 /// regular groups. DO NOT put min channels in `chats`. A min channel must
207 /// never become InputPeerChat - that causes fatal RPC failures.
208 pub channels_min: HashSet<i64>,
209 /// user_id -> (peer_id, msg_id) for min users seen in a message context.
210 /// Min users have an invalid access_hash; they must be referenced via
211 /// InputPeerUserFromMessage using the peer and message where they appeared.
212 pub min_contexts: HashMap<i64, (i64, i32)>,
213 /// Reverse index: lowercase username → (id, PeerType).
214 /// Populated by cache_user / cache_chat; always overwritten on update
215 /// (usernames can change).
216 pub username_to_peer: HashMap<String, (i64, PeerType)>,
217 /// Reverse index: E.164 phone → user_id.
218 pub phone_to_user: HashMap<String, i64>,
219 /// Experimental opt-ins that change error-vs-fallback behaviour.
220 pub(crate) experimental: ExperimentalFeatures,
221}
222
223impl Default for PeerCache {
224 fn default() -> Self {
225 Self::new(ExperimentalFeatures::default())
226 }
227}
228
229impl PeerCache {
230 /// Create a new empty cache with the given experimental-feature flags.
231 pub fn new(experimental: ExperimentalFeatures) -> Self {
232 Self {
233 users: HashMap::new(),
234 channels: HashMap::new(),
235 chats: HashSet::new(),
236 channels_min: HashSet::new(),
237 min_contexts: HashMap::new(),
238 username_to_peer: HashMap::new(),
239 phone_to_user: HashMap::new(),
240 experimental,
241 }
242 }
243
244 pub fn cache_user(&mut self, user: &tl::enums::User) {
245 if let tl::enums::User::User(u) = user {
246 if u.min {
247 // min=true: access_hash is not valid; requires a message context.
248 } else if let Some(hash) = u.access_hash {
249 // Never overwrite a valid non-zero hash with zero.
250 if hash != 0 {
251 self.users.insert(u.id, hash);
252 } else {
253 self.users.entry(u.id).or_insert(0);
254 }
255 // Full user always supersedes any min context.
256 self.min_contexts.remove(&u.id);
257 }
258 // Reverse indices (update even for min users so username lookup works)
259 if let Some(ref uname) = u.username {
260 self.username_to_peer
261 .insert(uname.to_lowercase(), (u.id, PeerType::User));
262 }
263 if let Some(ref phone) = u.phone {
264 self.phone_to_user.insert(phone.clone(), u.id);
265 }
266 }
267 }
268
269 /// Cache a user that arrived in a message context.
270 ///
271 /// For min users (access_hash is invalid), stores the peer+msg context so
272 /// they can later be referenced via `InputPeerUserFromMessage`.
273 ///
274 /// Uses **latest-wins** semantics: a newer message context replaces the
275 /// stored one. Recent messages are less likely to have been deleted.
276 pub fn cache_user_with_context(&mut self, user: &tl::enums::User, peer_id: i64, msg_id: i32) {
277 if let tl::enums::User::User(u) = user {
278 if u.min {
279 // Never downgrade a cached full user to a min context.
280 if self.experimental.cache_min_peers && !self.users.contains_key(&u.id) {
281 // Latest-wins: overwrite with the most recent message context.
282 self.min_contexts.insert(u.id, (peer_id, msg_id));
283 }
284 } else if let Some(hash) = u.access_hash {
285 // Never overwrite a non-zero hash with zero.
286 if hash != 0 {
287 self.users.insert(u.id, hash);
288 } else {
289 self.users.entry(u.id).or_insert(0);
290 }
291 self.min_contexts.remove(&u.id);
292 }
293 // Reverse indices
294 if let Some(ref uname) = u.username {
295 self.username_to_peer
296 .insert(uname.to_lowercase(), (u.id, PeerType::User));
297 }
298 if let Some(ref phone) = u.phone {
299 self.phone_to_user.insert(phone.clone(), u.id);
300 }
301 }
302 }
303
304 pub fn cache_chat(&mut self, chat: &tl::enums::Chat) {
305 match chat {
306 tl::enums::Chat::Channel(c) => {
307 let kind = if c.megagroup {
308 Some(ChannelKind::Megagroup)
309 } else if c.gigagroup {
310 Some(ChannelKind::Gigagroup)
311 } else {
312 Some(ChannelKind::Broadcast)
313 };
314 if c.min {
315 // min channel: no access_hash available.
316 // Store in channels_min; never put in chats (InputPeerChat fails).
317 if !self.channels.contains_key(&c.id) {
318 self.channels_min.insert(c.id);
319 }
320 } else if let Some(hash) = c.access_hash {
321 // Never overwrite a valid non-zero hash with zero.
322 if hash != 0 {
323 self.channels.insert(c.id, (hash, kind));
324 } else {
325 self.channels.entry(c.id).or_insert((0, kind));
326 }
327 // Full channel supersedes any min tracking.
328 self.channels_min.remove(&c.id);
329 }
330 // Reverse username index for channels (update regardless of min)
331 if let Some(ref uname) = c.username {
332 self.username_to_peer
333 .insert(uname.to_lowercase(), (c.id, PeerType::Channel));
334 }
335 }
336 tl::enums::Chat::ChannelForbidden(c) => {
337 // ChannelForbidden has no flags; treat as Broadcast kind.
338 if c.access_hash != 0 {
339 self.channels
340 .insert(c.id, (c.access_hash, Some(ChannelKind::Broadcast)));
341 } else {
342 self.channels
343 .entry(c.id)
344 .or_insert((0, Some(ChannelKind::Broadcast)));
345 }
346 self.channels_min.remove(&c.id);
347 }
348 tl::enums::Chat::Chat(c) => {
349 // Regular groups need no access_hash; track existence only.
350 self.chats.insert(c.id);
351 }
352 tl::enums::Chat::Forbidden(c) => {
353 self.chats.insert(c.id);
354 }
355 _ => {}
356 }
357 }
358
359 /// Look up the cached [`ChannelKind`] for a channel ID.
360 ///
361 /// Returns `None` when the channel is not in the cache or was loaded from a
362 /// pre-v6 session file that predates kind tracking.
363 pub fn channel_kind_of(&self, channel_id: i64) -> Option<ChannelKind> {
364 self.channels.get(&channel_id).and_then(|&(_, k)| k)
365 }
366
367 pub fn cache_users(&mut self, users: &[tl::enums::User]) {
368 for u in users {
369 self.cache_user(u);
370 }
371 }
372
373 pub fn cache_chats(&mut self, chats: &[tl::enums::Chat]) {
374 for c in chats {
375 self.cache_chat(c);
376 }
377 }
378
379 /// Store an already-resolved `InputPeer`'s access hash into the cache.
380 ///
381 /// Called when a caller provides a `PeerRef::Input` so that the subsequent
382 /// `peer_to_input` lookup succeeds without an RPC.
383 pub fn cache_input_peer(&mut self, ip: &tl::enums::InputPeer) {
384 match ip {
385 tl::enums::InputPeer::User(u) => {
386 if u.access_hash != 0 {
387 self.users.insert(u.user_id, u.access_hash);
388 } else {
389 self.users.entry(u.user_id).or_insert(0);
390 }
391 self.min_contexts.remove(&u.user_id);
392 }
393 tl::enums::InputPeer::Channel(c) => {
394 if c.access_hash != 0 {
395 self.channels
396 .entry(c.channel_id)
397 .and_modify(|e| e.0 = c.access_hash)
398 .or_insert((c.access_hash, None));
399 } else {
400 self.channels.entry(c.channel_id).or_insert((0, None));
401 }
402 self.channels_min.remove(&c.channel_id);
403 }
404 tl::enums::InputPeer::Chat(c) => {
405 self.chats.insert(c.chat_id);
406 }
407 // UserFromMessage: cache the container peer's hash AND record the
408 // min_context so peer_to_input() can rebuild InputPeerUserFromMessage.
409 tl::enums::InputPeer::UserFromMessage(u) => {
410 // Cache the container peer's access hash
411 self.cache_input_peer(&u.peer);
412 // Extract container peer_id for the min_context entry
413 let container_peer_id = match &u.peer {
414 tl::enums::InputPeer::Channel(c) => Some(c.channel_id),
415 tl::enums::InputPeer::Chat(c) => Some(c.chat_id),
416 tl::enums::InputPeer::User(pu) => Some(pu.user_id),
417 tl::enums::InputPeer::PeerSelf => Some(0i64),
418 _ => None,
419 };
420 if let Some(peer_id) = container_peer_id {
421 // Only set min_context if there is no full hash cached yet.
422 if self.experimental.cache_min_peers && !self.users.contains_key(&u.user_id) {
423 self.min_contexts.insert(u.user_id, (peer_id, u.msg_id));
424 }
425 }
426 }
427 // ChannelFromMessage: cache the container peer hash and channel entry.
428 tl::enums::InputPeer::ChannelFromMessage(c) => {
429 self.cache_input_peer(&c.peer);
430 // The channel itself has no standalone hash here; mark as known
431 // via channels_min so we don't lose track of it.
432 self.channels_min.insert(c.channel_id);
433 }
434 tl::enums::InputPeer::Empty | tl::enums::InputPeer::PeerSelf => {}
435 }
436 }
437
438 /// Remove stale cache entries when Telegram rejects them with
439 /// `PEER_ID_INVALID`, `CHANNEL_INVALID`, `USER_ID_INVALID`, or
440 /// `CHANNEL_PRIVATE`. The caller should then retry the operation.
441 pub fn invalidate_peer(&mut self, peer: &tl::enums::Peer) {
442 match peer {
443 tl::enums::Peer::User(u) => {
444 self.users.remove(&u.user_id);
445 self.min_contexts.remove(&u.user_id);
446 }
447 tl::enums::Peer::Channel(c) => {
448 self.channels.remove(&c.channel_id);
449 self.channels_min.remove(&c.channel_id);
450 }
451 tl::enums::Peer::Chat(_) => {} // basic groups have no hash to invalidate
452 }
453 }
454
455 pub(crate) fn user_input_peer(
456 &self,
457 user_id: i64,
458 ) -> Result<tl::enums::InputPeer, InvocationError> {
459 if user_id == 0 {
460 return Ok(tl::enums::InputPeer::PeerSelf);
461 }
462
463 // Full hash: best case.
464 if let Some(&hash) = self.users.get(&user_id) {
465 return Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
466 user_id,
467 access_hash: hash,
468 }));
469 }
470
471 // Min user: resolve via the message context where they were seen.
472 if let Some(&(peer_id, msg_id)) = self.min_contexts.get(&user_id) {
473 // The containing peer can be a channel, a basic group, or a DM user.
474 // Build the correct InputPeer variant for each case.
475 let container = if let Some(&(hash, _)) = self.channels.get(&peer_id) {
476 tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
477 channel_id: peer_id,
478 access_hash: hash,
479 })
480 } else if self.channels_min.contains(&peer_id) {
481 if self.experimental.allow_missing_channel_hash {
482 tracing::warn!(
483 "[ferogram::peer_cache] channel {peer_id} is a min peer \
484 (seen inside message for user {user_id}), using access_hash=0. \
485 This will likely cause CHANNEL_INVALID on user accounts. \
486 Call client.resolve_peer() to get a full access_hash first."
487 );
488 tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
489 channel_id: peer_id,
490 access_hash: 0,
491 })
492 } else {
493 return Err(InvocationError::PeerNotCached(format!(
494 "min user {user_id} was seen in channel {peer_id}, \
495 but that channel is only known as a min channel (no access_hash). \
496 Resolve the channel first, or enable \
497 ExperimentalFeatures::allow_missing_channel_hash."
498 )));
499 }
500 } else if self.chats.contains(&peer_id) {
501 // Basic group: no access_hash needed.
502 tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: peer_id })
503 } else if let Some(&hash) = self.users.get(&peer_id) {
504 // DM: min user was seen in a direct message with another user.
505 tl::enums::InputPeer::User(tl::types::InputPeerUser {
506 user_id: peer_id,
507 access_hash: hash,
508 })
509 } else {
510 return Err(InvocationError::PeerNotCached(format!(
511 "min user {user_id} was seen in peer {peer_id}, \
512 but that peer is not cached (not a known channel, chat, or user). \
513 Ensure the containing chat flows through the update loop first."
514 )));
515 };
516 return Ok(tl::enums::InputPeer::UserFromMessage(Box::new(
517 tl::types::InputPeerUserFromMessage {
518 peer: container,
519 msg_id,
520 user_id,
521 },
522 )));
523 }
524
525 // No hash at all.
526 if self.experimental.allow_zero_hash {
527 tracing::warn!(
528 "[ferogram::peer_cache] no access_hash cached for user {user_id}, using 0. \
529 This is valid for bots but will cause USER_ID_INVALID on user accounts. \
530 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
531 );
532 Ok(tl::enums::InputPeer::User(tl::types::InputPeerUser {
533 user_id,
534 access_hash: 0,
535 }))
536 } else {
537 Err(InvocationError::PeerNotCached(format!(
538 "no access_hash cached for user {user_id}. \
539 Ensure at least one message from this user flows through the \
540 update loop before using them as a peer, or call \
541 client.resolve_peer() first."
542 )))
543 }
544 }
545
546 fn channel_input_peer(&self, channel_id: i64) -> Result<tl::enums::InputPeer, InvocationError> {
547 if let Some(&(hash, _)) = self.channels.get(&channel_id) {
548 return Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
549 channel_id,
550 access_hash: hash,
551 }));
552 }
553
554 if self.experimental.allow_zero_hash {
555 tracing::warn!(
556 "[ferogram::peer_cache] no access_hash cached for channel {channel_id}, using 0. \
557 This is valid for bots but will cause CHANNEL_INVALID on user accounts. \
558 Disable ExperimentalFeatures::allow_zero_hash or call resolve_peer() first."
559 );
560 Ok(tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
561 channel_id,
562 access_hash: 0,
563 }))
564 } else {
565 Err(InvocationError::PeerNotCached(format!(
566 "no access_hash cached for channel {channel_id}. \
567 Ensure the channel flows through the update loop before using \
568 it as a peer, or call client.resolve_peer() first."
569 )))
570 }
571 }
572
573 /// Drop all cached min-user contexts.
574 ///
575 /// Safe to call at any time. After this, any min user that has no full
576 /// access_hash returns [`InvocationError::PeerNotCached`] until they
577 /// appear again in an update (and `cache_min_peers` is enabled) or until
578 /// `resolve_peer()` fetches their full hash.
579 ///
580 /// Call this periodically on gateway servers to bound memory growth from
581 /// min-user entries that accumulate over long uptimes.
582 pub fn clear_min_contexts(&mut self) {
583 self.min_contexts.clear();
584 }
585
586 /// A point-in-time snapshot of entry counts in this cache.
587 ///
588 /// Cheap to call, no allocation beyond the returned struct itself.
589 pub fn stats(&self) -> PeerCacheStats {
590 PeerCacheStats {
591 users: self.users.len(),
592 channels: self.channels.len(),
593 chats: self.chats.len(),
594 min_channels: self.channels_min.len(),
595 min_contexts: self.min_contexts.len(),
596 usernames: self.username_to_peer.len(),
597 phones: self.phone_to_user.len(),
598 }
599 }
600
601 pub fn peer_to_input(
602 &self,
603 peer: &tl::enums::Peer,
604 ) -> Result<tl::enums::InputPeer, InvocationError> {
605 match peer {
606 tl::enums::Peer::User(u) => self.user_input_peer(u.user_id),
607 tl::enums::Peer::Chat(c) => Ok(tl::enums::InputPeer::Chat(tl::types::InputPeerChat {
608 chat_id: c.chat_id,
609 })),
610 tl::enums::Peer::Channel(c) => self.channel_input_peer(c.channel_id),
611 }
612 }
613}