新增 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: 类型定义
377 lines
11 KiB
Python
377 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from yuxi.channel.extensions.line.bot import LineBotClient
|
|
from yuxi.channel.extensions.line.config import LineConfigAdapter
|
|
from yuxi.channel.extensions.line.format import chunk_text
|
|
from yuxi.channel.protocols import OutboundDeliveryMode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LineOutboundAdapter:
|
|
delivery_mode = OutboundDeliveryMode.DIRECT
|
|
chunker_mode = "newline"
|
|
text_chunk_limit: int = 5000
|
|
poll_max_options: int | None = None
|
|
supports_poll_duration_seconds = False
|
|
supports_anonymous_polls = False
|
|
extract_markdown_images = False
|
|
presentation_capabilities = None
|
|
delivery_capabilities = None
|
|
|
|
def __init__(self):
|
|
self._config = LineConfigAdapter()
|
|
|
|
async def send_text(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
logger.error("LINE send_text: account not resolved")
|
|
return
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
|
|
chunks = chunk_text(content, account.get("text_chunk_limit", 5000))
|
|
messages = [{"type": "text", "text": chunk} for chunk in chunks]
|
|
|
|
if reply_to_id:
|
|
await client.reply_message(reply_to_id, messages[:5])
|
|
if len(messages) > 5:
|
|
for batch in _batch_list(messages[5:], 5):
|
|
await client.push_message(target_id, batch)
|
|
else:
|
|
for batch in _batch_list(messages, 5):
|
|
await client.push_message(target_id, batch)
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
duration_ms: int | None = None,
|
|
file_name: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message = self._build_media_message(media_url, media_type, duration_ms, file_name)
|
|
|
|
if not message:
|
|
return
|
|
|
|
if reply_to_id:
|
|
await client.reply_message(reply_to_id, [message])
|
|
else:
|
|
await client.push_message(target_id, [message])
|
|
|
|
async def send_flex(
|
|
self,
|
|
target_id: str,
|
|
alt_text: str,
|
|
contents: dict,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message = {
|
|
"type": "flex",
|
|
"altText": alt_text[:400],
|
|
"contents": contents,
|
|
}
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
async def send_template(
|
|
self,
|
|
target_id: str,
|
|
alt_text: str,
|
|
template: dict,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message = {
|
|
"type": "template",
|
|
"altText": alt_text[:400],
|
|
"template": template,
|
|
}
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
async def send_location(
|
|
self,
|
|
target_id: str,
|
|
title: str,
|
|
address: str,
|
|
latitude: float,
|
|
longitude: float,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message = {
|
|
"type": "location",
|
|
"title": title,
|
|
"address": address,
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
}
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
async def send_quick_replies(
|
|
self,
|
|
target_id: str,
|
|
text: str,
|
|
items: list[str],
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
quick_reply_items = [
|
|
{"type": "action", "action": {"type": "message", "label": item[:20], "text": item}}
|
|
for item in items[:13]
|
|
]
|
|
message = {
|
|
"type": "text",
|
|
"text": text,
|
|
"quickReply": {"items": quick_reply_items},
|
|
}
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
|
return chunk_text(text, limit)
|
|
|
|
def sanitize_text(self, text: str, payload: object) -> str:
|
|
return text
|
|
|
|
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
|
|
return True
|
|
|
|
def resolve_target(
|
|
self,
|
|
to: str | None = None,
|
|
*,
|
|
config: dict | None = None,
|
|
allow_from: list[str] | None = None,
|
|
account_id: str | None = None,
|
|
mode: str | None = None,
|
|
) -> tuple[bool, str]:
|
|
if not to:
|
|
return False, "target required"
|
|
return True, to
|
|
|
|
async def _resolve_account(self, account_id: str | None) -> dict | None:
|
|
if not account_id:
|
|
account_id = "default"
|
|
try:
|
|
return await self._config.resolve_account(account_id)
|
|
except Exception:
|
|
logger.exception("LINE resolve_account error")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _build_media_message(
|
|
media_url: str, media_type: str,
|
|
duration_ms: int | None = None,
|
|
file_name: str | None = None,
|
|
) -> dict | None:
|
|
match media_type:
|
|
case "image":
|
|
return {
|
|
"type": "image",
|
|
"originalContentUrl": media_url,
|
|
"previewImageUrl": media_url,
|
|
}
|
|
case "video":
|
|
return {
|
|
"type": "video",
|
|
"originalContentUrl": media_url,
|
|
"previewImageUrl": media_url,
|
|
}
|
|
case "audio":
|
|
return {
|
|
"type": "audio",
|
|
"originalContentUrl": media_url,
|
|
"duration": duration_ms or 60000,
|
|
}
|
|
case "file":
|
|
return {
|
|
"type": "file",
|
|
"fileName": file_name or (media_url.rsplit("/", 1)[-1] if "/" in media_url else "file"),
|
|
"originalContentUrl": media_url,
|
|
}
|
|
case _:
|
|
return None
|
|
|
|
async def send_sticker(
|
|
self,
|
|
target_id: str,
|
|
package_id: str,
|
|
sticker_id: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message = {
|
|
"type": "sticker",
|
|
"packageId": package_id,
|
|
"stickerId": sticker_id,
|
|
}
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
async def send_imagemap(
|
|
self,
|
|
target_id: str,
|
|
base_url: str,
|
|
alt_text: str,
|
|
base_width: int,
|
|
base_height: int,
|
|
actions: list[dict],
|
|
*,
|
|
video: dict | None = None,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message: dict = {
|
|
"type": "imagemap",
|
|
"baseUrl": base_url,
|
|
"altText": alt_text[:400],
|
|
"baseSize": {"width": base_width, "height": base_height},
|
|
"actions": actions,
|
|
}
|
|
if video:
|
|
message["video"] = video
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
async def send_text_v2(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
emojis: list[dict] | None = None,
|
|
mentions: list[dict] | None = None,
|
|
quick_reply: dict | None = None,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return False
|
|
|
|
token = account.get("channel_access_token", "")
|
|
if not token:
|
|
return False
|
|
|
|
client = LineBotClient(channel_access_token=token)
|
|
message: dict = {"type": "textV2", "text": content}
|
|
|
|
if emojis:
|
|
substitution = {}
|
|
for i, e in enumerate(emojis):
|
|
substitution[f"${i}$"] = {"productId": e["productId"], "emojiId": e["emojiId"]}
|
|
message["substitution"] = substitution
|
|
if mentions:
|
|
message["mentions"] = mentions
|
|
if quick_reply:
|
|
message["quickReply"] = quick_reply
|
|
|
|
if reply_to_id:
|
|
return await client.reply_message(reply_to_id, [message])
|
|
return await client.push_message(target_id, [message])
|
|
|
|
|
|
def _batch_list(items: list, batch_size: int) -> list[list]:
|
|
return [items[i: i + batch_size] for i in range(0, len(items), batch_size)]
|