这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
1872 lines
77 KiB
Python
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},
|
|
)
|