ForcePilot/backend/package/yuxi/channel/extensions/line/monitor.py
Kris 7fd658af57 feat(channel): 添加 LINE 渠道扩展
新增 LINE 渠道扩展,支持在 Yuxi 平台中集成 LINE 即时通讯渠道。

包含以下功能模块:
- bot: LINE Bot 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- token_manager: Token 管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- flex_templates: Flex 模板消息
- card_command: 卡片指令处理
- template_messages: 模板消息
- rich_menu: 富菜单管理
- actions: 动作处理
- directives: 指令处理
- delivery: 消息送达确认
- loading: 加载动画
- media: 媒体资源处理
- types: 类型定义
2026-05-21 11:16:16 +08:00

391 lines
14 KiB
Python

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 "<non-text message>",
"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