from __future__ import annotations import json import logging import re from collections import OrderedDict from datetime import datetime, UTC from yuxi.channel.extensions.line.dedupe import LineEventDeduplicator, build_event_dedupe_key from yuxi.channel.extensions.line.types import LineWebhookEvent, LineWebhookSource, LineMention, LineMentionee from yuxi.channel.message.models import ( GroupContext, MessageType, PeerInfo, UnifiedMessage, ) from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) MENTION_PATTERN = re.compile(r"@\S+") GROUP_HISTORY_MAX_ENTRIES = 100 GROUP_HISTORY_TTL_SECONDS = 3600 class LineMonitor: delivery_mode = "direct" def __init__(self): self._dedupe = LineEventDeduplicator() self._group_history: OrderedDict[str, list[dict]] = OrderedDict() self._bot_mention_patterns: list[str] = [] self._group_configs: dict[str, dict] = {} def set_bot_mention_patterns(self, patterns: list[str]) -> None: self._bot_mention_patterns = patterns def set_group_configs(self, group_configs: dict[str, dict]) -> None: self._group_configs = group_configs def parse_webhook_body(self, raw_body: bytes) -> dict: try: return json.loads(raw_body) except json.JSONDecodeError: logger.warning("LINE webhook: invalid JSON body") return {} def parse_events(self, payload: dict) -> list[dict]: return payload.get("events", []) def parse_event_to_unified( self, event: dict, account_id: str, channel_access_token: str = "", display_name: str | None = None, ) -> UnifiedMessage | None: event_obj = self._parse_webhook_event(event) if event_obj.type != "message": logger.debug("LINE non-message event: type=%s", event_obj.type) return None dedupe_key = build_event_dedupe_key(account_id, event) if self._dedupe.is_duplicate(dedupe_key): logger.debug("LINE duplicate event: key=%s", dedupe_key) return None source = event_obj.source if not source: return None is_group = source.type in ("group", "room") group_id = source.group_id or source.room_id or "" mention = self._parse_mention(event.get("message", {}).get("mention", {})) text_content = event.get("message", {}).get("text", "") is_bot_mentioned = self._check_group_mention(mention, event_obj, text_content) if is_group else False if is_group and not is_bot_mentioned: self._record_group_history(group_id, source.user_id or "unknown", text_content, event_obj.timestamp) sender = PeerInfo( kind=PeerKind.GROUP if is_group else PeerKind.DIRECT, id=source.user_id or "unknown", display_name=display_name, is_bot=False, is_self=False, ) group: GroupContext | None = None if is_group and group_id: group = GroupContext( id=group_id, name=None, ) msg_type, content, media_urls = self._extract_message_content(event.get("message", {}), channel_access_token) timestamp = None if event_obj.timestamp: try: timestamp = datetime.fromtimestamp(event_obj.timestamp / 1000, tz=UTC) except (OSError, ValueError): pass return UnifiedMessage( msg_id=f"line:{event.get('message', {}).get('id', '')}", channel_type="line", account_id=account_id, content=content, sender=sender, message_type=msg_type, media_urls=media_urls, group=group, timestamp=timestamp, raw_payload=event, reply_to_id=event_obj.reply_token, metadata={ "reply_token": event_obj.reply_token, "source_type": source.type, "is_group": is_group, "is_bot_mentioned": is_bot_mentioned, "webhook_event_id": event_obj.webhook_event_id, "quote_token": event.get("message", {}).get("quoteToken"), **_resolve_group_metadata(self._group_configs, group_id), }, ) def parse_postback_to_unified( self, event: dict, account_id: str, channel_access_token: str = "", ) -> UnifiedMessage | None: event_obj = self._parse_webhook_event(event) if event_obj.type != "postback": return None source = event_obj.source if not source: return None postback_data = event.get("postback", {}).get("data", "") params = event.get("postback", {}).get("params", {}) sender = PeerInfo( kind=PeerKind.DIRECT, id=source.user_id or "unknown", display_name=None, is_bot=False, is_self=False, ) timestamp = None if event_obj.timestamp: try: timestamp = datetime.fromtimestamp(event_obj.timestamp / 1000, tz=UTC) except (OSError, ValueError): pass return UnifiedMessage( msg_id=f"line:postback:{event_obj.webhook_event_id}", channel_type="line", account_id=account_id, content=f"[Postback] {postback_data}" if postback_data else "[Postback]", sender=sender, message_type=MessageType.EVENT, media_urls=[], timestamp=timestamp, raw_payload=event, reply_to_id=event_obj.reply_token, metadata={ "reply_token": event_obj.reply_token, "source_type": source.type, "postback_data": postback_data, "postback_params": params, "webhook_event_id": event_obj.webhook_event_id, "event_type": "postback", }, ) def parse_follow_to_unified( self, event: dict, account_id: str, channel_access_token: str = "", ) -> UnifiedMessage | None: event_obj = self._parse_webhook_event(event) if event_obj.type != "follow": return None source = event_obj.source if not source: return None sender = PeerInfo( kind=PeerKind.DIRECT, id=source.user_id or "unknown", display_name=None, is_bot=False, is_self=False, ) timestamp = None if event_obj.timestamp: try: timestamp = datetime.fromtimestamp(event_obj.timestamp / 1000, tz=UTC) except (OSError, ValueError): pass return UnifiedMessage( msg_id=f"line:follow:{event_obj.webhook_event_id}", channel_type="line", account_id=account_id, content="[关注事件] 新用户关注了 Bot", sender=sender, message_type=MessageType.EVENT, media_urls=[], timestamp=timestamp, raw_payload=event, reply_to_id=event_obj.reply_token, metadata={ "reply_token": event_obj.reply_token, "source_type": source.type, "event_type": "follow", "webhook_event_id": event_obj.webhook_event_id, }, ) def _parse_webhook_event(self, event: dict) -> LineWebhookEvent: source_raw = event.get("source", {}) source = LineWebhookSource( type=source_raw.get("type", ""), user_id=source_raw.get("userId"), group_id=source_raw.get("groupId"), room_id=source_raw.get("roomId"), ) return LineWebhookEvent( type=event.get("type", ""), mode=event.get("mode", ""), timestamp=event.get("timestamp", 0), source=source, reply_token=event.get("replyToken"), webhook_event_id=event.get("webhookEventId", ""), delivery_context=event.get("deliveryContext", {}), message=event.get("message", {}), postback=event.get("postback", {}), mention=self._parse_mention(event.get("message", {}).get("mention", {})), raw=event, ) @staticmethod def _parse_mention(mention_data: dict) -> LineMention | None: if not mention_data: return None mentionees = [] for m in mention_data.get("mentionees", []): mentionees.append(LineMentionee( index=m.get("index", 0), length=m.get("length", 0), user_id=m.get("userId"), type=m.get("type"), is_self=m.get("isSelf", False), )) return LineMention(mentionees=mentionees) if mentionees else None @staticmethod def _is_bot_mentioned(mention: LineMention | None, event: LineWebhookEvent) -> bool: if mention: for m in mention.mentionees: if m.is_self or m.type == "all": return True return False def _is_bot_mentioned_by_pattern(self, text: str) -> bool: if not text or not self._bot_mention_patterns: return False for pattern in self._bot_mention_patterns: if re.search(pattern, text, re.IGNORECASE): return True return False def _check_group_mention(self, mention: LineMention | None, event: LineWebhookEvent, text: str) -> bool: native = self._is_bot_mentioned(mention, event) pattern = self._is_bot_mentioned_by_pattern(text) return native or pattern @staticmethod def _extract_message_content( message: dict, channel_access_token: str, ) -> tuple[MessageType, str, list[str]]: msg_type = message.get("type", "text") media_urls: list[str] = [] match msg_type: case "text": return MessageType.TEXT, message.get("text", ""), media_urls case "image": content_provider = message.get("contentProvider", {}) if content_provider.get("type") == "external": original_url = content_provider.get("originalContentUrl", "") if original_url: media_urls.append(original_url) return MessageType.IMAGE, "[图片]", media_urls case "video": content_provider = message.get("contentProvider", {}) if content_provider.get("type") == "external": original_url = content_provider.get("originalContentUrl", "") if original_url: media_urls.append(original_url) return MessageType.FILE, "[视频]", media_urls case "audio": return MessageType.VOICE, "[语音消息]", media_urls case "file": filename = message.get("fileName", "unknown") return MessageType.FILE, f"[文件] {filename}", media_urls case "location": loc_title = message.get("title", "") loc_address = message.get("address", "") return MessageType.TEXT, f"[位置] {loc_title} - {loc_address}", media_urls case "sticker": sticker_keywords = message.get("keywords", []) sticker_text = ", ".join(sticker_keywords) if sticker_keywords else "贴纸" return MessageType.EVENT, f"[贴纸] {sticker_text}", media_urls case _: return MessageType.TEXT, f"[{msg_type}]", media_urls def _record_group_history( self, group_id: str, sender_id: str, text: str, timestamp: int, ) -> None: if not group_id: return now = datetime.now(UTC).timestamp() if group_id not in self._group_history: self._group_history[group_id] = [] entry = { "sender": f"user:{sender_id}", "body": text or "", "timestamp": timestamp or int(now * 1000), } self._group_history[group_id].append(entry) while len(self._group_history[group_id]) > GROUP_HISTORY_MAX_ENTRIES: self._group_history[group_id].pop(0) def get_group_history(self, group_id: str) -> list[dict]: self._evict_expired_group_history() return self._group_history.get(group_id, []) def clear_group_history(self, group_id: str) -> None: self._group_history.pop(group_id, None) def _evict_expired_group_history(self) -> None: now = datetime.now(UTC).timestamp() cutoff = now - GROUP_HISTORY_TTL_SECONDS expired_groups = [] for group_id, entries in self._group_history.items(): entries[:] = [ e for e in entries if (e.get("timestamp", 0) / 1000) > cutoff ] if not entries: expired_groups.append(group_id) for group_id in expired_groups: del self._group_history[group_id] def _resolve_group_metadata(group_configs: dict, group_id: str) -> dict: if not group_id: return {} group_cfg = group_configs.get(group_id, {}) if not group_cfg: return {} result: dict = {} system_prompt = group_cfg.get("system_prompt") if system_prompt: result["group_system_prompt"] = system_prompt skills = group_cfg.get("skills") if skills: result["group_skills"] = skills return result