Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 72 additions & 3 deletions src/skills/telegram/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description: |
Use when Bub needs to: (1) Send a message to a Telegram user/group/channel,
(2) Reply to a specific Telegram message with reply_to_message_id,
(3) Edit an existing Telegram message, or (4) Push proactive Telegram notifications.
Prefer rich formatted messages (headings, lists, tables, blockquotes, math, collapsible details, captioned media) over plain markdown whenever the content is more than one short paragraph.
metadata:
channel: telegram
---
Expand All @@ -25,13 +26,45 @@ Collect these before execution:
- message content (required for send/edit)
- `reply_to_message_id` (required when you need a threaded reply)

## Rich Messages (Bot API 10.1+, June 2026) — **PREFERRED**

For any message that benefits from structured formatting — headings, bullet/numbered lists, tables, blockquotes, collapsible `<details>`, captioned media, math blocks, code blocks with language hints, or mixed media — use the dedicated `telegram_rich.py` script with `rich-html-style` markup. This is the **first-choice** path; fall back to plain markdown (`telegram_send.py`) only for short single-paragraph replies.

The Rich Message API uses `sendRichMessage` and `editMessageText` with a `rich_message` payload. Exactly one of `html` or `markdown` must be supplied. Supported HTML tags include `<b>`, `<i>`, `<u>`, `<s>`, `<tg-spoiler>`, `<code>`, `<pre>`, `<mark>`, `<sub>`, `<sup>`, `<h1>`-`<h6>`, `<p>`, `<ul>`/`<ol>`/`<li>`, `<blockquote>`, `<aside>`, `<details>`/`<summary>`, `<table>`, `<figure>`/`<figcaption>`, `<img>`/`<video>`/`<audio>`, `<tg-collage>`, `<tg-slideshow>`, `<tg-map>`, `<tg-math>`, `<tg-math-block>`, `<tg-reference>`, `<tg-emoji>`, `<tg-time>`. Full reference: https://core.telegram.org/bots/api#rich-html-style

```bash
# Send rich message (HTML)
uv run ./scripts/telegram_rich.py \
--chat-id <CHAT_ID> \
--html "<h2>Title</h2><p>Body with <b>bold</b> and <code>code</code>.</p>"

# Send rich message (HTML from heredoc via file)
uv run ./scripts/telegram_rich.py \
--chat-id <CHAT_ID> \
--html-file /tmp/payload.html \
--reply-to <MESSAGE_ID>

# Send rich message (Markdown instead of HTML)
uv run ./scripts/telegram_rich.py \
--chat-id <CHAT_ID> \
--markdown "## Title\n\n- item one\n- item two"

# Edit an existing message to become a rich message
uv run ./scripts/telegram_rich.py \
--chat-id <CHAT_ID> \
--edit <MESSAGE_ID> \
--html "<p>Updated content.</p>"
```

For multi-line rich HTML, prefer `--html-file` over passing the payload on the command line to avoid shell escaping pitfalls. The script reads the file as UTF-8 and strips a single trailing newline.

## Execution Policy

1. If handling a Telegram message and `message_id` is known, send a reply message with `--reply-to`.
2. If there is no message to reply to, send a normal message to `chat_id`.
1. If handling a direct user message in Telegram and `message_id` is known, send a reply message (`--reply-to`).
2. If source metadata says sender is a bot (`sender_is_bot=true`), do not use reply mode, but send a normal message and prefix content with `@<sender_username>` (or the provided source username). If the user doesn't have a username, use `--source-user-id` to mention via `tg://user?id=` link.
3. For long-running tasks, optionally send one progress message, then edit that same message for final status.
4. **ALWAYS pass message content via stdin using heredoc pipe and `--message -` (or `--text -`).** NEVER embed message text directly in shell arguments — special characters like `'`, `"`, `$`, `!` will be mangled or cause syntax errors.
5. Avoid emitting HTML tags in message content; use Markdown for formatting instead.
5. **Prefer `telegram_rich.py` (rich-html-style) over `telegram_send.py` for anything beyond a single short paragraph.** See the "Rich Messages" section above.

## Bot to co-Bot Communication

Expand Down Expand Up @@ -88,6 +121,16 @@ cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID>
Reply content here.
EOF

# Source message sender is bot: no direct reply, use @username style
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --source-is-bot --source-username <USERNAME> --message -
Message to a bot using @username mention.
EOF

# Source message sender is bot without username: use tg://user?id= link
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --source-is-bot --source-user-id <USER_ID> --source-display-name "Display Name" --message -
Message to a user/bot using tg://user?id= mention.
EOF

# Edit an existing message
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_edit.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --message-id <MESSAGE_ID> --text -
Updated content here.
Expand All @@ -100,16 +143,42 @@ For other actions that not covered by these scripts, use `curl` to call Telegram

## Script Interface Reference

