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 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.approval import LINEApprovalAdapter
|
||||
from yuxi.channels.adapters.line.probe import probe_line_bot
|
||||
from yuxi.channels.adapters.line.send import LINESender
|
||||
from yuxi.channels.adapters.line.webhook import (
|
||||
@ -26,6 +26,7 @@ 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,
|
||||
@ -113,8 +114,8 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
capabilities = ChannelCapabilities(
|
||||
chat_types=["direct", "group"],
|
||||
polls=False,
|
||||
reactions=False,
|
||||
polls=True,
|
||||
reactions=True,
|
||||
edit=False,
|
||||
unsend=False,
|
||||
reply=True,
|
||||
@ -127,6 +128,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
block_streaming=True,
|
||||
text_chunk_limit=5000,
|
||||
max_media_size_mb=10,
|
||||
narrowcast=True,
|
||||
)
|
||||
meta = ChannelMeta(
|
||||
id="line",
|
||||
@ -155,7 +157,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
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 | 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"
|
||||
@ -200,6 +202,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
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
|
||||
@ -293,8 +296,6 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
if not secret:
|
||||
raise ChannelAuthenticationError("LINE Channel Secret not configured")
|
||||
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
proxy_url = self.config.get("proxy")
|
||||
self._sender = LINESender(token, proxy=proxy_url)
|
||||
await self._sender.__aenter__()
|
||||
@ -331,7 +332,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
self._queue_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._queue_task, timeout=5.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
|
||||
except (TimeoutError, asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._queue_task = None
|
||||
if self._sender:
|
||||
@ -350,7 +351,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
for task in self._loading_animation_tasks.values():
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=3.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
|
||||
except (TimeoutError, asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._loading_animation_tasks.clear()
|
||||
if self._stream_cleanup_task:
|
||||
@ -393,44 +394,53 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
messages = self._build_send_payload(response)
|
||||
|
||||
reply_token = response.metadata.get("reply_token")
|
||||
if reply_token:
|
||||
result = (
|
||||
await self._sender.reply_message(reply_token, messages) if messages else DeliveryResult(success=True)
|
||||
)
|
||||
if not result.success and _is_reply_token_expired(result.error):
|
||||
logger.info("[LINE] replyToken expired, falling back to push")
|
||||
async def _do_send() -> DeliveryResult:
|
||||
reply_token = response.metadata.get("reply_token")
|
||||
if reply_token:
|
||||
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)
|
||||
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):
|
||||
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, "reply")
|
||||
self._track_sent_message(result.message_id, chat_id, "push")
|
||||
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
|
||||
@ -722,6 +732,40 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
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
|
||||
@ -767,11 +811,6 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
return False
|
||||
return await self._sender.link_rich_menu_to_user(user_id, rich_menu_id)
|
||||
|
||||
async def link_rich_menu_bulk(self, user_ids: list[str], rich_menu_id: str) -> bool:
|
||||
if self._status != ChannelStatus.CONNECTED or not self._sender:
|
||||
return False
|
||||
return await self._sender.link_rich_menu_to_users(user_ids, rich_menu_id)
|
||||
|
||||
async def unlink_rich_menu(self, user_id: str) -> bool:
|
||||
if self._status != ChannelStatus.CONNECTED or not self._sender:
|
||||
return False
|
||||
@ -1227,9 +1266,10 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _trim_set(id_set: set, order: deque, max_size: int = 10000, keep: int = 5000):
|
||||
while len(order) > max_size:
|
||||
old = order.popleft()
|
||||
id_set.discard(old)
|
||||
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:
|
||||
@ -1266,7 +1306,7 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
async def receive(self) -> AsyncIterator[ChannelMessage]:
|
||||
return
|
||||
yield # pragma: no cover
|
||||
yield # type: ignore[misc]
|
||||
|
||||
def normalize_inbound(self, raw: bytes) -> ChannelMessage:
|
||||
body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
@ -1552,6 +1592,34 @@ class LINEAdapter(BaseChannelAdapter):
|
||||
|
||||
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",
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
class LINEApprovalAdapter:
|
||||
_id_counter = itertools.count()
|
||||
|
||||
def __init__(self, adapter):
|
||||
self._adapter = adapter
|
||||
self._pending_approvals: dict[str, dict] = {}
|
||||
@ -19,7 +23,7 @@ class LINEApprovalAdapter:
|
||||
) -> dict | None:
|
||||
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:
|
||||
actions = [
|
||||
|
||||
@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
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*(.+?)\]\]")
|
||||
CONFIRM_RE = re.compile(r"\[\[confirm:\s*(.+?)\|\s*(.+?)\|\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:
|
||||
labels = [lbl.strip() for lbl in qr_match.group(1).split(",") if lbl.strip()]
|
||||
main_text = QUICK_REPLIES_RE.sub("", text).strip()
|
||||
items = [
|
||||
{
|
||||
"type": "action",
|
||||
"action": {"type": "message", "label": label[:20], "text": label},
|
||||
}
|
||||
for label in labels[:13]
|
||||
]
|
||||
items = build_quick_reply_items(labels)
|
||||
if items:
|
||||
result.append(
|
||||
{
|
||||
@ -48,7 +45,7 @@ def parse_line_directives(text: str) -> list[dict] | None:
|
||||
result.append(
|
||||
{
|
||||
"type": "template",
|
||||
"altText": question[:400],
|
||||
"altText": truncate_alt_text(question),
|
||||
"template": {
|
||||
"type": "confirm",
|
||||
"text": question[:240],
|
||||
@ -88,7 +85,7 @@ def parse_line_directives(text: str) -> list[dict] | None:
|
||||
result.append(
|
||||
{
|
||||
"type": "template",
|
||||
"altText": title[:400],
|
||||
"altText": truncate_alt_text(title),
|
||||
"template": {
|
||||
"type": "buttons",
|
||||
"title": title[:40],
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
def to_flex_message(contents: dict, alt_text: str = "Flex Message") -> dict:
|
||||
return {
|
||||
"type": "flex",
|
||||
"altText": alt_text[:400],
|
||||
"altText": truncate_alt_text(alt_text),
|
||||
"contents": contents,
|
||||
}
|
||||
|
||||
@ -59,3 +59,7 @@ def _hero_image(url: str, aspect_ratio: str = "20:13") -> dict:
|
||||
"aspectRatio": aspect_ratio,
|
||||
"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
|
||||
|
||||
|
||||
def build_quick_reply_items(options: list[str]) -> list[dict]:
|
||||
def build_quick_reply_items(options: list[str | dict]) -> list[dict]:
|
||||
items = []
|
||||
for label in options[:13]:
|
||||
if not label or not label.strip():
|
||||
continue
|
||||
safe_label = label.strip()[:20]
|
||||
items.append(
|
||||
{
|
||||
"type": "action",
|
||||
"action": {"type": "message", "label": safe_label, "text": label},
|
||||
}
|
||||
)
|
||||
for opt in options[:13]:
|
||||
if isinstance(opt, str):
|
||||
label = opt.strip()
|
||||
if not label:
|
||||
continue
|
||||
safe_label = label[:20]
|
||||
items.append(
|
||||
{
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
return {"type": "text", "text": text[:5000]}
|
||||
items = build_quick_reply_items(options)
|
||||
|
||||
@ -52,8 +52,15 @@ class LINESender:
|
||||
payload = {"messages": messages[:5]}
|
||||
return await self._post_with_retry("/message/broadcast", payload, api_name="broadcast")
|
||||
|
||||
async def reply_message(self, reply_token: str, messages: list[dict]) -> DeliveryResult:
|
||||
payload = {"replyToken": reply_token, "messages": messages[:5]}
|
||||
async def reply_message(
|
||||
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")
|
||||
|
||||
async def show_loading_animation(self, chat_id: str, seconds: int = 20) -> None:
|
||||
@ -183,6 +190,32 @@ class LINESender:
|
||||
except Exception:
|
||||
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:
|
||||
if not self._client:
|
||||
return None
|
||||
@ -257,6 +290,60 @@ class LINESender:
|
||||
async def validate_broadcast(self, messages: list[dict]) -> bool:
|
||||
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:
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
Loading…
Reference in New Issue
Block a user