ForcePilot/backend/package/yuxi/channels/adapters/telegram/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1872 lines
77 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import time
from collections.abc import AsyncIterator
from typing import Any, Literal
from telegram import BotCommand, BotCommandScope, Update
from telegram.error import TelegramError, InvalidToken, RetryAfter, Forbidden, BadRequest, TimedOut, NetworkError
from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelNotConnectedError,
)
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MentionsInfo,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .send import send_with_retry, send_stream_edit
from yuxi.channels.sdk.chunking import ChunkConfig, chunk_message as sdk_chunk_message
from yuxi.channels.sdk.format import markdown_to_html as sdk_markdown_to_html
from yuxi.channels.sdk.format import strip_html_tags as sdk_strip_html_tags
from yuxi.channels.capabilities import ChannelCapabilities, TTSVoiceCapabilities, TTSCapabilities
from yuxi.channels.meta import ChannelMeta
from .accounts.token import resolve_bot_token
from .connect.allowed_updates import get_allowed_updates
from .connect.polling_liveness import PollingLiveness
from .connect.update_offset_store import UpdateOffsetStore
from .reactions.reaction_level import ReactionLevelController
from .reactions.reaction_notifications import ReactionNotifications
from .tools.sendchataction_401_backoff import SendChatAction401Backoff
from .tools.request_timeouts import resolve_global_timeout
from .debounce import MessageDebouncer
from .thread_persistence import ThreadPersistence
from .topics.auto_topic_label import suggest_icon_color
from .topics.topic_name_cache import TopicNameCache
def _build_command_scope(scope_config: str | dict) -> BotCommandScope | None:
"""Build a BotCommandScope from configuration."""
if isinstance(scope_config, str):
if scope_config == "default":
return None
if scope_config == "all_private_chats":
return BotCommandScope(type="all_private_chats")
if scope_config == "all_group_chats":
return BotCommandScope(type="all_group_chats")
if scope_config == "all_chat_administrators":
return BotCommandScope(type="all_chat_administrators")
return None
if isinstance(scope_config, dict):
scope_type = scope_config.get("type", "default")
if scope_type == "default":
return None
return BotCommandScope(
type=scope_type,
chat_id=scope_config.get("chat_id"),
user_id=scope_config.get("user_id"),
)
return None
@register_builtin_adapter(aliases=["tg"])
class TelegramAdapter(BaseChannelAdapter):
channel_id = "telegram"
channel_type = ChannelType.TELEGRAM
text_chunk_limit = 4096
supports_markdown = False
supports_streaming = True
streaming_modes = ["off", "partial", "block", "progress"]
max_media_size_mb = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "group", "channel", "thread"],
polls=True,
reactions=True,
edit=True,
unsend=True,
reply=True,
threads=True,
media=True,
native_commands=True,
pin=True,
unpin=True,
group_management=True,
supports_markdown=False,
supports_streaming=True,
streaming_modes=["off", "partial", "block", "progress"],
text_chunk_limit=4096,
max_media_size_mb=100,
tts=TTSCapabilities(voice=TTSVoiceCapabilities(synthesis_target="voice-note", enabled=True)),
block_streaming=True,
)
meta = ChannelMeta(
id="telegram",
label="Telegram",
markdown_capable=False,
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._enabled = self.config.get("enabled", True)
self._history_limit = self.config.get("history_limit", 50)
self._application: Application | None = None
self._monitor_mode: Literal["polling", "webhook"] = "polling"
self._bot_info: dict[str, Any] | None = None
self._token_hash: str = ""
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
self._polling_task: asyncio.Task | None = None
self._dedup_set: dict[str, float] = {}
self._dedup_ttl = 300
cfg_size = self.config.get("max_media_size_mb")
if cfg_size is not None:
self.max_media_size_mb = int(cfg_size)
self._liveness = PollingLiveness(max_idle_seconds=self.config.get("polling_stall_threshold_ms", 120000) / 1000)
self._offset_store = UpdateOffsetStore()
self._reaction_level_ctrl = ReactionLevelController(self.config)
self._reaction_notifications = ReactionNotifications(self.config)
self._chat_action_backoff = SendChatAction401Backoff()
self._topic_name_cache = TopicNameCache()
self._sent_message_cache: dict[str, dict[str, Any]] = {}
self._sent_message_cache_ttl = self.config.get("sent_message_cache_ttl", 3600)
self._dedup_lock = asyncio.Lock()
self._cache_lock = asyncio.Lock()
self._silent_error_replies = self.config.get("silent_error_replies", False)
self._trusted_local_file_roots: list[str] = self.config.get("trusted_local_file_roots", [])
self._config_writes = self.config.get("config_writes", True)
self._commands_native = (
self.config.get("commands", {}).get("native", "auto")
if isinstance(self.config.get("commands"), dict)
else "auto"
)
self._commands_native_skills = (
self.config.get("commands", {}).get("native_skills", "auto")
if isinstance(self.config.get("commands"), dict)
else "auto"
)
self._debouncer = MessageDebouncer(
max_entries=self.config.get("debounce_max_entries", 1000),
ttl_seconds=self.config.get("debounce_ttl_seconds", 300),
)
self._thread_persistence = ThreadPersistence(
storage_path=self.config.get("thread_persistence_path"),
)
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
if not self._enabled:
self._status = ChannelStatus.DISABLED
logger.info(
f"[Telegram] Channel '{self.config.get('name', self.channel_id)}' is disabled, skipping connect"
)
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[Telegram] Starting channel '{self.config.get('name', self.channel_id)}'")
token = resolve_bot_token(self.config)
if not token:
raise ChannelAuthenticationError()
builder = ApplicationBuilder().token(token)
api_root = self.config.get("api_root")
if api_root:
builder.base_url(f"{api_root.rstrip('/')}/bot")
proxy = self.config.get("proxy")
if proxy:
builder.proxy_url(proxy)
connect_timeout = resolve_global_timeout(self.config)
builder.connect_timeout(connect_timeout)
builder.read_timeout(connect_timeout)
builder.write_timeout(connect_timeout)
network_cfg = self.config.get("network") or {}
if network_cfg:
pool_size = network_cfg.get("connectionPoolSize", 1)
builder.connection_pool_size(pool_size)
http_ver = network_cfg.get("httpVersion", "1.1")
builder.http_version(http_ver)
dns_order = network_cfg.get("dnsResultOrder")
if dns_order:
from telegram.request import HTTPXRequest
import socket
httpx_kwargs: dict[str, Any] = {}
if dns_order == "ipv4first":
httpx_kwargs["socket_options"] = [(socket.AF_INET, None)]
elif dns_order == "ipv6first":
httpx_kwargs["socket_options"] = [(socket.AF_INET6, None)]
if httpx_kwargs:
request = HTTPXRequest(
connection_pool_size=pool_size,
proxy_url=proxy,
connect_timeout=connect_timeout,
read_timeout=connect_timeout,
write_timeout=connect_timeout,
http_version=http_ver,
httpx_kwargs=httpx_kwargs,
)
builder.request(request)
builder.get_updates_request(request)
auto_select = network_cfg.get("autoSelectFamily", True)
logger.debug(f"[Telegram] Network: autoSelectFamily={auto_select}, dnsResultOrder={dns_order}")
self._application = builder.concurrent_updates(False).build()
try:
bot_info = await self._application.bot.get_me()
except InvalidToken:
raise ChannelAuthenticationError()
except (NetworkError, TimedOut) as e:
raise ChannelNotConnectedError() from e
self._bot_info = {
"id": bot_info.id,
"username": bot_info.username,
"name": f"{bot_info.first_name} {bot_info.last_name or ''}".strip(),
}
self._token_hash = hashlib.sha256(token.encode()).hexdigest()[:8]
logger.info(f"[Telegram] Bot verified: @{bot_info.username} (ID: {bot_info.id})")
await self._register_handlers()
await self._register_commands()
webhook_url = self.config.get("webhook_url")
if webhook_url:
await self._start_webhook()
self._monitor_mode = "webhook"
else:
await self._start_polling()
self._monitor_mode = "polling"
self._status = ChannelStatus.CONNECTED
logger.info(f"[Telegram] Channel started in {self._monitor_mode} mode")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[Telegram] Stopping channel '{self.config.get('name', self.channel_id)}'")
if self._polling_task and not self._polling_task.done():
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
self._polling_task = None
if self._application:
if self._monitor_mode == "polling":
try:
await self._application.updater.stop()
except Exception:
pass
try:
await self._application.stop()
await self._application.shutdown()
except Exception as e:
logger.warning(f"[Telegram] Error during shutdown: {e}")
self._application = None
self._status = ChannelStatus.DISCONNECTED
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
chat_id = response.identity.channel_chat_id
payload = self.format_outbound(response)
async def _do_send():
return await send_with_retry(self._application.bot, chat_id, payload, self.config)
try:
result = await self._circuit_breaker.call(_do_send)
if result and hasattr(result, "message_id"):
now = time.monotonic()
async with self._cache_lock:
self._sent_message_cache[str(result.message_id)] = {
"chat_id": chat_id,
"msg_id": str(result.message_id),
"cached_at": now,
}
expired = [
k
for k, v in self._sent_message_cache.items()
if now - v["cached_at"] > self._sent_message_cache_ttl
]
for k in expired:
del self._sent_message_cache[k]
return result
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except (TelegramError, Exception) as e:
return DeliveryResult(success=False, error=str(e))
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
max_bytes = self.max_media_size_mb * 1024 * 1024
if isinstance(data, (bytes, bytearray)) and len(data) > max_bytes:
return DeliveryResult(success=False, error=f"Media exceeds {self.max_media_size_mb}MB limit")
_send_map = {
"image": self._application.bot.send_photo,
"video": self._application.bot.send_video,
"audio": self._application.bot.send_audio,
"file": self._application.bot.send_document,
"animation": self._application.bot.send_animation,
"sticker": self._application.bot.send_sticker,
}
send_func = _send_map.get(media_type)
if send_func is None:
return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}")
max_retries = 3
for attempt in range(max_retries):
try:
msg = await send_func(chat_id=chat_id, **{media_type if media_type != "file" else "document": data})
return DeliveryResult(success=True, message_id=str(msg.message_id))
except RetryAfter as e:
await asyncio.sleep(e.retry_after)
except Forbidden:
return DeliveryResult(success=False, error="Bot blocked by user")
except (TimedOut, NetworkError, OSError):
if attempt == max_retries - 1:
return DeliveryResult(success=False, error="Media send failed after retries")
delay = 1.0 * (2**attempt)
await asyncio.sleep(delay)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
return DeliveryResult(success=False, error="Media send failed after retries")
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
html = sdk_markdown_to_html(content)
await self._application.bot.edit_message_text(
chat_id=chat_id,
message_id=int(msg_id),
text=html,
parse_mode="HTML",
)
return DeliveryResult(success=True, message_id=msg_id)
except BadRequest:
try:
clean_text = sdk_strip_html_tags(sdk_markdown_to_html(content))
await self._application.bot.edit_message_text(
chat_id=chat_id,
message_id=int(msg_id),
text=clean_text,
)
return DeliveryResult(success=True, message_id=msg_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
except RetryAfter as e:
return DeliveryResult(success=False, error=f"Rate limited, retry after {e.retry_after}s")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if finished:
return await self.edit_message(chat_id, msg_id, chunk)
return await send_stream_edit(self._application, chat_id, msg_id, chunk, self.config)
async def edit_message_caption(self, chat_id: str, msg_id: str, caption: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
html = sdk_markdown_to_html(caption)
await self._application.bot.edit_message_caption(
chat_id=chat_id,
message_id=int(msg_id),
caption=html,
parse_mode="HTML",
)
return DeliveryResult(success=True, message_id=msg_id)
except BadRequest:
try:
clean = sdk_strip_html_tags(sdk_markdown_to_html(caption))
await self._application.bot.edit_message_caption(
chat_id=chat_id,
message_id=int(msg_id),
caption=clean,
)
return DeliveryResult(success=True, message_id=msg_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def edit_message_reply_markup(self, chat_id: str, msg_id: str, reply_markup: Any = None) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.edit_message_reply_markup(
chat_id=chat_id,
message_id=int(msg_id),
reply_markup=reply_markup,
)
return DeliveryResult(success=True, message_id=msg_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.delete_message(chat_id=chat_id, message_id=int(msg_id))
return DeliveryResult(success=True, message_id=msg_id)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks delete permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
if not self._reaction_level_ctrl.should_react("message.received"):
return DeliveryResult(success=True, message_id=msg_id, metadata={"reaction_skipped": True})
try:
await self._application.bot.set_message_reaction(
chat_id=chat_id,
message_id=int(msg_id),
reaction=[{"type": "emoji", "emoji": emoji}],
)
return DeliveryResult(success=True, message_id=msg_id)
except BadRequest as e:
if "REACTION_INVALID" in str(e):
return DeliveryResult(success=True, message_id=msg_id, metadata={"reaction_rejected": True})
return DeliveryResult(success=False, error=str(e))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_chat_action(self, chat_id: str, action: str = "typing") -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
success = await self._chat_action_backoff.execute(self._application.bot, chat_id, action)
if success:
return DeliveryResult(success=True)
return DeliveryResult(success=False, error="sendChatAction skipped (backoff active)")
async def pin_message(self, chat_id: str, msg_id: str, disable_notification: bool = False) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.pin_chat_message(
chat_id=chat_id,
message_id=int(msg_id),
disable_notification=disable_notification,
)
return DeliveryResult(success=True, message_id=msg_id)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks pin permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.unpin_chat_message(
chat_id=chat_id,
message_id=int(msg_id),
)
return DeliveryResult(success=True, message_id=msg_id)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks unpin permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def unpin_all_messages(self, chat_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.unpin_all_chat_messages(chat_id=chat_id)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks unpin permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def forward_message(self, from_chat_id: str, to_chat_id: str, msg_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
result = await self._application.bot.forward_message(
chat_id=to_chat_id,
from_chat_id=from_chat_id,
message_id=int(msg_id),
)
return DeliveryResult(success=True, message_id=str(result.message_id))
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks forward permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_media_group(self, chat_id: str, media: list[dict[str, Any]]) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument
input_media = []
for item in media:
media_type = item.get("type", "photo")
media_data = item.get("media", "")
caption = item.get("caption")
kwargs = {}
if caption:
kwargs["caption"] = caption
kwargs["parse_mode"] = "HTML"
if media_type == "photo":
input_media.append(InputMediaPhoto(media=media_data, **kwargs))
elif media_type == "video":
input_media.append(InputMediaVideo(media=media_data, **kwargs))
elif media_type == "audio":
input_media.append(InputMediaAudio(media=media_data, **kwargs))
elif media_type == "document":
input_media.append(InputMediaDocument(media=media_data, **kwargs))
if not input_media:
return DeliveryResult(success=False, error="No valid media in group")
messages = await self._application.bot.send_media_group(
chat_id=chat_id,
media=input_media,
)
msg_ids = [str(m.message_id) for m in messages]
return DeliveryResult(success=True, message_id=msg_ids[0], metadata={"message_ids": msg_ids})
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_location(self, chat_id: str, latitude: float, longitude: float) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
msg = await self._application.bot.send_location(
chat_id=chat_id,
latitude=latitude,
longitude=longitude,
)
return DeliveryResult(success=True, message_id=str(msg.message_id))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_contact(
self, chat_id: str, phone_number: str, first_name: str, last_name: str = ""
) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
msg = await self._application.bot.send_contact(
chat_id=chat_id,
phone_number=phone_number,
first_name=first_name,
last_name=last_name or None,
)
return DeliveryResult(success=True, message_id=str(msg.message_id))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def create_forum_topic(
self, chat_id: str, name: str, icon_color: int | None = None, icon_custom_emoji_id: str | None = None
) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
if not name or name.strip() == "":
name = "New Topic"
if icon_color is None:
icon_color = suggest_icon_color(name)
kwargs = {"name": name, "icon_color": icon_color}
if icon_custom_emoji_id is not None:
kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
result = await self._application.bot.create_forum_topic(
chat_id=chat_id,
**kwargs,
)
thread_id = str(result.message_thread_id)
self._topic_name_cache.set(
thread_id,
{
"name": result.name,
"icon_color": icon_color,
},
)
return DeliveryResult(
success=True,
message_id=None,
metadata={
"message_thread_id": thread_id,
"name": result.name,
},
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_dice(self, chat_id: str, emoji: str = "\U0001f3b2") -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
msg = await self._application.bot.send_dice(chat_id=chat_id, emoji=emoji)
return DeliveryResult(success=True, message_id=str(msg.message_id))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def edit_forum_topic(
self, chat_id: str, topic_id: str, name: str | None = None, icon_custom_emoji_id: str | None = None
) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
kwargs = {"message_thread_id": int(topic_id)}
if name is not None:
kwargs["name"] = name
if icon_custom_emoji_id is not None:
kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
await self._application.bot.edit_forum_topic(chat_id=chat_id, **kwargs)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def close_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.close_forum_topic(
chat_id=chat_id,
message_thread_id=int(topic_id),
)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def reopen_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.reopen_forum_topic(
chat_id=chat_id,
message_thread_id=int(topic_id),
)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def delete_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.delete_forum_topic(
chat_id=chat_id,
message_thread_id=int(topic_id),
)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def kick_member(self, chat_id: str, user_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.ban_chat_member(chat_id=chat_id, user_id=int(user_id))
await self._application.bot.unban_chat_member(chat_id=chat_id, user_id=int(user_id))
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks kick permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def ban_member(self, chat_id: str, user_id: str, until_date: int | None = None) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
kwargs = {}
if until_date is not None:
kwargs["until_date"] = until_date
await self._application.bot.ban_chat_member(chat_id=chat_id, user_id=int(user_id), **kwargs)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks ban permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def unban_member(self, chat_id: str, user_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.unban_chat_member(chat_id=chat_id, user_id=int(user_id))
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks unban permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def mute_member(self, chat_id: str, user_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import ChatPermissions
await self._application.bot.restrict_chat_member(
chat_id=chat_id,
user_id=int(user_id),
permissions=ChatPermissions(can_send_messages=False),
)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks mute permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def unmute_member(self, chat_id: str, user_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import ChatPermissions
await self._application.bot.restrict_chat_member(
chat_id=chat_id,
user_id=int(user_id),
permissions=ChatPermissions(
can_send_messages=True,
can_send_media_messages=True,
can_send_polls=True,
can_send_other_messages=True,
can_add_web_page_previews=True,
),
)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks unmute permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def set_chat_permissions(self, chat_id: str, permissions: dict[str, bool]) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import ChatPermissions
await self._application.bot.set_chat_permissions(
chat_id=chat_id,
permissions=ChatPermissions(**permissions),
)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks permission management")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def set_chat_photo(self, chat_id: str, photo: Any) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.set_chat_photo(chat_id=chat_id, photo=photo)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks permission to set chat photo")
except BadRequest as e:
return DeliveryResult(success=False, error=f"Invalid photo: {e}")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def delete_chat_photo(self, chat_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.delete_chat_photo(chat_id=chat_id)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks permission to delete chat photo")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def rename_group(self, chat_id: str, title: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.set_chat_title(chat_id=chat_id, title=title)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks permission to rename group")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def set_chat_description(self, chat_id: str, description: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.set_chat_description(chat_id=chat_id, description=description)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks permission to set description")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def promote_admin(
self,
chat_id: str,
user_id: str,
can_change_info: bool = False,
can_post_messages: bool = False,
can_edit_messages: bool = False,
can_delete_messages: bool = False,
can_invite_users: bool = False,
can_restrict_members: bool = False,
can_pin_messages: bool = False,
can_promote_members: bool = False,
can_manage_chat: bool = False,
can_manage_video_chats: bool = False,
can_manage_topics: bool = False,
is_anonymous: bool = False,
) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import ChatAdministratorRights
rights = ChatAdministratorRights(
is_anonymous=is_anonymous,
can_manage_chat=can_manage_chat,
can_delete_messages=can_delete_messages,
can_manage_video_chats=can_manage_video_chats,
can_restrict_members=can_restrict_members,
can_promote_members=can_promote_members,
can_change_info=can_change_info,
can_invite_users=can_invite_users,
can_post_messages=can_post_messages,
can_edit_messages=can_edit_messages,
can_pin_messages=can_pin_messages,
can_manage_topics=can_manage_topics,
)
await self._application.bot.promote_chat_member(
chat_id=chat_id,
user_id=int(user_id),
rights=rights,
)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks promote permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def demote_admin(self, chat_id: str, user_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import ChatAdministratorRights
await self._application.bot.promote_chat_member(
chat_id=chat_id,
user_id=int(user_id),
rights=ChatAdministratorRights(),
)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks demote permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def create_chat_invite_link(
self, chat_id: str, name: str | None = None, member_limit: int | None = None
) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
kwargs = {}
if name is not None:
kwargs["name"] = name
if member_limit is not None:
kwargs["member_limit"] = member_limit
result = await self._application.bot.create_chat_invite_link(chat_id=chat_id, **kwargs)
return DeliveryResult(
success=True,
metadata={"invite_link": result.invite_link, "name": result.name},
)
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks invite link permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def export_chat_invite_link(self, chat_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
invite_link = await self._application.bot.export_chat_invite_link(chat_id=chat_id)
return DeliveryResult(success=True, metadata={"invite_link": invite_link})
except Forbidden:
return DeliveryResult(success=False, error="Bot lacks invite link permission")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def leave_chat(self, chat_id: str) -> DeliveryResult:
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
await self._application.bot.leave_chat(chat_id=chat_id)
return DeliveryResult(success=True)
except Forbidden:
return DeliveryResult(success=False, error="Bot cannot leave this chat")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def receive(self) -> AsyncIterator[ChannelMessage]:
if False:
yield # type: ignore[misc]
def normalize_inbound(self, raw: Update) -> ChannelMessage:
if raw.message is None:
return self._make_passthrough_event(raw)
msg = raw.message
chat = msg.chat
user = msg.from_user
debounce_key = self._debouncer.build_debounce_key(raw.to_dict())
if debounce_key and self._debouncer.is_duplicate(debounce_key):
return self._make_skip_event(raw)
chat_id = str(chat.id)
user_id = str(user.id) if user else "unknown"
msg_id = str(msg.message_id)
if chat.type == "channel":
chat_id = self._normalize_channel_id(chat_id)
chat_type = ChatType.DIRECT
if chat.type in ("group", "supergroup"):
chat_type = ChatType.GROUP
elif chat.type == "channel":
chat_type = ChatType.GUILD_CHANNEL
event_type = EventType.MESSAGE_RECEIVED
if msg.new_chat_members:
event_type = EventType.MEMBER_JOINED
elif msg.left_chat_member:
event_type = EventType.MEMBER_LEFT
elif msg.pinned_message:
event_type = EventType.SYSTEM_EVENT
elif msg.forum_topic_created:
event_type = EventType.SYSTEM_EVENT
metadata_extra = {"forum_topic_created": True, "forum_topic_name": msg.forum_topic_created.name}
elif msg.forum_topic_closed:
event_type = EventType.SYSTEM_EVENT
metadata_extra = {"forum_topic_closed": True}
elif msg.forum_topic_reopened:
event_type = EventType.SYSTEM_EVENT
metadata_extra = {"forum_topic_reopened": True}
else:
metadata_extra = {}
content = msg.text or msg.caption or ""
message_type = MessageType.TEXT
if msg.text and self._is_bot_command(msg):
message_type = MessageType.COMMAND
attachments = self._extract_attachments(msg)
if not content and attachments:
content = "(Media)"
mentions = self._extract_mentions(msg)
extracted_urls = self._extract_urls(msg)
metadata = {
"telegram_chat_type": chat.type,
"is_topic_message": msg.is_topic_message,
}
metadata.update(metadata_extra)
if msg.message_thread_id:
metadata["thread_id"] = str(msg.message_thread_id)
cached = self._topic_name_cache.get(str(msg.message_thread_id))
if cached:
metadata["thread_name"] = cached.get("name", "")
if chat.username:
metadata["chat_username"] = chat.username
if chat.title:
metadata["chat_title"] = chat.title
if msg.reply_to_message:
metadata["reply_to_message_id"] = str(msg.reply_to_message.message_id)
if msg.forward_origin:
fwd = msg.forward_origin
fwd_info = {"type": fwd.type}
if getattr(fwd, "sender_user", None):
fwd_info["sender_user_id"] = str(fwd.sender_user.id)
if getattr(fwd, "sender_chat", None):
fwd_info["sender_chat_id"] = str(fwd.sender_chat.id)
if fwd.sender_chat.title:
fwd_info["sender_chat_title"] = fwd.sender_chat.title
metadata["forward_info"] = fwd_info
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=user_id,
channel_chat_id=chat_id,
channel_message_id=msg_id,
),
event_type=event_type,
message_type=message_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
extracted_urls=extracted_urls,
reply_to_message_id=metadata.get("reply_to_message_id"),
metadata=metadata,
timestamp=msg.date,
)
@staticmethod
def _is_bot_command(msg) -> bool:
try:
from telegram import MessageEntity
except ImportError:
return bool(msg.text and msg.text.startswith("/"))
if msg.entities:
return any(e.type == MessageEntity.BOT_COMMAND for e in msg.entities)
return bool(msg.text and msg.text.startswith("/"))
def _extract_attachments(self, msg) -> list[Attachment]:
attachments = []
if msg.photo:
largest = msg.photo[-1]
attachments.append(
Attachment(
type="image",
file_id=largest.file_id,
mime_type="image/jpeg",
)
)
if msg.document:
attachments.append(
Attachment(
type="file",
file_id=msg.document.file_id,
filename=msg.document.file_name,
mime_type=msg.document.mime_type,
size_bytes=msg.document.file_size or 0,
)
)
if msg.video:
attachments.append(
Attachment(
type="video",
file_id=msg.video.file_id,
mime_type=msg.video.mime_type,
)
)
if msg.audio:
attachments.append(
Attachment(
type="audio",
file_id=msg.audio.file_id,
mime_type=msg.audio.mime_type,
)
)
if msg.voice:
attachments.append(
Attachment(
type="audio",
file_id=msg.voice.file_id,
mime_type=msg.voice.mime_type,
)
)
if msg.sticker:
attachments.append(
Attachment(
type="sticker",
file_id=msg.sticker.file_id,
)
)
if msg.animation:
attachments.append(
Attachment(
type="animation",
file_id=msg.animation.file_id,
filename=msg.animation.file_name,
mime_type=msg.animation.mime_type,
size_bytes=msg.animation.file_size or 0,
)
)
if msg.video_note:
attachments.append(
Attachment(
type="video_note",
file_id=msg.video_note.file_id,
size_bytes=msg.video_note.file_size or 0,
)
)
if msg.location:
attachments.append(
Attachment(
type="location",
metadata={
"latitude": msg.location.latitude,
"longitude": msg.location.longitude,
},
)
)
if msg.contact:
attachments.append(
Attachment(
type="contact",
metadata={
"phone_number": msg.contact.phone_number,
"first_name": msg.contact.first_name,
"last_name": msg.contact.last_name,
"user_id": str(msg.contact.user_id) if msg.contact.user_id else None,
},
)
)
if msg.dice:
attachments.append(
Attachment(
type="dice",
metadata={"emoji": msg.dice.emoji, "value": msg.dice.value},
)
)
if msg.poll:
attachments.append(
Attachment(
type="poll",
metadata={
"poll_id": msg.poll.id,
"question": msg.poll.question,
"options": [opt.text for opt in msg.poll.options],
},
)
)
if msg.venue:
attachments.append(
Attachment(
type="venue",
metadata={
"title": msg.venue.title,
"address": msg.venue.address,
"latitude": msg.venue.location.latitude,
"longitude": msg.venue.location.longitude,
},
)
)
if msg.media_group_id:
for att in attachments:
if att.type in ("image", "video", "audio"):
att.metadata["media_group_id"] = msg.media_group_id
return attachments
def _extract_mentions(self, msg) -> MentionsInfo | None:
if not msg.entities or not msg.text:
return None
mentioned_ids: list[str] = []
bot_username = (self._bot_info or {}).get("username", "")
for entity in msg.entities:
if entity.type == "mention":
mention_text = msg.text[entity.offset : entity.offset + entity.length]
mentioned_ids.append(mention_text)
elif entity.type == "text_mention" and entity.user:
mentioned_ids.append(str(entity.user.id))
if not mentioned_ids:
return None
bot_mentioned = f"@{bot_username}" in mentioned_ids if bot_username else False
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=bot_mentioned,
raw_text=msg.text,
)
def _extract_urls(self, msg) -> list[str]:
if not msg.entities or not msg.text:
return []
urls: list[str] = []
for entity in msg.entities:
if entity.type == "url":
url = msg.text[entity.offset : entity.offset + entity.length]
urls.append(url)
elif entity.type == "text_link":
urls.append(entity.url)
return urls
def _make_passthrough_event(self, update: Update) -> ChannelMessage:
if update.edited_message:
msg = update.edited_message
chat_id = str(msg.chat.id) if msg.chat else "unknown"
user_id = str(msg.from_user.id) if msg.from_user else "unknown"
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=user_id,
channel_chat_id=chat_id,
),
event_type=EventType.MESSAGE_UPDATED,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content="(passthrough)",
)
if update.callback_query:
cb = update.callback_query
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(cb.from_user.id),
channel_chat_id=str(cb.message.chat.id) if cb.message else "unknown",
),
event_type=EventType.CARD_ACTION,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content=cb.data or "",
)
if update.my_chat_member:
mcm = update.my_chat_member
chat_id = str(mcm.chat.id)
event = (
EventType.BOT_ADDED
if mcm.new_chat_member.status in ("member", "administrator")
else EventType.BOT_REMOVED
)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(mcm.from_user.id),
channel_chat_id=chat_id,
),
event_type=event,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if mcm.chat.type in ("group", "supergroup") else ChatType.DIRECT,
content=f"Bot {mcm.new_chat_member.status} in chat {chat_id}",
metadata={"chat_type": mcm.chat.type, "new_status": mcm.new_chat_member.status},
)
if update.chat_member:
cm = update.chat_member
chat_id = str(cm.chat.id)
event = (
EventType.MEMBER_JOINED
if cm.new_chat_member.status in ("member", "administrator")
and cm.old_chat_member.status not in ("member", "administrator")
else EventType.MEMBER_LEFT
if cm.new_chat_member.status in ("left", "kicked")
else EventType.SYSTEM_EVENT
)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(cm.new_chat_member.user.id),
channel_chat_id=chat_id,
),
event_type=event,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if cm.chat.type in ("group", "supergroup") else ChatType.DIRECT,
content=f"Member status change in chat {chat_id}",
metadata={
"chat_type": cm.chat.type,
"old_status": cm.old_chat_member.status,
"new_status": cm.new_chat_member.status,
},
)
if update.poll:
p = update.poll
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="unknown",
channel_chat_id="unknown",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.POLL,
chat_type=ChatType.DIRECT,
content=f"Poll {p.id}: {p.question}",
metadata={"poll_id": p.id, "is_closed": p.is_closed},
)
if update.poll_answer:
pa = update.poll_answer
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(pa.user.id) if pa.user else "unknown",
channel_chat_id="unknown",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.POLL,
chat_type=ChatType.DIRECT,
content=f"Poll answer for {pa.poll_id}",
metadata={"poll_id": pa.poll_id, "option_ids": pa.option_ids},
)
if update.message_reaction:
mr = update.message_reaction
bot_username = (self._bot_info or {}).get("username", "")
reactor = mr.user
if not self._reaction_notifications.should_notify(
bot_username, f"@{reactor.username}" if reactor and reactor.username else ""
):
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(mr.user.id) if mr.user else "unknown",
channel_chat_id=str(mr.chat.id),
),
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if mr.chat.type in ("group", "supergroup") else ChatType.DIRECT,
content="(reaction update - filtered)",
metadata={"_filtered": True},
)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(mr.user.id) if mr.user else "unknown",
channel_chat_id=str(mr.chat.id),
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if mr.chat.type in ("group", "supergroup") else ChatType.DIRECT,
content="(reaction update)",
metadata={
"message_id": str(mr.message_id),
"old_reaction": [str(r) for r in (mr.old_reaction or [])],
"new_reaction": [str(r) for r in (mr.new_reaction or [])],
},
)
if update.message_reaction_count:
mrc = update.message_reaction_count
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="unknown",
channel_chat_id=str(mrc.chat.id),
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if mrc.chat.type in ("group", "supergroup") else ChatType.DIRECT,
content="(reaction count update)",
metadata={
"message_id": str(mrc.message_id),
"reactions": [
{"type": str(r.type), "emoji": getattr(r, "emoji", None), "count": r.total_count}
for r in (mrc.reactions or [])
],
},
)
if update.chat_join_request:
cjr = update.chat_join_request
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=str(cjr.from_user.id),
channel_chat_id=str(cjr.chat.id),
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=f"Join request from user {cjr.from_user.id} in chat {cjr.chat.id}",
metadata={
"chat_type": cjr.chat.type,
"chat_title": getattr(cjr.chat, "title", None),
"user_id": str(cjr.from_user.id),
"user_name": cjr.from_user.full_name,
"bio": getattr(cjr, "bio", None),
"invite_link": getattr(cjr, "invite_link", None),
},
)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="unknown",
channel_chat_id="unknown",
),
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content="(unknown update)",
)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
html = sdk_markdown_to_html(response.content)
chunks = sdk_chunk_message(html, ChunkConfig(limit=self.text_chunk_limit))
main_text = chunks[0] if chunks else html
payload: dict[str, Any] = {
"chat_id": response.identity.channel_chat_id,
"text": main_text,
"parse_mode": "HTML",
}
thread_id = response.metadata.get("thread_id")
if thread_id:
payload["message_thread_id"] = thread_id
reply_mode = self.config.get("reply_to_mode", "first")
if reply_mode == "off":
pass
elif reply_mode in ("first", "all", "batched"):
if response.reply_to_message_id:
payload["reply_to_message_id"] = int(response.reply_to_message_id)
if reply_mode == "all" and response.identity.channel_message_id:
payload["reply_to_message_id"] = int(response.identity.channel_message_id)
quote_text = response.metadata.get("quote_text") or self.config.get("quote_text")
if quote_text and response.reply_to_message_id:
try:
from telegram import ReplyParameters
payload["reply_parameters"] = ReplyParameters(
message_id=int(response.reply_to_message_id),
quote=quote_text[:256],
)
except ImportError:
pass
if self.config.get("silent") or response.metadata.get("silent"):
payload["disable_notification"] = True
link_preview = self.config.get("link_preview", True)
payload["link_preview_options"] = {"is_disabled": not link_preview}
if response.message_type == MessageType.IMAGE and response.attachments:
force_doc = self.config.get("force_document", False)
payload.pop("text", None)
if force_doc:
payload["document"] = response.attachments[0].url or response.attachments[0].file_id
else:
payload["photo"] = response.attachments[0].url or response.attachments[0].file_id
payload["caption"] = main_text
elif response.message_type == MessageType.FILE and response.attachments:
payload.pop("text", None)
payload["document"] = response.attachments[0].url or response.attachments[0].file_id
payload["caption"] = main_text
reply_markup = self._build_reply_markup(response)
if reply_markup is not None:
payload["reply_markup"] = reply_markup
return payload
def _build_reply_markup(self, response: ChannelResponse) -> Any:
markup_cfg = response.metadata.get("reply_markup")
if not markup_cfg:
return None
markup_type = markup_cfg.get("type", "keyboard")
if markup_type == "force_reply":
from telegram import ForceReply
return ForceReply(
input_field_placeholder=markup_cfg.get("input_field_placeholder", ""),
selective=markup_cfg.get("selective", False),
)
if markup_type == "keyboard":
from telegram import KeyboardButton, ReplyKeyboardMarkup
keyboard_rows = markup_cfg.get("keyboard", [])
if not keyboard_rows:
return None
rows = []
for row in keyboard_rows:
buttons = []
for btn in row:
if isinstance(btn, str):
buttons.append(KeyboardButton(text=btn))
elif isinstance(btn, dict):
kwargs: dict[str, Any] = {"text": btn["text"]}
if btn.get("request_contact"):
kwargs["request_contact"] = True
if btn.get("request_location"):
kwargs["request_location"] = True
if btn.get("request_poll"):
from telegram import KeyboardButtonPollType
kwargs["request_poll"] = KeyboardButtonPollType(type=btn["request_poll"].get("type"))
if btn.get("web_app"):
from telegram import WebAppInfo
kwargs["web_app"] = WebAppInfo(url=btn["web_app"]["url"])
buttons.append(KeyboardButton(**kwargs))
rows.append(buttons)
return ReplyKeyboardMarkup(
keyboard=rows,
resize_keyboard=markup_cfg.get("resize_keyboard", True),
one_time_keyboard=markup_cfg.get("one_time_keyboard", False),
input_field_placeholder=markup_cfg.get("input_field_placeholder", ""),
selective=markup_cfg.get("selective", False),
is_persistent=markup_cfg.get("is_persistent", True),
)
return None
async def health_check(self) -> HealthStatus:
if not self._application:
if not self._enabled:
return HealthStatus(status="degraded", metadata={"reason": "channel_disabled"})
return HealthStatus(status="unhealthy", last_error="Application not initialized")
try:
bot_info = await self._application.bot.get_me()
liveness_status = self._liveness.get_status()
return HealthStatus(
status="degraded" if not liveness_status["healthy"] else "healthy",
metadata={
"bot_id": bot_info.id,
"bot_username": bot_info.username,
"monitor_mode": self._monitor_mode,
"adapter_status": self._status.value,
"liveness": liveness_status,
"backoff_active": not self._chat_action_backoff.can_send(),
},
last_connected_at=utc_now_naive(),
)
except InvalidToken:
return HealthStatus(status="unhealthy", last_error="Invalid bot token")
except (NetworkError, TimedOut) as e:
return HealthStatus(status="degraded", last_error=str(e))
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._application:
return {}
try:
chat = await self._application.bot.get_chat(chat_id=int(channel_user_id))
return {
"id": chat.id,
"username": chat.username,
"first_name": chat.first_name,
"last_name": chat.last_name,
"type": chat.type,
}
except Exception:
return {}
async def download_media(self, file_id: str) -> bytes:
if not self._application:
raise ChannelNotConnectedError()
tg_file = await self._application.bot.get_file(file_id)
max_bytes = self.max_media_size_mb * 1024 * 1024
if tg_file.file_size and tg_file.file_size > max_bytes:
raise ValueError(f"File size {tg_file.file_size} bytes exceeds {self.max_media_size_mb}MB limit")
return await tg_file.download_as_bytearray()
def is_local_file_path_trusted(self, file_path: str) -> bool:
if not self._trusted_local_file_roots:
return False
from pathlib import Path
resolved = Path(file_path).resolve()
return any(resolved.is_relative_to(Path(root).resolve()) for root in self._trusted_local_file_roots)
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
secret = self.config.get("webhook_secret", "")
if not secret:
return True
token = headers.get("X-Telegram-Bot-Api-Secret-Token", "")
return token == secret
@property
def history_limit(self) -> int:
return self._history_limit
@property
def enabled(self) -> bool:
return self._enabled
@property
def silent_error_replies(self) -> bool:
return self._silent_error_replies
@property
def trusted_local_file_roots(self) -> list[str]:
return self._trusted_local_file_roots
@property
def config_writes(self) -> bool:
return self._config_writes
def _get_commands_native(self) -> str:
commands_cfg = self.config.get("commands", {})
if isinstance(commands_cfg, dict):
return commands_cfg.get("native", self._commands_native)
return self._commands_native
def _get_commands_native_skills(self) -> str:
commands_cfg = self.config.get("commands", {})
if isinstance(commands_cfg, dict):
return commands_cfg.get("native_skills", self._commands_native_skills)
return self._commands_native_skills
async def _register_handlers(self) -> None:
if not self._application:
return
async def handle_update(update: Update, _context):
if self._message_handler is None:
return
update_key = f"u:{update.update_id}"
now = time.monotonic()
async with self._dedup_lock:
if update_key in self._dedup_set:
return
self._dedup_set[update_key] = now
expired = [k for k, v in self._dedup_set.items() if now - v > self._dedup_ttl]
for k in expired:
del self._dedup_set[k]
self._liveness.record_activity(update.update_id)
self._offset_store.set_offset(self._token_hash, update.update_id)
if update.callback_query:
try:
await update.callback_query.answer()
except Exception:
pass
channel_msg = self.normalize_inbound(update)
await self._message_handler(channel_msg)
self._application.add_handler(MessageHandler(filters.ALL, handle_update))
async def _register_commands(self) -> None:
if not self._application:
return
native_commands = self._get_commands_native()
if native_commands == "never":
return
commands_config = self.config.get("commands", [])
if isinstance(commands_config, dict):
cmd_entries = commands_config.get("commands", [])
elif isinstance(commands_config, list):
cmd_entries = commands_config
else:
cmd_entries = []
if not cmd_entries and native_commands != "always":
return
if isinstance(commands_config[0], (str, int)):
scoped = False
scoped_configs = cmd_entries # type: ignore[arg-type]
elif isinstance(commands_config[0], dict):
scoped = True
scoped_configs = [commands_config] if "scope" in commands_config else commands_config
else:
scoped = False
scoped_configs = cmd_entries # type: ignore[arg-type]
for entry in scoped_configs:
if scoped:
cmd_list = entry.get("commands", [])
scope_config = entry.get("scope", "default")
else:
cmd_list = [{"command": c, "description": c} if isinstance(c, str) else c for c in commands_config]
scope_config = "default"
break
commands = [BotCommand(c["command"], c["description"]) for c in cmd_list]
scope = _build_command_scope(scope_config)
if scope:
await self._application.bot.set_my_commands(commands, scope=scope)
else:
await self._application.bot.set_my_commands(commands)
async def _start_polling(self) -> None:
if not self._application:
return
allowed_updates = get_allowed_updates(self.config)
await self._application.bot.delete_webhook(drop_pending_updates=True)
await self._application.initialize()
await self._application.start()
poll_interval = self.config.get("poll_interval", 1.0)
saved_offset = self._offset_store.get_offset(self._token_hash)
if saved_offset > 0:
logger.info(f"[Telegram] Resuming from saved offset: {saved_offset}")
async def _poll():
poll_params: dict[str, Any] = {
"poll_interval": poll_interval,
"timeout": self.config.get("poll_timeout", 10),
"allowed_updates": allowed_updates,
}
if saved_offset > 0:
poll_params["offset"] = saved_offset + 1
try:
await self._application.updater.start_polling(**poll_params)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"[Telegram] Polling error, marking channel as error: {e}")
self._status = ChannelStatus.ERROR
async def _monitor_liveness():
check_interval = self.config.get("polling_liveness_interval", 60)
while True:
await asyncio.sleep(check_interval)
if not self._liveness.is_healthy():
logger.warning(
f"[Telegram] Polling liveness check failed, "
f"idle for {self._liveness.get_status()['last_activity_ago']:.1f}s"
)
if self._status == ChannelStatus.CONNECTED:
self._status = ChannelStatus.RECONNECTING
else:
if self._status == ChannelStatus.RECONNECTING:
self._status = ChannelStatus.CONNECTED
async def _persist_offset():
while True:
await asyncio.sleep(30)
status = self._liveness.get_status()
if status["last_update_id"] > 0:
self._offset_store.set_offset(self._token_hash, status["last_update_id"])
self._polling_task = asyncio.create_task(_poll())
asyncio.create_task(_monitor_liveness())
asyncio.create_task(_persist_offset())
async def _start_webhook(self) -> None:
if not self._application:
return
webhook_url = self.config["webhook_url"]
webhook_path = self.config.get("webhook_path", "/telegram-webhook")
webhook_secret = self.config.get("webhook_secret", "")
allowed_updates = get_allowed_updates(self.config)
full_url = f"{webhook_url.rstrip('/')}{webhook_path}"
webhook_params: dict[str, Any] = {
"url": full_url,
"drop_pending_updates": True,
"allowed_updates": allowed_updates,
}
if webhook_secret:
webhook_params["secret_token"] = webhook_secret
cert_path = self.config.get("webhook_cert_path")
if cert_path:
try:
with open(cert_path, "rb") as f:
webhook_params["certificate"] = f.read()
logger.info(f"[Telegram] Using custom SSL cert from {cert_path}")
except FileNotFoundError:
logger.warning(f"[Telegram] Webhook cert file not found: {cert_path}")
except Exception as e:
logger.warning(f"[Telegram] Failed to read webhook cert: {e}")
await self._application.bot.set_webhook(**webhook_params)
await self._application.initialize()
await self._application.start()
logger.info(f"[Telegram] Webhook set to {full_url}")
async def pre_connect(self) -> dict:
token = resolve_bot_token(self.config)
if not token:
return {"status": "error", "message": "Missing bot_token"}
try:
builder = ApplicationBuilder().token(token)
api_root = self.config.get("api_root")
if api_root:
builder.base_url(f"{api_root.rstrip('/')}/bot")
app = builder.build()
bot_info = await app.bot.get_me()
await app.shutdown()
return {
"status": "ok",
"bot_id": bot_info.id,
"bot_username": bot_info.username,
}
except InvalidToken:
return {"status": "error", "message": "Invalid bot token (401 Unauthorized)"}
except Exception as e:
return {"status": "error", "message": str(e)}
def _normalize_channel_id(self, chat_id: str) -> str:
if chat_id.startswith("-100"):
return chat_id[4:]
return chat_id
def _make_skip_event(self, raw: Update) -> ChannelMessage:
msg = raw.message
chat = msg.chat if msg else None
chat_id = str(chat.id) if chat else "0"
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="",
channel_chat_id=chat_id,
channel_message_id="0",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content="",
metadata={"skip": True},
)