### `telegram_rich.py` (preferred for structured messages)

- `--chat-id`, `-c`: required
- `--html`: rich message content as HTML
- `--html-file`: read rich message HTML from a file (preferred for multi-line payloads)
- `--markdown`: rich message content as Markdown
- `--markdown-file`: read rich message Markdown from a file
- `--rtl`: render right-to-left
- `--skip-entity-detection`: skip URL/email/mention auto-detection
- `--reply-to`, `-r`: optional message ID to reply to
- `--disable-notification`: send silently
- `--edit MESSAGE_ID`: edit an existing message instead of sending
- `--token`, `-t`: optional (normally not needed)

### `telegram_send.py`

- `--chat-id`, `-c`: required, supports comma-separated ids
- `--message`, `-m`: required (use `-` to read from stdin)
- `--reply-to`, `-r`: optional
- `--token`, `-t`: optional (normally not needed)
- `--source-is-bot`: optional flag, disables reply mode and adds mention prefix
- `--source-username`: optional, uses `@username` style mention when set
- `--source-user-id`: optional, uses `tg://user?id=` link mention when username is not available
- `--source-display-name`: optional, display name for user ID mention (defaults to "User")

### `telegram_edit.py`

- `--chat-id`, `-c`: required
- `--message-id`, `-m`: required
- `--text`, `-t`: required (use `-` to read from stdin)
- `--token`: optional (normally not needed)

### Bot to co-Bot Communication

In Telegram groups, communicate with another bot using only these patterns:

1. Reply directly to the other bot's message when `message_id` is available.
2. Use an explicit command mention such as `/command@OtherBot` when you need to invoke that bot intentionally.
3. Do not assume free-form group text will reach another bot.
224 changes: 224 additions & 0 deletions src/skills/telegram/scripts/telegram_rich.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/usr/bin/env uv run
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "requests>=2.31.0",
# ]
# ///

"""
Telegram Bot Rich Message Sender

Sends rich formatted messages via the Bot API 10.1 (June 2026) `sendRichMessage`
endpoint using the `rich-html-style` markup.

Use this script as the **preferred** path whenever the message benefits from
structured formatting (headings, lists, tables, blockquotes, collapsible
details, math, captioned figures, etc.). Fall back to `telegram_send.py`
(plain markdown) only for short single-paragraph replies.
"""

import argparse
import json
import os
import sys

import requests


def send_rich_message(
bot_token: str,
chat_id: str,
html: str | None = None,
markdown: str | None = None,
is_rtl: bool = False,
skip_entity_detection: bool = False,
reply_to_message_id: int | None = None,
disable_notification: bool | None = None,
) -> dict:
"""
Call `sendRichMessage` on the Bot API.

Exactly one of `html` or `markdown` must be supplied.

Returns:
API response as dict
"""
if (html is None) == (markdown is None):
raise ValueError("Exactly one of --html or --markdown must be supplied")

url = f"https://api.telegram.org/bot{bot_token}/sendRichMessage"

rich_message: dict = {}
if html is not None:
rich_message["html"] = html
if markdown is not None:
rich_message["markdown"] = markdown
if is_rtl:
rich_message["is_rtl"] = True
if skip_entity_detection:
rich_message["skip_entity_detection"] = True

payload: dict = {
"chat_id": chat_id,
"rich_message": json.dumps(rich_message, ensure_ascii=False),
}
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
if disable_notification is not None:
payload["disable_notification"] = disable_notification

response = requests.post(url, json=payload, timeout=30)
if response.status_code == 400 and reply_to_message_id is not None:
# Some channels don't accept threaded replies; retry without it.
payload.pop("reply_to_message_id", None)
response = requests.post(url, json=payload, timeout=30)
response.raise_for_status()
return response.json()


def edit_rich_message(
bot_token: str,
chat_id: str,
message_id: int,
html: str | None = None,
markdown: str | None = None,
is_rtl: bool = False,
) -> dict:
"""Edit an existing message in-place to a rich message."""
if (html is None) == (markdown is None):
raise ValueError("Exactly one of --html or --markdown must be supplied")

url = f"https://api.telegram.org/bot{bot_token}/editMessageText"

rich_message: dict = {}
if html is not None:
rich_message["html"] = html
if markdown is not None:
rich_message["markdown"] = markdown
if is_rtl:
rich_message["is_rtl"] = True

payload: dict = {
"chat_id": chat_id,
"message_id": message_id,
"rich_message": json.dumps(rich_message, ensure_ascii=False),
}
response = requests.post(url, json=payload, timeout=30)
response.raise_for_status()
return response.json()


def read_payload_from_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read().rstrip("\n")


