diff --git a/crates/lucarne-channel/src/lib.rs b/crates/lucarne-channel/src/lib.rs index a2b74d4..39d9ea7 100644 --- a/crates/lucarne-channel/src/lib.rs +++ b/crates/lucarne-channel/src/lib.rs @@ -23,8 +23,8 @@ use futures::stream::BoxStream; pub use types::{ Attachment, ChannelError, ChannelEvent, ChatId, CommandQuery, CommandQueryResult, FileUpload, - IncomingAttachment, IncomingMessage, MessageId, OutgoingButton, OutgoingMessage, Result, - WorkspaceHandle, WorkspaceId, + IncomingAttachment, IncomingMessage, MessageId, NotificationPolicy, OutgoingButton, + OutgoingMessage, Result, WorkspaceHandle, WorkspaceId, }; /// Describes how rendered text was produced so a channel impl knows @@ -134,6 +134,7 @@ pub trait Channel: Send + Sync { attachment: Attachment, ) -> Result { let mut upload = FileUpload::new(attachment.filename, attachment.bytes); + upload = upload.with_notification(attachment.notification); if let Some(caption) = attachment.caption { upload = upload.with_caption(caption); } @@ -237,6 +238,7 @@ mod attachment_tests { bytes: vec![1, 2, 3], caption: Some("Logo".into()), reply_to: Some(MessageId::new("source")), + notification: NotificationPolicy::Silent, }; let id = channel.send_attachment(&target, attachment).await.unwrap(); @@ -251,5 +253,6 @@ mod attachment_tests { files[0].reply_to.as_ref().map(|id| id.as_str()), Some("source") ); + assert_eq!(files[0].notification, NotificationPolicy::Silent); } } diff --git a/crates/lucarne-channel/src/robust.rs b/crates/lucarne-channel/src/robust.rs index adb58bd..2012b68 100644 --- a/crates/lucarne-channel/src/robust.rs +++ b/crates/lucarne-channel/src/robust.rs @@ -149,7 +149,7 @@ async fn send_with_fallback_inner( format: TextFormat::Plain, buttons: msg.buttons.clone(), reply_to: msg.reply_to.clone(), - silent: msg.silent, + notification: msg.notification, }; match channel.send_all(target, plain).await { Ok(ids) => { @@ -212,7 +212,9 @@ async fn file_fallback( let filename = fallback_filename(stem); info!(filename = %filename, reason = %reason, "uploading fallback file"); let caption = format!("↳ inline send failed ({}); see attached.", reason); - let mut file = FileUpload::new(filename, msg.body.as_bytes().to_vec()).with_caption(caption); + let mut file = FileUpload::new(filename, msg.body.as_bytes().to_vec()) + .with_caption(caption) + .with_notification(msg.notification); if let Some(reply_to) = msg.reply_to.clone() { file = file.reply_to(reply_to); } @@ -256,7 +258,7 @@ fn fallback_filename(stem: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::types::{ChatId, WorkspaceId}; + use crate::types::{ChatId, NotificationPolicy, WorkspaceId}; use async_trait::async_trait; use futures::stream::BoxStream; use std::sync::Mutex; @@ -327,12 +329,14 @@ mod tests { .lock() .unwrap() .push(Err(ChannelError::FormatRejected("bad md".into()))); - let msg = OutgoingMessage::markdown("# hi"); + let msg = OutgoingMessage::markdown("# hi").silent(); let id = send_with_fallback(&ch, &handle(), msg, "reply") .await .unwrap(); assert_eq!(id.as_str(), "file"); - assert!(ch.last_file.lock().unwrap().is_some()); + let file = ch.last_file.lock().unwrap(); + let file = file.as_ref().expect("fallback file"); + assert_eq!(file.notification, NotificationPolicy::Silent); } #[tokio::test] diff --git a/crates/lucarne-channel/src/types.rs b/crates/lucarne-channel/src/types.rs index 09ee05e..8a50003 100644 --- a/crates/lucarne-channel/src/types.rs +++ b/crates/lucarne-channel/src/types.rs @@ -67,6 +67,22 @@ pub struct OutgoingButton { pub data: String, } +/// Per-message delivery notification intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum NotificationPolicy { + /// Let the platform alert the user normally. + #[default] + Notify, + /// Deliver without a push/alert where the platform supports it. + Silent, +} + +impl NotificationPolicy { + pub fn is_silent(self) -> bool { + matches!(self, Self::Silent) + } +} + /// A message the bot wants to send. `format` tells the channel how to /// render `body` (plain vs markdown). `buttons` is optional inline /// keyboard laid out row by row. @@ -78,9 +94,8 @@ pub struct OutgoingMessage { /// If set, the platform should send this message as a reply to /// the given source message. pub reply_to: Option, - /// If true the channel should deliver the message silently (no - /// push notification); used for status pings. - pub silent: bool, + /// Whether this delivery may alert the user. + pub notification: NotificationPolicy, } impl OutgoingMessage { @@ -90,7 +105,7 @@ impl OutgoingMessage { format: super::TextFormat::Plain, buttons: Vec::new(), reply_to: None, - silent: false, + notification: NotificationPolicy::Notify, } } pub fn markdown(body: impl Into) -> Self { @@ -99,7 +114,7 @@ impl OutgoingMessage { format: super::TextFormat::Markdown, buttons: Vec::new(), reply_to: None, - silent: false, + notification: NotificationPolicy::Notify, } } pub fn with_buttons(mut self, rows: Vec>) -> Self { @@ -110,8 +125,12 @@ impl OutgoingMessage { self.reply_to = Some(id); self } + pub fn with_notification(mut self, notification: NotificationPolicy) -> Self { + self.notification = notification; + self + } pub fn silent(mut self) -> Self { - self.silent = true; + self.notification = NotificationPolicy::Silent; self } } @@ -125,6 +144,7 @@ pub struct FileUpload { pub bytes: Vec, pub caption: Option, pub reply_to: Option, + pub notification: NotificationPolicy, } impl FileUpload { @@ -134,6 +154,7 @@ impl FileUpload { bytes, caption: None, reply_to: None, + notification: NotificationPolicy::Notify, } } pub fn with_caption(mut self, caption: impl Into) -> Self { @@ -144,6 +165,14 @@ impl FileUpload { self.reply_to = Some(id); self } + pub fn with_notification(mut self, notification: NotificationPolicy) -> Self { + self.notification = notification; + self + } + pub fn silent(mut self) -> Self { + self.notification = NotificationPolicy::Silent; + self + } } /// An agent-originated attachment ready for channel delivery. @@ -154,6 +183,7 @@ pub struct Attachment { pub bytes: Vec, pub caption: Option, pub reply_to: Option, + pub notification: NotificationPolicy, } impl Attachment { @@ -164,6 +194,7 @@ impl Attachment { bytes, caption: None, reply_to: None, + notification: NotificationPolicy::Notify, } } pub fn with_caption(mut self, caption: impl Into) -> Self { @@ -174,6 +205,14 @@ impl Attachment { self.reply_to = Some(id); self } + pub fn with_notification(mut self, notification: NotificationPolicy) -> Self { + self.notification = notification; + self + } + pub fn silent(mut self) -> Self { + self.notification = NotificationPolicy::Silent; + self + } } /// A user-originated attachment (file, image, voice note, …). Payload diff --git a/crates/lucarne-telegram/src/bot.rs b/crates/lucarne-telegram/src/bot.rs index 6fb9aaf..3a42503 100644 --- a/crates/lucarne-telegram/src/bot.rs +++ b/crates/lucarne-telegram/src/bot.rs @@ -5275,7 +5275,9 @@ fn history_upload_from_bytes( caption: Option, reply_to: MessageId, ) -> FileUpload { - let mut upload = FileUpload::new(format!("history-image.{ext}"), bytes).reply_to(reply_to); + let mut upload = FileUpload::new(format!("history-image.{ext}"), bytes) + .reply_to(reply_to) + .silent(); if let Some(caption) = caption.filter(|caption| !caption.trim().is_empty()) { upload = upload.with_caption(caption); } @@ -7222,7 +7224,10 @@ mod tests { assert_eq!(sent[0].format, lucarne_channel::TextFormat::Markdown); assert!(sent[0].body.contains("Lucarne update available")); assert!(sent[0].reply_to.is_none()); - assert!(!sent[0].silent); + assert_eq!( + sent[0].notification, + lucarne_channel::NotificationPolicy::Notify + ); drop(sent); assert_eq!( @@ -11444,6 +11449,15 @@ done ¬ification_topic.chat, &MessageId::new("sent-3"), ), + None, + "deleted silent preview should not route replies" + ); + assert_eq!( + bot.state.resolve_message_session_binding( + channel.name(), + ¬ification_topic.chat, + &MessageId::new("sent-4"), + ), Some(provider_session_id), "final assistant message must route replies" ); @@ -11453,7 +11467,7 @@ done message_id: MessageId::new("user-2"), chat: notification_topic.chat.clone(), workspace: Some(notification_topic.workspace.clone()), - reply_to: Some(MessageId::new("sent-3")), + reply_to: Some(MessageId::new("sent-4")), user: "alice".into(), text: Some("second follow-up".into()), attachments: Vec::new(), diff --git a/crates/lucarne-telegram/src/channel.rs b/crates/lucarne-telegram/src/channel.rs index b41e7d0..21d0a97 100644 --- a/crates/lucarne-telegram/src/channel.rs +++ b/crates/lucarne-telegram/src/channel.rs @@ -301,7 +301,7 @@ impl Channel for TelegramChannel { if let Some(t) = thread { req = req.message_thread_id(t); } - if msg.silent { + if msg.notification.is_silent() { req = req.disable_notification(true); } if idx == 0 { @@ -491,6 +491,9 @@ impl Channel for TelegramChannel { if let Some(cap) = file.caption { req = req.caption(cap); } + if file.notification.is_silent() { + req = req.disable_notification(true); + } if let Some(reply_to) = file.reply_to.as_ref() { req = req.reply_parameters( ReplyParameters::new(parse_tg_message_id(reply_to)?).allow_sending_without_reply(), @@ -528,6 +531,7 @@ impl Channel for TelegramChannel { bytes, caption, reply_to, + notification, } = attachment; let input = InputFile::memory(bytes).file_name(filename); let reply_parameters = reply_to @@ -544,6 +548,9 @@ impl Channel for TelegramChannel { if let Some(cap) = caption { req = req.caption(cap); } + if notification.is_silent() { + req = req.disable_notification(true); + } if let Some(reply) = reply_parameters { req = req.reply_parameters(reply); } @@ -561,6 +568,9 @@ impl Channel for TelegramChannel { if let Some(cap) = caption { req = req.caption(cap); } + if notification.is_silent() { + req = req.disable_notification(true); + } if let Some(reply) = reply_parameters { req = req.reply_parameters(reply); } @@ -578,6 +588,9 @@ impl Channel for TelegramChannel { if let Some(cap) = caption { req = req.caption(cap); } + if notification.is_silent() { + req = req.disable_notification(true); + } if let Some(reply) = reply_parameters { req = req.reply_parameters(reply); } diff --git a/crates/lucarne-telegram/src/turn.rs b/crates/lucarne-telegram/src/turn.rs index 9eb8afb..32ddf0e 100644 --- a/crates/lucarne-telegram/src/turn.rs +++ b/crates/lucarne-telegram/src/turn.rs @@ -37,7 +37,8 @@ use lucarne_channel::{ robust::retry_attachment_delivery, robust::{send_with_fallback, send_with_fallback_all}, types::{ - Attachment as ChannelAttachment, ChannelError, MessageId, OutgoingMessage, WorkspaceHandle, + Attachment as ChannelAttachment, ChannelError, MessageId, NotificationPolicy, + OutgoingMessage, WorkspaceHandle, }, Channel, }; @@ -346,6 +347,7 @@ enum DrainOutcome { struct PendingAttachmentDelivery { attachment: AgentAttachment, reply_to: Option, + notification: NotificationPolicy, } #[derive(Debug)] @@ -942,9 +944,11 @@ async fn send_attachment_delivery_failure( provider_id: &str, attachment: &AgentAttachment, reply_to: Option, + notification: NotificationPolicy, error: &str, ) -> Option { - let mut msg = OutgoingMessage::plain(render_attachment_delivery_failure(attachment, error)); + let mut msg = OutgoingMessage::plain(render_attachment_delivery_failure(attachment, error)) + .with_notification(notification); if let Some(reply_to) = reply_to { msg = msg.reply_to(reply_to); } @@ -994,8 +998,11 @@ async fn send_attachment_caption_overflow( provider_id: &str, body: String, reply_to: MessageId, + notification: NotificationPolicy, ) -> Vec { - let msg = OutgoingMessage::plain(body).reply_to(reply_to); + let msg = OutgoingMessage::plain(body) + .reply_to(reply_to) + .with_notification(notification); match send_with_fallback_all(channel, target, msg, provider_id).await { Ok(ids) => ids, Err(err) => { @@ -1019,8 +1026,9 @@ async fn deliver_pending_attachments( for pending in attachments { let attachment = pending.attachment; let reply_to = pending.reply_to; + let notification = pending.notification; let (channel_attachment, caption_overflow) = - match channel_attachment_from_event(&attachment, reply_to.clone()) { + match channel_attachment_from_event(&attachment, reply_to.clone(), notification) { Ok(channel_attachment) => channel_attachment, Err(err) => { warn!( @@ -1035,6 +1043,7 @@ async fn deliver_pending_attachments( provider_id, &attachment, reply_to, + notification, &err, ) .await @@ -1055,6 +1064,7 @@ async fn deliver_pending_attachments( provider_id, overflow, id, + notification, ) .await, ); @@ -1077,6 +1087,7 @@ async fn deliver_pending_attachments( provider_id, &attachment, reply_to, + notification, &failure_error, ) .await @@ -1092,6 +1103,7 @@ async fn deliver_pending_attachments( fn channel_attachment_from_event( attachment: &AgentAttachment, reply_to: Option, + notification: NotificationPolicy, ) -> Result<(ChannelAttachment, Option), String> { let bytes = base64::engine::general_purpose::STANDARD .decode(attachment.data_base64.as_bytes()) @@ -1108,7 +1120,8 @@ fn channel_attachment_from_event( attachment.filename.to_string(), attachment.media_type.to_string(), bytes, - ); + ) + .with_notification(notification); let mut caption_overflow = None; if let Some(caption) = attachment.caption.as_ref() { let (caption, overflow) = split_telegram_attachment_caption(caption.as_str()); @@ -1271,6 +1284,7 @@ async fn drain_events( pending_attachments.push(PendingAttachmentDelivery { attachment, reply_to: drafts.reply_to.clone(), + notification: NotificationPolicy::Notify, }); debug!( target: "lucarne_telegram::turn", @@ -2133,6 +2147,7 @@ mod tests { assert_eq!(attachments[0].media_type, "image/png"); assert_eq!(attachments[0].bytes, vec![1, 2, 3]); assert_eq!(attachments[0].caption.as_deref(), Some("caption")); + assert_eq!(attachments[0].notification, NotificationPolicy::Notify); let recorded = recorder.items.lock().unwrap(); assert!(recorded .iter() @@ -2160,6 +2175,7 @@ mod tests { caption: Some(caption.into()), }, reply_to: None, + notification: NotificationPolicy::Notify, }], ) .await; @@ -2180,6 +2196,7 @@ mod tests { assert_eq!(sends.len(), 1); assert_eq!(sends[0].body, "TAIL"); assert_eq!(sends[0].reply_to, Some(MessageId::new("attachment-1"))); + assert_eq!(sends[0].notification, NotificationPolicy::Notify); } #[tokio::test] @@ -2279,7 +2296,7 @@ mod tests { std::mem::take(&mut report.attachments), ) .await; - assert_eq!(ids, vec![MessageId::new("2")]); + assert_eq!(ids, vec![MessageId::new("3")]); assert_eq!( *test_channel.attachment_attempts.lock().unwrap(), lucarne_channel::robust::ATTACHMENT_DELIVERY_MAX_RETRIES + 1 @@ -2797,6 +2814,7 @@ mod tests { ); assert_eq!(msg.buttons.len(), 2); + assert_eq!(msg.notification, NotificationPolicy::Notify); assert_eq!( registry.actions.lock().unwrap().as_slice(), &[ @@ -2832,6 +2850,7 @@ mod tests { ); assert!(msg.body.contains("rm delete-target.txt")); + assert_eq!(msg.notification, NotificationPolicy::Notify); assert!(msg.body.contains("/tmp/repo")); assert!(msg.body.contains("Command:")); assert!(msg.body.contains("CWD:")); @@ -3119,7 +3138,8 @@ mod tests { .lock() .unwrap() .iter() - .any(|message| message.body == "The"), + .any(|message| message.body == "The" + && message.notification == NotificationPolicy::Silent), "reasoning should create process output outside the timer status" ); assert!(drafts.fallback_msg_id.is_some()); @@ -3131,8 +3151,15 @@ mod tests { let finalized = drafts.finalize(&channel, &target, "pi", None).await; assert_eq!(finalized.bytes, "下午 1:17".len()); - let edits = channel.edits.lock().unwrap(); - assert_eq!(edits.last().expect("final edit").1.body, "下午 1:17"); + assert_eq!(finalized.message_ids, vec![MessageId::new("2")]); + for (_, msg) in channel.edits.lock().unwrap().iter() { + assert_eq!(msg.notification, NotificationPolicy::Silent); + } + let sends = channel.sends.lock().unwrap(); + assert_eq!(sends.len(), 2); + assert_eq!(sends[1].body, "下午 1:17"); + assert_eq!(sends[1].notification, NotificationPolicy::Notify); + assert_eq!(channel.deletes.lock().unwrap().as_slice(), &["1"]); } #[tokio::test] @@ -3184,7 +3211,7 @@ mod tests { } #[tokio::test] - async fn final_reply_edits_live_preview_in_place_without_replay() { + async fn final_reply_deletes_silent_preview_and_sends_notify_message() { let channel = TestChannel::default(); let target = test_target(); let mut drafts = DraftStream::new(); @@ -3196,13 +3223,15 @@ mod tests { let finalized = drafts.finalize(&channel, &target, "test", None).await; assert_eq!(finalized.bytes, "这是最终结论".len()); - assert!(channel.deletes.lock().unwrap().is_empty()); - assert_eq!(channel.sends.lock().unwrap().len(), 1); - let edits = channel.edits.lock().unwrap(); - let (id, msg) = edits.last().expect("final reply should edit preview"); - assert_eq!(id, "1"); - assert_eq!(msg.body, "这是最终结论"); - assert_eq!(msg.format, lucarne_channel::TextFormat::Markdown); + assert_eq!(finalized.message_ids, vec![MessageId::new("2")]); + assert_eq!(channel.deletes.lock().unwrap().as_slice(), &["1"]); + assert!(channel.edits.lock().unwrap().is_empty()); + let sends = channel.sends.lock().unwrap(); + assert_eq!(sends.len(), 2); + assert_eq!(sends[0].notification, NotificationPolicy::Silent); + assert_eq!(sends[1].body, "这是最终结论"); + assert_eq!(sends[1].format, lucarne_channel::TextFormat::Markdown); + assert_eq!(sends[1].notification, NotificationPolicy::Notify); } #[tokio::test] @@ -3219,6 +3248,7 @@ mod tests { assert_eq!(sends.len(), 1); assert_eq!(sends[0].body, "Use `skills` and **markdown**"); assert_eq!(sends[0].format, lucarne_channel::TextFormat::Markdown); + assert_eq!(sends[0].notification, NotificationPolicy::Notify); } #[tokio::test] @@ -3240,10 +3270,11 @@ mod tests { let sends = channel.sends.lock().unwrap(); assert_eq!(sends.len(), 1); assert_eq!(sends[0].format, lucarne_channel::TextFormat::Markdown); + assert_eq!(sends[0].notification, NotificationPolicy::Silent); } #[tokio::test] - async fn final_not_modified_edit_does_not_send_duplicate_reply() { + async fn final_reply_does_not_depend_on_preview_edit_success() { let channel = TestChannel::default(); channel.edit_errors.lock().unwrap().push( "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message" @@ -3259,11 +3290,13 @@ mod tests { let finalized = drafts.finalize(&channel, &target, "test", None).await; assert_eq!(finalized.bytes, "Hello".len()); - assert_eq!( - channel.sends.lock().unwrap().len(), - 1, - "the existing preview already contains the final text" - ); + assert_eq!(finalized.message_ids, vec![MessageId::new("2")]); + let sends = channel.sends.lock().unwrap(); + assert_eq!(sends.len(), 2); + assert_eq!(sends[0].notification, NotificationPolicy::Silent); + assert_eq!(sends[1].body, "Hello"); + assert_eq!(sends[1].notification, NotificationPolicy::Notify); + assert_eq!(channel.deletes.lock().unwrap().as_slice(), &["1"]); } #[tokio::test] diff --git a/crates/lucarne-telegram/src/turn/projection.rs b/crates/lucarne-telegram/src/turn/projection.rs index 579d0c5..161e094 100644 --- a/crates/lucarne-telegram/src/turn/projection.rs +++ b/crates/lucarne-telegram/src/turn/projection.rs @@ -168,7 +168,7 @@ impl DraftStream { } async fn edit_fallback(&mut self, channel: &dyn Channel, target: &WorkspaceHandle, text: &str) { - let msg = self.with_reply(OutgoingMessage::markdown(text.to_string())); + let msg = self.with_reply(OutgoingMessage::markdown(text.to_string()).silent()); match self.fallback_msg_id.as_ref() { Some(id) => { if let Err(e) = channel.edit(target, id, msg).await { @@ -186,9 +186,10 @@ impl DraftStream { } } - /// Finalize the turn by editing the live reply bubble into the - /// formal final reply if one exists. Returns the bytes of the - /// committed final reply. + /// Finalize the turn by sending a formal final reply if one exists. + /// Live preview bubbles are silent drafts; after the final send + /// succeeds, the preview is deleted so the final reply is the + /// message that may notify the user. pub(super) async fn finalize( &mut self, channel: &dyn Channel, @@ -196,17 +197,10 @@ impl DraftStream { provider_id: &str, footer: Option<&AgentMessageFooter>, ) -> DraftFinalizeResult { - let thought_preview_id = self - .current - .as_ref() - .filter(|current| current.kind == DraftKind::Thought) - .and_then(|_| self.fallback_msg_id.clone()); + let preview_id = self.fallback_msg_id.take(); self.current.take(); if let Some(msg) = self.final_rich_message.take() { - if let Some(id) = thought_preview_id { - self.delete_thought_preview(channel, target, &id).await; - } let msg = self.with_reply(msg); let bytes = msg.body.len(); let mut result = DraftFinalizeResult { @@ -214,7 +208,12 @@ impl DraftStream { message_ids: Vec::new(), }; match channel.send(target, msg).await { - Ok(id) => result.message_ids.push(id), + Ok(id) => { + result.message_ids.push(id); + if let Some(preview_id) = preview_id.as_ref() { + delete_preview(channel, target, preview_id).await; + } + } Err(e) => { warn!(error = %e, "rich final send failed"); } @@ -223,8 +222,8 @@ impl DraftStream { } let Some(final_text) = self.final_message.take() else { - if let Some(id) = thought_preview_id { - self.delete_thought_preview(channel, target, &id).await; + if let Some(preview_id) = preview_id.as_ref() { + delete_preview(channel, target, preview_id).await; } return DraftFinalizeResult::default(); }; @@ -245,42 +244,21 @@ impl DraftStream { agent_return = %log_event_text(&final_text, EVENT_LOG_TEXT_MAX), "sending final assistant reply" ); - if let Some(id) = self.fallback_msg_id.take() { - result.message_ids = edit_final_reply( - channel, - target, - &id, - provider_id, - final_text, - self.reply_to.as_ref(), - ) - .await; - } else { - let msg = self.with_reply(final_reply_message(final_text)); - match send_with_fallback_all(channel, target, msg, provider_id).await { - Ok(ids) => result.message_ids = ids, - Err(e) => { - warn!(error = %e, "final send failed"); + let msg = self.with_reply(final_reply_message(final_text)); + match send_with_fallback_all(channel, target, msg, provider_id).await { + Ok(ids) => { + result.message_ids = ids; + if let Some(preview_id) = preview_id.as_ref() { + delete_preview(channel, target, preview_id).await; } } + Err(e) => { + warn!(error = %e, "final send failed"); + } } result } - async fn delete_thought_preview( - &mut self, - channel: &dyn Channel, - target: &WorkspaceHandle, - id: &MessageId, - ) { - if self.fallback_msg_id.as_ref() == Some(id) { - self.fallback_msg_id.take(); - } - if let Err(e) = channel.delete(target, id).await { - warn!(error = %e, "thought preview delete failed"); - } - } - fn with_reply(&self, mut msg: OutgoingMessage) -> OutgoingMessage { if msg.reply_to.is_none() { msg.reply_to = self.reply_to.clone(); @@ -289,79 +267,9 @@ impl DraftStream { } } -async fn edit_final_reply( - channel: &dyn Channel, - target: &WorkspaceHandle, - id: &MessageId, - provider_id: &str, - text: String, - reply_to: Option<&MessageId>, -) -> Vec { - if text.chars().count() > channel.message_char_limit() { - let mut ids = vec![id.clone()]; - let _ = channel - .edit( - target, - id, - OutgoingMessage::plain("Answer too long, sent as follow-up."), - ) - .await; - let msg = maybe_reply_to(final_reply_message(text), reply_to); - match send_with_fallback_all(channel, target, msg, provider_id).await { - Ok(mut fallback_ids) => ids.append(&mut fallback_ids), - Err(e) => { - warn!(error = %e, "final fallback send failed after oversized edit"); - } - } - return ids; - } - - let markdown = final_reply_message(text.clone()); - match channel.edit(target, id, markdown).await { - Ok(()) => vec![id.clone()], - Err(e) if is_message_not_modified(&e.to_string()) => { - debug!( - error = %e, - "final edit was a no-op; keeping existing streamed reply" - ); - vec![id.clone()] - } - Err(lucarne_channel::types::ChannelError::FormatRejected(reason)) => { - warn!(reason = %reason, "final markdown edit rejected; sending fallback text"); - let mut ids = vec![id.clone()]; - let msg = maybe_reply_to(final_reply_message(text), reply_to); - match send_with_fallback_all(channel, target, msg, provider_id).await { - Ok(mut fallback_ids) => ids.append(&mut fallback_ids), - Err(e) => { - warn!(error = %e, "final fallback send failed after markdown edit rejection"); - } - } - ids - } - Err(e) if looks_like_parse_error(&e.to_string()) => { - warn!(error = %e, "final markdown edit looked like parse error; sending fallback text"); - let mut ids = vec![id.clone()]; - let msg = maybe_reply_to(final_reply_message(text), reply_to); - match send_with_fallback_all(channel, target, msg, provider_id).await { - Ok(mut fallback_ids) => ids.append(&mut fallback_ids), - Err(e) => { - warn!(error = %e, "final fallback send failed after markdown edit parse error"); - } - } - ids - } - Err(e) => { - warn!(error = %e, "final edit failed"); - let mut ids = vec![id.clone()]; - let msg = maybe_reply_to(final_reply_message(text), reply_to); - match send_with_fallback_all(channel, target, msg, provider_id).await { - Ok(mut fallback_ids) => ids.append(&mut fallback_ids), - Err(e) => { - warn!(error = %e, "final fallback send failed after edit failure"); - } - } - ids - } +async fn delete_preview(channel: &dyn Channel, target: &WorkspaceHandle, id: &MessageId) { + if let Err(e) = channel.delete(target, id).await { + warn!(error = %e, "preview delete failed"); } } @@ -379,18 +287,6 @@ pub(super) fn maybe_reply_to( msg } -fn looks_like_parse_error(s: &str) -> bool { - let s = s.to_ascii_lowercase(); - s.contains("parse") - || s.contains("entities") - || s.contains("markdown") - || s.contains("can't parse") -} - -fn is_message_not_modified(s: &str) -> bool { - s.to_ascii_lowercase().contains("message is not modified") -} - fn append_preview_chunk(buf: &mut String, kind: DraftKind, chunk: &str, streaming: bool) { if kind == DraftKind::Message && streaming { if chunk.is_empty() { diff --git a/docs/decisions/2026-06-01-telegram-outbound-notification-policy.md b/docs/decisions/2026-06-01-telegram-outbound-notification-policy.md new file mode 100644 index 0000000..25ad839 --- /dev/null +++ b/docs/decisions/2026-06-01-telegram-outbound-notification-policy.md @@ -0,0 +1,32 @@ +# Telegram Outbound Notification Policy + +## Context + +Telegram delivery has two separate concerns: sending a bot message into the +topic and deciding whether that send should trigger a user push notification. +Lucarne already used silent text messages for status and control replies, but +file uploads and agent attachments had no channel-level silent/notify intent. + +## Decision + +Add a channel-level `NotificationPolicy` shared by text messages, fallback file +uploads, and agent attachments. Channel callers set semantic intent; Telegram +maps `Silent` to `disable_notification(true)` at the adapter boundary. + +Turn progress, reasoning previews, assistant live previews, command/control +acknowledgements, history replay, and other routine status output are silent. +Final assistant output is sent as a new notify-eligible message; once that send +succeeds, the silent live preview is deleted instead of being edited into the +final answer. Final agent attachments keep the default notify policy. Approval +and clarification prompts also notify because they require user action to +unblock a turn. + +## Consequences + +- Telegram text, document, photo, and video sends can all preserve silent + delivery intent. +- Fallback files inherit the notification policy of the message they replace. +- Long-running turns can show silent progress without suppressing the final + answer notification. +- The common channel layer still exposes only delivery intent; Telegram API + details stay inside `lucarne-telegram`.