feat: show installed skills in UI (#7910)
Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -135,6 +135,7 @@ pub enum ConfigValueResponse {
|
|||||||
pub enum CommandType {
|
pub enum CommandType {
|
||||||
Builtin,
|
Builtin,
|
||||||
Recipe,
|
Recipe,
|
||||||
|
Skill,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -387,14 +388,23 @@ pub async fn get_provider_models(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct SlashCommandsQuery {
|
||||||
|
/// Optional working directory to discover local skills from
|
||||||
|
pub working_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/config/slash_commands",
|
path = "/config/slash_commands",
|
||||||
|
params(SlashCommandsQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
|
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
|
pub async fn get_slash_commands(
|
||||||
|
axum::extract::Query(query): axum::extract::Query<SlashCommandsQuery>,
|
||||||
|
) -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
|
||||||
let mut commands: Vec<_> = slash_commands::list_commands()
|
let mut commands: Vec<_> = slash_commands::list_commands()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|command| SlashCommand {
|
.map(|command| SlashCommand {
|
||||||
@@ -412,6 +422,23 @@ pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorRe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let working_dir = query.working_dir.map(std::path::PathBuf::from);
|
||||||
|
for source in
|
||||||
|
goose::agents::platform_extensions::summon::list_installed_sources(working_dir.as_deref())
|
||||||
|
{
|
||||||
|
if matches!(
|
||||||
|
source.kind,
|
||||||
|
goose::agents::platform_extensions::summon::SourceKind::Skill
|
||||||
|
| goose::agents::platform_extensions::summon::SourceKind::BuiltinSkill
|
||||||
|
) {
|
||||||
|
commands.push(SlashCommand {
|
||||||
|
command: source.name,
|
||||||
|
help: source.description,
|
||||||
|
command_type: CommandType::Skill,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(SlashCommandsResponse { commands }))
|
Ok(Json(SlashCommandsResponse { commands }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ static COMMANDS: &[CommandDef] = &[
|
|||||||
name: "clear",
|
name: "clear",
|
||||||
description: "Clear the conversation history",
|
description: "Clear the conversation history",
|
||||||
},
|
},
|
||||||
|
CommandDef {
|
||||||
|
name: "skills",
|
||||||
|
description: "List installed skills and other available sources",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn list_commands() -> &'static [CommandDef] {
|
pub fn list_commands() -> &'static [CommandDef] {
|
||||||
@@ -72,6 +76,7 @@ impl Agent {
|
|||||||
"prompt" => self.handle_prompt_command(¶ms, session_id).await,
|
"prompt" => self.handle_prompt_command(¶ms, session_id).await,
|
||||||
"compact" => self.handle_compact_command(session_id).await,
|
"compact" => self.handle_compact_command(session_id).await,
|
||||||
"clear" => self.handle_clear_command(session_id).await,
|
"clear" => self.handle_clear_command(session_id).await,
|
||||||
|
"skills" => self.handle_skills_command(session_id).await,
|
||||||
_ => {
|
_ => {
|
||||||
self.handle_recipe_command(command, params_str, session_id)
|
self.handle_recipe_command(command, params_str, session_id)
|
||||||
.await
|
.await
|
||||||
@@ -129,6 +134,49 @@ impl Agent {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
|
||||||
|
use super::platform_extensions::summon::{list_installed_sources, SourceKind};
|
||||||
|
|
||||||
|
let working_dir = self
|
||||||
|
.config
|
||||||
|
.session_manager
|
||||||
|
.get_session(session_id, false)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.working_dir);
|
||||||
|
let sources = list_installed_sources(working_dir.as_deref());
|
||||||
|
let skills: Vec<_> = sources
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
if skills.is_empty() {
|
||||||
|
output.push_str("No skills installed.\n\n");
|
||||||
|
output.push_str("Skills are loaded from SKILL.md files in:\n");
|
||||||
|
output.push_str(" - ~/.claude/skills/\n");
|
||||||
|
output.push_str(" - ~/.config/agents/skills/\n");
|
||||||
|
output.push_str(" - .goose/skills/ (in current directory)\n");
|
||||||
|
output.push_str(" - .claude/skills/ (in current directory)\n");
|
||||||
|
output.push_str(" - .agents/skills/ (in current directory)\n");
|
||||||
|
} else {
|
||||||
|
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
|
||||||
|
for skill in &skills {
|
||||||
|
let kind_label = if skill.kind == SourceKind::BuiltinSkill {
|
||||||
|
" *(builtin)*"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
output.push_str(&format!(
|
||||||
|
"- **{}**{}: {}\n",
|
||||||
|
skill.name, kind_label, skill.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Message::assistant().with_text(output)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_prompts_command(
|
async fn handle_prompts_command(
|
||||||
&self,
|
&self,
|
||||||
params: &[&str],
|
params: &[&str],
|
||||||
|
|||||||
@@ -390,6 +390,16 @@ fn scan_agents_from_dir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all discovered sources (skills, recipes, agents) from the given working directory.
|
||||||
|
/// If no directory is provided, falls back to `std::env::current_dir()`.
|
||||||
|
/// This is useful for listing what's available without needing a full SummonClient instance.
|
||||||
|
pub fn list_installed_sources(working_dir: Option<&Path>) -> Vec<Source> {
|
||||||
|
let dir = working_dir
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
discover_filesystem_sources(&dir)
|
||||||
|
}
|
||||||
|
|
||||||
fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
|
fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
|
||||||
let mut sources: Vec<Source> = Vec::new();
|
let mut sources: Vec<Source> = Vec::new();
|
||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
|
|||||||
const POLL_TIMEOUT_SECS: u64 = 30;
|
const POLL_TIMEOUT_SECS: u64 = 30;
|
||||||
const MAX_MESSAGE_LENGTH: usize = 4096;
|
const MAX_MESSAGE_LENGTH: usize = 4096;
|
||||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
/// Maximum voice file size we'll attempt to download (20 MB, Telegram's bot API limit).
|
||||||
|
const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
pub struct TelegramGateway {
|
pub struct TelegramGateway {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
@@ -28,6 +30,45 @@ struct TelegramMessage {
|
|||||||
from: Option<TelegramUser>,
|
from: Option<TelegramUser>,
|
||||||
chat: TelegramChat,
|
chat: TelegramChat,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
|
voice: Option<TelegramVoice>,
|
||||||
|
audio: Option<TelegramAudio>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramVoice {
|
||||||
|
file_id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
duration: Option<i32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
file_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio files sent as documents (not inline voice notes).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramAudio {
|
||||||
|
file_id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
duration: Option<i32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
file_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata extracted from a Telegram voice note or audio attachment.
|
||||||
|
struct VoiceInfo<'a> {
|
||||||
|
file_id: &'a str,
|
||||||
|
file_size: Option<i64>,
|
||||||
|
duration: Option<i32>,
|
||||||
|
mime_type: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the Telegram `getFile` API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramFile {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
file_id: String,
|
||||||
|
file_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -146,6 +187,137 @@ impl TelegramGateway {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download a file from Telegram by its `file_id`.
|
||||||
|
///
|
||||||
|
/// This is a two-step process:
|
||||||
|
/// 1. Call `getFile` to obtain the server-side `file_path`.
|
||||||
|
/// 2. Fetch the raw bytes from `https://api.telegram.org/file/bot<TOKEN>/<file_path>`.
|
||||||
|
async fn download_file(&self, file_id: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
// Step 1 – resolve file_id → file_path
|
||||||
|
let resp: TelegramResponse<TelegramFile> = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("getFile"))
|
||||||
|
.json(&serde_json::json!({ "file_id": file_id }))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tg_file = resp.result.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Telegram getFile error: {}",
|
||||||
|
resp.description.unwrap_or_default()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let file_path = tg_file
|
||||||
|
.file_path
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Telegram getFile returned no file_path"))?;
|
||||||
|
|
||||||
|
// Step 2 – download raw bytes
|
||||||
|
let download_url = format!(
|
||||||
|
"{}/file/bot{}/{}",
|
||||||
|
TELEGRAM_API_BASE, self.bot_token, file_path
|
||||||
|
);
|
||||||
|
let bytes = self.client.get(&download_url).send().await?.bytes().await?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save voice bytes to a temporary file and return the path.
|
||||||
|
///
|
||||||
|
/// Files are stored under `<tmp>/goose_voice/voice_<uuid>.<ext>` so Goose
|
||||||
|
/// can access them via its shell tools. The extension is derived from the
|
||||||
|
/// MIME type when available, falling back to `.ogg` for voice notes.
|
||||||
|
///
|
||||||
|
/// On Unix the directory is created with mode `0700` and files with `0600`
|
||||||
|
/// so other local users cannot read private voice content.
|
||||||
|
fn save_voice_file(
|
||||||
|
bytes: &[u8],
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
) -> anyhow::Result<std::path::PathBuf> {
|
||||||
|
let dir = std::env::temp_dir().join("goose_voice");
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
// Restrict directory permissions to owner-only on Unix.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = mime_type
|
||||||
|
.and_then(|m| m.rsplit('/').next())
|
||||||
|
.map(|sub| {
|
||||||
|
// Normalise common MIME sub-types to file extensions.
|
||||||
|
match sub {
|
||||||
|
"mpeg" => "mp3",
|
||||||
|
"mp4" | "x-m4a" => "m4a",
|
||||||
|
"ogg" => "ogg",
|
||||||
|
"wav" | "x-wav" => "wav",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or("ogg");
|
||||||
|
|
||||||
|
let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4());
|
||||||
|
let path = dir.join(filename);
|
||||||
|
std::fs::write(&path, bytes)?;
|
||||||
|
|
||||||
|
// Restrict file permissions to owner-only on Unix.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the text prompt that tells Goose about a voice message file.
|
||||||
|
fn voice_prompt(
|
||||||
|
path: &std::path::Path,
|
||||||
|
duration: Option<i32>,
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let duration_hint = duration
|
||||||
|
.map(|d| format!(" (duration: {d}s)"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let format_hint = mime_type
|
||||||
|
.map(|m| format!(" The file format is {m}."))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"The user sent a voice message{duration_hint}. \
|
||||||
|
The audio file is saved at: {}{format_hint}\n\n\
|
||||||
|
Please transcribe this audio file using available command-line tools \
|
||||||
|
(e.g. whisper, ffmpeg, sox, or any STT utility you can find on this system) \
|
||||||
|
and then respond to what the user said. \
|
||||||
|
If no transcription tool is available, let the user know and ask them to type their message instead.",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract metadata from either a voice note or an audio attachment.
|
||||||
|
/// Returns `None` when neither is present.
|
||||||
|
fn voice_info(msg: &TelegramMessage) -> Option<VoiceInfo<'_>> {
|
||||||
|
if let Some(ref v) = msg.voice {
|
||||||
|
return Some(VoiceInfo {
|
||||||
|
file_id: &v.file_id,
|
||||||
|
file_size: v.file_size,
|
||||||
|
duration: v.duration,
|
||||||
|
mime_type: v.mime_type.as_deref(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(ref a) = msg.audio {
|
||||||
|
return Some(VoiceInfo {
|
||||||
|
file_id: &a.file_id,
|
||||||
|
file_size: a.file_size,
|
||||||
|
duration: a.duration,
|
||||||
|
mime_type: a.mime_type.as_deref(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser {
|
fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser {
|
||||||
PlatformUser {
|
PlatformUser {
|
||||||
platform: "telegram".to_string(),
|
platform: "telegram".to_string(),
|
||||||
@@ -177,6 +349,21 @@ impl Gateway for TelegramGateway {
|
|||||||
|
|
||||||
tracing::info!("Telegram gateway starting long-poll loop");
|
tracing::info!("Telegram gateway starting long-poll loop");
|
||||||
|
|
||||||
|
// Spawn a background task that periodically removes stale voice files
|
||||||
|
// (older than 1 hour) so they don't accumulate on disk.
|
||||||
|
let cleanup_cancel = cancel.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cleanup_cancel.cancelled() => break,
|
||||||
|
_ = interval.tick() => {
|
||||||
|
cleanup_voice_files(std::time::Duration::from_secs(3600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel.cancelled() => {
|
_ = cancel.cancelled() => {
|
||||||
@@ -192,9 +379,46 @@ impl Gateway for TelegramGateway {
|
|||||||
let Some(tg_msg) = update.message else {
|
let Some(tg_msg) = update.message else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let text = match tg_msg.text {
|
|
||||||
Some(ref t) => t.clone(),
|
// Determine the text to send to the handler.
|
||||||
None => continue,
|
// Voice/audio messages are downloaded, saved to
|
||||||
|
// disk, and converted into a prompt that asks
|
||||||
|
// Goose to transcribe the file using CLI tools.
|
||||||
|
let text = if let Some(voice) = Self::voice_info(&tg_msg) {
|
||||||
|
// Reject files that exceed the Telegram bot
|
||||||
|
// download limit.
|
||||||
|
if voice.file_size.unwrap_or(0) > MAX_VOICE_FILE_SIZE {
|
||||||
|
tracing::warn!(
|
||||||
|
file_size = voice.file_size,
|
||||||
|
"voice file exceeds size limit, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.download_file(voice.file_id).await {
|
||||||
|
Ok(bytes) => match Self::save_voice_file(&bytes, voice.mime_type) {
|
||||||
|
Ok(path) => Self::voice_prompt(&path, voice.duration, voice.mime_type),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"failed to save voice file"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"failed to download voice file from Telegram"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(ref t) = tg_msg.text {
|
||||||
|
t.clone()
|
||||||
|
} else {
|
||||||
|
// Neither text nor voice — skip.
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = Self::to_platform_user(&tg_msg);
|
let user = Self::to_platform_user(&tg_msg);
|
||||||
@@ -273,6 +497,29 @@ impl Gateway for TelegramGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove voice files from the temp directory that are older than `max_age`.
|
||||||
|
fn cleanup_voice_files(max_age: std::time::Duration) {
|
||||||
|
let dir = std::env::temp_dir().join("goose_voice");
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cutoff = std::time::SystemTime::now() - max_age;
|
||||||
|
let mut removed = 0u32;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let dominated = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.is_some_and(|t| t < cutoff);
|
||||||
|
if dominated && std::fs::remove_file(entry.path()).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::debug!(removed, "cleaned up stale voice files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::string_slice)]
|
#[allow(clippy::string_slice)]
|
||||||
fn split_message(text: &str, max_len: usize) -> Vec<String> {
|
fn split_message(text: &str, max_len: usize) -> Vec<String> {
|
||||||
if text.len() <= max_len {
|
if text.len() <= max_len {
|
||||||
@@ -384,6 +631,173 @@ mod tests {
|
|||||||
assert_eq!(chunks[1].chars().count(), 1);
|
assert_eq!(chunks[1].chars().count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_info_from_voice_message() {
|
||||||
|
let msg = TelegramMessage {
|
||||||
|
message_id: 1,
|
||||||
|
from: None,
|
||||||
|
chat: TelegramChat {
|
||||||
|
id: 123,
|
||||||
|
chat_type: "private".into(),
|
||||||
|
},
|
||||||
|
text: None,
|
||||||
|
voice: Some(TelegramVoice {
|
||||||
|
file_id: "voice_file_123".into(),
|
||||||
|
duration: Some(5),
|
||||||
|
mime_type: Some("audio/ogg".into()),
|
||||||
|
file_size: Some(10000),
|
||||||
|
}),
|
||||||
|
audio: None,
|
||||||
|
};
|
||||||
|
let info = TelegramGateway::voice_info(&msg);
|
||||||
|
assert!(info.is_some());
|
||||||
|
let v = info.unwrap();
|
||||||
|
assert_eq!(v.file_id, "voice_file_123");
|
||||||
|
assert_eq!(v.file_size, Some(10000));
|
||||||
|
assert_eq!(v.duration, Some(5));
|
||||||
|
assert_eq!(v.mime_type, Some("audio/ogg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_info_from_audio_message() {
|
||||||
|
let msg = TelegramMessage {
|
||||||
|
message_id: 1,
|
||||||
|
from: None,
|
||||||
|
chat: TelegramChat {
|
||||||
|
id: 123,
|
||||||
|
chat_type: "private".into(),
|
||||||
|
},
|
||||||
|
text: None,
|
||||||
|
voice: None,
|
||||||
|
audio: Some(TelegramAudio {
|
||||||
|
file_id: "audio_file_456".into(),
|
||||||
|
duration: Some(120),
|
||||||
|
mime_type: Some("audio/mpeg".into()),
|
||||||
|
file_size: Some(500_000),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let info = TelegramGateway::voice_info(&msg);
|
||||||
|
assert!(info.is_some());
|
||||||
|
let v = info.unwrap();
|
||||||
|
assert_eq!(v.file_id, "audio_file_456");
|
||||||
|
assert_eq!(v.duration, Some(120));
|
||||||
|
assert_eq!(v.mime_type, Some("audio/mpeg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_info_none_for_text() {
|
||||||
|
let msg = TelegramMessage {
|
||||||
|
message_id: 1,
|
||||||
|
from: None,
|
||||||
|
chat: TelegramChat {
|
||||||
|
id: 123,
|
||||||
|
chat_type: "private".into(),
|
||||||
|
},
|
||||||
|
text: Some("hello".into()),
|
||||||
|
voice: None,
|
||||||
|
audio: None,
|
||||||
|
};
|
||||||
|
assert!(TelegramGateway::voice_info(&msg).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_prefers_voice_over_audio() {
|
||||||
|
let msg = TelegramMessage {
|
||||||
|
message_id: 1,
|
||||||
|
from: None,
|
||||||
|
chat: TelegramChat {
|
||||||
|
id: 123,
|
||||||
|
chat_type: "private".into(),
|
||||||
|
},
|
||||||
|
text: None,
|
||||||
|
voice: Some(TelegramVoice {
|
||||||
|
file_id: "voice_wins".into(),
|
||||||
|
duration: Some(3),
|
||||||
|
mime_type: None,
|
||||||
|
file_size: None,
|
||||||
|
}),
|
||||||
|
audio: Some(TelegramAudio {
|
||||||
|
file_id: "audio_loses".into(),
|
||||||
|
duration: Some(60),
|
||||||
|
mime_type: None,
|
||||||
|
file_size: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let v = TelegramGateway::voice_info(&msg).unwrap();
|
||||||
|
assert_eq!(v.file_id, "voice_wins");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_prompt_includes_path_and_duration() {
|
||||||
|
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
|
||||||
|
let prompt = TelegramGateway::voice_prompt(&path, Some(10), Some("audio/ogg"));
|
||||||
|
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
|
||||||
|
assert!(prompt.contains("(duration: 10s)"));
|
||||||
|
assert!(prompt.contains("audio/ogg"));
|
||||||
|
assert!(prompt.contains("transcribe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_prompt_without_duration() {
|
||||||
|
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
|
||||||
|
let prompt = TelegramGateway::voice_prompt(&path, None, None);
|
||||||
|
assert!(!prompt.contains("duration"));
|
||||||
|
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_prompt_with_mp3_mime() {
|
||||||
|
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.mp3");
|
||||||
|
let prompt = TelegramGateway::voice_prompt(&path, Some(60), Some("audio/mpeg"));
|
||||||
|
assert!(prompt.contains("audio/mpeg"));
|
||||||
|
assert!(!prompt.contains("OGG"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_voice_file_creates_file_ogg() {
|
||||||
|
let bytes = b"fake ogg data";
|
||||||
|
let path = TelegramGateway::save_voice_file(bytes, Some("audio/ogg")).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(path.to_str().unwrap().ends_with(".ogg"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_voice_file_creates_file_mp3() {
|
||||||
|
let bytes = b"fake mp3 data";
|
||||||
|
let path = TelegramGateway::save_voice_file(bytes, Some("audio/mpeg")).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(path.to_str().unwrap().ends_with(".mp3"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_voice_file_defaults_to_ogg() {
|
||||||
|
let bytes = b"unknown format";
|
||||||
|
let path = TelegramGateway::save_voice_file(bytes, None).unwrap();
|
||||||
|
assert!(path.to_str().unwrap().ends_with(".ogg"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_preserves_recent_files() {
|
||||||
|
let dir = std::env::temp_dir().join("goose_voice");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let recent_file = dir.join("voice_cleanup_recent_test.ogg");
|
||||||
|
std::fs::write(&recent_file, b"recent").unwrap();
|
||||||
|
// With a 1-hour max_age, a just-created file should survive.
|
||||||
|
cleanup_voice_files(std::time::Duration::from_secs(3600));
|
||||||
|
assert!(recent_file.exists());
|
||||||
|
let _ = std::fs::remove_file(&recent_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_handles_missing_dir() {
|
||||||
|
// Should not panic even when the directory doesn't exist.
|
||||||
|
cleanup_voice_files(std::time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn split_preserves_content() {
|
fn split_preserves_content() {
|
||||||
let text = format!(
|
let text = format!(
|
||||||
|
|||||||
+14
-1
@@ -1569,6 +1569,18 @@
|
|||||||
"super::routes::config_management"
|
"super::routes::config_management"
|
||||||
],
|
],
|
||||||
"operationId": "get_slash_commands",
|
"operationId": "get_slash_commands",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "working_dir",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Optional working directory to discover local skills from",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Slash commands retrieved successfully",
|
"description": "Slash commands retrieved successfully",
|
||||||
@@ -4139,7 +4151,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"Builtin",
|
"Builtin",
|
||||||
"Recipe"
|
"Recipe",
|
||||||
|
"Skill"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ConfigKey": {
|
"ConfigKey": {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export type CheckProviderRequest = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandType = 'Builtin' | 'Recipe';
|
export type CommandType = 'Builtin' | 'Recipe' | 'Skill';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration key metadata for provider setup
|
* Configuration key metadata for provider setup
|
||||||
@@ -2837,7 +2837,12 @@ export type SetConfigProviderData = {
|
|||||||
export type GetSlashCommandsData = {
|
export type GetSlashCommandsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
query?: never;
|
query?: {
|
||||||
|
/**
|
||||||
|
* Optional working directory to discover local skills from
|
||||||
|
*/
|
||||||
|
working_dir?: string | null;
|
||||||
|
};
|
||||||
url: '/config/slash_commands';
|
url: '/config/slash_commands';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DisplayItem } from './MentionPopover';
|
import { DisplayItem } from './MentionPopover';
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => {
|
|||||||
return { Icon: Zap, color: '#3b82f6' }; // Blue
|
return { Icon: Zap, color: '#3b82f6' }; // Blue
|
||||||
case 'Recipe':
|
case 'Recipe':
|
||||||
return { Icon: BookOpen, color: '#10b981' }; // Green
|
return { Icon: BookOpen, color: '#10b981' }; // Green
|
||||||
|
case 'Skill':
|
||||||
|
return { Icon: Sparkles, color: '#8b5cf6' }; // Purple
|
||||||
case 'Directory':
|
case 'Directory':
|
||||||
return { Icon: Folder, color: '#f59e0b' }; // Amber
|
return { Icon: Folder, color: '#f59e0b' }; // Amber
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const typeOrder: Record<DisplayItemType, number> = {
|
|||||||
Directory: 0,
|
Directory: 0,
|
||||||
File: 1,
|
File: 1,
|
||||||
Builtin: 2,
|
Builtin: 2,
|
||||||
Recipe: 3,
|
Skill: 3,
|
||||||
|
Recipe: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DisplayItem {
|
export interface DisplayItem {
|
||||||
@@ -441,6 +442,16 @@ const MentionPopover = forwardRef<
|
|||||||
});
|
});
|
||||||
}, [items, query, currentWorkingDir]);
|
}, [items, query, currentWorkingDir]);
|
||||||
|
|
||||||
|
const getSelectionText = (item: DisplayItem): string => {
|
||||||
|
if (item.itemType === 'Skill') {
|
||||||
|
return `Use the ${item.name} skill to `;
|
||||||
|
}
|
||||||
|
if (['Builtin', 'Recipe'].includes(item.itemType)) {
|
||||||
|
return '/' + item.name;
|
||||||
|
}
|
||||||
|
return item.extra;
|
||||||
|
};
|
||||||
|
|
||||||
// Expose methods to parent component
|
// Expose methods to parent component
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -448,7 +459,7 @@ const MentionPopover = forwardRef<
|
|||||||
getDisplayFiles: () => displayItems,
|
getDisplayFiles: () => displayItems,
|
||||||
selectFile: (index: number) => {
|
selectFile: (index: number) => {
|
||||||
if (displayItems[index]) {
|
if (displayItems[index]) {
|
||||||
onSelect(displayItems[index].extra);
|
onSelect(getSelectionText(displayItems[index]));
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -459,7 +470,10 @@ const MentionPopover = forwardRef<
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (isSlashCommand) {
|
if (isSlashCommand) {
|
||||||
const response = await getSlashCommands({ throwOnError: true });
|
const response = await getSlashCommands({
|
||||||
|
query: { working_dir: currentWorkingDir },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
|
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
|
||||||
name: cmd.command,
|
name: cmd.command,
|
||||||
extra: cmd.help,
|
extra: cmd.help,
|
||||||
@@ -475,7 +489,7 @@ const MentionPopover = forwardRef<
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
}, [isOpen, isSlashCommand, scanFilesFromRoot]);
|
}, [isOpen, isSlashCommand, scanFilesFromRoot, currentWorkingDir]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -509,12 +523,7 @@ const MentionPopover = forwardRef<
|
|||||||
const handleItemClick = (index: number) => {
|
const handleItemClick = (index: number) => {
|
||||||
if (index >= 0 && index < displayItems.length) {
|
if (index >= 0 && index < displayItems.length) {
|
||||||
onSelectedIndexChange(index);
|
onSelectedIndexChange(index);
|
||||||
const displayItem = displayItems[index];
|
onSelect(getSelectionText(displayItems[index]));
|
||||||
onSelect(
|
|
||||||
['Builtin', 'Recipe'].includes(displayItem.itemType)
|
|
||||||
? '/' + displayItem.name
|
|
||||||
: displayItem.extra
|
|
||||||
);
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -551,7 +560,7 @@ const MentionPopover = forwardRef<
|
|||||||
>
|
>
|
||||||
{displayItems.map((item, index) => (
|
{displayItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.extra}
|
key={`${item.itemType}-${item.name}`}
|
||||||
onClick={() => handleItemClick(index)}
|
onClick={() => handleItemClick(index)}
|
||||||
data-selected={index === selectedIndex}
|
data-selected={index === selectedIndex}
|
||||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||||
|
|||||||
Reference in New Issue
Block a user