Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
20 changes: 20 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
17 changes: 17 additions & 0 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,6 +78,8 @@ and replace `YOU` with your macOS user name:
<dict>
<key>PATH</key>
<string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>OPENAI_API_KEY</key>
<string>replace-with-your-openai-api-key</string>
</dict>

<key>RunAtLoad</key>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
31 changes: 29 additions & 2 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/launchd/com.owainlewis.push.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
<dict>
<key>PATH</key>
<string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<!-- Optional: uncomment both lines to enable voice messages. -->
<!-- <key>OPENAI_API_KEY</key> -->
<!-- <string>replace-with-your-openai-api-key</string> -->
</dict>

<key>RunAtLoad</key>
Expand Down
1 change: 1 addition & 0 deletions examples/systemd/push.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 43 additions & 3 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
pub mime_type: String,
pub filename: String,
/// Channels that already have the bytes may provide them directly.
pub data: Option<Vec<u8>>,
}

#[derive(Debug, Clone)]
pub struct RawMessage {
Expand All @@ -16,6 +28,7 @@ pub struct RawMessage {
pub chat_identifier: String,
pub is_group: bool,
pub text: String,
pub voice: Option<InboundVoice>,
pub is_from_me: bool,
pub is_supported: bool,
/// Channel-specific thread/topic id (Telegram `message_thread_id`).
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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, .. }
Expand Down Expand Up @@ -283,6 +300,28 @@ impl Channel {
Self::Telegram(telegram) => telegram.send_typing(target).await,
}
}

pub async fn download_voice(&self, voice: &InboundVoice) -> Result<AudioClip> {
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 {
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Check>) {
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<Check>) {
checks.push(Check::pass(
"config",
Expand Down
Loading
Loading