def main():
parser = argparse.ArgumentParser(
description="Send rich formatted messages via Bot API 10.1 sendRichMessage (rich-html-style)",
)
parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID")
parser.add_argument(
"--html",
help="Rich message content as HTML. Mutually exclusive with --markdown.",
)
parser.add_argument(
"--markdown",
help="Rich message content as Markdown. Mutually exclusive with --html.",
)
parser.add_argument(
"--html-file",
help="Read rich message HTML from a file (useful for heredoc payloads).",
)
parser.add_argument(
"--markdown-file",
help="Read rich message Markdown from a file (useful for heredoc payloads).",
)
parser.add_argument(
"--rtl",
action="store_true",
help="Render the message right-to-left.",
)
parser.add_argument(
"--skip-entity-detection",
action="store_true",
help="Skip auto-detection of URLs/emails/mentions in the text.",
)
parser.add_argument(
"--reply-to",
"-r",
type=int,
help="Message ID to reply to (creates threaded conversation).",
)
parser.add_argument(
"--disable-notification",
action="store_true",
help="Send silently.",
)
parser.add_argument(
"--edit",
type=int,
metavar="MESSAGE_ID",
help="Edit an existing message instead of sending a new one.",
)
parser.add_argument(
"--token",
"-t",
help="Bot token (defaults to $BUB_TELEGRAM_TOKEN env var).",
)

args = parser.parse_args()

bot_token = args.token or os.environ.get("BUB_TELEGRAM_TOKEN")
if not bot_token:
print("❌ Error: Bot token required. Set BUB_TELEGRAM_TOKEN env var or use --token")
sys.exit(1)

html = args.html
markdown = args.markdown
if args.html_file:
html = read_payload_from_file(args.html_file)
if args.markdown_file:
markdown = read_payload_from_file(args.markdown_file)

if (html is None) == (markdown is None):
print("❌ Error: supply exactly one of --html, --html-file, --markdown, --markdown-file")
sys.exit(1)

try:
if args.edit is not None:
result = edit_rich_message(
bot_token=bot_token,
chat_id=args.chat_id,
message_id=args.edit,
html=html,
markdown=markdown,
is_rtl=args.rtl,
)
print(f"✅ Rich message edited (message_id = {args.edit})")
else:
result = send_rich_message(
bot_token=bot_token,
chat_id=args.chat_id,
html=html,
markdown=markdown,
is_rtl=args.rtl,
skip_entity_detection=args.skip_entity_detection,
reply_to_message_id=args.reply_to,
disable_notification=args.disable_notification or None,
)
mid = result.get("result", {}).get("message_id")
print(f"✅ Rich message sent successfully to {args.chat_id} (message_id = {mid})")
except requests.HTTPError as e:
print(f"❌ HTTP Error: {e}")
body = e.response.text if e.response is not None else ""
print(f" Response: {body[:1000]}")
sys.exit(1)
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)


if __name__ == "__main__":
main()
33 changes: 30 additions & 3 deletions src/skills/telegram/scripts/telegram_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ def send_message(
return response.json()


def escape_markdown_v2(text: str) -> str:
"""
Escape special characters for Telegram MarkdownV2 format.
"""
escape_chars = r'_*[]()~`>#+-=|{}.!'
return ''.join('\\' + char if char in escape_chars else char for char in text)


def main():
parser = argparse.ArgumentParser(description="Send messages via Telegram Bot API (auto-converts to MarkdownV2)")
parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID")
Expand All @@ -138,6 +146,14 @@ def main():
"--source-username",
help="Source username for @username prefix when --source-is-bot is enabled",
)
parser.add_argument(
"--source-user-id",
help="Source user ID for mention when username is not available (uses tg://user?id= link)",
)
parser.add_argument(
"--source-display-name",
help="Display name for user ID mention (defaults to 'User')",
)

args = parser.parse_args()

Expand All @@ -150,13 +166,24 @@ def main():
# Parse chat IDs
chat_id = args.chat_id.strip()
reply_to = args.reply_to

# Read message from stdin if "-" was passed, otherwise use the inline value.
message = sys.stdin.read() if args.message == "-" else args.message

# Handle source-is-bot mode: prefix the message with a mention so the bot picks it up.
if args.source_is_bot and not reply_to and not message.startswith("/"):
if not args.source_username:
print("❌ Error: --source-username is required when --source-is-bot is set without --reply-to")
if args.source_user_id:
# Use tg://user?id= link for mention (works without username)
display_name = args.source_display_name or "User"
escaped_name = escape_markdown_v2(display_name)
mention = f"[{escaped_name}](tg://user?id={args.source_user_id})"
message = f"{mention}\n\n{message}"
elif args.source_username:
# Fall back to explicit /bot@username command mention
message = f"/bot@{args.source_username} {message}"
else:
print("❌ Error: --source-username or --source-user-id is required when --source-is-bot is set without --reply-to")
sys.exit(1)
message = f"/bot@{args.source_username} {message}"

# Send messages
try:
Expand Down
Loading