diff --git a/Cargo.lock b/Cargo.lock
index 7d6912b..79c4d59 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -633,6 +633,22 @@ version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "mio"
version = "1.2.1"
@@ -849,6 +865,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
+ "futures-util",
"http",
"http-body",
"http-body-util",
@@ -857,6 +874,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
+ "mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1396,6 +1414,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
diff --git a/Cargo.toml b/Cargo.toml
index e57e366..56c3334 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,7 @@ path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "sync", "signal"] }
-reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/README.md b/README.md
index 0f2848e..a5be99d 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,7 @@ and the rest of your day.
## What works today
- iMessage on macOS and Telegram private chats on macOS or Linux
+- Telegram voice notes with OpenAI transcription and spoken replies
- Claude Code, Codex, and Pi backends, selectable by channel or conversation
- One Git-versioned assistant repository containing `SOUL.md`, `context/`, and
approved `jobs/`
diff --git a/docs/architecture.md b/docs/architecture.md
index e6be597..67f7520 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -223,6 +223,26 @@ One provider can fail or rate-limit without cancelling the other. A shared
shutdown signal cancels pending polls and lets every channel drain its workers.
Replies remain bound to the originating loop and exact target.
+## Voice Boundary
+
+Voice has two independent adapters. A channel adapter exposes opaque inbound
+voice metadata, downloads bytes only after the normal allowlist accepts the
+message, and uploads a generated audio clip. The provider-neutral voice layer
+transcribes and synthesizes in-memory audio. Its output is plain text or a
+generic audio clip, so it has no Telegram identifiers or Bot API behavior.
+Download and transcription run in the accepted message's per-thread worker,
+so slow audio cannot pause polling or work in another conversation.
+
+The first provider uses `OPENAI_API_KEY` for both `gpt-4o-transcribe` and
+`gpt-4o-mini-tts`. The first channel implementation is Telegram. A future
+channel needs only voice download and upload support. It does not need to
+change gateway routing, agent requests, or the OpenAI client.
+
+Voice is an optional enhancement. Missing credentials stop voice processing
+for that message with a text explanation, while ordinary text traffic remains
+available. Speech synthesis and voice delivery happen after durable text
+delivery, so a voice-side failure cannot discard an agent answer.
+
Optional `primary_delivery` selects one enabled, allowlisted channel target for
proactive output. Resolution is lazy: an absent or invalid primary returns a
scoped error to the proactive caller without affecting reply polling.
diff --git a/docs/configuration.md b/docs/configuration.md
index 9847036..1ca6c73 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -91,6 +91,11 @@ private. `push init` creates new config files with mode `0600` on Unix. The
`bot_token_env` setting remains available when an environment variable is a
better fit. See the [Telegram guide](telegram.md).
+Telegram voice notes are optional and need no TOML settings. Set
+`OPENAI_API_KEY` in the gateway process environment to enable both
+transcription and speech replies. Without it, text remains fully available and
+voice notes get a helpful fallback. See [Voice Messages](telegram.md#voice-messages).
+
### Run both providers
Use `channels` instead of `channel`:
diff --git a/docs/services.md b/docs/services.md
index 2b66bad..3befc86 100644
--- a/docs/services.md
+++ b/docs/services.md
@@ -31,6 +31,8 @@ Use absolute paths in service files. The service user needs:
- for iMessage on macOS, Full Disk Access and `osascript`
- for Telegram, a token in the private config and network access to
`api.telegram.org`
+- for optional voice messages, `OPENAI_API_KEY` in the service environment and
+ network access to `api.openai.com`
`state_path` stores independent cursors for each channel and backend session
ids. `database_path` stores the canonical conversation journal. Chat agents run
@@ -76,6 +78,8 @@ and replace `YOU` with your macOS user name:
PATH/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
+ OPENAI_API_KEY
+ replace-with-your-openai-api-keyRunAtLoad
@@ -109,6 +113,10 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.owainlewis.push.plis
launchctl kickstart -k gui/$(id -u)/com.owainlewis.push
```
+The plist contains the optional OpenAI key, so protect it with
+`chmod 600 ~/Library/LaunchAgents/com.owainlewis.push.plist`. Omit the key when
+you do not want voice support.
+
## Linux systemd
Use this for Telegram-only deployments. The iMessage channel still requires
@@ -136,6 +144,7 @@ WorkingDirectory=%h/.push
Restart=on-failure
RestartSec=10
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
+EnvironmentFile=-%h/.config/push/env
[Install]
WantedBy=default.target
@@ -150,6 +159,14 @@ systemctl --user status push.service
journalctl --user -u push.service -f
```
+For voice support, create the optional private environment file:
+
+```sh
+printf 'OPENAI_API_KEY=replace-with-your-openai-api-key\n' > ~/.config/push/env
+chmod 600 ~/.config/push/env
+systemctl --user restart push.service
+```
+
Keep `~/.push/config.toml` at mode `0600` because it contains the Telegram bot
token. Do not commit this file or print it in service logs.
diff --git a/docs/telegram.md b/docs/telegram.md
index 09c6467..2254c0b 100644
--- a/docs/telegram.md
+++ b/docs/telegram.md
@@ -50,14 +50,41 @@ Doctor validates that a token is available without displaying its value. A
Telegram-only preflight does not open `chat.db` and does not require macOS or
`osascript`.
+## Voice Messages
+
+Set one optional environment variable to enable both incoming voice
+transcription and spoken replies:
+
+```sh
+export OPENAI_API_KEY="your-api-key"
+push doctor
+push
+```
+
+Send the bot a Telegram voice note. Push downloads it only after the sender
+passes the normal allowlist, transcribes it with `gpt-4o-transcribe`, sends the
+transcript through the selected coding agent, then returns the answer as both
+text and an Opus voice note generated by `gpt-4o-mini-tts`.
+
+There is no local Whisper, FFmpeg, or other audio dependency. Audio stays in
+memory and is limited to 20 MB. If `OPENAI_API_KEY` is absent, normal text
+messages keep working and voice notes receive an actionable text reply. API or
+speech generation errors also fall back to text without stopping the gateway.
+The spoken reply is AI-generated. Voice-note audio is sent to OpenAI for
+processing, so review OpenAI's data controls before enabling this feature.
+
+Voice processing is separate from the Telegram adapter. Telegram is the first
+channel to provide voice attachment download and upload, so another messaging
+channel can add those transport operations without changing the OpenAI layer.
+
## Allowlisting and Routing
An incoming Telegram update reaches the agent only when all of these are true:
-- it is a normal text message in a private chat
+- it is a normal text message or voice note in a private chat
- its numeric sender id is in `telegram.allow_user_ids`, or its numeric chat id
is in `telegram.allow_chat_ids`
-- the text is not empty
+- the message contains non-empty text or a voice attachment
Group chats, channels, group forum topics, edited messages, and other update
types are out of scope and ignored. The private-chat thread key is
diff --git a/examples/launchd/com.owainlewis.push.plist b/examples/launchd/com.owainlewis.push.plist
index 121abfe..8bb0919 100644
--- a/examples/launchd/com.owainlewis.push.plist
+++ b/examples/launchd/com.owainlewis.push.plist
@@ -20,6 +20,9 @@
PATH/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
+
+
+
RunAtLoad
diff --git a/examples/systemd/push.service b/examples/systemd/push.service
index c7c2a1a..bf3d2e3 100644
--- a/examples/systemd/push.service
+++ b/examples/systemd/push.service
@@ -10,6 +10,7 @@ WorkingDirectory=%h/.push
Restart=on-failure
RestartSec=10
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
+EnvironmentFile=-%h/.config/push/env
[Install]
WantedBy=default.target
diff --git a/src/audit.rs b/src/audit.rs
index 396b099..323f842 100644
--- a/src/audit.rs
+++ b/src/audit.rs
@@ -296,6 +296,7 @@ mod tests {
chat_identifier: "+15551234567".to_string(),
is_group: false,
text: "secret request".to_string(),
+ voice: None,
is_from_me: false,
is_supported: true,
thread_id: None,
diff --git a/src/channel.rs b/src/channel.rs
index b676f3f..46c2080 100644
--- a/src/channel.rs
+++ b/src/channel.rs
@@ -7,6 +7,18 @@ use anyhow::{bail, Context, Result};
use crate::config::{ChannelKind, Config};
use crate::imessage::{Poller as IMessagePoller, Sender as IMessageSender};
use crate::telegram::Telegram;
+use crate::voice::AudioClip;
+
+#[derive(Debug, Clone)]
+pub struct InboundVoice {
+ /// Channel-owned file identifier. The voice layer treats this as opaque.
+ pub locator: String,
+ pub file_size: Option,
+ pub mime_type: String,
+ pub filename: String,
+ /// Channels that already have the bytes may provide them directly.
+ pub data: Option>,
+}
#[derive(Debug, Clone)]
pub struct RawMessage {
@@ -16,6 +28,7 @@ pub struct RawMessage {
pub chat_identifier: String,
pub is_group: bool,
pub text: String,
+ pub voice: Option,
pub is_from_me: bool,
pub is_supported: bool,
/// Channel-specific thread/topic id (Telegram `message_thread_id`).
@@ -150,6 +163,7 @@ impl Channel {
chat_identifier: message.chat_identifier,
is_group: message.is_group,
text: message.text,
+ voice: None,
is_from_me: message.is_from_me,
is_supported: true,
thread_id: None,
@@ -172,7 +186,10 @@ impl Channel {
/// Returns `(thread_key, reply_target)` for an accepted message.
pub fn accept(&self, message: &RawMessage) -> Option<(String, String)> {
- if !message.is_supported || message.is_group || message.text.trim().is_empty() {
+ if !message.is_supported
+ || message.is_group
+ || (message.text.trim().is_empty() && message.voice.is_none())
+ {
return None;
}
match self {
@@ -222,8 +239,8 @@ impl Channel {
"unsupported_update"
} else if message.is_group {
"group_chat"
- } else if message.text.trim().is_empty() {
- "empty_text"
+ } else if message.text.trim().is_empty() && message.voice.is_none() {
+ "empty_message"
} else {
match self {
Self::IMessage { reply_marker, .. }
@@ -283,6 +300,28 @@ impl Channel {
Self::Telegram(telegram) => telegram.send_typing(target).await,
}
}
+
+ pub async fn download_voice(&self, voice: &InboundVoice) -> Result {
+ if let Some(bytes) = &voice.data {
+ return Ok(AudioClip {
+ bytes: bytes.clone(),
+ filename: voice.filename.clone(),
+ mime_type: voice.mime_type.clone(),
+ });
+ }
+ match self {
+ Self::Telegram(telegram) => telegram.download_voice(voice).await,
+ Self::IMessage { .. } => bail!("iMessage voice messages are not supported yet"),
+ }
+ }
+
+ #[cfg_attr(test, allow(dead_code))]
+ pub async fn send_voice(&self, target: &str, clip: &AudioClip) -> Result<()> {
+ match self {
+ Self::Telegram(telegram) => telegram.send_voice(target, clip).await,
+ Self::IMessage { .. } => bail!("iMessage voice replies are not supported yet"),
+ }
+ }
}
pub(crate) fn normalize_handle(value: &str) -> String {
@@ -331,6 +370,7 @@ mod tests {
chat_identifier: chat.to_string(),
is_group,
text: "hello".to_string(),
+ voice: None,
is_from_me: false,
is_supported: true,
thread_id: None,
diff --git a/src/doctor.rs b/src/doctor.rs
index d9048ed..fe1544a 100644
--- a/src/doctor.rs
+++ b/src/doctor.rs
@@ -75,9 +75,24 @@ fn run_checks(cfg: &config::Config) -> CheckReport {
Err(e) => checks.push(Check::fail("channels", e.to_string())),
}
check_bins(cfg, &mut checks);
+ check_voice(&mut checks);
CheckReport { checks }
}
+fn check_voice(checks: &mut Vec) {
+ if std::env::var(crate::voice::OPENAI_API_KEY_ENV).is_ok_and(|value| !value.trim().is_empty()) {
+ checks.push(Check::pass(
+ "voice messages",
+ "OPENAI_API_KEY is set; transcription and speech replies are enabled",
+ ));
+ } else {
+ checks.push(Check::pass(
+ "voice messages",
+ "OPENAI_API_KEY is not set; text messaging works and voice requests get a helpful fallback",
+ ));
+ }
+}
+
fn check_config(cfg: &config::Config, checks: &mut Vec) {
checks.push(Check::pass(
"config",
diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs
index bc8073d..da2154c 100644
--- a/src/gateway/mod.rs
+++ b/src/gateway/mod.rs
@@ -19,18 +19,22 @@ use tracing::{error, info, warn};
use crate::agent::Runner;
use crate::approval::{AnswerOrigin, AnswerOutcome};
use crate::audit::{AuditEvent, AuditLog};
-use crate::channel::{Channel, RawMessage};
+use crate::channel::{Channel, InboundVoice, RawMessage};
use crate::config::{AgentBackend, ChannelKind, Config, PrimaryDeliveryConfig};
use crate::history::{History, OutboundOrigin};
use crate::jobs;
use crate::store::Store;
use crate::util::now_ms;
+use crate::voice::Voice;
const QUEUE_DEPTH: usize = 32;
#[cfg(not(test))]
const SEND_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(30);
+#[cfg(test)]
+type SentVoiceReplies = Arc)>>>;
+
#[derive(Clone)]
struct Job {
row_id: i64,
@@ -40,6 +44,8 @@ struct Job {
backend: AgentBackend,
approval_origin: AnswerOrigin,
text: String,
+ reply_with_voice: bool,
+ voice_attachment: Option,
}
/// Shared, cheaply cloneable context handed to each worker task.
@@ -55,11 +61,14 @@ struct Ctx {
reply_marker: String,
assistant_dir: String,
audit: Arc,
+ voice: Option,
#[cfg(test)]
setup_failure_replies: Arc>>,
#[cfg(test)]
sent_replies: Arc>>,
#[cfg(test)]
+ sent_voice_replies: SentVoiceReplies,
+ #[cfg(test)]
send_failures_remaining: Arc>,
}
@@ -341,11 +350,17 @@ impl Gateway {
reply_marker: cfg.reply_marker.clone(),
assistant_dir: cfg.assistant_dir.clone(),
audit,
+ #[cfg(not(test))]
+ voice: Voice::from_env(),
+ #[cfg(test)]
+ voice: None,
#[cfg(test)]
setup_failure_replies: Arc::new(Mutex::new(Vec::new())),
#[cfg(test)]
sent_replies: Arc::new(Mutex::new(Vec::new())),
#[cfg(test)]
+ sent_voice_replies: Arc::new(Mutex::new(Vec::new())),
+ #[cfg(test)]
send_failures_remaining: Arc::new(Mutex::new(0)),
};
let poll_interval = cfg.poll_interval_dur()?;
@@ -493,12 +508,22 @@ impl Gateway {
continue;
}
if let Some((thread, target)) = self.channel.accept(m) {
+ let reply_with_voice = m.voice.is_some();
+ let message_text = if reply_with_voice {
+ "[Voice message]".to_string()
+ } else {
+ m.text.trim().to_string()
+ };
let approval_origin = approval_origin(m, &thread);
- let approval = self.ctx.history.lock().unwrap().answer_question(
- &approval_origin,
- m.text.trim(),
- now_ms(),
- );
+ let approval = if reply_with_voice {
+ Ok(AnswerOutcome::NotAnAnswer)
+ } else {
+ self.ctx.history.lock().unwrap().answer_question(
+ &approval_origin,
+ message_text.trim(),
+ now_ms(),
+ )
+ };
match approval {
Ok(AnswerOutcome::NotAnAnswer) => {}
Ok(outcome @ (AnswerOutcome::Selected(_) | AnswerOutcome::Duplicate(_))) => {
@@ -546,7 +571,7 @@ impl Gateway {
m.channel,
&thread,
&m.event_id(),
- m.text.trim(),
+ message_text.trim(),
) {
Ok(id) => id,
Err(error) => {
@@ -593,7 +618,9 @@ impl Gateway {
target,
backend,
approval_origin,
- text: m.text.trim().to_string(),
+ text: message_text,
+ reply_with_voice,
+ voice_attachment: m.voice.clone(),
};
if !self.route(job).await {
return;
diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs
index 40324ae..ef5e23c 100644
--- a/src/gateway/tests.rs
+++ b/src/gateway/tests.rs
@@ -5,13 +5,56 @@ use super::worker::{
use super::*;
use crate::agent::{FakeRunCall, FakeRunner};
use crate::approval::Question;
-use crate::channel::{normalize_handle, thread_handle};
+use crate::channel::{normalize_handle, thread_handle, InboundVoice};
use crate::history::DeliveryStatus;
use crate::imessage::{Poller, Sender};
+use crate::voice::{AudioClip, VoiceFuture, VoiceProvider};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use uuid::Uuid;
+struct FakeVoice;
+
+impl VoiceProvider for FakeVoice {
+ fn transcribe<'a>(&'a self, _clip: AudioClip) -> VoiceFuture<'a, String> {
+ Box::pin(async { Ok("voice request".to_string()) })
+ }
+
+ fn synthesize<'a>(&'a self, _text: &'a str) -> VoiceFuture<'a, AudioClip> {
+ Box::pin(async {
+ Ok(AudioClip {
+ bytes: vec![4, 5, 6],
+ filename: "reply.opus".to_string(),
+ mime_type: "audio/ogg".to_string(),
+ })
+ })
+ }
+}
+
+struct BlockingVoice {
+ release: tokio::sync::Mutex