Compare commits

..

2 Commits

Author SHA1 Message Date
8c59673eac feat(cli): add skillforge command for auto-discovery of skills
Some checks failed
CodeQL Analysis / CodeQL Analysis (push) Has been cancelled
PR Hygiene / nudge-stale-prs (push) Has been cancelled
CI / Detect Change Scope (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
Update Contributors NOTICE / Update NOTICE with new contributors (push) Has been cancelled
- Add zeroclaw skillforge run --min-score 0.7 --dry-run
- Add zeroclaw skillforge scout --query "ai agent" --limit 20
- Add zeroclaw skillforge status to show configuration
- Wire SkillForgeConfig into main Config struct
- Add new_with_query method to GitHubScout for custom searches
2026-02-18 03:37:45 +00:00
1a9102e871 feat(telegram): add voice message auto-transcription
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
- Add download_voice_file method to download voice/audio from Telegram
- Add transcribe_voice_file method using faster-whisper
- Add parse_voice_message to detect voice/audio messages
- Modify listen() to process voice messages and include transcription
- Add file_url helper for Telegram file downloads
- Add tests for voice message parsing
2026-02-18 02:19:37 +00:00
7 changed files with 590 additions and 90 deletions

View File

@@ -3,6 +3,7 @@ use async_trait::async_trait;
use reqwest::multipart::{Form, Part};
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
/// Telegram's maximum message length for text messages
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
@@ -179,14 +180,19 @@ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>)
}
/// Telegram channel — long-polls the Bot API for updates
pub struct TelegramChannel { workspace_dir: std::path::PathBuf,
pub struct TelegramChannel {
workspace_dir: std::path::PathBuf,
bot_token: String,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>, workspace_dir: Option<std::path::PathBuf>) -> Self {
pub fn new(
bot_token: String,
allowed_users: Vec<String>,
workspace_dir: Option<std::path::PathBuf>,
) -> Self {
Self {
bot_token,
allowed_users,
@@ -196,7 +202,14 @@ impl TelegramChannel {
}
fn api_url(&self, method: &str) -> String {
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
}
fn file_url(&self, file_path: &str) -> String {
format!(
"https://api.telegram.org/file/bot{}/{}",
self.bot_token, file_path
)
}
fn is_user_allowed(&self, username: &str) -> bool {
@@ -210,6 +223,131 @@ impl TelegramChannel {
identities.into_iter().any(|id| self.is_user_allowed(id))
}
/// Download a voice/audio file from Telegram and return the local path
async fn download_voice_file(&self, file_id: &str) -> anyhow::Result<std::path::PathBuf> {
// Get file path from Telegram
let get_file_url = self.api_url("getFile");
let body = serde_json::json!({
"file_id": file_id
});
let resp = self
.client
.post(&get_file_url)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram getFile failed: {}", err);
}
let data: serde_json::Value = resp.json().await?;
let file_path = data
.get("result")
.and_then(|r| r.get("file_path"))
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("No file_path in Telegram response"))?;
// Download the file
let download_url = self.file_url(file_path);
let file_resp = self.client.get(&download_url).send().await?;
if !file_resp.status().is_success() {
anyhow::bail!("Failed to download voice file: {}", file_resp.status());
}
// Save to workspace with unique name
let file_name = format!("voice_{}.ogg", chrono::Utc::now().format("%Y%m%d_%H%M%S_%f"));
let local_path = self.workspace_dir.join(&file_name);
let bytes = file_resp.bytes().await?;
tokio::fs::write(&local_path, &bytes).await?;
tracing::info!("Downloaded voice file to: {:?}", local_path);
Ok(local_path)
}
/// Transcribe a voice file using faster-whisper
async fn transcribe_voice_file(&self, audio_path: &std::path::Path) -> anyhow::Result<String> {
let script = r#"
import sys
import json
def transcribe(audio_path):
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")
segments, info = model.transcribe(audio_path, beam_size=5)
text_parts = []
for segment in segments:
text_parts.append(segment.text.strip())
result = {
"language": info.language,
"duration": round(info.duration, 2),
"text": " ".join(text_parts)
}
print(json.dumps(result))
if __name__ == "__main__":
transcribe(sys.argv[1])
"#;
let output = Command::new("python3")
.arg("-c")
.arg(script)
.arg(audio_path)
.output()
.await;
match output {
Ok(result) => {
if result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(json) => {
let text = json
.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let language = json
.get("language")
.and_then(|l| l.as_str())
.unwrap_or("unknown");
let duration = json
.get("duration")
.and_then(|d| d.as_f64())
.unwrap_or(0.0);
tracing::info!(
"Transcribed voice message: {:.1}s, language: {}",
duration,
language
);
Ok(text)
}
Err(_) => {
// Fallback: return raw stdout
Ok(stdout.trim().to_string())
}
}
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
tracing::warn!("Voice transcription failed: {}", stderr);
Err(anyhow::anyhow!("Transcription failed: {}", stderr.trim()))
}
}
Err(e) => {
tracing::warn!("Failed to run transcription: {}", e);
Err(anyhow::anyhow!("Failed to run transcription: {}", e))
}
}
}
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
let message = update.get("message")?;
@@ -241,8 +379,9 @@ impl TelegramChannel {
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::warn!(
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
"Telegram: ignoring message from unauthorized user: username={}, user_id={}. \
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
username,
user_id.as_deref().unwrap_or("unknown")
);
return None;
@@ -260,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
.unwrap_or(0);
Some(ChannelMessage {
id: format!("telegram_{chat_id}_{message_id}"),
id: format!("telegram_{}_{}", chat_id, message_id),
sender: sender_identity,
reply_target: chat_id,
content: text.to_string(),
@@ -272,17 +411,80 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
})
}
/// Parse voice/audio messages from Telegram update (returns file_id and metadata)
fn parse_voice_message(
&self,
update: &serde_json::Value,
) -> Option<(String, String, String, i64)> {
let message = update.get("message")?;
// Check for voice message
let file_id = if let Some(voice) = message.get("voice") {
voice.get("file_id").and_then(|v| v.as_str())?
} else if let Some(audio) = message.get("audio") {
// Also support audio messages
audio.get("file_id").and_then(|v| v.as_str())?
} else {
return None;
};
let username = message
.get("from")
.and_then(|from| from.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let user_id = message
.get("from")
.and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let sender_identity = if username == "unknown" {
user_id.clone().unwrap_or_else(|| "unknown".to_string())
} else {
username.clone()
};
let mut identities = vec![username.as_str()];
if let Some(id) = user_id.as_deref() {
identities.push(id);
}
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::warn!(
"Telegram: ignoring voice message from unauthorized user: {}",
sender_identity
);
return None;
}
let chat_id = message
.get("chat")
.and_then(|chat| chat.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())?;
let message_id = message
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Some((file_id.to_string(), sender_identity, chat_id, message_id))
}
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
let chunks = split_message_for_telegram(message);
for (index, chunk) in chunks.iter().enumerate() {
let text = if chunks.len() > 1 {
if index == 0 {
format!("{chunk}\n\n(continues...)")
format!("{}\n\n(continues...)", chunk)
} else if index == chunks.len() - 1 {
format!("(continued)\n\n{chunk}")
format!("(continued)\n\n{}", chunk)
} else {
format!("(continued)\n\n{chunk}\n\n(continues...)")
format!("(continued)\n\n{}\n\n(continues...)", chunk)
}
} else {
chunk.to_string()
@@ -372,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram {method} by URL failed: {err}");
anyhow::bail!("Telegram {} by URL failed: {}", method, err);
}
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
tracing::info!("Telegram {} sent to {}: {}", method, chat_id, url);
Ok(())
}
@@ -408,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
let path = Path::new(target);
if !path.exists() {
anyhow::bail!("Telegram attachment path not found: {target}");
anyhow::bail!("Telegram attachment path not found: {}", target);
}
match attachment.kind {
@@ -452,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
anyhow::bail!("Telegram sendDocument failed: {}", err);
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -486,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
anyhow::bail!("Telegram sendDocument failed: {}", err);
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -525,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
anyhow::bail!("Telegram sendPhoto failed: {}", err);
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -559,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
anyhow::bail!("Telegram sendPhoto failed: {}", err);
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -598,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVideo failed: {err}");
anyhow::bail!("Telegram sendVideo failed: {}", err);
}
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
tracing::info!("Telegram video sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -637,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendAudio failed: {err}");
anyhow::bail!("Telegram sendAudio failed: {}", err);
}
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
tracing::info!("Telegram audio sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -676,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVoice failed: {err}");
anyhow::bail!("Telegram sendVoice failed: {}", err);
}
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
tracing::info!("Telegram voice sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -708,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
anyhow::bail!("Telegram sendDocument by URL failed: {}", err);
}
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
tracing::info!("Telegram document (URL) sent to {}: {}", chat_id, url);
Ok(())
}
@@ -740,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
anyhow::bail!("Telegram sendPhoto by URL failed: {}", err);
}
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
tracing::info!("Telegram photo (URL) sent to {}: {}", chat_id, url);
Ok(())
}
@@ -827,7 +1029,7 @@ impl Channel for TelegramChannel {
let resp = match self.client.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Telegram poll error: {e}");
tracing::warn!("Telegram poll error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
@@ -836,7 +1038,7 @@ impl Channel for TelegramChannel {
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Telegram parse error: {e}");
tracing::warn!("Telegram parse error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
@@ -849,10 +1051,8 @@ impl Channel for TelegramChannel {
offset = uid + 1;
}
let Some(msg) = self.parse_update_message(update) else {
continue;
};
// First, try to parse as text message
if let Some(msg) = self.parse_update_message(update) {
// Send "typing" indicator immediately when we receive a message
let typing_body = serde_json::json!({
"chat_id": &msg.reply_target,
@@ -863,11 +1063,70 @@ impl Channel for TelegramChannel {
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
.await; // Ignore errors for typing indicator
.await;
if tx.send(msg).await.is_err() {
return Ok(());
}
continue;
}
// Then, try to parse as voice/audio message
if let Some((file_id, sender, chat_id, message_id)) =
self.parse_voice_message(update)
{
// Send "typing" indicator
let typing_body = serde_json::json!({
"chat_id": &chat_id,
"action": "typing"
});
let _ = self
.client
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
.await;
// Download and transcribe the voice file
let transcription = match self.download_voice_file(&file_id).await {
Ok(audio_path) => {
let result = self.transcribe_voice_file(&audio_path).await;
// Clean up the downloaded file
let _ = tokio::fs::remove_file(&audio_path).await;
result
}
Err(e) => {
tracing::warn!("Failed to download voice file: {}", e);
Err(e)
}
};
let content = match transcription {
Ok(text) if !text.is_empty() => {
format!("[Voice message transcription]\n{}", text)
}
Ok(_) => "[Voice message - empty transcription]".to_string(),
Err(e) => {
format!("[Voice message - transcription failed: {}]", e)
}
};
let msg = ChannelMessage {
id: format!("telegram_{}_{}", chat_id, message_id),
sender,
reply_target: chat_id,
content,
channel: "telegram".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
}
}
@@ -884,7 +1143,7 @@ impl Channel for TelegramChannel {
{
Ok(Ok(resp)) => resp.status().is_success(),
Ok(Err(e)) => {
tracing::debug!("Telegram health check failed: {e}");
tracing::debug!("Telegram health check failed: {}", e);
false
}
Err(_) => {
@@ -901,41 +1160,50 @@ mod tests {
#[test]
fn telegram_channel_name() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
assert_eq!(ch.name(), "telegram");
}
#[test]
fn telegram_api_url() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("getMe"),
"https://api.telegram.org/bot123:ABC/getMe"
);
}
#[test]
fn telegram_file_url() {
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.file_url("voice/file.ogg"),
"https://api.telegram.org/file/bot123:ABC/voice/file.ogg"
);
}
#[test]
fn telegram_user_allowed_wildcard() {
let ch = TelegramChannel::new("t".into(), vec!["*".into()]);
let ch = TelegramChannel::new("t".into(), vec!["*".into()], None);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn telegram_user_allowed_specific() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], None);
assert!(ch.is_user_allowed("alice"));
assert!(!ch.is_user_allowed("eve"));
}
#[test]
fn telegram_user_denied_empty() {
let ch = TelegramChannel::new("t".into(), vec![]);
let ch = TelegramChannel::new("t".into(), vec![], None);
assert!(!ch.is_user_allowed("anyone"));
}
#[test]
fn telegram_user_exact_match_not_substring() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
assert!(!ch.is_user_allowed("alice_bot"));
assert!(!ch.is_user_allowed("alic"));
assert!(!ch.is_user_allowed("malice"));
@@ -943,13 +1211,13 @@ mod tests {
#[test]
fn telegram_user_empty_string_denied() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
assert!(!ch.is_user_allowed(""));
}
#[test]
fn telegram_user_case_sensitive() {
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], None);
assert!(ch.is_user_allowed("Alice"));
assert!(!ch.is_user_allowed("alice"));
assert!(!ch.is_user_allowed("ALICE"));
@@ -957,7 +1225,7 @@ mod tests {
#[test]
fn telegram_wildcard_with_specific_users() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], None);
assert!(ch.is_user_allowed("alice"));
assert!(ch.is_user_allowed("bob"));
assert!(ch.is_user_allowed("anyone"));
@@ -965,13 +1233,13 @@ mod tests {
#[test]
fn telegram_user_allowed_by_numeric_id_identity() {
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]);
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], None);
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
}
#[test]
fn telegram_user_denied_when_none_of_identities_match() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], None);
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
}
@@ -1025,7 +1293,7 @@ mod tests {
#[test]
fn parse_update_message_uses_chat_id_as_reply_target() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 1,
"message": {
@@ -1053,7 +1321,7 @@ mod tests {
#[test]
fn parse_update_message_allows_numeric_id_without_username() {
let ch = TelegramChannel::new("token".into(), vec!["555".into()]);
let ch = TelegramChannel::new("token".into(), vec!["555".into()], None);
let update = serde_json::json!({
"update_id": 2,
"message": {
@@ -1076,11 +1344,63 @@ mod tests {
assert_eq!(msg.reply_target, "12345");
}
#[test]
fn parse_voice_message_extracts_file_id() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 3,
"message": {
"message_id": 42,
"voice": {
"file_id": "AwADBAADbXXXXXXXX",
"duration": 5
},
"from": {
"id": 555,
"username": "alice"
},
"chat": {
"id": 12345
}
}
});
let result = ch.parse_voice_message(&update);
assert!(result.is_some());
let (file_id, sender, chat_id, message_id) = result.unwrap();
assert_eq!(file_id, "AwADBAADbXXXXXXXX");
assert_eq!(sender, "alice");
assert_eq!(chat_id, "12345");
assert_eq!(message_id, 42);
}
#[test]
fn parse_voice_message_returns_none_for_text_message() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 4,
"message": {
"message_id": 10,
"text": "hello",
"from": {
"id": 555,
"username": "alice"
},
"chat": {
"id": 12345
}
}
});
let result = ch.parse_voice_message(&update);
assert!(result.is_none());
}
// ── File sending API URL tests ──────────────────────────────────
#[test]
fn telegram_api_url_send_document() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendDocument"),
"https://api.telegram.org/bot123:ABC/sendDocument"
@@ -1089,7 +1409,7 @@ mod tests {
#[test]
fn telegram_api_url_send_photo() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendPhoto"),
"https://api.telegram.org/bot123:ABC/sendPhoto"
@@ -1098,7 +1418,7 @@ mod tests {
#[test]
fn telegram_api_url_send_video() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendVideo"),
"https://api.telegram.org/bot123:ABC/sendVideo"
@@ -1107,7 +1427,7 @@ mod tests {
#[test]
fn telegram_api_url_send_audio() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendAudio"),
"https://api.telegram.org/bot123:ABC/sendAudio"
@@ -1116,7 +1436,7 @@ mod tests {
#[test]
fn telegram_api_url_send_voice() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendVoice"),
"https://api.telegram.org/bot123:ABC/sendVoice"
@@ -1128,7 +1448,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_builds_correct_form() {
// This test verifies the method doesn't panic and handles bytes correctly
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"Hello, this is a test file content".to_vec();
// The actual API call will fail (no real server), but we verify the method exists
@@ -1143,13 +1463,14 @@ mod tests {
// Error should be network-related, not a code bug
assert!(
err.contains("error") || err.contains("failed") || err.contains("connect"),
"Expected network error, got: {err}"
"Expected network error, got: {}",
err
);
}
#[tokio::test]
async fn telegram_send_photo_bytes_builds_correct_form() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
// Minimal valid PNG header bytes
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
@@ -1162,7 +1483,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let result = ch
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
@@ -1173,7 +1494,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let result = ch
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
@@ -1186,7 +1507,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/file.txt");
let result = ch.send_document("123456", path, None).await;
@@ -1196,13 +1517,14 @@ mod tests {
// Should fail with file not found error
assert!(
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
"Expected file not found error, got: {err}"
"Expected file not found error, got: {}",
err
);
}
#[tokio::test]
async fn telegram_send_photo_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/photo.jpg");
let result = ch.send_photo("123456", path, None).await;
@@ -1212,7 +1534,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_video_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/video.mp4");
let result = ch.send_video("123456", path, None).await;
@@ -1222,7 +1544,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_audio_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/audio.mp3");
let result = ch.send_audio("123456", path, None).await;
@@ -1232,7 +1554,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_voice_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/voice.ogg");
let result = ch.send_voice("123456", path, None).await;
@@ -1320,7 +1642,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"test content".to_vec();
// With caption
@@ -1338,7 +1660,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
// With caption
@@ -1363,7 +1685,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes: Vec<u8> = vec![];
let result = ch
@@ -1376,7 +1698,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_filename() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"content".to_vec();
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
@@ -1387,7 +1709,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_chat_id() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"content".to_vec();
let result = ch
@@ -1405,7 +1727,7 @@ mod tests {
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
let chat_id = "123456";
let message_id = 789;
let expected_id = format!("telegram_{chat_id}_{message_id}");
let expected_id = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(expected_id, "telegram_123456_789");
}
@@ -1414,8 +1736,8 @@ mod tests {
// Same chat_id + same message_id = same ID (prevents duplicates after restart)
let chat_id = "123456";
let message_id = 789;
let id1 = format!("telegram_{chat_id}_{message_id}");
let id2 = format!("telegram_{chat_id}_{message_id}");
let id1 = format!("telegram_{}_{}", chat_id, message_id);
let id2 = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(id1, id2);
}
@@ -1423,8 +1745,8 @@ mod tests {
fn telegram_message_id_different_message_different_id() {
// Different message IDs produce different IDs
let chat_id = "123456";
let id1 = format!("telegram_{chat_id}_789");
let id2 = format!("telegram_{chat_id}_790");
let id1 = format!("telegram_{}_789", chat_id);
let id2 = format!("telegram_{}_790", chat_id);
assert_ne!(id1, id2);
}
@@ -1432,8 +1754,8 @@ mod tests {
fn telegram_message_id_different_chat_different_id() {
// Different chats produce different IDs even with same message_id
let message_id = 789;
let id1 = format!("telegram_123456_{message_id}");
let id2 = format!("telegram_789012_{message_id}");
let id1 = format!("telegram_123456_{}", message_id);
let id2 = format!("telegram_789012_{}", message_id);
assert_ne!(id1, id2);
}
@@ -1442,7 +1764,7 @@ mod tests {
// Verify format doesn't contain random UUID components
let chat_id = "123456";
let message_id = 789;
let id = format!("telegram_{chat_id}_{message_id}");
let id = format!("telegram_{}_{}", chat_id, message_id);
assert!(!id.contains('-')); // No UUID dashes
assert!(id.starts_with("telegram_"));
}
@@ -1452,7 +1774,7 @@ mod tests {
// Edge case: message_id can be 0 (fallback/missing case)
let chat_id = "123456";
let message_id = 0;
let id = format!("telegram_{chat_id}_{message_id}");
let id = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(id, "telegram_123456_0");
}
}

View File

@@ -92,6 +92,10 @@ pub struct Config {
/// Hardware configuration (wizard-driven physical world setup).
#[serde(default)]
pub hardware: HardwareConfig,
/// SkillForge auto-discovery configuration.
#[serde(default)]
pub skillforge: crate::skillforge::SkillForgeConfig,
}
// ── Delegate Agents ──────────────────────────────────────────────
@@ -1645,6 +1649,7 @@ impl Default for Config {
peripherals: PeripheralsConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
skillforge: crate::skillforge::SkillForgeConfig::default(),
}
}
}

View File

@@ -61,6 +61,7 @@ pub mod rag;
pub mod runtime;
pub mod security;
pub mod service;
pub mod skillforge;
pub mod skills;
pub mod tools;
pub mod tunnel;

View File

@@ -209,6 +209,12 @@ enum Commands {
skill_command: SkillCommands,
},
/// Auto-discover and integrate skills from GitHub/ClawHub
Skillforge {
#[command(subcommand)]
skillforge_command: skillforge::SkillforgeCommands,
},
/// Migrate data from other agent runtimes
Migrate {
#[command(subcommand)]
@@ -563,6 +569,10 @@ async fn main() -> Result<()> {
skills::handle_command(skill_command, &config.workspace_dir)
}
Commands::Skillforge { skillforge_command } => {
skillforge::handle_command(skillforge_command, &config).await
}
Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await
}

View File

@@ -132,6 +132,7 @@ pub fn run_wizard() -> Result<Config> {
peripherals: crate::config::PeripheralsConfig::default(),
agents: std::collections::HashMap::new(),
hardware: hardware_config,
skillforge: crate::skillforge::SkillForgeConfig::default(),
};
println!(
@@ -346,6 +347,7 @@ pub fn run_quick_setup(
peripherals: crate::config::PeripheralsConfig::default(),
agents: std::collections::HashMap::new(),
hardware: crate::config::HardwareConfig::default(),
skillforge: crate::skillforge::SkillForgeConfig::default(),
};
config.save()?;

View File

@@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation};
use self::integrate::Integrator;
use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
use crate::config::Config;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum SkillforgeCommands {
Run {
#[arg(long, default_value = "0.7")]
min_score: f64,
#[arg(long)]
dry_run: bool,
#[arg(long)]
source: Option<String>,
},
Scout {
#[arg(long, default_value = "zeroclaw skill")]
query: String,
#[arg(long, default_value = "20")]
limit: usize,
},
Status,
}
pub async fn handle_command(command: SkillforgeCommands, config: &Config) -> Result<()> {
let _forge_config = config.skillforge.clone();
match command {
SkillforgeCommands::Run { min_score, dry_run, source } => {
run_forge(config, min_score, dry_run, source).await
}
SkillforgeCommands::Scout { query, limit } => {
run_scout(config, query, limit).await
}
SkillforgeCommands::Status => {
show_status(config)
}
}
}
async fn run_forge(config: &Config, min_score: f64, dry_run: bool, source: Option<String>) -> Result<()> {
println!("🔮 SkillForge Pipeline");
println!(" Min Score: {}", min_score);
println!(" Dry Run: {}", dry_run);
println!(" Source: {}", source.as_deref().unwrap_or("all"));
println!();
let mut forge_config = config.skillforge.clone();
forge_config.enabled = true;
forge_config.min_score = min_score;
forge_config.auto_integrate = !dry_run;
if let Some(src) = source {
forge_config.sources = vec![src];
}
let forge = SkillForge::new(forge_config);
let report = forge.forge().await?;
println!("📊 Results:");
println!(" Discovered: {}", report.discovered);
println!(" Evaluated: {}", report.evaluated);
println!(" Auto-integrated: {}", report.auto_integrated);
println!(" Manual review: {}", report.manual_review);
println!(" Skipped: {}", report.skipped);
println!();
if !report.results.is_empty() {
println!("📋 Top Candidates:");
for (i, res) in report.results.iter().take(10).enumerate() {
let status = match res.recommendation {
Recommendation::Auto => "✅ AUTO",
Recommendation::Manual => "⚠️ REVIEW",
Recommendation::Skip => "❌ SKIP",
};
println!(" {}. {} [{:.2}] {}", i + 1, res.candidate.name, res.total_score, status);
println!(" {}", res.candidate.description.chars().take(60).collect::<String>());
}
}
Ok(())
}
async fn run_scout(config: &Config, query: String, limit: usize) -> Result<()> {
println!("🔍 SkillForge Scout");
println!(" Query: {}", query);
println!(" Limit: {}", limit);
println!();
let github_token = config.skillforge.github_token.clone();
let scout = GitHubScout::new_with_query(github_token, query.clone());
let results = scout.discover().await?;
let limited: Vec<_> = results.into_iter().take(limit).collect();
println!("📋 Found {} candidates:", limited.len());
for (i, candidate) in limited.iter().enumerate() {
println!(" {}. {}", i + 1, candidate.name);
println!(" URL: {}", candidate.url);
println!("{} stars", candidate.stars);
let desc: String = candidate.description.chars().take(60).collect();
if !desc.is_empty() {
println!(" {}", desc);
}
}
Ok(())
}
fn show_status(config: &Config) -> Result<()> {
println!("🔮 SkillForge Status");
println!();
println!("Configuration:");
println!(" Enabled: {}", config.skillforge.enabled);
println!(" Auto-integrate: {}", config.skillforge.auto_integrate);
println!(" Sources: {:?}", config.skillforge.sources);
println!(" Scan interval: {}h", config.skillforge.scan_interval_hours);
println!(" Min score: {:.2}", config.skillforge.min_score);
println!(" Output directory: {}", config.skillforge.output_dir);
println!(" GitHub token: {}", if config.skillforge.github_token.is_some() { "configured" } else { "not set" });
let skills_dir = std::path::Path::new(&config.skillforge.output_dir);
if skills_dir.exists() {
let count = std::fs::read_dir(skills_dir)
.map(|entries| entries.filter_map(|e| e.ok()).count())
.unwrap_or(0);
println!();
println!("Integrated skills: {}", count);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

View File

@@ -103,6 +103,36 @@ impl GitHubScout {
}
}
pub fn new_with_query(token: Option<String>, query: String) -> Self {
use std::time::Duration;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
"application/vnd.github+json".parse().expect("valid header"),
);
headers.insert(
reqwest::header::USER_AGENT,
"ZeroClaw-SkillForge/0.1".parse().expect("valid header"),
);
if let Some(ref t) = token {
if let Ok(val) = format!("Bearer {t}").parse() {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
}
let client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.expect("failed to build reqwest client");
Self {
client,
queries: vec![query],
}
}
/// Parse the GitHub search/repositories JSON response.
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
let items = match body.get("items").and_then(|v| v.as_array()) {