Add WeChat image messaging and QR login polish

This commit is contained in:
Coffee
2026-03-26 18:59:26 +08:00
parent 5b2dbd6ea8
commit 5705366efb
22 changed files with 1655 additions and 198 deletions
-1
View File
@@ -295,7 +295,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
} else {
tool_defs
};
// Update context for this iteration
reason_ctx.available_tools = tool_defs;
reason_ctx.system_prompt = Some(if force_text {
+102 -60
View File
@@ -31,7 +31,58 @@ fn requires_preexisting_uuid_thread(channel: &str) -> bool {
matches!(channel, "gateway" | "test")
}
fn validate_inbound_text_for_message(
safety: &crate::safety::SafetyLayer,
content: &str,
attachments: &[crate::channels::IncomingAttachment],
) -> crate::safety::ValidationResult {
if content.trim().is_empty() && !attachments.is_empty() {
crate::safety::ValidationResult::ok()
} else {
safety.validate_input(content)
}
}
impl Agent {
fn reject_unsafe_inbound_user_message(
&self,
message: &IncomingMessage,
content: &str,
) -> Option<SubmissionResult> {
let validation =
validate_inbound_text_for_message(self.safety(), content, &message.attachments);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Some(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Some(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Some(SubmissionResult::error(warning));
}
None
}
/// Hydrate a historical thread from DB into memory if not already present.
///
/// Called before `resolve_thread` so that the session manager finds the
@@ -226,34 +277,11 @@ impl Agent {
}
// Run the same safety checks that the normal path applies
// (validation, policy, secret scan) so that blocked content
// is never stored in pending_messages or serialized.
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
// so blocked content is never stored in pending_messages.
if let Some(rejection) =
self.reject_unsafe_inbound_user_message(message, content)
{
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Queued message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
return Ok(rejection);
}
if !thread.queue_message(content.to_string()) {
@@ -307,39 +335,11 @@ impl Agent {
}
}
// Safety validation for user input
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {}",
details
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
// Scan inbound messages for secrets (API keys, tokens).
// Catching them here prevents the LLM from echoing them back, which
// would trigger the outbound leak detector and create error loops.
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
// Validate inbound content before the turn is created. Attachment-only
// messages are allowed to pass through so multimodal channels can send
// an empty text body alongside real image/document payloads.
if let Some(rejection) = self.reject_unsafe_inbound_user_message(message, content) {
return Ok(rejection);
}
// Handle explicit commands (starting with /) directly
@@ -1880,6 +1880,9 @@ fn rebuild_chat_messages_from_db(
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::{AttachmentKind, IncomingAttachment};
use crate::config::SafetyConfig;
use crate::safety::SafetyLayer;
#[test]
fn test_rebuild_chat_messages_user_assistant_only() {
@@ -2017,6 +2020,45 @@ mod tests {
assert_eq!(result[7].content, "Written");
}
#[test]
fn test_validate_inbound_text_rejects_empty_text_without_attachments() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let result = validate_inbound_text_for_message(&safety, "", &[]);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "input");
assert_eq!(result.errors[0].message, "Input cannot be empty");
}
#[test]
fn test_validate_inbound_text_allows_empty_text_when_attachments_exist() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let attachments = vec![IncomingAttachment {
id: "image-1".to_string(),
kind: AttachmentKind::Image,
mime_type: "image/jpeg".to_string(),
filename: Some("photo.jpg".to_string()),
size_bytes: Some(128),
source_url: Some("https://example.com/photo.jpg".to_string()),
storage_key: None,
extracted_text: None,
data: vec![1, 2, 3],
duration_secs: None,
}];
let result = validate_inbound_text_for_message(&safety, "", &attachments);
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
crate::history::ConversationMessage {
id: uuid::Uuid::new_v4(),