新增 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: 类型定义
250 lines
9.2 KiB
Python
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]
|