refactor(line): 重构Line适配器相关代码,优化功能与可读性
1. 抽离alt文本截断逻辑为公共工具函数 2. 重构快速回复构建函数,支持更多action类型 3. 新增 narrowcast 消息发送与进度查询能力 4. 新增用户粉丝数、好友人口统计API调用 5. 优化回复消息逻辑,支持quote token 6. 改进审批ID生成逻辑,避免重复 7. 调整部分配置与异常处理逻辑 8. 新增熔断器与相关指标统计
This commit is contained in:
parent
4e07292a81
commit
f736a0ebb3
@ -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",
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user