Skip to content

Commit 29f6de5

Browse files
committed
feat(send): add --topic flag for sending messages to forum topics
- Add --topic <TOPIC_ID> optional flag to send command - Implement send_text_to_topic() using raw TL invocation - Set top_msg_id in InputReplyToMessage for proper topic routing - Include topic_id in JSON output when specified - Skip socket path for topic messages (direct connection required) - Error if --topic combined with --sticker (not supported yet)
1 parent 78cce4d commit 29f6de5

2 files changed

Lines changed: 136 additions & 6 deletions

File tree

src/app/send.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use chrono::Utc;
66
use grammers_client::InputMessage;
77
use grammers_session::defs::PeerRef;
88
use grammers_tl_types as tl;
9+
use rand::Rng;
910

1011
/// Decode a file_id string back to its components.
1112
/// Returns (doc_id, access_hash, file_reference)
@@ -55,6 +56,117 @@ impl App {
5556
Ok(msg.id() as i64)
5657
}
5758

59+
/// Send a text message to a specific forum topic by ID, returns the message ID.
60+
/// Uses raw TL invocation to set top_msg_id for topic support.
61+
pub async fn send_text_to_topic(
62+
&mut self,
63+
chat_id: i64,
64+
topic_id: i32,
65+
text: &str,
66+
) -> Result<i64> {
67+
let peer_ref = self.resolve_peer_ref(chat_id).await?;
68+
let input_peer: tl::enums::InputPeer = peer_ref.into();
69+
70+
let random_id: i64 = rand::rng().random();
71+
72+
let request = tl::functions::messages::SendMessage {
73+
no_webpage: true,
74+
silent: false,
75+
background: false,
76+
clear_draft: false,
77+
noforwards: false,
78+
update_stickersets_order: false,
79+
invert_media: false,
80+
allow_paid_floodskip: false,
81+
peer: input_peer,
82+
reply_to: Some(
83+
tl::types::InputReplyToMessage {
84+
reply_to_msg_id: topic_id,
85+
top_msg_id: Some(topic_id),
86+
reply_to_peer_id: None,
87+
quote_text: None,
88+
quote_entities: None,
89+
quote_offset: None,
90+
monoforum_peer_id: None,
91+
todo_item_id: None,
92+
}
93+
.into(),
94+
),
95+
message: text.to_string(),
96+
random_id,
97+
reply_markup: None,
98+
entities: None,
99+
schedule_date: None,
100+
send_as: None,
101+
quick_reply_shortcut: None,
102+
effect: None,
103+
allow_paid_stars: None,
104+
suggested_post: None,
105+
};
106+
107+
let updates = self.tg.client.invoke(&request).await?;
108+
109+
// Extract message ID from updates
110+
let msg_id = Self::extract_message_id_from_updates(&updates)?;
111+
112+
let now = Utc::now();
113+
self.store
114+
.upsert_message(UpsertMessageParams {
115+
id: msg_id,
116+
chat_id,
117+
sender_id: 0,
118+
ts: now,
119+
edit_ts: None,
120+
from_me: true,
121+
text: text.to_string(),
122+
media_type: None,
123+
media_path: None,
124+
reply_to_id: None,
125+
topic_id: Some(topic_id as i64),
126+
})
127+
.await?;
128+
129+
// Update chat's last_message_ts
130+
self.store
131+
.upsert_chat(chat_id, "user", "", None, Some(now), false)
132+
.await?;
133+
134+
Ok(msg_id)
135+
}
136+
137+
/// Extract message ID from Updates response
138+
fn extract_message_id_from_updates(updates: &tl::enums::Updates) -> Result<i64> {
139+
match updates {
140+
tl::enums::Updates::Updates(u) => {
141+
for update in &u.updates {
142+
if let tl::enums::Update::NewMessage(m) = update {
143+
if let tl::enums::Message::Message(msg) = &m.message {
144+
return Ok(msg.id as i64);
145+
}
146+
}
147+
if let tl::enums::Update::NewChannelMessage(m) = update {
148+
if let tl::enums::Message::Message(msg) = &m.message {
149+
return Ok(msg.id as i64);
150+
}
151+
}
152+
}
153+
anyhow::bail!("No message ID found in Updates response")
154+
}
155+
tl::enums::Updates::UpdateShort(u) => {
156+
if let tl::enums::Update::NewMessage(m) = &u.update {
157+
if let tl::enums::Message::Message(msg) = &m.message {
158+
return Ok(msg.id as i64);
159+
}
160+
}
161+
anyhow::bail!("No message ID found in UpdateShort response")
162+
}
163+
tl::enums::Updates::UpdateShortMessage(u) => Ok(u.id as i64),
164+
tl::enums::Updates::UpdateShortChatMessage(u) => Ok(u.id as i64),
165+
tl::enums::Updates::UpdateShortSentMessage(u) => Ok(u.id as i64),
166+
_ => anyhow::bail!("Unexpected Updates type"),
167+
}
168+
}
169+
58170
/// Mark a chat as read.
59171
pub async fn mark_read(&mut self, chat_id: i64) -> Result<()> {
60172
let peer_ref = self.resolve_peer_ref(chat_id).await?;

src/cmd/send.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,20 @@ pub struct SendArgs {
1717
/// Sticker file_id (from `tgcli stickers show --pack <pack>`)
1818
#[arg(long, conflicts_with = "message")]
1919
pub sticker: Option<String>,
20+
21+
/// Forum topic ID (for sending to a specific topic in a forum/supergroup)
22+
#[arg(long)]
23+
pub topic: Option<i32>,
2024
}
2125

2226
pub async fn run(cli: &Cli, args: &SendArgs) -> Result<()> {
2327
let store_dir = cli.store_dir();
2428

2529
// Handle sticker sending
2630
if let Some(ref sticker_id) = args.sticker {
31+
if args.topic.is_some() {
32+
anyhow::bail!("--topic is not supported with --sticker yet");
33+
}
2734
// Stickers always use direct connection (no socket support yet)
2835
let mut app = App::new(cli).await?;
2936
let msg_id = app.send_sticker(args.to, sticker_id).await?;
@@ -47,8 +54,8 @@ pub async fn run(cli: &Cli, args: &SendArgs) -> Result<()> {
4754
.as_ref()
4855
.expect("message required when no sticker");
4956

50-
// Try socket first (sync process may be running)
51-
if crate::app::socket::is_socket_available(&store_dir) {
57+
// Try socket first (sync process may be running) - but not for topic messages yet
58+
if args.topic.is_none() && crate::app::socket::is_socket_available(&store_dir) {
5259
let resp = crate::app::socket::send_request(
5360
&store_dir,
5461
crate::app::socket::SocketRequest::SendText {
@@ -74,16 +81,27 @@ pub async fn run(cli: &Cli, args: &SendArgs) -> Result<()> {
7481
}
7582
}
7683

77-
// Fallback: direct connection
84+
// Direct connection (required for topic messages)
7885
let mut app = App::new(cli).await?;
79-
let msg_id = app.send_text(args.to, message).await?;
86+
87+
let msg_id = if let Some(topic_id) = args.topic {
88+
app.send_text_to_topic(args.to, topic_id, message).await?
89+
} else {
90+
app.send_text(args.to, message).await?
91+
};
8092

8193
if cli.json {
82-
out::write_json(&serde_json::json!({
94+
let mut json = serde_json::json!({
8395
"sent": true,
8496
"to": args.to,
8597
"id": msg_id,
86-
}))?;
98+
});
99+
if let Some(topic_id) = args.topic {
100+
json["topic"] = serde_json::json!(topic_id);
101+
}
102+
out::write_json(&json)?;
103+
} else if let Some(topic_id) = args.topic {
104+
println!("Sent to {} topic {}", args.to, topic_id);
87105
} else {
88106
println!("Sent to {}", args.to);
89107
}

0 commit comments

Comments
 (0)