diff --git a/README.md b/README.md index da8b4d4..d3b096f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ The `--pre` flag formats messages as fixed-width text: telegram-send --pre "monospace" ``` +To send a message to a specific chat id without changing the configuration: +``` shell +telegram-send --chat-id -1001234567890 "Hello" +``` + +To send a message to a chat id by alias (the mapping should be in the configuration): +``` shell +telegram-send --alias channel "Hello" +``` +To use an alias, you must define an `[aliases]` block in the configuration file (`~/.config/telegram-send.conf` by default, or `%LOCALAPPDATA%\telegram-send\telegram-send.conf` on Windows), mapping the alias to a specific chat ID: +```ini +[aliases] +channel = -1001234567890 +group = -1009876543210 +``` + +To configure and use the bot token securely without storing it in a plain-text configuration file, you can set the `TELEGRAM_BOT_TOKEN` environment variable. If this is present, telegram-send will use it during `telegram-send --configure` and skip prompting you for the token. The token will not be saved into the configuration file. +``` shell +export TELEGRAM_BOT_TOKEN="123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789" +telegram-send --configure +``` + To send a message without link previews: ``` shell telegram-send --disable-web-page-preview "https://github.com/rahiel/telegram-send" diff --git a/telegram_send/telegram_send.py b/telegram_send/telegram_send.py index 009c652..d1c96d0 100644 --- a/telegram_send/telegram_send.py +++ b/telegram_send/telegram_send.py @@ -17,6 +17,7 @@ import argparse import asyncio import configparser +import os import re import sys from copy import deepcopy @@ -93,6 +94,8 @@ async def run(): parser.add_argument("--timeout", help="Set the read timeout for network operations. (in seconds)", type=float, default=30., action="store") parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument("--chat-id", help="Target chat id to send messages to, bypassing the configured chat id", dest="chat_id") + parser.add_argument("--alias", help="Target chat alias to send messages to, bypassing the configured chat id. The mapping is stored in the configuration.", dest="alias") args = parser.parse_args() conf : list[str | None] @@ -130,7 +133,7 @@ async def run(): args.message = [message] + args.message try: - await delete(args.delete, conf=conf[0]) + await delete(args.delete, conf=conf[0], chat_id=args.chat_id, alias=args.alias) message_ids = [] for c in conf: message_ids += await send( @@ -148,7 +151,9 @@ async def run(): audios=args.audio, captions=args.caption, locations=args.location, - timeout=args.timeout + timeout=args.timeout, + chat_id=args.chat_id, + alias=args.alias ) if args.showids and message_ids: smessage_ids = [str(m) for m in message_ids] @@ -170,7 +175,7 @@ async def run(): async def send(*, messages=None, files=None, images=None, stickers=None, animations=None, videos=None, audios=None, captions=None, locations=None, conf=None, parse_mode=None, pre=False, silent=False, - disable_web_page_preview=False, timeout=30): + disable_web_page_preview=False, timeout=30, chat_id=None, alias=None): """Send data over Telegram. All arguments are optional. Always use this function with explicit keyword arguments. So @@ -206,8 +211,10 @@ async def send(*, - silent (bool): Send silently without sound. - disable_web_page_preview (bool): Disables web page previews for all links in the messages. - timeout (int|float): The read timeout for network connections in seconds. + - chat_id (str): Target chat id to send messages to, bypassing the configured chat id. + - alias (str): Target chat alias to send messages to, bypassing the configured chat id. The mapping is stored in the configuration. """ - settings = get_config_settings(conf) + settings = get_config_settings(conf, override_chat_id=chat_id, override_alias=alias) token = settings.token chat_id = settings.chat_id reply_to_message_id = settings.reply_to_message_id @@ -323,7 +330,7 @@ def make_captions(items, captions): return message_ids -async def delete(message_ids, conf=None, timeout=30): +async def delete(message_ids, conf=None, timeout=30, chat_id=None, alias=None): """Delete messages that have been sent before over Telegram. Restrictions given by Telegram API apply. Note that Telegram restricts this to messages which have been sent during the last 48 hours. @@ -335,8 +342,10 @@ async def delete(message_ids, conf=None, timeout=30): - conf (str): Path of configuration file to use. Will use the default config if not specified. `~` expands to user's home directory. - timeout (int|float): The read timeout for network connections in seconds. + - chat_id (str): Target chat id to delete messages from, bypassing the configured chat id. + - alias (str): Target chat alias to delete messages from. """ - settings = get_config_settings(conf) + settings = get_config_settings(conf, override_chat_id=chat_id, override_alias=alias) token = settings.token chat_id = settings.chat_id bot = telegram.Bot(token) @@ -365,14 +374,16 @@ async def configure(conf=None, channel=False, group=False, fm_integration=False) contact_url = "https://telegram.me/" root_topic_message = None - print(f"Talk with the {markup('BotFather', 'cyan')} on Telegram ({contact_url}BotFather), " - "create a bot and insert the token") - try: - token = input(markup(prompt, "magenta")).strip() - except UnicodeEncodeError: - # some users can only display ASCII - prompt = "> " - token = input(markup(prompt, "magenta")).strip() + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + print(f"Talk with the {markup('BotFather', 'cyan')} on Telegram ({contact_url}BotFather), " + "create a bot and insert the token") + try: + token = input(markup(prompt, "magenta")).strip() + except UnicodeEncodeError: + # some users can only display ASCII + prompt = "> " + token = input(markup(prompt, "magenta")).strip() try: bot = telegram.Bot(token) @@ -479,10 +490,13 @@ def get_root_topic_message(message: telegram.Message): config = configparser.ConfigParser() + config_data = {"chat_id": chat_id} + if not os.environ.get("TELEGRAM_BOT_TOKEN"): + config_data["TOKEN"] = token if root_topic_message is not None and isinstance(root_topic_message, telegram.Message): - config["telegram"] = {"TOKEN": token, "chat_id": chat_id, "reply_to_message_id": root_topic_message.message_id} - else: - config["telegram"] = {"TOKEN": token, "chat_id": chat_id} + config_data["reply_to_message_id"] = root_topic_message.message_id + + config["telegram"] = config_data conf_dir = dirname(conf) if conf_dir: makedirs(conf_dir, exist_ok=True) @@ -557,21 +571,49 @@ class Settings(NamedTuple): reply_to_message_id: int | str | None -def get_config_settings(conf=None) -> Settings: +def get_config_settings(conf=None, override_chat_id=None, override_alias=None) -> Settings: conf = expanduser(conf) if conf else get_config_path() config = configparser.ConfigParser() - if not config.read(conf) or not config.has_section("telegram"): - raise ConfigError(f"Config not found: {conf}") - missing_options = set(["token", "chat_id"]) - set(config.options("telegram")) - if len(missing_options) > 0: - raise ConfigError(f"Missing options in config: {', '.join(missing_options)}") + # Allow reading token from env instead of config file + env_token = os.environ.get("TELEGRAM_BOT_TOKEN") + + has_config = config.read(conf) and config.has_section("telegram") + + if not has_config: + if not env_token: + raise ConfigError(f"Config not found: {conf}") + + required_options = {"chat_id"} + if not env_token: + required_options.add("token") + + if has_config: + missing_options = set(required_options) - set(config.options("telegram")) + if len(missing_options) > 0: + raise ConfigError(f"Missing options in config: {', '.join(missing_options)}") + + if has_config and not env_token: + token = config.get("telegram", "token") + else: + token = env_token + + if override_chat_id: + chat = override_chat_id + elif override_alias: + if has_config and config.has_section("aliases") and config.has_option("aliases", override_alias): + chat = config.get("aliases", override_alias) + else: + raise ConfigError(f"Alias '{override_alias}' not found in config: {conf}") + else: + if has_config and config.has_option("telegram", "chat_id"): + chat = config.get("telegram", "chat_id") + else: + raise ConfigError(f"Missing options in config: chat_id") - token = config.get("telegram", "token") - chat = config.get("telegram", "chat_id") - reply = config.get("telegram", "reply_to_message_id", fallback=None) + reply = config.get("telegram", "reply_to_message_id", fallback=None) if has_config else None - chat_id = int(chat) if chat.isdigit() else chat + chat_id = int(chat) if str(chat).lstrip('-').isdigit() else chat reply_to_message_id = int(reply) if reply and reply.isdigit() else reply return Settings(token=token, chat_id=chat_id, reply_to_message_id=reply_to_message_id)