新增 LINE 官方账号对接的全套功能,包括: 1. 基础的 Bot 探测、会话解析、消息格式化能力 2. 富媒体消息模板、快速回复、卡片指令支持 3. Webhook 签名验证、重放防护、多账户路由管理 4. 消息发送、回复、分块传输、用户绑定管理 5. 交互式配置向导与诊断工具
211 lines
8.4 KiB
Python
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
|