ForcePilot/backend/package/yuxi/channel/extensions/twitch/client.py
Kris 0babb1ca8e feat(channel): 添加 Tlon 渠道扩展
新增 Tlon 渠道扩展,支持在 Yuxi 平台中集成 Tlon/Urbit 去中心化通讯平台。

包含以下功能模块:
- tlon_api: Tlon API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- sse_client: SSE 客户端
- outbound: 外发消息管理
- send: 消息发送
- security: 安全校验
- auth: 认证管理
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- approval: 审批流程
- channel_mgmt: 频道管理
- channel_ops: 频道操作
- contacts: 联系人管理
- discovery: 服务发现
- doctor: 健康诊断
- expose: 服务暴露
- gallery: 图库管理
- history: 历史记录
- hooks: 钩子管理
- media: 媒体资源处理
- notebook: 笔记本功能
- settings_store: 设置存储
- setup: 初始化设置
- story: 故事功能
- targets: 目标管理
- cite_parser: 引用解析
- utils: 工具函数
- types: 类型定义
2026-05-21 11:55:25 +08:00

250 lines
9.2 KiB
Python

import asyncio
import logging
import uuid
from yuxi.channel.extensions.twitch.config import TwitchAccountConfig, normalize_twitch_channel
from yuxi.channel.extensions.twitch.token import denormalize_token, resolve_twitch_token
from yuxi.channel.extensions.twitch.types import TwitchChatMessage, TwitchSendResult
logger = logging.getLogger(__name__)
class TwitchClientManager:
def __init__(self, logger_=None):
self._logger = logger_ or logger
self._clients: dict[str, object] = {}
self._message_handlers: dict[str, callable] = {}
self._http_clients: dict[str, object] = {}
async def get_client(self, account: TwitchAccountConfig, account_id: str) -> object:
key = f"{account.username}:{account.channel}"
if key in self._clients:
return self._clients[key]
token, _source = resolve_twitch_token(account, account_id)
bare_token = denormalize_token(token)
from twitchio import Client
client = Client(
token=bare_token,
initial_channels=[account.channel],
)
self._setup_client_handlers(client, account, account_id)
self._clients[key] = client
asyncio.create_task(client.start())
return client
def _setup_client_handlers(self, client, account: TwitchAccountConfig, account_id: str):
manager = self
@client.event()
async def event_message(message):
if message.author is None:
return
emotes = _parse_emotes_from_tags(getattr(message, "tags", {}))
badges = _parse_badges_from_tags(getattr(message, "tags", {}))
handler = manager._message_handlers.get(f"{account.username}:{account.channel}")
if handler:
await handler(
TwitchChatMessage(
username=message.author.name,
display_name=message.author.display_name,
user_id=message.author.id,
message=message.content,
channel=normalize_twitch_channel(message.channel.name),
id=message.id,
is_mod=message.author.is_mod,
is_owner=message.author.is_broadcaster,
is_vip=message.author.is_vip,
is_sub=message.author.is_subscriber,
chat_type="group",
emotes=emotes,
badges=badges,
)
)
@client.event()
async def event_clearchat(message):
tags = getattr(message, "tags", {})
target_user = getattr(message, "target_user", None)
duration = tags.get("ban-duration")
handler = manager._message_handlers.get(f"{account.username}:{account.channel}")
if handler:
await handler(
TwitchChatMessage(
username="",
user_id=None,
display_name=None,
message="",
channel=normalize_twitch_channel(message.channel.name),
id=None,
chat_type="group",
event_type="clearchat",
target_user=target_user.name if target_user else None,
duration=int(duration) if duration else None,
)
)
def on_message(self, account: TwitchAccountConfig, handler):
key = f"{account.username}:{account.channel}"
self._message_handlers[key] = handler
def get_http_client(self, account: TwitchAccountConfig):
if not account.client_id or not account.client_secret:
self._logger.debug("HTTPClient not available: missing client_id/client_secret")
return None
key = f"http:{account.username}:{account.channel}"
if key not in self._http_clients:
from twitchio import HTTPClient
self._http_clients[key] = HTTPClient(
client_id=account.client_id,
client_secret=account.client_secret,
)
return self._http_clients[key]
async def send_message(
self, account: TwitchAccountConfig, channel: str, text: str, reply_to_id: str | None = None
) -> TwitchSendResult:
if reply_to_id:
return await self.send_reply(account, channel, text, reply_to_id)
key = f"{account.username}:{account.channel}"
client = self._clients.get(key)
if not client:
return TwitchSendResult(ok=False, error="Client not connected")
normalized_channel = normalize_twitch_channel(channel)
target = client.get_channel(normalized_channel)
if target is None:
return TwitchSendResult(ok=False, error=f"Channel {normalized_channel} not found")
message_id = _generate_message_id()
await target.send(text)
return TwitchSendResult(ok=True, message_id=message_id)
async def send_reply(
self,
account: TwitchAccountConfig,
channel: str,
text: str,
reply_to_id: str,
) -> TwitchSendResult:
http = self.get_http_client(account)
if http is None:
return TwitchSendResult(ok=False, error="HTTPClient not available (need client_id + client_secret)")
import asyncio
try:
users = await asyncio.wait_for(http.get_users(logins=[channel]), timeout=10)
if not users:
return TwitchSendResult(ok=False, error=f"User '{channel}' not found")
broadcaster_id = users[0].id
bot_users = await asyncio.wait_for(http.get_users(logins=[account.username]), timeout=10)
if not bot_users:
return TwitchSendResult(ok=False, error=f"Bot user '{account.username}' not found")
moderator_id = bot_users[0].id
token, _ = resolve_twitch_token(account)
await http.post_chat_messages(
token=denormalize_token(token),
broadcaster_id=broadcaster_id,
moderator_id=moderator_id,
message=text,
reply_parent_message_id=reply_to_id,
)
return TwitchSendResult(ok=True, message_id=_generate_message_id())
except Exception as e:
self._logger.warning("send_reply failed: %s", e)
return TwitchSendResult(ok=False, error=str(e))
async def disconnect(self, account: TwitchAccountConfig):
key = f"{account.username}:{account.channel}"
client = self._clients.pop(key, None)
if client:
await client.close()
self._message_handlers.pop(key, None)
http_key = f"http:{account.username}:{account.channel}"
http = self._http_clients.pop(http_key, None)
if http:
await http.close()
async def disconnect_all(self):
for client in list(self._clients.values()):
await client.close()
self._clients.clear()
self._message_handlers.clear()
for http in list(self._http_clients.values()):
await http.close()
self._http_clients.clear()
class ClientManagerRegistry:
def __init__(self):
self._registry: dict[str, TwitchClientManager] = {}
self._eventsub_registry: dict[str, object] = {}
def get_or_create(self, account_id: str, logger_=None) -> TwitchClientManager:
if account_id not in self._registry:
self._registry[account_id] = TwitchClientManager(logger_)
return self._registry[account_id]
def get(self, account_id: str) -> TwitchClientManager | None:
return self._registry.get(account_id)
def get_eventsub(self, account_id: str) -> object | None:
return self._eventsub_registry.get(account_id)
def set_eventsub(self, account_id: str, manager: object):
self._eventsub_registry[account_id] = manager
async def remove(self, account_id: str):
manager = self._registry.pop(account_id, None)
if manager:
await manager.disconnect_all()
eventsub = self._eventsub_registry.pop(account_id, None)
if eventsub:
await eventsub.stop()
async def remove_all(self):
for manager in list(self._registry.values()):
await manager.disconnect_all()
self._registry.clear()
for eventsub in list(self._eventsub_registry.values()):
await eventsub.stop()
self._eventsub_registry.clear()
client_manager_registry = ClientManagerRegistry()
def _generate_message_id() -> str:
return uuid.uuid4().hex
def _parse_emotes_from_tags(tags: dict) -> list[dict]:
emotes_raw = tags.get("emotes", "")
if not emotes_raw:
return []
result = []
for emote_entry in emotes_raw.split("/"):
if ":" not in emote_entry:
continue
emote_id, positions = emote_entry.split(":", 1)
result.append({"id": emote_id, "positions": positions})
return result
def _parse_badges_from_tags(tags: dict) -> list[str]:
badges_raw = tags.get("badges", "")
if not badges_raw:
return []
return [b.split("/")[0] for b in badges_raw.split(",") if b]