from __future__ import annotations import asyncio import json import logging import time import urllib.parse from urllib.parse import urlencode import aiohttp from yuxi.channel.extensions.synology_chat.types import ( BASE_RETRY_DELAY_S, CACHE_TTL_S, HTTP_TIMEOUT_S, MAX_RETRIES, MIN_SEND_INTERVAL_MS, ) logger = logging.getLogger(__name__) _last_send_time: float = 0.0 _chat_user_cache: dict[str, tuple[float, list[dict]]] = {} async def send_message( incoming_url: str, text: str, user_id: str | None = None, channel_id: str | None = None, allow_insecure_ssl: bool = False, ) -> bool: global _last_send_time elapsed = (time.monotonic() - _last_send_time) * 1000 if elapsed < MIN_SEND_INTERVAL_MS: await asyncio.sleep((MIN_SEND_INTERVAL_MS - elapsed) / 1000) payload: dict = {"text": text} if user_id: payload["user_ids"] = [int(user_id)] if channel_id: payload["channel_id"] = channel_id body = f"payload={urlencode({'payload': json.dumps(payload)})}" ssl_context: bool | None = False if allow_insecure_ssl else None for attempt in range(MAX_RETRIES): try: _last_send_time = time.monotonic() async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=HTTP_TIMEOUT_S)) as session: async with session.post( incoming_url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ssl=ssl_context if ssl_context is not None else True, ) as resp: if resp.status < 500: return resp.status < 400 except Exception: logger.exception("Failed to send message (attempt %d/%d)", attempt + 1, MAX_RETRIES) if attempt < MAX_RETRIES - 1: delay = BASE_RETRY_DELAY_S * (2**attempt) await asyncio.sleep(delay) return False async def send_file_url( incoming_url: str, file_url: str, user_id: str | None = None, allow_insecure_ssl: bool = False, ) -> bool: parsed = urllib.parse.urlparse(file_url) if parsed.scheme not in ("http", "https"): logger.warning("SSRF blocked: invalid scheme for file URL %s", file_url) return False payload: dict = {"file_url": file_url} if user_id: payload["user_ids"] = [int(user_id)] body = f"payload={urlencode({'payload': json.dumps(payload)})}" ssl_context: bool | None = False if allow_insecure_ssl else None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=HTTP_TIMEOUT_S)) as session: async with session.post( incoming_url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ssl=ssl_context if ssl_context is not None else True, ) as resp: return resp.status < 400 def _build_user_list_url(incoming_url: str) -> str: parsed = urllib.parse.urlparse(incoming_url) base = f"{parsed.scheme}://{parsed.netloc}/webapi/entry.cgi" params = { "api": "SYNO.Chat.External", "method": "user_list", "version": "2", } return f"{base}?{urlencode(params)}" async def fetch_chat_users( incoming_url: str, allow_insecure_ssl: bool = False, ) -> list[dict]: list_url = _build_user_list_url(incoming_url) if list_url in _chat_user_cache: cached_at, users = _chat_user_cache[list_url] if time.monotonic() - cached_at < CACHE_TTL_S: return users ssl_context: bool | None = False if allow_insecure_ssl else None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: async with session.get( list_url, ssl=ssl_context if ssl_context is not None else True, ) as resp: data = await resp.json() users = data.get("data", {}).get("users", []) _chat_user_cache[list_url] = (time.monotonic(), users) return users async def send_attachment( incoming_url: str, text: str, attachments: list[dict], user_id: str | None = None, allow_insecure_ssl: bool = False, ) -> bool: global _last_send_time elapsed = (time.monotonic() - _last_send_time) * 1000 if elapsed < MIN_SEND_INTERVAL_MS: await asyncio.sleep((MIN_SEND_INTERVAL_MS - elapsed) / 1000) payload: dict = {"text": text, "attachments": attachments} if user_id: payload["user_ids"] = [int(user_id)] body = f"payload={urlencode({'payload': json.dumps(payload)})}" ssl_context: bool | None = False if allow_insecure_ssl else None for attempt in range(MAX_RETRIES): try: _last_send_time = time.monotonic() async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=HTTP_TIMEOUT_S)) as session: async with session.post( incoming_url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ssl=ssl_context if ssl_context is not None else True, ) as resp: if resp.status < 500: return resp.status < 400 except Exception: logger.exception("Failed to send attachment (attempt %d/%d)", attempt + 1, MAX_RETRIES) if attempt < MAX_RETRIES - 1: delay = BASE_RETRY_DELAY_S * (2**attempt) await asyncio.sleep(delay) return False def build_button_attachment( callback_id: str, text: str, buttons: list[dict], ) -> dict: actions = [] for btn in buttons: actions.append( { "type": "button", "name": btn["name"], "value": btn["value"], "text": btn["text"], "style": btn.get("style", "grey"), } ) return { "callback_id": callback_id, "text": text, "actions": actions, } def _build_channel_list_url(incoming_url: str) -> str: parsed = urllib.parse.urlparse(incoming_url) base = f"{parsed.scheme}://{parsed.netloc}/webapi/entry.cgi" params = { "api": "SYNO.Chat.External", "method": "channel_list", "version": "2", } return f"{base}?{urlencode(params)}" _channel_list_cache: dict[str, tuple[float, list[dict]]] = {} async def fetch_chat_channels( incoming_url: str, allow_insecure_ssl: bool = False, ) -> list[dict]: list_url = _build_channel_list_url(incoming_url) if list_url in _channel_list_cache: cached_at, channels = _channel_list_cache[list_url] if time.monotonic() - cached_at < CACHE_TTL_S: return channels ssl_context: bool | None = False if allow_insecure_ssl else None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: async with session.get( list_url, ssl=ssl_context if ssl_context is not None else True, ) as resp: data = await resp.json() channels = data.get("data", {}).get("channels", []) _channel_list_cache[list_url] = (time.monotonic(), channels) return channels def _build_post_list_url(incoming_url: str, channel_id: str | None = None) -> str: parsed = urllib.parse.urlparse(incoming_url) base = f"{parsed.scheme}://{parsed.netloc}/webapi/entry.cgi" params = { "api": "SYNO.Chat.External", "method": "post_list", "version": "2", } if channel_id: params["channel_id"] = channel_id return f"{base}?{urlencode(params)}" async def fetch_chat_posts( incoming_url: str, channel_id: str | None = None, offset: int = 0, limit: int = 50, allow_insecure_ssl: bool = False, ) -> list[dict]: list_url = _build_post_list_url(incoming_url, channel_id) params = {"offset": offset, "limit": limit} ssl_context: bool | None = False if allow_insecure_ssl else None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session: async with session.get( list_url, params=params, ssl=ssl_context if ssl_context is not None else True, ) as resp: data = await resp.json() return data.get("data", {}).get("posts", []) def _build_file_get_url(incoming_url: str, file_id: str) -> str: parsed = urllib.parse.urlparse(incoming_url) base = f"{parsed.scheme}://{parsed.netloc}/webapi/entry.cgi" params = { "api": "SYNO.Chat.External", "method": "post_file_get", "version": "2", "file_id": file_id, } return f"{base}?{urlencode(params)}" async def fetch_chat_file_bytes( incoming_url: str, file_id: str, allow_insecure_ssl: bool = False, ) -> bytes | None: file_url = _build_file_get_url(incoming_url, file_id) ssl_context: bool | None = False if allow_insecure_ssl else None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session: async with session.get( file_url, ssl=ssl_context if ssl_context is not None else True, ) as resp: if resp.status != 200: return None return await resp.read() async def resolve_legacy_webhook_name_to_chat_user_id( incoming_url: str, webhook_username: str, allow_insecure_ssl: bool = False, ) -> str | None: if not webhook_username: return None users = await fetch_chat_users(incoming_url, allow_insecure_ssl) for user in users: if user.get("nickname") == webhook_username: return str(user.get("user_id")) for user in users: if user.get("username") == webhook_username: return str(user.get("user_id")) return None