From 58c9cef5325fae9d51da278200923bee4ff9a8f1 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Wed, 11 Nov 2026 10:07:17 -0500 Subject: [PATCH] Keep the fake Telegram's clock inside the Inbox window The Inbox leaves out a chat nobody touched for thirty days. The fake Telegram stamped its whole world at a fixed 2026-10-13 12:00 UTC, so at 12:00 UTC on 2026-11-11 its older chats aged out and the_chats_are_one_list_with_no_space_each_filed_in_every_folder_it_matches failed on every platform, on every open pull request, with no code change. The fake now starts its clock at TINYCLIENT_TELEGRAM_T0 (unix seconds) and falls back to the old fixed date. The test harness sets it to yesterday's midnight UTC, and the two assertions that named absolute times are relative to it. Co-Authored-By: Claude Sonnet 5.5 --- crates/chatd/src/adapters/telegram/fake.rs | 128 +++++++++++---------- crates/chatd/tests/telegram.rs | 18 ++- 2 files changed, 82 insertions(+), 64 deletions(-) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index bacead82..3b0c575d 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -62,8 +62,14 @@ const FAMILY: i32 = 3; const CROWD: i64 = 100_000; const CODE: &str = "24680"; const PASSWORD: &str = "correct horse"; -/// 2026-10-13 12:00 UTC, so every run stores the same times. -const T0: i32 = 1_791_892_800; +/// 2026-10-13 12:00 UTC, so every run stores the same times unless the harness says otherwise. +const FIXED_T0: i32 = 1_791_892_800; +/// Where the fake's clock starts, in unix seconds. `TINYCLIENT_TELEGRAM_T0` moves it: the Inbox leaves out +/// a chat nobody touched for thirty days, so a world pinned to one date stops matching the day it ages out. +fn t0() -> i32 { + static T0: std::sync::OnceLock = std::sync::OnceLock::new(); + *T0.get_or_init(|| std::env::var("TINYCLIENT_TELEGRAM_T0").ok().and_then(|seconds| seconds.parse().ok()).unwrap_or(FIXED_T0)) +} /// The prime Telegram itself uses for the password proof, with generator 3. const PRIME: &str = "c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b"; @@ -95,7 +101,7 @@ fn user(id: i64, first: &str, last: &str, username: &str) -> tl::types::User { user.is_self = id == ME; user.contact = id == ODALYS; if id == ODALYS { - user.status = Some(tl::types::UserStatusOnline { expires: T0 + 300 }.into()); + user.status = Some(tl::types::UserStatusOnline { expires: t0() + 300 }.into()); user.photo = Some(tl::types::UserProfilePhoto { has_video: false, personal: false, photo_id: 5_002, stripped_thumb: None, dc_id: 2 }.into()); } if id == TOBIN { @@ -140,7 +146,7 @@ fn users() -> Vec { fn a_channel(id: i64, title: &str, broadcast: bool, forum: bool, username: &str) -> tl::types::Channel { let mut channel: tl::types::Channel = - blank(&[&put(0u32), &put(0u32), &put(id), &put(title.to_owned()), &put(tl::enums::ChatPhoto::Empty), &put(T0 - 86_400)]); + blank(&[&put(0u32), &put(0u32), &put(id), &put(title.to_owned()), &put(tl::enums::ChatPhoto::Empty), &put(t0() - 86_400)]); channel.access_hash = Some(id * 1_000 + 9); channel.broadcast = broadcast; channel.megagroup = !broadcast; @@ -161,7 +167,7 @@ fn chats() -> Vec { title: "Trail crew".to_owned(), photo: tl::enums::ChatPhoto::Empty, participants_count: 3, - date: T0 - 86_400, + date: t0() - 86_400, version: 1, migrated_to: None, admin_rights: None, @@ -246,7 +252,7 @@ fn dialog(peer: Who, top: i32, read: i32, outbox: i32, unread: i32, pinned: bool /// Telegram's word for the text a chat's draft holds, written by its own date. fn draft_message(text: &str) -> tl::enums::DraftMessage { - let draft: tl::types::DraftMessage = blank(&[&put(0u32), &put(text.to_owned()), &put(T0)]); + let draft: tl::types::DraftMessage = blank(&[&put(0u32), &put(text.to_owned()), &put(t0())]); draft.into() } @@ -268,7 +274,7 @@ fn photo(id: i64) -> tl::enums::Photo { id, access_hash: id + 1, file_reference: vec![1, 2, 3], - date: T0, + date: t0(), sizes: vec![ tl::types::PhotoStrippedSize { r#type: "i".to_owned(), bytes: vec![1, 20, 30] }.into(), tl::types::PhotoSize { r#type: "m".to_owned(), w: 320, h: 240, size: 4_096 + 9 }.into(), @@ -377,34 +383,34 @@ impl World { file_at_once: 0, }; let odalys = Who::User(ODALYS); - world.store(None, message(odalys, 10, ODALYS, T0 - 300, "Are you coming to the ridge on Saturday?")); - world.store(None, message(odalys, 11, ME, T0 - 240, "Yes. I'll bring the stove.")); - world.store(None, message(odalys, 12, ODALYS, T0 - 60, "Good. Trailhead at seven.")); - world.store(None, message(Who::Group(CREW), 20, TOBIN, T0 - 3_600, "Maps are in the shared folder.")); - world.store(None, message(Who::Channel(NOTES), 5, 0, T0 - 7_200, "Snow line is at 1,900 m this week.")); - let mut tools = message(Who::Channel(WORKSHOP), 41, TOBIN, T0 - 500, "Who has the torque wrench?"); + world.store(None, message(odalys, 10, ODALYS, t0() - 300, "Are you coming to the ridge on Saturday?")); + world.store(None, message(odalys, 11, ME, t0() - 240, "Yes. I'll bring the stove.")); + world.store(None, message(odalys, 12, ODALYS, t0() - 60, "Good. Trailhead at seven.")); + world.store(None, message(Who::Group(CREW), 20, TOBIN, t0() - 3_600, "Maps are in the shared folder.")); + world.store(None, message(Who::Channel(NOTES), 5, 0, t0() - 7_200, "Snow line is at 1,900 m this week.")); + let mut tools = message(Who::Channel(WORKSHOP), 41, TOBIN, t0() - 500, "Who has the torque wrench?"); if held_at_wire("TINYCLIENT_TELEGRAM_FORUM_TTL") { tools.date = crate::hub::now() as i32; tools.ttl_period = Some(10); } world.store(Some(TOOLS), in_topic(tools, TOOLS)); - world.store(Some(JIGS), in_topic(message(Who::Channel(WORKSHOP), JIGS, TOBIN, T0 - 200, "First jig done: the box joints came out clean."), JIGS)); - world.store(None, message(Who::User(TOBIN), 30, TOBIN, T0 - 90_000, "Thanks for the lift.")); + world.store(Some(JIGS), in_topic(message(Who::Channel(WORKSHOP), JIGS, TOBIN, t0() - 200, "First jig done: the box joints came out clean."), JIGS)); + world.store(None, message(Who::User(TOBIN), 30, TOBIN, t0() - 90_000, "Thanks for the lift.")); // My newest word to Tobin, which his dialog's read-outbox covers: the row's ✓✓ at boot. - world.store(None, message(Who::User(TOBIN), 31, ME, T0 - 80_000, "Trailhead at seven, then.")); - world.store(None, message(Who::User(ME), 2, ME, T0 - 100_000, "Packing list: stove, map, headlamp.")); - world.store(None, message(Who::User(SERVICE), 3, SERVICE, T0 - 110_000, "Login code: 24680. Do not give this code to anyone.")); - world.store(None, message(Who::User(BOT), 4, BOT, T0 - 120_000, "Trail report: all clear.")); + world.store(None, message(Who::User(TOBIN), 31, ME, t0() - 80_000, "Trailhead at seven, then.")); + world.store(None, message(Who::User(ME), 2, ME, t0() - 100_000, "Packing list: stove, map, headlamp.")); + world.store(None, message(Who::User(SERVICE), 3, SERVICE, t0() - 110_000, "Login code: 24680. Do not give this code to anyone.")); + world.store(None, message(Who::User(BOT), 4, BOT, t0() - 120_000, "Trail report: all clear.")); // My newest word to the bot, which its dialog's read-outbox stops below: ✓ without ✓✓. - world.store(None, message(Who::User(BOT), 5, ME, T0 - 115_000, "Any closures this weekend?")); - world.store(None, message(Who::User(SCAM), 6, SCAM, T0 - 130_000, "You won a prize!")); - world.store(None, message(Who::User(FAKE), 7, FAKE, T0 - 140_000, "Your account needs verification.")); + world.store(None, message(Who::User(BOT), 5, ME, t0() - 115_000, "Any closures this weekend?")); + world.store(None, message(Who::User(SCAM), 6, SCAM, t0() - 130_000, "You won a prize!")); + world.store(None, message(Who::User(FAKE), 7, FAKE, t0() - 140_000, "Your account needs verification.")); // A draft typed on the phone before this device signed in: it rides Odalys's dialog at // the next reading of it, as any draft typed elsewhere does. world.drafts.insert((Who::User(ODALYS), None), "Finished packing. Meet at the ridge?".to_owned()); for at in 0..crowd() { let who = Who::User(CROWD + at); - world.store(None, message(who, 1, CROWD + at, T0 - 200_000 - at as i32 * 60, &format!("Hello from {at}"))); + world.store(None, message(who, 1, CROWD + at, t0() - 200_000 - at as i32 * 60, &format!("Hello from {at}"))); } world } @@ -618,7 +624,7 @@ fn input_who(peer: &tl::enums::InputPeer) -> Who { } fn updates(updates: Vec) -> tl::enums::Updates { - tl::types::Updates { updates, users: users(), chats: chats(), date: T0, seq: 0 }.into() + tl::types::Updates { updates, users: users(), chats: chats(), date: t0(), seq: 0 }.into() } impl World { @@ -658,12 +664,12 @@ fn discussion_answer(place: &str, unread: i32) -> Result, Fault> { if place != key(Who::Channel(NOTES), Some(5)) { return Err(rpc(400, "MESSAGE_ID_INVALID", None)); } - let mut echo = message(Who::Channel(CHATTERS), ECHO, ME, T0 - 7_200, "Snow line is at 1,900 m this week."); + let mut echo = message(Who::Channel(CHATTERS), ECHO, ME, t0() - 7_200, "Snow line is at 1,900 m this week."); // The echo is the channel's post standing in the group: no author of its own, and the // forward header names the post it says again. echo.from_id = None; echo.out = false; - let mut from: tl::types::MessageFwdHeader = blank(&[&put(0u32), &put(T0 - 7_200)]); + let mut from: tl::types::MessageFwdHeader = blank(&[&put(0u32), &put(t0() - 7_200)]); from.from_id = Some(Who::Channel(NOTES).peer()); from.channel_post = Some(5); echo.fwd_from = Some(from.into()); @@ -725,9 +731,9 @@ fn topics_answer(world: &mut World, forum: Who) -> Result, Fault> { order_by_create_date: false, count: 3, topics: vec![ - topic(world, JIGS, "Jigs", JIGS, T0 - 200, 2, 5000), - topic(world, TOOLS, "Tools", top, T0 - 40_000, 1, 5001), - topic(world, 1, "General", 1, T0 - 90_000, 0, 5002), + topic(world, JIGS, "Jigs", JIGS, t0() - 200, 2, 5000), + topic(world, TOOLS, "Tools", top, t0() - 40_000, 1, 5001), + topic(world, 1, "General", 1, t0() - 90_000, 0, 5002), ], messages, chats: chats(), @@ -892,7 +898,7 @@ impl World { // Bold on an accented word, a link on an emoji, a mention by id, code, a spoiler. "incoming" => { let text = "Café at 🦀 dock, ask Tobin: stove.fuel() secret"; - let mut message = message(odalys, 13, ODALYS, T0 + 10, text); + let mut message = message(odalys, 13, ODALYS, t0() + 10, text); message.entities = Some(vec![ tl::types::MessageEntityBold { offset: 0, length: 4 }.into(), tl::types::MessageEntityTextUrl { offset: 8, length: 2, url: "https://example.org/dock".to_owned() }.into(), @@ -907,7 +913,7 @@ impl World { // is the plain emoji Telegram chose as the alt glyph. "emoji-text" => { let text = "Cast off 🦀 at dawn"; - let mut message = message(odalys, 22, ODALYS, T0 + 90, text); + let mut message = message(odalys, 22, ODALYS, t0() + 90, text); message.entities = Some(vec![ tl::types::MessageEntityCustomEmoji { offset: 9, length: 2, document_id: 5002 }.into(), ]); @@ -917,7 +923,7 @@ impl World { // The same while its conversation is on screen, with a still emoji's document. "emoji-text-live" => { let text = "Back at the 🔥 bench"; - let mut message = message(odalys, 23, ODALYS, T0 + 100, text); + let mut message = message(odalys, 23, ODALYS, t0() + 100, text); message.entities = Some(vec![ tl::types::MessageEntityCustomEmoji { offset: 12, length: 2, document_id: 5001 }.into(), ]); @@ -925,7 +931,7 @@ impl World { vec![new(message)] } "photo" => { - let mut message = message(odalys, 14, ODALYS, T0 + 20, "The view from the ridge"); + let mut message = message(odalys, 14, ODALYS, t0() + 20, "The view from the ridge"); message.media = Some(tl::types::MessageMediaPhoto { spoiler: false, live_photo: false, photo: Some(photo(6_014)), ttl_seconds: None, video: None }.into()); self.store(None, message.clone()); vec![new(message)] @@ -972,7 +978,7 @@ impl World { // A comment gains reactions, as the thread's next page carries them: the page // answer is all that changes, nothing is told on the update stream. "comment-react" => { - let mut comment = message(Who::Channel(CHATTERS), 51, ODALYS, T0 - 40, "Yes, at the hut now."); + let mut comment = message(Who::Channel(CHATTERS), 51, ODALYS, t0() - 40, "Yes, at the hut now."); comment.reactions = Some(comment_reactions().into()); // The thread's page is kept under the post's id, as `getReplies` counts it. self.messages.entry(key(Who::Channel(NOTES), Some(5))).or_default().insert(51, comment.into()); @@ -990,8 +996,8 @@ impl World { }.into())] } "edit" => { - let mut message = message(odalys, 13, ODALYS, T0 + 10, "Café at the dock, seven sharp"); - message.edit_date = Some(T0 + 30); + let mut message = message(odalys, 13, ODALYS, t0() + 10, "Café at the dock, seven sharp"); + message.edit_date = Some(t0() + 30); self.store(None, message.clone()); vec![update(tl::types::UpdateEditMessage { message: message.into(), pts: 0, pts_count: 0 }.into())] } @@ -1005,7 +1011,7 @@ impl World { // still to live: the store never shows the first and drops the second at its // moment, whether the account is online or not. "ttl" => { - let mut gone = message(odalys, 17, ODALYS, T0 - 3_600, "Gone before it arrived"); + let mut gone = message(odalys, 17, ODALYS, t0() - 3_600, "Gone before it arrived"); gone.ttl_period = Some(60); let mut alive = message(odalys, 18, ODALYS, crate::hub::now() as i32, "Vanishes in a moment"); alive.ttl_period = Some(10); @@ -1023,7 +1029,7 @@ impl World { vec![update(tl::types::UpdateDeleteMessages { messages: vec![17, 18], pts: 0, pts_count: 2 }.into())] } "topic" => { - let message = in_topic(message(Who::Channel(WORKSHOP), 42, TOBIN, T0 + 40, "Found it, bottom drawer."), TOOLS); + let message = in_topic(message(Who::Channel(WORKSHOP), 42, TOBIN, t0() + 40, "Found it, bottom drawer."), TOOLS); self.store(Some(TOOLS), message.clone()); vec![update(tl::types::UpdateNewChannelMessage { message: message.into(), pts: 0, pts_count: 0 }.into())] } @@ -1057,7 +1063,7 @@ impl World { } // Odalys goes offline; Tobin's last-seen stays hidden. "status" => vec![ - update(tl::types::UpdateUserStatus { user_id: ODALYS, status: tl::types::UserStatusOffline { was_online: T0 }.into() }.into()), + update(tl::types::UpdateUserStatus { user_id: ODALYS, status: tl::types::UserStatusOffline { was_online: t0() }.into() }.into()), update(tl::types::UpdateUserStatus { user_id: TOBIN, status: tl::enums::UserStatus::Recently(blank(&[&put(0u32)])) }.into()), ], "poll" => { @@ -1082,7 +1088,7 @@ impl World { hash: 0, }; let results: tl::types::PollResults = blank(&[&put(0u32)]); - let mut message = message(Who::Group(CREW), 21, TOBIN, T0 + 50, ""); + let mut message = message(Who::Group(CREW), 21, TOBIN, t0() + 50, ""); message.media = Some(tl::enums::MessageMedia::Poll(Box::new(tl::types::MessageMediaPoll { poll: poll.into(), results: results.into(), attached_media: None }))); self.store(None, message.clone()); vec![update(tl::types::UpdateNewMessage { message: message.into(), pts: 0, pts_count: 0 }.into())] @@ -1092,7 +1098,7 @@ impl World { id: 6_100, access_hash: 6_101, file_reference: vec![4], - date: T0, + date: t0(), mime_type: "audio/ogg".to_owned(), size: 18_000, thumbs: None, @@ -1107,7 +1113,7 @@ impl World { } .into()], }; - let mut message = message(odalys, 15, ODALYS, T0 + 60, ""); + let mut message = message(odalys, 15, ODALYS, t0() + 60, ""); message.media = Some( tl::types::MessageMediaDocument { nopremium: false, @@ -1128,8 +1134,8 @@ impl World { } // A post from the channel, forwarded into the direct chat as an answer to message 12. "forward" => { - let mut message = message(odalys, 16, ODALYS, T0 + 70, "Snow line is at 1,900 m this week."); - let mut from: tl::types::MessageFwdHeader = blank(&[&put(0u32), &put(T0 - 7_200)]); + let mut message = message(odalys, 16, ODALYS, t0() + 70, "Snow line is at 1,900 m this week."); + let mut from: tl::types::MessageFwdHeader = blank(&[&put(0u32), &put(t0() - 7_200)]); from.from_id = Some(Who::Channel(NOTES).peer()); from.channel_post = Some(5); message.fwd_from = Some(from.into()); @@ -1144,13 +1150,13 @@ impl World { // after the word says how many of them the user has not read. "comments" => { self.comments_unread = words.next().and_then(|count| count.parse().ok()).unwrap_or(0); - let mut post = message(Who::Channel(NOTES), 5, 0, T0 - 7_200, "Snow line is at 1,900 m this week."); + let mut post = message(Who::Channel(NOTES), 5, 0, t0() - 7_200, "Snow line is at 1,900 m this week."); post.replies = Some(blank::(&[&put(1u32), &put(2i32), &put(0i32), &put(CHATTERS)]).into()); self.store(None, post.clone()); // A comment is a message of the discussion group, which `getReplies` answers // for the post itself, so it is kept under the post's thread here. let thread = self.messages.entry(key(Who::Channel(NOTES), Some(5))).or_default(); - for (id, from, date, text) in [(50, TOBIN, T0 - 100, "On my way up front?"), (51, ODALYS, T0 - 40, "Yes, at the hut now.")] { + for (id, from, date, text) in [(50, TOBIN, t0() - 100, "On my way up front?"), (51, ODALYS, t0() - 40, "Yes, at the hut now.")] { thread.insert(id, message(Who::Channel(CHATTERS), id, from, date, text).into()); } vec![update(tl::types::UpdateEditChannelMessage { message: post.into(), pts: 0, pts_count: 0 }.into())] @@ -1170,7 +1176,7 @@ impl World { peer_id: Who::Group(CREW).peer(), saved_peer_id: None, reply_to: None, - date: T0 + 80, + date: t0() + 80, action: tl::types::MessageActionChatEditTitle { title: "Ridge crew".to_owned() }.into(), reactions: None, ttl_period: None, @@ -1213,13 +1219,13 @@ impl World { vec![update(tl::types::UpdateNotifySettings { peer: tl::enums::NotifyPeer::NotifyBroadcasts, notify_settings: settings(None) }.into())] } Some("group") => { - let mut said = message(Who::Group(CREW), 21, TOBIN, T0 + 90, "Are you seeing this?"); + let mut said = message(Who::Group(CREW), 21, TOBIN, t0() + 90, "Are you seeing this?"); said.mentioned = true; self.store(None, said.clone()); vec![new(said)] } _ => { - let mut said = message(Who::User(TOBIN), 32, TOBIN, T0 + 90, "Are you seeing this?"); + let mut said = message(Who::User(TOBIN), 32, TOBIN, t0() + 90, "Are you seeing this?"); said.mentioned = true; self.store(None, said.clone()); vec![new(said)] @@ -1373,7 +1379,7 @@ impl World { } if asked::(body).is_some() { self.log("GetState"); - return answer(tl::enums::updates::State::State(tl::types::updates::State { pts: 1, qts: 0, date: T0, seq: 1, unread_count: 0 })); + return answer(tl::enums::updates::State::State(tl::types::updates::State { pts: 1, qts: 0, date: t0(), seq: 1, unread_count: 0 })); } if asked::(body).is_some() { self.log("GetUsers"); @@ -1463,7 +1469,7 @@ impl World { dialog }) .collect(); - let state = tl::types::updates::State { pts: 1, qts: 0, date: T0, seq: 1, unread_count: 0 }; + let state = tl::types::updates::State { pts: 1, qts: 0, date: t0(), seq: 1, unread_count: 0 }; return answer(tl::enums::messages::PeerDialogs::Dialogs(tl::types::messages::PeerDialogs { dialogs, messages: Vec::new(), chats: chats(), users: users(), state: state.into() })); } if let Some(request) = asked::(body) { @@ -1489,7 +1495,7 @@ impl World { drafted(self, &mut dialog, who); dialog }).collect(); - let state = tl::types::updates::State { pts: 1, qts: 0, date: T0, seq: 1, unread_count: 0 }; + let state = tl::types::updates::State { pts: 1, qts: 0, date: t0(), seq: 1, unread_count: 0 }; return answer(tl::enums::messages::PeerDialogs::Dialogs(tl::types::messages::PeerDialogs { dialogs, messages: Vec::new(), chats: chats(), users: users(), state: state.into() })); } return Err(rpc(400, "PEER_ID_INVALID", None)); @@ -1552,7 +1558,7 @@ impl World { id, access_hash: i64::from(id) + 7, file_reference: vec![1u8, 2u8, 3u8], - date: T0 - 90, + date: t0() - 90, mime_type: if animated { "application/x-tgsticker".to_owned() } else { "image/webp".to_owned() }, size: if animated { 20_000 } else { 4_096 + 9 }, thumbs: None, @@ -1736,7 +1742,7 @@ impl World { } _ => file_piece(request.offset, request.limit), }; - let piece = tl::types::upload::File { r#type: tl::enums::storage::FileType::FileJpeg, mtime: T0, bytes: held }; + let piece = tl::types::upload::File { r#type: tl::enums::storage::FileType::FileJpeg, mtime: t0(), bytes: held }; self.file_in += 1; self.file_at_once = self.file_at_once.max(self.file_in); let answered = answer(tl::enums::upload::File::File(piece)); @@ -1752,7 +1758,7 @@ impl World { return answer(self.me()); } if let Some(request) = asked::(body) { - let one = |user_id| tl::enums::ChatParticipant::Participant(tl::types::ChatParticipant { user_id, inviter_id: TOBIN, date: T0, rank: None }); + let one = |user_id| tl::enums::ChatParticipant::Participant(tl::types::ChatParticipant { user_id, inviter_id: TOBIN, date: t0(), rank: None }); let participants = tl::types::ChatParticipants { chat_id: request.chat_id, participants: vec![one(TOBIN), one(ODALYS), one(ME)], version: 1 }; let mut full: tl::types::ChatFull = blank(&[ &put(0u32), @@ -1814,7 +1820,7 @@ impl World { messages.truncate(request.limit.max(0) as usize); } if name == "links" { - let mut linked = message(peer, 17, ODALYS, T0 - 30, "Route map"); + let mut linked = message(peer, 17, ODALYS, t0() - 30, "Route map"); linked.entities = Some(vec![tl::types::MessageEntityTextUrl { offset: 0, length: 5, url: "https://example.org/route".to_owned() }.into()]); messages = vec![linked.into()]; } @@ -1876,7 +1882,7 @@ impl World { user_id, inviter_id: None, promoted_by: TOBIN, - date: T0 - 1_000, + date: t0() - 1_000, admin_rights: rights.into(), rank: Some("Quartermaster".to_owned()), }; @@ -1946,7 +1952,7 @@ impl World { if asked::(body).is_some() { self.log("GetAllStickers"); let mut set: tl::types::StickerSet = blank(&[&put(0u32), &put(900i64), &put(901i64), &put("Hiking".to_owned()), &put("hiking".to_owned()), &put(2i32), &put(1i32)]); - set.installed_date = Some(T0); + set.installed_date = Some(t0()); let found = tl::types::messages::AllStickers { hash: 1, sets: vec![set.into()] }; return Some(answer(tl::enums::messages::AllStickers::Stickers(found))); } @@ -2000,7 +2006,7 @@ fn gif(id: i64) -> tl::types::Document { id, access_hash: id + 1, file_reference: vec![7], - date: T0, + date: t0(), mime_type: "video/mp4".to_owned(), size: 120_000, thumbs: Some(vec![tl::types::PhotoSize { r#type: "m".to_owned(), w: 320, h: 180, size: 9_000 }.into()]), @@ -2029,7 +2035,7 @@ fn sticker(id: i64) -> tl::types::Document { id, access_hash: id + 1, file_reference: vec![8], - date: T0, + date: t0(), mime_type: "application/x-tgsticker".to_owned(), size: 30_000, thumbs: Some(vec![tl::types::PhotoSize { r#type: "m".to_owned(), w: 128, h: 128, size: 4_000 }.into()]), @@ -2092,7 +2098,7 @@ fn held_media(media: &tl::enums::InputMedia, id: i32) -> (String, tl::enums::Mes id, access_hash: id + 1, file_reference: vec![9], - date: T0, + date: t0(), mime_type: one.mime_type.clone(), size: 18_000, thumbs: None, diff --git a/crates/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index 076ccbcd..2a5f8d7d 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -24,6 +24,17 @@ const SESSION: &str = r#"{"user":7001,"name":"Mira Kessler","home":2,"dcs":[]}"# /// What the fake serves for any file: longer than one piece, so it takes two requests. const FILE_BYTES: u64 = 256 * 1024 + 9; +/// Where the fake Telegram's clock starts: yesterday's midnight UTC, so its chats are always recent +/// enough for the Inbox (it drops what nobody touched for thirty days) and `t0() + 300`, when its +/// users stop being online, has always passed. One value for the whole process; chatd gets it by env. +fn t0() -> i64 { + static T0: std::sync::OnceLock = std::sync::OnceLock::new(); + *T0.get_or_init(|| { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + now - now % 86_400 - 86_400 + }) +} + fn key(id: &str) -> Key { Key { network: Network::Telegram, account: "7001".into(), id: id.into() } } @@ -100,6 +111,7 @@ impl World { command .env("TINYCLIENT_DIR", &dir) .env("TINYCLIENT_TELEGRAM_FAKE", qr_seconds) + .env("TINYCLIENT_TELEGRAM_T0", t0().to_string()) .env_remove("TINYCLIENT_SESSION_DISCORD") .env_remove("TINYCLIENT_SESSION_SLACK") .env_remove("TINYCLIENT_SESSION_TELEGRAM") @@ -1073,9 +1085,9 @@ fn asking_a_channel_posts_comments_reads_the_discussion_group_under_the_post() { assert_eq!(world.called("GetHistory c9001_5"), 0); // The newest comment's time rides the post's footer, as "last reply", once the page was - // read: the section itself names no date. Comment 51 went at 2026-10-13 11:59:20 UTC. + // read: the section itself names no date. Comment 51 went 40 seconds before the fake's clock start. let fresh = world.until("the footer's last reply", || { - world.message("c9001", "5").filter(|post| post.thread.as_ref().is_some_and(|section| section.last == 1_791_892_760)) + world.message("c9001", "5").filter(|post| post.thread.as_ref().is_some_and(|section| section.last == t0() - 40)) }); let section = fresh.thread.as_ref().unwrap(); assert_eq!(section.replies, 2, "{section:?}"); @@ -1598,7 +1610,7 @@ fn the_panes_ask_telegram_once_each_and_counts_are_telegrams_own() { let Response::Profile { profile } = world.ask(Request::Profile { person: key("u7003"), space: Some(key("c9002")) }) else { panic!("no profile in the forum"); }; - assert_eq!((profile.nickname.as_str(), profile.joined), ("Quartermaster", Some(1_791_891_800))); + assert_eq!((profile.nickname.as_str(), profile.joined), ("Quartermaster", Some(t0() - 1_000))); assert_eq!(world.called("GetParticipant u7003"), 1); // A scam account's card warns. let Response::Profile { profile } = world.ask(Request::Profile { person: key("u7005"), space: None }) else {