新增 Synology Chat 渠道扩展,支持在 Yuxi 平台中集成群晖 Synology Chat 即时通讯渠道。 包含以下功能模块: - client: Synology Chat API 客户端封装 - accounts: 账户管理 - webhook: Webhook 事件处理 - security: 安全校验 - dedupe: 消息去重 - status: 会话状态管理 - session: 会话管理 - types: 类型定义
321 lines
9.7 KiB
Python
321 lines
9.7 KiB
Python
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
|