Skip to content

Commit 509464d

Browse files
committed
feat: add --topic and --all-topics flags to read command for forum groups
1 parent 39244f3 commit 509464d

6 files changed

Lines changed: 247 additions & 60 deletions

File tree

src/app/mod.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,55 @@ impl App {
150150
chat_id
151151
);
152152
}
153+
154+
/// Mark all forum topics in a chat as read.
155+
/// Returns the number of topics marked as read.
156+
pub async fn mark_read_all_topics(&self, chat_id: i64) -> Result<usize> {
157+
let peer_ref = self.resolve_peer_ref_for_topics(chat_id).await?;
158+
let input_peer: tl::enums::InputPeer = peer_ref.into();
159+
160+
// First, fetch all topics
161+
let request = tl::functions::messages::GetForumTopics {
162+
peer: input_peer.clone(),
163+
q: None,
164+
offset_date: 0,
165+
offset_id: 0,
166+
offset_topic: 0,
167+
limit: 100,
168+
};
169+
170+
let result = self
171+
.tg
172+
.client
173+
.invoke(&request)
174+
.await
175+
.with_context(|| format!("Failed to fetch forum topics for chat {}", chat_id))?;
176+
177+
let topics = match result {
178+
tl::enums::messages::ForumTopics::Topics(t) => t.topics,
179+
};
180+
181+
let mut count = 0;
182+
for topic_enum in topics {
183+
if let tl::enums::ForumTopic::Topic(topic) = topic_enum {
184+
// Mark this topic as read
185+
let read_request = tl::functions::messages::ReadDiscussion {
186+
peer: input_peer.clone(),
187+
msg_id: topic.id,
188+
read_max_id: topic.top_message,
189+
};
190+
191+
match self.tg.client.invoke(&read_request).await {
192+
Ok(_) => {
193+
count += 1;
194+
}
195+
Err(e) => {
196+
log::warn!("Failed to mark topic {} as read: {}", topic.id, e);
197+
}
198+
}
199+
}
200+
}
201+
202+
Ok(count)
203+
}
153204
}

src/app/send.rs

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -387,13 +387,14 @@ impl App {
387387
Ok(())
388388
}
389389

