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