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