diff --git a/crates/chatd/src/adapters/mod.rs b/crates/chatd/src/adapters/mod.rs index 9bcb6465..4db39eb0 100644 --- a/crates/chatd/src/adapters/mod.rs +++ b/crates/chatd/src/adapters/mod.rs @@ -907,6 +907,10 @@ impl Context { self.hub.store().conversation(&self.key(conversation_id)) } + pub(crate) fn unread_telegram_groups(&self, limit: u32) -> rusqlite::Result> { + self.hub.store().unread_telegram_groups(&self.account, limit) + } + /// Remembers the newest message id the network named, without a preview. "Mark as read" /// before history has loaded acks this id. pub fn note_newest(&self, conversation_id: &str, message_id: &str) -> Stores { diff --git a/crates/chatd/src/adapters/telegram.rs b/crates/chatd/src/adapters/telegram.rs index d6816efe..4e455ae7 100644 --- a/crates/chatd/src/adapters/telegram.rs +++ b/crates/chatd/src/adapters/telegram.rs @@ -241,6 +241,13 @@ impl Account { return; } let learned = self.dir().learn(users, chats); + for chat in chats { + match chat { + tl::enums::Chat::Channel(channel) if !channel.min => self.wire.channel_membership(channel.id, !channel.left), + tl::enums::Chat::ChannelForbidden(channel) => self.wire.channel_membership(channel.id, false), + _ => {} + } + } for (key, note) in &learned.notes { self.ctx.set_note(key, note); } diff --git a/crates/chatd/src/adapters/telegram/content.rs b/crates/chatd/src/adapters/telegram/content.rs index 10bc88c4..e6b397db 100644 --- a/crates/chatd/src/adapters/telegram/content.rs +++ b/crates/chatd/src/adapters/telegram/content.rs @@ -2,7 +2,7 @@ //! service messages to one grey line, and whatever tinyclient cannot draw to a named line with a //! link into Telegram's own app. Nothing here asks the network anything. -use super::peers::{message_link, thread_id, Directory, Who}; +use super::peers::{is_replies, message_link, thread_id, Directory, Who}; use grammers_tl_types as tl; use protocol::{ Attachment, Forwarded, MediaFile, MediaKind, MentionKind, Message, MessageKind, Preview, Reaction, Sender, Span, @@ -34,6 +34,8 @@ pub struct Mapped { pub id: i32, /// The forum topic its reply header names. `None` in a forum means the General topic. pub topic: Option, + /// The root of a discussion thread, distinct from the comment this message answers. + pub discussion_top: Option, /// The message it answers, when it is a reply and not just a post in a topic. pub reply: Option, pub message: Message, @@ -478,19 +480,26 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option { peer, id: service.id, topic: topic_of(service.reply_to.as_ref()).0, + discussion_top: None, reply: None, message, wants: Vec::new(), }) } tl::enums::Message::Message(raw) => { - let peer = Who::of(&raw.peer_id); + let mut peer = Who::of(&raw.peer_id); // No author: in a direct chat it is one of the two; a channel's post is the channel's. let from = match (&raw.from_id, peer) { (Some(from), _) => Who::of(from), (None, Who::User(_)) if raw.out => Who::User(dir.me), (None, peer) => peer, }; + // Telegram addresses @replies copies to our own peer. They are personal + // notifications, and must never land in Saved Messages or the source group. + let replies = is_replies(from) && (peer == Who::User(dir.me) || peer == from); + if replies { + peer = from; + } let outgoing = raw.out || (from == Who::User(dir.me) && peer == Who::User(dir.me)); let mut message = Message::plain(raw.id.to_string(), "", raw.message.clone(), i64::from(raw.date), outgoing); // The message's own timer: Telegram stops serving it at `date + ttl_period`, and @@ -520,6 +529,21 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option { } } message.forwarded = raw.fwd_from.as_ref().map(|header| Box::new(forwarded(dir, header))); + if replies { + message.mentions_me = true; + if let (Some(tl::enums::MessageFwdHeader::Header(forward)), Some(tl::enums::MessageReplyHeader::Header(reply))) = + (&raw.fwd_from, &raw.reply_to) + { + if let Some(author) = forward.from_id.as_ref().map(Who::of) { + message.sender = sender(dir, author); + } + if let (Some(group), Some(id), Some(source)) = + (reply.reply_to_peer_id.as_ref().map(Who::of), forward.saved_from_msg_id, &mut message.forwarded) + { + source.link = message_link(group, dir.get(group), id); + } + } + } let mut wants = Vec::new(); match &raw.media { None | Some(MessageMedia::Empty) => {} @@ -561,11 +585,16 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option { } } let (topic, reply) = topic_of(raw.reply_to.as_ref()); + let discussion_top = match &raw.reply_to { + Some(tl::enums::MessageReplyHeader::Header(header)) if !header.forum_topic => header.reply_to_top_id, + _ => None, + }; Some(Mapped { peer, id: raw.id, topic, - reply, + discussion_top, + reply: if replies { None } else { reply }, message, wants, }) diff --git a/crates/chatd/src/adapters/telegram/fake.rs b/crates/chatd/src/adapters/telegram/fake.rs index 3b0c575d..49000d78 100644 --- a/crates/chatd/src/adapters/telegram/fake.rs +++ b/crates/chatd/src/adapters/telegram/fake.rs @@ -50,6 +50,7 @@ const TOOLS: i32 = 31; const JIGS: i32 = 55; /// Telegram's own account, a bot, an account Telegram flags as a scam, and one it flags an impostor. const SERVICE: i64 = 777_000; +const REPLIES: i64 = 1_271_266_957; const BOT: i64 = 7004; const SCAM: i64 = 7005; const FAKE: i64 = 7006; @@ -181,12 +182,16 @@ fn chats() -> Vec { notes }), tl::enums::Chat::Channel(a_channel(WORKSHOP, "Workshop", false, true, "")), - // The discussion group the channel's post's comments are written into: a megagroup, - // learned whole from the answers that name it, so its hash ready for a send. - tl::enums::Chat::Channel(a_channel(CHATTERS, "Field Notes chat", false, false, "")), ] } +fn discussion_group(left: bool) -> tl::types::Channel { + let mut group = a_channel(CHATTERS, "Field Notes chat", false, false, ""); + group.left = left; + group.has_link = true; + group +} + fn message(peer: Who, id: i32, from: i64, date: i32, text: &str) -> tl::types::Message { let mut message: tl::types::Message = blank(&[&put(0u32), &put(0u32), &put(id), &put(peer.peer()), &put(date), &put(text.to_owned())]); message.out = from == ME; @@ -679,7 +684,7 @@ fn discussion_answer(place: &str, unread: i32) -> Result, Fault> { read_inbox_max_id: Some(0), read_outbox_max_id: None, unread_count: unread, - chats: chats(), + chats: vec![discussion_group(true).into()], users: users(), }; answer(tl::enums::messages::DiscussionMessage::Message(found)) @@ -866,6 +871,21 @@ fn update(update: tl::enums::Update) -> Event { Event::Update { update: Box::new(update), users: users(), chats: chats() } } +/// An update about the discussion group, with the membership description Telegram supplied. +fn discussion_update(update: tl::enums::Update, left: bool, min: bool) -> Event { + let mut group = discussion_group(left); + group.min = min; + if min { + group.access_hash = None; + } + let mut users = users(); + let mut replies = user(REPLIES, "Replies", "", "replies"); + replies.bot = true; + replies.bot_info_version = Some(1); + users.push(replies.into()); + Event::Update { update: Box::new(update), users, chats: vec![tl::enums::Chat::Channel(group)] } +} + impl World { /// One line from the test: what Telegram then says unasked. fn steer(&mut self, line: &str) -> Vec { @@ -995,6 +1015,39 @@ impl World { reactions: comment_reactions().into(), }.into())] } + "discussion-membership" => { + let left = words.next() != Some("joined"); + vec![discussion_update(tl::types::UpdateChannel { channel_id: CHATTERS }.into(), left, false)] + } + // The other post (root 80) has activity even though our only comment was + // under root 60. A partial chat in the update does not refresh membership. + "discussion-comment" => { + let root = words.next().and_then(|id| id.parse().ok()).unwrap_or(ECHO); + let id = words.next().and_then(|id| id.parse().ok()).unwrap_or(81); + let mut said = message(Who::Channel(CHATTERS), id, TOBIN, crate::hub::now() as i32, "A new discussion comment"); + let mut reply: tl::types::MessageReplyHeader = blank(&[&put(0u32)]); + reply.reply_to_msg_id = Some(root - 1); + reply.reply_to_top_id = Some(root); + said.reply_to = Some(reply.into()); + vec![discussion_update(tl::types::UpdateNewChannelMessage { message: said.into(), pts: 0, pts_count: 0 }.into(), false, true)] + } + // Telegram sends both a source update and a personal copy addressed to self. + "discussion-reply" => { + let source = self.steer("discussion-comment 60 82"); + let mut copy = message(Who::User(ME), 83, REPLIES, crate::hub::now() as i32, "A reply to your comment"); + let mut reply: tl::types::MessageReplyHeader = blank(&[&put(0u32)]); + reply.reply_to_msg_id = Some(59); + reply.reply_to_top_id = Some(ECHO); + reply.reply_to_peer_id = Some(Who::Channel(CHATTERS).peer()); + copy.reply_to = Some(reply.into()); + let mut forward: tl::types::MessageFwdHeader = blank(&[&put(0u32), &put(t0())]); + forward.from_id = Some(Who::User(TOBIN).peer()); + forward.saved_from_peer = Some(Who::Channel(CHATTERS).peer()); + forward.saved_from_msg_id = Some(82); + copy.fwd_from = Some(forward.into()); + self.store(None, { let mut stored = copy.clone(); stored.peer_id = Who::User(REPLIES).peer(); stored }); + source.into_iter().chain([discussion_update(tl::types::UpdateNewMessage { message: copy.into(), pts: 0, pts_count: 0 }.into(), false, true)]).collect() + } "edit" => { let mut message = message(odalys, 13, ODALYS, t0() + 10, "Café at the dock, seven sharp"); message.edit_date = Some(t0() + 30); @@ -1500,6 +1553,15 @@ impl World { } return Err(rpc(400, "PEER_ID_INVALID", None)); } + if let Some(request) = asked::(body) { + let ids: Vec<_> = request.id.into_iter().filter_map(|input| match input { + tl::enums::InputChannel::Channel(channel) => Some(channel.channel_id), + _ => None, + }).collect(); + self.log(&format!("GetChannels {ids:?}")); + let chats = chats().into_iter().chain([discussion_group(true).into()]).filter(|chat| ids.contains(&chat.id())).collect(); + return answer(tl::enums::messages::Chats::Chats(tl::types::messages::Chats { chats })); + } if let Some(request) = asked::(body) { // As Telegram does: the list unchanged since the hash it was asked with is not sent. let unchanged = request.hash != 0 && request.hash == super::sync::contacts_hash(1, &[ODALYS]); diff --git a/crates/chatd/src/adapters/telegram/info.rs b/crates/chatd/src/adapters/telegram/info.rs index a2078f2d..9e519e84 100644 --- a/crates/chatd/src/adapters/telegram/info.rs +++ b/crates/chatd/src/adapters/telegram/info.rs @@ -162,17 +162,20 @@ pub(super) fn set_mute(account: &Account, conversation: &str, notify: Notify) -> /// Leaves a group or a channel; history stays on Telegram for the others. pub(super) fn leave(account: &Account, conversation: &str) -> Result<(), Fault> { - let answer = match Who::parse(conversation) { - Some((Who::Group(chat_id), None)) => account.ask(&tl::functions::messages::DeleteChatUser { + let Some((who, None)) = Who::parse(conversation) else { + return Err(invalid("PEER_NOT_A_GROUP")); + }; + let answer = match who { + Who::Group(chat_id) => account.ask(&tl::functions::messages::DeleteChatUser { revoke_history: false, chat_id, user_id: tl::enums::InputUser::UserSelf, })?, - Some((who @ Who::Channel(_), None)) => account.ask(&tl::functions::channels::LeaveChannel { channel: account.input_channel(who)? })?, + Who::Channel(_) => account.ask(&tl::functions::channels::LeaveChannel { channel: account.input_channel(who)? })?, _ => return Err(invalid("PEER_NOT_A_GROUP")), }; super::live::answered(account, &answer); - let _ = account.ctx.conversation_gone(conversation); + super::live::left(account, who); Ok(()) } diff --git a/crates/chatd/src/adapters/telegram/live.rs b/crates/chatd/src/adapters/telegram/live.rs index f9613128..be6cebbe 100644 --- a/crates/chatd/src/adapters/telegram/live.rs +++ b/crates/chatd/src/adapters/telegram/live.rs @@ -34,7 +34,10 @@ fn placed(account: &Account, raw: &tl::enums::Message) -> Option<(String, Mapped } else { None }; - if !ensure.is_none_or(|(who, topic)| ensure_row(account, who, topic)) { + if !match ensure { + Some((who, topic)) => ensure_row(account, who, topic), + None => matches!(account.ctx.known(&conversation), Ok(Some(_))), + } { return None; } finish(account, &conversation, &mut mapped, &HashMap::new()); @@ -46,6 +49,14 @@ fn message(account: &Account, raw: &tl::enums::Message, edit: bool) { let Some((conversation, mut mapped)) = placed(account, raw) else { return; }; + let watching = account.ctx.watching(); + let open = watching.conversation.as_ref().is_some_and(|open| open.account() == account.ctx.account && open.id == conversation); + let member = sync::member(account, mapped.peer); + // Nonmembers can view comments. Only the thread on screen gets its live copy; + // Telegram sends a separate @replies message for anything that needs their attention. + if !member && !open { + return; + } // An edit of something never stored is not news: it would count as a new unread message. if edit && !matches!(account.ctx.stored_message(&conversation, &mapped.message.id), Ok(Some(_))) { return; @@ -63,11 +74,14 @@ fn message(account: &Account, raw: &tl::enums::Message, edit: bool) { mapped.message.views = stored.views; } } - let _ = account.ctx.message(&conversation, &mapped.message); + if member { + let _ = account.ctx.message(&conversation, &mapped.message); + } else { + let _ = account.ctx.history(&conversation, std::slice::from_ref(&mapped.message)); + } // What it shows is fetched only when the user is looking at the conversation; otherwise // opening it fetches the page it is on. - let watching = account.ctx.watching(); - if watching.conversation.as_ref().is_some_and(|open| open.account() == account.ctx.account && open.id == conversation) { + if open { sync::reaction_emoji(account, &conversation, std::slice::from_ref(&mapped.message)); sync::text_emoji(account, &conversation, std::slice::from_ref(&mapped.message)); account.files.want(mapped.wants); @@ -177,6 +191,17 @@ pub(super) fn apply(account: &Account, update: &Update) { read(account, &conversation, seen.max_id, seen.still_unread_count); } Update::ReadChannelInbox(seen) => read(account, &Who::Channel(seen.channel_id).text(), seen.max_id, seen.still_unread_count), + Update::ChatParticipantDelete(changed) if changed.user_id == account.me => left(account, Who::Group(changed.chat_id)), + Update::ChannelParticipant(changed) if changed.user_id == account.me => { + use tl::enums::ChannelParticipant; + if match &changed.new_participant { + None | Some(ChannelParticipant::Left(_)) => true, + Some(ChannelParticipant::Banned(banned)) => banned.left || matches!(&banned.banned_rights, tl::enums::ChatBannedRights::Rights(rights) if rights.view_messages), + _ => false, + } { + left(account, Who::Channel(changed.channel_id)); + } + } // Telegram's second tick: the other side read my messages up to here. Update::ReadHistoryOutbox(seen) => { let who = Who::of(&seen.peer); @@ -240,6 +265,14 @@ pub(super) fn apply(account: &Account, update: &Update) { } } +pub(super) fn left(account: &Account, who: Who) { + account.dir().left(who); + if let Who::Channel(id) = who { + account.wire.channel_membership(id, false); + } + sync::remove_chat(account, who); +} + /// Applies what a request of ours changed, from its own answer: the caller reports the change /// before it answers the shell. The same updates also arrive in order with the rest, where /// recording them again changes nothing. @@ -269,7 +302,7 @@ pub(super) fn sent(account: &Account, conversation: &str, who: Who, text: &str, let me = Who::User(account.me); message.sender = protocol::Sender { id: me.text(), name: account.dir().name(me), ..protocol::Sender::default() }; message.spans = content::spans(text, short.entities.as_deref().unwrap_or(&[])); - let mut mapped = Mapped { peer: who, id: short.id, topic: None, reply, message, wants: Vec::new() }; + let mut mapped = Mapped { peer: who, id: short.id, topic: None, discussion_top: None, reply, message, wants: Vec::new() }; finish(account, conversation, &mut mapped, &HashMap::new()); return Some(mapped.message); } diff --git a/crates/chatd/src/adapters/telegram/peers.rs b/crates/chatd/src/adapters/telegram/peers.rs index 4f785fcd..0827cb68 100644 --- a/crates/chatd/src/adapters/telegram/peers.rs +++ b/crates/chatd/src/adapters/telegram/peers.rs @@ -10,6 +10,11 @@ use grammers_tl_types as tl; use protocol::Mark; use std::collections::HashMap; +/// Telegram's @replies peer, on the production network and the test network. +pub fn is_replies(who: Who) -> bool { + matches!(who, Who::User(1_271_266_957 | 708_513)) +} + /// Most peers held in memory. Past this the directory starts over; hashes are also kept as /// notes in the store, so nothing becomes unreachable. const KEPT: usize = 8_192; @@ -81,6 +86,8 @@ pub struct Known { /// A channel proper: only its owners post. pub broadcast: bool, pub forum: bool, + /// Membership from a full chat description. A partial channel does not say. + pub member: Option, /// We may read but not write: a channel we do not run, a group we were removed from. pub read_only: bool, /// The picture's id and the datacenter that serves it. 0 for none. @@ -186,6 +193,7 @@ fn of_chat(chat: &tl::enums::Chat) -> Option<(Who, Known)> { let (photo, photo_dc) = chat_photo(&chat.photo); let known = Known { name: chat.title.clone(), + member: Some(!chat.left && !chat.deactivated), read_only: chat.left || chat.deactivated || member_cannot_send(chat.creator, chat.admin_rights.is_some(), chat.default_banned_rights.as_ref(), None), photo, photo_dc, @@ -194,7 +202,7 @@ fn of_chat(chat: &tl::enums::Chat) -> Option<(Who, Known)> { (Who::Group(chat.id), known) } Chat::Forbidden(chat) => { - let known = Known { name: chat.title.clone(), read_only: true, ..Known::default() }; + let known = Known { name: chat.title.clone(), member: Some(false), read_only: true, ..Known::default() }; (Who::Group(chat.id), known) } Chat::Channel(channel) => { @@ -214,6 +222,7 @@ fn of_chat(chat: &tl::enums::Chat) -> Option<(Who, Known)> { handle: first_username(&channel.username, &channel.usernames), broadcast: channel.broadcast, forum: channel.forum, + member: (!channel.min).then_some(!channel.left), read_only: channel.left || restricted, photo, photo_dc, @@ -227,6 +236,7 @@ fn of_chat(chat: &tl::enums::Chat) -> Option<(Who, Known)> { hash: Some(channel.access_hash), name: channel.title.clone(), broadcast: channel.broadcast, + member: Some(false), read_only: true, ..Known::default() }; @@ -312,13 +322,19 @@ impl Directory { if known.hash.is_none() { known.hash = before.and_then(|before| before.hash); } + if matches!(chat, tl::enums::Chat::Channel(channel) if channel.min) { + known.member = before.and_then(|before| before.member); + if let Some(before) = before { + known.read_only = before.read_only; + } + } if before == Some(&known) { continue; } if before.map(|before| before.hash) != Some(known.hash) { learned.notes.extend(known.note().map(|note| (note_key(who), note))); } - if before.is_some() { + if before.is_some() || known.member == Some(false) { learned.chats.push(who); } self.map.insert(who, known); @@ -326,6 +342,12 @@ impl Directory { learned } + pub fn left(&mut self, who: Who) { + let known = self.map.entry(who).or_default(); + known.member = Some(false); + known.read_only = true; + } + /// A peer remembered only as a note from an earlier run. pub fn recall(&mut self, who: Who, note: &str) { let Some((hash, name)) = note.split_once('\t') else { diff --git a/crates/chatd/src/adapters/telegram/real.rs b/crates/chatd/src/adapters/telegram/real.rs index c1dffbe8..1c77a444 100644 --- a/crates/chatd/src/adapters/telegram/real.rs +++ b/crates/chatd/src/adapters/telegram/real.rs @@ -20,6 +20,7 @@ use grammers_session::types::{ChannelState, DcOption, PeerAuth, PeerId, PeerInfo use grammers_session::updates::UpdatesLike; use grammers_session::{BoxFuture, Session, SessionData}; use grammers_tl_types as tl; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex}; @@ -47,6 +48,7 @@ impl std::error::Error for Poisoned {} pub struct Vault { data: Mutex, me: Mutex>, + nonmembers: Mutex>, /// A key was made, or the home moved, since the session was last written out. unsaved: AtomicBool, } @@ -83,6 +85,7 @@ impl Vault { Vault { data: Mutex::new(data), me: Mutex::new(me), + nonmembers: Mutex::default(), unsaved: AtomicBool::new(false), } } @@ -162,6 +165,12 @@ impl Session for Vault { if peer == PeerId::self_user() || me.as_ref().is_some_and(|me| me.id() == peer) { return Ok(me); } + // grammers checks this lookup before asking for a channel difference. Without a + // peer it stops tracking the channel, which also stops polling public groups + // after we leave. The adapter still holds the access hash for reading comments. + if self.nonmembers.lock().map_err(|_| Poisoned)?.contains(&peer) { + return Ok(None); + } Ok(self.data()?.peer_infos.get(&peer).cloned()) }) } @@ -263,7 +272,15 @@ fn named(peers: &PeerMap) -> (Vec, Vec) { match peer { Peer::User(user) => users.push(user.raw.clone()), Peer::Group(group) => chats.push(group.raw.clone()), - Peer::Channel(channel) => chats.push(tl::enums::Chat::Channel(channel.raw.clone())), + Peer::Channel(channel) => { + let mut raw = channel.raw.clone(); + // grammers represents a forbidden broadcast as a channel with date 0. + // Its synthetic left flag is false, which is not a membership signal. + if raw.date == 0 && raw.stories_unavailable { + raw.left = true; + } + chats.push(tl::enums::Chat::Channel(raw)); + } } } (users, chats) @@ -350,6 +367,20 @@ impl Wire for Real { self.events.lock().ok()?.take() } + fn channel_membership(&self, id: i64, member: bool) { + let (Some(peer), Ok(mut nonmembers)) = (PeerId::channel(id), self.vault.nonmembers.lock()) else { + return; + }; + if member { + nonmembers.remove(&peer); + } else { + if nonmembers.len() >= PEERS_KEPT && !nonmembers.contains(&peer) { + nonmembers.clear(); + } + nonmembers.insert(peer); + } + } + fn home(&self) -> i32 { self.vault.home_dc_id().unwrap_or(2) } diff --git a/crates/chatd/src/adapters/telegram/sync.rs b/crates/chatd/src/adapters/telegram/sync.rs index 87aaa226..fda66419 100644 --- a/crates/chatd/src/adapters/telegram/sync.rs +++ b/crates/chatd/src/adapters/telegram/sync.rs @@ -3,7 +3,7 @@ use super::content::{self, Mapped, Want}; use super::filing::{self, Cursor, Facts, Filed}; -use super::peers::{thread_id, Known, Who}; +use super::peers::{is_replies, thread_id, Known, Who}; use super::wire::Fault; use super::{presence, Account, PAGE}; use crate::store::{ConversationRow, Filing, PreviewLine, preview_text}; @@ -63,6 +63,7 @@ pub(super) fn is_muted(account: &Account, who: Who, settings: &tl::enums::PeerNo /// What kind of row a peer is. fn kind(who: Who, known: &Known) -> ConversationKind { match who { + who if is_replies(who) => ConversationKind::Group, Who::User(_) => ConversationKind::Direct, Who::Group(_) => ConversationKind::Group, Who::Channel(_) if known.forum => ConversationKind::Forum, @@ -82,6 +83,11 @@ fn row(account: &Account, who: Who, known: &Known) -> ConversationRow { // the user stars it. row.listed = !(matches!(who, Who::Channel(_)) && known.broadcast); match who { + who if is_replies(who) => { + row.title = "Replies".to_owned(); + row.read_only = true; + row.bot = false; + } Who::User(id) if id == account.me => { // Saved Messages: the user's own notes, with no presence and no call. row.title = "Saved Messages".to_owned(); @@ -108,6 +114,9 @@ fn row(account: &Account, who: Who, known: &Known) -> ConversationRow { /// Records a conversation the store does not have yet: a chat that was not among those read at /// sign-on, a person the user is about to write to. True once it is there. pub(super) fn ensure_row(account: &Account, who: Who, topic: Option) -> bool { + if !member(account, who) { + return false; + } let id = match topic { Some(topic) => thread_id(who, topic), None => who.text(), @@ -115,10 +124,10 @@ pub(super) fn ensure_row(account: &Account, who: Who, topic: Option) -> boo if matches!(account.ctx.known(&id), Ok(Some(_))) { return true; } - if account.input(who).is_err() && !matches!(who, Who::Group(_)) { + if account.input(who).is_err() && !is_replies(who) && !matches!(who, Who::Group(_)) { return false; } - let Some(known) = account.dir().get(who).cloned() else { + let Some(known) = account.dir().get(who).cloned().or_else(|| is_replies(who).then(Known::default)) else { return false; }; let fresh = |account: &Account| { @@ -154,10 +163,14 @@ pub(super) fn ensure_row(account: &Account, who: Who, topic: Option) -> boo /// A group or channel was renamed, or its picture or our rights in it changed. Its filing is /// left as stored. pub(super) fn refresh_row(account: &Account, who: Who) { - let Ok(Some(stored)) = account.ctx.known(&who.text()) else { + let Some(known) = account.dir().get(who).cloned() else { return; }; - let Some(known) = account.dir().get(who).cloned() else { + if known.member == Some(false) { + remove_chat(account, who); + return; + } + let Ok(Some(stored)) = account.ctx.known(&who.text()) else { return; }; let mut fresh = row(account, who, &known); @@ -166,6 +179,30 @@ pub(super) fn refresh_row(account: &Account, who: Who) { let _ = account.ctx.conversation(&fresh); } +/// A group is allowed to count unread only when Telegram has confirmed membership. +/// Knowing its access hash is enough to read comments, but says nothing about membership. +pub(super) fn member(account: &Account, who: Who) -> bool { + matches!(who, Who::User(_)) || account.dir().get(who).is_some_and(|known| known.member == Some(true)) +} + +pub(super) fn remove_chat(account: &Account, who: Who) { + { + let mut live = account.live(); + let filed = &mut live.filed; + filed.facts.remove(&who); + for list in filed.order.iter_mut().chain(filed.pins.iter_mut()) { + list.retain(|held| *held != who); + } + if filed.open.is_some_and(|(held, _)| held == who) { + filed.open = None; + } + } + let id = who.text(); + if matches!(account.ctx.known(&id), Ok(Some(_))) { + let _ = account.ctx.conversation_gone(&id); + } +} + /// What this account may send, from what Telegram says of it: Premium raises the file size and /// the caption, and allows custom emoji. fn capabilities(premium: bool) -> Capabilities { @@ -218,9 +255,29 @@ pub(super) fn sign_on(account: &Arc) -> Result<(), Fault> { // An earlier build kept every chat in one invented Space. The rows read just now have left // it; what is left (chats past the newest 500) goes with it, and comes back on scroll. let _ = account.ctx.spaces(&[]); + verify_unread_groups(account); contacts(account) } +/// A group left while this client was offline may be missing from the new dialog list. +/// Confirm older unread rows without treating a capped dialog list as proof of leaving. +fn verify_unread_groups(account: &Account) { + let groups = account.ctx.unread_telegram_groups(500).unwrap_or_default(); + let id: Vec<_> = groups.iter() + .filter_map(|id| Who::parse(id).map(|(who, _)| who)) + .filter(|who| account.dir().get(*who).and_then(|known| known.member).is_none()) + .filter_map(|who| account.input_channel(who).ok()) + .take(100) + .collect(); + if id.is_empty() { + return; + } + // This is a bounded cache repair. It should not hold sign-in through a flood wait. + if let Ok(chats) = super::wire::call(account.wire.as_ref(), &tl::functions::channels::GetChannels { id }) { + account.learn(&[], &chats.chats()); + } +} + /// Telegram's caching hash over a list of numbers (`core.telegram.org/api/offsets`, "Hash /// generation"): Telegram answers "not modified" when it matches what it would send. pub(super) fn telegram_hash(numbers: impl IntoIterator) -> i64 { @@ -473,6 +530,9 @@ fn record(account: &Account, list: usize, dialog: &tl::types::Dialog, newest: Op let Some(known) = account.dir().get(who).cloned() else { return; }; + if known.member == Some(false) { + return; + } let is_muted = muted(account, who, &dialog.notify_settings); let unread = (dialog.unread_count.max(0) as u32).max(u32::from(dialog.unread_mark)); let filing = { @@ -962,9 +1022,9 @@ fn discussion(account: &Account, channel: Who, post: i32) -> Result<(Who, i32), account.learn(&found.users, &found.chats); // How many comments the user has not read comes with the discussion, not with the post. comment_unread(account, channel, post, found.unread_count); - // The answer leads with the post itself, standing in the group: the id in it is what - // a send answers, and the group it stands in is where a send goes. - let Some((group, echo)) = found.messages.iter().find_map(|message| match message { + // The answer is newest first, so the forwarded post that starts the thread comes last. + // Its id is what a send answers, in the group that holds the thread. + let Some((group, echo)) = found.messages.iter().rev().find_map(|message| match message { tl::enums::Message::Message(echo) => Some((Who::of(&echo.peer_id), echo.id)), _ => None, }) else { @@ -972,7 +1032,7 @@ fn discussion(account: &Account, channel: Who, post: i32) -> Result<(Who, i32), }; let mut live = account.live(); if live.discussion.len() >= DISCUSSIONS { - live.discussion.clear(); + live.discussion.pop_front(); } live.discussion.push_back((thread, group, echo)); Ok((group, echo)) @@ -1013,7 +1073,18 @@ fn comment_row(account: &Account, thread: &str, channel: Who, post: i32) { /// names the echo its reply answers. `None` leaves the message with the group's own id, /// for a conversation of the group itself. fn comments_thread(account: &Account, mapped: &Mapped) -> Option { - echo_thread(account, mapped.peer, mapped.reply) + if let Some(top) = mapped.discussion_top { + return echo_thread(account, mapped.peer, Some(top)); + } + let reply = mapped.reply?; + // A send may be echoed without a top id. If it answers a comment we already + // placed, it belongs with that comment. Only use threads of a linked post here. + echo_thread(account, mapped.peer, Some(reply)).or_else(|| { + let placed = account.placed(Some(mapped.peer), reply)?; + account.live().discussion.iter() + .any(|(thread, group, _)| *thread == placed && *group == mapped.peer) + .then_some(placed) + }) } /// The comments thread of the discussion group an update belongs to: the thread that was diff --git a/crates/chatd/src/adapters/telegram/wire.rs b/crates/chatd/src/adapters/telegram/wire.rs index 1793aecc..a9007fec 100644 --- a/crates/chatd/src/adapters/telegram/wire.rs +++ b/crates/chatd/src/adapters/telegram/wire.rs @@ -111,6 +111,8 @@ pub trait Wire: Send + Sync { fn invoke(&self, dc: Option, body: Vec) -> Result, Fault>; /// The events, once: whoever takes them is the only reader. fn events(&self) -> Option>; + /// Whether the update stream may catch up on this channel on its own. + fn channel_membership(&self, _id: i64, _member: bool) {} /// The datacenter the account lives in. fn home(&self) -> i32; /// The account turned out to live elsewhere (a sign-in was redirected). diff --git a/crates/chatd/src/store.rs b/crates/chatd/src/store.rs index 858e2dbf..aa3bb1ba 100644 --- a/crates/chatd/src/store.rs +++ b/crates/chatd/src/store.rs @@ -1360,6 +1360,19 @@ impl Store { .optional() } + /// Cached Telegram supergroups still contributing unread counts. The adapter verifies + /// membership for rows its paged dialog list did not confirm. + pub(crate) fn unread_telegram_groups(&self, account: &AccountKey, limit: u32) -> rusqlite::Result> { + let mut query = self.db.prepare( + "SELECT id FROM conversation WHERE network = 'telegram' AND account = ?1 + AND kind IN ('group', 'forum') AND id GLOB 'c[0-9]*' AND parent = '' AND NOT hidden + AND (unread > 0 OR mentions > 0) + ORDER BY updated DESC, id LIMIT ?2", + )?; + let rows = query.query_map(params![account.account, limit], |row| row.get(0))?; + rows.collect() + } + /// Whether a space already holds text channels, directs, and/or voice rooms. Directs sort /// after channels by position, so a paged tree read can miss them. pub fn space_text_kinds(&self, space: &Key) -> rusqlite::Result<(bool, bool, bool)> { diff --git a/crates/chatd/tests/telegram.rs b/crates/chatd/tests/telegram.rs index 2a5f8d7d..9ba7a659 100644 --- a/crates/chatd/tests/telegram.rs +++ b/crates/chatd/tests/telegram.rs @@ -1170,6 +1170,64 @@ fn a_sent_comment_lands_in_the_thread_and_the_posts_count_rises_under_its_own_id assert_eq!(world.conversation("c9001_5").unwrap().root.as_deref(), Some("5")); } +#[test] +fn a_discussion_group_stops_counting_unread_after_leaving_on_another_device() { + let world = World::signed_in(); + world.connected(); + world.steer("comments"); + world.until("the post's comment section", || world.message("c9001", "5").filter(|post| post.thread.is_some())); + world.messages("c9001_5"); + world.until("the comments to be writable", || world.conversation("c9001_5").filter(|row| !row.read_only)); + world.send("c9001_5", "8001", "My comment on the first post", None); + world.until("the comment to be sent", || world.messages("c9001_5").into_iter().find(|m| m.state == SendState::Sent && m.outgoing)); + + world.steer("discussion-membership joined"); + world.steer("discussion-comment 80 81"); + world.until("unread activity in the joined group", || world.conversation("c9003").filter(|row| row.unread == 1)); + + world.steer("discussion-membership left"); + world.steer("discussion-comment 80 84"); + world.steer("discussion-reply"); + let reply = world.arrived("u1271266957", "83"); + assert_eq!(reply.sender.name, "Tobin Reyes"); + assert!(reply.mentions_me); + assert_eq!(reply.forwarded.unwrap().link, "https://t.me/c/9003/82"); + assert_eq!(world.conversation("u1271266957").map(|row| (row.unread, row.read_only)), Some((1, true))); + assert!(world.conversation("c9003").is_none(), "the group row came back"); + assert!(world.message("u7001", "83").is_none(), "the notification was copied into Saved Messages"); +} + +#[test] +fn a_nested_comment_lands_on_the_open_post_without_an_unread_badge_for_a_nonmember() { + let world = World::signed_in(); + world.connected(); + world.steer("comments"); + world.until("the post's comment section", || world.message("c9001", "5").filter(|post| post.thread.is_some())); + world.messages("c9001_5"); + world.until("the discussion to be resolved", || world.conversation("c9001_5").filter(|row| !row.read_only)); + world.watch(Watching { conversation: Some(key("c9001_5")), ..Watching::default() }); + world.steer("discussion-comment 60 85"); + let reply = world.arrived("c9001_5", "85"); + assert_eq!(reply.text, "A new discussion comment"); + assert_eq!(world.conversation("c9001_5").unwrap().unread, 0); + assert!(world.conversation("c9003").is_none()); +} + +#[test] +fn a_cached_unread_group_left_while_offline_does_not_keep_its_badge_on_reconnect() { + let world = World::seed(Some(SESSION), "30", &[], |store| { + let group = key("c9003"); + let row = chatd::store::ConversationRow::new("c9003", ConversationKind::Group, "Field Notes chat"); + store.upsert_conversation(&group.account(), &row).unwrap(); + store.set_counts(&group, 3, 0, None, t0()).unwrap(); + store.set_name(&group.account(), "peer:c9003", "9003009\tField Notes chat").unwrap(); + }); + world.connected(); + assert_eq!(world.called("GetChannels [9003]"), 1); + assert!(world.conversation("c9003").is_none()); + assert!(world.conversation("c9001").is_some()); +} + #[test] fn a_comments_reactions_document_is_asked_and_its_file_lands_for_the_chip_like_a_normal_messages_does() { let world = World::signed_in(); diff --git a/crates/protocol/src/message.rs b/crates/protocol/src/message.rs index 0c2c3fc0..022416db 100644 --- a/crates/protocol/src/message.rs +++ b/crates/protocol/src/message.rs @@ -222,7 +222,7 @@ pub struct ThreadSummary { /// The thread's conversation id: what the thread pane asks `Messages` for. pub id: String, pub replies: u32, - /// Unix seconds of the newest reply. + /// Unix seconds of the newest reply, or 0 when its timestamp is unknown. pub last: i64, /// The latest people to reply, newest first, at most [`THREAD_FACES`], for their pictures. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/tinyclient/src/gui/conversation/draw.rs b/crates/tinyclient/src/gui/conversation/draw.rs index 87ba8e24..25ef2773 100644 --- a/crates/tinyclient/src/gui/conversation/draw.rs +++ b/crates/tinyclient/src/gui/conversation/draw.rs @@ -1850,10 +1850,7 @@ impl Pane { // A thread does not open inside a thread. let thread = message.thread.as_ref().filter(|_| !self.is_thread())?; let replies = if thread.replies == 1 { "1 reply".to_owned() } else { format!("{} replies", thread.replies) }; - let last = match age(now(), thread.last).as_str() { - "now" => "Last reply just now".to_owned(), - ago => format!("Last reply {ago} ago"), - }; + let last = last_reply_label(now(), thread.last); // The latest repliers, overlapped, each ringed in the color behind them. // Placed by hand: each a ringed 24 pt disc, 19 pt on from the one before. let shown = thread.participants.len().min(protocol::THREAD_FACES); @@ -1902,7 +1899,7 @@ impl Pane { .when(thread.participants.is_empty(), |line| line.child(icon(Icon::Thread, 14., theme::accent_text()))) .when(!thread.participants.is_empty(), |line| line.child(faces)) .child(div().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::accent_text()).child(replies)) - .child(div().type_small().text_color(theme::text_faint()).child(last)) + .children(last.map(|last| div().type_small().text_color(theme::text_faint()).child(last))) .children(unread); Some(div().flex().child(line).into_any_element()) } @@ -2126,6 +2123,14 @@ fn own_face( message.sender.avatar.clone().or_else(account).or_else(shown) } +/// Some networks supply a reply count before they supply a timestamp for the last reply. +fn last_reply_label(now: i64, last: i64) -> Option { + (last > 0).then(|| match age(now, last).as_str() { + "now" => "Last reply just now".to_owned(), + ago => format!("Last reply {ago} ago"), + }) +} + /// A view count the way Telegram's apps say it: "987", "12.4K", "1.2M". The number is cut, /// not rounded, so a count never reads as more than Telegram said. fn views_short(views: u32) -> String { @@ -2180,6 +2185,14 @@ mod tests { assert_eq!(views_short(1_200_000), "1.2M"); } + #[test] + fn the_last_reply_label_requires_a_known_timestamp() { + let now = 1_794_553_200; + assert_eq!(last_reply_label(now, 0), None); + assert_eq!(last_reply_label(now, now - 20).as_deref(), Some("Last reply just now")); + assert_eq!(last_reply_label(now, now - 7200).as_deref(), Some("Last reply 2h ago")); + } + #[test] fn only_my_newest_message_carries_the_mark_and_only_once_sent() { let theirs = Rc::new(Message::plain("3", "mara", "hi", 30, false)); diff --git a/e2e/lab/platforms/mac/helper/main.swift b/e2e/lab/platforms/mac/helper/main.swift index b490173e..f9d26f2e 100644 --- a/e2e/lab/platforms/mac/helper/main.swift +++ b/e2e/lab/platforms/mac/helper/main.swift @@ -16,6 +16,7 @@ // Frame time is seconds since R was written (R holds the epoch). // key --pid P --combo alt-down post a key press (modifiers: cmd alt ctrl shift) to P only // click --pid P --x X --y Y post a click at X,Y points inside P's first window +// text --pid P --out F read visible text from P's first window into F import AppKit import AVFoundation import CoreGraphics @@ -23,6 +24,7 @@ import CoreMedia import CoreVideo import Foundation import ScreenCaptureKit +import Vision setvbuf(stdout, nil, _IOLBF, 0) setvbuf(stderr, nil, _IOLBF, 0) @@ -70,6 +72,24 @@ func status() { say("{\"screen_recording\": \(screen), \"accessibility\": \(ax)}") } +@available(macOS 14.0, *) +func windowText(pid: pid_t) async throws -> [String] { + guard let target = windows(of: pid).first else { return [] } + let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) + guard let window = content.windows.first(where: { $0.windowID == target.id && $0.owningApplication?.processID == pid }) else { + return [] + } + let config = SCStreamConfiguration() + config.width = Int(window.frame.width) * 2 + config.height = Int(window.frame.height) * 2 + let filter = SCContentFilter(desktopIndependentWindow: window) + let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) + let request = VNRecognizeTextRequest() + request.usesLanguageCorrection = false + try VNImageRequestHandler(cgImage: image).perform([request]) + return (request.results ?? []).compactMap { $0.topCandidates(1).first?.string } +} + // MARK: input, to one process only let keyCodes: [String: CGKeyCode] = [ @@ -266,6 +286,27 @@ case "status": case "windows": guard let p = option("pid").flatMap({ Int32($0) }) else { die("--pid", 64) } for w in windows(of: p) { say("{\"id\": \(w.id), \"x\": \(w.bounds.origin.x), \"y\": \(w.bounds.origin.y), \"w\": \(w.bounds.width), \"h\": \(w.bounds.height)}") } +case "text": + guard #available(macOS 14.0, *) else { die("Window text requires macOS 14", 77) } + guard let p = option("pid").flatMap({ Int32($0) }), let out = option("out") else { die("--pid --out", 64) } + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + Task { + let url = URL(fileURLWithPath: out) + let encoder = JSONEncoder() + do { + guard CGPreflightScreenCaptureAccess() else { + try encoder.encode("Screen Recording is not granted to TinyLabHelper").write(to: url, options: .atomic) + exit(77) + } + try await encoder.encode(windowText(pid: p)).write(to: url, options: .atomic) + exit(0) + } catch { + try? encoder.encode("Could not read the window: \(error)").write(to: url, options: .atomic) + exit(1) + } + } + app.run() case "key": guard let p = option("pid").flatMap({ Int32($0) }), let combo = option("combo") else { die("--pid --combo", 64) } guard AXIsProcessTrusted() else { die("Accessibility is not granted to TinyLabHelper", 77) } diff --git a/e2e/scenarios/telegram-discussion-unreads/drive.linux.sh b/e2e/scenarios/telegram-discussion-unreads/drive.linux.sh new file mode 100644 index 00000000..98195337 --- /dev/null +++ b/e2e/scenarios/telegram-discussion-unreads/drive.linux.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" diff --git a/e2e/scenarios/telegram-discussion-unreads/drive.macos.sh b/e2e/scenarios/telegram-discussion-unreads/drive.macos.sh new file mode 100644 index 00000000..98195337 --- /dev/null +++ b/e2e/scenarios/telegram-discussion-unreads/drive.macos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" diff --git a/e2e/scenarios/telegram-discussion-unreads/drive.py b/e2e/scenarios/telegram-discussion-unreads/drive.py new file mode 100644 index 00000000..9f2d3072 --- /dev/null +++ b/e2e/scenarios/telegram-discussion-unreads/drive.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Observe the real Tinyclient Inbox while an isolated Telegram session reconnects.""" + +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import time + + +OUT = Path(os.environ["E2E_OUT"]).resolve() +APP = Path(os.environ["E2E_APP_DIR"]).resolve() +T0 = float(os.environ.get("E2E_T0") or time.time()) +WINDOWS = sys.platform == "win32" + + +def step(words, ok): + print(("[ok] " if ok else "[FAILED] ") + words, flush=True) + with (OUT / "steps.jsonl").open("a", encoding="utf-8") as out: + out.write(json.dumps({"t": round(time.time() - T0, 2), "step": words, "ok": bool(ok)}) + "\n") + if not ok: + raise RuntimeError(words) + + +def until(predicate, seconds=25): + end = time.monotonic() + seconds + while time.monotonic() < end: + if predicate(): + return True + time.sleep(0.2) + return False + + +def stop(process): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def run(data, log): + folder = APP / "Contents" / "MacOS" if APP.suffix == ".app" else APP + suffix = ".exe" if WINDOWS else "" + chatd, tinyclient = (folder / (name + suffix) for name in ("chatd", "tinyclient")) + env = {k: v for k, v in os.environ.items() if not k.startswith("TINYCLIENT_SESSION_")} + env.update(TINYCLIENT_DIR=str(data), TINYCLIENT_TELEGRAM_FAKE="30", TINYCLIENT_TELEGRAM_T0=str(int(time.time())), + TINYCLIENT_SESSION_TELEGRAM='{"user":7001,"name":"Mira Kessler","home":2,"dcs":[]}', + TINYCLIENT_LOG_LEVEL="info") + + def spawn(binary, console=False): + return subprocess.Popen([str(binary)], cwd=data, env=env, stdout=log, stderr=log, + creationflags=0x08000000 if WINDOWS and console else 0) + + def inbox(): + result = subprocess.run([str(tinyclient), "--text"], cwd=data, env=env, capture_output=True, text=True, + timeout=8, creationflags=0x08000000 if WINDOWS else 0) + return result.stdout if result.returncode == 0 else "" + + def steer(line): + with (data / "telegram.ctl").open("a", encoding="utf-8") as control: + control.write(line + "\n") + + def badge(text, title): + prefix = "Telegram " + title.ljust(24) + " " + return next((line[len(prefix):len(prefix) + 3].strip() for line in text.splitlines() + if line.startswith(prefix)), None) + + daemon = spawn(chatd, console=True) + try: + step("Telegram connects with a fresh profile", until(lambda: "Telegram Connected" in inbox())) + finally: + stop(daemon) + + # Fixture: the previous app session ended with three unread comments in the group. + # While it was offline, the person left in another Telegram app. The fake's dialogs omit it. + with sqlite3.connect(data / "chat.sqlite") as store: + store.execute("INSERT INTO conversation(network, account, id, kind, title, unread, updated) " + "VALUES ('telegram', '7001', 'c9003', 'group', 'Field Notes chat', 3, ?)", (int(time.time()),)) + store.execute("INSERT OR REPLACE INTO name(network, account, id, value) " + "VALUES ('telegram', '7001', 'peer:c9003', ?)", ("9003009\tField Notes chat",)) + + daemon = spawn(chatd, console=True) + window = None + try: + step("Telegram reconnects after leaving the discussion group", until(lambda: "Telegram Connected" in inbox())) + if not os.environ.get("E2E_NO_WINDOW"): + window = spawn(tinyclient) + (OUT / "app.pid").write_text(str(window.pid), encoding="utf-8") + step("The Inbox window opens", until(lambda: any( + b"tinyclient: window open" in path.read_bytes() for path in data.rglob("tinyclient.log") + ), 15)) + before = inbox() + (OUT / "inbox-after-reconnect.txt").write_text(before, encoding="utf-8") + step("The left group and its old badge are gone from the Inbox", badge(before, "Field Notes chat") is None) + + steer("discussion-comment 80 84") + steer("discussion-reply") + step("One direct reply appears in Replies", until(lambda: badge(inbox(), "Replies") == "1")) + after = inbox() + (OUT / "inbox-after-reply.txt").write_text(after, encoding="utf-8") + step("Activity on the later post did not bring the group back", badge(after, "Field Notes chat") is None) + step("The direct reply is not in Saved Messages", "A reply to your comment" not in next( + (line for line in after.splitlines() if line.startswith("Telegram Saved Messages")), "")) + if window: + time.sleep(2) # Give the lab time to record the new Inbox row. + finally: + if window: + stop(window) + stop(daemon) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + (OUT / "steps.jsonl").write_text("", encoding="utf-8") + with tempfile.TemporaryDirectory(prefix="tiny-tg-", dir=None if WINDOWS else "/tmp") as temp: + with (OUT / "app.log").open("wb") as log: + run(Path(temp), log) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, sqlite3.Error, subprocess.TimeoutExpired) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/e2e/scenarios/telegram-discussion-unreads/drive.windows.ps1 b/e2e/scenarios/telegram-discussion-unreads/drive.windows.ps1 new file mode 100644 index 00000000..afff558a --- /dev/null +++ b/e2e/scenarios/telegram-discussion-unreads/drive.windows.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" +python "$PSScriptRoot\drive.py" +exit $LASTEXITCODE diff --git a/e2e/scenarios/telegram-discussion-unreads/scenario.json b/e2e/scenarios/telegram-discussion-unreads/scenario.json new file mode 100644 index 00000000..40e43802 --- /dev/null +++ b/e2e/scenarios/telegram-discussion-unreads/scenario.json @@ -0,0 +1,12 @@ +{ + "id": "telegram-discussion-unreads", + "title": "Leaving a Telegram discussion group clears its badge and keeps direct replies", + "platforms": ["windows", "macos", "linux"], + "not_applicable": { + "android": "The lab's phone demo build bypasses chatd and cannot run the scripted Telegram wire.", + "ios": "The lab's phone demo build bypasses chatd and cannot run the scripted Telegram wire." + }, + "needs": [], + "release": false, + "max_still_seconds": 40 +} diff --git a/e2e/scenarios/telegram-reply-footer/drive.macos.sh b/e2e/scenarios/telegram-reply-footer/drive.macos.sh new file mode 100644 index 00000000..98195337 --- /dev/null +++ b/e2e/scenarios/telegram-reply-footer/drive.macos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" diff --git a/e2e/scenarios/telegram-reply-footer/drive.py b/e2e/scenarios/telegram-reply-footer/drive.py new file mode 100644 index 00000000..213d0bb0 --- /dev/null +++ b/e2e/scenarios/telegram-reply-footer/drive.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Open a real Telegram channel pane and read the footer from its window pixels.""" + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +OUT = Path(os.environ["E2E_OUT"]).resolve() +APP = Path(os.environ["E2E_APP_DIR"]).resolve() / "Contents" / "MacOS" +HELPER = os.environ["E2E_HELPER"] +T0 = float(os.environ.get("E2E_T0") or time.time()) + + +def step(words, ok): + print(("[ok] " if ok else "[FAILED] ") + words, flush=True) + with (OUT / "steps.jsonl").open("a", encoding="utf-8") as log: + log.write(json.dumps({"t": round(time.time() - T0, 2), "step": words, "ok": bool(ok)}) + "\n") + if not ok: + raise RuntimeError(words) + + +def until(predicate, seconds=25): + end = time.monotonic() + seconds + while time.monotonic() < end: + value = predicate() + if value: + return value + time.sleep(0.25) + return None + + +def window_text(pid): + path = OUT / "window-text.json" + path.unlink(missing_ok=True) + helper = Path(HELPER) + launch = ["open", "-n", "-a", HELPER, "--args"] if helper.suffix == ".app" else [HELPER] + subprocess.run([*launch, "text", "--pid", str(pid), "--out", str(path)], + check=True, timeout=20) + if not until(path.exists, 10): + raise RuntimeError("The lab helper did not read the window") + seen = json.loads(path.read_text(encoding="utf-8")) + if isinstance(seen, str): + raise RuntimeError(seen) + return " ".join(" ".join(seen).lower().split()) + + +def stop(proc): + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def run(data): + env = {name: value for name, value in os.environ.items() if not name.startswith("TINYCLIENT_SESSION_")} + env.update(TINYCLIENT_DIR=str(data), TINYCLIENT_TELEGRAM_FAKE="30", TINYCLIENT_TELEGRAM_T0=str(int(time.time())), + TINYCLIENT_SESSION_TELEGRAM='{"user":7001,"name":"Mira Kessler","home":2,"dcs":[]}', + TINYCLIENT_LOG_LEVEL="info") + window = None + with (OUT / "app.log").open("wb") as log: + daemon = subprocess.Popen([APP / "chatd"], env=env, stdout=log, stderr=log) + try: + def connected(): + result = subprocess.run([APP / "tinyclient", "--text"], env=env, capture_output=True, text=True, timeout=5) + return "Telegram Connected" in result.stdout + + step("The invented Telegram account is connected", until(connected)) + channel = json.dumps({"network": "telegram", "account": "7001", "id": "c9001"}) + window = subprocess.Popen([APP / "tinyclient", "--open=" + channel], env=env, stdout=log, stderr=log) + (OUT / "app.pid").write_text(str(window.pid), encoding="utf-8") + calls = data / "telegram.calls" + step("The Field Notes channel is open", until(lambda: calls.exists() and + "GetSponsoredMessages c9001" in calls.read_text(encoding="utf-8"))) + with (data / "telegram.ctl").open("a", encoding="utf-8") as control: + control.write("comments\n") + + def footer(): + text = window_text(window.pid) + return text if re.search(r"\b2 replies\b", text) else None + + seen = until(footer) + step("The post shows a 2 replies link before its comments have been opened", seen) + step("The footer omits the unknown last reply time", "last reply" not in seen) + time.sleep(2) + finally: + stop(window) + stop(daemon) + + +if __name__ == "__main__": + OUT.mkdir(parents=True, exist_ok=True) + (OUT / "steps.jsonl").write_text("", encoding="utf-8") + try: + with tempfile.TemporaryDirectory(prefix="tiny-tg-footer-", dir="/tmp") as directory: + run(Path(directory)) + except (RuntimeError, OSError, subprocess.SubprocessError) as error: + print(error, flush=True) + raise SystemExit(1) diff --git a/e2e/scenarios/telegram-reply-footer/scenario.json b/e2e/scenarios/telegram-reply-footer/scenario.json new file mode 100644 index 00000000..306b2031 --- /dev/null +++ b/e2e/scenarios/telegram-reply-footer/scenario.json @@ -0,0 +1,14 @@ +{ + "id": "telegram-reply-footer", + "title": "A Telegram post with an unknown reply time still shows its reply link", + "platforms": ["macos"], + "not_applicable": { + "windows": "This visual assertion uses the macOS lab's window text reader. The shared footer is also covered by the Rust regression test.", + "linux": "This visual assertion uses the macOS lab's window text reader. The shared footer is also covered by the Rust regression test.", + "android": "The lab's phone demo build bypasses the scripted Telegram wire.", + "ios": "The lab's phone demo build bypasses the scripted Telegram wire." + }, + "needs": [], + "release": false, + "max_still_seconds": 40 +}