新增了Telegram适配器的全套基础模块,包括: 1. 核心适配器入口与会话工具 2. 账号管理、认证与配置系统 3. 连接相关的轮询、Webhook、更新偏移管理 4. 话题路由、管理与缓存系统 5. 消息反抖动、超时配置与工具类 6. 响应式UI与命令交互系统 7. 反应表情与通知系统 8. 审批与安全审计模块 9. 健康检查与状态监控 10. 贴纸缓存与视觉工具 11. 流式响应与协作功能 12. 群组迁移与目标归一化处理
1868 lines
76 KiB
Python
1868 lines
76 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._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()
|
|
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()
|
|
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},
|
|
)
|