ForcePilot/backend/package/yuxi/channel/extensions/line/outbound.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

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)]