Skip to main content
Glama
mikedelcastillo

watush-mcp

README.md
# watush-mcp

A robust Telegram [MCP](https://modelcontextprotocol.io) server for agents.
Any agent — **Claude Code**, **Claude Desktop**, or **Codex** — can use it to
**message**, **send files**, **update**, **clean up after**, and **ask questions
of** you or **named people** (partner, family, friends) through your own
Telegram bot.

So you can say:

> "message me on telegram once the build is done"
> "send me a screenshot of the dashboard"
> "telegram me the report as a PDF when it's written"
> "keep me updated on the GPU temps in one telegram message"
> "ping the family: dinner at 7"
> "message me on watush if you need anything while I'm out"

…and it routes to the right person. You (the **owner**) always get an **audit
note** for anything sent to, edited for, deleted from, or asked of someone else.

Configure **several bot tokens** and you get several **channels** — separate
Telegram threads you can mute or read independently ("use the 2nd bot for the
noisy stuff") — plus **automatic failover** when one bot is rate-limited or its
token dies. See [Channels](#channels-several-bots).

It **cannot browse your chats**: the only thing it ever reads is the reply to a
question it is actively waiting on.

---

## How it works

- **Seven tools** are exposed to the agent:
  - `send_telegram_message({ text, to?, channel? })` — send a message. `to` is a
    person's **name** or a **tag** (case-insensitive). Omit `to` (or use `"me"`)
    to message yourself. A tag shared by several people fans out to all of them.
    `channel` picks which bot to send through. Returns a **handle** like
    `4021@main`.
  - `send_telegram_file({ path, caption?, to?, channel?, as? })` — send **any file**:
    screenshots, images, video, PDFs, `.md`/`.docx`/`.csv`, logs, archives.
    `path` is a file **on the machine running the server** (or an `http(s)`
    URL for Telegram to fetch). The kind is picked from the extension —
    images preview inline, `.mp4` plays, everything else arrives as a
    downloadable attachment — and `as` overrides that. See
    [Sending files](#sending-files).
  - `edit_telegram_message({ message_id, text, to? })` — rewrite a sent message
    in place. Perfect for **live status messages**: one message that keeps
    updating instead of a wall of notifications. No time limit. On a message
    sent by `send_telegram_file`, this rewrites its **caption**.
  - `delete_telegram_message({ message_id, to? })` — remove a sent message to
    cut noise. Telegram allows this within **48 hours** of sending.
  - `ask_telegram_question({ question, options?, to?, timeout_seconds? })` —
    ask a question with **tap-to-answer buttons** (or free-text replies — you
    can always just type instead). The agent blocks until you answer; your tap
    or reply is returned to it directly. The question message updates itself to
    show what you answered.
  - `list_recipients()` — lets the agent discover who/what tags it can address
    (names + tags only; chat IDs are never exposed).
  - `list_channels()` — lets the agent discover which bots exist, their positions
    and tags, and which are currently rate-limited or unable to reach someone
    (tokens are never exposed).
- **Markdown formatting** works in all message/question text: `**bold**`,
  `*italic*`, `` `code` ``, fenced blocks, `[links](url)`, `~~strike~~`,
  headers. Rendered as native Telegram formatting, with an automatic
  plain-text fallback so a message is never lost to a formatting error.
- **Timing is the agent's job.** "in 30 minutes" is handled by the agent that
  calls the tool; the tool itself always sends *now*.
- **No background process.** Stdio MCP servers are spawned on demand by each
  client, so there's nothing to "start on login" — installing simply registers
  the server with your agents so it's available whenever they run.

---

## Setup

### 1. Install (build + wire all agents)

```bash
cd ~/Code/watush-mcp
./install.sh
```

This installs dependencies, compiles the TypeScript, creates `config.yaml` (if
missing), and registers the server with Claude Code, Codex, and Claude Desktop —
whichever are present. Existing agent configs are backed up first. Re-run it any
time (e.g. after a Node version change).

### 2. Configure `config.yaml`

`config.yaml` holds your secrets and is **git-ignored** — it is never committed.
Copy the example if you don't have one yet:

```bash
cp config.example.yaml config.yaml && chmod 600 config.yaml
```

```yaml
defaultChannel: "main"
channels:                           # one bot per channel; order = fallback order
  - name: "main"
    token: "123456789:ABC..."       # from @BotFather
    tags: ["general"]
  - name: "backup1"
    token: "987654321:XYZ..."
    tags: ["backup"]
owner:
  name: "Owner"
  chatId: "111111"                  # your numeric Telegram ID (gets audit copies)
recipients:
  - name: "Alice"
    chatId: "222222"
    tags: ["partner", "household"]
  - name: "Bob"
    chatId: "333333"
    tags: ["friend", "team"]
auditToOwner: true
```

### 3. Find chat IDs

Everyone you want to message (including you) must DM the bot once — and with
several channels configured, **once per bot**. Chat IDs are global to Telegram,
but a bot cannot open a conversation on its own, so a perfectly valid chat ID
still fails on a bot that person never started. `npm run doctor` shows exactly
which pairs are missing.

You have two ways to capture the numeric IDs — neither runs in the background:

**a) On-demand `/chatinfo` listener (recommended)**

```bash
npm run chatinfo
```

This starts a temporary listener **on every channel at once**, so one pass
confirms who has started which bot. Each person DMs a bot **`/chatinfo`** and
that bot replies with their own chat ID (it's also printed in your terminal,
prefixed with the channel). Press **Ctrl-C** when you're done — it does not keep
running.

```bash
npm run chatinfo -- --once             # auto-stop after the first /chatinfo
npm run chatinfo -- --timeout=60       # auto-stop after 60 seconds
npm run chatinfo -- --channel=backup1  # listen on one channel only
```

**b) One-shot snapshot**

```bash
npm run chats
```

Prints the chat ID + name of everyone who recently messaged **any** of your bots,
and which bots saw each chat. (Reads only — never writes your config.)

Copy the right IDs into `config.yaml`, then verify the whole matrix:

```bash
npm run doctor
```

```
CHANNEL      BOT          TOKEN     Mike (you)    Meg&Mike
1. main   *  @watushbot   ok        ok            ok
2. backup1   @watush1bot  ok        ok            MISSING
3. backup2   @watush2bot  ok        ok            MISSING
```

`MISSING` means that bot cannot message that chat yet — press Start in a DM with
it, or add it to that group.

### 4. Restart Claude Desktop

If you use Claude Desktop, restart it so it loads the new server. Claude Code and
Codex pick it up on their next run.

---

## Usage

Just ask, in any agent:

> "message me on telegram: deploy finished ✅"
> "text my partner: leaving now"
> "notify the family: flight landed"

**Live-updating messages** — one message, continuously rewritten:

> "track the download and update me in a single telegram message"
> "keep a running status of the migration on watush"

**Cleanup** — the agent can delete its own noise:

> "delete those progress messages now that it's done"

**Questions** — the agent asks, you tap (or type), the agent continues:

> "message me on watush if you need anything" → later, your phone shows
> *"Deploy to prod now, or wait for the tests?"* with **[Deploy now]**
> **[Wait]** buttons. Tap one and the agent carries on with your answer.

Behind the scenes the agent calls `send_telegram_message`,
`edit_telegram_message`, `delete_telegram_message`, or
`ask_telegram_question`. Anything involving a person other than you also
sends you an audit note like:

```
🔔 Audit — sent to Alice: leaving now
✏️ Audit — edited message to Alice: updated ETA 18:30
❓ Audit — asked Alice: pizza or sushi?
```

### Sending files

> "send me a screenshot of the desktop"
> "telegram me that chart"
> "write the summary to a .md and send it over"

The agent takes the screenshot or writes the document **first**, then passes the
path. The file has to live on the machine running the server — each machine runs
its own copy, so "send me a screenshot" means *that* machine's screen.

| Extension | Arrives as | Method |
|---|---|---|
| `.jpg` `.jpeg` `.png` `.webp` `.bmp` | inline photo (recompressed) | `sendPhoto` |
| `.gif` | looping animation | `sendAnimation` |
| `.mp4` `.mov` `.m4v` `.webm` | inline video player | `sendVideo` |
| `.mp3` `.m4a` `.flac` `.aac` `.wav` | audio track | `sendAudio` |
| `.ogg` `.oga` `.opus` | voice note | `sendVoice` |
| **everything else** — `.pdf` `.md` `.docx` `.csv` `.zip` `.log` … | downloadable file | `sendDocument` |

- **`as` overrides detection.** `as: "document"` keeps an image at **full
  quality** (Telegram recompresses photos); `as: "photo"` forces an inline
  preview.
- **Limits.** Telegram caps bot uploads at **10 MB for photos** and **50 MB**
  for everything else. An oversized image is downgraded to a document
  automatically; over 50 MB is refused with a clear message.
- **Nothing gets stuck.** If Telegram rejects a file as a photo/video (odd
  dimensions, an unsupported codec), it is retried once as a document — but
  only when a different kind could actually help, never on a block or a rate
  limit.
- **Captions** take Markdown, and are trimmed to Telegram's 1024-character cap.
- **URLs** work for images, video, audio, PDF and ZIP. Telegram will not fetch
  other file types by URL — download it locally first, then send the path.
- **Audit copies.** A file sent to someone else is `copyMessage`d into your
  chat, so you get the actual media without a second upload.

### How questions work (and their limits)

- The question arrives as a normal Telegram message from your bot, with one
  button per option. Tapping answers instantly; typing a message answers
  free-form. With several questions pending in one chat, **swipe-reply** to a
  specific question message to target it; a plain message answers the newest.
- The asking agent waits as long as its `timeout_seconds` (default 15 min).
  In Claude Code, long waits are automatically moved to a background task, so
  the agent keeps working until your answer arrives.
- Several agent sessions can wait on questions at once — watush coordinates
  them so only one process polls Telegram (Telegram allows a single poller
  per bot token), and answers are routed to whichever session asked.
- **One machine at a time.** The coordination state is per-machine, but the
  bot token's update stream is global. If agents on **two different
  machines** wait on questions at the same moment, one machine's poller can
  consume (and lose) the other machine's answers, and they compete for the
  single polling slot. Keep simultaneous questions on one machine, or give
  each machine its own bot token (each token gets its own state dir
  automatically).

---

## Channels (several bots)

Each channel is its own Telegram bot, which means its own **conversation thread**.
Give the noisy things their own bot and you can mute that thread without missing
an alert. Configure as many as you like — @BotFather allows 20 per account.

```yaml
defaultChannel: "main"
channels:
  - name: "main"
    token: "..."
    tags: ["general"]
  - name: "backup1"
    token: "..."
    tags: ["backup"]
  - name: "backup2"
    token: "..."
    tags: ["backup"]
    # fallback: false   # keep this one out of automatic failover
```

**Order matters.** The list order is the failover order. Channel 1 is the
default unless `defaultChannel` names another — and a channel is always
reachable by its own name, even one literally named `main` or `default`.

### Addressing a channel

| You say…                     | `channel`      | Resolves to                        |
| ---------------------------- | -------------- | ---------------------------------- |
| "send it on backup1"         | `backup1`      | the channel named `backup1`        |
| "use the 2nd bot"            | `2nd`          | channel at position 2              |
| also `2`, `second`, `bot 2`, `#2`, `last` | | the same, by position        |
| "use a backup"               | `backup`       | the **first** channel tagged `backup` |
| *(omitted)*                  | —              | `defaultChannel`                   |

A name always wins over a position reading, so `backup1` means the channel
*named* `backup1` — even though it sits at position 2.

### Failover

A **send** walks the chain until a bot gets through, and the result says so:

```
Sent to you — message_id 4@backup1. (watush: Unauthorized — fell back to backup1.)
```

It falls over on rate limits (429), dead tokens (401/404), server errors (5xx),
network failures, and "this bot can't reach that chat". It does **not** fall over
on content errors — bad markup or an over-long message fails identically on every
bot, so retrying would only spend a second bot's quota to reproduce the error.
At most 6 bots are tried per delivery, and if that cap is reached the result
names the channels it never got to.

**Messages are at-least-once.** Telegram has no idempotency keys, so a send whose
response times out may already have arrived; retrying it on the next bot can
duplicate a notification. For messages and files that is the intended trade — a
rare double ping beats a silently lost alert, which is the entire reason for
having spare bots. Where a duplicate would be actively harmful the code does
*not* retry an ambiguous failure: `ask_telegram_question` will not re-post a
question that may already be on screen with a live keyboard, and a media audit
copy that times out is not re-sent as a text note.

**Edits and deletes never fail over.** A `message_id` belongs to exactly one
(bot, chat) pair, so `4021@main` can only be edited by `main`. If that channel is
down the call fails and says which channel owns the message — quietly re-posting
through another bot would strand the original and split a live-progress thread
across two chats mid-run.

### Handles

`send_telegram_message` returns `4021@main`, not a bare number, because with
several bots the same `message_id` genuinely exists in several threads — three
fresh bots will each mint `message_id 2` in your chat. Pass the handle back to
`edit_telegram_message` / `delete_telegram_message` and the right bot is used
automatically. A bare number is still accepted and means the default channel.

### Health memory

Failures are remembered in `~/.watush/state/channels.json` so the next send
routes around a known problem instead of rediscovering it:

- **Channel cooldown** — the bot itself is in trouble (flood-wait, dead token,
  5xx, no network). Time-based, honouring Telegram's own `retry_after`.
- **Unreachable pair** — the bot is fine but cannot reach *that one chat*
  (nobody pressed Start; it was never added to the group). Recorded per
  (channel, chat), so a bot missing from one group is never treated as down for
  your DMs. Expires after 6 h, so adding the bot to a group heals routing
  without running anything.

Nothing is ever *dropped* on the basis of this memory — unhealthy channels sink
to the back of the queue rather than being removed, so if every bot is
flood-waiting, the one recovering soonest is still tried. A successful send
clears both kinds of memory for that pair.

---

## Configuration reference

| Key                 | Meaning                                                        |
| ------------------- | ------------------------------------------------------------- |
| `channels[].name`   | Channel name, addressable as `channel`.                        |
| `channels[].token`  | That channel's bot token from @BotFather.                      |
| `channels[].tags`   | Aliases like `backup`, `noisy` (a tag picks the first match).   |
| `channels[].fallback` | `false` keeps a channel out of automatic failover.           |
| `defaultChannel`    | Channel used when the agent names none. Defaults to the first.  |
| `botToken`          | Legacy single-bot form; migrated to a one-entry channel list.   |
| `owner.name`        | Your display name.                                             |
| `owner.chatId`      | Your numeric Telegram ID. Receives audit copies.              |
| `recipients[].name` | A person's name (addressable as `to`).                         |
| `recipients[].chatId` | Their numeric Telegram ID.                                   |
| `recipients[].tags` | Aliases like `partner`, `family` (addressable as `to`).       |
| `auditToOwner`      | `true` (default) to copy the owner on messages to others.    |

**Environment overrides** (handy for CI / secrets managers):
`TELEGRAM_BOT_TOKEN` (replaces the default channel's token), `TELEGRAM_CHAT_ID`
(owner), `TELEGRAM_RECIPIENTS` and `WATUSH_CHANNELS` (JSON arrays merged over the
file, by name), and `WATUSH_CONFIG` (alternate config path).

---

## Resolving `to`

| You say…                  | `to`            | Goes to                         |
| ------------------------- | --------------- | ------------------------------- |
| "message me"              | *(omitted)*     | owner (you)                     |
| "message Alice"           | `Alice`         | Alice                           |
| "message my partner"      | `partner`       | whoever is tagged `partner`     |
| "message the family"      | `family`        | everyone tagged `family`        |
| "message Dana" (unknown)  | `Dana`          | nothing — returns a helpful error listing known names/tags |

---

## Uninstall

```bash
./uninstall.sh           # unregister from all agents (keeps config.yaml)
./uninstall.sh --purge   # also remove dist/ and node_modules/
```

---

## Security

- `config.yaml` is git-ignored and `chmod 600`. The token and chat IDs never
  leave your machine and are never committed.
- No chat browsing: there is no tool to read message history or list chats.
  Incoming messages are only read while a question is pending, and only to
  resolve that question.
- Question state (pending questions, answers, poll coordination) lives in
  `~/.watush/state/<token-hash>/`, keyed by a hash of the bot token; override
  the location with `WATUSH_STATE_DIR`.
- If your token ever leaks, rotate it in @BotFather (`/revoke`) and paste the new
  one into `config.yaml`.

**On `send_telegram_file` and file access.** The tool will read any path the
agent hands it, deliberately: restricting it to a workspace root would break its
whole purpose (screenshots land in temp dirs, documents live anywhere), and it
would buy little — an agent that can read a file could already paste the
contents through `send_telegram_message`. The file tool is a nicer envelope for
that same trust, not a new one. Two things are refused outright:

- **watush's own `config.yaml`**, so the bot token can never be uploaded to a
  chat.
- **UNC paths to hosts outside the LAN** (`\\evil.example\share\x.png`). On
  Windows, merely stat-ing one performs an SMB handshake that hands the remote
  server a crackable NTLM challenge-response. LAN shares (`\\COVE\Archive`,
  private IPs) still work.

Note that sending a file **does** upload its bytes to Telegram's servers. If you
keep data deliberately off third-party infrastructure, that applies here.

---

## Development

```bash
npm install
npm run build      # tsc -> dist/
npm test           # offline unit tests (resolution, config, fallback, audit logic)
npm run doctor     # check every token + who each bot can actually reach
npm start          # run the MCP server over stdio
```

Project layout:

```
src/config.ts       load YAML config + resolve recipients (pure, tested)
src/channels.ts     channel resolution (name/position/tag) + handles (pure, tested)
src/health.ts       per-channel cooldowns + per-chat reachability memory (tested)
src/format.ts       Markdown → Telegram HTML + plain-text fallback (pure, tested)
src/telegram.ts     Telegram Bot API client: send/edit/delete/callbacks/updates
src/deliver.ts      resolve + send/edit/delete + audit orchestration (pure, tested)
src/questions.ts    question engine: shared state, poll lock, answer spool
src/mcp-server.ts   the MCP server (seven tools)
src/doctor.ts       "npm run doctor" token + reachability matrix
src/list-chats.ts   read-only "npm run chats" helper
src/listen.ts       on-demand "npm run chatinfo" listener
bin/watush-mcp-server  launcher that resolves node for GUI apps / nvm
scripts/            config editors used by install/uninstall
```

MIT licensed.

TDQS

A4.6/5.0

Scored across 2 tools

Disambiguation5/5

The two tools are clearly distinct: one lists recipients, the other sends messages. No overlap or ambiguity.

Naming Consistency5/5

Both tools follow a consistent verb_noun pattern (list_recipients and send_telegram_message), making the naming predictable.

Tool Count5/5

Two tools are exactly appropriate for a focused Telegram messaging server. Each tool has a clear, indispensable role.

Completeness5/5

The toolset covers the full lifecycle of sending a Telegram message: discover recipients and send messages. No obvious gaps given the intended scope.

Maintenance

ActivitySlowing
ResponsivenessNo issues