新增 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: 类型定义
259 lines
8.9 KiB
Python
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}"
|