ForcePilot/backend/package/yuxi/channel/extensions/synology_chat/client.py
Kris 156f716600 feat(channel): 添加 Synology Chat 渠道扩展
新增 Synology Chat 渠道扩展,支持在 Yuxi 平台中集成群晖 Synology Chat 即时通讯渠道。

包含以下功能模块:
- client: Synology Chat API 客户端封装
- accounts: 账户管理
- webhook: Webhook 事件处理
- security: 安全校验
- dedupe: 消息去重
- status: 会话状态管理
- session: 会话管理
- types: 类型定义
2026-05-21 11:44:45 +08:00

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