Fix Telegram auto-verify flow and routing (#1273)

* Fix Telegram auto-verify flow and routing

* Fix CI formatting and clippy follow-ups

* Simplify Telegram waiting state update

* Fix notification fallback scopes

* Fix message metadata routing and zh-CN copy
This commit is contained in:
Henry Park
2026-03-16 20:19:43 -07:00
committed by GitHub
parent d0cb5f0ac5
commit 4675e9618c
18 changed files with 1045 additions and 131 deletions
+111 -27
View File
@@ -54,16 +54,65 @@ pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
}
}
#[cfg(test)]
fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option<String> {
metadata
.get("notify_user")
.and_then(|value| value.as_str())
.or_else(|| metadata.get("owner_id").and_then(|value| value.as_str()))
resolve_owner_scope_notification_user(
metadata.get("notify_user").and_then(|value| value.as_str()),
metadata.get("owner_id").and_then(|value| value.as_str()),
)
}
fn trimmed_option(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn resolve_owner_scope_notification_user(
explicit_user: Option<&str>,
owner_fallback: Option<&str>,
) -> Option<String> {
trimmed_option(explicit_user).or_else(|| trimmed_option(owner_fallback))
}
async fn resolve_channel_notification_user(
extension_manager: Option<&Arc<ExtensionManager>>,
channel: Option<&str>,
explicit_user: Option<&str>,
owner_fallback: Option<&str>,
) -> Option<String> {
if let Some(user) = trimmed_option(explicit_user) {
return Some(user);
}
if let Some(channel_name) = trimmed_option(channel)
&& let Some(extension_manager) = extension_manager
&& let Some(target) = extension_manager
.notification_target_for_channel(&channel_name)
.await
{
return Some(target);
}
resolve_owner_scope_notification_user(explicit_user, owner_fallback)
}
async fn resolve_routine_notification_target(
extension_manager: Option<&Arc<ExtensionManager>>,
metadata: &serde_json::Value,
) -> Option<String> {
resolve_channel_notification_user(
extension_manager,
metadata
.get("notify_channel")
.and_then(|value| value.as_str()),
metadata.get("notify_user").and_then(|value| value.as_str()),
metadata.get("owner_id").and_then(|value| value.as_str()),
)
.await
}
fn should_fallback_routine_notification(error: &ChannelError) -> bool {
!matches!(error, ChannelError::MissingRoutingTarget { .. })
}
@@ -395,11 +444,13 @@ impl Agent {
.timezone
.clone()
.or_else(|| Some(self.config.default_timezone.clone()));
if let Some(channel) = &hb_config.notify_channel {
let user = hb_config
.notify_user
.clone()
.unwrap_or_else(|| self.owner_id().to_string());
let heartbeat_notify_user = resolve_owner_scope_notification_user(
hb_config.notify_user.as_deref(),
Some(self.owner_id()),
);
if let Some(channel) = &hb_config.notify_channel
&& let Some(user) = heartbeat_notify_user.as_deref()
{
config = config.with_notify(user, channel);
}
@@ -409,26 +460,32 @@ impl Agent {
// Spawn notification forwarder that routes through channel manager
let notify_channel = hb_config.notify_channel.clone();
let notify_user = hb_config
.notify_user
.clone()
.unwrap_or_else(|| self.owner_id().to_string());
let notify_target = resolve_channel_notification_user(
self.deps.extension_manager.as_ref(),
hb_config.notify_channel.as_deref(),
hb_config.notify_user.as_deref(),
Some(self.owner_id()),
)
.await;
let notify_user = heartbeat_notify_user;
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
let targeted_ok = if let Some(ref channel) = notify_channel
&& let Some(ref user) = notify_target
{
channels
.broadcast(channel, &notify_user, response.clone())
.broadcast(channel, user, response.clone())
.await
.is_ok()
} else {
false
};
if !targeted_ok {
let results = channels.broadcast_all(&notify_user, response).await;
if !targeted_ok && let Some(ref user) = notify_user {
let results = channels.broadcast_all(user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
@@ -496,6 +553,7 @@ impl Agent {
// Spawn notification forwarder (mirrors heartbeat pattern)
let channels = self.channels.clone();
let extension_manager = self.deps.extension_manager.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let notify_channel = response
@@ -503,7 +561,18 @@ impl Agent {
.get("notify_channel")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let Some(user) = resolve_routine_notification_user(&response.metadata)
let fallback_user = resolve_owner_scope_notification_user(
response
.metadata
.get("notify_user")
.and_then(|v| v.as_str()),
response.metadata.get("owner_id").and_then(|v| v.as_str()),
);
let Some(user) = resolve_routine_notification_target(
extension_manager.as_ref(),
&response.metadata,
)
.await
else {
tracing::warn!(
notify_channel = ?notify_channel,
@@ -537,7 +606,7 @@ impl Agent {
false
};
if !targeted_ok {
if !targeted_ok && let Some(user) = fallback_user {
let results = channels.broadcast_all(&user, response).await;
for (ch, result) in results {
if let Err(e) = result {
@@ -624,6 +693,29 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
// Event-triggered routines consume plain user input before it enters
// the normal chat/tool pipeline. This avoids a duplicate turn where
// the main agent responds and the routine also fires on the same
// inbound message.
if !message.is_internal
&& matches!(
SubmissionParser::parse(&message.content),
Submission::UserInput { .. }
)
&& let Some(ref engine) = routine_engine_for_loop
{
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
continue;
}
}
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -696,14 +788,6 @@ impl Agent {
}
}
}
// Check event triggers (cheap in-memory regex, fires async if matched)
if let Some(ref engine) = routine_engine_for_loop {
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!("Fired {} event-triggered routines", fired);
}
}
}
// Cleanup