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

1722 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import json
import os
import re
import threading
import time
from collections.abc import AsyncIterator
from typing import Any, ClassVar
from yuxi.channels.adapters.line.formatter import LINEMessageFormatter
from yuxi.channels.adapters.line.markdown_to_line import extract_flex_messages_from_markdown
from yuxi.channels.adapters.line.normalizer import LINEEventNormalizer
from yuxi.channels.adapters.line.approval import LINEApprovalAdapter
from yuxi.channels.adapters.line.probe import probe_line_bot
from yuxi.channels.adapters.line.send import LINESender
from yuxi.channels.adapters.line.webhook import (
LINE_SIGNATURE_HEADER,
MultiAccountSignatureRouter,
WebhookReplayGuard,
validate_line_signature,
)
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
)
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelAccountSnapshot,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
MentionsInfo,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
def _is_reply_token_expired(error: str | None) -> bool:
if not error:
return False
error_lower = error.lower()
return any(kw in error_lower for kw in ("expired", "invalid reply token", "reply token"))
def _is_auth_error(error: str | None) -> bool:
if not error:
return False
error_lower = error.lower()
return any(kw in error_lower for kw in ("401", "authentication", "unauthorized"))
def _is_comm_channel_disabled(error: str | None) -> bool:
if not error:
return False
error_lower = error.lower()
return any(kw in error_lower for kw in ("disabled", "communication channel", "not enabled"))
def _is_rate_limited(error: str | None) -> bool:
if not error:
return False
error_lower = error.lower()
return any(kw in error_lower for kw in ("429", "rate limit", "rate limited", "too many requests"))
def _is_network_error(error: str | None) -> bool:
if not error:
return False
error_lower = error.lower()
return any(kw in error_lower for kw in ("network", "timeout", "connect", "dns", "refused", "unreachable"))
def _classify_send_error(error: str | None) -> str:
if not error:
return "unknown"
if _is_auth_error(error):
return "auth"
if _is_reply_token_expired(error):
return "reply_token_expired"
if _is_comm_channel_disabled(error):
return "channel_disabled"
if _is_rate_limited(error):
return "rate_limited"
if _is_network_error(error):
return "network"
error_lower = error.lower()
if "500" in error_lower or "server error" in error_lower:
return "server_error"
if "403" in error_lower or "forbidden" in error_lower:
return "forbidden"
return "unknown"
@register_builtin_adapter
class LINEAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "line"
channel_type: ClassVar[ChannelType] = ChannelType.LINE
webhook_path: ClassVar[str | None] = "line/callback"
text_chunk_limit: ClassVar[int] = 5000
supports_markdown: ClassVar[bool] = True
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "loading_animation", "chunked"]
max_media_size_mb: ClassVar[int] = 10
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
polls=False,
reactions=False,
edit=False,
unsend=False,
reply=True,
media=True,
group_management=False,
pin=False,
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "loading_animation", "chunked"],
block_streaming=True,
text_chunk_limit=5000,
max_media_size_mb=10,
)
meta = ChannelMeta(
id="line",
label="LINE",
selection_label="LINE (Messaging API)",
detail_label="LINE Messaging API Bot",
blurb="通过 LINE Messaging API 连接 LINE 官方账号支持文本、媒体、Flex Message、Rich Menu 等功能",
order=60,
docs_path="/channels/line",
system_image="line.svg",
aliases=["line-bot", "line-messaging"],
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._sender: LINESender | None = None
self._normalizer = LINEEventNormalizer(channel_id=self.channel_id)
self._formatter = LINEMessageFormatter()
self._bot_info: dict[str, Any] = {}
self._connected_at: float | None = None
self._self_user_id: str | None = None
self._config_account: dict[str, Any] = {}
self._cached_token: str | None = None
self._cached_secret: str | None = None
self._token_source: str = "unknown"
self._last_reply_token: str | None = None
self._token_lock = threading.Lock()
self.dm_policy: str = config.get("dm_policy", "open") if config else "open"
self.group_policy: str = config.get("group_policy", "open") if config else "open"
self._dm_allow_from: set[str] = set(config.get("allow_from", []) if config else [])
self._dm_pending_pairing: dict[str, str] = {}
self._groups_config: dict[str, dict] = {}
if config and config.get("groups"):
for g in config["groups"]:
gid = g.get("id", "")
if gid:
self._groups_config[gid] = {
"enabled": g.get("enabled", True),
"require_mention": g.get("require_mention", False),
"system_prompt": g.get("system_prompt"),
"skills": g.get("skills", []),
"allow_from": g.get("allow_from"),
}
self._seen_webhook_ids: set[str] = set()
self._seen_message_ids: set[str] = set()
self._sent_message_cache: dict[str, dict] = {}
self._message_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue()
self._queue_task: asyncio.Task | None = None
self._last_message_at: float | None = None
self._last_error: str | None = None
self._reconnect_attempts: int = 0
self._streaming_states: dict[str, dict] = {}
self._reaction_cache: dict[str, list[dict]] = {}
self._poll_results: dict[str, dict] = {}
self._profile_cache: dict[str, dict] = {}
self._group_info_cache: dict[str, dict] = {}
self._metrics: dict[str, Any] = {
"messages_sent": 0,
"messages_failed": 0,
"streaming_sessions": 0,
"total_latency_sum": 0.0,
"error_counts": {},
"started_at": time.time(),
}
self._replay_guard = WebhookReplayGuard()
self._signature_router = MultiAccountSignatureRouter()
self.thread_bindings_enabled: bool = (
config.get("thread_bindings", {}).get("enabled", False) if config else False
)
self.thread_bindings_idle_hours: float = config.get("thread_bindings", {}).get("idle_hours", 6) if config else 6
self.thread_bindings_max_age_hours: float = (
config.get("thread_bindings", {}).get("max_age_hours", 72) if config else 72
)
self.thread_bindings_spawn_subagent: bool = (
config.get("thread_bindings", {}).get("spawn_subagent_sessions", False) if config else False
)
self.thread_bindings_spawn_acp: bool = (
config.get("thread_bindings", {}).get("spawn_acp_sessions", False) if config else False
)
self.conversation_bindings: dict = config.get("conversation_bindings", {}) if config else {}
self.default_account: str | None = config.get("default_account") if config else None
self.response_prefix: str = config.get("response_prefix", "") if config else ""
self.media_max_mb: int = config.get("media_max_mb", 10) if config else 10
self._agent_prompt: str | None = config.get("agent_prompt") if config else None
self._skip_message_history: list[dict] = []
self._target_id_re = re.compile(r"^[UCR][a-f0-9]{32}$|^line:", re.IGNORECASE)
self._loading_animation_tasks: dict[str, asyncio.Task] = {}
self._approval: LINEApprovalAdapter | None = None
@property
def status(self) -> ChannelStatus:
return self._status
async def _resolve_token_and_secret(self, account_id: str | None = None) -> tuple[str, str]:
if account_id is None and self._cached_token is not None:
return self._cached_token, self._cached_secret
account_config = self.config.get("accounts", {})
if account_id:
account = account_config.get(account_id, {})
else:
account = account_config.get("default", {})
self._config_account = account
token = account.get("channel_access_token", "")
token_source = "config"
if not token:
token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN", "")
token_source = "env"
if not token:
token_file = account.get("token_file", "")
if not token_file:
token_file = self.config.get("token_file", "")
if token_file:
token = await self._read_file_credential(token_file)
token_source = "file"
secret = account.get("channel_secret", "")
if not secret:
secret = os.getenv("LINE_CHANNEL_SECRET", "")
if not secret:
secret_file = account.get("secret_file", "")
if not secret_file:
secret_file = self.config.get("secret_file", "")
if secret_file:
secret = await self._read_file_credential(secret_file)
if account_id is None:
self._cached_token = token
self._cached_secret = secret
self._token_source = token_source
return token, secret
@staticmethod
async def _read_file_credential(file_path: str) -> str:
loop = asyncio.get_event_loop()
content = await loop.run_in_executor(None, lambda: open(file_path, encoding="utf-8").read().strip())
return content
def list_account_ids(self) -> list[str]:
accounts = self.config.get("accounts", {})
return [k for k in accounts if k != "default" and isinstance(accounts[k], dict)]
async def connect(self) -> None:
self._status = ChannelStatus.CONNECTING
logger.info(f"[LINE] Starting channel '{self.config.get('name', self.channel_id)}'")
token, secret = await self._resolve_token_and_secret()
if not token:
raise ChannelAuthenticationError("LINE Channel Access Token not configured")
if not secret:
raise ChannelAuthenticationError("LINE Channel Secret not configured")
proxy_url = self.config.get("proxy")
self._sender = LINESender(token, proxy=proxy_url)
await self._sender.__aenter__()
info = await self._sender.get_bot_info()
if not info:
await self._sender.__aexit__()
raise ChannelAuthenticationError("LINE Bot verification failed: unable to get bot info")
self._bot_info = {
"display_name": info.get("displayName", ""),
"user_id": info.get("userId", ""),
"picture_url": info.get("pictureUrl", ""),
}
self._self_user_id = info.get("userId", "")
self._signature_router.register_account("default", secret)
for account_id in self.list_account_ids():
_, account_secret = await self._resolve_token_and_secret(account_id)
if account_secret:
self._signature_router.register_account(account_id, account_secret)
self._connected_at = time.time()
self._status = ChannelStatus.CONNECTED
self._queue_task = asyncio.ensure_future(self._queue_consumer())
self._approval = LINEApprovalAdapter(self)
logger.info(f"[LINE] Bot '{self._bot_info.get('display_name', '')}' ({self._self_user_id}) connected")
async def disconnect(self) -> None:
self._status = ChannelStatus.DISCONNECTED
if self._queue_task:
self._queue_task.cancel()
try:
await self._queue_task
except (asyncio.CancelledError, Exception):
pass
self._queue_task = None
if self._sender:
await self._sender.__aexit__()
self._sender = None
self._bot_info = {}
self._seen_webhook_ids.clear()
self._seen_message_ids.clear()
self._sent_message_cache.clear()
self._replay_guard.clear()
self._signature_router.clear()
for task in self._loading_animation_tasks.values():
task.cancel()
self._loading_animation_tasks.clear()
logger.info("[LINE] adapter disconnected")
async def logout_account(self, account_id: str | None = None) -> None:
if account_id is None:
self._cached_token = None
self._cached_secret = None
self._replay_guard.clear()
self._signature_router.clear()
self._seen_webhook_ids.clear()
self._seen_message_ids.clear()
logger.info("[LINE] all accounts logged out")
else:
self._signature_router.unregister_account(account_id)
accounts = self.config.get("accounts", {})
if account_id in accounts:
acct = accounts[account_id]
acct.pop("channel_access_token", None)
acct.pop("channel_secret", None)
acct.pop("token_file", None)
acct.pop("secret_file", None)
logger.info(f"[LINE] account '{account_id}' logged out")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
self._metrics["messages_failed"] += 1
return DeliveryResult(success=False, error="LINE not connected")
chat_id = response.identity.channel_chat_id
content = response.content
if self.response_prefix and content:
if not content.startswith(self.response_prefix):
content = self.response_prefix + content
messages = self._build_send_payload(response)
reply_token = response.metadata.get("reply_token")
if reply_token:
result = (
await self._sender.reply_message(reply_token, messages) if messages else DeliveryResult(success=True)
)
if not result.success and _is_reply_token_expired(result.error):
logger.info("[LINE] replyToken expired, falling back to push")
result = (
await self._sender.push_message(chat_id, messages) if messages else DeliveryResult(success=True)
)
self._record_result(result)
if _is_auth_error(result.error):
await self._auto_disable_graduated("LINE auth error during reply")
if _is_comm_channel_disabled(result.error):
await self._auto_disable_graduated("LINE communication channel disabled")
if result.success and result.message_id:
self._track_sent_message(result.message_id, chat_id, "reply")
self._last_message_at = time.time()
return result
if not messages:
return DeliveryResult(success=True)
result = await self._sender.push_message(chat_id, messages)
self._record_result(result)
if _is_auth_error(result.error):
await self._auto_disable_graduated("LINE auth error during push")
if _is_comm_channel_disabled(result.error):
await self._auto_disable_graduated("LINE communication channel disabled")
if _is_rate_limited(result.error):
logger.warning(f"[LINE] rate limited during push to {chat_id}")
if result.error:
error_category = _classify_send_error(result.error)
logger.debug(f"[LINE] push error category={error_category}: {result.error}")
if result.success and result.message_id:
self._track_sent_message(result.message_id, chat_id, "push")
self._last_message_at = time.time()
return result
def _build_send_payload(self, response: ChannelResponse) -> list[dict]:
metadata = response.metadata
flex_contents = metadata.get("flex_contents")
if flex_contents:
return [
{"type": "flex", "altText": metadata.get("alt_text", "Flex Message")[:400], "contents": flex_contents}
]
template = metadata.get("template")
if template:
return [{"type": "template", "altText": metadata.get("alt_text", "Template")[:400], "template": template}]
location = metadata.get("location")
if location:
return [
{
"type": "location",
"title": location.get("title", "")[:100],
"address": location.get("address", "")[:100],
"latitude": location.get("latitude", 0),
"longitude": location.get("longitude", 0),
}
]
text_content = response.content
if self.response_prefix and text_content:
if not text_content.startswith(self.response_prefix):
text_content = self.response_prefix + text_content
quick_replies = metadata.get("quick_replies")
if quick_replies and not text_content:
return [{"type": "text", "text": " ", "quickReply": {"items": quick_replies[:13]}}]
messages = self._formatter.format(response)
final: list[dict] = []
markdown_flex = []
if response.content and self.supports_markdown:
markdown_flex = extract_flex_messages_from_markdown(response.content)
for msg in messages:
if isinstance(msg, dict) and msg.get("type") == "flex":
final.append(msg)
for msg in markdown_flex:
if isinstance(msg, dict) and msg.get("type") == "flex" and msg not in final:
final.append(msg)
for msg in messages:
if isinstance(msg, dict) and msg.get("type") == "template" and msg not in final:
final.append(msg)
for msg in messages:
if isinstance(msg, dict) and msg.get("type") == "location" and msg not in final:
final.append(msg)
for msg in messages:
if isinstance(msg, dict) and msg.get("type") == "text" and msg not in final:
final.append(msg)
for msg in messages:
if isinstance(msg, dict) and msg not in final:
final.append(msg)
if quick_replies and final:
last = final[-1]
if isinstance(last, dict) and last.get("type") == "text":
last["quickReply"] = {"items": quick_replies[:13]}
elif isinstance(last, dict) and last.get("type") in ("flex", "template", "image", "video"):
final.append({"type": "text", "text": " ", "quickReply": {"items": quick_replies[:13]}})
return final[:5]
def _record_result(self, result: DeliveryResult) -> None:
if result.success:
self._metrics["messages_sent"] += 1
else:
self._metrics["messages_failed"] += 1
category = _classify_send_error(result.error)
self._metrics["error_counts"][category] = self._metrics["error_counts"].get(category, 0) + 1
def get_metrics(self) -> dict[str, Any]:
uptime = time.time() - self._metrics["started_at"]
metrics = dict(self._metrics)
total = metrics["messages_sent"] + metrics["messages_failed"]
metrics["uptime_seconds"] = uptime
metrics["total_messages"] = total
metrics["success_rate"] = metrics["messages_sent"] / total if total > 0 else 1.0
return metrics
async def send_media(self, chat_id: str, media_type: str, data: Any, **kwargs) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
url = data if isinstance(data, str) else kwargs.get("url", "")
if not url:
return DeliveryResult(
success=False,
error="LINE media send requires HTTPS URL. Provide URL as 'data' or 'url' kwarg.",
)
safe_url = self._ensure_https_url(url)
if not safe_url:
return DeliveryResult(
success=False,
error="LINE media URL must be a valid HTTPS URL (max 2000 chars)",
)
if media_type == "image":
preview_url = kwargs.get("preview_url", safe_url)
safe_preview = self._ensure_https_url(str(preview_url)) or safe_url
messages = [
{
"type": "image",
"originalContentUrl": safe_url,
"previewImageUrl": safe_preview,
}
]
elif media_type == "video":
preview_url = kwargs.get("preview_url", "")
safe_preview = self._ensure_https_url(str(preview_url)) if preview_url else ""
tracking_id = kwargs.get("tracking_id")
msg: dict = {
"type": "video",
"originalContentUrl": safe_url,
"previewImageUrl": safe_preview,
}
if tracking_id:
msg["trackingId"] = tracking_id
messages = [msg]
elif media_type == "audio":
duration = kwargs.get("duration", 60000)
messages = [
{
"type": "audio",
"originalContentUrl": safe_url,
"duration": duration,
}
]
elif media_type == "file":
file_name = kwargs.get("file_name", kwargs.get("filename", "file"))
file_size = kwargs.get("file_size", kwargs.get("size", 0))
messages = [
{
"type": "file",
"originalContentUrl": safe_url,
"fileName": str(file_name)[:600],
"fileSize": int(file_size) if file_size else 0,
}
]
else:
return DeliveryResult(
success=False,
error=f"LINE media type '{media_type}' not supported. Use image/video/audio/file.",
)
reply_token = kwargs.get("reply_token")
if reply_token:
return await self._sender.reply_message(reply_token, messages)
return await self._sender.push_message(chat_id, messages)
async def download_media(self, file_id: str) -> bytes:
if not self._sender:
raise RuntimeError("LINE adapter not connected")
content = await self._sender.get_message_content(file_id)
if content is None:
raise RuntimeError(f"Failed to download LINE message content: {file_id}")
return content
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
state = self._get_or_create_stream_state(chat_id, msg_id)
state["accumulated"] += chunk
state["chunks_sent"] += 1
identity = self._build_stream_identity(chat_id, msg_id)
with self._token_lock:
reply_token = self._last_reply_token
metadata = {}
if reply_token:
metadata["reply_token"] = reply_token
response = ChannelResponse(identity=identity, content=chunk, metadata=metadata)
result = await self.send(response)
if finished:
state["finished"] = True
state["finished_at"] = time.time()
self._streaming_states.pop(f"{chat_id}:{msg_id}", None)
return result
async def send_stream_start(self, chat_id: str, msg_id: str) -> None:
state = self._get_or_create_stream_state(chat_id, msg_id)
state["status"] = "streaming"
await self.send_loading_animation(chat_id, 60)
async def send_reasoning_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
state = self._get_or_create_stream_state(chat_id, msg_id)
state["reasoning_mode"] = True
state["reasoning_accumulated"] += chunk
wrapped = f"💭 {chunk}"
return await self.send_stream_chunk(chat_id, msg_id, wrapped, finished)
def _get_or_create_stream_state(self, chat_id: str, msg_id: str) -> dict:
key = f"{chat_id}:{msg_id}"
if key not in self._streaming_states:
self._streaming_states[key] = {
"status": "idle",
"started_at": time.time(),
"accumulated": "",
"reasoning_mode": False,
"reasoning_accumulated": "",
"chunks_sent": 0,
"finished": False,
"finished_at": None,
}
return self._streaming_states[key]
def get_stream_state(self, chat_id: str, msg_id: str) -> dict | None:
key = f"{chat_id}:{msg_id}"
return self._streaming_states.get(key)
async def cancel_stream(self, chat_id: str, msg_id: str) -> None:
key = f"{chat_id}:{msg_id}"
state = self._streaming_states.pop(key, None)
if state:
state["status"] = "cancelled"
state["finished"] = True
state["finished_at"] = time.time()
logger.debug(f"[LINE] stream cancelled: {key}")
async def send_loading_animation(self, chat_id: str, seconds: int = 20) -> None:
if self._sender and _is_dm_chat(chat_id):
await self._sender.show_loading_animation(chat_id, seconds)
async def _loading_animation_keepalive(self, chat_id: str) -> None:
if not _is_dm_chat(chat_id):
return
try:
while True:
await asyncio.sleep(18)
if chat_id not in self._loading_animation_tasks:
break
await self._sender.show_loading_animation(chat_id, 20)
except asyncio.CancelledError:
pass
async def start_loading_animation_with_keepalive(self, chat_id: str) -> None:
if self._sender:
await self._sender.show_loading_animation(chat_id, 20)
if chat_id in self._loading_animation_tasks:
self._loading_animation_tasks[chat_id].cancel()
self._loading_animation_tasks[chat_id] = asyncio.ensure_future(self._loading_animation_keepalive(chat_id))
async def stop_loading_animation(self, chat_id: str) -> None:
task = self._loading_animation_tasks.pop(chat_id, None)
if task:
task.cancel()
async def send_multicast(self, user_ids: list[str], response: ChannelResponse) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
messages = self._formatter.format(response)
return await self._sender.multicast_message(user_ids, messages)
async def send_broadcast(self, response: ChannelResponse) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
messages = self._formatter.format(response)
return await self._sender.broadcast_message(messages)
async def mark_as_read(self, chat_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.mark_as_read(chat_id)
async def validate_messages(
self, messages: list[dict], validate_type: str = "push", user_ids: list[str] | None = None
) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
if validate_type == "push":
return await self._sender.validate_push(messages)
if validate_type == "reply":
return await self._sender.validate_reply(messages)
if validate_type == "multicast":
return await self._sender.validate_multicast(user_ids or [], messages)
if validate_type == "broadcast":
return await self._sender.validate_broadcast(messages)
return False
async def list_rich_menus(self) -> list[dict] | None:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return None
return await self._sender.get_rich_menus()
async def create_rich_menu(self, rich_menu_def: dict, image_bytes: bytes | None = None) -> str | None:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return None
menu_id = await self._sender.create_rich_menu(rich_menu_def)
if menu_id and image_bytes:
ok = await self._sender.set_rich_menu_image(menu_id, image_bytes)
if not ok:
logger.warning(f"LINE rich menu {menu_id} created but image upload failed")
return menu_id
async def delete_rich_menu(self, rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.delete_rich_menu(rich_menu_id)
async def link_rich_menu(self, user_id: str, rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.link_rich_menu_to_user(user_id, rich_menu_id)
async def link_rich_menu_bulk(self, user_ids: list[str], rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.link_rich_menu_to_users(user_ids, rich_menu_id)
async def unlink_rich_menu(self, user_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.unlink_rich_menu_from_user(user_id)
async def set_default_rich_menu(self, rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.set_default_rich_menu(rich_menu_id)
async def cancel_default_rich_menu(self) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.cancel_default_rich_menu()
async def link_rich_menu_bulk(self, user_ids: list[str], rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.link_rich_menu_to_multiple_users(user_ids, rich_menu_id)
async def unlink_rich_menu_bulk(self, user_ids: list[str]) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.unlink_rich_menu_from_multiple_users(user_ids)
async def get_rich_menu_image(self, rich_menu_id: str) -> bytes | None:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return None
return await self._sender.get_rich_menu_image(rich_menu_id)
async def validate_rich_menu_object(self, rich_menu: dict) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.validate_rich_menu_object(rich_menu)
async def create_rich_menu_alias(self, alias_name: str, rich_menu_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.create_rich_menu_alias(alias_name, rich_menu_id)
async def delete_rich_menu_alias(self, alias_name: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
return await self._sender.delete_rich_menu_alias(alias_name)
async def get_rich_menu_by_alias(self, alias_name: str) -> dict | None:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return None
return await self._sender.get_rich_menu_by_alias(alias_name)
@staticmethod
def create_default_menu_config() -> dict:
return {
"size": {"width": 2500, "height": 1686},
"selected": True,
"name": "default",
"chatBarText": "打开菜单",
"areas": [
{
"bounds": {"x": 0, "y": 0, "width": 833, "height": 843},
"action": {"type": "message", "text": "帮助"},
},
{
"bounds": {"x": 833, "y": 0, "width": 833, "height": 843},
"action": {"type": "message", "text": "功能"},
},
{
"bounds": {"x": 1666, "y": 0, "width": 834, "height": 843},
"action": {"type": "message", "text": "设置"},
},
{
"bounds": {"x": 0, "y": 843, "width": 833, "height": 843},
"action": {"type": "uri", "label": "官网", "uri": "https://line.me"},
},
{
"bounds": {"x": 833, "y": 843, "width": 833, "height": 843},
"action": {"type": "message", "text": "搜索"},
},
{
"bounds": {"x": 1666, "y": 843, "width": 834, "height": 843},
"action": {"type": "message", "text": "关于"},
},
],
}
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
cache_key = f"{chat_id}:{msg_id}"
if cache_key not in self._reaction_cache:
self._reaction_cache[cache_key] = []
self._reaction_cache[cache_key].append(
{
"emoji": emoji,
"timestamp": time.time(),
}
)
if len(self._reaction_cache) > 1000:
oldest_keys = sorted(self._reaction_cache.keys())[:500]
for k in oldest_keys:
del self._reaction_cache[k]
messages = [{"type": "text", "text": emoji}]
return await self._sender.push_message(chat_id, messages)
def get_reactions(self, msg_id: str, chat_id: str | None = None) -> list[dict]:
if chat_id:
key = f"{chat_id}:{msg_id}"
return self._reaction_cache.get(key, [])
results = []
for key, reactions in self._reaction_cache.items():
if key.endswith(f":{msg_id}"):
results.extend(reactions)
return results
def clear_reactions(self, msg_id: str | None = None) -> None:
if msg_id:
keys_to_remove = [k for k in self._reaction_cache if k.endswith(f":{msg_id}")]
for k in keys_to_remove:
del self._reaction_cache[k]
else:
self._reaction_cache.clear()
async def send_sticker(
self, chat_id: str, package_id: str, sticker_id: str, reply_token: str | None = None
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
return await self._sender.send_sticker(chat_id, package_id, sticker_id, reply_token)
async def create_poll(
self,
chat_id: str,
question: str,
options: list[str],
anonymous: bool = False,
duration_seconds: int = 0,
allow_multiple: bool = False,
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="LINE not connected")
if len(options) < 1 or len(options) > 4:
return DeliveryResult(success=False, error="Poll requires 1-4 options")
if len(question) > 240:
question = question[:237] + "..."
poll_id = f"poll_{int(time.time() * 1000)}_{chat_id[:10]}"
self._poll_results[poll_id] = {
"chat_id": chat_id,
"question": question,
"options": options,
"anonymous": anonymous,
"allow_multiple": allow_multiple,
"votes": {opt: 0 for opt in options},
"voters": {},
"created_at": time.time(),
"expires_at": time.time() + duration_seconds if duration_seconds > 0 else None,
}
actions = []
for i, opt in enumerate(options[:4]):
label = opt[:20] if len(opt) > 20 else opt
actions.append(
{
"type": "postback",
"label": label,
"data": f"poll_id={poll_id}&answer={i}",
"displayText": f"投票: {label}",
}
)
body_sections = [
{"type": "text", "text": question, "wrap": True, "size": "md"},
{"type": "separator", "margin": "md"},
]
for i, opt in enumerate(options[:4]):
idx = ["", "", "", ""][i]
body_sections.append(
{
"type": "text",
"text": f"{idx} {opt}",
"wrap": True,
"size": "sm",
"margin": "sm",
}
)
bubble = {
"type": "bubble",
"header": {
"type": "box",
"layout": "vertical",
"contents": [{"type": "text", "text": "📊 投票", "weight": "bold", "size": "lg", "color": "#ffffff"}],
"backgroundColor": "#06C755",
},
"body": {
"type": "box",
"layout": "vertical",
"contents": body_sections,
},
"footer": {
"type": "box",
"layout": "vertical",
"contents": actions,
},
}
message = {
"type": "flex",
"altText": f"投票: {question[:40]}",
"contents": bubble,
}
result = await self._sender.push_message(chat_id, [message])
if result.success and result.message_id:
self._poll_results[poll_id]["message_id"] = result.message_id
return result
def record_vote(self, poll_id: str, option_index: int, voter_id: str) -> dict | None:
poll = self._poll_results.get(poll_id)
if not poll:
return None
if poll["expires_at"] and time.time() > poll["expires_at"]:
return None
if not poll["allow_multiple"] and voter_id in poll["voters"]:
return None
if option_index < 0 or option_index >= len(poll["options"]):
return None
option_name = poll["options"][option_index]
poll["votes"][option_name] += 1
poll["voters"][voter_id] = option_index
return self.get_poll_results(poll_id)
def get_poll_results(self, poll_id: str) -> dict | None:
poll = self._poll_results.get(poll_id)
if not poll:
return None
total_votes = sum(poll["votes"].values())
return {
"poll_id": poll_id,
"question": poll["question"],
"options": poll["options"],
"votes": poll["votes"],
"total_votes": total_votes,
"voter_count": len(poll["voters"]),
"anonymous": poll["anonymous"],
"allow_multiple": poll["allow_multiple"],
"created_at": poll["created_at"],
"expires_at": poll["expires_at"],
}
def list_active_polls(self, chat_id: str | None = None) -> list[dict]:
results = []
for poll_id, poll in self._poll_results.items():
if chat_id and poll["chat_id"] != chat_id:
continue
if poll["expires_at"] and time.time() > poll["expires_at"]:
continue
results.append(self.get_poll_results(poll_id))
return results
def close_poll(self, poll_id: str) -> dict | None:
poll = self._poll_results.get(poll_id)
if not poll:
return None
poll["expires_at"] = time.time()
return self.get_poll_results(poll_id)
async def _check_dm_access(self, channel_user_id: str) -> tuple[bool, str | None]:
if self.dm_policy == "disabled":
return False, "DM access disabled by policy"
if self.dm_policy == "open":
return True, None
if self.dm_policy == "allowlist":
if channel_user_id in self._dm_allow_from:
return True, None
return False, "DM access denied: user not in allowlist"
if self.dm_policy == "pairing":
if channel_user_id in self._dm_allow_from:
return True, None
if channel_user_id not in self._dm_pending_pairing:
code = _gen_pairing_code()
self._dm_pending_pairing[channel_user_id] = code
await self._send_pairing_prompt(channel_user_id, code)
return False, "DM pairing required: send pairing code to continue"
return True, None
async def _check_group_access(self, chat_id: str, content: str = "") -> tuple[bool, str | None]:
group_cfg = self._resolve_group_config(chat_id)
if group_cfg is None:
return True, None
if not group_cfg.get("enabled", False):
return False, "Group not in allowlist"
if group_cfg.get("require_mention", False):
if not _bot_mentioned(content, self._self_user_id):
result = (False, "Bot not mentioned")
self._skip_message_history.append(
{
"chat_id": chat_id,
"content": content[:200],
"skipped_at": time.time(),
"reason": "require_mention",
}
)
return result
return True, None
def get_group_system_prompt(self, chat_id: str) -> str | None:
group_cfg = self._resolve_group_config(chat_id)
if group_cfg:
return group_cfg.get("system_prompt")
return None
def get_group_skills(self, chat_id: str) -> list[str]:
group_cfg = self._resolve_group_config(chat_id)
if group_cfg:
return group_cfg.get("skills", [])
return []
def get_group_allow_from(self, chat_id: str) -> list[str] | None:
group_cfg = self._resolve_group_config(chat_id)
if group_cfg:
return group_cfg.get("allow_from")
return None
def get_skip_message_history(self) -> list[dict]:
result = list(self._skip_message_history)
return result
def _resolve_group_config(self, chat_id: str) -> dict | None:
if self.group_policy == "disabled":
return {"enabled": False}
if self.group_policy == "open":
return None
if self.group_policy == "allowlist":
specific = self._groups_config.get(chat_id)
if specific is not None:
return specific
wildcard = self._groups_config.get("*")
if wildcard is not None:
return wildcard
return {"enabled": False}
return None
async def _send_pairing_prompt(self, user_id: str, code: str) -> None:
if not self._sender:
return
messages = [
{
"type": "text",
"text": f"👋 你好!请发送配对码 {code} 以完成验证。\n请在聊天中输入: /pair {code}",
}
]
await self._sender.push_message(f"user_{user_id}", messages)
async def approve_pairing(self, code: str) -> bool:
for uid, pending_code in list(self._dm_pending_pairing.items()):
if pending_code == code:
self._dm_allow_from.add(uid)
del self._dm_pending_pairing[uid]
if self._sender:
messages = [{"type": "text", "text": "✅ 配对成功!你现在可以与 Bot 对话了。"}]
await self._sender.push_message(f"user_{uid}", messages)
return True
return False
async def _auto_disable(self, reason: str) -> None:
logger.error(f"[LINE] auto-disabling channel due to: {reason}")
self._last_error = reason
self._status = ChannelStatus.ERROR
if self._queue_task:
self._queue_task.cancel()
try:
await self._queue_task
except (asyncio.CancelledError, Exception):
pass
self._queue_task = None
if self._sender:
try:
await self._sender.__aexit__()
except Exception:
pass
self._sender = None
async def _queue_consumer(self) -> None:
while True:
try:
message = await self._message_queue.get()
try:
if self._message_handler:
await self._message_handler(message)
except Exception as exc:
logger.error(f"[LINE] queue handler failed: {exc}")
finally:
self._message_queue.task_done()
except asyncio.CancelledError:
logger.debug("[LINE] message queue consumer cancelled")
break
async def enqueue_message(self, message: ChannelMessage) -> None:
await self._message_queue.put(message)
logger.debug(f"[LINE] enqueued message for chat={message.identity.channel_chat_id}")
def _track_sent_message(self, message_id: str, chat_id: str, send_type: str = "push") -> None:
self._sent_message_cache[message_id] = {
"chat_id": chat_id,
"type": send_type,
"sent_at": time.time(),
"delivered": time.time(),
"read": None,
}
if len(self._sent_message_cache) > 5000:
oldest = sorted(self._sent_message_cache.keys())[:2500]
for k in oldest:
del self._sent_message_cache[k]
def _mark_delivered(self, message_id: str) -> None:
entry = self._sent_message_cache.get(message_id)
if entry:
entry["delivered"] = time.time()
def _mark_read(self, message_id: str) -> None:
entry = self._sent_message_cache.get(message_id)
if entry:
entry["read"] = time.time()
def _is_duplicate_webhook(self, webhook_event_id: str) -> bool:
if not webhook_event_id:
return False
if webhook_event_id in self._seen_webhook_ids:
return True
self._seen_webhook_ids.add(webhook_event_id)
if len(self._seen_webhook_ids) > 10000:
self._seen_webhook_ids = set(list(self._seen_webhook_ids)[-5000:])
return False
def _is_duplicate_message(self, message_id: str) -> bool:
if not message_id:
return False
if message_id in self._seen_message_ids:
return True
self._seen_message_ids.add(message_id)
if len(self._seen_message_ids) > 10000:
self._seen_message_ids = set(list(self._seen_message_ids)[-5000:])
return False
@staticmethod
def _ensure_https_url(url: str) -> str | None:
if not url:
return None
if not isinstance(url, str):
return None
if len(url) > 2000:
return None
if not url.startswith("https://"):
return None
from urllib.parse import urlparse
parsed = urlparse(url)
hostname = parsed.hostname or ""
if _is_private_hostname(hostname):
return None
return url
def get_account_snapshot(self) -> ChannelAccountSnapshot:
from yuxi.channels.models import build_snapshot_from_adapter
snapshot = build_snapshot_from_adapter(self)
snapshot.dm_policy = self.dm_policy
snapshot.group_policy = self.group_policy
snapshot.allow_from_count = len(self._dm_allow_from)
snapshot.webhook_path = self.webhook_path or ""
snapshot.bot = self._bot_info or None
snapshot.last_message_at = self._last_message_at
snapshot.last_error = self._last_error
snapshot.pairing_pending = len(self._dm_pending_pairing)
snapshot.token_source = self._token_source
return snapshot
async def receive(self) -> AsyncIterator[ChannelMessage]:
return
yield # pragma: no cover
def normalize_inbound(self, raw: bytes) -> ChannelMessage:
body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw
data = json.loads(body_str)
events = data.get("events", [])
if not events:
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="unknown",
channel_chat_id="unknown",
),
content="(empty webhook)",
)
primary_msg = self._normalizer.normalize(events[0])
webhook_id = events[0].get("webhookEventId", "")
if self._is_duplicate_webhook(webhook_id):
logger.debug(f"[LINE] duplicate webhook event: {webhook_id}")
msg_id = primary_msg.identity.channel_message_id
if msg_id and self._is_duplicate_message(msg_id):
logger.debug(f"[LINE] duplicate message: {msg_id}")
content = primary_msg.content
mentions = _resolve_mentions(content, self._self_user_id, events[0])
primary_msg.mentions = mentions
primary_msg.metadata["mentions"] = {
"mentioned_user_ids": mentions.mentioned_user_ids,
"is_bot_mentioned": mentions.is_bot_mentioned,
}
with self._token_lock:
self._last_reply_token = primary_msg.metadata.get("reply_token")
self._last_message_at = time.time()
for i, event in enumerate(events[1:], start=1):
msg = self._normalizer.normalize(event)
event_type_str = event.get("type", "unknown")
msg.mentions = _resolve_mentions(msg.content, self._self_user_id)
logger.debug(f"[LINE] dispatching additional event #{i}: {event_type_str}")
if self._message_handler:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(self._message_handler(msg))
else:
loop.run_until_complete(self._message_handler(msg))
except Exception as exc:
logger.error(f"[LINE] event handler failed for {event_type_str}: {exc}")
return primary_msg
def format_outbound(self, response: ChannelResponse) -> Any:
return self._formatter.format(response)
async def health_check(self) -> HealthStatus:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return HealthStatus(status="unhealthy", last_error="Not connected")
start = time.monotonic()
try:
info = await self._sender.get_bot_info()
latency_ms = (time.monotonic() - start) * 1000
metadata: dict[str, Any] = {
"bot_display_name": self._bot_info.get("display_name", ""),
"bot_user_id": self._self_user_id,
"dm_policy": self.dm_policy,
"group_policy": self.group_policy,
"allow_from_count": len(self._dm_allow_from),
"group_count": len(self._groups_config),
"seen_webhook_ids": len(self._seen_webhook_ids),
"sent_message_cache": len(self._sent_message_cache),
}
warnings: list[str] = []
if self.dm_policy == "allowlist" and len(self._dm_allow_from) == 0:
warnings.append("DM allowlist is empty — no users can access")
if self.group_policy == "allowlist" and len(self._groups_config) == 0:
warnings.append("Group allowlist is empty — no groups can access")
if self.dm_policy == "pairing":
pending = len(self._dm_pending_pairing)
if pending > 0:
warnings.append(f"{pending} users pending DM pairing")
if warnings:
metadata["warnings"] = warnings
quota = await self._sender.get_message_quota()
if quota:
metadata["message_quota_type"] = quota.get("type", "unknown")
metadata["message_quota_value"] = quota.get("value")
consumption = await self._sender.get_message_quota_consumption()
if consumption:
metadata["message_usage"] = consumption.get("totalUsage")
if info:
return HealthStatus(
status="healthy",
latency_ms=latency_ms,
last_connected_at=utc_now_naive(),
metadata=metadata,
)
return HealthStatus(status="unhealthy", last_error="Bot info fetch returned empty")
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
_, secret = await self._resolve_token_and_secret()
if not secret:
logger.error("[LINE] webhook: channel_secret not configured, rejecting request")
return False
signature = headers.get(LINE_SIGNATURE_HEADER, "")
if not signature:
logger.warning("[LINE] webhook: missing X-Line-Signature header")
return False
if not validate_line_signature(body, signature, secret):
matched_account = self._signature_router.match_signature(body, signature)
if matched_account:
_, account_secret = await self._resolve_token_and_secret(matched_account)
if account_secret:
logger.info(f"[LINE] webhook matched account '{matched_account}' via signature routing")
try:
self._replay_guard.check_and_claim(signature)
except Exception:
logger.warning("[LINE] replay attack detected (multi-account)")
return False
return True
logger.warning("[LINE] webhook: invalid signature, rejecting request")
return False
try:
self._replay_guard.check_and_claim(signature)
except Exception:
logger.warning("[LINE] replay attack detected")
return False
return True
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
cached = self._profile_cache.get(channel_user_id)
if cached and time.time() - cached.get("cached_at", 0) < 3600:
return cached["data"]
if not self._sender:
profile = await self._lookup_friends_info(channel_user_id)
if profile:
self._cache_profile(channel_user_id, profile)
return profile
return cached.get("data", {}) if cached else {}
profile = await self._sender.get_profile(channel_user_id)
if profile:
data = {
"display_name": profile.get("displayName", ""),
"user_id": profile.get("userId", channel_user_id),
"picture_url": profile.get("pictureUrl", ""),
"status_message": profile.get("statusMessage", ""),
}
self._cache_profile(channel_user_id, data)
return data
return cached.get("data", {}) if cached else {}
async def get_group_info(self, group_id: str) -> dict[str, Any]:
cached = self._group_info_cache.get(group_id)
if cached and time.time() - cached.get("cached_at", 0) < 3600:
return cached["data"]
if not self._sender:
return {}
try:
summary = await self._sender.get_group_summary(group_id)
if summary:
data = {
"group_id": group_id,
"group_name": summary.get("groupName", ""),
"picture_url": summary.get("pictureUrl", ""),
}
self._group_info_cache[group_id] = {"data": data, "cached_at": time.time()}
return data
except Exception:
pass
return cached.get("data", {}) if cached else {}
async def get_member_count(self, group_id: str) -> int | None:
if not self._sender:
return None
try:
count = await self._sender.get_group_member_count(group_id)
return count
except Exception:
return None
def _cache_profile(self, user_id: str, data: dict) -> None:
self._profile_cache[user_id] = {"data": data, "cached_at": time.time()}
if len(self._profile_cache) > 1000:
oldest_keys = sorted(self._profile_cache.keys())[:500]
for k in oldest_keys:
del self._profile_cache[k]
async def _lookup_friends_info(self, user_id: str) -> dict | None:
return None
async def pre_connect(self) -> dict:
token, _ = await self._resolve_token_and_secret()
if not token:
return {"status": "error", "message": "Missing channel_access_token"}
probe_result = await probe_line_bot(token)
if probe_result.get("status") == "error":
return {"status": "error", "message": probe_result.get("message", "Probe failed")}
return probe_result
async def agent_prompt(self) -> str | None:
prompts = []
if self._agent_prompt:
prompts.append(self._agent_prompt)
else:
prompts.extend(
[
"你正在通过 LINE 与用户对话。",
"用户可以发送文本、图片、视频、音频、文件、位置和贴纸。",
]
)
prompts.extend(
[
"你可以通过 Flex Message 发送丰富的卡片内容,包括气泡卡片和轮播卡片。",
"LINE 消息支持 Quick Reply最多 13 个选项、Confirm 模板和 Buttons 模板。",
"你发送的 Markdown 文本会自动转换为 LINE 兼容的装饰文本(粗体、斜体、删除线)。",
"使用 [[card:receipt:{...}]] 指令发送收据卡片,使用 [[card:event:{...}]] 发送事件卡片。",
"文本消息限制为 5000 字符,单次最多发送 5 条消息。",
"群组消息中,当 Bot 被 @提及 时 `is_bot_mentioned` 为 true。",
]
)
hints = self._message_tool_hints()
if hints:
prompts.append(hints)
return "\n".join(prompts)
def _message_tool_hints(self) -> str:
return (
"## 富消息工具提示\n"
"你可以使用 `[[directive]]` 指令语法直接构建 LINE 富消息:\n"
"- `[[quick_replies: 选项1, 选项2, ...]]` — 快速回复按钮最多13个\n"
"- `[[confirm: 问题 | 是按钮文字 | 否按钮文字]]` — 确认模板\n"
"- `[[buttons: 标题 | 描述 | 按钮1:数据, 按钮2:数据]]` — 按钮模板\n"
"- `[[location: 标题 | 地址 | 纬度 | 经度]]` — 位置消息\n"
"- `[[card:receipt|agenda|event|media|apple_tv|device_control|info:{...}]]` — Flex 卡片\n"
"- 附加信息使用 `[[extra: 内容]]`\n"
)
async def heartbeat(self) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return False
try:
info = await self._sender.get_bot_info()
return info is not None
except Exception:
return False
async def list_peers(self, limit: int = 50) -> list[dict]:
peers: list[dict] = []
for uid in list(self._dm_allow_from)[:limit]:
try:
info = await self.get_user_info(uid)
if info:
peers.append({"type": "user", "id": uid, "name": info.get("display_name", uid)})
except Exception:
pass
for gid in list(self._groups_config.keys())[:limit]:
peers.append({"type": "group", "id": gid, "enabled": self._groups_config[gid].get("enabled", True)})
return peers
def build_channel_summary(self) -> dict:
return {
"channel": "line",
"status": self._status.value if hasattr(self._status, "value") else str(self._status),
"dm_policy": self.dm_policy,
"group_policy": self.group_policy,
"allow_from_count": len(self._dm_allow_from),
"group_config_count": len(self._groups_config),
"pending_pairings": len(self._dm_pending_pairing),
"seen_webhook_ids": len(self._seen_webhook_ids),
"seen_message_ids": len(self._seen_message_ids),
"sent_message_cache_size": len(self._sent_message_cache),
"last_error": self._last_error,
"reconnect_attempts": self._reconnect_attempts,
"bot_name": self._bot_info.get("display_name", ""),
}
def collect_status_issues(self) -> list[dict]:
issues: list[dict] = []
if self._status != ChannelStatus.CONNECTED:
issues.append({"severity": "error", "message": f"Channel not connected: {self._status}"})
if self._last_error:
issues.append({"severity": "warning", "message": f"Last error: {self._last_error}"})
if self.dm_policy == "allowlist" and len(self._dm_allow_from) == 0:
issues.append({"severity": "warning", "message": "DM allowlist is empty"})
if self.group_policy == "allowlist" and len(self._groups_config) == 0:
issues.append({"severity": "warning", "message": "Group allowlist is empty"})
return issues
def collect_audit_findings(self) -> list[dict]:
findings: list[dict] = []
findings.append(
{
"check": "dm_policy",
"value": self.dm_policy,
"status": "ok" if self.dm_policy == "open" or len(self._dm_allow_from) > 0 else "warn",
"detail": f"DM policy: {self.dm_policy}, allowlist size: {len(self._dm_allow_from)}",
}
)
findings.append(
{
"check": "group_policy",
"value": self.group_policy,
"status": "ok" if self.group_policy == "open" or len(self._groups_config) > 0 else "warn",
"detail": f"Group policy: {self.group_policy}, configured groups: {len(self._groups_config)}",
}
)
findings.append(
{
"check": "token_configured",
"status": "ok" if self._cached_token else "warn",
"detail": "Channel access token is configured"
if self._cached_token
else "Channel access token missing",
}
)
findings.append(
{
"check": "secret_configured",
"status": "ok" if self._cached_secret else "warn",
"detail": "Channel secret is configured" if self._cached_secret else "Channel secret missing",
}
)
findings.append(
{
"check": "webhook_path",
"value": self.webhook_path,
"status": "ok",
"detail": f"Webhook path: {self.webhook_path}",
}
)
return findings
async def _auto_disable_graduated(self, reason: str) -> None:
from yuxi.channels.adapters.line.send import _BASE_BACKOFF, _MAX_RETRIES
self._reconnect_attempts += 1
if self._reconnect_attempts <= _MAX_RETRIES:
wait = _BASE_BACKOFF * (2 ** (self._reconnect_attempts - 1))
logger.warning(
f"[LINE] auth/disable retry {self._reconnect_attempts}/{_MAX_RETRIES}, "
f"waiting {wait:.1f}s before reconnect (reason: {reason})"
)
await asyncio.sleep(wait)
try:
await self.connect()
self._reconnect_attempts = 0
logger.info("[LINE] reconnected after graduated retry")
return
except Exception as e:
logger.warning(f"[LINE] reconnect attempt {self._reconnect_attempts} failed: {e}")
logger.error(f"[LINE] all {_MAX_RETRIES} reconnect attempts exhausted, disabling channel")
await self._auto_disable(reason)
async def before_deliver_payload(self, messages: list[dict]) -> list[dict]:
return [msg for msg in messages if msg is not None]
async def reload(self) -> None:
logger.info("[LINE] reloading configuration")
if self._sender:
try:
await self._sender.__aexit__()
except Exception:
pass
self._sender = None
token, _ = await self._resolve_token_and_secret()
if token:
self._cached_token = token
self._seen_webhook_ids.clear()
self._seen_message_ids.clear()
self._reconnect_attempts = 0
if self._status == ChannelStatus.CONNECTED:
await self.connect()
def _gen_pairing_code(length: int = 6) -> str:
import secrets
import string
return "".join(secrets.choice(string.digits) for _ in range(length))
def _bot_mentioned(content: str, self_user_id: str | None) -> bool:
if not content or not self_user_id:
return False
return self_user_id.lower() in content.lower()
def _is_dm_chat(chat_id: str) -> bool:
return chat_id.startswith("user_")
def _is_group_chat(chat_id: str) -> bool:
return chat_id.startswith("group_") or chat_id.startswith("room_")
def _is_private_hostname(hostname: str) -> bool:
if not hostname:
return True
private_suffixes = (
".local",
".localhost",
".internal",
".intranet",
".corp",
".lan",
".home",
".test",
)
hostname_lower = hostname.lower()
if hostname_lower in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
return True
if hostname_lower.startswith("10.") or hostname_lower.startswith("192.168."):
return True
if hostname_lower.startswith("172."):
parts = hostname_lower.split(".")
try:
second = int(parts[1])
if 16 <= second <= 31:
return True
except (IndexError, ValueError):
pass
if hostname_lower.startswith("169.254."):
return True
if hostname_lower.startswith("fc") or hostname_lower.startswith("fd"):
return True
if any(hostname_lower.endswith(suffix) for suffix in private_suffixes):
return True
return False
def _resolve_mentions(content: str, self_user_id: str | None, raw_event: dict | None = None) -> MentionsInfo:
if not content and not raw_event:
return MentionsInfo()
mentioned_ids: list[str] = []
is_bot_mentioned = False
if raw_event:
message_obj = raw_event.get("message", {})
mention_data = message_obj.get("mention")
if isinstance(mention_data, dict):
native_mentionees = mention_data.get("mentionees", [])
for m in native_mentionees:
if isinstance(m, dict):
uid = m.get("userId", "")
if uid and uid not in mentioned_ids:
mentioned_ids.append(uid)
if uid.lower() == (self_user_id or "").lower():
is_bot_mentioned = True
if content:
_mention_pattern = re.compile(r"@(U[a-f0-9]{32})", re.IGNORECASE)
for mention_id in _mention_pattern.findall(content):
if mention_id not in mentioned_ids:
mentioned_ids.append(mention_id)
if mention_id.lower() == (self_user_id or "").lower():
is_bot_mentioned = True
if not is_bot_mentioned and self_user_id:
_pattern_mention = re.compile(
rf"@\s*{re.escape(self_user_id)}\b|@bot\b|@\s*bot\b",
re.IGNORECASE,
)
if _pattern_mention.search(content):
is_bot_mentioned = True
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=is_bot_mentioned,
raw_text=content,
)
def _validate_target_id(target_id: str) -> bool:
return bool(re.match(r"^[UCR][a-f0-9]{32}$|^line:", target_id, re.IGNORECASE))