Compare commits
2 Commits
5d9c716a72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c59673eac | |||
| 1a9102e871 |
@@ -3,6 +3,7 @@ use async_trait::async_trait;
|
|||||||
use reqwest::multipart::{Form, Part};
|
use reqwest::multipart::{Form, Part};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Telegram's maximum message length for text messages
|
/// Telegram's maximum message length for text messages
|
||||||
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
|
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
|
/// 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,
|
bot_token: String,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TelegramChannel {
|
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 {
|
Self {
|
||||||
bot_token,
|
bot_token,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
@@ -196,7 +202,14 @@ impl TelegramChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, method: &str) -> String {
|
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 {
|
fn is_user_allowed(&self, username: &str) -> bool {
|
||||||
@@ -210,6 +223,131 @@ impl TelegramChannel {
|
|||||||
identities.into_iter().any(|id| self.is_user_allowed(id))
|
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> {
|
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
|
||||||
let message = update.get("message")?;
|
let message = update.get("message")?;
|
||||||
|
|
||||||
@@ -241,8 +379,9 @@ impl TelegramChannel {
|
|||||||
|
|
||||||
if !self.is_any_user_allowed(identities.iter().copied()) {
|
if !self.is_any_user_allowed(identities.iter().copied()) {
|
||||||
tracing::warn!(
|
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`.",
|
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
||||||
|
username,
|
||||||
user_id.as_deref().unwrap_or("unknown")
|
user_id.as_deref().unwrap_or("unknown")
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
@@ -260,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Some(ChannelMessage {
|
Some(ChannelMessage {
|
||||||
id: format!("telegram_{chat_id}_{message_id}"),
|
id: format!("telegram_{}_{}", chat_id, message_id),
|
||||||
sender: sender_identity,
|
sender: sender_identity,
|
||||||
reply_target: chat_id,
|
reply_target: chat_id,
|
||||||
content: text.to_string(),
|
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<()> {
|
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||||
let chunks = split_message_for_telegram(message);
|
let chunks = split_message_for_telegram(message);
|
||||||
|
|
||||||
for (index, chunk) in chunks.iter().enumerate() {
|
for (index, chunk) in chunks.iter().enumerate() {
|
||||||
let text = if chunks.len() > 1 {
|
let text = if chunks.len() > 1 {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
format!("{chunk}\n\n(continues...)")
|
format!("{}\n\n(continues...)", chunk)
|
||||||
} else if index == chunks.len() - 1 {
|
} else if index == chunks.len() - 1 {
|
||||||
format!("(continued)\n\n{chunk}")
|
format!("(continued)\n\n{}", chunk)
|
||||||
} else {
|
} else {
|
||||||
format!("(continued)\n\n{chunk}\n\n(continues...)")
|
format!("(continued)\n\n{}\n\n(continues...)", chunk)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chunk.to_string()
|
chunk.to_string()
|
||||||
@@ -372,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
let path = Path::new(target);
|
let path = Path::new(target);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
anyhow::bail!("Telegram attachment path not found: {target}");
|
anyhow::bail!("Telegram attachment path not found: {}", target);
|
||||||
}
|
}
|
||||||
|
|
||||||
match attachment.kind {
|
match attachment.kind {
|
||||||
@@ -452,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +1029,7 @@ impl Channel for TelegramChannel {
|
|||||||
let resp = match self.client.post(&url).json(&body).send().await {
|
let resp = match self.client.post(&url).json(&body).send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Telegram poll error: {e}");
|
tracing::warn!("Telegram poll error: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -836,7 +1038,7 @@ impl Channel for TelegramChannel {
|
|||||||
let data: serde_json::Value = match resp.json().await {
|
let data: serde_json::Value = match resp.json().await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Telegram parse error: {e}");
|
tracing::warn!("Telegram parse error: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -849,10 +1051,8 @@ impl Channel for TelegramChannel {
|
|||||||
offset = uid + 1;
|
offset = uid + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(msg) = self.parse_update_message(update) else {
|
// First, try to parse as text message
|
||||||
continue;
|
if let Some(msg) = self.parse_update_message(update) {
|
||||||
};
|
|
||||||
|
|
||||||
// Send "typing" indicator immediately when we receive a message
|
// Send "typing" indicator immediately when we receive a message
|
||||||
let typing_body = serde_json::json!({
|
let typing_body = serde_json::json!({
|
||||||
"chat_id": &msg.reply_target,
|
"chat_id": &msg.reply_target,
|
||||||
@@ -863,11 +1063,70 @@ impl Channel for TelegramChannel {
|
|||||||
.post(self.api_url("sendChatAction"))
|
.post(self.api_url("sendChatAction"))
|
||||||
.json(&typing_body)
|
.json(&typing_body)
|
||||||
.send()
|
.send()
|
||||||
.await; // Ignore errors for typing indicator
|
.await;
|
||||||
|
|
||||||
if tx.send(msg).await.is_err() {
|
if tx.send(msg).await.is_err() {
|
||||||
return Ok(());
|
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(Ok(resp)) => resp.status().is_success(),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::debug!("Telegram health check failed: {e}");
|
tracing::debug!("Telegram health check failed: {}", e);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -901,41 +1160,50 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_channel_name() {
|
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");
|
assert_eq!(ch.name(), "telegram");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url() {
|
fn telegram_api_url() {
|
||||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ch.api_url("getMe"),
|
ch.api_url("getMe"),
|
||||||
"https://api.telegram.org/bot123:ABC/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]
|
#[test]
|
||||||
fn telegram_user_allowed_wildcard() {
|
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"));
|
assert!(ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_allowed_specific() {
|
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("alice"));
|
||||||
assert!(!ch.is_user_allowed("eve"));
|
assert!(!ch.is_user_allowed("eve"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_denied_empty() {
|
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"));
|
assert!(!ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_exact_match_not_substring() {
|
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("alice_bot"));
|
||||||
assert!(!ch.is_user_allowed("alic"));
|
assert!(!ch.is_user_allowed("alic"));
|
||||||
assert!(!ch.is_user_allowed("malice"));
|
assert!(!ch.is_user_allowed("malice"));
|
||||||
@@ -943,13 +1211,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_empty_string_denied() {
|
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(""));
|
assert!(!ch.is_user_allowed(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_case_sensitive() {
|
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"));
|
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]
|
#[test]
|
||||||
fn telegram_wildcard_with_specific_users() {
|
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("alice"));
|
||||||
assert!(ch.is_user_allowed("bob"));
|
assert!(ch.is_user_allowed("bob"));
|
||||||
assert!(ch.is_user_allowed("anyone"));
|
assert!(ch.is_user_allowed("anyone"));
|
||||||
@@ -965,13 +1233,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_allowed_by_numeric_id_identity() {
|
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"]));
|
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_denied_when_none_of_identities_match() {
|
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"]));
|
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,7 +1293,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_update_message_uses_chat_id_as_reply_target() {
|
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!({
|
let update = serde_json::json!({
|
||||||
"update_id": 1,
|
"update_id": 1,
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1053,7 +1321,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_update_message_allows_numeric_id_without_username() {
|
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!({
|
let update = serde_json::json!({
|
||||||
"update_id": 2,
|
"update_id": 2,
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1076,11 +1344,63 @@ mod tests {
|
|||||||
assert_eq!(msg.reply_target, "12345");
|
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 ──────────────────────────────────
|
// ── File sending API URL tests ──────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_document() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendDocument"),
|
ch.api_url("sendDocument"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendDocument"
|
"https://api.telegram.org/bot123:ABC/sendDocument"
|
||||||
@@ -1089,7 +1409,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_photo() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendPhoto"),
|
ch.api_url("sendPhoto"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
||||||
@@ -1098,7 +1418,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_video() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendVideo"),
|
ch.api_url("sendVideo"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendVideo"
|
"https://api.telegram.org/bot123:ABC/sendVideo"
|
||||||
@@ -1107,7 +1427,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_audio() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendAudio"),
|
ch.api_url("sendAudio"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendAudio"
|
"https://api.telegram.org/bot123:ABC/sendAudio"
|
||||||
@@ -1116,7 +1436,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_voice() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendVoice"),
|
ch.api_url("sendVoice"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendVoice"
|
"https://api.telegram.org/bot123:ABC/sendVoice"
|
||||||
@@ -1128,7 +1448,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_builds_correct_form() {
|
async fn telegram_send_document_bytes_builds_correct_form() {
|
||||||
// This test verifies the method doesn't panic and handles bytes correctly
|
// 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();
|
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
|
// 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
|
// Error should be network-related, not a code bug
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
||||||
"Expected network error, got: {err}"
|
"Expected network error, got: {}",
|
||||||
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_bytes_builds_correct_form() {
|
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
|
// Minimal valid PNG header bytes
|
||||||
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
@@ -1162,7 +1483,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_by_url_builds_correct_json() {
|
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
|
let result = ch
|
||||||
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
||||||
@@ -1173,7 +1494,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_by_url_builds_correct_json() {
|
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
|
let result = ch
|
||||||
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
||||||
@@ -1186,7 +1507,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/file.txt");
|
||||||
|
|
||||||
let result = ch.send_document("123456", path, None).await;
|
let result = ch.send_document("123456", path, None).await;
|
||||||
@@ -1196,13 +1517,14 @@ mod tests {
|
|||||||
// Should fail with file not found error
|
// Should fail with file not found error
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
|
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]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/photo.jpg");
|
||||||
|
|
||||||
let result = ch.send_photo("123456", path, None).await;
|
let result = ch.send_photo("123456", path, None).await;
|
||||||
@@ -1212,7 +1534,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_video_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/video.mp4");
|
||||||
|
|
||||||
let result = ch.send_video("123456", path, None).await;
|
let result = ch.send_video("123456", path, None).await;
|
||||||
@@ -1222,7 +1544,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_audio_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/audio.mp3");
|
||||||
|
|
||||||
let result = ch.send_audio("123456", path, None).await;
|
let result = ch.send_audio("123456", path, None).await;
|
||||||
@@ -1232,7 +1554,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_voice_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/voice.ogg");
|
||||||
|
|
||||||
let result = ch.send_voice("123456", path, None).await;
|
let result = ch.send_voice("123456", path, None).await;
|
||||||
@@ -1320,7 +1642,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_with_caption() {
|
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();
|
let file_bytes = b"test content".to_vec();
|
||||||
|
|
||||||
// With caption
|
// With caption
|
||||||
@@ -1338,7 +1660,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_bytes_with_caption() {
|
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];
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
|
||||||
|
|
||||||
// With caption
|
// With caption
|
||||||
@@ -1363,7 +1685,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_file() {
|
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 file_bytes: Vec<u8> = vec![];
|
||||||
|
|
||||||
let result = ch
|
let result = ch
|
||||||
@@ -1376,7 +1698,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_filename() {
|
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 file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
||||||
@@ -1387,7 +1709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_chat_id() {
|
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 file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
let result = ch
|
let result = ch
|
||||||
@@ -1405,7 +1727,7 @@ mod tests {
|
|||||||
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
|
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
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");
|
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)
|
// Same chat_id + same message_id = same ID (prevents duplicates after restart)
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
let message_id = 789;
|
||||||
let id1 = format!("telegram_{chat_id}_{message_id}");
|
let id1 = format!("telegram_{}_{}", chat_id, message_id);
|
||||||
let id2 = format!("telegram_{chat_id}_{message_id}");
|
let id2 = format!("telegram_{}_{}", chat_id, message_id);
|
||||||
assert_eq!(id1, id2);
|
assert_eq!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,8 +1745,8 @@ mod tests {
|
|||||||
fn telegram_message_id_different_message_different_id() {
|
fn telegram_message_id_different_message_different_id() {
|
||||||
// Different message IDs produce different IDs
|
// Different message IDs produce different IDs
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let id1 = format!("telegram_{chat_id}_789");
|
let id1 = format!("telegram_{}_789", chat_id);
|
||||||
let id2 = format!("telegram_{chat_id}_790");
|
let id2 = format!("telegram_{}_790", chat_id);
|
||||||
assert_ne!(id1, id2);
|
assert_ne!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,8 +1754,8 @@ mod tests {
|
|||||||
fn telegram_message_id_different_chat_different_id() {
|
fn telegram_message_id_different_chat_different_id() {
|
||||||
// Different chats produce different IDs even with same message_id
|
// Different chats produce different IDs even with same message_id
|
||||||
let message_id = 789;
|
let message_id = 789;
|
||||||
let id1 = format!("telegram_123456_{message_id}");
|
let id1 = format!("telegram_123456_{}", message_id);
|
||||||
let id2 = format!("telegram_789012_{message_id}");
|
let id2 = format!("telegram_789012_{}", message_id);
|
||||||
assert_ne!(id1, id2);
|
assert_ne!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,7 +1764,7 @@ mod tests {
|
|||||||
// Verify format doesn't contain random UUID components
|
// Verify format doesn't contain random UUID components
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
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.contains('-')); // No UUID dashes
|
||||||
assert!(id.starts_with("telegram_"));
|
assert!(id.starts_with("telegram_"));
|
||||||
}
|
}
|
||||||
@@ -1452,7 +1774,7 @@ mod tests {
|
|||||||
// Edge case: message_id can be 0 (fallback/missing case)
|
// Edge case: message_id can be 0 (fallback/missing case)
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 0;
|
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");
|
assert_eq!(id, "telegram_123456_0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ pub struct Config {
|
|||||||
/// Hardware configuration (wizard-driven physical world setup).
|
/// Hardware configuration (wizard-driven physical world setup).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hardware: HardwareConfig,
|
pub hardware: HardwareConfig,
|
||||||
|
|
||||||
|
/// SkillForge auto-discovery configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skillforge: crate::skillforge::SkillForgeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delegate Agents ──────────────────────────────────────────────
|
// ── Delegate Agents ──────────────────────────────────────────────
|
||||||
@@ -1645,6 +1649,7 @@ impl Default for Config {
|
|||||||
peripherals: PeripheralsConfig::default(),
|
peripherals: PeripheralsConfig::default(),
|
||||||
agents: HashMap::new(),
|
agents: HashMap::new(),
|
||||||
hardware: HardwareConfig::default(),
|
hardware: HardwareConfig::default(),
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod rag;
|
|||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod skillforge;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|||||||
10
src/main.rs
10
src/main.rs
@@ -209,6 +209,12 @@ enum Commands {
|
|||||||
skill_command: SkillCommands,
|
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 data from other agent runtimes
|
||||||
Migrate {
|
Migrate {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -563,6 +569,10 @@ async fn main() -> Result<()> {
|
|||||||
skills::handle_command(skill_command, &config.workspace_dir)
|
skills::handle_command(skill_command, &config.workspace_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Skillforge { skillforge_command } => {
|
||||||
|
skillforge::handle_command(skillforge_command, &config).await
|
||||||
|
}
|
||||||
|
|
||||||
Commands::Migrate { migrate_command } => {
|
Commands::Migrate { migrate_command } => {
|
||||||
migration::handle_command(migrate_command, &config).await
|
migration::handle_command(migrate_command, &config).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ pub fn run_wizard() -> Result<Config> {
|
|||||||
peripherals: crate::config::PeripheralsConfig::default(),
|
peripherals: crate::config::PeripheralsConfig::default(),
|
||||||
agents: std::collections::HashMap::new(),
|
agents: std::collections::HashMap::new(),
|
||||||
hardware: hardware_config,
|
hardware: hardware_config,
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -346,6 +347,7 @@ pub fn run_quick_setup(
|
|||||||
peripherals: crate::config::PeripheralsConfig::default(),
|
peripherals: crate::config::PeripheralsConfig::default(),
|
||||||
agents: std::collections::HashMap::new(),
|
agents: std::collections::HashMap::new(),
|
||||||
hardware: crate::config::HardwareConfig::default(),
|
hardware: crate::config::HardwareConfig::default(),
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|||||||
@@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation};
|
|||||||
use self::integrate::Integrator;
|
use self::integrate::Integrator;
|
||||||
use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
|
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
|
// Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
/// Parse the GitHub search/repositories JSON response.
|
||||||
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
|
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
|
||||||
let items = match body.get("items").and_then(|v| v.as_array()) {
|
let items = match body.get("items").and_then(|v| v.as_array()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user