diff --git a/README.md b/README.md index 1253f94..8c8bdad 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ https://pushassistant.com/ Good coding agents should be useful beyond an open terminal. Push makes the agent you already trust available through iMessage, Telegram, -or Slack. It can answer a message, continue a conversation, or run a Markdown +Slack, or Missive. It can answer a message, continue a conversation, or run a Markdown job on a schedule. Give it clear context and a useful set of jobs, and it can act as your AI chief of staff. Your assistant files stay in a Git repository you own. @@ -46,7 +46,7 @@ schedules to Claude Code, Codex, or Pi. ```mermaid flowchart TD - Message["Message from you
iMessage · Telegram · Slack"] + Message["Message from you
iMessage · Telegram · Slack · Missive"] Jobs["Scheduled
Markdown jobs"] Repo["Assistant repository
SOUL.md · context · jobs"] Push["Push
message gateway · scheduler · history"] @@ -63,7 +63,7 @@ flowchart TD ## What it does - Runs on your Mac or Linux machine -- Connects private iMessage, Telegram, and Slack chats +- Connects private iMessage, Telegram, Slack, and Missive conversations - Uses your existing Claude Code, Codex, or Pi setup - Keeps conversations and job history between restarts - Runs one-off or scheduled Markdown jobs diff --git a/config.toml.example b/config.toml.example index 7b3f2a9..797f584 100644 --- a/config.toml.example +++ b/config.toml.example @@ -19,6 +19,14 @@ allow_user_ids = [123456789] # bot_token = "xoxb-..." # Prefer SLACK_BOT_TOKEN. # allow_user_ids = ["U012ABCDEF"] +# Missive alternative. See docs/missive.md. +# channel = "missive" +# poll_interval = "3s" # At least one second per configured conversation. +# [missive] +# api_token = "..." # Prefer MISSIVE_API_TOKEN. +# conversation_ids = ["00000000-0000-0000-0000-000000000000"] +# allow_user_ids = ["00000000-0000-0000-0000-000000000000"] + # Required when a job contains an enabled cron trigger. # [primary_delivery] # channel = "telegram" diff --git a/docs/architecture.md b/docs/architecture.md index 2e0f638..2b82201 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Push Architecture Push is one local Rust process. It receives from configured iMessage, Telegram, -and Slack channels, filters messages, loads the configured assistant repository, +Slack, and Missive channels, filters messages, loads the configured assistant repository, runs a configured agent backend, and sends the final reply. @@ -55,7 +55,8 @@ Those belong to the selected backend. Push polls channel adapters and shells out to local agent commands. It opens no server port and accepts no inbound network connection. Telegram uses outbound -HTTPS long polling and Slack uses an outbound Socket Mode WebSocket. +HTTPS long polling, Slack uses an outbound Socket Mode WebSocket, and Missive +uses outbound REST polling. The trust boundary is the messaging account plus the configured channel allowlist. @@ -67,9 +68,11 @@ flowchart LR user([You]) -->|iMessage| db[(chat.db)] user -->|private chat| tg[Telegram Bot API] user -->|app DM| slack[Slack] + user -->|comment| missive[Missive REST API] db -->|poll| push tg -->|long poll| push slack -->|Socket Mode| push + missive -->|poll comments| push subgraph push[Push gateway] poller[Channel poller] --> gateway[Gateway loop] gateway --> worker[Per-thread worker] @@ -88,15 +91,17 @@ flowchart LR sender -->|osascript| db sender -->|sendMessage| tg sender -->|chat.postMessage| slack + sender -->|create post| missive db -->|reply| user tg -->|reply| user slack -->|reply| user + missive -->|reply| user ``` ## Channel Boundary The gateway depends on one closed, compile-time channel contract. iMessage, -Telegram, and Slack implement it, and the `Channel` enum provides static dispatch. This is +Telegram, Slack, and Missive implement it, and the `Channel` enum provides static dispatch. This is an internal Rust boundary, not a dynamic plugin system or a configuration extension point. @@ -111,14 +116,15 @@ Each channel implementation owns these semantics: reply target, approval sender/chat identity, and ordered route-key groups. Telegram topics inherit a parent-chat route; iMessage retains its legacy unprefixed route aliases. Slack keeps one workspace-scoped DM identity while targeting - the exact originating Slack message thread. + the exact originating Slack message thread. Missive keeps one thread per + allowlisted conversation. - **Outbound delivery:** validate proactive targets, plan durable chunks, and send one chunk to the exact accepted target. Replies never cross from one channel loop to another. - **Typing:** declare an optional refresh interval and send best-effort activity updates. Typing failures do not fail an assistant turn. - **Rich messages:** choose plain or rich delivery per chunk. iMessage keeps one - plain marked reply. Telegram and Slack own their formatting, size limits, + plain marked reply. Telegram, Slack, and Missive own their formatting, size limits, splitting, and provider-specific addressing. - **Retry:** expose the timeout and bounded retry cadence used for stored chat replies. A send call is one attempt. Generated output is persisted before @@ -271,7 +277,7 @@ accepted input survives a process restart without relying on an in-memory WebSocket buffer. Ignored envelopes retain redacted rejection metadata for the audit path without retaining message content. -With advanced `channels = ["imessage", "telegram", "slack"]` configuration, one +With advanced `channels = ["imessage", "telegram", "slack", "missive"]` configuration, one coordinator starts an independent polling loop, acknowledgement tracker, and thread queue map for each enabled provider. The loops share one locked state store, canonical history database, backend runner set, and serialized audit log. @@ -400,8 +406,9 @@ backend session. An allowed inbound message can cause an agent to run tools. The sender filter is the trust boundary. iMessage uses `imessage.self_handles` and `imessage.allow_from`; Telegram uses stable numeric `telegram.allow_user_ids` -and `telegram.allow_chat_ids`; Slack uses stable `slack.allow_user_ids` member -IDs and verifies the authenticated workspace. +and `telegram.allow_chat_ids`. Slack uses stable `slack.allow_user_ids` member +IDs and verifies the authenticated workspace. Missive requires both +`missive.conversation_ids` and stable `missive.allow_user_ids`. Push preserves sandbox, approval, permission-mode, and tool-list settings for chats. Codex and Claude jobs bypass interactive permissions so unattended work diff --git a/docs/configuration.md b/docs/configuration.md index 1b2104b..2ceb876 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,6 +102,21 @@ Slack requires both `SLACK_APP_TOKEN` and `SLACK_BOT_TOKEN`, or the matching `slack.app_token` and `slack.bot_token` values in the private config. At least one exact Slack member ID is required. See the [Slack guide](slack.md). +### Missive + +```toml +poll_interval = "3s" + +[missive] +conversation_ids = ["00000000-0000-0000-0000-000000000000"] +allow_user_ids = ["00000000-0000-0000-0000-000000000000"] +``` + +Missive requires `MISSIVE_API_TOKEN`, or `missive.api_token` in a private +config. Both an explicit conversation allowlist and an explicit user allowlist +are required. The polling interval must allow at least one second per configured +conversation. See the [Missive guide](missive.md). + Telegram voice notes are optional. Configure the shared voice provider with: ```toml @@ -122,7 +137,7 @@ See [Voice Messages](telegram.md#voice-messages). Use `channels` instead of `channel`: ```toml -channels = ["imessage", "telegram", "slack"] +channels = ["imessage", "telegram", "slack", "missive"] agent = "codex" [imessage] @@ -145,6 +160,7 @@ provider does not stop the other. must be enabled and the target must appear in that channel's allowlist. Telegram topic targets use `":"`. Slack primary targets use an allowlisted member ID such as `U012ABCDEF`. +Missive primary targets use an allowlisted conversation ID. ## Routing @@ -174,6 +190,7 @@ Thread keys are: - `telegram:dm:` - `telegram:dm::topic:` - `slack:dm::` +- `missive:conversation:` ## Agent permissions @@ -218,6 +235,14 @@ requests. Review [permissions and security](security.md) before enabling jobs. | `slack.bot_token` | `SLACK_BOT_TOKEN` fallback | Bot token used for `auth.test`, replies, and progress | | `slack.allow_user_ids` | `[]` | Trusted stable Slack member IDs | +### Missive + +| Setting | Default | Purpose | +| --- | --- | --- | +| `missive.api_token` | `MISSIVE_API_TOKEN` fallback | API token; the environment value is used when this is omitted | +| `missive.conversation_ids` | `[]` | Exact conversations whose comments Push may poll and reply to | +| `missive.allow_user_ids` | `[]` | Trusted stable Missive comment-author IDs | + ### Voice | Setting | Default | Purpose | diff --git a/docs/contributing.md b/docs/contributing.md index 4b74fc1..451a960 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -29,7 +29,7 @@ cargo test --locked | CLI and startup | `src/main.rs` | | TOML loading and validation | `src/config.rs` | | Channel-neutral boundary | `src/channel.rs` | -| iMessage, Telegram, and Slack adapters | `src/imessage/`, `src/telegram.rs`, `src/slack.rs` | +| iMessage, Telegram, Slack, and Missive adapters | `src/imessage/`, `src/telegram.rs`, `src/slack.rs`, `src/missive.rs` | | Gateway, queues, workers, delivery | `src/gateway/` | | Claude Code, Codex, and Pi adapters | `src/claude.rs`, `src/codex.rs`, `src/pi.rs` | | Canonical SQLite history | `src/history.rs` | diff --git a/docs/getting-started.md b/docs/getting-started.md index 3dcfce9..009545a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # Quickstart This guide gets one private chat working with one coding-agent backend. Start -with Telegram or Slack on macOS or Linux, or iMessage on macOS. Add multiple channels, +with Telegram, Slack, or Missive on macOS or Linux, or iMessage on macOS. Add multiple channels, routes, and scheduled jobs after the basic path passes `push doctor`. ## 1. Check the requirements @@ -9,7 +9,7 @@ routes, and scheduled jobs after the basic path passes `push doctor`. You need: - Apple Silicon macOS or x86_64 Linux for the current prebuilt release -- macOS for iMessage, or macOS/Linux for Telegram or Slack +- macOS for iMessage, or macOS/Linux for Telegram, Slack, or Missive - Claude Code, Codex, or Pi installed, authenticated, and runnable by the same user that will run Push - Git for the assistant repository created by `push init` @@ -142,6 +142,27 @@ practical structure for identity, context, shared skills, jobs, and evals. Read the [Slack guide](slack.md) for app setup, scopes, token storage, filtering, and recovery behavior. +=== "Missive" + + Create a Missive API token, choose one private conversation, and collect + the stable IDs for that conversation and the trusted comment author. Put + the token in the service environment, then edit `~/.push/config.toml`: + + ```toml + channel = "missive" + agent = "codex" + assistant_root = "~/Code/assistant" + poll_interval = "3s" + + [missive] + conversation_ids = ["00000000-0000-0000-0000-000000000000"] + allow_user_ids = ["00000000-0000-0000-0000-000000000000"] + ``` + + Set `MISSIVE_API_TOKEN` for the service. Read the [Missive + guide](missive.md) for the comments-only command model, rate limit, + allowlists, and first-run behavior. + Replace `codex` with `claude` for Claude Code or `pi` for Pi. Pi must already have a configured model provider or authenticated account for the service user. diff --git a/docs/index.md b/docs/index.md index eb0334a..b6b0410 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ hide: # Build your own 24/7 AI chief of staff. Push turns Claude Code, Codex, or Pi into an always-on personal assistant. -Message it from iMessage, Telegram, or Slack, give it recurring jobs, and let it +Message it from iMessage, Telegram, Slack, or Missive, give it recurring jobs, and let it handle work in the background.

