ForcePilot/backend/package/yuxi/channels/adapters/line/adapter.py

1722 lines
69 KiB
Python
Raw Normal View History

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