ForcePilot/backend/package/yuxi/channels/adapters/twitch/normalizer.py
Kris 59cd13cf84 feat(twitch): 实现完整的Twitch聊天适配器模块
新增Twitch IRC协议相关的全套实现,包括:
1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重
2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理
3. API客户端:Helix API封装、认证提供者
4. 配置与部署:配置校验、设置向导
5. 辅助功能:配对管理、健康检查、目标解析等
2026-05-12 00:50:10 +08:00

234 lines
7.5 KiB
Python

from __future__ import annotations
from datetime import datetime, UTC
from typing import Any
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
from .irc_parser import (
ParsedIRCMessage,
extract_nick,
parse_badges,
parse_emotes,
make_irc_message_id,
)
_FILTERED_NOTICE_MSG_IDS = frozenset(
{
"subs_on",
"subs_off",
"emote_only_on",
"emote_only_off",
"slow_on",
"slow_off",
"followers_on",
"followers_off",
"r9k_on",
"r9k_off",
"host_on",
"host_off",
}
)
_EVENT_MAP: dict[str, str] = {
"sub": "subscription",
"resub": "resubscription",
"subgift": "subscription_gift",
"anonsubgift": "anonymous_subscription_gift",
"submysterygift": "subscription_mystery_gift",
"raid": "raid",
"ritual": "ritual",
"announcement": "announcement",
}
_SKIP_COMMANDS = frozenset({"JOIN", "PART", "HOSTTARGET", "ROOMSTATE", "USERSTATE"})
def _make_identity(
channel_user_id: str,
channel_chat_id: str,
channel_message_id: str | None = None,
) -> ChannelIdentity:
return ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id=channel_user_id,
channel_chat_id=channel_chat_id,
channel_message_id=channel_message_id,
)
def _make_timestamp(tmi_sent_ts: str | None) -> datetime:
ts = int(tmi_sent_ts or "0")
if ts:
return datetime.fromtimestamp(ts / 1000, tz=UTC)
return datetime.now(UTC)
def resolve_mentions(content: str, bot_username: str | None = None) -> MentionsInfo:
mentioned: list[str] = []
is_bot_mentioned = False
bot_lower = bot_username.lower() if bot_username else None
for word in content.split():
if word.startswith("@"):
name = word[1:].rstrip(",.")
mentioned.append(name)
if bot_lower and name.lower() == bot_lower:
is_bot_mentioned = True
return MentionsInfo(
mentioned_user_ids=mentioned,
is_bot_mentioned=is_bot_mentioned,
)
def normalize_irc_message(parsed: ParsedIRCMessage) -> ChannelMessage | None:
command = parsed.command
tags = parsed.tags
# ---- PRIVMSG: 普通聊天消息 ----
if command == "PRIVMSG":
channel = parsed.params[0] if parsed.params else ""
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
message_id = tags.get("id") or tags.get("tmi-sent-ts") or make_irc_message_id(user_id)
identity = _make_identity(user_id, channel, message_id)
bits = int(tags.get("bits", "0"))
badges = parse_badges(tags.get("badges", ""))
emotes = parse_emotes(tags.get("emotes", ""), parsed.trailing)
metadata: dict[str, Any] = {
"display_name": tags.get("display-name", user_id),
"color": tags.get("color"),
"badges": badges,
"emotes": emotes,
"bits": bits,
"is_mod": tags.get("mod") == "1",
"is_subscriber": tags.get("subscriber") == "1",
"is_turbo": tags.get("turbo") == "1",
"user_type": tags.get("user-type"),
"tmi_sent_ts": tags.get("tmi-sent-ts"),
}
return ChannelMessage(
identity=identity,
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=parsed.trailing,
mentions=resolve_mentions(parsed.trailing),
metadata=metadata,
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
)
# ---- USERNOTICE: 订阅/赠礼/Raid/新人 ----
if command == "USERNOTICE":
msg_id = tags.get("msg-id", "")
channel = parsed.params[0] if parsed.params else ""
user_id = tags.get("user-id", "system")
message_id = tags.get("id") or make_irc_message_id(user_id, tags.get("tmi-sent-ts"))
identity = _make_identity(user_id, channel, message_id)
metadata = {
"event": _EVENT_MAP.get(msg_id, msg_id),
"display_name": tags.get("display-name", user_id),
"msg_param_sub_plan": tags.get("msg-param-sub-plan"),
"msg_param_months": tags.get("msg-param-cumulative-months"),
"msg_param_recipient": tags.get("msg-param-recipient-user-name"),
"msg_param_gift_count": tags.get("msg-param-gift-months"),
"msg_param_viewer_count": tags.get("msg-param-viewerCount"),
"system_msg": tags.get("system-msg", parsed.trailing),
}
return ChannelMessage(
identity=identity,
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=tags.get("system-msg", parsed.trailing),
metadata=metadata,
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
)
# ---- CLEARCHAT: 消息删除/用户 timeout ----
if command == "CLEARCHAT":
channel = parsed.params[0] if parsed.params else ""
target_user = tags.get("target-user-id", "") or parsed.trailing
ban_duration = tags.get("ban-duration", "0")
return ChannelMessage(
identity=_make_identity(target_user, channel),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content="",
metadata={
"event": "clearchat",
"ban_duration_sec": int(ban_duration),
},
timestamp=datetime.now(UTC),
)
# ---- WHISPER: 私信 ----
if command == "WHISPER":
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
message_id = tags.get("message-id") or make_irc_message_id(user_id)
identity = _make_identity(user_id, f"#whisper_{user_id}", message_id)
display_name = tags.get("display-name", user_id)
metadata: dict[str, Any] = {
"display_name": display_name,
"color": tags.get("color"),
"badges": parse_badges(tags.get("badges", "")),
"emotes": parse_emotes(tags.get("emotes", ""), parsed.trailing),
"thread_id": tags.get("thread-id"),
}
return ChannelMessage(
identity=identity,
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content=parsed.trailing,
mentions=resolve_mentions(parsed.trailing),
metadata=metadata,
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
)
# ---- NOTICE: 系统通知 ----
if command == "NOTICE":
channel = parsed.params[0] if parsed.params else ""
msg_id = tags.get("msg-id", "")
if msg_id in _FILTERED_NOTICE_MSG_IDS:
return None
return ChannelMessage(
identity=_make_identity("twitch_system", channel),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=parsed.trailing,
metadata={"event": "notice", "msg_id": msg_id},
timestamp=datetime.now(UTC),
)
# ---- PING → 内部处理 ----
if command == "PING":
return None
# ---- JOIN/PART/HOSTTARGET/ROOMSTATE/USERSTATE → 内部状态 ----
if command in _SKIP_COMMANDS:
return None
return None