From 53a36ad1f37fc46861c24cdea3464d993afee476 Mon Sep 17 00:00:00 2001 From: wangyuyan-agent Date: Mon, 9 Mar 2026 20:44:16 +0800 Subject: [PATCH] feat: reject feedback prompt + timeout UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After Reject, bot sends a follow-up message prompting for an optional reason. User has 60 seconds to reply; if they do, the feedback is captured in the JSON output under the existing 'feedback' field. If no reply arrives within 60s, the request exits cleanly with feedback: null. - On timeout, the inline keyboard is now removed from the original message and a localised '⏰ Request timed out' notice is sent, so users aren't left with stale Approve/Reject buttons. - New i18n strings added for all three locales (en, zh-CN, zh-TW): reject_feedback_prompt, reject_feedback_callback, timeout_notice. --- src/i18n.rs | 40 +++++++++------ src/providers/telegram.rs | 103 +++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/i18n.rs b/src/i18n.rs index c436214..2eb1a2f 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -32,31 +32,43 @@ pub struct Messages { pub prompt_text: &'static str, pub approved_callback: &'static str, pub rejected_callback: &'static str, + pub reject_feedback_prompt: &'static str, + pub reject_feedback_callback: &'static str, + pub timeout_notice: &'static str, } impl Locale { pub fn messages(self) -> Messages { match self { Self::En => Messages { - approve_button: "\u{2705} Approve", - reject_button: "\u{274C} Reject", + approve_button: "✅ Approve", + reject_button: "❌ Reject", prompt_text: "Please approve or reject this request.", - approved_callback: "Approved \u{2714}", - rejected_callback: "Rejected \u{2714}", + approved_callback: "Approved ✔", + rejected_callback: "Rejected ✔", + reject_feedback_prompt: "❌ Rejected. Reply to this message within 60 seconds to add a reason (or ignore to skip).", + reject_feedback_callback: "Rejected with feedback ✔", + timeout_notice: "⏰ Request timed out — no response received.", }, Self::ZhCN => Messages { - approve_button: "\u{2705} \u{6279}\u{51C6}", - reject_button: "\u{274C} \u{62D2}\u{7EDD}", - prompt_text: "\u{8BF7}\u{6279}\u{51C6}\u{6216}\u{62D2}\u{7EDD}\u{6B64}\u{8BF7}\u{6C42}\u{3002}", - approved_callback: "\u{5DF2}\u{6279}\u{51C6} \u{2714}", - rejected_callback: "\u{5DF2}\u{62D2}\u{7EDD} \u{2714}", + approve_button: "✅ 批准", + reject_button: "❌ 拒绝", + prompt_text: "请批准或拒绝此请求。", + approved_callback: "已批准 ✔", + rejected_callback: "已拒绝 ✔", + reject_feedback_prompt: "❌ 已拒绝。请在 60 秒内回复此消息以添加原因(忽略则跳过)。", + reject_feedback_callback: "已拒绝并附上原因 ✔", + timeout_notice: "⏰ 请求已超时 — 未收到响应。", }, Self::ZhTW => Messages { - approve_button: "\u{2705} \u{6279}\u{51C6}", - reject_button: "\u{274C} \u{62D2}\u{7D55}", - prompt_text: "\u{8ACB}\u{6279}\u{51C6}\u{6216}\u{62D2}\u{7D55}\u{6B64}\u{8ACB}\u{6C42}\u{3002}", - approved_callback: "\u{5DF2}\u{6279}\u{51C6} \u{2714}", - rejected_callback: "\u{5DF2}\u{62D2}\u{7D55} \u{2714}", + approve_button: "✅ 批准", + reject_button: "❌ 拒絕", + prompt_text: "請批准或拒絕此請求。", + approved_callback: "已批准 ✔", + rejected_callback: "已拒絕 ✔", + reject_feedback_prompt: "❌ 已拒絕。請在 60 秒內回覆此訊息以附上原因(忽略則跳過)。", + reject_feedback_callback: "已拒絕並附上原因 ✔", + timeout_notice: "⏰ 請求已超時 — 未收到回應。", }, } } diff --git a/src/providers/telegram.rs b/src/providers/telegram.rs index cffcc1a..e892e5d 100644 --- a/src/providers/telegram.rs +++ b/src/providers/telegram.rs @@ -217,6 +217,82 @@ impl TelegramProvider { Ok(()) } + async fn send_notice(&self, text: &str) -> Result<()> { + #[derive(Serialize)] + struct Req { + chat_id: i64, + text: String, + parse_mode: String, + } + self.client + .post(format!("{}/sendMessage", self.base_url)) + .json(&Req { + chat_id: self.config.chat_id, + text: text.to_string(), + parse_mode: "HTML".to_string(), + }) + .send() + .await?; + Ok(()) + } + + /// After a Reject button click, wait up to 60 seconds for a text reply + /// to the original message. Returns Some(feedback) or None if no reply arrives. + async fn wait_for_reject_feedback( + &self, + sent_message_id: i64, + mut offset: Option, + ) -> Result> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + + loop { + if tokio::time::Instant::now() >= deadline { + return Ok(None); + } + + let remaining = deadline - tokio::time::Instant::now(); + let poll_timeout = remaining.min(Duration::from_secs(10)); + + let mut url = format!( + "{}/getUpdates?timeout={}&allowed_updates=[\"message\"]", + self.base_url, + poll_timeout.as_secs() + ); + if let Some(off) = offset { + url.push_str(&format!("&offset={off}")); + } + + let resp: TelegramResponse> = + self.client.get(&url).send().await?.json().await?; + + let updates = match resp.result { + Some(u) => u, + None => continue, + }; + + for update in updates { + offset = Some(update.update_id + 1); + + if let Some(msg) = update.message { + if msg.chat.id != self.config.chat_id { + continue; + } + if let Some(ref reply_to) = msg.reply_to_message { + if reply_to.message_id == sent_message_id { + let user_id = msg.from.as_ref().map_or(0, |u| u.id); + if !self.is_trusted(user_id) { + continue; + } + return Ok(msg.text.clone()); + } + } + } + } + + tokio::time::sleep(POLL_INTERVAL).await; + } + } + async fn poll_for_response( &self, sent_message_id: i64, @@ -229,6 +305,11 @@ impl TelegramProvider { loop { if tokio::time::Instant::now() >= deadline { info!("Timeout reached, no response received"); + // Remove buttons and send timeout notice + self.edit_message_reply_markup(self.config.chat_id, sent_message_id) + .await + .ok(); + self.send_notice(self.messages.timeout_notice).await.ok(); return Ok(FeedbackResponse::timeout(title)); } @@ -300,11 +381,31 @@ impl TelegramProvider { "Response received" ); + // For reject: prompt for optional feedback and wait 60s + let feedback = if decision == Decision::Rejected { + self.send_notice(self.messages.reject_feedback_prompt) + .await + .ok(); + let fb = self + .wait_for_reject_feedback(sent_message_id, offset) + .await + .unwrap_or(None); + if fb.is_some() { + info!(feedback = ?fb, "Reject feedback received"); + self.send_notice(self.messages.reject_feedback_callback) + .await + .ok(); + } + fb + } else { + None + }; + return Ok(FeedbackResponse { decision, user: cb.from.display_name(), user_id: cb.from.id, - feedback: None, + feedback, timestamp: Utc::now(), request_title: title.to_string(), });