Skip to content

Commit 1f232a3

Browse files
author
antx-code
committed
feat: reject feedback prompt + timeout UX cleanup
Inspired by PR #1 (thanks @wangyuyan-agent). - After reject, send follow-up asking for reason; accept any text message from trusted user within configurable window (default 60s) - On timeout, remove stale inline keyboard + send timeout notice - New config: reject_feedback_timeout (seconds, 0 to skip) - New i18n strings: reject_feedback_prompt, timeout_notice
1 parent 4ad6791 commit 1f232a3

5 files changed

Lines changed: 159 additions & 6 deletions

File tree

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub struct Config {
1010
pub default_provider: String,
1111
#[serde(default = "default_timeout")]
1212
pub default_timeout: u64,
13+
#[serde(default = "default_reject_feedback_timeout")]
14+
pub reject_feedback_timeout: u64,
1315
#[serde(default)]
1416
pub locale: Locale,
1517
pub telegram: Option<TelegramConfig>,
@@ -47,6 +49,10 @@ fn default_timeout() -> u64 {
4749
3600
4850
}
4951

52+
fn default_reject_feedback_timeout() -> u64 {
53+
60
54+
}
55+
5056
fn default_audit_file() -> String {
5157
let base = dirs::data_local_dir()
5258
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
@@ -102,6 +108,8 @@ impl Config {
102108
pub fn generate_default() -> String {
103109
r#"default_provider = "telegram"
104110
default_timeout = 3600
111+
# Seconds to wait for reject feedback (0 = skip)
112+
reject_feedback_timeout = 60
105113
# locale: "en" (default), "zh-CN", "zh-TW"
106114
locale = "en"
107115

src/i18n.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub struct Messages {
3232
pub prompt_text: &'static str,
3333
pub approved_callback: &'static str,
3434
pub rejected_callback: &'static str,
35+
pub reject_feedback_prompt: &'static str,
36+
pub timeout_notice: &'static str,
3537
}
3638

3739
impl Locale {
@@ -43,20 +45,26 @@ impl Locale {
4345
prompt_text: "Please approve or reject this request.",
4446
approved_callback: "Approved \u{2714}",
4547
rejected_callback: "Rejected \u{2714}",
48+
reject_feedback_prompt: "\u{274C} <b>Rejected.</b> Reply to this message to add a reason (or ignore to skip).",
49+
timeout_notice: "\u{23F0} Request timed out \u{2014} no response received.",
4650
},
4751
Self::ZhCN => Messages {
4852
approve_button: "\u{2705} \u{6279}\u{51C6}",
4953
reject_button: "\u{274C} \u{62D2}\u{7EDD}",
5054
prompt_text: "\u{8BF7}\u{6279}\u{51C6}\u{6216}\u{62D2}\u{7EDD}\u{6B64}\u{8BF7}\u{6C42}\u{3002}",
5155
approved_callback: "\u{5DF2}\u{6279}\u{51C6} \u{2714}",
5256
rejected_callback: "\u{5DF2}\u{62D2}\u{7EDD} \u{2714}",
57+
reject_feedback_prompt: "\u{274C} <b>\u{5DF2}\u{62D2}\u{7EDD}\u{3002}</b>\u{56DE}\u{590D}\u{6B64}\u{6D88}\u{606F}\u{4EE5}\u{6DFB}\u{52A0}\u{539F}\u{56E0}\u{FF08}\u{5FFD}\u{7565}\u{5219}\u{8DF3}\u{8FC7}\u{FF09}\u{3002}",
58+
timeout_notice: "\u{23F0} \u{8BF7}\u{6C42}\u{5DF2}\u{8D85}\u{65F6} \u{2014} \u{672A}\u{6536}\u{5230}\u{54CD}\u{5E94}\u{3002}",
5359
},
5460
Self::ZhTW => Messages {
5561
approve_button: "\u{2705} \u{6279}\u{51C6}",
5662
reject_button: "\u{274C} \u{62D2}\u{7D55}",
5763
prompt_text: "\u{8ACB}\u{6279}\u{51C6}\u{6216}\u{62D2}\u{7D55}\u{6B64}\u{8ACB}\u{6C42}\u{3002}",
5864
approved_callback: "\u{5DF2}\u{6279}\u{51C6} \u{2714}",
5965
rejected_callback: "\u{5DF2}\u{62D2}\u{7D55} \u{2714}",
66+
reject_feedback_prompt: "\u{274C} <b>\u{5DF2}\u{62D2}\u{7D55}\u{3002}</b>\u{56DE}\u{8986}\u{6B64}\u{8A0A}\u{606F}\u{4EE5}\u{9644}\u{4E0A}\u{539F}\u{56E0}\u{FF08}\u{5FFD}\u{7565}\u{5247}\u{8DF3}\u{904E}\u{FF09}\u{3002}",
67+
timeout_notice: "\u{23F0} \u{8ACB}\u{6C42}\u{5DF2}\u{8D85}\u{6642} \u{2014} \u{672A}\u{6536}\u{5230}\u{56DE}\u{61C9}\u{3002}",
6068
},
6169
}
6270
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ async fn main() -> Result<()> {
8888
title: title.clone(),
8989
body: body_content,
9090
timeout_secs,
91+
reject_feedback_timeout_secs: config.reject_feedback_timeout,
9192
};
9293

9394
let provider: Box<dyn Provider> = match config.default_provider.as_str() {

src/providers/telegram.rs

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,19 +217,136 @@ impl TelegramProvider {
217217
Ok(())
218218
}
219219

220+
/// Send a message as a reply to another message
221+
async fn send_reply(&self, reply_to_message_id: i64, text: &str) -> Result<i64> {
222+
#[derive(Serialize)]
223+
struct Req {
224+
chat_id: i64,
225+
text: String,
226+
parse_mode: String,
227+
reply_to_message_id: i64,
228+
}
229+
let resp: TelegramResponse<Message> = self
230+
.client
231+
.post(format!("{}/sendMessage", self.base_url))
232+
.json(&Req {
233+
chat_id: self.config.chat_id,
234+
text: text.to_string(),
235+
parse_mode: "HTML".to_string(),
236+
reply_to_message_id,
237+
})
238+
.send()
239+
.await?
240+
.json()
241+
.await?;
242+
let msg_id = resp.result.map_or(0, |m| m.message_id);
243+
Ok(msg_id)
244+
}
245+
246+
/// Send a standalone notice message
247+
async fn send_notice(&self, text: &str) -> Result<()> {
248+
#[derive(Serialize)]
249+
struct Req {
250+
chat_id: i64,
251+
text: String,
252+
parse_mode: String,
253+
}
254+
self.client
255+
.post(format!("{}/sendMessage", self.base_url))
256+
.json(&Req {
257+
chat_id: self.config.chat_id,
258+
text: text.to_string(),
259+
parse_mode: "HTML".to_string(),
260+
})
261+
.send()
262+
.await?;
263+
Ok(())
264+
}
265+
266+
/// After reject, send a follow-up reply and wait for feedback text
267+
async fn wait_for_reject_feedback(
268+
&self,
269+
original_message_id: i64,
270+
wait_secs: u64,
271+
mut offset: Option<i64>,
272+
) -> Result<Option<String>> {
273+
if wait_secs == 0 {
274+
return Ok(None);
275+
}
276+
277+
// Send follow-up as reply to the original request message
278+
// Send follow-up as reply to the original request message
279+
self.send_reply(original_message_id, self.messages.reject_feedback_prompt)
280+
.await
281+
.ok();
282+
283+
let deadline = tokio::time::Instant::now() + Duration::from_secs(wait_secs);
284+
285+
loop {
286+
if tokio::time::Instant::now() >= deadline {
287+
return Ok(None);
288+
}
289+
290+
let remaining = deadline - tokio::time::Instant::now();
291+
let poll_timeout = remaining.min(Duration::from_secs(10));
292+
293+
let mut url = format!(
294+
"{}/getUpdates?timeout={}&allowed_updates=[\"message\"]",
295+
self.base_url,
296+
poll_timeout.as_secs()
297+
);
298+
if let Some(off) = offset {
299+
url.push_str(&format!("&offset={off}"));
300+
}
301+
302+
let resp: TelegramResponse<Vec<Update>> =
303+
self.client.get(&url).send().await?.json().await?;
304+
305+
let updates = match resp.result {
306+
Some(u) => u,
307+
None => continue,
308+
};
309+
310+
for update in updates {
311+
offset = Some(update.update_id + 1);
312+
313+
if let Some(msg) = update.message {
314+
if msg.chat.id != self.config.chat_id {
315+
continue;
316+
}
317+
let user_id = msg.from.as_ref().map_or(0, |u| u.id);
318+
if !self.is_trusted(user_id) {
319+
continue;
320+
}
321+
// Accept any text message from trusted user in this chat
322+
// (reply to original, reply to follow-up, or direct message)
323+
if msg.text.is_some() {
324+
return Ok(msg.text.clone());
325+
}
326+
}
327+
}
328+
329+
tokio::time::sleep(POLL_INTERVAL).await;
330+
}
331+
}
332+
220333
async fn poll_for_response(
221334
&self,
222335
sent_message_id: i64,
223336
timeout: Duration,
224-
title: &str,
337+
request: &FeedbackRequest,
225338
) -> Result<FeedbackResponse> {
226339
let deadline = tokio::time::Instant::now() + timeout;
227340
let mut offset: Option<i64> = None;
228341

229342
loop {
230343
if tokio::time::Instant::now() >= deadline {
231344
info!("Timeout reached, no response received");
232-
return Ok(FeedbackResponse::timeout(title));
345+
self.edit_message_reply_markup(self.config.chat_id, sent_message_id)
346+
.await
347+
.ok();
348+
self.send_notice(self.messages.timeout_notice).await.ok();
349+
return Ok(FeedbackResponse::timeout(&request.title));
233350
}
234351

235352
let remaining = deadline - tokio::time::Instant::now();
@@ -300,13 +417,31 @@ impl TelegramProvider {
300417
"Response received"
301418
);
302419

420+
// For reject: prompt for optional feedback
421+
let feedback = if decision == Decision::Rejected {
422+
let fb = self
423+
.wait_for_reject_feedback(
424+
sent_message_id,
425+
request.reject_feedback_timeout_secs,
426+
offset,
427+
)
428+
.await
429+
.unwrap_or(None);
430+
if fb.is_some() {
431+
info!(feedback = ?fb, "Reject feedback received");
432+
}
433+
fb
434+
} else {
435+
None
436+
};
437+
303438
return Ok(FeedbackResponse {
304439
decision,
305440
user: cb.from.display_name(),
306441
user_id: cb.from.id,
307-
feedback: None,
442+
feedback,
308443
timestamp: Utc::now(),
309-
request_title: title.to_string(),
444+
request_title: request.title.clone(),
310445
});
311446
}
312447

@@ -350,7 +485,7 @@ impl TelegramProvider {
350485
user_id,
351486
feedback: feedback_text,
352487
timestamp: Utc::now(),
353-
request_title: title.to_string(),
488+
request_title: request.title.clone(),
354489
});
355490
}
356491
}
@@ -367,7 +502,7 @@ impl Provider for TelegramProvider {
367502
async fn send_and_wait(&self, request: &FeedbackRequest) -> Result<FeedbackResponse> {
368503
let msg_id = self.send_message(request).await?;
369504
let timeout = Duration::from_secs(request.timeout_secs);
370-
self.poll_for_response(msg_id, timeout, &request.title).await
505+
self.poll_for_response(msg_id, timeout, request).await
371506
}
372507
}
373508

src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub struct FeedbackRequest {
66
pub title: String,
77
pub body: String,
88
pub timeout_secs: u64,
9+
pub reject_feedback_timeout_secs: u64,
910
}
1011

1112
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

0 commit comments

Comments
 (0)