Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/chatd/src/adapters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
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 {
Expand Down
7 changes: 7 additions & 0 deletions crates/chatd/src/adapters/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
35 changes: 32 additions & 3 deletions crates/chatd/src/adapters/telegram/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<i32>,
/// The root of a discussion thread, distinct from the comment this message answers.
pub discussion_top: Option<i32>,
/// The message it answers, when it is a reply and not just a post in a topic.
pub reply: Option<i32>,
pub message: Message,
Expand Down Expand Up @@ -478,19 +480,26 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option<Mapped> {
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
Expand Down Expand Up @@ -520,6 +529,21 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option<Mapped> {
}
}
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) => {}
Expand Down Expand Up @@ -561,11 +585,16 @@ pub fn map(dir: &Directory, raw: &tl::enums::Message) -> Option<Mapped> {
}
}
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,
})
Expand Down
70 changes: 66 additions & 4 deletions crates/chatd/src/adapters/telegram/fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -181,12 +182,16 @@ fn chats() -> Vec<tl::enums::Chat> {
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;
Expand Down Expand Up @@ -679,7 +684,7 @@ fn discussion_answer(place: &str, unread: i32) -> Result<Vec<u8>, 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))
Expand Down Expand Up @@ -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<Event> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1500,6 +1553,15 @@ impl World {
}
return Err(rpc(400, "PEER_ID_INVALID", None));
}
if let Some(request) = asked::<f::channels::GetChannels>(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::<f::contacts::GetContacts>(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]);
Expand Down
11 changes: 7 additions & 4 deletions crates/chatd/src/adapters/telegram/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
43 changes: 38 additions & 5 deletions crates/chatd/src/adapters/telegram/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
Loading