mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497b93cebb | ||
|
|
5a70e3e1ef | ||
|
|
bbb2d5c4dd |
+1
-1
@@ -41,7 +41,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -25,5 +25,3 @@ opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -76,6 +76,9 @@ struct TelegramMessage {
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
|
||||
/// Voice message.
|
||||
voice: Option<TelegramVoice>,
|
||||
|
||||
/// Original message if this is a reply.
|
||||
reply_to_message: Option<Box<TelegramMessage>>,
|
||||
|
||||
@@ -139,6 +142,36 @@ struct MessageEntity {
|
||||
user: Option<TelegramUser>,
|
||||
}
|
||||
|
||||
/// Telegram Voice object.
|
||||
/// https://core.telegram.org/bots/api#voice
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramVoice {
|
||||
/// Identifier for this file, which can be used to download the file.
|
||||
file_id: String,
|
||||
|
||||
/// Duration of the audio in seconds.
|
||||
duration: u32,
|
||||
|
||||
/// MIME type of the file.
|
||||
#[serde(default)]
|
||||
mime_type: Option<String>,
|
||||
|
||||
/// File size in bytes.
|
||||
#[serde(default)]
|
||||
file_size: Option<i64>,
|
||||
}
|
||||
|
||||
/// Telegram File object returned by getFile.
|
||||
/// https://core.telegram.org/bots/api#file
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramFile {
|
||||
/// Identifier for this file.
|
||||
file_id: String,
|
||||
|
||||
/// File path for downloading. Use https://api.telegram.org/file/bot<token>/<file_path>.
|
||||
file_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Telegram API response wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TelegramApiResponse<T> {
|
||||
@@ -867,6 +900,87 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice File Download
|
||||
// ============================================================================
|
||||
|
||||
/// Download a voice file from Telegram by file_id.
|
||||
///
|
||||
/// 1. Call getFile to get the file_path.
|
||||
/// 2. Download the file bytes from /file/bot{TOKEN}/{file_path}.
|
||||
fn download_voice_file(file_id: &str) -> Result<Vec<u8>, String> {
|
||||
// Reject file_id containing curly braces to prevent credential placeholder
|
||||
// injection (e.g., a malicious file_id like "{OPENAI_API_KEY}" would be
|
||||
// interpreted by the host-side credential injector).
|
||||
if file_id.contains('{') || file_id.contains('}') {
|
||||
return Err("invalid file_id: contains forbidden characters".to_string());
|
||||
}
|
||||
|
||||
// Step 1: Call getFile to get file_path
|
||||
// Double braces `{{...}}` produce a literal `{TELEGRAM_BOT_TOKEN}` placeholder
|
||||
// in the URL, which the host-side credential injector replaces with the real token.
|
||||
let get_file_url = format!(
|
||||
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getFile?file_id={}",
|
||||
file_id
|
||||
);
|
||||
|
||||
let headers = serde_json::json!({});
|
||||
let result = channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None);
|
||||
|
||||
let response = result.map_err(|e| format!("getFile request failed: {}", e))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("getFile returned {}: {}", response.status, body_str));
|
||||
}
|
||||
|
||||
let api_response: TelegramApiResponse<TelegramFile> =
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse getFile response: {}", e))?;
|
||||
|
||||
if !api_response.ok {
|
||||
return Err(format!(
|
||||
"getFile API error: {}",
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
let file = api_response
|
||||
.result
|
||||
.ok_or_else(|| "getFile returned no result".to_string())?;
|
||||
|
||||
let file_path = file
|
||||
.file_path
|
||||
.ok_or_else(|| "getFile returned no file_path".to_string())?;
|
||||
|
||||
// Sanitize file_path against credential placeholder injection
|
||||
if file_path.contains('{') || file_path.contains('}') {
|
||||
return Err("invalid file_path: contains forbidden characters".to_string());
|
||||
}
|
||||
|
||||
// Step 2: Download the actual file bytes
|
||||
let download_url = format!(
|
||||
"https://api.telegram.org/file/bot{{TELEGRAM_BOT_TOKEN}}/{}",
|
||||
file_path
|
||||
);
|
||||
|
||||
let result =
|
||||
channel_host::http_request("GET", &download_url, &headers.to_string(), None, None);
|
||||
|
||||
let response = result.map_err(|e| format!("File download failed: {}", e))?;
|
||||
|
||||
if response.status != 200 {
|
||||
return Err(format!(
|
||||
"File download returned status {}",
|
||||
response.status
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response.body)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update Handling
|
||||
// ============================================================================
|
||||
@@ -886,6 +1000,9 @@ fn handle_update(update: TelegramUpdate) {
|
||||
|
||||
/// Process a single message.
|
||||
fn handle_message(message: TelegramMessage) {
|
||||
// Check for voice note first (voice-only messages have no text)
|
||||
let is_voice = message.voice.is_some();
|
||||
|
||||
// Use text or caption (for media messages)
|
||||
let content = message
|
||||
.text
|
||||
@@ -893,7 +1010,8 @@ fn handle_message(message: TelegramMessage) {
|
||||
.or_else(|| message.caption.filter(|c| !c.is_empty()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if content.is_empty() {
|
||||
// Allow voice notes through even when content is empty
|
||||
if content.is_empty() && !is_voice {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1038,19 +1156,65 @@ fn handle_message(message: TelegramMessage) {
|
||||
},
|
||||
);
|
||||
|
||||
// Handle voice notes: download and attach audio bytes.
|
||||
// Note: download is synchronous (two HTTP roundtrips to Telegram API).
|
||||
// This blocks the WASM execution for the current polling tick.
|
||||
let mut attachments = Vec::new();
|
||||
let mut voice_download_failed = false;
|
||||
if let Some(ref voice) = message.voice {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Voice note from user {} (duration: {}s, file_id: {})",
|
||||
from.id, voice.duration, voice.file_id
|
||||
),
|
||||
);
|
||||
|
||||
match download_voice_file(&voice.file_id) {
|
||||
Ok(audio_bytes) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Downloaded voice file: {} bytes", audio_bytes.len()),
|
||||
);
|
||||
attachments.push(channel_host::Attachment {
|
||||
kind: channel_host::AttachmentKind::Audio,
|
||||
mime_type: voice
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "audio/ogg".to_string()),
|
||||
data: audio_bytes,
|
||||
filename: Some(format!("voice_{}.ogg", voice.file_id)),
|
||||
duration_secs: Some(voice.duration),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to download voice file: {}", e),
|
||||
);
|
||||
voice_download_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine what to emit to the agent.
|
||||
// - Voice notes: use "[Voice note]" as content (transcription happens host-side)
|
||||
// - `/start` (no args): emit a welcome placeholder so the agent greets the user
|
||||
// - Other bare `/commands` (e.g. /interrupt, /help): pass the raw command through
|
||||
// so Submission::parse() can handle it
|
||||
// - Commands with args (e.g. `/start hello`): cleaned_text already has the args
|
||||
// - Plain text: pass through as-is
|
||||
let trimmed_content = content.trim();
|
||||
let content_to_emit = if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
let content_to_emit = if is_voice && voice_download_failed && content.is_empty() {
|
||||
"[Voice note: download failed]".to_string()
|
||||
} else if is_voice && content.is_empty() {
|
||||
"[Voice note]".to_string()
|
||||
} else if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
"[User started the bot]".to_string()
|
||||
} else if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
|
||||
// Bare control command like /interrupt, /stop, /help — pass through raw
|
||||
trimmed_content.to_string()
|
||||
} else if cleaned_text.is_empty() {
|
||||
} else if cleaned_text.is_empty() && !is_voice {
|
||||
return;
|
||||
} else {
|
||||
cleaned_text
|
||||
@@ -1063,6 +1227,7 @@ fn handle_message(message: TelegramMessage) {
|
||||
content: content_to_emit,
|
||||
thread_id: None, // Telegram doesn't have threads in the same way
|
||||
metadata_json,
|
||||
attachments,
|
||||
});
|
||||
|
||||
channel_host::log(
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" }
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" },
|
||||
{ "host": "api.telegram.org", "path_prefix": "/file/bot" }
|
||||
],
|
||||
"credentials": {
|
||||
"telegram_bot": {
|
||||
|
||||
@@ -9,6 +9,32 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Kind of attachment carried on an incoming message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AttachmentKind {
|
||||
/// Audio content (voice notes, audio files).
|
||||
Audio,
|
||||
/// Image content (photos, screenshots).
|
||||
Image,
|
||||
/// Document content (PDFs, files).
|
||||
Document,
|
||||
}
|
||||
|
||||
/// Binary attachment on a message (e.g., voice note, photo).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Attachment {
|
||||
/// What kind of content this is.
|
||||
pub kind: AttachmentKind,
|
||||
/// MIME type (e.g., "audio/ogg", "image/jpeg").
|
||||
pub mime_type: String,
|
||||
/// Raw bytes of the attachment.
|
||||
pub data: Vec<u8>,
|
||||
/// Optional filename.
|
||||
pub filename: Option<String>,
|
||||
/// Duration in seconds (for audio/video).
|
||||
pub duration_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// A message received from an external channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingMessage {
|
||||
@@ -28,6 +54,8 @@ pub struct IncomingMessage {
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Channel-specific metadata.
|
||||
pub metadata: serde_json::Value,
|
||||
/// Binary attachments (voice notes, images, etc.).
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
impl IncomingMessage {
|
||||
@@ -46,6 +74,7 @@ impl IncomingMessage {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +95,12 @@ impl IncomingMessage {
|
||||
self.user_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set attachments.
|
||||
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
|
||||
self.attachments = attachments;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of incoming messages.
|
||||
|
||||
+4
-1
@@ -35,7 +35,10 @@ pub mod wasm;
|
||||
pub mod web;
|
||||
mod webhook_server;
|
||||
|
||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
pub use channel::{
|
||||
Attachment, AttachmentKind, Channel, IncomingMessage, MessageStream, OutgoingResponse,
|
||||
StatusUpdate,
|
||||
};
|
||||
pub use http::HttpChannel;
|
||||
pub use manager::ChannelManager;
|
||||
pub use repl::ReplChannel;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::channels::channel::Attachment;
|
||||
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::tools::wasm::{HostState, LogLevel};
|
||||
@@ -17,6 +18,9 @@ const MAX_EMITS_PER_EXECUTION: usize = 100;
|
||||
/// Maximum message content size (64 KB).
|
||||
const MAX_MESSAGE_CONTENT_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// Maximum size for a single attachment (10 MB).
|
||||
const MAX_ATTACHMENT_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// A message emitted by a WASM channel to be sent to the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmittedMessage {
|
||||
@@ -37,6 +41,9 @@ pub struct EmittedMessage {
|
||||
|
||||
/// Timestamp when the message was emitted.
|
||||
pub emitted_at_millis: u64,
|
||||
|
||||
/// Binary attachments (voice notes, images, etc.).
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
impl EmittedMessage {
|
||||
@@ -52,6 +59,7 @@ impl EmittedMessage {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +80,12 @@ impl EmittedMessage {
|
||||
self.metadata_json = metadata_json.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set attachments.
|
||||
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
|
||||
self.attachments = attachments;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending workspace write operation.
|
||||
@@ -168,7 +182,7 @@ impl ChannelHostState {
|
||||
///
|
||||
/// Messages are queued and delivered after callback execution completes.
|
||||
/// Rate limiting is enforced per-execution and globally.
|
||||
pub fn emit_message(&mut self, msg: EmittedMessage) -> Result<(), WasmChannelError> {
|
||||
pub fn emit_message(&mut self, mut msg: EmittedMessage) -> Result<(), WasmChannelError> {
|
||||
// Check per-execution limit
|
||||
if !self.emit_enabled {
|
||||
self.emits_dropped += 1;
|
||||
@@ -186,6 +200,22 @@ impl ChannelHostState {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate attachment sizes — drop only oversized attachments, not the whole message
|
||||
msg.attachments.retain(|attachment| {
|
||||
if attachment.data.len() > MAX_ATTACHMENT_SIZE {
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
size = attachment.data.len(),
|
||||
max = MAX_ATTACHMENT_SIZE,
|
||||
mime = %attachment.mime_type,
|
||||
"Attachment too large, dropping attachment (message still delivered)"
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Validate message content size
|
||||
if msg.content.len() > MAX_MESSAGE_CONTENT_SIZE {
|
||||
tracing::warn!(
|
||||
|
||||
+118
-36
@@ -40,6 +40,7 @@ use wasmtime::Store;
|
||||
use wasmtime::component::Linker;
|
||||
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
|
||||
|
||||
use crate::channels::channel::{Attachment, AttachmentKind};
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::channels::wasm::host::{
|
||||
@@ -436,6 +437,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
user_id = %msg.user_id,
|
||||
user_name = ?msg.user_name,
|
||||
content_len = msg.content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"WASM emit_message called"
|
||||
);
|
||||
|
||||
@@ -448,6 +450,31 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
}
|
||||
emitted = emitted.with_metadata(msg.metadata_json);
|
||||
|
||||
// Convert WIT attachments to Rust types
|
||||
if !msg.attachments.is_empty() {
|
||||
let attachments = msg
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let kind = match a.kind {
|
||||
near::agent::channel_host::AttachmentKind::Audio => AttachmentKind::Audio,
|
||||
near::agent::channel_host::AttachmentKind::Image => AttachmentKind::Image,
|
||||
near::agent::channel_host::AttachmentKind::Document => {
|
||||
AttachmentKind::Document
|
||||
}
|
||||
};
|
||||
Attachment {
|
||||
kind,
|
||||
mime_type: a.mime_type,
|
||||
data: a.data,
|
||||
filename: a.filename,
|
||||
duration_secs: a.duration_secs,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
emitted = emitted.with_attachments(attachments);
|
||||
}
|
||||
|
||||
match self.host_state.emit_message(emitted) {
|
||||
Ok(()) => {
|
||||
tracing::info!("Message emitted to host state successfully");
|
||||
@@ -553,6 +580,9 @@ pub struct WasmChannel {
|
||||
/// In-memory workspace store persisting writes across callback invocations.
|
||||
/// Ensures WASM channels can maintain state (e.g., polling offsets) between ticks.
|
||||
workspace_store: Arc<ChannelWorkspaceStore>,
|
||||
|
||||
/// Optional transcription middleware for audio attachments.
|
||||
transcription_middleware: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
@@ -584,9 +614,18 @@ impl WasmChannel {
|
||||
typing_task: RwLock::new(None),
|
||||
pairing_store,
|
||||
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
|
||||
transcription_middleware: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the transcription middleware for audio attachment processing.
|
||||
pub fn set_transcription_middleware(
|
||||
&mut self,
|
||||
middleware: Arc<crate::transcription::TranscriptionMiddleware>,
|
||||
) {
|
||||
self.transcription_middleware = Some(middleware);
|
||||
}
|
||||
|
||||
/// Update the channel config before starting.
|
||||
///
|
||||
/// Merges the provided values into the existing config JSON.
|
||||
@@ -1494,27 +1533,18 @@ impl WasmChannel {
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
|
||||
let msg = Self::convert_emitted_to_incoming(
|
||||
&self.name,
|
||||
emitted,
|
||||
self.transcription_middleware.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
}
|
||||
|
||||
if let Some(thread_id) = emitted.thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
}
|
||||
|
||||
// Send to stream
|
||||
// Send to stream (log post-transcription state intentionally)
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
user_id = %msg.user_id,
|
||||
content_len = msg.content.len(),
|
||||
"Sending emitted message to agent"
|
||||
);
|
||||
|
||||
@@ -1551,6 +1581,7 @@ impl WasmChannel {
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
let transcription_middleware = self.transcription_middleware.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval_timer = tokio::time::interval(interval);
|
||||
@@ -1585,6 +1616,7 @@ impl WasmChannel {
|
||||
emitted_messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
transcription_middleware.as_deref(),
|
||||
).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
@@ -1708,6 +1740,7 @@ impl WasmChannel {
|
||||
messages: Vec<EmittedMessage>,
|
||||
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
||||
transcription_middleware: Option<&crate::transcription::TranscriptionMiddleware>,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
@@ -1739,27 +1772,15 @@ impl WasmChannel {
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
||||
let msg =
|
||||
Self::convert_emitted_to_incoming(channel_name, emitted, transcription_middleware)
|
||||
.await;
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
}
|
||||
|
||||
if let Some(thread_id) = emitted.thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
}
|
||||
|
||||
// Send to stream
|
||||
// Send to stream (log post-transcription state intentionally)
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
user_id = %msg.user_id,
|
||||
content_len = msg.content.len(),
|
||||
"Sending polled message to agent"
|
||||
);
|
||||
|
||||
@@ -1779,6 +1800,65 @@ impl WasmChannel {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert an `EmittedMessage` to an `IncomingMessage`, applying transcription
|
||||
/// middleware with a timeout if available.
|
||||
///
|
||||
/// Shared by both `process_emitted_messages` (HTTP callback path) and
|
||||
/// `dispatch_emitted_messages` (polling path) to avoid duplication.
|
||||
async fn convert_emitted_to_incoming(
|
||||
channel_name: &str,
|
||||
emitted: EmittedMessage,
|
||||
transcription_middleware: Option<&crate::transcription::TranscriptionMiddleware>,
|
||||
) -> IncomingMessage {
|
||||
// Save user_id before partial moves for potential timeout fallback
|
||||
let user_id = emitted.user_id.clone();
|
||||
|
||||
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
}
|
||||
|
||||
if let Some(thread_id) = emitted.thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
}
|
||||
|
||||
// Carry attachments through (moves emitted.attachments into msg)
|
||||
if !emitted.attachments.is_empty() {
|
||||
msg = msg.with_attachments(emitted.attachments);
|
||||
}
|
||||
|
||||
// Apply transcription middleware with a 30-second timeout to prevent
|
||||
// a slow/hanging provider from blocking the message pipeline indefinitely.
|
||||
if let Some(middleware) = transcription_middleware {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(30), middleware.process(msg))
|
||||
.await
|
||||
{
|
||||
Ok(processed) => return processed,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
channel = %channel_name,
|
||||
"Transcription timed out after 30s, delivering message without transcript"
|
||||
);
|
||||
// Timeout: `msg` was moved into the timed-out future, so
|
||||
// reconstruct a fallback message from the saved user_id.
|
||||
return IncomingMessage::new(
|
||||
channel_name,
|
||||
&user_id,
|
||||
"[Voice note: transcription timed out]",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -2332,6 +2412,7 @@ mod tests {
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2370,6 +2451,7 @@ mod tests {
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ mod safety;
|
||||
mod sandbox;
|
||||
mod secrets;
|
||||
mod skills;
|
||||
mod transcription;
|
||||
mod tunnel;
|
||||
mod wasm;
|
||||
|
||||
@@ -45,6 +46,7 @@ pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
pub use self::secrets::SecretsConfig;
|
||||
pub use self::skills::SkillsConfig;
|
||||
pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
|
||||
@@ -74,6 +76,7 @@ pub struct Config {
|
||||
pub sandbox: SandboxModeConfig,
|
||||
pub claude_code: ClaudeCodeConfig,
|
||||
pub skills: SkillsConfig,
|
||||
pub transcription: TranscriptionConfig,
|
||||
pub observability: crate::observability::ObservabilityConfig,
|
||||
}
|
||||
|
||||
@@ -198,6 +201,7 @@ impl Config {
|
||||
sandbox: SandboxModeConfig::resolve()?,
|
||||
claude_code: ClaudeCodeConfig::resolve()?,
|
||||
skills: SkillsConfig::resolve()?,
|
||||
transcription: TranscriptionConfig::resolve(settings)?,
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Transcription provider configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptionConfig {
|
||||
/// Whether transcription is enabled.
|
||||
pub enabled: bool,
|
||||
/// Provider to use: "openai".
|
||||
pub provider: String,
|
||||
/// OpenAI API key (reused from embeddings/LLM config).
|
||||
pub openai_api_key: Option<SecretString>,
|
||||
/// Model to use for transcription.
|
||||
pub model: String,
|
||||
/// Optional language hint (ISO-639-1, e.g., "en").
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TranscriptionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: "openai".to_string(),
|
||||
openai_api_key: None,
|
||||
model: "whisper-1".to_string(),
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptionConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let provider = optional_env("TRANSCRIPTION_PROVIDER")?
|
||||
.unwrap_or_else(|| settings.transcription.provider.clone());
|
||||
|
||||
let model = optional_env("TRANSCRIPTION_MODEL")?
|
||||
.unwrap_or_else(|| settings.transcription.model.clone());
|
||||
|
||||
let language = optional_env("TRANSCRIPTION_LANGUAGE")?
|
||||
.or_else(|| settings.transcription.language.clone());
|
||||
|
||||
let enabled = optional_env("TRANSCRIPTION_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "TRANSCRIPTION_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(settings.transcription.enabled);
|
||||
|
||||
// Only "openai" is currently supported
|
||||
if enabled && provider != "openai" {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "TRANSCRIPTION_PROVIDER".to_string(),
|
||||
message: format!(
|
||||
"unsupported provider '{}', only 'openai' is currently supported",
|
||||
provider
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
openai_api_key,
|
||||
model,
|
||||
language,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
use crate::settings::{Settings, TranscriptionSettings};
|
||||
|
||||
fn clear_transcription_env() {
|
||||
unsafe {
|
||||
std::env::remove_var("TRANSCRIPTION_ENABLED");
|
||||
std::env::remove_var("TRANSCRIPTION_PROVIDER");
|
||||
std::env::remove_var("TRANSCRIPTION_MODEL");
|
||||
std::env::remove_var("TRANSCRIPTION_LANGUAGE");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcription_defaults_from_settings() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_transcription_env();
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = TranscriptionConfig::resolve(&settings).expect("resolve should succeed");
|
||||
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.provider, "openai");
|
||||
assert_eq!(config.model, "whisper-1");
|
||||
assert!(config.language.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcription_env_overrides_settings() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_transcription_env();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("TRANSCRIPTION_ENABLED", "true");
|
||||
std::env::set_var("TRANSCRIPTION_MODEL", "whisper-large-v3");
|
||||
std::env::set_var("TRANSCRIPTION_LANGUAGE", "en");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = TranscriptionConfig::resolve(&settings).expect("resolve should succeed");
|
||||
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.model, "whisper-large-v3");
|
||||
assert_eq!(config.language, Some("en".to_string()));
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("TRANSCRIPTION_ENABLED");
|
||||
std::env::remove_var("TRANSCRIPTION_MODEL");
|
||||
std::env::remove_var("TRANSCRIPTION_LANGUAGE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcription_settings_with_custom_values() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_transcription_env();
|
||||
|
||||
let settings = Settings {
|
||||
transcription: TranscriptionSettings {
|
||||
enabled: true,
|
||||
model: "whisper-large-v3".to_string(),
|
||||
language: Some("fr".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = TranscriptionConfig::resolve(&settings).expect("resolve should succeed");
|
||||
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.model, "whisper-large-v3");
|
||||
assert_eq!(config.language, Some("fr".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcription_rejects_unsupported_provider() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_transcription_env();
|
||||
|
||||
let settings = Settings {
|
||||
transcription: TranscriptionSettings {
|
||||
enabled: true,
|
||||
provider: "deepgram".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = TranscriptionConfig::resolve(&settings);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("unsupported provider"),
|
||||
"Error should mention unsupported provider, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcription_disabled_skips_provider_validation() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_transcription_env();
|
||||
|
||||
// When disabled, any provider string is accepted (never used)
|
||||
let settings = Settings {
|
||||
transcription: TranscriptionSettings {
|
||||
enabled: false,
|
||||
provider: "nonexistent".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = TranscriptionConfig::resolve(&settings).expect("should succeed when disabled");
|
||||
assert!(!config.enabled);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ pub mod setup;
|
||||
pub mod skills;
|
||||
pub mod tools;
|
||||
pub mod tracing_fmt;
|
||||
pub mod transcription;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
pub mod worker;
|
||||
|
||||
+31
-1
@@ -1055,6 +1055,32 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Collect webhook route fragments; a single WebhookServer hosts them all.
|
||||
let mut webhook_routes: Vec<axum::Router> = Vec::new();
|
||||
|
||||
// Build transcription middleware if enabled.
|
||||
let transcription_middleware: Option<Arc<ironclaw::transcription::TranscriptionMiddleware>> =
|
||||
if config.transcription.enabled {
|
||||
if let Some(ref api_key) = config.transcription.openai_api_key {
|
||||
let provider = Arc::new(ironclaw::transcription::openai::OpenAiWhisper::new(
|
||||
api_key.clone(),
|
||||
config.transcription.model.clone(),
|
||||
));
|
||||
let middleware = ironclaw::transcription::TranscriptionMiddleware::new(
|
||||
provider,
|
||||
config.transcription.language.clone(),
|
||||
);
|
||||
tracing::info!(
|
||||
provider = %config.transcription.provider,
|
||||
model = %config.transcription.model,
|
||||
"Audio transcription enabled"
|
||||
);
|
||||
Some(Arc::new(middleware))
|
||||
} else {
|
||||
tracing::warn!("Transcription enabled but OPENAI_API_KEY not set, disabling");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load WASM channels and register their webhook routes.
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
||||
@@ -1099,7 +1125,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
require_secret: webhook_secret.is_some(),
|
||||
}];
|
||||
|
||||
let channel_arc = Arc::new(loaded.channel);
|
||||
let mut channel = loaded.channel;
|
||||
if let Some(ref mw) = transcription_middleware {
|
||||
channel.set_transcription_middleware(Arc::clone(mw));
|
||||
}
|
||||
let channel_arc = Arc::new(channel);
|
||||
|
||||
{
|
||||
let mut config_updates = std::collections::HashMap::new();
|
||||
|
||||
@@ -63,6 +63,11 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub embeddings: EmbeddingsSettings,
|
||||
|
||||
// === Transcription (STT) ===
|
||||
/// Transcription configuration for voice notes.
|
||||
#[serde(default)]
|
||||
pub transcription: TranscriptionSettings,
|
||||
|
||||
// === Step 6: Channels ===
|
||||
/// Tunnel configuration for public webhook endpoints.
|
||||
#[serde(default)]
|
||||
@@ -146,6 +151,45 @@ impl Default for EmbeddingsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcription (STT) configuration for voice notes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscriptionSettings {
|
||||
/// Whether transcription is enabled.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Provider to use: "openai".
|
||||
#[serde(default = "default_transcription_provider")]
|
||||
pub provider: String,
|
||||
|
||||
/// Model to use for transcription.
|
||||
#[serde(default = "default_transcription_model")]
|
||||
pub model: String,
|
||||
|
||||
/// Optional language hint (ISO-639-1, e.g., "en").
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
fn default_transcription_provider() -> String {
|
||||
"openai".to_string()
|
||||
}
|
||||
|
||||
fn default_transcription_model() -> String {
|
||||
"whisper-1".to_string()
|
||||
}
|
||||
|
||||
impl Default for TranscriptionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: default_transcription_provider(),
|
||||
model: default_transcription_model(),
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunnel settings for public webhook endpoints.
|
||||
///
|
||||
/// The tunnel URL is shared across all channels that need webhooks.
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Audio transcription for voice notes and audio attachments.
|
||||
//!
|
||||
//! Provides a `TranscriptionProvider` trait and middleware that automatically
|
||||
//! transcribes audio attachments on incoming messages before they reach the agent.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! [WASM Channel] → emit-message { attachments=[audio bytes] }
|
||||
//! → [Host: EmittedMessage → IncomingMessage]
|
||||
//! → [TranscriptionMiddleware: detect audio, call provider, replace content]
|
||||
//! → [Agent Loop: sees plain text]
|
||||
//! ```
|
||||
|
||||
pub mod openai;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::channels::{AttachmentKind, IncomingMessage};
|
||||
|
||||
/// Supported audio formats for transcription.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioFormat {
|
||||
/// OGG with Opus codec (Telegram voice notes).
|
||||
OggOpus,
|
||||
/// MP3.
|
||||
Mp3,
|
||||
/// WAV.
|
||||
Wav,
|
||||
/// WebM.
|
||||
Webm,
|
||||
/// M4A / AAC.
|
||||
M4a,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
/// File extension for this format.
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OggOpus => "ogg",
|
||||
Self::Mp3 => "mp3",
|
||||
Self::Wav => "wav",
|
||||
Self::Webm => "webm",
|
||||
Self::M4a => "m4a",
|
||||
}
|
||||
}
|
||||
|
||||
/// MIME type for this format.
|
||||
pub fn mime_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OggOpus => "audio/ogg",
|
||||
Self::Mp3 => "audio/mpeg",
|
||||
Self::Wav => "audio/wav",
|
||||
Self::Webm => "audio/webm",
|
||||
Self::M4a => "audio/mp4",
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect format from MIME type string.
|
||||
pub fn from_mime_type(mime: &str) -> Option<Self> {
|
||||
// Normalize: strip parameters (e.g., "audio/ogg; codecs=opus" → "audio/ogg")
|
||||
let base = mime.split(';').next().unwrap_or(mime).trim();
|
||||
match base {
|
||||
"audio/ogg" | "audio/opus" => Some(Self::OggOpus),
|
||||
"audio/mpeg" | "audio/mp3" => Some(Self::Mp3),
|
||||
"audio/wav" | "audio/x-wav" | "audio/wave" => Some(Self::Wav),
|
||||
"audio/webm" => Some(Self::Webm),
|
||||
"audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => Some(Self::M4a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from transcription operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TranscriptionError {
|
||||
/// Unsupported audio format.
|
||||
#[error("unsupported audio format: {mime_type}")]
|
||||
UnsupportedFormat { mime_type: String },
|
||||
|
||||
/// Audio file exceeds the provider's size limit.
|
||||
#[error("audio file too large: {size} bytes (max {max})")]
|
||||
FileTooLarge { size: usize, max: usize },
|
||||
|
||||
/// Provider API returned an error.
|
||||
#[error("transcription API error: {message}")]
|
||||
ApiError { message: String },
|
||||
|
||||
/// Network or HTTP error.
|
||||
#[error("transcription request failed: {0}")]
|
||||
RequestFailed(String),
|
||||
|
||||
/// Provider is not configured.
|
||||
#[error("transcription provider not configured: {reason}")]
|
||||
NotConfigured { reason: String },
|
||||
}
|
||||
|
||||
/// Trait for speech-to-text transcription providers.
|
||||
#[async_trait]
|
||||
pub trait TranscriptionProvider: Send + Sync {
|
||||
/// Provider name (e.g., "openai").
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Model name (e.g., "whisper-1").
|
||||
fn model_name(&self) -> &str;
|
||||
|
||||
/// Maximum file size in bytes.
|
||||
fn max_file_size(&self) -> usize;
|
||||
|
||||
/// Supported audio formats.
|
||||
fn supported_formats(&self) -> &[AudioFormat];
|
||||
|
||||
/// Transcribe audio bytes to text.
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: &[u8],
|
||||
format: AudioFormat,
|
||||
language: Option<&str>,
|
||||
) -> Result<String, TranscriptionError>;
|
||||
}
|
||||
|
||||
/// Middleware that detects audio attachments and transcribes them.
|
||||
pub struct TranscriptionMiddleware {
|
||||
provider: Arc<dyn TranscriptionProvider>,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl TranscriptionMiddleware {
|
||||
/// Create a new transcription middleware.
|
||||
pub fn new(provider: Arc<dyn TranscriptionProvider>, language: Option<String>) -> Self {
|
||||
Self { provider, language }
|
||||
}
|
||||
|
||||
/// Process an incoming message, transcribing any audio attachments.
|
||||
///
|
||||
/// If audio attachments are found and transcription succeeds, the message
|
||||
/// content is replaced with the transcribed text. On failure, a fallback
|
||||
/// message is used.
|
||||
pub async fn process(&self, mut msg: IncomingMessage) -> IncomingMessage {
|
||||
let audio_attachment = msg
|
||||
.attachments
|
||||
.iter()
|
||||
.find(|a| a.kind == AttachmentKind::Audio);
|
||||
|
||||
let Some(attachment) = audio_attachment else {
|
||||
return msg;
|
||||
};
|
||||
|
||||
let format = match AudioFormat::from_mime_type(&attachment.mime_type) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
mime = %attachment.mime_type,
|
||||
"Unsupported audio format for transcription"
|
||||
);
|
||||
if msg.content.is_empty() || msg.content == "[Voice note]" {
|
||||
msg.content = "[Voice note: unsupported audio format]".to_string();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
};
|
||||
|
||||
// Check size limit
|
||||
if attachment.data.len() > self.provider.max_file_size() {
|
||||
tracing::warn!(
|
||||
size = attachment.data.len(),
|
||||
max = self.provider.max_file_size(),
|
||||
"Audio attachment exceeds provider size limit"
|
||||
);
|
||||
if msg.content.is_empty() || msg.content == "[Voice note]" {
|
||||
msg.content = "[Voice note: file too large for transcription]".to_string();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
match self
|
||||
.provider
|
||||
.transcribe(&attachment.data, format, self.language.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
tracing::info!(
|
||||
provider = %self.provider.name(),
|
||||
text_len = text.len(),
|
||||
"Audio transcription successful"
|
||||
);
|
||||
msg.content = text;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
provider = %self.provider.name(),
|
||||
"Audio transcription failed"
|
||||
);
|
||||
if msg.content.is_empty() || msg.content == "[Voice note]" {
|
||||
msg.content = "[Voice note: transcription failed]".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::Attachment;
|
||||
|
||||
#[test]
|
||||
fn audio_format_from_mime_type() {
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/ogg"),
|
||||
Some(AudioFormat::OggOpus)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/ogg; codecs=opus"),
|
||||
Some(AudioFormat::OggOpus)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/mpeg"),
|
||||
Some(AudioFormat::Mp3)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/mp3"),
|
||||
Some(AudioFormat::Mp3)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/wav"),
|
||||
Some(AudioFormat::Wav)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/webm"),
|
||||
Some(AudioFormat::Webm)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/mp4"),
|
||||
Some(AudioFormat::M4a)
|
||||
);
|
||||
assert_eq!(
|
||||
AudioFormat::from_mime_type("audio/m4a"),
|
||||
Some(AudioFormat::M4a)
|
||||
);
|
||||
assert_eq!(AudioFormat::from_mime_type("text/plain"), None);
|
||||
assert_eq!(AudioFormat::from_mime_type(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_extension() {
|
||||
assert_eq!(AudioFormat::OggOpus.extension(), "ogg");
|
||||
assert_eq!(AudioFormat::Mp3.extension(), "mp3");
|
||||
assert_eq!(AudioFormat::Wav.extension(), "wav");
|
||||
assert_eq!(AudioFormat::Webm.extension(), "webm");
|
||||
assert_eq!(AudioFormat::M4a.extension(), "m4a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_mime_type() {
|
||||
assert_eq!(AudioFormat::OggOpus.mime_type(), "audio/ogg");
|
||||
assert_eq!(AudioFormat::Mp3.mime_type(), "audio/mpeg");
|
||||
}
|
||||
|
||||
/// Mock provider for testing middleware.
|
||||
struct MockProvider {
|
||||
result: Result<String, TranscriptionError>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for MockProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn model_name(&self) -> &str {
|
||||
"mock-1"
|
||||
}
|
||||
fn max_file_size(&self) -> usize {
|
||||
25 * 1024 * 1024
|
||||
}
|
||||
fn supported_formats(&self) -> &[AudioFormat] {
|
||||
&[AudioFormat::OggOpus, AudioFormat::Mp3]
|
||||
}
|
||||
async fn transcribe(
|
||||
&self,
|
||||
_audio: &[u8],
|
||||
_format: AudioFormat,
|
||||
_language: Option<&str>,
|
||||
) -> Result<String, TranscriptionError> {
|
||||
match &self.result {
|
||||
Ok(text) => Ok(text.clone()),
|
||||
Err(_) => Err(TranscriptionError::ApiError {
|
||||
message: "mock error".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_transcribes_audio_attachment() {
|
||||
let provider = Arc::new(MockProvider {
|
||||
result: Ok("Hello, world!".to_string()),
|
||||
});
|
||||
let middleware = TranscriptionMiddleware::new(provider, None);
|
||||
|
||||
let msg = IncomingMessage::new("telegram", "user1", "[Voice note]").with_attachments(vec![
|
||||
Attachment {
|
||||
kind: AttachmentKind::Audio,
|
||||
mime_type: "audio/ogg".to_string(),
|
||||
data: vec![0u8; 100],
|
||||
filename: None,
|
||||
duration_secs: Some(5),
|
||||
},
|
||||
]);
|
||||
|
||||
let result = middleware.process(msg).await;
|
||||
assert_eq!(result.content, "Hello, world!");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_skips_non_audio_messages() {
|
||||
let provider = Arc::new(MockProvider {
|
||||
result: Ok("transcribed".to_string()),
|
||||
});
|
||||
let middleware = TranscriptionMiddleware::new(provider, None);
|
||||
|
||||
let msg = IncomingMessage::new("telegram", "user1", "regular text");
|
||||
let result = middleware.process(msg).await;
|
||||
assert_eq!(result.content, "regular text");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_handles_transcription_failure() {
|
||||
let provider = Arc::new(MockProvider {
|
||||
result: Err(TranscriptionError::ApiError {
|
||||
message: "test".to_string(),
|
||||
}),
|
||||
});
|
||||
let middleware = TranscriptionMiddleware::new(provider, None);
|
||||
|
||||
let msg = IncomingMessage::new("telegram", "user1", "[Voice note]").with_attachments(vec![
|
||||
Attachment {
|
||||
kind: AttachmentKind::Audio,
|
||||
mime_type: "audio/ogg".to_string(),
|
||||
data: vec![0u8; 100],
|
||||
filename: None,
|
||||
duration_secs: Some(5),
|
||||
},
|
||||
]);
|
||||
|
||||
let result = middleware.process(msg).await;
|
||||
assert_eq!(result.content, "[Voice note: transcription failed]");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_handles_unsupported_format() {
|
||||
let provider = Arc::new(MockProvider {
|
||||
result: Ok("text".to_string()),
|
||||
});
|
||||
let middleware = TranscriptionMiddleware::new(provider, None);
|
||||
|
||||
let msg = IncomingMessage::new("telegram", "user1", "[Voice note]").with_attachments(vec![
|
||||
Attachment {
|
||||
kind: AttachmentKind::Audio,
|
||||
mime_type: "audio/flac".to_string(),
|
||||
data: vec![0u8; 100],
|
||||
filename: None,
|
||||
duration_secs: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let result = middleware.process(msg).await;
|
||||
assert_eq!(result.content, "[Voice note: unsupported audio format]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! OpenAI Whisper transcription provider.
|
||||
//!
|
||||
//! Uses the OpenAI `/v1/audio/transcriptions` endpoint with multipart form upload.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::multipart;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::transcription::{AudioFormat, TranscriptionError, TranscriptionProvider};
|
||||
|
||||
/// Maximum file size for Whisper API (25 MB).
|
||||
const WHISPER_MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Supported formats for Whisper.
|
||||
const WHISPER_FORMATS: &[AudioFormat] = &[
|
||||
AudioFormat::OggOpus,
|
||||
AudioFormat::Mp3,
|
||||
AudioFormat::Wav,
|
||||
AudioFormat::Webm,
|
||||
AudioFormat::M4a,
|
||||
];
|
||||
|
||||
/// OpenAI Whisper transcription provider.
|
||||
pub struct OpenAiWhisper {
|
||||
api_key: SecretString,
|
||||
model: String,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl OpenAiWhisper {
|
||||
/// Create a new OpenAI Whisper provider.
|
||||
pub fn new(api_key: SecretString, model: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
base_url: "https://api.openai.com".to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom base URL (for testing or alternative endpoints).
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = url.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for OpenAiWhisper {
|
||||
fn name(&self) -> &str {
|
||||
"openai"
|
||||
}
|
||||
|
||||
fn model_name(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn max_file_size(&self) -> usize {
|
||||
WHISPER_MAX_FILE_SIZE
|
||||
}
|
||||
|
||||
fn supported_formats(&self) -> &[AudioFormat] {
|
||||
WHISPER_FORMATS
|
||||
}
|
||||
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: &[u8],
|
||||
format: AudioFormat,
|
||||
language: Option<&str>,
|
||||
) -> Result<String, TranscriptionError> {
|
||||
if audio.len() > WHISPER_MAX_FILE_SIZE {
|
||||
return Err(TranscriptionError::FileTooLarge {
|
||||
size: audio.len(),
|
||||
max: WHISPER_MAX_FILE_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = format!("audio.{}", format.extension());
|
||||
|
||||
// Note: to_vec() copies the audio bytes for the multipart body.
|
||||
// Peak memory is ~2x the file size (original slice + copy). Acceptable
|
||||
// for the 25 MB Whisper limit; revisit if supporting larger files.
|
||||
let file_part = multipart::Part::bytes(audio.to_vec())
|
||||
.file_name(filename)
|
||||
.mime_str(format.mime_type())
|
||||
.map_err(|e| TranscriptionError::RequestFailed(e.to_string()))?;
|
||||
|
||||
let mut form = multipart::Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", self.model.clone())
|
||||
.text("response_format", "text");
|
||||
|
||||
if let Some(lang) = language {
|
||||
form = form.text("language", lang.to_string());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/audio/transcriptions", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", self.api_key.expose_secret()),
|
||||
)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| TranscriptionError::RequestFailed(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| TranscriptionError::RequestFailed(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(TranscriptionError::ApiError {
|
||||
message: format!("HTTP {}: {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
// response_format=text returns raw text, trim whitespace
|
||||
Ok(body.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_too_large_is_rejected_sync() {
|
||||
// The size check happens before any async work, so we can verify
|
||||
// via a simple construction + manual call of the guard logic.
|
||||
let oversized = vec![0u8; WHISPER_MAX_FILE_SIZE + 1];
|
||||
assert!(oversized.len() > WHISPER_MAX_FILE_SIZE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_too_large_returns_error() {
|
||||
let provider = OpenAiWhisper::new(
|
||||
SecretString::from("sk-test".to_string()),
|
||||
"whisper-1".to_string(),
|
||||
);
|
||||
|
||||
let oversized = vec![0u8; WHISPER_MAX_FILE_SIZE + 1];
|
||||
let result = provider
|
||||
.transcribe(&oversized, AudioFormat::Wav, None)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(TranscriptionError::FileTooLarge { size, max }) => {
|
||||
assert_eq!(size, WHISPER_MAX_FILE_SIZE + 1);
|
||||
assert_eq!(max, WHISPER_MAX_FILE_SIZE);
|
||||
}
|
||||
other => panic!("Expected FileTooLarge, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_error_on_bad_url() {
|
||||
// Point at a URL that will fail to connect
|
||||
let provider = OpenAiWhisper::new(
|
||||
SecretString::from("sk-test".to_string()),
|
||||
"whisper-1".to_string(),
|
||||
)
|
||||
.with_base_url("http://127.0.0.1:1"); // port 1 won't be listening
|
||||
|
||||
let audio = vec![0u8; 100];
|
||||
let result = provider
|
||||
.transcribe(&audio, AudioFormat::OggOpus, Some("en"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(TranscriptionError::RequestFailed(_))),
|
||||
"Expected RequestFailed, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_metadata() {
|
||||
let provider = OpenAiWhisper::new(
|
||||
SecretString::from("sk-test".to_string()),
|
||||
"whisper-1".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(provider.name(), "openai");
|
||||
assert_eq!(provider.model_name(), "whisper-1");
|
||||
assert_eq!(provider.max_file_size(), WHISPER_MAX_FILE_SIZE);
|
||||
assert_eq!(provider.supported_formats().len(), 5);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,27 @@ interface channel-host {
|
||||
|
||||
// ==================== Channel-Specific Capabilities ====================
|
||||
|
||||
/// Kind of attachment (audio, image, document).
|
||||
enum attachment-kind {
|
||||
audio,
|
||||
image,
|
||||
document,
|
||||
}
|
||||
|
||||
/// Binary attachment on an emitted message (e.g., voice note, photo).
|
||||
record attachment {
|
||||
/// What kind of content this is.
|
||||
kind: attachment-kind,
|
||||
/// MIME type (e.g., "audio/ogg", "image/jpeg").
|
||||
mime-type: string,
|
||||
/// Raw bytes of the attachment.
|
||||
data: list<u8>,
|
||||
/// Optional filename.
|
||||
filename: option<string>,
|
||||
/// Duration in seconds (for audio/video).
|
||||
duration-secs: option<u32>,
|
||||
}
|
||||
|
||||
/// A message to emit to the agent.
|
||||
record emitted-message {
|
||||
/// User identifier within the channel (e.g., Slack user ID).
|
||||
@@ -125,6 +146,8 @@ interface channel-host {
|
||||
thread-id: option<string>,
|
||||
/// Channel-specific metadata as JSON string.
|
||||
metadata-json: string,
|
||||
/// Optional binary attachments (voice notes, images, etc.).
|
||||
attachments: list<attachment>,
|
||||
}
|
||||
|
||||
/// Emit a message to the agent.
|
||||
|
||||
Reference in New Issue
Block a user