refactor(line): 重构Line适配器相关代码,优化功能与可读性

1.  抽离alt文本截断逻辑为公共工具函数
2.  重构快速回复构建函数,支持更多action类型
3.  新增 narrowcast 消息发送与进度查询能力
4.  新增用户粉丝数、好友人口统计API调用
5.  优化回复消息逻辑,支持quote token
6.  改进审批ID生成逻辑,避免重复
7.  调整部分配置与异常处理逻辑
8.  新增熔断器与相关指标统计
This commit is contained in:
Kris 2026-05-13 16:11:04 +08:00
parent 4e07292a81
commit f736a0ebb3
6 changed files with 283 additions and 70 deletions

View File

@ -9,10 +9,10 @@ from collections import deque
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any, ClassVar 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.formatter import LINEMessageFormatter
from yuxi.channels.adapters.line.markdown_to_line import extract_flex_messages_from_markdown 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.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.probe import probe_line_bot
from yuxi.channels.adapters.line.send import LINESender from yuxi.channels.adapters.line.send import LINESender
from yuxi.channels.adapters.line.webhook import ( from yuxi.channels.adapters.line.webhook import (
@ -26,6 +26,7 @@ from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import ( from yuxi.channels.exceptions import (
ChannelAuthenticationError, ChannelAuthenticationError,
) )
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import ( from yuxi.channels.models import (
ChannelAccountSnapshot, ChannelAccountSnapshot,
@ -113,8 +114,8 @@ class LINEAdapter(BaseChannelAdapter):
capabilities = ChannelCapabilities( capabilities = ChannelCapabilities(
chat_types=["direct", "group"], chat_types=["direct", "group"],
polls=False, polls=True,
reactions=False, reactions=True,
edit=False, edit=False,
unsend=False, unsend=False,
reply=True, reply=True,
@ -127,6 +128,7 @@ class LINEAdapter(BaseChannelAdapter):
block_streaming=True, block_streaming=True,
text_chunk_limit=5000, text_chunk_limit=5000,
max_media_size_mb=10, max_media_size_mb=10,
narrowcast=True,
) )
meta = ChannelMeta( meta = ChannelMeta(
id="line", id="line",
@ -155,7 +157,7 @@ class LINEAdapter(BaseChannelAdapter):
self._token_source: str = "unknown" self._token_source: str = "unknown"
self._last_reply_token: str | None = None self._last_reply_token: str | None = None
self._last_reply_token_at: float | None = None self._last_reply_token_at: float | None = None
self._token_lock: asyncio.Lock | None = None self._token_lock: asyncio.Lock = asyncio.Lock()
self.dm_policy: str = config.get("dm_policy", "open") if config else "open" 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.group_policy: str = config.get("group_policy", "open") if config else "open"
@ -200,6 +202,7 @@ class LINEAdapter(BaseChannelAdapter):
self._replay_guard = WebhookReplayGuard() self._replay_guard = WebhookReplayGuard()
self._signature_router = MultiAccountSignatureRouter() self._signature_router = MultiAccountSignatureRouter()
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="line")
self.thread_bindings_enabled: bool = ( self.thread_bindings_enabled: bool = (
config.get("thread_bindings", {}).get("enabled", False) if config else False config.get("thread_bindings", {}).get("enabled", False) if config else False
@ -293,8 +296,6 @@ class LINEAdapter(BaseChannelAdapter):
if not secret: if not secret:
raise ChannelAuthenticationError("LINE Channel Secret not configured") raise ChannelAuthenticationError("LINE Channel Secret not configured")
self._token_lock = asyncio.Lock()
proxy_url = self.config.get("proxy") proxy_url = self.config.get("proxy")
self._sender = LINESender(token, proxy=proxy_url) self._sender = LINESender(token, proxy=proxy_url)
await self._sender.__aenter__() await self._sender.__aenter__()
@ -331,7 +332,7 @@ class LINEAdapter(BaseChannelAdapter):
self._queue_task.cancel() self._queue_task.cancel()
try: try:
await asyncio.wait_for(self._queue_task, timeout=5.0) await asyncio.wait_for(self._queue_task, timeout=5.0)
except (asyncio.CancelledError, asyncio.TimeoutError, Exception): except (TimeoutError, asyncio.CancelledError, Exception):
pass pass
self._queue_task = None self._queue_task = None
if self._sender: if self._sender:
@ -350,7 +351,7 @@ class LINEAdapter(BaseChannelAdapter):
for task in self._loading_animation_tasks.values(): for task in self._loading_animation_tasks.values():
try: try:
await asyncio.wait_for(task, timeout=3.0) await asyncio.wait_for(task, timeout=3.0)
except (asyncio.CancelledError, asyncio.TimeoutError, Exception): except (TimeoutError, asyncio.CancelledError, Exception):
pass pass
self._loading_animation_tasks.clear() self._loading_animation_tasks.clear()
if self._stream_cleanup_task: if self._stream_cleanup_task:
@ -393,44 +394,53 @@ class LINEAdapter(BaseChannelAdapter):
messages = self._build_send_payload(response) messages = self._build_send_payload(response)
reply_token = response.metadata.get("reply_token") async def _do_send() -> DeliveryResult:
if reply_token: reply_token = response.metadata.get("reply_token")
result = ( if reply_token:
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 = ( result = (
await self._sender.push_message(chat_id, messages) if messages else DeliveryResult(success=True) 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) self._record_result(result)
if _is_auth_error(result.error): if _is_auth_error(result.error):
await self._auto_disable_graduated("LINE auth error during reply") await self._auto_disable_graduated("LINE auth error during push")
if _is_comm_channel_disabled(result.error): if _is_comm_channel_disabled(result.error):
await self._auto_disable_graduated("LINE communication channel disabled") 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: if result.success and result.message_id:
self._track_sent_message(result.message_id, chat_id, "reply") self._track_sent_message(result.message_id, chat_id, "push")
self._last_message_at = time.time() self._last_message_at = time.time()
return result return result
if not messages: try:
self._last_message_at = time.time() return await self._circuit_breaker.call(_do_send)
return DeliveryResult(success=True) except CircuitBreakerOpenError:
result = await self._sender.push_message(chat_id, messages) self._metrics["messages_failed"] += 1
self._record_result(result) return DeliveryResult(success=False, error="Circuit breaker open")
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]: def _build_send_payload(self, response: ChannelResponse) -> list[dict]:
metadata = response.metadata metadata = response.metadata
@ -722,6 +732,40 @@ class LINEAdapter(BaseChannelAdapter):
messages = self._formatter.format(response) messages = self._formatter.format(response)
return await self._sender.broadcast_message(messages) 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: async def mark_as_read(self, chat_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender: if self._status != ChannelStatus.CONNECTED or not self._sender:
return False return False
@ -767,11 +811,6 @@ class LINEAdapter(BaseChannelAdapter):
return False return False
return await self._sender.link_rich_menu_to_user(user_id, rich_menu_id) 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: async def unlink_rich_menu(self, user_id: str) -> bool:
if self._status != ChannelStatus.CONNECTED or not self._sender: if self._status != ChannelStatus.CONNECTED or not self._sender:
return False return False
@ -1227,9 +1266,10 @@ class LINEAdapter(BaseChannelAdapter):
@staticmethod @staticmethod
def _trim_set(id_set: set, order: deque, max_size: int = 10000, keep: int = 5000): def _trim_set(id_set: set, order: deque, max_size: int = 10000, keep: int = 5000):
while len(order) > max_size: if len(order) > max_size:
old = order.popleft() while len(order) > keep:
id_set.discard(old) old = order.popleft()
id_set.discard(old)
@staticmethod @staticmethod
def _ensure_https_url(url: str) -> str | None: def _ensure_https_url(url: str) -> str | None:
@ -1266,7 +1306,7 @@ class LINEAdapter(BaseChannelAdapter):
async def receive(self) -> AsyncIterator[ChannelMessage]: async def receive(self) -> AsyncIterator[ChannelMessage]:
return return
yield # pragma: no cover yield # type: ignore[misc]
def normalize_inbound(self, raw: bytes) -> ChannelMessage: def normalize_inbound(self, raw: bytes) -> ChannelMessage:
body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw
@ -1552,6 +1592,34 @@ class LINEAdapter(BaseChannelAdapter):
return peers 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: def build_channel_summary(self) -> dict:
return { return {
"channel": "line", "channel": "line",

View File

@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import itertools
from yuxi.utils.logging_config import logger from yuxi.utils.logging_config import logger
class LINEApprovalAdapter: class LINEApprovalAdapter:
_id_counter = itertools.count()
def __init__(self, adapter): def __init__(self, adapter):
self._adapter = adapter self._adapter = adapter
self._pending_approvals: dict[str, dict] = {} self._pending_approvals: dict[str, dict] = {}
@ -19,7 +23,7 @@ class LINEApprovalAdapter:
) -> dict | None: ) -> dict | None:
import time import time
approval_id = f"apr_{int(time.time() * 1000)}_{chat_id[:10]}" approval_id = f"apr_{int(time.time() * 1000)}_{next(self._id_counter)}_{chat_id[:10]}"
if actions is None: if actions is None:
actions = [ actions = [

View File

@ -2,6 +2,9 @@ from __future__ import annotations
import re import re
from yuxi.channels.adapters.line.flex_templates.common import truncate_alt_text
from yuxi.channels.adapters.line.quick_reply import build_quick_reply_items
QUICK_REPLIES_RE = re.compile(r"\[\[quick_replies:\s*(.+?)\]\]") QUICK_REPLIES_RE = re.compile(r"\[\[quick_replies:\s*(.+?)\]\]")
CONFIRM_RE = re.compile(r"\[\[confirm:\s*(.+?)\|\s*(.+?)\|\s*(.+?)\]\]") CONFIRM_RE = re.compile(r"\[\[confirm:\s*(.+?)\|\s*(.+?)\|\s*(.+?)\]\]")
BUTTONS_RE = re.compile(r"\[\[buttons:\s*(.+?)\]\]") BUTTONS_RE = re.compile(r"\[\[buttons:\s*(.+?)\]\]")
@ -20,13 +23,7 @@ def parse_line_directives(text: str) -> list[dict] | None:
if qr_match and len(result) < _MAX_DIRECTIVES: if qr_match and len(result) < _MAX_DIRECTIVES:
labels = [lbl.strip() for lbl in qr_match.group(1).split(",") if lbl.strip()] labels = [lbl.strip() for lbl in qr_match.group(1).split(",") if lbl.strip()]
main_text = QUICK_REPLIES_RE.sub("", text).strip() main_text = QUICK_REPLIES_RE.sub("", text).strip()
items = [ items = build_quick_reply_items(labels)
{
"type": "action",
"action": {"type": "message", "label": label[:20], "text": label},
}
for label in labels[:13]
]
if items: if items:
result.append( result.append(
{ {
@ -48,7 +45,7 @@ def parse_line_directives(text: str) -> list[dict] | None:
result.append( result.append(
{ {
"type": "template", "type": "template",
"altText": question[:400], "altText": truncate_alt_text(question),
"template": { "template": {
"type": "confirm", "type": "confirm",
"text": question[:240], "text": question[:240],
@ -88,7 +85,7 @@ def parse_line_directives(text: str) -> list[dict] | None:
result.append( result.append(
{ {
"type": "template", "type": "template",
"altText": title[:400], "altText": truncate_alt_text(title),
"template": { "template": {
"type": "buttons", "type": "buttons",
"title": title[:40], "title": title[:40],

View File

@ -4,7 +4,7 @@ from __future__ import annotations
def to_flex_message(contents: dict, alt_text: str = "Flex Message") -> dict: def to_flex_message(contents: dict, alt_text: str = "Flex Message") -> dict:
return { return {
"type": "flex", "type": "flex",
"altText": alt_text[:400], "altText": truncate_alt_text(alt_text),
"contents": contents, "contents": contents,
} }
@ -59,3 +59,7 @@ def _hero_image(url: str, aspect_ratio: str = "20:13") -> dict:
"aspectRatio": aspect_ratio, "aspectRatio": aspect_ratio,
"aspectMode": "cover", "aspectMode": "cover",
} }
def truncate_alt_text(text: str, max_len: int = 400) -> str:
return text[:max_len]

View File

@ -1,22 +1,75 @@
from __future__ import annotations from __future__ import annotations
def build_quick_reply_items(options: list[str]) -> list[dict]: def build_quick_reply_items(options: list[str | dict]) -> list[dict]:
items = [] items = []
for label in options[:13]: for opt in options[:13]:
if not label or not label.strip(): if isinstance(opt, str):
continue label = opt.strip()
safe_label = label.strip()[:20] if not label:
items.append( continue
{ safe_label = label[:20]
"type": "action", items.append(
"action": {"type": "message", "label": safe_label, "text": label}, {
} "type": "action",
) "action": {"type": "message", "label": safe_label, "text": label},
}
)
elif isinstance(opt, dict):
label = opt.get("label", "").strip()
if not label:
continue
safe_label = label[:20]
action_type = opt.get("action_type", "message")
if action_type == "postback":
action = {
"type": "postback",
"label": safe_label,
"data": opt.get("data", ""),
"display_text": opt.get("display_text", safe_label),
}
if "input_option" in opt:
action["inputOption"] = opt["input_option"]
if "fill_in_text" in opt:
action["fillInText"] = opt["fill_in_text"]
elif action_type == "uri":
action = {
"type": "uri",
"label": safe_label,
"uri": opt.get("uri", ""),
}
if opt.get("alt_uri"):
alt_uri = opt["alt_uri"]
if isinstance(alt_uri, dict):
action["altUri"] = alt_uri
elif action_type == "camera":
action = {
"type": "camera",
"label": safe_label,
}
elif action_type == "camera_roll":
action = {
"type": "cameraRoll",
"label": safe_label,
}
elif action_type == "location":
action = {
"type": "location",
"label": safe_label,
}
else:
action = {
"type": "message",
"label": safe_label,
"text": opt.get("text", label),
}
items.append({"type": "action", "action": action})
return items return items
def build_text_with_quick_reply(text: str, options: list[str]) -> dict: def build_text_with_quick_reply(text: str, options: list[str | dict]) -> dict:
if not options: if not options:
return {"type": "text", "text": text[:5000]} return {"type": "text", "text": text[:5000]}
items = build_quick_reply_items(options) items = build_quick_reply_items(options)

View File

@ -52,8 +52,15 @@ class LINESender:
payload = {"messages": messages[:5]} payload = {"messages": messages[:5]}
return await self._post_with_retry("/message/broadcast", payload, api_name="broadcast") return await self._post_with_retry("/message/broadcast", payload, api_name="broadcast")
async def reply_message(self, reply_token: str, messages: list[dict]) -> DeliveryResult: async def reply_message(
payload = {"replyToken": reply_token, "messages": messages[:5]} self, reply_token: str, messages: list[dict], quote_token: str | None = None
) -> DeliveryResult:
payload: dict = {"replyToken": reply_token, "messages": messages[:5]}
if quote_token:
messages_for_send = list(payload["messages"])
if messages_for_send:
messages_for_send[0] = {**messages_for_send[0], "quoteToken": quote_token}
payload["messages"] = messages_for_send
return await self._post_with_retry("/message/reply", payload, api_name="reply") return await self._post_with_retry("/message/reply", payload, api_name="reply")
async def show_loading_animation(self, chat_id: str, seconds: int = 20) -> None: async def show_loading_animation(self, chat_id: str, seconds: int = 20) -> None:
@ -183,6 +190,32 @@ class LINESender:
except Exception: except Exception:
return None return None
async def get_number_of_followers(self, date: str | None = None) -> dict | None:
if not self._client:
return None
try:
params = {"date": date} if date else {}
resp = await self._client.get(
"/insight/followers",
params=params,
)
if resp.status_code == 200:
return resp.json()
return None
except Exception:
return None
async def get_friend_demographics(self) -> dict | None:
if not self._client:
return None
try:
resp = await self._client.get("/insight/message/demographic")
if resp.status_code == 200:
return resp.json()
return None
except Exception:
return None
async def get_delivery_status(self, date: str, msg_type: str = "push") -> dict | None: async def get_delivery_status(self, date: str, msg_type: str = "push") -> dict | None:
if not self._client: if not self._client:
return None return None
@ -257,6 +290,60 @@ class LINESender:
async def validate_broadcast(self, messages: list[dict]) -> bool: async def validate_broadcast(self, messages: list[dict]) -> bool:
return await self._validate_messages("/message/validate/broadcast", {"messages": messages[:5]}) return await self._validate_messages("/message/validate/broadcast", {"messages": messages[:5]})
async def narrowcast_message(
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 not self._client:
return DeliveryResult(success=False, error="Client not initialized")
payload: dict = {"messages": messages[:5]}
if recipient:
payload["recipient"] = recipient
if demographic_filter:
payload["filter"] = {"demographic": demographic_filter}
if limit:
payload["limit"] = limit
if notification_disabled:
payload["notificationDisabled"] = True
headers = {}
if retry_key:
headers["X-Line-Retry-Key"] = retry_key
try:
resp = await self._client.post(
"/message/narrowcast",
json=payload,
headers=headers,
)
if resp.status_code == 202:
return DeliveryResult(success=True, message_id="narrowcast_accepted")
if resp.status_code == 200:
return DeliveryResult(success=True)
return DeliveryResult(
success=False,
error=f"Narrowcast failed: {resp.status_code}",
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def get_narrowcast_progress(self, request_id: str) -> dict | None:
if not self._client:
return None
try:
resp = await self._client.get(f"/message/progress/narrowcast?requestId={request_id}")
if resp.status_code == 200:
return resp.json()
return None
except Exception:
return None
async def _validate_messages(self, path: str, payload: dict) -> bool: async def _validate_messages(self, path: str, payload: dict) -> bool:
if not self._client: if not self._client:
return False return False