新增 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: 类型定义
391 lines
14 KiB
Python
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
|