Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions crates/librefang-api/src/routes/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1810,12 +1810,10 @@ pub async fn uninstall_hand(
Err(librefang_hands::HandError::NotFound(id)) => {
ApiErrorResponse::not_found(format!("Hand not found: {id}")).into_json_tuple()
}
Err(librefang_hands::HandError::BuiltinHand(id)) => {
ApiErrorResponse::not_found(format!(
"Hand '{id}' is a built-in and cannot be uninstalled"
))
.into_json_tuple()
}
Err(librefang_hands::HandError::BuiltinHand(id)) => ApiErrorResponse::not_found(format!(
"Hand '{id}' is a built-in and cannot be uninstalled"
))
.into_json_tuple(),
Err(librefang_hands::HandError::AlreadyActive(msg)) => {
ApiErrorResponse::conflict(msg).into_json_tuple()
}
Expand Down
195 changes: 195 additions & 0 deletions crates/librefang-channels/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,35 @@ fn content_to_text(content: &ChannelContent) -> String {
ChannelContent::FileData { filename, .. } => format!("[File: {filename}]"),
ChannelContent::Interactive { text, .. } => text.clone(),
ChannelContent::ButtonCallback { action, .. } => format!("[Button: {action}]"),
ChannelContent::DeleteMessage { message_id } => {
format!("[Delete message: {message_id}]")
}
ChannelContent::Audio {
url,
caption,
duration_seconds,
..
} => match caption {
Some(c) => format!("[Audio ({duration_seconds}s): {url}]\n{c}"),
None => format!("[Audio ({duration_seconds}s): {url}]"),
},
ChannelContent::Animation {
url,
caption,
duration_seconds,
} => match caption {
Some(c) => format!("[Animation ({duration_seconds}s): {url}]\n{c}"),
None => format!("[Animation ({duration_seconds}s): {url}]"),
},
ChannelContent::Sticker { file_id } => format!("[Sticker: {file_id}]"),
ChannelContent::MediaGroup { items } => format!("[Media group: {} items]", items.len()),
ChannelContent::Poll { question, .. } => format!("[Poll: {question}]"),
ChannelContent::PollAnswer {
poll_id,
option_ids,
} => {
format!("[Poll answer: poll={poll_id}, options={option_ids:?}]")
}
}
}

Expand Down Expand Up @@ -1920,6 +1949,57 @@ async fn dispatch_message(
}
}
}
ChannelContent::DeleteMessage { ref message_id } => {
format!("[Delete message: {message_id}]")
}
ChannelContent::Audio {
ref url,
ref caption,
duration_seconds,
..
} => match caption {
Some(c) => format!("[User sent audio ({duration_seconds}s): {url}]\nCaption: {c}"),
None => format!("[User sent audio ({duration_seconds}s): {url}]"),
},
ChannelContent::Animation {
ref url,
ref caption,
duration_seconds,
} => match caption {
Some(c) => {
format!("[User sent animation ({duration_seconds}s): {url}]\nCaption: {c}")
}
None => format!("[User sent animation ({duration_seconds}s): {url}]"),
},
ChannelContent::Sticker { ref file_id } => format!("[User sent sticker: {file_id}]"),
ChannelContent::MediaGroup { ref items } => {
format!("[User sent media group: {} items]", items.len())
}
ChannelContent::Poll { ref question, .. } => format!("[Poll: {question}]"),
ChannelContent::PollAnswer {
ref poll_id,
ref option_ids,
} => {
let question = message
.metadata
.get("poll_question")
.and_then(|v| v.as_str())
.unwrap_or(poll_id);
let options: Vec<String> = message
.metadata
.get("poll_options")
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
.unwrap_or_default();
if options.is_empty() {
format!("[User answered poll {poll_id}: options {option_ids:?}]")
} else {
let selected: Vec<&str> = option_ids
.iter()
.filter_map(|&i| options.get(i as usize).map(|s| s.as_str()))
.collect();
format!("[User answered poll \"{question}\": selected {selected:?}]")
}
}
};

// Check if it's a slash command embedded in text (e.g. "/agents")
Expand Down Expand Up @@ -3706,6 +3786,121 @@ mod tests {
assert_eq!(content_to_text(&cb), "[Button: approve]");
}

#[test]
fn test_content_to_text_audio() {
let content = ChannelContent::Audio {
url: "https://example.com/song.mp3".to_string(),
caption: Some("My song".to_string()),
duration_seconds: 180,
title: Some("Song Title".to_string()),
performer: Some("Artist".to_string()),
};
let text = content_to_text(&content);
assert!(
text.contains("song.mp3") || text.contains("Song Title") || text.contains("Audio"),
"Audio content_to_text should contain meaningful info, got: {text}"
);
}

#[test]
fn test_content_to_text_audio_no_caption() {
let content = ChannelContent::Audio {
url: "https://example.com/track.mp3".to_string(),
caption: None,
duration_seconds: 60,
title: None,
performer: None,
};
let text = content_to_text(&content);
assert!(
!text.is_empty(),
"Audio without caption should still produce text"
);
}

#[test]
fn test_content_to_text_animation() {
let content = ChannelContent::Animation {
url: "https://example.com/funny.gif".to_string(),
caption: Some("LOL".to_string()),
duration_seconds: 5,
};
let text = content_to_text(&content);
assert!(
text.contains("LOL") || text.contains("Animation") || text.contains("funny.gif"),
"Animation content_to_text should contain meaningful info, got: {text}"
);
}

#[test]
fn test_content_to_text_sticker() {
let content = ChannelContent::Sticker {
file_id: "CAACAgIAAxkBAAI".to_string(),
};
let text = content_to_text(&content);
assert!(!text.is_empty(), "Sticker should produce non-empty text");
}

#[test]
fn test_content_to_text_media_group() {
let content = ChannelContent::MediaGroup {
items: vec![
crate::types::MediaGroupItem::Photo {
url: "https://example.com/1.jpg".to_string(),
caption: Some("First".to_string()),
},
crate::types::MediaGroupItem::Video {
url: "https://example.com/2.mp4".to_string(),
caption: None,
duration_seconds: 30,
},
],
};
let text = content_to_text(&content);
assert!(
text.contains("2") || text.contains("album") || text.contains("media"),
"MediaGroup should mention item count or type, got: {text}"
);
}

#[test]
fn test_content_to_text_poll() {
let content = ChannelContent::Poll {
question: "What is 2+2?".to_string(),
options: vec!["3".to_string(), "4".to_string(), "5".to_string()],
is_quiz: true,
correct_option_id: Some(1),
explanation: Some("Basic math".to_string()),
};
let text = content_to_text(&content);
assert!(
text.contains("2+2") || text.contains("Poll") || text.contains("quiz"),
"Poll should contain the question or type, got: {text}"
);
}

#[test]
fn test_content_to_text_poll_answer() {
let content = ChannelContent::PollAnswer {
poll_id: "poll_123".to_string(),
option_ids: vec![0, 2],
};
let text = content_to_text(&content);
assert!(!text.is_empty(), "PollAnswer should produce non-empty text");
}

#[test]
fn test_content_to_text_delete_message() {
let content = ChannelContent::DeleteMessage {
message_id: "42".to_string(),
};
let text = content_to_text(&content);
assert!(
text.contains("42") || text.contains("delete") || text.contains("Delete"),
"DeleteMessage should mention message_id or action, got: {text}"
);
}

mod message_debouncer {
use super::*;
use std::collections::HashMap;
Expand Down
Loading
Loading