Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
Manage Telegram bot profile, commands, photos, menu button, and admin rights from any agent via Bot API.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
telegram-bot-settings/scripts/telegram_bot_settings.py
1#!/usr/bin/env python32"""Manage Telegram bot public identity and account-level settings via Bot API."""34from __future__ import annotations56import argparse7import json8import mimetypes9import os10import sys11import tempfile12import urllib.error13import urllib.request14import uuid15from pathlib import Path16from typing import Any1718DEFAULT_TOKEN_ENV_VARS = [19"TELEGRAM_BOT_TOKEN",20"BOT_TOKEN",21"TG_BOT_TOKEN",22]2324DEFAULT_TOKEN_FILE_ENV_VARS = [25"TELEGRAM_BOT_TOKEN_FILE",26"BOT_TOKEN_FILE",27"TG_BOT_TOKEN_FILE",28"OPENCLAW_TELEGRAM_TOKEN_FILE",29]3031DEFAULT_TOKEN_FILES = [32"~/.config/telegram-bot-settings/bot.token",33"~/.config/telegram-bot-settings/telegram-bot.token",34"~/.config/telegram-bot-settings/telegram_bot_token",35"~/.secrets/telegram-bot-settings/bot.token",36"~/.secrets/telegram_bot_token",37"./secrets/telegram-bot.token",38"./secrets/telegram_bot_token",39"~/.openclaw/credentials/telegram-default.token",40]4142DEFAULT_DOTENV_FILES = [43os.environ.get("TELEGRAM_BOT_SETTINGS_ENV"),44"./secrets/telegram-bot-settings.env",45"./secrets/telegram.env",46"./.env.local",47"./.env",48"~/.config/telegram-bot-settings/.env",49]505152class BotAPIError(RuntimeError):53pass545556def eprint(*args: Any) -> None:57print(*args, file=sys.stderr)585960def read_dotenv_value(path: Path, keys: list[str]) -> str | None:61try:62for raw_line in path.read_text(encoding="utf-8").splitlines():63line = raw_line.strip()64if not line or line.startswith("#"):65continue66if line.startswith("export "):67line = line[len("export ") :].strip()68if "=" not in line:69continue70name, value = line.split("=", 1)71name = name.strip()72if name not in keys:73continue74token = value.strip().strip("\"'").strip()75if token:76return token77except OSError:78return None79return None808182def read_token(args: argparse.Namespace) -> str:83if getattr(args, "token", None):84return args.token.strip()8586for env_name in DEFAULT_TOKEN_ENV_VARS:87env_token = os.environ.get(env_name)88if env_token:89return env_token.strip()9091candidate_paths: list[str] = []92if getattr(args, "token_file", None):93candidate_paths.append(args.token_file)94candidate_paths.extend(os.environ.get(env_name) for env_name in DEFAULT_TOKEN_FILE_ENV_VARS)95candidate_paths.extend(DEFAULT_TOKEN_FILES)9697for raw_path in candidate_paths:98if not raw_path:99continue100path = Path(raw_path).expanduser()101if path.exists():102token = path.read_text(encoding="utf-8").strip()103if token:104return token105106dotenv_keys = DEFAULT_TOKEN_ENV_VARS + DEFAULT_TOKEN_FILE_ENV_VARS107for raw_path in DEFAULT_DOTENV_FILES:108if not raw_path:109continue110path = Path(raw_path).expanduser()111if not path.exists():112continue113token = read_dotenv_value(path, DEFAULT_TOKEN_ENV_VARS)114if token:115return token116token_file = read_dotenv_value(path, DEFAULT_TOKEN_FILE_ENV_VARS)117if token_file:118candidate = Path(token_file).expanduser()119if candidate.exists():120value = candidate.read_text(encoding="utf-8").strip()121if value:122return value123124raise SystemExit(125"Could not find a Telegram bot token. Pass --token, --token-file, set TELEGRAM_BOT_TOKEN/BOT_TOKEN/TG_BOT_TOKEN, or store it in a supported secret file or dotenv file."126)127128129def load_jsonish(value: str | None) -> Any:130if value is None:131return None132raw = value133if raw.startswith("@"):134raw = Path(raw[1:]).expanduser().read_text(encoding="utf-8")135return json.loads(raw)136137138def normalize_form_value(value: Any) -> str:139if isinstance(value, (dict, list)):140return json.dumps(value, ensure_ascii=False)141if isinstance(value, bool):142return "true" if value else "false"143if value is None:144return ""145return str(value)146147148class TelegramBotAPI:149def __init__(self, token: str, timeout: int = 60) -> None:150self.token = token151self.timeout = timeout152self.base_url = f"https://api.telegram.org/bot{token}"153154def call(self, method: str, data: dict[str, Any] | None = None, files: dict[str, Path] | None = None) -> Any:155url = f"{self.base_url}/{method}"156data = {k: v for k, v in (data or {}).items() if v is not None}157158if files:159boundary = "----telegrambotsettings" + uuid.uuid4().hex160body = self._build_multipart_body(boundary, data, files)161request = urllib.request.Request(162url,163data=body,164headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},165)166elif data:167body = json.dumps(data, ensure_ascii=False).encode("utf-8")168request = urllib.request.Request(169url,170data=body,171headers={"Content-Type": "application/json; charset=utf-8"},172)173else:174request = urllib.request.Request(url)175176try:177with urllib.request.urlopen(request, timeout=self.timeout) as response:178payload = json.load(response)179except urllib.error.HTTPError as exc:180payload = self._decode_error_payload(exc)181description = payload.get("description") or str(exc)182raise BotAPIError(f"{method} failed: {description}") from exc183except urllib.error.URLError as exc:184raise BotAPIError(f"{method} failed: {exc}") from exc185186if not payload.get("ok"):187description = payload.get("description") or "Unknown Telegram Bot API error"188raise BotAPIError(f"{method} failed: {description}")189return payload.get("result")190191@staticmethod192def _decode_error_payload(exc: urllib.error.HTTPError) -> dict[str, Any]:193try:194body = exc.read().decode("utf-8", "replace")195return json.loads(body)196except Exception:197return {"ok": False, "description": str(exc)}198199@staticmethod200def _build_multipart_body(boundary: str, data: dict[str, Any], files: dict[str, Path]) -> bytes:201crlf = "\r\n"202parts: list[bytes] = []203204for name, value in data.items():205parts.append(206(207f"--{boundary}{crlf}"208f'Content-Disposition: form-data; name="{name}"{crlf}{crlf}'209f"{normalize_form_value(value)}{crlf}"210).encode("utf-8")211)212213for field_name, path in files.items():214content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"215parts.append(216(217f"--{boundary}{crlf}"218f'Content-Disposition: form-data; name="{field_name}"; filename="{path.name}"{crlf}'219f"Content-Type: {content_type}{crlf}{crlf}"220).encode("utf-8")221)222parts.append(path.read_bytes())223parts.append(crlf.encode("utf-8"))224225parts.append(f"--{boundary}--{crlf}".encode("utf-8"))226return b"".join(parts)227228229def print_json(payload: Any) -> None:230print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))231232233def maybe_language_code(args: argparse.Namespace) -> dict[str, Any]:234return {"language_code": args.language_code} if getattr(args, "language_code", None) else {}235236237def maybe_scope(args: argparse.Namespace) -> dict[str, Any]:238data: dict[str, Any] = {}239if getattr(args, "scope", None):240data["scope"] = load_jsonish(args.scope)241if getattr(args, "language_code", None):242data["language_code"] = args.language_code243return data244245246def ensure_static_photo(path: Path, keep_temp: bool = False) -> tuple[Path, Path | None]:247if path.suffix.lower() in {".jpg", ".jpeg"}:248return path, None249250try:251from PIL import Image252except ImportError as exc:253raise SystemExit(254"Static profile photos must be JPG/JPEG. Install Pillow to auto-convert or pre-convert the file."255) from exc256257image = Image.open(path).convert("RGB")258temp = tempfile.NamedTemporaryFile(prefix="telegram-bot-photo-", suffix=".jpg", delete=False)259temp_path = Path(temp.name)260temp.close()261image.save(temp_path, format="JPEG", quality=95)262return temp_path, temp_path if not keep_temp else None263264265def command_status(api: TelegramBotAPI, args: argparse.Namespace) -> None:266summary = {267"me": api.call("getMe"),268"name": api.call("getMyName", maybe_language_code(args)),269"description": api.call("getMyDescription", maybe_language_code(args)),270"short_description": api.call("getMyShortDescription", maybe_language_code(args)),271"commands": api.call("getMyCommands", maybe_scope(args)),272"menu_button": api.call("getChatMenuButton"),273"default_admin_rights": {274"groups": api.call("getMyDefaultAdministratorRights"),275"channels": api.call("getMyDefaultAdministratorRights", {"for_channels": True}),276},277}278print_json(summary)279280281def command_get_name(api: TelegramBotAPI, args: argparse.Namespace) -> None:282print_json(api.call("getMyName", maybe_language_code(args)))283284285def command_set_name(api: TelegramBotAPI, args: argparse.Namespace) -> None:286data = {"name": args.text, **maybe_language_code(args)}287print_json(api.call("setMyName", data))288289290def command_get_description(api: TelegramBotAPI, args: argparse.Namespace) -> None:291print_json(api.call("getMyDescription", maybe_language_code(args)))292293294def command_set_description(api: TelegramBotAPI, args: argparse.Namespace) -> None:295data = {"description": args.text, **maybe_language_code(args)}296print_json(api.call("setMyDescription", data))297298299def command_get_short_description(api: TelegramBotAPI, args: argparse.Namespace) -> None:300print_json(api.call("getMyShortDescription", maybe_language_code(args)))301302303def command_set_short_description(api: TelegramBotAPI, args: argparse.Namespace) -> None:304data = {"short_description": args.text, **maybe_language_code(args)}305print_json(api.call("setMyShortDescription", data))306307308def command_get_commands(api: TelegramBotAPI, args: argparse.Namespace) -> None:309print_json(api.call("getMyCommands", maybe_scope(args)))310311312def command_set_commands(api: TelegramBotAPI, args: argparse.Namespace) -> None:313data = {"commands": load_jsonish(args.commands), **maybe_scope(args)}314print_json(api.call("setMyCommands", data))315316317def command_delete_commands(api: TelegramBotAPI, args: argparse.Namespace) -> None:318print_json(api.call("deleteMyCommands", maybe_scope(args)))319320321def command_get_menu_button(api: TelegramBotAPI, args: argparse.Namespace) -> None:322data = {"chat_id": args.chat_id} if args.chat_id is not None else None323print_json(api.call("getChatMenuButton", data))324325326def command_set_menu_button(api: TelegramBotAPI, args: argparse.Namespace) -> None:327data: dict[str, Any] = {"menu_button": load_jsonish(args.button)}328if args.chat_id is not None:329data["chat_id"] = args.chat_id330print_json(api.call("setChatMenuButton", data))331332333def command_get_default_admin_rights(api: TelegramBotAPI, args: argparse.Namespace) -> None:334data = {"for_channels": True} if args.for_channels else None335print_json(api.call("getMyDefaultAdministratorRights", data))336337338def command_set_default_admin_rights(api: TelegramBotAPI, args: argparse.Namespace) -> None:339data: dict[str, Any] = {}340if args.rights:341data["rights"] = load_jsonish(args.rights)342if args.for_channels:343data["for_channels"] = True344print_json(api.call("setMyDefaultAdministratorRights", data))345346347def command_set_photo(api: TelegramBotAPI, args: argparse.Namespace) -> None:348path = Path(args.path).expanduser().resolve()349if not path.exists():350raise SystemExit(f"File does not exist: {path}")351352cleanup_path: Path | None = None353try:354if args.animated:355data: dict[str, Any] = {356"photo": {357"type": "animated",358"animation": "attach://animation_file",359}360}361if args.main_frame_timestamp is not None:362data["photo"]["main_frame_timestamp"] = args.main_frame_timestamp363result = api.call(364"setMyProfilePhoto",365data=data,366files={"animation_file": path},367)368else:369upload_path, cleanup_candidate = ensure_static_photo(path, keep_temp=args.keep_temp)370cleanup_path = cleanup_candidate371data = {372"photo": {373"type": "static",374"photo": "attach://photo_file",375}376}377result = api.call(378"setMyProfilePhoto",379data=data,380files={"photo_file": upload_path},381)382print_json(result)383finally:384if cleanup_path and cleanup_path.exists():385cleanup_path.unlink()386387388def command_remove_photo(api: TelegramBotAPI, args: argparse.Namespace) -> None:389print_json(api.call("removeMyProfilePhoto"))390391392def command_raw(api: TelegramBotAPI, args: argparse.Namespace) -> None:393data = load_jsonish(args.data) if args.data else None394files: dict[str, Path] = {}395for file_arg in args.file or []:396if "=" not in file_arg:397raise SystemExit(f"Invalid --file value '{file_arg}'. Use field_name=/path/to/file")398field_name, raw_path = file_arg.split("=", 1)399path = Path(raw_path).expanduser().resolve()400if not path.exists():401raise SystemExit(f"File does not exist: {path}")402files[field_name] = path403print_json(api.call(args.method, data=data, files=files or None))404405406def add_common_auth_flags(parser: argparse.ArgumentParser) -> None:407parser.add_argument("--token", help="Telegram bot token. Overrides discovered env vars and secret files.")408parser.add_argument(409"--token-file",410help="Path to a file containing the Telegram bot token. Overrides discovered token-file env vars and default secret files.",411)412413414def build_parser() -> argparse.ArgumentParser:415parser = argparse.ArgumentParser(416description="Manage Telegram bot public identity and account-level settings via Bot API."417)418add_common_auth_flags(parser)419subparsers = parser.add_subparsers(dest="command", required=True)420421status = subparsers.add_parser("status", help="Fetch a combined snapshot of common bot settings.")422add_common_auth_flags(status)423status.add_argument("--language-code", help="Optional IETF language code for localized values.")424status.add_argument("--scope", help="Optional JSON or @file for getMyCommands scope.")425status.set_defaults(func=command_status)426427get_name = subparsers.add_parser("get-name", help="Read the current bot name.")428add_common_auth_flags(get_name)429get_name.add_argument("--language-code", help="Optional IETF language code.")430get_name.set_defaults(func=command_get_name)431432set_name = subparsers.add_parser("set-name", help="Set the bot name.")433add_common_auth_flags(set_name)434set_name.add_argument("--text", required=True, help="New bot name.")435set_name.add_argument("--language-code", help="Optional IETF language code.")436set_name.set_defaults(func=command_set_name)437438get_description = subparsers.add_parser("get-description", help="Read the current bot description.")439add_common_auth_flags(get_description)440get_desc