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