ForcePilot/backend/package/yuxi/channels/adapters/twitch/normalizer.py
Kris 18d1ea2aac refactor(twitch): 重构Twitch适配器,新增Helix API支持与功能优化
本次提交对Twitch适配器进行了全面升级与优化:
1.  修复UTF8截断逻辑,避免越界访问
2.  重构群聊策略配置,标准化mention相关规则
3.  新增消息缓存管理器,支持通过消息ID查询已发送消息
4.  更新配置schema,新增prefer_helix_send开关和deprecated策略自动转换
5.  新增CLEARMSG和ROOMSTATE IRC消息解析,补充事件订阅支持
6.  优化令牌刷新逻辑,增加重试机制与退避策略
7.  新增Helix API聊天消息发送、删除和公告功能
8.  扩展事件订阅类型,新增直播状态、频道更新等系统事件
9.  新增reply、delete_message、announcement等动作支持,完善操作能力
10. 重构流式发送逻辑,新增进度指示器和配置项
11. 优化重连策略,增加指数退避与计数重置
2026-05-13 16:16:02 +08:00

279 lines
9.1 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
from .irc_parser import (
ParsedIRCMessage,
extract_nick,
make_irc_message_id,
parse_badges,
parse_emotes,
)
_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", "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),
)
# ---- CLEARMSG: 单条消息删除 ----
if command == "CLEARMSG":
channel = parsed.params[0] if parsed.params else ""
target_msg_id = tags.get("target-msg-id", "")
login = tags.get("login", "")
target_user_id = tags.get("target-user-id", "")
return ChannelMessage(
identity=_make_identity(target_user_id or "twitch_system", channel, target_msg_id),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content="",
metadata={
"event": "clearmsg",
"target_msg_id": target_msg_id,
"login": login,
},
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")),
)
# ---- ROOMSTATE: 房间状态变更 ----
if command == "ROOMSTATE":
channel = parsed.params[0] if parsed.params else ""
room_state_flags = {
"emote_only": tags.get("emote-only") == "1",
"subs_only": tags.get("subs-only") == "1",
"followers_only": tags.get("followers-only", "-1"),
"slow": tags.get("slow", "0"),
"r9k": tags.get("r9k") == "1",
}
return ChannelMessage(
identity=_make_identity("twitch_system", channel),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content="Room state changed",
metadata={
"event": "roomstate",
"room_state": room_state_flags,
},
timestamp=datetime.now(UTC),
)
# ---- 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