ForcePilot/backend/package/yuxi/channels/adapters/line/normalizer.py
Kris a4ec94ef9d feat(line): 实现完整的 LINE 聊天适配器功能
新增 LINE 官方账号对接的全套功能,包括:
1. 基础的 Bot 探测、会话解析、消息格式化能力
2. 富媒体消息模板、快速回复、卡片指令支持
3. Webhook 签名验证、重放防护、多账户路由管理
4. 消息发送、回复、分块传输、用户绑定管理
5. 交互式配置向导与诊断工具
2026-05-12 00:45:33 +08:00

211 lines
8.4 KiB
Python

from __future__ import annotations
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MessageType,
)
class LINEEventNormalizer:
def __init__(self, channel_id: str = "line"):
self._channel_id = channel_id
def normalize(self, event: dict) -> ChannelMessage:
source = event.get("source", {})
source_type = source.get("type", "user")
source_id = source.get("userId") or source.get("groupId") or source.get("roomId") or "unknown"
chat_type = ChatType.DIRECT if source_type == "user" else ChatType.GROUP
chat_id = self._resolve_chat_id(source_type, source_id)
event_type = self._resolve_event_type(event.get("type", ""))
reply_token = event.get("replyToken", "")
message_obj = event.get("message", {})
msg_type_str = message_obj.get("type", "text")
raw_event_type = event.get("type", "")
if raw_event_type == "postback":
content = event.get("postback", {}).get("data", "")
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "follow":
content = "用户关注了 Bot"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "join":
content = "Bot 加入群组/房间"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "unfollow":
content = "用户取消关注了 Bot"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "leave":
content = "Bot 离开了群组/房间"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "memberJoined":
content = self._build_member_event_content(event, "joined")
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "memberLeft":
content = self._build_member_event_content(event, "left")
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "unsend":
unsend_msg_id = event.get("unsend", {}).get("messageId", "")
content = f"用户撤回了消息 ({unsend_msg_id})"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "beacon":
beacon = event.get("beacon", {})
content = f"Beacon 事件: {beacon.get('type', '')} (hwid: {beacon.get('hwid', '')})"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "accountLink":
link_data = event.get("link", {})
content = f"账号关联: result={link_data.get('result', '')}"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "things":
things_data = event.get("things", {})
content = f"Things 事件: {things_data.get('type', '')} (deviceId: {things_data.get('deviceId', '')})"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "activated":
content = "Chat Control: 频道已激活"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "deactivated":
content = "Chat Control: 频道已停用"
msg_type = MessageType.TEXT
attachments = []
elif raw_event_type == "videoPlayComplete":
tracking_id = event.get("videoPlayComplete", {}).get("trackingId", "")
content = f"视频播放完成 (trackingId: {tracking_id})"
msg_type = MessageType.TEXT
attachments = []
else:
content, msg_type, attachments = self._extract_content(msg_type_str, message_obj)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self._channel_id,
channel_type=ChannelType.LINE,
channel_user_id=source_id,
channel_chat_id=chat_id,
channel_message_id=message_obj.get("id") or event.get("webhookEventId", ""),
),
event_type=event_type,
message_type=msg_type,
chat_type=chat_type,
content=content,
attachments=attachments,
metadata={
"source_type": source_type,
"reply_token": reply_token,
"raw_event_type": event.get("type", ""),
"raw_msg_type": msg_type_str,
"timestamp_ms": event.get("timestamp", 0),
},
)
@staticmethod
def _resolve_chat_id(source_type: str, source_id: str) -> str:
prefix_map = {"user": "user_", "group": "group_", "room": "room_"}
return f"{prefix_map.get(source_type, 'unknown_')}{source_id}"
@staticmethod
def _resolve_event_type(raw_type: str) -> EventType:
mapping = {
"message": EventType.MESSAGE_RECEIVED,
"follow": EventType.BOT_ADDED,
"unfollow": EventType.BOT_REMOVED,
"join": EventType.MEMBER_JOINED,
"leave": EventType.MEMBER_LEFT,
"memberJoined": EventType.MEMBER_JOINED,
"memberLeft": EventType.MEMBER_LEFT,
"postback": EventType.CARD_ACTION,
"unsend": EventType.MESSAGE_DELETED,
"beacon": EventType.SYSTEM_EVENT,
"accountLink": EventType.SYSTEM_EVENT,
"things": EventType.SYSTEM_EVENT,
"activated": EventType.SYSTEM_EVENT,
"deactivated": EventType.SYSTEM_EVENT,
"videoPlayComplete": EventType.SYSTEM_EVENT,
}
return mapping.get(raw_type, EventType.MESSAGE_RECEIVED)
@staticmethod
def _extract_content(msg_type: str, msg: dict) -> tuple[str, MessageType, list[Attachment]]:
if msg_type == "text":
text = msg.get("text", "")
mention_info = LINEEventNormalizer._extract_native_mentions(msg)
if mention_info:
text = f"{text}\n[Mentions: {mention_info}]"
return text, MessageType.TEXT, []
if msg_type == "image":
return "", MessageType.IMAGE, [Attachment(type="image", file_id=msg.get("id"))]
if msg_type == "video":
return "", MessageType.VIDEO, [Attachment(type="video", file_id=msg.get("id"))]
if msg_type == "audio":
duration = msg.get("duration", 0)
return f"Audio ({duration}ms)", MessageType.AUDIO, [Attachment(type="audio", file_id=msg.get("id"))]
if msg_type == "file":
filename = msg.get("file_name", "unknown")
size = msg.get("file_size", 0)
return (
f"File: {filename} ({size} bytes)",
MessageType.FILE,
[Attachment(type="file", file_id=msg.get("id"), filename=filename, size_bytes=size)],
)
if msg_type == "location":
title = msg.get("title", "")
address = msg.get("address", "")
return f"Location: {title} ({address})", MessageType.LOCATION, []
if msg_type == "sticker":
pkg_id = msg.get("package_id", "")
sticker_id = msg.get("sticker_id", "")
return f"Sticker (pkg:{pkg_id}, id:{sticker_id})", MessageType.TEXT, []
return str(msg)[:500], MessageType.TEXT, []
@staticmethod
def _build_member_event_content(event: dict, action: str) -> str:
members = (
event.get("members") or event.get("left", {}).get("members") or event.get("joined", {}).get("members") or []
)
source = event.get("source", {})
group_id = source.get("groupId") or source.get("roomId") or "unknown"
user_ids = [m.get("userId", "?") for m in members]
if action == "joined":
users_str = ", ".join(user_ids)
return f"[{group_id}] 新成员加入: {users_str}"
else:
users_str = ", ".join(user_ids)
return f"[{group_id}] 成员离开: {users_str}"
@staticmethod
def _extract_native_mentions(msg: dict) -> str | None:
mention = msg.get("mention")
if not mention or not isinstance(mention, dict):
return None
mentionees = mention.get("mentionees")
if not mentionees:
return None
user_ids = [m.get("userId", "?") for m in mentionees if isinstance(m, dict)]
return ", ".join(user_ids) if user_ids else None