from __future__ import annotations import asyncio import json import os import re import time from collections import deque from collections.abc import AsyncIterator from typing import Any, ClassVar from yuxi.channels.adapters.line.approval import LINEApprovalAdapter 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.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.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError 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=True, reactions=True, 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, narrowcast=True, ) 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._last_reply_token_at: float | None = None self._token_lock: asyncio.Lock = asyncio.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_webhook_ids_order: deque[str] = deque() self._seen_message_ids: set[str] = set() self._seen_message_ids_order: deque[str] = deque() 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._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="line") 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._stream_cleanup_task: asyncio.Task | None = None 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: def _read(): with open(file_path, encoding="utf-8") as f: return f.read().strip() loop = asyncio.get_event_loop() return await loop.run_in_executor(None, _read) 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._stream_cleanup_task = asyncio.ensure_future(self._stream_state_cleanup_loop()) 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 asyncio.wait_for(self._queue_task, timeout=5.0) except (TimeoutError, 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_webhook_ids_order.clear() self._seen_message_ids.clear() self._seen_message_ids_order.clear() self._sent_message_cache.clear() self._replay_guard.clear() self._signature_router.clear() for task in self._loading_animation_tasks.values(): task.cancel() for task in self._loading_animation_tasks.values(): try: await asyncio.wait_for(task, timeout=3.0) except (TimeoutError, asyncio.CancelledError, Exception): pass self._loading_animation_tasks.clear() if self._stream_cleanup_task: self._stream_cleanup_task.cancel() self._stream_cleanup_task = None 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_webhook_ids_order.clear() self._seen_message_ids.clear() self._seen_message_ids_order.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) async def _do_send() -> DeliveryResult: 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: self._last_message_at = time.time() 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 try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: self._metrics["messages_failed"] += 1 return DeliveryResult(success=False, error="Circuit breaker open") 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) metadata = {} if state.get("use_reply_mode"): async with self._token_lock: metadata["reply_token"] = self._last_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" async with self._token_lock: reply_token = self._last_reply_token reply_token_at = self._last_reply_token_at use_reply = False if reply_token and reply_token_at: use_reply = (time.time() - reply_token_at) < 50 state["use_reply_mode"] = use_reply 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 _stream_state_cleanup_loop(self) -> None: while self._status == ChannelStatus.CONNECTED: await asyncio.sleep(300) now = time.time() stale_keys = [ key for key, state in self._streaming_states.items() if not state.get("finished") and now - state.get("started_at", now) > 600 ] for key in stale_keys: logger.warning(f"[LINE] cleaning up stale stream state: {key}") del self._streaming_states[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 send_narrowcast( self, messages: list[dict], recipient: dict | None = None, demographic_filter: dict | None = None, limit: dict | None = None, notification_disabled: bool = False, retry_key: str | None = None, ) -> DeliveryResult: if self._status != ChannelStatus.CONNECTED or not self._sender: return DeliveryResult(success=False, error="LINE not connected") result = await self._sender.narrowcast_message( messages=messages, recipient=recipient, demographic_filter=demographic_filter, limit=limit, notification_disabled=notification_disabled, retry_key=retry_key, ) if result.success: self._metrics["messages_sent"] += 1 else: self._metrics["messages_failed"] += 1 self._metrics["error_counts"][_classify_send_error(result.error)] = ( self._metrics["error_counts"].get(_classify_send_error(result.error), 0) + 1 ) return result async def get_narrowcast_progress(self, request_id: str) -> dict | None: if self._status != ChannelStatus.CONNECTED or not self._sender: return None return await self._sender.get_narrowcast_progress(request_id) 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 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) self._seen_webhook_ids_order.append(webhook_event_id) self._trim_set(self._seen_webhook_ids, self._seen_webhook_ids_order) 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) self._seen_message_ids_order.append(message_id) self._trim_set(self._seen_message_ids, self._seen_message_ids_order) return False @staticmethod def _trim_set(id_set: set, order: deque, max_size: int = 10000, keep: int = 5000): if len(order) > max_size: while len(order) > keep: old = order.popleft() id_set.discard(old) @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 # type: ignore[misc] 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, } self._last_reply_token = primary_msg.metadata.get("reply_token") self._last_reply_token_at = time.time() if self._last_reply_token else None 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 @staticmethod def build_text_v2_message(text: str, substitutions: list[dict] | None = None) -> dict: msg: dict = {"type": "textV2", "text": text} if substitutions: msg["substitutions"] = substitutions return msg @staticmethod def build_imagemap_message( base_url: str, alt_text: str, base_width: int = 1040, base_height: int = 1040, actions: list[dict] | None = None, video: dict | None = None, ) -> dict: msg: dict = { "type": "imagemap", "baseUrl": base_url, "altText": alt_text[:400], "baseSize": {"width": base_width, "height": base_height}, } if actions: msg["actions"] = actions if video: msg["video"] = video return msg 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 self._cached_token = None self._cached_secret = None self._last_reply_token = None self._last_reply_token_at = None token, _ = await self._resolve_token_and_secret() if token: self._cached_token = token self._seen_webhook_ids.clear() self._seen_webhook_ids_order.clear() self._seen_message_ids.clear() self._seen_message_ids_order.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))