Metadata-Version: 2.4
Name: pushpush
Version: 0.3.1
Summary: Send a message to Telegram, Slack, or Discord through one call, with the service's own limits checked before anything is sent.
Author-email: Seokhoon Joo <seokhoonj@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.11
Requires-Dist: credbox<0.5,>=0.4.0
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# pushpush

[![check](https://github.com/seokhoonj/pushpush/actions/workflows/check.yml/badge.svg)](https://github.com/seokhoonj/pushpush/actions/workflows/check.yml)
[![PyPI](https://img.shields.io/pypi/v/pushpush)](https://pypi.org/project/pushpush/)
[![Python](https://img.shields.io/pypi/pyversions/pushpush)](https://pypi.org/project/pushpush/)
[![License](https://img.shields.io/pypi/l/pushpush)](https://github.com/seokhoonj/pushpush/blob/main/LICENSE)

**English** | [한국어](README.ko.md)

Send messages to Telegram, Slack, or Discord from Python. Send text and a single
file, and save the destinations you use often under a name (a *route*) so you can
call them by name.

```python
from pushpush import send

send("chip supply crash -- take a look", to="alerts")
send(media="chart.png", caption="today", to="alerts")
```

Using Claude Code? You can send just by asking, no Python needed → [Use it from an AI coding agent](#8-use-it-from-an-ai-coding-agent)

Works on Windows, macOS, and Linux. Its one runtime dependency is credbox (the
credential store), itself zero-dependency; messages go over the standard library's
`urllib`.

## At a glance

One `send()` resolves the route, finds the secret, lets the provider frame the
request, refuses anything the service could not carry before the network is
touched, and returns the result as a `SendReceipt`.

```mermaid
flowchart TB
    caller["send(text, to='alerts')"] --> cfg["load_config()<br/>config.toml"]
    cfg --> route["Route<br/>provider + destination"]
    route --> secret["resolve_secret()<br/>env / credentials.json"]
    secret --> validate{"Provider.validate()<br/>can the service carry it?"}
    validate -->|"no"| refuse["raise<br/>(at the call site)"]
    validate -->|"yes"| frame["send_text / send_media<br/>frame the request"]
    frame --> http["http.post_json / post_multipart<br/>urllib"]
    http --> service(("Telegram<br/>Slack<br/>Discord"))
    service --> receipt["SendReceipt<br/>route · message_id · response"]
```

## What you can send, and where

| Service | Text | File | Credential |
|---|---|---|---|
| Telegram | Yes | Yes (photo/document) | bot token + chat_id |
| Discord | Yes | Yes (webhook attachment) | webhook URL |
| Slack | Yes | Bot token only | webhook URL or bot token (xoxb) |

Slack files go only through a **bot token** (with the `files:write` scope), and
the route's `destination` must be a channel id (`C…`). An incoming webhook has no
file upload at all -- a media push on a webhook route is refused clearly with
`MediaUnsupportedError`, so send it via Telegram or Discord, or put a link in the
text.

### Limits, checked before the send

Each service publishes fixed limits; pushpush checks them at the call site and
refuses an over-limit push -- `InvalidPushError` for text or a caption,
`MediaTooLargeError` for a file -- rather than letting it fail mid-flight.

| Service | Text | Media caption | File |
|---|---|---|---|
| Telegram | 4096 chars | 1024 chars | 50 MB (10 MB for a photo) |
| Discord | 2000 chars | 2000 chars | 8 MB |
| Slack | no local cap | no local cap | workspace limit |

A caption is the words that ride with a file. Because a media caption often has a
lower cap than a plain-text message (Telegram: 1024 vs 4096), attaching media can
lower the effective text limit -- the same words that fit as text may be refused
as a caption. Slack accepts long text and splits or blocks it itself, so pushpush
does not pre-check its length.

## Requirements

- **Python 3.11 or newer.** Check with `python --version` in a terminal. (On
  Windows it may be `py --version`.)
- **A credential for the service you send to** -- a bot token or a webhook URL.
  See [4. Getting credentials](#4-getting-credentials).

## 1. Install

pushpush's one runtime dependency is credbox, itself zero-dependency; nothing else
comes along.

```sh
pip install pushpush
```

Check it worked:

```sh
pushpush --version
```

<details>
<summary>From source (for development)</summary>

```sh
git clone https://github.com/seokhoonj/pushpush.git
cd pushpush
python -m venv .venv
source .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
```

</details>

## 2. Configure a route

A route saves a "which service, and where" pair under a name. To send, you call
the name (`to="alerts"`). Create it at `.config/pushpush/config.toml` under your
home directory.

| | Path (default) |
|---|---|
| macOS · Linux | `~/.config/pushpush/config.toml` |
| Windows | `C:\Users\<username>\.config\pushpush\config.toml` |

These are the defaults: set an absolute `XDG_CONFIG_HOME` to move the directory, or
`PUSHPUSH_CONFIG` to point at one config file elsewhere. Create the folder if it does
not exist. The contents:

```toml
default_route = "alerts"

[routes.alerts]
provider    = "telegram"
destination = "123456789"    # the chat_id the bot messages (the token is a secret, kept elsewhere)

[routes.team]
provider = "slack"           # a webhook needs no destination (the URL carries the channel)

[routes.trades]
provider = "discord"         # the webhook URL points to the channel too
```

Where `destination` is needed and where it is not:

- **Telegram** -- always needed. The chat_id the bot sends to.
- **Discord** -- not needed. The webhook URL already points to the channel.
- **Slack** -- a bot token (xoxb) needs a channel (`destination = "#alerts"`).
  A webhook URL does not.

If there is only one route, `default_route` can be omitted.

## 3. Store the credential

A token or webhook URL is a secret, so it lives not in the config file but in a
separate **0600-permission file**. Don't write it in plain text in a conversation
or a script -- enter it in your own terminal with `getpass`:

```sh
python -c "
from getpass import getpass
from pushpush import load_config, store_secret
route = load_config().resolve_route('alerts')
store_secret(route, getpass('secret for alerts: '))
print('stored')
"
```

It is saved to `~/.config/pushpush/credentials.json` (mode 0600). Once entered,
you are not asked again. For a container or a one-off run, you can supply it via
an environment variable instead of a file:

```sh
export PUSHPUSH_SECRET_ALERTS="bot-token-or-webhook-URL"
```

With several routes, use `PUSHPUSH_SECRET_<ROUTE>` -- a bare `PUSHPUSH_SECRET`
cannot say which route it is for, so one service's token could go to another's.

## 4. Getting credentials

### Telegram

1. In Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather) to create
   a bot. At the end it gives you a **bot token** (`123456:ABC-...`) -- that is
   the secret.
2. Start a chat with your bot (a bot cannot message you first -- that is
   Telegram's rule).
3. Find the chat_id: send your bot any message, then open
   `https://api.telegram.org/bot<token>/getUpdates` and read `chat.id`. That
   number is the `destination`.

### Discord

Channel settings → Integrations → Webhooks → New Webhook → **Copy Webhook URL**.
That whole URL is the secret, and no `destination` is needed.

### Slack

- **The simple way -- webhook**: create a per-channel webhook URL at
  [Incoming Webhooks](https://api.slack.com/messaging/webhooks). The whole URL is
  the secret; no `destination`.
- **Bot token**: create an app, grant it `chat:write` (and `files:write` to send
  files), and get a bot token (`xoxb-...`). Then put the channel in `destination`
  -- a name like `#alerts` for text, or the channel id `C…` when you send files.

## 5. Sending

```python
from pushpush import send

# text
receipt = send("market close -- KOSPI +1.2%", to="alerts")
print(receipt.message_id)

# a file (a photo goes inline, anything else as a document)
send(media="report.pdf", caption="daily report", to="alerts")

# formatting (Telegram: plain/markdown/html; Slack, Discord: plain/markdown)
send("<b>bold</b>", to="alerts", markup="html")

# without a notification sound
send("nightly batch done", to="alerts", silent=True)
```

Omit `to` and it goes to `default_route`. A send needs at least one of `text` or
`media`.

### What comes back

`send` returns a `SendReceipt` -- where it went, plus **the service's whole raw
reply**:

```python
receipt = send("hi", to="alerts")
receipt.route        # "alerts"
receipt.provider     # "telegram"
receipt.message_id   # the id the service gave the message (when it returns one)
receipt.response     # the full service reply (read-only)
```

## 6. From the shell

Installing pushpush also gives you a `pushpush` command -- a thin wrapper over
`send()` for scripts and cron.

```sh
pushpush send "deploy finished" --to alerts
pushpush send --media chart.png --to alerts       # the piped/positional text captions it
echo "batch done" | pushpush send --to alerts     # text from stdin
pushpush routes                                   # list the configured routes
```

The options: `-t/--to` names the route (default: `default_route`), `-m/--media`
attaches a file, `-c/--caption` labels it (use either the message text or `--caption`
for a media label, not both), `--markup plain|markdown|html` picks the rendering, and
`-s/--silent` delivers without a sound.

It reads the same config and secrets as the Python API. Unlike the Python call it
does not confirm before sending -- it is for automation. For an interactive,
confirm-before-send flow, use the Claude Code skill below.

## 7. Failures are caught before sending

A message the service could not carry is stopped at the call site, before the
network -- rather than a bad send arriving later as a silent non-delivery, it
raises right there.

| Exception | When |
|---|---|
| `InvalidPushError` | nothing to send (no text, no media), a caption without media, a media send carrying both text and caption (only one can label the file), a destination needed but absent, or the text or caption over the service's length limit |
| `MediaError` / `MediaTooLargeError` | the file is missing or not a file / over the service's limit |
| `MediaUnsupportedError` | the route cannot carry a file (a Slack incoming webhook -- use a bot token) |
| `MarkupUnsupportedError` | the service does not render that markup (html is Telegram only) |
| `MissingSecretError` | the route has no secret |
| `SendFailedError` | the service was reached and refused -- a revoked token, a wrong chat_id, etc. Carries the service's own reason |
| `ConfigError` | the config file is missing or malformed (`UnknownRouteError` and `UnknownProviderError`, for a route or provider not in the config, are kinds of it) |
| `CredentialsError` | the stored secret could not be read (a malformed or unreadable store) |
| `urllib.error.URLError` | the network itself failed -- DNS, a refused connection, a timeout |

To catch everything:

```python
import urllib.error
from pushpush import send, PushpushError

try:
    send("hi", to="alerts")
except (PushpushError, urllib.error.URLError) as err:
    print("could not send:", err)
```

## 8. Use it from an AI coding agent

This repo ships a `send` skill: describe what to send in plain words ("send this
to Telegram") and it confirms the route, shows you the content, and sends only
after you approve.

### 8.1 Claude Code

In the Claude Code chat, add the marketplace and install:

```
/plugin marketplace add seokhoonj/pushpush
/plugin install pushpush@pushpush
```

Then invoke it with `/pushpush:send`, or just ask in plain language. The skill calls
the `pushpush` command, so install the package too (step 1). See `plugins/pushpush/skills/send/SKILL.md`.

### 8.2 Codex

In your terminal, add the marketplace and install:

```
codex plugin marketplace add seokhoonj/pushpush
codex plugin add pushpush@pushpush
```

The `send` skill responds automatically to matching requests.

### 8.3 By hand (symlink)

Prefer no plugin? Symlink the skill into your skills directory and call it as
`/send`:

```sh
ln -s "$PWD/plugins/pushpush/skills/send" ~/.claude/skills/send   # Claude Code -> /send
ln -s "$PWD/plugins/pushpush/skills/send" ~/.codex/skills/send    # Codex -> $pushpush:send
```

Claude Code picks it up immediately; Codex needs a restart to load it.
