Files
meshcore-bot/modules/service_plugins/telegram_bridge_service.py
T
agessaman 647202bc5b refactor: get_running_loop() where get_event_loop() was called inside a coroutine
Phase 8's mechanical part. The 19 asyncio.get_event_loop() calls that run directly in an async def (found by walking each file's AST; calls inside nested sync functions or lambdas don't count) become asyncio.get_running_loop(). Inside a running coroutine both return the same loop; get_running_loop says so and never creates one. The five calls in sync functions (maintenance.run_data_retention, radio_link._handle_serial_probe_error, scheduler._run_in_current_loop and run_scheduler, weather_service._run_daily_forecast) can run on threads without a loop, where the two differ, so they stay. No test patches get_event_loop.
2026-10-03 22:37:37 -07:00

383 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Telegram Bridge Service for MeshCore Bot
Posts MeshCore channel messages to Telegram via the Bot API (one-way, read-only)
"""
import asyncio
import contextlib # noqa: F401 importable from this module on dev
import copy # noqa: F401 importable from this module on dev
import html
import os
import re
import time
from collections import deque
from dataclasses import dataclass
from typing import Any, Optional
from meshcore import EventType # noqa: F401 importable from this module on dev
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
aiohttp = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
requests = None # type: ignore[assignment]
REQUESTS_AVAILABLE = False
from ..profanity_filter import censor, contains_profanity # noqa: F401 re-exported
from .base_service import BaseServicePlugin # noqa: F401 re-exported
from .channel_bridge_utils import ChannelBridgeBase
# Telegram API
TELEGRAM_API_BASE = "https://api.telegram.org/bot"
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
TELEGRAM_TRUNCATE_AT = 4000
@dataclass
class QueuedMessage:
"""Represents a message queued for Telegram posting."""
chat_id: str
payload: dict[str, Any]
channel_name: str
retry_count: int = 0
first_queued: float = 0.0
next_retry_at: float = 0.0
def __post_init__(self):
if self.first_queued == 0.0:
self.first_queued = time.time()
if self.next_retry_at == 0.0:
self.next_retry_at = time.time()
class TelegramBridgeService(ChannelBridgeBase):
"""Telegram bridge service.
Posts MeshCore channel messages to Telegram channels/groups via the Bot API.
One-way bridge - messages only flow from MeshCore to Telegram.
Direct messages are NEVER bridged for privacy.
"""
config_section = 'TelegramBridge'
description = "Posts MeshCore channel messages to Telegram (one-way, read-only)"
# Web-viewer settings schema (see modules/settings_schema.py)
settings_schema = [
{"key": "api_token", "label": "Bot API token", "type": "str", "default": "",
"help": "Token from @BotFather. Can also be set via the TELEGRAM_BOT_TOKEN env var."},
{"key": "parse_mode", "label": "Parse mode", "type": "enum",
"options": [{"value": "HTML", "label": "HTML"},
{"value": "Markdown", "label": "Markdown"},
{"value": "MarkdownV2", "label": "MarkdownV2"}],
"default": "HTML", "help": "Message formatting mode."},
{"key": "disable_web_page_preview", "label": "Disable link previews", "type": "bool",
"default": False, "help": "Disable link previews in bridged messages."},
{"key": "max_message_length", "label": "Max message length", "type": "int",
"min": 1, "max": 4096, "default": 4096, "help": "Telegram's hard limit is 4096."},
{"key": "filter_profanity", "label": "Profanity filter", "type": "enum",
"options": [{"value": "drop", "label": "Drop (don't bridge)"},
{"value": "censor", "label": "Censor (****)"},
{"value": "off", "label": "Off"}],
"default": "drop", "help": "How to handle profanity in messages and usernames."},
{"key": "bridge_bot_responses", "label": "Bridge bot responses", "type": "bool",
"default": True, "help": "Also bridge the bot's own command replies."},
]
settings_dynamic_sections = [
{"section": "TelegramBridge", "key_prefix": "bridge.",
"label": "Channel mappings", "key_label": "MeshCore channel", "value_label": "Telegram chat ID",
"help": "Map a MeshCore channel to a Telegram chat. Use @channelusername (public) or a "
"numeric -100... ID (private). DMs are never bridged.",
"key_placeholder": "Public", "value_placeholder": "@YourChannel or -1001234567890"},
]
def __init__(self, bot: Any):
super().__init__(bot)
# State stop() relies on, set before any early return so a bridge left
# disabled by missing config still stops cleanly at shutdown.
self.channel_chat_ids: dict[str, str] = {}
self.message_queues: dict[str, list[QueuedMessage]] = {}
self.send_times: dict[str, deque] = {}
self.http_session: Optional[aiohttp.ClientSession] = None
self._queue_processor_task: Optional[asyncio.Task] = None
if not AIOHTTP_AVAILABLE and not REQUESTS_AVAILABLE:
self.logger.error(
"Neither aiohttp nor requests available. Telegram bridge requires one of these."
)
self.enabled = False
return
# API token: config or env (env takes precedence for security)
self.api_token = (
os.environ.get('TELEGRAM_BOT_TOKEN') or
self.bot.config.get('TelegramBridge', 'api_token', fallback='').strip()
)
if not self.api_token:
self.logger.error("Telegram bridge: api_token not set. Set in config or TELEGRAM_BOT_TOKEN env.")
self.enabled = False
return
self._load_channel_mappings()
# Optional settings
self.parse_mode = self.bot.config.get('TelegramBridge', 'parse_mode', fallback='HTML')
if self.parse_mode and self.parse_mode.upper() not in ('HTML', 'MARKDOWN', 'MARKDOWNV2'):
self.parse_mode = 'HTML'
self.disable_web_page_preview = self.bot.config.getboolean(
'TelegramBridge', 'disable_web_page_preview', fallback=False
)
self.max_message_length = self.bot.config.getint(
'TelegramBridge', 'max_message_length', fallback=TELEGRAM_MAX_MESSAGE_LENGTH
)
self.max_message_length = min(self.max_message_length, TELEGRAM_MAX_MESSAGE_LENGTH)
# Profanity filter: drop (default), censor, or off
raw_filter = self.bot.config.get('TelegramBridge', 'filter_profanity', fallback='drop').strip().lower()
if raw_filter not in ('drop', 'censor', 'off'):
raw_filter = 'drop'
self.filter_profanity = raw_filter
# Bridge bot's own channel responses to Telegram (default: true)
self.bridge_bot_responses = self.bot.config.getboolean(
'TelegramBridge', 'bridge_bot_responses', fallback=True
)
# Rate limiting: ~1 message per second per chat
self.rate_limit_min_interval = 1.0
self.max_retries = 5
self.retry_delay_base = 1.0
self.max_queue_age = 300
if not self.channel_chat_ids:
self.logger.warning(
"No Telegram channel mappings configured. "
"Add bridge.<channelname> = <chat_id> in [TelegramBridge]"
)
bridge_label = "Telegram"
payload_text_key = "text"
old_message_log = "Dropping old message from queue [{channel}]: age {age:.1f}s > {max_age}s"
retry_log = "Message failed, retry in {delay:.1f}s ({retry}/{max_retries}) [{channel}]"
processor_error_log = "Error in Telegram queue processor: {error}"
@property
def _bridge_mappings(self) -> dict[str, str]:
return self.channel_chat_ids
def _open_http_session(self) -> None:
if AIOHTTP_AVAILABLE:
self.http_session = aiohttp.ClientSession()
else:
self.logger.debug("Using requests for HTTP (fallback)")
def _init_queues(self) -> None:
for chat_id in self.channel_chat_ids.values():
self.message_queues[chat_id] = []
self.send_times[chat_id] = deque()
def _targets_for(self, channel_name: str) -> Optional[str]:
# Normalize: strip leading # and compare case-insensitively so bridge.HowlTest matches #howltest
channel_key = channel_name.lstrip('#').lower()
for config_channel, cid in self.channel_chat_ids.items():
if config_channel.lstrip('#').lower() == channel_key:
return cid
return None
async def _deliver(self, targets: str, sender_name: str, message_text: str, channel_name: str) -> None:
full_text = self._build_message_text(sender_name, message_text, channel_name)
full_text = self._truncate_text(full_text)
await self._queue_message(targets, full_text, channel_name)
def _throttled(self, key: str, queue: Any, current_time: float) -> bool:
# Enforce min interval per chat
if key in self.send_times:
st = self.send_times[key]
while st and (current_time - st[0]) > self.rate_limit_min_interval:
st.popleft()
if st and (current_time - st[-1]) < self.rate_limit_min_interval:
return True
return False
async def _send_queued(self, queued_msg: QueuedMessage) -> bool:
return await self._send_to_telegram(
queued_msg.chat_id,
queued_msg.payload,
queued_msg.channel_name,
queued_msg,
)
def _load_channel_mappings(self) -> None:
"""Load bridge.<channel> = chat_id from config."""
if not self.bot.config.has_section('TelegramBridge'):
return
for key, value in self.bot.config.items('TelegramBridge'):
if key.startswith('bridge.'):
channel_name = key[7:].strip()
chat_id = value.strip()
if not chat_id:
continue
self.channel_chat_ids[channel_name] = chat_id
# Log chat_id safely (mask numeric IDs partially)
if chat_id.startswith('@'):
safe_id = chat_id
else:
safe_id = chat_id[:4] + '...' + chat_id[-4:] if len(chat_id) > 10 else '***'
self.logger.info(f"Configured Telegram bridge: {channel_name} → {safe_id}")
self.logger.info(f"Loaded {len(self.channel_chat_ids)} Telegram channel mapping(s)")
def _mask_token(self, token: str) -> str:
if not token or len(token) < 8:
return "***"
return token[:4] + "..." + token[-4:]
def _format_mentions_html(self, text: str) -> str:
"""Convert @[username] to <code>@username</code> for Telegram HTML."""
pattern = r'@\[([^\]]+)\]'
return re.sub(pattern, r'<code>@\1</code>', text)
def _escape_html(self, s: str) -> str:
return html.escape(s, quote=True)
def _build_message_text(
self,
sender_name: str,
message_text: str,
channel_name: str,
use_channel_tag: bool = True,
) -> str:
"""Build HTML message: [Channel] **Sender**: text (with escaping)."""
safe_sender = self._escape_html(sender_name)
formatted_body = self._format_mentions_html(message_text)
# Escape HTML in the rest of the body (mentions already wrapped in <code>)
parts = re.split(r'(<code>[^<]*</code>)', formatted_body)
escaped_parts = [
self._escape_html(p) if not p.startswith('<code>') else p
for p in parts
]
safe_body = ''.join(escaped_parts)
prefix = f"<i>[{self._escape_html(channel_name)}]</i> " if use_channel_tag else ""
return f"{prefix}<b>{safe_sender}</b>: {safe_body}"
def _truncate_text(self, text: str) -> str:
if len(text) <= self.max_message_length:
return text
self.logger.debug(f"Truncating message from {len(text)} to {self.max_message_length} chars")
return text[: self.max_message_length - 1].rstrip() + "…"
async def _queue_message(self, chat_id: str, text: str, channel_name: str) -> None:
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": text,
}
if self.parse_mode:
payload["parse_mode"] = self.parse_mode
if self.disable_web_page_preview:
payload["disable_web_page_preview"] = True
queued = QueuedMessage(
chat_id=chat_id,
payload=payload,
channel_name=channel_name,
)
if chat_id not in self.message_queues:
self.message_queues[chat_id] = []
self.message_queues[chat_id].append(queued)
self.logger.debug(f"Queued message for Telegram [{channel_name}]: {text[:50]}...")
async def _send_to_telegram(
self,
chat_id: str,
payload: dict[str, Any],
channel_name: str,
queued_msg: Optional[QueuedMessage] = None,
) -> bool:
url = f"{TELEGRAM_API_BASE}{self.api_token}/sendMessage"
if AIOHTTP_AVAILABLE and self.http_session:
return await self._send_async(url, payload, channel_name, queued_msg)
elif REQUESTS_AVAILABLE:
return await self._send_sync(url, payload, channel_name, queued_msg)
self.logger.error("No HTTP library available for Telegram")
return False
async def _send_async(
self,
url: str,
payload: dict[str, Any],
channel_name: str,
queued_msg: Optional[QueuedMessage] = None,
) -> bool:
try:
assert self.http_session is not None
async with self.http_session.post(
url, json=payload, timeout=aiohttp.ClientTimeout(total=10)
) as response:
data = await response.json() if response.content else {}
if response.status == 200 and data.get('ok'):
self.logger.debug(f"Posted to Telegram [{channel_name}]: {payload['text'][:50]}...")
return True
if response.status == 429:
retry_after = (data.get('parameters') or {}).get('retry_after', 60)
self.logger.warning(
f"Telegram rate limit for [{channel_name}]. Retry after: {retry_after}s"
)
if queued_msg:
queued_msg.next_retry_at = time.time() + retry_after
queued_msg.retry_count = max(0, queued_msg.retry_count - 1)
return False
self.logger.warning(
f"Telegram API returned {response.status} for [{channel_name}]: {data.get('description', '')}"
)
return False
except asyncio.TimeoutError:
self.logger.error(f"Timeout posting to Telegram [{channel_name}]")
return False
except Exception as e:
self.logger.error(f"Error posting to Telegram [{channel_name}]: {e}")
return False
async def _send_sync(
self,
url: str,
payload: dict[str, Any],
channel_name: str,
queued_msg: Optional[QueuedMessage] = None,
) -> bool:
try:
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None,
lambda: requests.post(url, json=payload, timeout=10),
)
data = response.json() if response.content else {}
if response.status_code == 200 and data.get('ok'):
self.logger.debug(f"Posted to Telegram [{channel_name}]: {payload['text'][:50]}...")
return True
if response.status_code == 429:
retry_after = (data.get('parameters') or {}).get('retry_after', 60)
self.logger.warning(
f"Telegram rate limit for [{channel_name}]. Retry after: {retry_after}s"
)
if queued_msg:
queued_msg.next_retry_at = time.time() + retry_after
queued_msg.retry_count = max(0, queued_msg.retry_count - 1)
return False
self.logger.warning(
f"Telegram API returned {response.status_code} for [{channel_name}]: "
f"{data.get('description', '')}"
)
return False
except Exception as e:
self.logger.error(f"Error posting to Telegram [{channel_name}]: {e}")
return False