ForcePilot/backend/package/yuxi/channel/extensions/twitch/monitor.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

259 lines
8.9 KiB
Python

import asyncio
import logging
import time
from yuxi.channel.message.models import (
GroupContext,
MessageType,
PeerInfo,
PeerKind,
UnifiedMessage,
)
from yuxi.channel.extensions.twitch.access_control import check_twitch_access_control
from yuxi.channel.extensions.twitch.client import client_manager_registry
from yuxi.channel.extensions.twitch.config import TwitchAccountConfig, TwitchConfig
from yuxi.channel.extensions.twitch.token import refresh_twitch_token, validate_twitch_token
from yuxi.channel.extensions.twitch.types import TwitchChatMessage, TwitchEventSubNotification
logger = logging.getLogger(__name__)
async def monitor_twitch_provider(
account: TwitchAccountConfig,
account_id: str,
config: TwitchConfig,
runtime,
abort_signal,
):
manager = client_manager_registry.get_or_create(account_id, runtime.logger)
stopped = False
async def on_abort():
nonlocal stopped
stopped = True
abort_signal.add_done_callback(lambda _: asyncio.create_task(on_abort()))
bot_username = account.username.lower()
async def message_handler(message: TwitchChatMessage):
if stopped:
return
if message.username.lower() == bot_username:
return
access_result = check_twitch_access_control(
message=message,
account=account,
bot_username=bot_username,
)
if not access_result.allowed:
return
runtime.logger.info(
"Twitch inbound: channel=%s user=%s msg_id=%s",
message.channel,
message.username,
message.id,
)
asyncio.create_task(
process_twitch_message(
message=message,
account=account,
account_id=account_id,
config=config,
runtime=runtime,
)
)
manager.on_message(account, message_handler)
validation = await validate_twitch_token(account)
if not validation.get("valid"):
logger.warning(
"Twitch token invalid for account=%s: %s",
account_id,
validation.get("message") or validation.get("error") or validation.get("reason", "unknown"),
)
if account.refresh_token and account.client_id and account.client_secret:
refresh_result = await refresh_twitch_token(account)
if refresh_result.get("ok"):
logger.info("Twitch token refreshed for account=%s", account_id)
account.access_token = refresh_result["access_token"]
account.refresh_token = refresh_result.get("refresh_token", account.refresh_token)
account.expires_in = refresh_result.get("expires_in")
account.obtainment_timestamp = time.time()
else:
logger.error(
"Twitch token refresh failed for account=%s: %s",
account_id,
refresh_result.get("error"),
)
raise ValueError(f"Twitch token invalid and refresh failed: {refresh_result.get('error')}")
else:
raise ValueError("Twitch token invalid and no refresh_token configured")
_client = await manager.get_client(account, account_id)
if config.eventsub_enabled and account.client_id and account.client_secret:
from yuxi.channel.extensions.twitch.eventsub import TwitchEventSubManager
eventsub = TwitchEventSubManager(runtime.logger)
async def eventsub_handler(notification: TwitchEventSubNotification):
if stopped:
return
runtime.logger.info(
"Twitch EventSub: type=%s channel=%s",
notification.subscription_type,
notification.channel,
)
asyncio.create_task(
process_eventsub_notification(
notification=notification,
account=account,
account_id=account_id,
config=config,
runtime=runtime,
)
)
eventsub.on_notification(eventsub_handler)
client_manager_registry.set_eventsub(account_id, eventsub)
asyncio.create_task(eventsub.start(account, account_id))
while not stopped:
await asyncio.sleep(1)
async def process_twitch_message(
message: TwitchChatMessage,
account: TwitchAccountConfig,
account_id: str,
config: TwitchConfig,
runtime,
):
sender_id = message.user_id or message.username
metadata = {
"twitch_channel": message.channel,
"twitch_is_mod": message.is_mod,
"twitch_is_vip": message.is_vip,
"twitch_is_owner": message.is_owner,
"twitch_is_sub": message.is_sub,
}
if message.emotes:
metadata["twitch_emotes"] = message.emotes
if message.badges:
metadata["twitch_badges"] = message.badges
if message.event_type:
metadata["twitch_event_type"] = message.event_type
if message.target_user:
metadata["twitch_target_user"] = message.target_user
if message.duration is not None:
metadata["twitch_duration"] = message.duration
unified = UnifiedMessage(
msg_id=message.id or f"twitch:{int(time.time() * 1000)}:{sender_id}",
channel_type="twitch",
account_id=account_id,
content=message.message,
sender=PeerInfo(
kind=PeerKind.USER,
id=sender_id,
display_name=message.display_name or message.username,
username=message.username,
),
group=GroupContext(
id=message.channel,
name=message.channel,
route_peer_kind="group",
route_peer_id=message.channel,
),
message_type=MessageType.TEXT,
timestamp=time.time(),
metadata=metadata,
)
if runtime.queue is not None:
await runtime.queue.put(unified)
async def process_eventsub_notification(
notification: TwitchEventSubNotification,
account: TwitchAccountConfig,
account_id: str,
config: TwitchConfig,
runtime,
):
event = notification.event
content = _format_eventsub_content(notification.subscription_type, event)
sender_name = event.get("user_name") or event.get("broadcaster_user_name") or "twitch"
sender_id = event.get("user_id") or event.get("broadcaster_user_id") or "system"
channel = notification.channel or account.channel
unified = UnifiedMessage(
msg_id=notification.id,
channel_type="twitch",
account_id=account_id,
content=content,
sender=PeerInfo(
kind=PeerKind.USER,
id=sender_id,
display_name=sender_name,
username=sender_name,
),
group=GroupContext(
id=channel,
name=channel,
route_peer_kind="group",
route_peer_id=channel,
),
message_type=MessageType.TEXT,
timestamp=time.time(),
metadata={
"twitch_channel": channel,
"twitch_event_type": notification.subscription_type,
"twitch_event_raw": event,
},
)
if runtime.queue is not None:
await runtime.queue.put(unified)
def _format_eventsub_content(sub_type: str, event: dict) -> str:
formatters = {
"channel.follow": lambda e: f"[follow] {e.get('user_name', 'Someone')} followed the channel!",
"channel.subscribe": lambda e: f"[subscribe] {e.get('user_name', 'Someone')} subscribed!",
"channel.subscription.gift": lambda e: (
f"[gift] {e.get('user_name', 'Someone')} gifted {e.get('total', 1)} subscriptions!"
),
"channel.subscription.message": lambda e: (
f"[resub] {e.get('user_name', 'Someone')} resubscribed for "
f"{e.get('cumulative_months', '?')} months! "
f"Message: {e.get('message', {}).get('text', '')}"
),
"channel.cheer": lambda e: f"[cheer] {e.get('user_name', 'Anonymous')} cheered {e.get('bits', 0)} bits!",
"stream.online": lambda e: (
f"[stream.online] Stream started! Title: {e.get('title', 'N/A')}, Category: {e.get('category_name', 'N/A')}"
),
"stream.offline": lambda e: "[stream.offline] Stream ended.",
"channel.raid": lambda e: (
f"[raid] {e.get('from_broadcaster_user_name', 'Someone')} raided with {e.get('viewers', 0)} viewers!"
),
"channel.update": lambda e: (
f"[update] Channel updated. Title: {e.get('title', 'N/A')}, Category: {e.get('category_name', 'N/A')}"
),
"channel.ban": lambda e: f"[ban] {e.get('user_name', 'Someone')} was banned. Reason: {e.get('reason', 'N/A')}",
"channel.unban": lambda e: f"[unban] {e.get('user_name', 'Someone')} was unbanned.",
}
formatter = formatters.get(sub_type)
if formatter:
return formatter(event)
return f"[eventsub:{sub_type}] {event}"