390-
/// Forward a message from one chat to another.
390+
/// Forward a message from one chat to another (optionally to a specific topic).
391391
/// Returns the new message ID in the destination chat.
392392
pub async fn forward_message(
393393
&self,
394394
from_chat_id: i64,
395395
msg_id: i64,
396396
to_chat_id: i64,
397+
to_topic_id: Option<i32>,
397398
) -> Result<i64> {
398399
let from_peer = self.resolve_peer_ref(from_chat_id).await?;
399400
let to_peer = self.resolve_peer_ref(to_chat_id).await?;
@@ -415,7 +416,7 @@ impl App {
415416
id: vec![msg_id as i32],
416417
random_id: vec![random_id],
417418
to_peer: to_input_peer,
418-
top_msg_id: None,
419+
top_msg_id: to_topic_id,
419420
schedule_date: None,
420421
send_as: None,
421422
quick_reply_shortcut: None,
@@ -434,14 +435,29 @@ impl App {
434435
Ok(new_msg_id)
435436
}
436437

437-
/// Mark a chat as read.
438-
pub async fn mark_read(&mut self, chat_id: i64) -> Result<()> {
438+
/// Mark a chat (or topic in a forum) as read.
439+
pub async fn mark_read(&mut self, chat_id: i64, topic_id: Option<i32>) -> Result<()> {
439440
let peer_ref = self.resolve_peer_ref(chat_id).await?;
440-
self.tg
441-
.client
442-
.mark_as_read(peer_ref)
443-
.await
444-
.context(format!("Failed to mark chat {} as read", chat_id))?;
441+
442+
if let Some(tid) = topic_id {
443+
// For forum topics, use ReadDiscussion to mark the topic as read
444+
let input_peer: tl::enums::InputPeer = peer_ref.into();
445+
let request = tl::functions::messages::ReadDiscussion {
446+
peer: input_peer,
447+
msg_id: tid,
448+
read_max_id: i32::MAX,
449+
};
450+
self.tg.client.invoke(&request).await.context(format!(
451+
"Failed to mark topic {} in chat {} as read",
452+
tid, chat_id
453+
))?;
454+
} else {
455+
self.tg
456+
.client
457+
.mark_as_read(peer_ref)
458+
.await
459+
.context(format!("Failed to mark chat {} as read", chat_id))?;
460+
}
445461
Ok(())
446462
}
447463

@@ -1026,33 +1042,63 @@ impl App {
10261042
Ok(())
10271043
}
10281044

1029-
/// Send typing indicator to a chat.
1030-
pub async fn set_typing(&self, chat_id: i64) -> Result<()> {
1045+
/// Send typing indicator to a chat (or topic in a forum).
1046+
pub async fn set_typing(&self, chat_id: i64, topic_id: Option<i32>) -> Result<()> {
10311047
let peer_ref = self.resolve_peer_ref(chat_id).await?;
1032-
self.tg
1033-
.client
1034-
.action(peer_ref)
1035-
.oneshot(SendMessageAction::SendMessageTypingAction)
1036-
.await
1037-
.context(format!(
1038-
"Failed to set typing indicator in chat {}",
1039-
chat_id
1048+
1049+
if let Some(tid) = topic_id {
1050+
// For forum topics, use raw TL to set typing with top_msg_id
1051+
let input_peer: tl::enums::InputPeer = peer_ref.into();
1052+
let request = tl::functions::messages::SetTyping {
1053+
peer: input_peer,
1054+
top_msg_id: Some(tid),
1055+
action: SendMessageAction::SendMessageTypingAction,
1056+
};
1057+
self.tg.client.invoke(&request).await.context(format!(
1058+
"Failed to set typing indicator in topic {} of chat {}",
1059+
tid, chat_id
10401060
))?;
1061+
} else {
1062+
self.tg
1063+
.client
1064+
.action(peer_ref)
1065+
.oneshot(SendMessageAction::SendMessageTypingAction)
1066+
.await
1067+
.context(format!(
1068+
"Failed to set typing indicator in chat {}",
1069+
chat_id
1070+
))?;
1071+
}
10411072
Ok(())
10421073
}
10431074

1044-
/// Cancel typing indicator in a chat.
1045-
pub async fn cancel_typing(&self, chat_id: i64) -> Result<()> {
1075+
/// Cancel typing indicator in a chat (or topic in a forum).
1076+
pub async fn cancel_typing(&self, chat_id: i64, topic_id: Option<i32>) -> Result<()> {
10461077
let peer_ref = self.resolve_peer_ref(chat_id).await?;
1047-
self.tg
1048-
.client
1049-
.action(peer_ref)
1050-
.cancel()
1051-
.await
1052-
.context(format!(
1053-
"Failed to cancel typing indicator in chat {}",
1054-
chat_id
1078+
1079+
if let Some(tid) = topic_id {
1080+
// For forum topics, use raw TL to cancel typing with top_msg_id
1081+
let input_peer: tl::enums::InputPeer = peer_ref.into();
1082+
let request = tl::functions::messages::SetTyping {
1083+
peer: input_peer,
1084+
top_msg_id: Some(tid),
1085+
action: SendMessageAction::SendMessageCancelAction,
1086+
};
1087+
self.tg.client.invoke(&request).await.context(format!(
1088+
"Failed to cancel typing indicator in topic {} of chat {}",
1089+
tid, chat_id
10551090
))?;
1091+
} else {
1092+
self.tg
1093+
.client
1094+
.action(peer_ref)
1095+
.cancel()
1096+
.await
1097+
.context(format!(
1098+
"Failed to cancel typing indicator in chat {}",
1099+
chat_id
1100+
))?;
1101+
}
10561102
Ok(())
10571103
}
10581104
}

src/cmd/messages.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ pub enum MessagesCommand {
6565
/// Chat ID filter
6666
#[arg(long)]
6767
chat: Option<i64>,
68+
/// Topic ID (for forum groups)
69+
#[arg(long)]
70+
topic: Option<i32>,
6871
/// Sender ID filter
6972
#[arg(long)]
7073
from: Option<i64>,
@@ -161,6 +164,9 @@ pub enum MessagesCommand {
161164
/// Destination chat ID
162165
#[arg(long)]
163166
to: i64,
167+
/// Destination topic ID (for forum groups)
168+
#[arg(long)]
169+
topic: Option<i32>,
164170
},
165171
/// Edit a message's text
166172
Edit {
@@ -323,6 +329,7 @@ pub async fn run(cli: &Cli, cmd: &MessagesCommand) -> Result<()> {
323329
MessagesCommand::Search {
324330
query,
325331
chat,
332+
topic,
326333
from,
327334
limit,
328335
media_type,
@@ -334,6 +341,7 @@ pub async fn run(cli: &Cli, cmd: &MessagesCommand) -> Result<()> {
334341
.search_messages(store::SearchMessagesParams {
335342
query: query.clone(),
336343
chat_id: *chat,
344+
topic_id: *topic,
337345
from_id: *from,
338346
limit: *limit,
339347
media_type: media_type.clone(),
@@ -469,20 +477,34 @@ pub async fn run(cli: &Cli, cmd: &MessagesCommand) -> Result<()> {
469477
);
470478
}
471479
}
472-
MessagesCommand::Forward { chat, id, to } => {
480+
MessagesCommand::Forward {
481+
chat,
482+
id,
483+
to,
484+
topic,
485+
} => {
473486
// Forward requires network access
474487
let app = App::new(cli).await?;
475488

476-
let new_msg_id = app.forward_message(*chat, *id, *to).await?;
489+
let new_msg_id = app.forward_message(*chat, *id, *to, *topic).await?;
477490

478491
if cli.json {
479-
out::write_json(&serde_json::json!({
492+
let mut json = serde_json::json!({
480493
"forwarded": true,
481494
"from_chat": chat,
482495
"message_id": id,
483496
"to_chat": to,
484497
"new_message_id": new_msg_id,
485-
}))?;
498+
});
499+
if let Some(topic_id) = topic {
500+
json["to_topic"] = serde_json::json!(topic_id);
501+
}
502+
out::write_json(&json)?;
503+
} else if let Some(topic_id) = topic {
504+
println!(
505+
"Forwarded message {} from {} to {} topic {} (new ID: {})",
506+
id, chat, to, topic_id, new_msg_id
507+
);
486508
} else {
487509
println!(
488510
"Forwarded message {} from {} to {} (new ID: {})",

src/cmd/read.rs

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,83 @@ pub struct ReadArgs {
1313
/// Message ID (mark up to this message as read)
1414
#[arg(long)]
1515
pub message: Option<i64>,
16+
17+
/// Topic ID (for forum groups - marks a specific topic as read)
18+
#[arg(long)]
19+
pub topic: Option<i32>,
20+
21+
/// Mark all topics as read (for forum groups)
22+
#[arg(long)]
23+
pub all_topics: bool,
1624
}
1725

1826
pub async fn run(cli: &Cli, args: &ReadArgs) -> Result<()> {
1927
let store_dir = cli.store_dir();
2028

21-
// Try socket first
22-
if crate::app::socket::is_socket_available(&store_dir) {
23-
let resp = crate::app::socket::send_request(
24-
&store_dir,
25-
crate::app::socket::SocketRequest::MarkRead {
26-
chat: args.chat,
27-
message: args.message,
28-
},
29-
)
30-
.await?;
31-
32-
if resp.ok {
33-
if cli.json {
34-
out::write_json(&serde_json::json!({ "marked_read": true }))?;
35-
} else {
36-
println!("Marked as read.");
29+
// Validate: --topic and --all-topics are mutually exclusive
30+
if args.topic.is_some() && args.all_topics {
31+
anyhow::bail!("Cannot use both --topic and --all-topics at the same time");
32+
}
33+
34+
// Try socket first (but only for simple chat read, not topics)
35+
if args.topic.is_none() && !args.all_topics {
36+
if crate::app::socket::is_socket_available(&store_dir) {
37+
let resp = crate::app::socket::send_request(
38+
&store_dir,
39+
crate::app::socket::SocketRequest::MarkRead {
40+
chat: args.chat,
41+
message: args.message,
42+
},
43+
)
44+
.await?;
45+
46+
if resp.ok {
47+
if cli.json {
48+
out::write_json(&serde_json::json!({ "marked_read": true }))?;
49+
} else {
50+
println!("Marked as read.");
51+
}
52+
return Ok(());
3753
}
38-
return Ok(());
3954
}
4055
}
4156

4257
// Fallback: direct connection
4358
let mut app = App::new(cli).await?;
44-
app.mark_read(args.chat).await?;
4559

46-
if cli.json {
47-
out::write_json(&serde_json::json!({ "marked_read": true }))?;
60+
if args.all_topics {
61+
// Mark all topics as read
62+
let count = app.mark_read_all_topics(args.chat).await?;
63+
64+
if cli.json {
65+
out::write_json(&serde_json::json!({
66+
"marked_read": true,
67+
"topics_count": count
68+
}))?;
69+
} else {
70+
println!("Marked {} topics as read.", count);
71+
}
72+
} else if let Some(topic_id) = args.topic {
73+
// Mark a specific topic as read
74+
app.mark_read(args.chat, Some(topic_id)).await?;
75+
76+
if cli.json {
77+
out::write_json(&serde_json::json!({
78+
"marked_read": true,
79+
"topic_id": topic_id
80+
}))?;
81+
} else {
82+
println!("Marked topic {} as read.", topic_id);
83+
}
4884
} else {
49-
println!("Marked as read.");
85+
// Mark the whole chat as read (or a single topic if --topic was given but not --all-topics)
86+
app.mark_read(args.chat, None).await?;
87+
88+
if cli.json {
89+
out::write_json(&serde_json::json!({ "marked_read": true }))?;
90+
} else {
91+
println!("Marked as read.");
92+
}
5093
}
5194

5295
Ok(())

0 commit comments

Comments
 (0)