1. 抽离alt文本截断逻辑为公共工具函数 2. 重构快速回复构建函数,支持更多action类型 3. 新增 narrowcast 消息发送与进度查询能力 4. 新增用户粉丝数、好友人口统计API调用 5. 优化回复消息逻辑,支持quote token 6. 改进审批ID生成逻辑,避免重复 7. 调整部分配置与异常处理逻辑 8. 新增熔断器与相关指标统计
1846 lines
74 KiB
Python
1846 lines
74 KiB
Python
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))
|