新增Twitch IRC协议相关的全套实现,包括: 1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重 2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理 3. API客户端:Helix API封装、认证提供者 4. 配置与部署:配置校验、设置向导 5. 辅助功能:配对管理、健康检查、目标解析等
234 lines
7.5 KiB
Python
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
|