@@ -92,7 +92,7 @@ sends the result back when the work is done. ## One lightweight bridge. Your agent does the work.

-
01Message your assistant

Use iMessage, Telegram, or Slack from wherever you are.

+
01Message your assistant

Use iMessage, Telegram, Slack, or Missive from wherever you are.

02Push starts the work

It restores the conversation and runs your chosen coding agent in the background.

03Get the result

Push saves the response and sends it back to the same chat.

@@ -128,8 +128,9 @@ finish without an operator. --- - Set up private [iMessage](channels/imessage.md), [Telegram](telegram.md), or - [Slack](slack.md) conversations with narrow sender allowlists. + Set up private [iMessage](channels/imessage.md), [Telegram](telegram.md), + [Slack](slack.md), or [Missive](missive.md) conversations with narrow + sender allowlists. [:octicons-arrow-right-24: Configure channels](configuration.md#channels) diff --git a/docs/missive.md b/docs/missive.md new file mode 100644 index 0000000..7bf5462 --- /dev/null +++ b/docs/missive.md @@ -0,0 +1,69 @@ +# Missive + +Push can use comments in explicitly allowlisted Missive conversations as a +private command channel. It polls Missive's REST API over outbound HTTPS and +returns each assistant reply as a Missive post in the same conversation. It +does not expose a webhook, send email, or create a draft. + +## Configure access + +Create a Missive API token for the account that will run Push, then put it in +the service environment: + +```sh +export MISSIVE_API_TOKEN="..." +``` + +Choose the exact conversation IDs Push may read and the exact Missive user IDs +whose comments may invoke the assistant: + +```toml +channel = "missive" +agent = "codex" +assistant_root = "~/Code/assistant" +poll_interval = "3s" + +[missive] +conversation_ids = ["00000000-0000-0000-0000-000000000000"] +allow_user_ids = ["00000000-0000-0000-0000-000000000000"] +``` + +You may set `missive.api_token` in a private config instead, but the environment +variable is safer for a service. Push rejects an inline Missive token when the +config is inside the Git-versioned assistant repository. + +Missive limits continuous polling to one request per second. Push makes one +request per configured conversation during a normal poll, so +`poll_interval` must be at least one second for every configured conversation. +For example, three conversations require `poll_interval = "3s"` or longer. + +## Message behavior + +- Only new comments are commands. Other email or chat activity is ignored. +- Both the conversation ID and comment author ID must be allowlisted. +- The first successful startup records current comments and does not replay + them into the assistant. +- Stable Missive comment IDs are deduplicated in + `.missive-inbox.db` before dispatch. +- Rejected comment content is cleared before it is stored locally. +- Replies are Markdown posts in the exact originating conversation. They do + not send or modify the underlying email. +- Posts longer than Missive's 8,000-character Markdown limit are split into + durable chunks. + +Run the normal preflight after configuring the channel: + +```sh +push doctor +push +``` + +To use scheduled delivery, the target is one configured conversation ID: + +```toml +[primary_delivery] +channel = "missive" +target = "00000000-0000-0000-0000-000000000000" +``` + +Missive voice messages and typing indicators are not supported. diff --git a/docs/security.md b/docs/security.md index 2c0cf7e..a4d7c6f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -15,11 +15,14 @@ Keep these allowlists narrow: - `imessage.self_handles` and `imessage.allow_from` - `telegram.allow_user_ids` and `telegram.allow_chat_ids` - `slack.allow_user_ids` +- `missive.conversation_ids` and `missive.allow_user_ids` Use stable numeric Telegram IDs. Usernames are mutable and are not accepted as security identities. Treat a lost phone, shared messaging account, or compromised allowed sender as access to the assistant. Slack allowlists use stable member IDs, never mutable display names. +Missive requires both the exact conversation and the stable comment-author ID; +it never treats an email address or display name as authorization. ## Agent permissions @@ -70,7 +73,7 @@ Keep them on local durable storage with permissions restricted to the service user. Keep the assistant directory in its own private Git repository. Never put real config secrets, state, audit logs, or databases in that repository. An explicit `assistant_root` config stored inside it cannot -contain inline Telegram, Slack, or OpenAI credentials; use the matching environment +contain inline Telegram, Slack, Missive, or OpenAI credentials; use the matching environment variable or move the config outside. When `voice.openai_api_key` is configured, `push doctor` requires the config file to be private on Unix: @@ -83,6 +86,7 @@ chmod 600 ~/.push/config.toml Push opens no inbound server port. iMessage reads local state. Telegram uses outbound HTTPS long polling and Slack uses an authenticated outbound Socket Mode WebSocket. Neither needs a webhook. +Missive uses outbound HTTPS polling and likewise needs no webhook. This reduces exposure, but it does not make an allowed message harmless. ## Durable questions diff --git a/mkdocs.yml b/mkdocs.yml index 1ddec55..fee6af5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - iMessage: channels/imessage.md - Telegram: telegram.md - Slack: slack.md + - Missive: missive.md - Use Push: - Jobs and schedules: jobs.md - Permissions and security: security.md diff --git a/src/assistant.rs b/src/assistant.rs index 283a04e..1941f2a 100644 --- a/src/assistant.rs +++ b/src/assistant.rs @@ -8,8 +8,8 @@ use std::process::Command; use anyhow::{bail, Context, Result}; use crate::config::{ - validate_inline_slack_token_location, validate_inline_token_location, - validate_inline_voice_key_location, + validate_inline_missive_token_location, validate_inline_slack_token_location, + validate_inline_token_location, validate_inline_voice_key_location, }; use crate::util::expand_home; @@ -181,6 +181,17 @@ fn validate_config_secrets(config_path: &Path, target: &Path, config: &toml::Tab ] { validate_inline_slack_token_location(&config_path, &assistant, app_token, bot_token)?; } + let flat_missive_token = config + .get("missive_api_token") + .and_then(toml::Value::as_str); + let nested_missive_token = config + .get("missive") + .and_then(toml::Value::as_table) + .and_then(|missive| missive.get("api_token")) + .and_then(toml::Value::as_str); + for token in [flat_missive_token, nested_missive_token] { + validate_inline_missive_token_location(&config_path, &assistant, token)?; + } let flat_voice_key = config .get("voice_openai_api_key") .and_then(toml::Value::as_str); @@ -864,4 +875,22 @@ mod tests { assert!(!target.join("SOUL.md").exists()); let _ = fs::remove_dir_all(target); } + + #[test] + fn refuses_inline_missive_token_in_a_config_inside_the_assistant() { + let target = temp_path("assistant-missive-secret-config"); + fs::create_dir_all(&target).unwrap(); + let config = target.join("config.toml"); + fs::write( + &config, + "channel = 'missive'\n[missive]\napi_token = 'missive-secret'\nconversation_ids = ['c1']\nallow_user_ids = ['u1']\n", + ) + .unwrap(); + + let error = init(target.to_str().unwrap(), config.to_str().unwrap()).unwrap_err(); + + assert!(error.to_string().contains("inline Missive token")); + assert!(!target.join("SOUL.md").exists()); + let _ = fs::remove_dir_all(target); + } } diff --git a/src/channel.rs b/src/channel.rs index 2ee3d39..a640573 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -8,6 +8,7 @@ use anyhow::{bail, Context, Result}; use crate::approval::AnswerOrigin; use crate::config::{ChannelKind, Config}; use crate::imessage::{Poller as IMessagePoller, Sender as IMessageSender}; +use crate::missive::Missive; use crate::slack::{parse_message_target, Slack}; use crate::telegram::Telegram; use crate::voice::AudioClip; @@ -140,6 +141,7 @@ pub enum Channel { IMessage(IMessageChannel), Telegram(Telegram), Slack(Slack), + Missive(Missive), } impl Channel { @@ -174,6 +176,13 @@ impl Channel { cfg.slack_allow_user_ids.clone(), &cfg.state_path, )?)), + ChannelKind::Missive => Ok(Self::Missive(Missive::new( + cfg.missive_token() + .ok_or_else(|| anyhow::anyhow!("Missive API token is not configured"))?, + cfg.missive_conversation_ids.clone(), + cfg.missive_allow_user_ids.clone(), + &cfg.state_path, + )?)), } } @@ -182,6 +191,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::primary_target(channel, configured), Self::Telegram(channel) => ChannelContract::primary_target(channel, configured), Self::Slack(channel) => ChannelContract::primary_target(channel, configured), + Self::Missive(channel) => ChannelContract::primary_target(channel, configured), } } @@ -190,6 +200,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::id(channel), Self::Telegram(channel) => ChannelContract::id(channel), Self::Slack(channel) => ChannelContract::id(channel), + Self::Missive(channel) => ChannelContract::id(channel), } } @@ -198,6 +209,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::poll(channel, since).await, Self::Telegram(channel) => ChannelContract::poll(channel, since).await, Self::Slack(channel) => ChannelContract::poll(channel, since).await, + Self::Missive(channel) => ChannelContract::poll(channel, since).await, } } @@ -206,6 +218,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::latest_cursor(channel).await, Self::Telegram(channel) => ChannelContract::latest_cursor(channel).await, Self::Slack(channel) => ChannelContract::latest_cursor(channel).await, + Self::Missive(channel) => ChannelContract::latest_cursor(channel).await, } } @@ -215,6 +228,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::accept(channel, message), Self::Telegram(channel) => ChannelContract::accept(channel, message), Self::Slack(channel) => ChannelContract::accept(channel, message), + Self::Missive(channel) => ChannelContract::accept(channel, message), } } @@ -223,6 +237,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::reject_reason(channel, message), Self::Telegram(channel) => ChannelContract::reject_reason(channel, message), Self::Slack(channel) => ChannelContract::reject_reason(channel, message), + Self::Missive(channel) => ChannelContract::reject_reason(channel, message), } } @@ -231,6 +246,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::approval_origin(channel, message, thread), Self::Telegram(channel) => ChannelContract::approval_origin(channel, message, thread), Self::Slack(channel) => ChannelContract::approval_origin(channel, message, thread), + Self::Missive(channel) => ChannelContract::approval_origin(channel, message, thread), } } @@ -239,6 +255,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::route_thread_groups(channel, thread), Self::Telegram(channel) => ChannelContract::route_thread_groups(channel, thread), Self::Slack(channel) => ChannelContract::route_thread_groups(channel, thread), + Self::Missive(channel) => ChannelContract::route_thread_groups(channel, thread), } } @@ -247,6 +264,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::outbound_chunks(channel, text, marker), Self::Telegram(channel) => ChannelContract::outbound_chunks(channel, text, marker), Self::Slack(channel) => ChannelContract::outbound_chunks(channel, text, marker), + Self::Missive(channel) => ChannelContract::outbound_chunks(channel, text, marker), } } @@ -261,6 +279,9 @@ impl Channel { Self::Slack(channel) => { ChannelContract::scheduled_outbound_chunks(channel, text, marker) } + Self::Missive(channel) => { + ChannelContract::scheduled_outbound_chunks(channel, text, marker) + } } } @@ -270,6 +291,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::send_chunk(channel, target, chunk).await, Self::Telegram(channel) => ChannelContract::send_chunk(channel, target, chunk).await, Self::Slack(channel) => ChannelContract::send_chunk(channel, target, chunk).await, + Self::Missive(channel) => ChannelContract::send_chunk(channel, target, chunk).await, } } @@ -278,6 +300,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::typing_refresh(channel), Self::Telegram(channel) => ChannelContract::typing_refresh(channel), Self::Slack(channel) => ChannelContract::typing_refresh(channel), + Self::Missive(channel) => ChannelContract::typing_refresh(channel), } } @@ -286,6 +309,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::send_typing(channel, target).await, Self::Telegram(channel) => ChannelContract::send_typing(channel, target).await, Self::Slack(channel) => ChannelContract::send_typing(channel, target).await, + Self::Missive(channel) => ChannelContract::send_typing(channel, target).await, } } @@ -294,6 +318,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::download_voice(channel, voice).await, Self::Telegram(channel) => ChannelContract::download_voice(channel, voice).await, Self::Slack(channel) => ChannelContract::download_voice(channel, voice).await, + Self::Missive(channel) => ChannelContract::download_voice(channel, voice).await, } } @@ -303,6 +328,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::send_voice(channel, target, clip).await, Self::Telegram(channel) => ChannelContract::send_voice(channel, target, clip).await, Self::Slack(channel) => ChannelContract::send_voice(channel, target, clip).await, + Self::Missive(channel) => ChannelContract::send_voice(channel, target, clip).await, } } @@ -311,6 +337,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::delivery_semantics(channel), Self::Telegram(channel) => ChannelContract::delivery_semantics(channel), Self::Slack(channel) => ChannelContract::delivery_semantics(channel), + Self::Missive(channel) => ChannelContract::delivery_semantics(channel), } } @@ -319,6 +346,7 @@ impl Channel { Self::IMessage(channel) => ChannelContract::shutdown_semantics(channel), Self::Telegram(channel) => ChannelContract::shutdown_semantics(channel), Self::Slack(channel) => ChannelContract::shutdown_semantics(channel), + Self::Missive(channel) => ChannelContract::shutdown_semantics(channel), } } } @@ -684,6 +712,103 @@ impl ChannelContract for Slack { } } +impl ChannelContract for Missive { + fn id(&self) -> &'static str { + "missive" + } + + fn primary_target(&self, configured: &str) -> Result { + let conversation = configured.trim(); + if conversation.is_empty() { + bail!("primary delivery target cannot be empty"); + } + if !self.allows_conversation(conversation) { + bail!( + "Missive primary conversation {conversation:?} is not in missive.conversation_ids" + ); + } + Ok(conversation.to_string()) + } + + async fn poll(&self, since: i64) -> Result> { + self.poll(since).await + } + + async fn latest_cursor(&self) -> Result { + self.latest_cursor().await + } + + fn accept(&self, message: &RawMessage) -> Option<(String, String)> { + if message.is_from_me + || common_reject_reason(message).is_some() + || !self.allows_user(&message.handle) + || !self.allows_conversation(&message.chat_identifier) + { + return None; + } + Some(( + format!("missive:conversation:{}", message.chat_identifier), + message.chat_identifier.clone(), + )) + } + + fn reject_reason(&self, message: &RawMessage) -> &'static str { + if message.is_from_me { + "bot_message" + } else if !self.allows_user(&message.handle) + || !self.allows_conversation(&message.chat_identifier) + { + "not_allowlisted" + } else { + common_reject_reason(message).unwrap_or("unsupported_update") + } + } + + fn approval_origin(&self, message: &RawMessage, thread: &str) -> AnswerOrigin { + AnswerOrigin { + channel: self.id().to_string(), + thread_key: thread.to_string(), + sender_key: message.handle.clone(), + chat_key: message.chat_identifier.clone(), + } + } + + fn route_thread_groups(&self, thread: &str) -> Vec> { + vec![vec![thread.to_string()]] + } + + fn outbound_chunks(&self, text: &str, _marker: &str) -> Vec { + crate::missive::split_text(text) + .into_iter() + .map(|text| OutboundChunk { + text, + rich_markdown: true, + }) + .collect() + } + + async fn send_chunk(&self, target: &str, chunk: &OutboundChunk) -> Result<()> { + self.send_message(target, &chunk.text).await + } + + async fn download_voice(&self, _voice: &InboundVoice) -> Result { + bail!("Missive voice messages are not supported") + } + + async fn send_voice(&self, _target: &str, _clip: &AudioClip) -> Result<()> { + bail!("Missive voice replies are not supported") + } + + fn delivery_semantics(&self) -> DeliverySemantics { + DeliverySemantics { + send_timeout: Duration::ZERO, + retry_attempts: 3, + retry_delay: Duration::from_secs(1), + exhausted_retry_delay: Duration::from_secs(5), + } + } +} + fn common_reject_reason(message: &RawMessage) -> Option<&'static str> { if !message.is_supported { Some("unsupported_update") @@ -774,6 +899,25 @@ mod tests { ) } + fn missive() -> Channel { + let state = std::env::temp_dir() + .join(format!( + "push-channel-missive-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + Channel::Missive( + Missive::new( + "missive-test".to_string(), + vec!["conversation-1".to_string()], + vec!["user-1".to_string()], + &state, + ) + .unwrap(), + ) + } + fn imessage_message(chat: &str, handle: &str, is_from_me: bool) -> RawMessage { RawMessage { row_id: 1, @@ -811,6 +955,7 @@ mod tests { assert_contract::(); assert_contract::(); assert_contract::(); + assert_contract::(); } #[test] @@ -920,6 +1065,45 @@ mod tests { assert_eq!(channel.reject_reason(&unauthorized), "not_allowlisted"); } + #[test] + fn missive_contract_keeps_conversation_identity_and_allowlists() { + let channel = missive(); + let message = RawMessage { + row_id: 1, + provider_event_id: Some("comment-1".to_string()), + channel: "missive", + handle: "user-1".to_string(), + chat_identifier: "conversation-1".to_string(), + is_group: false, + text: "hello".to_string(), + voice: None, + is_from_me: false, + is_supported: true, + thread_id: None, + }; + + let (thread, target) = channel.accept(&message).unwrap(); + assert_eq!(thread, "missive:conversation:conversation-1"); + assert_eq!(target, "conversation-1"); + assert_eq!(message.event_id(), "missive:comment-1"); + assert_eq!(channel.primary_target("conversation-1").unwrap(), target); + assert_eq!(channel.delivery_semantics().send_timeout, Duration::ZERO); + assert_eq!( + channel.approval_origin(&message, &thread), + AnswerOrigin { + channel: "missive".to_string(), + thread_key: thread, + sender_key: "user-1".to_string(), + chat_key: "conversation-1".to_string(), + } + ); + + let mut unauthorized = message; + unauthorized.handle = "user-2".to_string(); + assert_eq!(channel.accept(&unauthorized), None); + assert_eq!(channel.reject_reason(&unauthorized), "not_allowlisted"); + } + #[test] fn telegram_accepts_allowlisted_private_user_or_chat() { assert_eq!(telegram().typing_refresh(), Some(Duration::from_secs(4))); diff --git a/src/config.rs b/src/config.rs index 9c8637b..eb6fbde 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ use crate::util::expand_home; pub const TELEGRAM_BOT_TOKEN_ENV: &str = "TELEGRAM_BOT_TOKEN"; pub const SLACK_APP_TOKEN_ENV: &str = "SLACK_APP_TOKEN"; pub const SLACK_BOT_TOKEN_ENV: &str = "SLACK_BOT_TOKEN"; +pub const MISSIVE_API_TOKEN_ENV: &str = "MISSIVE_API_TOKEN"; pub const DEFAULT_VOICE_NAME: &str = "cedar"; const SUPPORTED_VOICE_NAMES: &[&str] = &[ "alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", @@ -68,6 +69,12 @@ pub struct Config { #[serde(default)] pub slack_allow_user_ids: Vec, #[serde(default)] + pub missive_api_token: Option, + #[serde(default)] + pub missive_conversation_ids: Vec, + #[serde(default)] + pub missive_allow_user_ids: Vec, + #[serde(default)] pub voice_openai_api_key: Option, #[serde(default = "default_voice_name")] pub voice_name: String, @@ -226,6 +233,15 @@ impl Config { ("allow_user_ids", "slack_allow_user_ids"), ], )?; + flatten_provider_section( + root, + "missive", + &[ + ("api_token", "missive_api_token"), + ("conversation_ids", "missive_conversation_ids"), + ("allow_user_ids", "missive_allow_user_ids"), + ], + )?; flatten_provider_section( root, "voice", @@ -268,6 +284,11 @@ impl Config { c.slack_app_token.as_deref(), c.slack_bot_token.as_deref(), )?; + validate_inline_missive_token_location( + &config_path, + &assistant_root, + c.missive_api_token.as_deref(), + )?; validate_inline_voice_key_location( &config_path, &assistant_root, @@ -375,6 +396,10 @@ impl Config { configured_secret(self.slack_bot_token.as_deref(), SLACK_BOT_TOKEN_ENV) } + pub fn missive_token(&self) -> Option { + configured_secret(self.missive_api_token.as_deref(), MISSIVE_API_TOKEN_ENV) + } + pub fn route_for_message( &self, channel: &str, @@ -550,6 +575,38 @@ impl Config { bail!("slack.bot_token cannot be empty"); } } + ChannelKind::Missive => { + if self.missive_conversation_ids.is_empty() + || self + .missive_conversation_ids + .iter() + .any(|id| id.trim().is_empty()) + { + bail!("set missive.conversation_ids to explicit Missive conversation IDs"); + } + if self.missive_allow_user_ids.is_empty() + || self + .missive_allow_user_ids + .iter() + .any(|id| id.trim().is_empty()) + { + bail!("set missive.allow_user_ids to explicit Missive user IDs"); + } + if self + .missive_api_token + .as_deref() + .is_some_and(|v| v.trim().is_empty()) + { + bail!("missive.api_token cannot be empty"); + } + if self.poll_interval_dur()?.as_secs_f64() + < self.missive_conversation_ids.len() as f64 + { + bail!( + "poll_interval must be at least one second per Missive conversation to respect the API's continuous polling limit" + ); + } + } } } self.agent_backend()?; @@ -651,6 +708,22 @@ pub(crate) fn validate_inline_slack_token_location( Ok(()) } +pub(crate) fn validate_inline_missive_token_location( + config_path: &Path, + assistant_root: &Path, + token: Option<&str>, +) -> Result<()> { + if token.is_some_and(|token| !token.trim().is_empty()) + && config_path.starts_with(assistant_root) + { + bail!( + "config {} contains an inline Missive token inside the Git-versioned assistant repository. Move the config outside the assistant or use MISSIVE_API_TOKEN.", + config_path.display() + ); + } + Ok(()) +} + fn resolve_assistant_root(value: &str, config_path: &Path) -> Result { let expanded = expand_home(value); if expanded.trim().is_empty() { @@ -781,6 +854,7 @@ pub enum ChannelKind { IMessage, Telegram, Slack, + Missive, } impl ChannelKind { @@ -789,8 +863,9 @@ impl ChannelKind { "imessage" => Ok(Self::IMessage), "telegram" => Ok(Self::Telegram), "slack" => Ok(Self::Slack), + "missive" => Ok(Self::Missive), other => bail!( - "invalid channel {other:?}; expected \"imessage\", \"telegram\", or \"slack\"" + "invalid channel {other:?}; expected \"imessage\", \"telegram\", \"slack\", or \"missive\"" ), } } @@ -800,6 +875,7 @@ impl ChannelKind { Self::IMessage => "imessage", Self::Telegram => "telegram", Self::Slack => "slack", + Self::Missive => "missive", } } } @@ -908,6 +984,9 @@ mod tests { slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(), + missive_api_token: None, + missive_conversation_ids: Vec::new(), + missive_allow_user_ids: Vec::new(), voice_openai_api_key: None, voice_name: DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(), diff --git a/src/doctor.rs b/src/doctor.rs index 4d4461f..adc8cf2 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -6,7 +6,9 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Result}; use crate::{channel::Channel, config, history, jobs}; -use config::{SLACK_APP_TOKEN_ENV, SLACK_BOT_TOKEN_ENV, TELEGRAM_BOT_TOKEN_ENV}; +use config::{ + MISSIVE_API_TOKEN_ENV, SLACK_APP_TOKEN_ENV, SLACK_BOT_TOKEN_ENV, TELEGRAM_BOT_TOKEN_ENV, +}; /// Fails fast with actionable messages when the environment is not ready. pub fn preflight(cfg: &config::Config) -> Result<()> { @@ -73,6 +75,7 @@ fn run_checks(cfg: &config::Config) -> CheckReport { config::ChannelKind::IMessage => check_imessage_db(cfg, &mut checks), config::ChannelKind::Telegram => check_telegram_config(cfg, &mut checks), config::ChannelKind::Slack => check_slack_config(cfg, &mut checks), + config::ChannelKind::Missive => check_missive_config(cfg, &mut checks), } } } @@ -173,6 +176,7 @@ fn check_secret_config_permissions(cfg: &config::Config, checks: &mut Vec cfg.telegram_bot_token.as_deref(), cfg.slack_app_token.as_deref(), cfg.slack_bot_token.as_deref(), + cfg.missive_api_token.as_deref(), ] .into_iter() .flatten() @@ -212,7 +216,7 @@ fn check_config(cfg: &config::Config, checks: &mut Vec) { checks.push(Check::pass( "config", format!( - "channels={}, agent={}, assistant_root={}, imessage.self_handles={}, imessage.allow_from={}, telegram.allow_user_ids={}, telegram.allow_chat_ids={}, slack.allow_user_ids={}", + "channels={}, agent={}, assistant_root={}, imessage.self_handles={}, imessage.allow_from={}, telegram.allow_user_ids={}, telegram.allow_chat_ids={}, slack.allow_user_ids={}, missive.conversation_ids={}, missive.allow_user_ids={}", cfg.enabled_channel_kinds() .map(|channels| channels.into_iter().map(|kind| kind.as_str()).collect::>().join(",")) .unwrap_or_else(|_| cfg.channel.clone()), @@ -222,7 +226,9 @@ fn check_config(cfg: &config::Config, checks: &mut Vec) { cfg.allow_from.len(), cfg.telegram_allow_user_ids.len(), cfg.telegram_allow_chat_ids.len(), - cfg.slack_allow_user_ids.len() + cfg.slack_allow_user_ids.len(), + cfg.missive_conversation_ids.len(), + cfg.missive_allow_user_ids.len() ), )); } @@ -345,6 +351,22 @@ fn check_slack_config(cfg: &config::Config, checks: &mut Vec) { } } +fn check_missive_config(cfg: &config::Config, checks: &mut Vec) { + if cfg.missive_token().is_some() { + checks.push(Check::pass( + "Missive API token", + format!("loaded from config or {MISSIVE_API_TOKEN_ENV}"), + )); + } else { + checks.push(Check::fail( + "Missive API token", + format!( + "not configured. Set {MISSIVE_API_TOKEN_ENV} or missive.api_token without printing it." + ), + )); + } +} + fn ensure_writable_dir(dir: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dir)?; let probe = dir.join(format!(".push-doctor-write-test-{}", std::process::id())); diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index b1fdd38..d34f608 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -2755,6 +2755,9 @@ fn test_config(state_path: &str, _sessions_dir: &str, assistant_dir: &str) -> Co slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(), + missive_api_token: None, + missive_conversation_ids: Vec::new(), + missive_allow_user_ids: Vec::new(), voice_openai_api_key: None, voice_name: crate::config::DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(), diff --git a/src/main.rs b/src/main.rs index 2ccac1f..14b31ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -//! push is a tiny iMessage gateway for personal assistant agents. It polls the -//! macOS Messages database for new messages, sends each through a configured -//! coding-agent backend, and texts the reply back. +//! Push is a small messaging gateway for personal assistant agents. It accepts +//! allowlisted messages from built-in channels, sends each through a configured +//! coding-agent backend, and returns the reply to the originating conversation. mod agent; mod approval; @@ -16,6 +16,7 @@ mod history; mod imessage; mod jobs; mod markdown; +mod missive; mod pi; mod rehydration; mod restart; @@ -824,6 +825,11 @@ app_token = "xapp-config" bot_token = "xoxb-config" allow_user_ids = ["U1"] +[missive] +api_token = "missive-config" +conversation_ids = ["conv-1"] +allow_user_ids = ["user-1"] + [voice] openai_api_key = "config-openai-key" name = "onyx" @@ -840,6 +846,9 @@ name = "onyx" assert_eq!(cfg.slack_app_token.as_deref(), Some("xapp-config")); assert_eq!(cfg.slack_bot_token.as_deref(), Some("xoxb-config")); assert_eq!(cfg.slack_allow_user_ids, ["U1"]); + assert_eq!(cfg.missive_api_token.as_deref(), Some("missive-config")); + assert_eq!(cfg.missive_conversation_ids, ["conv-1"]); + assert_eq!(cfg.missive_allow_user_ids, ["user-1"]); assert_eq!( cfg.voice_openai_api_key.as_deref(), Some("config-openai-key") @@ -869,6 +878,43 @@ allow_user_ids = [] let _ = std::fs::remove_file(path); } + #[test] + fn missive_config_requires_explicit_allowlists_and_safe_polling() { + let missing_users = temp_path("missive-user-allowlist-config"); + std::fs::write( + &missing_users, + r#"channel = "missive" +[missive] +conversation_ids = ["conversation-1"] +allow_user_ids = [] +"#, + ) + .unwrap(); + let error = Config::load(missing_users.to_str().unwrap()).unwrap_err(); + assert!(error + .to_string() + .contains("set missive.allow_user_ids to explicit Missive user IDs")); + + let fast_polling = temp_path("missive-poll-rate-config"); + std::fs::write( + &fast_polling, + r#"channel = "missive" +poll_interval = "1s" +[missive] +conversation_ids = ["conversation-1", "conversation-2"] +allow_user_ids = ["user-1"] +"#, + ) + .unwrap(); + let error = Config::load(fast_polling.to_str().unwrap()).unwrap_err(); + assert!(error + .to_string() + .contains("one second per Missive conversation")); + + let _ = std::fs::remove_file(missing_users); + let _ = std::fs::remove_file(fast_polling); + } + #[test] fn voice_config_defaults_to_cedar_and_rejects_unknown_names() { let default_path = temp_path("default-voice-config"); diff --git a/src/missive.rs b/src/missive.rs new file mode 100644 index 0000000..75d403e --- /dev/null +++ b/src/missive.rs @@ -0,0 +1,569 @@ +//! Missive REST API input and output using outbound-only polling. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use reqwest::{Client, Response, StatusCode}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Deserialize; +use serde_json::json; +use tokio::sync::Mutex as AsyncMutex; + +use crate::channel::RawMessage; + +const API_BASE: &str = "https://public.missiveapp.com/v1"; +const MAX_POST_CHARS: usize = 8_000; +const COMMENTS_PER_PAGE: usize = 10; +const MAX_PAGES_PER_POLL: usize = 100; + +#[derive(Clone)] +pub struct Missive { + token: String, + conversation_ids: Vec, + conversation_set: HashSet, + allow_user_ids: HashSet, + inbox: Arc>, + client: Client, + api_base: String, + request_gate: Arc>>, + request_interval: Duration, +} + +struct Inbox { + connection: Connection, + path: String, +} + +#[derive(Debug, Deserialize)] +struct CommentsResponse { + #[serde(default)] + comments: Vec, +} + +#[derive(Debug, Deserialize)] +struct Comment { + id: String, + #[serde(default)] + body: String, + created_at: i64, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct Author { + id: String, +} + +impl Missive { + pub fn new( + token: String, + conversation_ids: Vec, + allow_user_ids: Vec, + state_path: &str, + ) -> Result { + let inbox_path = format!("{state_path}.missive-inbox.db"); + Self::with_api_base( + token, + conversation_ids, + allow_user_ids, + &inbox_path, + API_BASE.to_string(), + ) + } + + fn with_api_base( + token: String, + conversation_ids: Vec, + allow_user_ids: Vec, + inbox_path: &str, + api_base: String, + ) -> Result { + let conversation_ids = conversation_ids + .into_iter() + .map(|id| id.trim().to_string()) + .collect::>(); + Ok(Self { + token, + conversation_set: conversation_ids.iter().cloned().collect(), + conversation_ids, + allow_user_ids: allow_user_ids + .into_iter() + .map(|id| id.trim().to_string()) + .collect(), + inbox: Arc::new(Mutex::new(Inbox::open(inbox_path)?)), + client: Client::builder() + .timeout(Duration::from_secs(25)) + .build() + .context("build Missive HTTP client")?, + api_base, + request_gate: Arc::new(AsyncMutex::new(None)), + request_interval: if cfg!(test) { + Duration::ZERO + } else { + Duration::from_secs(1) + }, + }) + } + + pub fn allows_user(&self, user: &str) -> bool { + self.allow_user_ids.contains(user) + } + + pub fn allows_conversation(&self, conversation: &str) -> bool { + self.conversation_set.contains(conversation) + } + + pub async fn poll(&self, since: i64) -> Result> { + self.refresh(true).await?; + self.inbox.lock().unwrap().after(since) + } + + pub async fn latest_cursor(&self) -> Result { + // Seed the local deduplication inbox before returning the cursor so a + // first run never replays existing Missive history. + self.refresh(false).await?; + self.inbox.lock().unwrap().latest_cursor() + } + + pub async fn send_message(&self, conversation: &str, text: &str) -> Result<()> { + if !self.allows_conversation(conversation) { + bail!("Missive delivery conversation is not allowlisted"); + } + let body = json!({ + "posts": { + "conversation": conversation, + "markdown": text, + "username": "Push", + "notification": { + "title": "Push", + "body": "Assistant replied" + } + } + }); + let url = format!("{}/posts", self.api_base.trim_end_matches('/')); + let response = self + .send_with_retry(|| self.client.post(&url).bearer_auth(&self.token).json(&body)) + .await + .context("create Missive post")?; + if !response.status().is_success() { + bail!("Missive create post failed with HTTP {}", response.status()); + } + Ok(()) + } + + async fn refresh(&self, paginate: bool) -> Result<()> { + for conversation in &self.conversation_ids { + self.refresh_conversation(conversation, paginate).await?; + } + Ok(()) + } + + async fn refresh_conversation(&self, conversation: &str, paginate: bool) -> Result<()> { + let mut until = None; + let mut pending = Vec::new(); + let mut complete = false; + for _ in 0..MAX_PAGES_PER_POLL { + let response = self.fetch_comments(conversation, until).await?; + if response.comments.is_empty() { + complete = true; + break; + } + let page_len = response.comments.len(); + let mut reached_known_event = false; + let mut oldest = i64::MAX; + let mut newest = i64::MIN; + for comment in response.comments { + oldest = oldest.min(comment.created_at); + newest = newest.max(comment.created_at); + let known = self.inbox.lock().unwrap().contains(&comment.id)?; + reached_known_event |= known; + if !known { + pending.push(comment); + } + } + if !paginate || reached_known_event || page_len < COMMENTS_PER_PAGE || oldest == newest + { + complete = true; + break; + } + until = Some(oldest); + } + if !complete { + bail!("Missive comment pagination exceeded the per-poll safety limit"); + } + + pending.sort_by_key(|comment| comment.created_at); + let mut inbox = self.inbox.lock().unwrap(); + for comment in pending { + let author = comment + .author + .as_ref() + .map(|author| author.id.as_str()) + .unwrap_or_default(); + let allowed = self.allows_user(author); + let text = if allowed { comment.body.as_str() } else { "" }; + inbox.insert( + &comment.id, + conversation, + author, + text, + !comment.body.trim().is_empty() && !author.is_empty(), + )?; + } + Ok(()) + } + + async fn fetch_comments( + &self, + conversation: &str, + until: Option, + ) -> Result { + let url = format!( + "{}/conversations/{conversation}/comments", + self.api_base.trim_end_matches('/') + ); + let response = self + .send_with_retry(|| { + let request = self.client.get(&url).bearer_auth(&self.token); + match until { + Some(value) => request.query(&[("until", value)]), + None => request, + } + }) + .await + .context("list Missive comments")?; + let status = response.status(); + if !status.is_success() { + bail!("Missive list comments failed with HTTP {status}"); + } + response + .json() + .await + .with_context(|| format!("decode Missive comments response ({status})")) + } + + async fn send_with_retry( + &self, + request: impl Fn() -> reqwest::RequestBuilder, + ) -> Result { + let mut previous = self.request_gate.lock().await; + if let Some(last_request) = *previous { + let remaining = self.request_interval.saturating_sub(last_request.elapsed()); + if !remaining.is_zero() { + tokio::time::sleep(remaining).await; + } + } + let request_started = Instant::now(); + *previous = Some(request_started); + let mut response = request().send().await.context("call Missive API")?; + if response.status() == StatusCode::TOO_MANY_REQUESTS { + let delay = retry_after(response.headers()).max( + self.request_interval + .saturating_sub(request_started.elapsed()), + ); + tokio::time::sleep(delay).await; + *previous = Some(Instant::now()); + response = request().send().await.context("retry Missive API")?; + } + Ok(response) + } +} + +impl Inbox { + fn open(path: &str) -> Result { + if let Some(parent) = Path::new(path).parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create Missive inbox directory {}", parent.display()))?; + } + let connection = + Connection::open(path).with_context(|| format!("open Missive inbox {path}"))?; + crate::util::restrict_permissions(Path::new(path), false) + .with_context(|| format!("restrict Missive inbox permissions {path}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .context("configure Missive inbox busy timeout")?; + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS missive_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + comment_id TEXT NOT NULL UNIQUE, + conversation_id TEXT NOT NULL, + user_id TEXT NOT NULL, + text TEXT NOT NULL, + is_supported INTEGER NOT NULL + );", + )?; + Ok(Self { + connection, + path: path.to_string(), + }) + } + + fn contains(&self, comment_id: &str) -> Result { + self.connection + .query_row( + "SELECT 1 FROM missive_comments WHERE comment_id = ?1", + [comment_id], + |_| Ok(()), + ) + .optional() + .map(|value| value.is_some()) + .context("check Missive comment deduplication state") + } + + fn insert( + &mut self, + comment_id: &str, + conversation_id: &str, + user_id: &str, + text: &str, + is_supported: bool, + ) -> Result<()> { + self.connection.execute( + "INSERT INTO missive_comments ( + comment_id, conversation_id, user_id, text, is_supported + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(comment_id) DO NOTHING", + params![comment_id, conversation_id, user_id, text, is_supported], + )?; + Ok(()) + } + + fn latest_cursor(&self) -> Result { + self.connection + .query_row("SELECT MAX(id) FROM missive_comments", [], |row| row.get(0)) + .optional()? + .flatten() + .map_or(Ok(0), Ok) + } + + fn after(&self, since: i64) -> Result> { + let mut statement = self.connection.prepare( + "SELECT id, comment_id, conversation_id, user_id, text, is_supported + FROM missive_comments WHERE id > ?1 ORDER BY id", + )?; + let messages = statement + .query_map([since], |row| { + Ok(RawMessage { + row_id: row.get(0)?, + provider_event_id: Some(row.get(1)?), + channel: "missive", + chat_identifier: row.get(2)?, + handle: row.get(3)?, + text: row.get(4)?, + is_supported: row.get(5)?, + is_group: false, + voice: None, + is_from_me: false, + thread_id: None, + }) + })? + .collect::, _>>() + .with_context(|| format!("read pending Missive inbox events from {}", self.path))?; + Ok(messages) + } +} + +fn retry_after(headers: &reqwest::header::HeaderMap) -> Duration { + let seconds = headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(1); + Duration::from_secs(seconds) +} + +pub fn split_text(text: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_chars = 0; + for character in text.chars() { + if current_chars == MAX_POST_CHARS { + chunks.push(std::mem::take(&mut current)); + current_chars = 0; + } + current.push(character); + current_chars += 1; + } + if !current.is_empty() || chunks.is_empty() { + chunks.push(current); + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + async fn server(responses: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let counter = Arc::new(AtomicUsize::new(0)); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let mut buffer = vec![0; 16_384]; + let size = stream.read(&mut buffer).await.unwrap(); + captured + .lock() + .unwrap() + .push(String::from_utf8_lossy(&buffer[..size]).to_string()); + let index = counter.fetch_add(1, Ordering::SeqCst); + let response = responses.get(index).or_else(|| responses.last()).unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + (format!("http://{address}/v1"), requests) + } + + fn http_json(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn rate_limited() -> String { + "HTTP/1.1 429 Too Many Requests\r\nretry-after: 0\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}".to_string() + } + + fn client(api_base: String) -> Missive { + let inbox = + std::env::temp_dir().join(format!("push-missive-test-{}.db", uuid::Uuid::new_v4())); + Missive::with_api_base( + "secret-token".to_string(), + vec!["conv-1".to_string()], + vec!["user-1".to_string()], + inbox.to_str().unwrap(), + api_base, + ) + .unwrap() + } + + #[tokio::test] + async fn polls_deduplicates_and_redacts_unallowlisted_comments() { + let body = r#"{"comments":[{"id":"c2","body":"private","created_at":2,"author":{"id":"user-2"}},{"id":"c1","body":"hello","created_at":1,"author":{"id":"user-1"}}]}"#; + let (api, _) = server(vec![http_json("200 OK", body)]).await; + let missive = client(api); + + let messages = missive.poll(0).await.unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].text, "hello"); + assert_eq!(messages[1].text, ""); + assert_eq!(messages[1].handle, "user-2"); + let cursor = messages.last().unwrap().row_id; + assert!(missive.poll(cursor).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn first_cursor_skips_existing_history() { + let body = + r#"{"comments":[{"id":"c1","body":"old","created_at":1,"author":{"id":"user-1"}}]}"#; + let (api, _) = server(vec![http_json("200 OK", body)]).await; + let missive = client(api); + + let cursor = missive.latest_cursor().await.unwrap(); + assert!(cursor > 0); + assert!(missive.poll(cursor).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn pagination_uses_the_oldest_timestamp_and_preserves_chronology() { + let comments = (2..=11) + .rev() + .map(|created_at| { + format!( + r#"{{"id":"c{created_at}","body":"{created_at}","created_at":{created_at},"author":{{"id":"user-1"}}}}"# + ) + }) + .collect::>() + .join(","); + let first = format!(r#"{{"comments":[{comments}]}}"#); + let second = + r#"{"comments":[{"id":"c1","body":"1","created_at":1,"author":{"id":"user-1"}}]}"#; + let (api, requests) = server(vec![ + http_json("200 OK", &first), + http_json("200 OK", second), + ]) + .await; + let missive = client(api); + + let messages = missive.poll(0).await.unwrap(); + assert_eq!(messages.len(), 11); + assert_eq!(messages.first().unwrap().text, "1"); + assert_eq!(messages.last().unwrap().text, "11"); + assert!(requests.lock().unwrap()[1] + .starts_with("GET /v1/conversations/conv-1/comments?until=2 HTTP/1.1")); + } + + #[tokio::test] + async fn posts_wrapped_markdown_to_the_exact_allowlisted_conversation() { + let (api, requests) = server(vec![http_json("201 Created", "{}")]).await; + let missive = client(api); + + missive.send_message("conv-1", "**hello**").await.unwrap(); + let request = &requests.lock().unwrap()[0]; + assert!(request.starts_with("POST /v1/posts HTTP/1.1")); + assert!(request.contains("authorization: Bearer secret-token")); + assert!(request.contains(r#""conversation":"conv-1""#)); + assert!(request.contains(r#""markdown":"**hello**""#)); + assert!(!request.contains("secret-token\"")); + } + + #[tokio::test] + async fn retries_one_rate_limited_request_without_exposing_the_response() { + let (api, requests) = server(vec![ + rate_limited(), + http_json("201 Created", r#"{"posts":{"id":"post-1"}}"#), + ]) + .await; + let mut missive = client(api); + missive.request_interval = Duration::from_millis(20); + let started = Instant::now(); + + missive.send_message("conv-1", "hello").await.unwrap(); + + assert!(started.elapsed() >= Duration::from_millis(20)); + assert_eq!(requests.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn failed_requests_still_advance_the_rate_gate() { + let (api, requests) = server(vec![ + String::new(), + http_json("201 Created", r#"{"posts":{"id":"post-1"}}"#), + ]) + .await; + let mut missive = client(api); + missive.request_interval = Duration::from_millis(30); + + assert!(missive.send_message("conv-1", "first").await.is_err()); + assert!(tokio::time::timeout( + Duration::from_millis(5), + missive.send_message("conv-1", "second") + ) + .await + .is_err()); + + tokio::time::sleep(Duration::from_millis(30)).await; + missive.send_message("conv-1", "second").await.unwrap(); + assert_eq!(requests.lock().unwrap().len(), 2); + } + + #[test] + fn splitting_is_unicode_safe_and_bounded() { + let chunks = split_text(&format!("{}🦀", "a".repeat(MAX_POST_CHARS))); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), MAX_POST_CHARS); + assert_eq!(chunks[1], "🦀"); + } +} diff --git a/src/test_support.rs b/src/test_support.rs index 9c43967..48c16ea 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -57,6 +57,9 @@ pub fn test_config() -> crate::config::Config { slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(), + missive_api_token: None, + missive_conversation_ids: Vec::new(), + missive_allow_user_ids: Vec::new(), voice_openai_api_key: None, voice_name: crate::config::DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(),