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