# Pushover A GenServer client for the Pushover push notification service with alarm integration for scheduled delivery. --- ## Table of Contents 1. [Overview](#overview) 2. [Features](#features) 3. [Configuration](#configuration) 4. [Usage](#usage) 5. [API Reference](#api-reference) 6. [Internals / Flow](#internals--flow) 7. [Troubleshooting](#troubleshooting) 8. [Related Documentation](#related-documentation) --- ## Overview Pushover is a registered GenServer that sends push notifications to mobile devices via the [Pushover API](https://pushover.net/api). It supports immediate delivery (async and sync) and scheduled delivery through the Alarm module. When an alarm fires, Pushover receives a `{:send, payload}` cast, sends the notification, and reports the outcome back to Alarm via `Alarm.completed/2` for end-to-end observability. The module loads API credentials from application config on startup and includes automatic retry on transient HTTP errors. ## Features | Feature | Description | |---------|-------------| | Async notifications | Fire-and-forget with `send/1` — returns immediately | | Sync notifications | Wait for delivery confirmation with `send_sync/1` | | Alarm integration | Schedule notifications for the future with `set/2` and `set/3` | | Completion reporting | Reports success/failure back to Alarm for observability | | Priority levels | All Pushover priorities (-2 to 2) including emergency | | HTTP retry | Transient request failures retried once automatically | | Credential safety | `state/0` redacts API tokens for safe inspection | ## Configuration Credentials are loaded from the `:mcp` application config: ```elixir config :mcp, pushover_token: "your_api_token", pushover_user: "your_user_key" ``` In production, override via environment variables `PUSHOVER_TOKEN` and `PUSHOVER_USER`. If either credential is missing at startup, the GenServer stops with `{:stop, :missing_credentials}`. ## Message Payload The payload map supports the following keys: | Key | Type | Required | Description | |-----|------|----------|-------------| | `:message` | string | Yes | The notification body text | | `:title` | string | No | Title displayed above the message | | `:priority` | integer | No | Priority level (-2 to 2, default 0) | | `:url` | string | No | Supplementary URL to include | | `:url_title` | string | No | Title for the supplementary URL | | `:sound` | string | No | Notification sound name (default `"magic"`) | | `:alarm_id` | integer | No | Alarm ID for completion reporting (set automatically by Alarm) | ### Priority Levels | Level | Name | Behavior | |-------|------|----------| | -2 | Lowest | No notification or alert | | -1 | Low | No sound or vibration | | 0 | Normal | Default notification | | 1 | High | Bypasses quiet hours | | 2 | Emergency | Requires user acknowledgment | ## Usage ### Send an immediate notification ```elixir Pushover.send(%{message: "Server is down!", title: "Alert", priority: 1}) ``` ### Send synchronously and wait for result ```elixir {:ok, response} = Pushover.send_sync(%{title: "Test", message: "Testing sync delivery"}) ``` ### Schedule a notification for later ```elixir Pushover.set("tomorrow at 9am", "Good morning!") Pushover.set("in 30 minutes", "Take a break!", "Reminder") ``` ### Send with a URL ```elixir Pushover.send(%{ title: "New Article", message: "Check out this post", url: "https://example.com/article", url_title: "Read More" }) ``` ### Inspect state (credentials redacted) ```elixir Pushover.state() # => %{token: "***", user: "***"} ``` ## API Reference ### `start_link/1` Starts the Pushover GenServer. Reads credentials from `:mcp` application config. **Parameters:** - `opts` (keyword) — currently unused **Returns:** `{:ok, pid}` or `{:stop, :missing_credentials}` ```elixir {:ok, pid} = Pushover.start_link() ``` --- ### `send/1` Sends a push notification asynchronously (fire and forget). Returns immediately without waiting for the API response. **Parameters:** - `payload` (map) — must contain `:message` key **Returns:** `:ok` or `{:error, :missing_message}` ```elixir Pushover.send(%{message: "Hello!"}) Pushover.send(%{title: "Alert", message: "CPU at 95%", priority: 1}) ``` --- ### `send_sync/1` Sends a push notification synchronously and waits for the API response. **Parameters:** - `payload` (map) — must contain `:message` key **Returns:** - `{:ok, response}` — JSON response body from Pushover API - `{:error, {:http_error, status_code, body}}` — HTTP error - `{:error, {:request_failed, reason}}` — network/connection error - `{:error, {:exception, error}}` — unexpected exception - `{:error, :missing_message}` — payload missing `:message` ```elixir {:ok, response} = Pushover.send_sync(%{title: "Test", message: "Sync test"}) ``` --- ### `set/2` Schedules a push notification for a future time using the Alarm module. **Parameters:** - `chronic_str` (string) — natural language time (e.g. `"in 30 minutes"`, `"tomorrow at 9am"`) - `message` (string) — notification body text **Returns:** `{:ok, alarm_id}` or `{:error, reason}` ```elixir Pushover.set("in 5 minutes", "Take a break!") Pushover.set("tomorrow at 8am", "Good morning!") ``` --- ### `set/3` Schedules a push notification with a title for a future time. **Parameters:** - `chronic_str` (string) — natural language time - `message` (string) — notification body text - `title` (string) — notification title **Returns:** `{:ok, alarm_id}` or `{:error, reason}` ```elixir Pushover.set("next monday at 9am", "Weekly standup in 10 minutes", "Reminder") ``` --- ### `state/0` Returns the current GenServer state with API credentials redacted. **Returns:** `%{token: "***", user: "***"}` ```elixir Pushover.state() # => %{token: "***", user: "***"} ``` ## Internals / Flow ### Immediate Send (async) ``` Pushover.send(%{message: "text"}) ├── GenServer.cast(Pushover, {:send, payload}) ├── send_message(payload, state) │ ├── Merge payload with %{sound: "magic", token: ..., user: ...} │ ├── Req.post("https://api.pushover.net/1/messages.json", form: full_payload) │ ├── On transient failure → retry once after 1s │ └── Return {:ok, body} or {:error, reason} ├── If payload has :alarm_id → Alarm.completed(alarm_id, result_str) └── {:noreply, state} ``` ### Scheduled Send (via Alarm) ``` Pushover.set("tomorrow at 9am", "Good morning!", "Greeting") ├── Alarm.parse_timestamp("tomorrow at 9am") → unix_timestamp ├── Alarm.set_timer(unix_timestamp, Pushover, {:send, %{message: ..., title: ...}}) └── Return {:ok, alarm_id} [Alarm fires at scheduled time] ├── Timer sends GenServer.cast(Pushover, {:send, %{message: ..., title: ...}}) ├── Pushover.handle_cast({:send, payload}, state) ├── send_message(payload, state) → HTTP POST to Pushover API ├── If :alarm_id present → Alarm.completed(alarm_id, "ok" or "FAILED: reason") └── {:noreply, state} ``` ### HTTP Retry Logic The `send_message/3` function retries once on `{:error, {:request_failed, _}}` after a 1-second delay. Non-transient errors (HTTP 4xx/5xx, exceptions) are not retried. ``` send_message(payload, state, retries \\ 1) ├── POST to Pushover API ├── {:error, {:request_failed, _}} AND retries > 0 │ ├── Process.sleep(1_000) │ └── send_message(payload, state, retries - 1) └── Otherwise return result as-is ``` ## Troubleshooting **`{:stop, :missing_credentials}` on startup** - Ensure `:pushover_token` and `:pushover_user` are set in `config/config.exs` or via environment variables - Check: `Application.get_env(:mcp, :pushover_token)` **Notifications not arriving** - Verify credentials are valid at [pushover.net/apps](https://pushover.net/apps) - Check logs for `[Pushover] Sending:` entries — if absent, the cast was never received - Check for `{:error, {:http_error, 401, _}}` — indicates invalid token or user key **Scheduled notification did not send** - Check `Alarm.get(alarm_id)` — verify the alarm fired (`started_at` is set) - Check `result` field — `"ok"` means it sent, `"FAILED:"` prefix means delivery failed - If `result` is `"timeout_unknown"` — Pushover did not report back; check network connectivity **`{:error, :missing_message}` returned** - The payload map must contain a `:message` key: `%{message: "text"}` - A payload with only `:title` is not valid ## Related Documentation - [Alarm](alarm.md) — persistent scheduler used by `set/2` and `set/3` - [Timer](timer.md) — single-timer manager that delivers alarm messages - [Permissions.Platform](permissions_platform.md) — MCP tool `pushover_schedule` permission wrapper --- *Source: `lib/pushover.ex` — Last updated: 2026-03-03*