Metadata-Version: 2.4
Name: lingtai-telegram
Version: 0.2.0
Summary: LingTai Telegram MCP server — bot API client with LICC inbox callback.
Project-URL: Homepage, https://github.com/Lingtai-AI/lingtai-telegram
Project-URL: Repository, https://github.com/Lingtai-AI/lingtai-telegram
Project-URL: LingTai, https://lingtai.ai
Author-email: Zesen Huang <hzsbazinga@outlook.com>
License: MIT
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Requires-Dist: mcp>=1.0.0
Description-Content-Type: text/markdown

# lingtai-telegram

LingTai Telegram MCP server — Bot API client with multi-account support and LICC inbox callback.

This is the canonical setup, configuration, and troubleshooting doc for the `lingtai-telegram` MCP. It is fetched by LingTai agents (or anyone else) when they need to install or configure this server.

> **MCP / LICC contract spec:** see the `lingtai-anatomy` skill, `reference/mcp-protocol.md`, for the canonical specification of the catalog → registry → activation chain, environment-variable injection, and the LICC v1 inbox callback protocol. The reference client implementation is `src/lingtai_telegram/licc.py` in this repo (vendored verbatim into all first-party LingTai MCP repos — copy it if you're writing your own).

## Tools

One omnibus MCP tool: `telegram(action=...)`. Actions: `send`, `check`, `read`, `reply`, `search`, `delete`, `edit`, `contacts`, `add_contact`, `remove_contact`, `accounts`. Compound message IDs: `account_alias:chat_id:message_id`.

## Typing indicators

The `send` action accepts an optional `chat_action` parameter that maps directly to Telegram's [`sendChatAction`](https://core.telegram.org/bots/api#sendchataction) API. When `chat_action` is present and **no `text`/`media` is provided**, the MCP sends a status indicator (e.g. "myagent is typing...") instead of an actual message. Useful for acknowledging long-running work — web searches, avatar dispatch, large file generation — before the real reply arrives.

Valid values:

- `typing` — for text replies being composed.
- `upload_photo` — for photo uploads in progress.
- `upload_document` — for document uploads in progress.
- `upload_voice` — for voice messages being prepared.

Example — show "typing..." while preparing a reply:

```json
{
  "action": "send",
  "account": "myagent",
  "chat_id": 123456789,
  "chat_action": "typing"
}
```

**Important:** Telegram auto-expires the indicator after 5 seconds. For tasks longer than that, re-send the chat action every ~4 seconds in a loop until the real reply is ready. There is no "stop typing" call — sending the actual message implicitly clears the indicator.

The chat-action path skips duplicate-message protection and is **not** persisted to `telegram/<account>/sent/`, since it produces no message.

## Streaming responses

For long-running tasks (>5s) the bot can send a placeholder message immediately and edit it in place once the real result is ready. This avoids the perception of silence while the agent is working.

Pattern:

```python
# 1. Send a placeholder. The bot also fires `sendChatAction(typing)` so
#    the user sees "is typing…" alongside the placeholder text.
res = telegram(
    action="send",
    chat_id=123456789,
    text="⏳ Working on it…",
    placeholder=True,
)
# res == {"status": "sent", "message_id": "myagent:123456789:42",
#         "placeholder": True, "hint": "..."}

# 2. Do the work (LLM call, computation, web fetch, etc.)
final_text = run_long_task()

# 3. Edit the placeholder in place with the final result.
telegram(action="edit", message_id=res["message_id"], text=final_text)
```

Notes:

- `placeholder=True` is a hint flag — it does not change the on-the-wire send, only fires a typing chat action and tags the response with `placeholder: true` plus a follow-up `hint` string for the agent.
- The compound `message_id` returned by `send` is the same `account:chat_id:message_id` form used everywhere else and is the input to `edit`/`delete`/`reply`.
- Telegram throttles edits to ~1/sec per chat. If you stream partial updates, debounce on the agent side; the MCP does not buffer.
- Typing indicators auto-expire after ~5s. For very long tasks, re-call `send(placeholder=True, …)` is overkill — better to update the placeholder text periodically via `edit`, which itself surfaces activity to the user.

## Inline keyboards

`send` and `edit` accept an optional `reply_markup` argument that attaches an inline keyboard (rows of buttons) to the outgoing message. When the user taps a button, Telegram sends back a `callback_query` update — this MCP delivers it to the agent inbox as a LICC event with `metadata.type == "callback_query"` and `metadata.callback_data` set to the button's `callback_data` string. The Telegram spinner is auto-dismissed by `account.py:_process_update`, so agents don't need to call `answerCallbackQuery` themselves.

The `reply_markup` shape Telegram expects is:

```json
{"inline_keyboard": [[{"text": "Yes", "callback_data": "yes"}, {"text": "No", "callback_data": "no"}]]}
```

i.e. a list of rows, each row a list of buttons. `callback_data` is capped at 64 bytes by Telegram — keep it short and dispatch on it from the agent side.

### Examples

**Yes / No**

```json
{
  "action": "send",
  "chat_id": 123456789,
  "text": "Approve the deploy?",
  "reply_markup": {
    "inline_keyboard": [[
      {"text": "Yes", "callback_data": "yes"},
      {"text": "No",  "callback_data": "no"}
    ]]
  }
}
```

**Approve / Reject**

```json
{
  "action": "send",
  "chat_id": 123456789,
  "text": "Pull request #42 ready for review.",
  "reply_markup": {
    "inline_keyboard": [[
      {"text": "Approve", "callback_data": "approve"},
      {"text": "Reject",  "callback_data": "reject"}
    ]]
  }
}
```

**Option list (3+ buttons, one per row)**

```json
{
  "action": "send",
  "chat_id": 123456789,
  "text": "Pick a city:",
  "reply_markup": {
    "inline_keyboard": [
      [{"text": "Tokyo", "callback_data": "city:tokyo"}],
      [{"text": "Osaka", "callback_data": "city:osaka"}],
      [{"text": "Kyoto", "callback_data": "city:kyoto"}]
    ]
  }
}
```

### Helper builders (Python)

If you're composing `reply_markup` from Python (e.g. inside an MCP that wraps this one, or a script that calls `TelegramAccount.send_message` directly), the package exposes three small dict builders:

```python
from lingtai_telegram import (
    inline_keyboard_yes_no,
    inline_keyboard_approve_reject,
    inline_keyboard_options,
)

acct.send_message(chat_id, "Approve the deploy?",
                  reply_markup=inline_keyboard_yes_no())

acct.send_message(chat_id, "PR #42 ready for review.",
                  reply_markup=inline_keyboard_approve_reject())

acct.send_message(chat_id, "Pick a city:",
                  reply_markup=inline_keyboard_options([
                      {"text": "Tokyo", "data": "city:tokyo"},
                      {"text": "Osaka", "data": "city:osaka"},
                      {"text": "Kyoto", "data": "city:kyoto"},
                  ]))
```

All three return plain dicts in the shape Telegram expects — they're just sugar over the JSON above.

## Inbound messages (LICC)

Inbound Telegram updates flow into the host agent's inbox via the LingTai Inbox Callback Contract. Each new message is delivered as a LICC event with:

- `from` — username (or first_name as fallback).
- `subject` — `"telegram message from <user> via <account_alias>"` (voice messages: `"telegram voice message from <user> via <account_alias> (transcribed)"`).
- `body` — a ~300 char preview of the message text (use `telegram(action="check"|"read")` to see the full conversation).
- `metadata.type` — one of `"message"`, `"callback_query"`, `"edited_message"`. Use this to dispatch button presses (callback_query) separately from free-text messages.
- `metadata.message_id` — compound ID for `reply`/`delete`/`edit`.
- `metadata.account` — which configured bot received it.
- `metadata.chat_id`, `metadata.has_media`, `metadata.has_callback` — routing flags.
- `metadata.callback_data` — for `callback_query` events, the `callback_data` string from the tapped button (the same value you set in `reply_markup` when sending). `null` for plain messages.
- `metadata.is_voice_transcript` — `true` if the message was a voice/audio message that was transcribed.
- `metadata.voice_duration` — duration in seconds for voice messages (null for non-voice).

### Voice messages

Voice and audio messages from Telegram are automatically transcribed using [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (local Whisper model). The transcribed text is delivered as the message body, so agents receive voice input as regular text.

**How it works:**
1. User sends a voice message to the bot
2. MCP downloads the voice file (`.oga` format)
3. Transcribes using faster-whisper (local, no API key needed)
4. Delivers the transcribed text as the message body
5. Original audio file is preserved in `telegram/<account>/inbox/<msg_id>/attachments/`

**Metadata includes:**
- `metadata.is_voice_transcript: true` — indicates this was a voice message
- `metadata.voice_duration` — duration in seconds
- `media.type: "voice"` or `"audio"` — media type
- `voice_transcript` — full transcript with language detection and segments

**Fallback:** If transcription fails (e.g., missing dependencies), the message body will be:
```
[Voice message received — transcription failed: <error>]
```

**Dependencies:** `faster-whisper` is auto-installed on first transcription. For manual installation:
```bash
pip install faster-whisper
```

## Install

```bash
# Into the LingTai agent's venv (typically ~/.lingtai-tui/runtime/venv/)
pip install git+https://github.com/Lingtai-AI/lingtai-telegram.git
```

After install, `python -m lingtai_telegram` (or the `lingtai-telegram` script) starts the MCP server over stdio.

## Configure

The server reads its bot config from a JSON file pointed at by `LINGTAI_TELEGRAM_CONFIG`. Recommended path: `.secrets/telegram.json` inside the agent's working directory. Plaintext only — this MCP does not support `*_env` indirection.

### Config schema

```json
{
  "accounts": [
    {
      "alias": "myagent",
      "bot_token": "1234567890:ABCdefGhIJklMNOpqRSTuvwxyz",
      "allowed_users": [123456789, 987654321],
      "poll_interval": 1.0,
      "commands": [
        {"command": "status", "description": "Show agent status"},
        {"command": "help", "description": "List available commands"}
      ]
    }
  ]
}
```

- `alias` — human-friendly name for this account; used as the `account` parameter in tool calls.
- `bot_token` — issued by [@BotFather](https://t.me/BotFather). Format: `<bot_id>:<auth_string>`.
- `allowed_users` — optional allow-list of Telegram user IDs (integers). When set, updates from other users are silently ignored. Omit to accept any sender.
- `poll_interval` — seconds between getUpdates long-polls (default 1.0).
- `commands` — optional list of slash commands to register with @BotFather via `setMyCommands` on bot startup. Each entry is `{"command": "<name>", "description": "<text>"}` (command name without the leading `/`, lowercase, max 32 chars; description 1-256 chars). When omitted, a default set is registered: `/status`, `/help`, `/kanban`, `/brief`, `/clear`. Pass an explicit empty list `[]` to clear the menu. Registration is best-effort — failures are logged but do not block bot startup.

### Activation in LingTai

```json
{
  "addons": ["telegram"],
  "mcp": {
    "telegram": {
      "type": "stdio",
      "command": "/path/to/your/python",
      "args": ["-m", "lingtai_telegram"],
      "env": {
        "LINGTAI_TELEGRAM_CONFIG": ".secrets/telegram.json"
      }
    }
  }
}
```

Then run `system(action="refresh")` from the agent. The MCP subprocess starts, the per-account poll threads begin, and the omnibus `telegram` tool becomes available.

## Troubleshooting

- **`LINGTAI_TELEGRAM_CONFIG env var not set`** — your `init.json` `mcp.telegram.env` entry is missing the `LINGTAI_TELEGRAM_CONFIG` key.
- **`Telegram config not found`** — the path resolves but no file exists. Relative paths are resolved against `LINGTAI_AGENT_DIR`.
- **`Unauthorized: invalid token specified`** — wrong or revoked bot token. Re-issue via [@BotFather](https://t.me/BotFather) (`/mybots` → token).
- **Server boots but no inbound messages** — bot privacy mode may be on. In @BotFather: `/setprivacy` → `Disable` (allows the bot to see all messages in groups). Direct messages always work.
- **`MCP server failed to start`** — usually the `command` path in `init.json` doesn't have `lingtai_telegram` installed. Confirm with `<command> -m lingtai_telegram --help` from a shell.
- **Tool calls return `Telegram manager not initialized`** — server boot failed (most often a bad token). Check stderr for the underlying exception, fix the config, then `system(action="refresh")`.

## License

MIT.
