{"id":"7e64b3d2-aa82-4103-a790-6f497f5c868b","slug":"pushpush","kind":"skill","name":"pushpush","summary":"Send messages to Telegram, Slack, or Discord from Python.","owner":"seokhoonj","repoUrl":"https://github.com/seokhoonj/pushpush","license":"MIT","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-07-24T09:06:19.000Z","addedAt":"2026-10-21T10:19:04.677Z","logoUrl":"https://avatars.githubusercontent.com/u/12861878?u=67769c897207038c322bbf31ac1a86d345776e1d&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["communication","developer-tools"],"tags":["chatops","cli","discord","messaging","notifications","python","slack","telegram"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T03:59:17.837Z","sample":false,"url":"https://conduits.dev/tools/pushpush","language":"Python","transports":["stdio"],"install":{"pypi":"pushpush"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# pushpush\n\n[![check](https://github.com/seokhoonj/pushpush/actions/workflows/check.yml/badge.svg)](https://github.com/seokhoonj/pushpush/actions/workflows/check.yml)\n[![PyPI](https://img.shields.io/pypi/v/pushpush)](https://pypi.org/project/pushpush/)\n[![Python](https://img.shields.io/pypi/pyversions/pushpush)](https://pypi.org/project/pushpush/)\n[![License](https://img.shields.io/pypi/l/pushpush)](https://github.com/seokhoonj/pushpush/blob/main/LICENSE)\n\n**English** | [한국어](README.ko.md)\n\nSend messages to Telegram, Slack, or Discord from Python. Send text and a single\nfile, and save the destinations you use often under a name (a *route*) so you can\ncall them by name.\n\n```python\nfrom pushpush import send\n\nsend(\"chip supply crash -- take a look\", to=\"alerts\")\nsend(media=\"chart.png\", caption=\"today\", to=\"alerts\")\n```\n\nUsing 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)\n\nWorks on Windows, macOS, and Linux. Its one runtime dependency is credbox (the\ncredential store), itself zero-dependency; messages go over the standard library's\n`urllib`.\n\n## At a glance\n\nOne `send()` resolves the route, finds the secret, lets the provider frame the\nrequest, refuses anything the service could not carry before the network is\ntouched, and returns the result as a `SendReceipt`.\n\n```mermaid\nflowchart TB\n    caller[\"send(text, to='alerts')\"] --> cfg[\"load_config()<br/>config.toml\"]\n    cfg --> route[\"Route<br/>provider + destination\"]\n    route --> secret[\"resolve_secret()<br/>env / credentials.json\"]\n    secret --> validate{\"Provider.validate()<br/>can the service carry it?\"}\n    validate -->|\"no\"| refuse[\"raise<br/>(at the call site)\"]\n    validate -->|\"yes\"| frame[\"send_text / send_media<br/>frame the request\"]\n    frame --> http[\"http.post_json / post_multipart<br/>urllib\"]\n    http --> service((\"Telegram<br/>Slack<br/>Discord\"))\n    service --> receipt[\"SendReceipt<br/>route · message_id · response\"]\n```\n\n## What you can send, and where\n\n| Service | Text | File | Credential |\n|---|---|---|---|\n| Telegram | Yes | Yes (photo/document) | bot token + chat_id |\n| Discord | Yes | Yes (webhook attachment) | webhook URL |\n| Slack | Yes | Bot token only | webhook URL or bot token (xoxb) |\n\nSlack files go only through a **bot token** (with the `files:write` scope), and\nthe route's `destination` must be a channel id (`C…`). An incoming webhook has no\nfile upload at all -- a media push on a webhook route is refused clearly with\n`MediaUnsupportedError`, so send it via Telegram or Discord, or put a link in the\ntext.\n\n### Limits, checked before the send\n\nEach service publishes fixed limits; pushpush checks them at the call site and\nrefuses an over-limit push -- `InvalidPushError` for text or a caption,\n`MediaTooLargeError` for a file -- rather than letting it fail mid-flight.\n\n| Service | Text | Media caption | File |\n|---|---|---|---|\n| Telegram | 4096 chars | 1024 chars | 50 MB (10 MB for a photo) |\n| Discord | 2000 chars | 2000 chars | 8 MB |\n| Slack | no local cap | no local cap | workspace limit |\n\nA caption is the words that ride with a file. Because a media caption often has a\nlower cap than a plain-text message (Telegram: 1024 vs 4096), attaching media can\nlower the effective text limit -- the same words that fit as text may be refused\nas a caption. Slack accepts long text and splits or blocks it itself, so pushpush\ndoes not pre-check its length.\n\n## Requirements\n\n- **Python 3.11 or newer.** Check with `python --version` in a terminal. (On\n  Windows it may be `py --version`.)\n- **A credential for the service you send to** -- a bot token or a webhook URL.\n  See [4. Getting credentials](#4-getting-credentials).\n\n## 1. Install\n\npushpush's one runtime dependency is credbox, itself zero-dependency; nothing else\ncomes along.\n\n```sh\npip install pushpush\n```\n\nCheck it worked:\n\n```sh\npushpush --version\n```\n\n<details>\n<summary>From source (for development)</summary>\n\n```sh\ngit clone https://github.com/seokhoonj/pushpush.git\ncd pushpush\npython -m venv .venv\nsource .venv/bin/activate    # Windows: .venv\\Scripts\\activate\npip install -e \".[dev]\"\n```\n\n</details>\n\n## 2. Configure a route\n\nA route saves a \"which service, and where\" pair under a name. To send, you call\nthe name (`to=\"alerts\"`). Create it at `.config/pushpush/config.toml` under your\nhome directory.\n\n| | Path (default) |\n|---|---|\n| macOS · Linux | `~/.config/pushpush/config.toml` |\n| Windows | `C:\\Users\\<username>\\.config\\pushpush\\config.toml` |\n\nThese are the defaults: set an absolute `XDG_CONFIG_HOME` to move the directory, or\n`PUSHPUSH_CONFIG` to point at one config file elsewhere. Create the folder if it does\nnot exist. The contents:\n\n```toml\ndefault_route = \"alerts\"\n\n[routes.alerts]\nprovider    = \"telegram\"\ndestination = \"123456789\"    # the chat_id the bot messages (the token is a secret, kept elsewhere)\n\n[routes.team]\nprovider = \"slack\"           # a webhook needs no destination (the URL carries the channel)\n\n[routes.trades]\nprovider = \"discord\"         # the webhook URL points to the channel too\n```\n\nWhere `destination` is needed and where it is not:\n\n- **Telegram** -- always needed. The chat_id the bot sends to.\n- **Discord** -- not needed. The webhook URL already points to the channel.\n- **Slack** -- a bot token (xoxb) needs a channel (`destination = \"#alerts\"`).\n  A webhook URL does not.\n\nIf there is only one route, `default_route` can be omitted.\n\n## 3. Store the credential\n\nA token or webhook URL is a secret, so it lives not in the config file but in a\nseparate **0600-permission file**. Don't write it in plain text in a conversation\nor a script -- enter it in your own terminal with `getpass`:\n\n```sh\npython -c \"\nfrom getpass import getpass\nfrom pushpush import load_config, store_secret\nroute = load_config().resolve_route('alerts')\nstore_secret(route, getpass('secret for alerts: '))\nprint('stored')\n\"\n```\n\nIt is saved to `~/.config/pushpush/credentials.json` (mode 0600). Once entered,\nyou are not asked again. For a container or a one-off run, you can supply it via\nan environment variable instead of a file:\n\n```sh\nexport PUSHPUSH_SECRET_ALERTS=\"bot-token-or-webhook-URL\"\n```\n\nWith several routes, use `PUSHPUSH_SECRET_<ROUTE>` -- a bare `PUSHPUSH_SECRET`\ncannot say which route it is for, so one service's token could go to another's.\n\n## 4. Getting credentials\n\n### Telegram\n\n1. In Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather) to create\n   a bot. At the end it gives you a **bot token** (`123456:ABC-...`) -- that is\n   the secret.\n2. Start a chat with your bot (a bot cannot message you first -- that is\n   Telegram's rule).\n3. Find the chat_id: send your bot any message, then open\n   `https://api.telegram.org/bot<token>/getUpdates` and read `chat.id`. That\n   number is the `destination`.\n\n### Discord\n\nChannel settings → Integrations → Webhooks → New Webhook → **Copy Webhook URL**.\nThat whole URL is the secret, and no `destination` is needed.\n\n### Slack\n\n- **The simple way -- webhook**: create a per-channel webhook URL at\n  [Incoming Webhooks](https://api.slack.com/messaging/webhooks). The whole URL is\n  the secret; no `destination`.\n- **Bot token**: create an app, grant it `chat:write` (and `files:write` to send\n  files), and get a bot token (`xoxb-...`). Then put the channel in `destination`\n  -- a name like `#alerts` for text, or the channel id `C…` when you send files.\n\n## 5. Sending\n\n```python\nfrom pushpush import send\n\n# text\nreceipt = send(\"market close -- KOSPI +1.2%\", to=\"alerts\")\nprint(receipt.message_id)\n\n# a file (a photo goes inline, anything else as a document)\nsend(media=\"report.pdf\", caption=\"daily report\", to=\"alerts\")\n\n# formatting (Telegram: plain/markdown/html; Slack, Discord: plain/markdown)\nsend(\"<b>bold</b>\", to=\"alerts\", markup=\"html\")\n\n# without a notification sound\nsend(\"nightly batch done\", to=\"alerts\", silent=True)\n```\n\nOmit `to` and it goes to `default_route`. A send needs at least one of `text` or\n`media`.\n\n### What comes back\n\n`send` returns a `SendReceipt` -- where it went, plus **the service's whole raw\nreply**:\n\n```python\nreceipt = send(\"hi\", to=\"alerts\")\nreceipt.route        # \"alerts\"\nreceipt.provider     # \"telegram\"\nreceipt.message_id   # the id the service gave the message (when it returns one)\nreceipt.response     # the full service reply (read-only)\n```\n\n## 6. From the shell\n\nInstalling pushpush also gives you a `pushpush` command -- a thin wrapper over\n`send()` for scripts and cron.\n\n```sh\npushpush send \"deploy finished\" --to alerts\npushpush send --media chart.png --to alerts       # the piped/positional text captions it\necho \"batch done\" | pushpush send --to alerts     # text from stdin\npushpush routes                                   # list the configured routes\n```\n\nThe options: `-t/--to` names the route (default: `default_route`), `-m/--media`\nattaches a file, `-c/--caption` labels it (use either the message text or `--caption`\nfor a media label, not both), `--markup plain|markdown|html` picks the rendering, and\n`-s/--silent` delivers without a sound.\n\nIt reads the same config and secrets as the Python API. Unlike the Python call it\ndoes not confirm before sending -- it is for automation. For an interactive,\nconfirm-before-send flow, use the Claude Code skill below.\n\n## 7. Failures are caught before sending\n\nA message the service could not carry is stopped at the call site, before the\nnetwork -- rather than a bad send arriving later as a silent non-delivery, it\nraises right there.\n\n| Exception | When |\n|---|---|\n| `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 |\n| `MediaError` / `MediaTooLargeError` | the file is missing or not a file / over the service's limit |\n| `MediaUnsupportedError` | the route cannot carry a file (a Slack incoming webhook -- use a bot token) |\n| `MarkupUnsupportedError` | the service does not render that markup (html is Telegram only) |\n| `MissingSecretError` | the route has no secret |\n| `SendFailedError` | the service was reached and refused -- a revoked token, a wrong chat_id, etc. Carries the service's own reason |\n| `ConfigError` | the config file is missing or malformed (`UnknownRouteError` and `UnknownProviderError`, for a route or provider not in the config, are kinds of it) |\n| `CredentialsError` | the stored secret could not be read (a malformed or unreadable store) |\n| `urllib.error.URLError` | the network itself failed -- DNS, a refused connection, a timeout |\n\nTo catch everything:\n\n```python\nimport urllib.error\nfrom pushpush import send, PushpushError\n\ntry:\n    send(\"hi\", to=\"alerts\")\nexcept (PushpushError, urllib.error.URLError) as err:\n    print(\"could not send:\", err)\n```\n\n## 8. Use it from an AI coding agent\n\nThis repo ships a `send` skill: describe what to send in plain words (\"send this\nto Telegram\") and it confirms the route, shows you the content, and sends only\nafter you approve.\n\n### 8.1 Claude Code\n\nIn the Claude Code chat, add the marketplace and install:\n\n```\n/plugin marketplace add seokhoonj/pushpush\n/plugin install pushpush@pushpush\n```\n\nThen invoke it with `/pushpush:send`, or just ask in plain language. The skill calls\nthe `pushpush` command, so install the package too (step 1). See `plugins/pushpush/skills/send/SKILL.md`.\n\n### 8.2 Codex\n\nIn your terminal, add the marketplace and install:\n\n```\ncodex plugin marketplace add seokhoonj/pushpush\ncodex plugin add pushpush@pushpush\n```\n\nThe `send` skill responds automatically to matching requests.\n\n### 8.3 By hand (symlink)\n\nPrefer no plugin? Symlink the skill into your skills directory and call it as\n`/send`:\n\n```sh\nln -s \"$PWD/plugins/pushpush/skills/send\" ~/.claude/skills/send   # Claude Code -> /send\nln -s \"$PWD/plugins/pushpush/skills/send\" ~/.codex/skills/send    # Codex -> $pushpush:send\n```\n\nClaude Code picks it up immediately; Codex needs a restart to load it.\n"}