新增企业微信、微博、WhatsApp、Workplace 四个渠道扩展。 企业微信渠道扩展主要模块:config, gateway, webhook, webhook_bot, outbound, streaming, pairing, security, crypto, dedupe, persistent_dedupe, card, directory, events, externalcontact, media, mentions, menu, message, oauth, status 微博渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, passive_reply, broadcast, message, menu, media, subscription, template, status WhatsApp 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, monitor, status Workplace 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, challenge, groups, media, mentions, menu, monitor, persona, quick_reply, signature, subscriptions, template, threading, users, status
762 lines
25 KiB
Python
762 lines
25 KiB
Python
"""WhatsApp Outbound 适配器 — 消息发送 (Cloud API HTTPS REST)"""
|
|
|
|
import asyncio
|
|
import aiohttp
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WHATSAPP_API_BASE = "https://graph.facebook.com/v22.0"
|
|
MAX_RETRIES = 3
|
|
INITIAL_BACKOFF_SECONDS = 1.0
|
|
|
|
|
|
def _mime_for_type(media_type: str) -> str:
|
|
mapping = {
|
|
"image": "image/jpeg",
|
|
"video": "video/mp4",
|
|
"audio": "audio/mp3",
|
|
"voice": "audio/ogg",
|
|
"document": "application/octet-stream",
|
|
}
|
|
return mapping.get(media_type, "application/octet-stream")
|
|
|
|
|
|
class WhatsAppOutbound:
|
|
delivery_mode = "direct"
|
|
chunker_mode = "length"
|
|
text_chunk_limit: int = 2000
|
|
poll_max_options: int = 12
|
|
supports_poll_duration_seconds = False
|
|
supports_anonymous_polls = False
|
|
extract_markdown_images = True
|
|
presentation_capabilities = None
|
|
delivery_capabilities = None
|
|
|
|
def __init__(self, config_adapter=None):
|
|
self._config_adapter = config_adapter
|
|
|
|
@staticmethod
|
|
def _build_url(phone_number_id: str) -> str:
|
|
return f"{WHATSAPP_API_BASE}/{phone_number_id}/messages"
|
|
|
|
@staticmethod
|
|
def _headers(access_token: str) -> dict:
|
|
return {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async def _resolve_account(self, account_id: str | None) -> dict | None:
|
|
from yuxi.channel.extensions.whatsapp.config import WhatsAppConfigAdapter
|
|
|
|
adapter = self._config_adapter or WhatsAppConfigAdapter()
|
|
aid = account_id or adapter.default_account_id({})
|
|
return await adapter.resolve_account(aid)
|
|
|
|
async def _send_with_retry(
|
|
self,
|
|
phone_number_id: str,
|
|
access_token: str,
|
|
payload: dict,
|
|
*,
|
|
max_retries: int = MAX_RETRIES,
|
|
) -> tuple[int, dict]:
|
|
backoff = INITIAL_BACKOFF_SECONDS
|
|
last_result: dict = {}
|
|
|
|
for attempt in range(max_retries + 1):
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
self._build_url(phone_number_id),
|
|
json=payload,
|
|
headers=self._headers(access_token),
|
|
) as resp:
|
|
result = await resp.json()
|
|
if resp.status == 200:
|
|
return resp.status, result
|
|
if resp.status == 429 and attempt < max_retries:
|
|
retry_after = resp.headers.get("Retry-After")
|
|
wait = float(retry_after) if retry_after else backoff
|
|
logger.warning(
|
|
"WhatsApp rate limited, retrying in %.1fs (attempt %d/%d)",
|
|
wait, attempt + 1, max_retries,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
backoff *= 2
|
|
last_result = result
|
|
continue
|
|
return resp.status, result
|
|
|
|
return 429, last_result
|
|
|
|
@staticmethod
|
|
def _inject_biz_opaque(payload: dict, callback_data: str | None) -> None:
|
|
if callback_data:
|
|
payload["biz_opaque_callback_data"] = callback_data[:512]
|
|
|
|
# ── 基础消息发送 ──────────────────────────────────────────
|
|
|
|
async def send_text(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
recipient_type: str = "individual",
|
|
biz_opaque_data: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
logger.error("WhatsApp send_text: account not resolved")
|
|
return
|
|
|
|
payload: dict[str, Any] = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": recipient_type,
|
|
"to": target_id,
|
|
"type": "text",
|
|
"text": {"body": content},
|
|
}
|
|
|
|
if reply_to_id:
|
|
payload["context"] = {"message_id": reply_to_id}
|
|
self._inject_biz_opaque(payload, biz_opaque_data)
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_text failed: {result}")
|
|
else:
|
|
logger.debug(f"WhatsApp send_text OK: msg_id={result.get('messages', [{}])[0].get('id')}")
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
recipient_type: str = "individual",
|
|
*,
|
|
use_link: bool = False,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
if not media_url:
|
|
logger.error("WhatsApp send_media: media_url is empty")
|
|
return
|
|
|
|
media_ref: dict[str, str]
|
|
if use_link:
|
|
media_ref = {"link": media_url}
|
|
else:
|
|
media_id = await self._upload_media(media_url, media_type, account)
|
|
if not media_id:
|
|
logger.error("WhatsApp send_media: media upload failed, aborting")
|
|
return
|
|
media_ref = {"id": media_id}
|
|
|
|
payload: dict[str, Any] = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": recipient_type,
|
|
"to": target_id,
|
|
"type": media_type,
|
|
media_type: media_ref,
|
|
}
|
|
if reply_to_id:
|
|
payload["context"] = {"message_id": reply_to_id}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_media failed: {result}")
|
|
|
|
async def send_reaction(
|
|
self,
|
|
target_id: str,
|
|
message_id: str,
|
|
emoji: str,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"to": target_id,
|
|
"type": "reaction",
|
|
"reaction": {"message_id": message_id, "emoji": emoji},
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_reaction failed: {result}")
|
|
|
|
async def send_sticker(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
if not media_url:
|
|
logger.error("WhatsApp send_sticker: media_url is empty")
|
|
return
|
|
|
|
media_id = await self._upload_media(media_url, "image", account)
|
|
if not media_id:
|
|
logger.error("WhatsApp send_sticker: media upload failed, aborting")
|
|
return
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "sticker",
|
|
"sticker": {"id": media_id},
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_sticker failed: {result}")
|
|
|
|
async def send_location(
|
|
self,
|
|
target_id: str,
|
|
latitude: float,
|
|
longitude: float,
|
|
*,
|
|
name: str | None = None,
|
|
address: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
location: dict[str, Any] = {"latitude": str(latitude), "longitude": str(longitude)}
|
|
if name:
|
|
location["name"] = name
|
|
if address:
|
|
location["address"] = address
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "location",
|
|
"location": location,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_location failed: {result}")
|
|
|
|
async def send_contacts(
|
|
self,
|
|
target_id: str,
|
|
contacts: list[dict],
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "contacts",
|
|
"contacts": contacts,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_contacts failed: {result}")
|
|
|
|
# ── 请求/回复类交互式消息 ─────────────────────────────
|
|
|
|
async def request_location(
|
|
self,
|
|
target_id: str,
|
|
body_text: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "interactive",
|
|
"interactive": {
|
|
"type": "location_request_message",
|
|
"body": {"text": body_text[:1024]},
|
|
},
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp request_location failed: {result}")
|
|
|
|
async def request_address(
|
|
self,
|
|
target_id: str,
|
|
body_text: str,
|
|
country: str,
|
|
*,
|
|
values: list[str] | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
interactive: dict[str, Any] = {
|
|
"type": "address_message",
|
|
"body": {"text": body_text[:1024]},
|
|
}
|
|
|
|
if values:
|
|
interactive["action"] = {
|
|
"name": "address_message",
|
|
"parameters": {
|
|
"country": country,
|
|
"values": values,
|
|
},
|
|
}
|
|
else:
|
|
interactive["action"] = {
|
|
"name": "address_message",
|
|
"parameters": {"country": country},
|
|
}
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp request_address failed: {result}")
|
|
|
|
async def send_cta_url_button(
|
|
self,
|
|
target_id: str,
|
|
body_text: str,
|
|
button_text: str,
|
|
url: str,
|
|
*,
|
|
header_text: str | None = None,
|
|
footer_text: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
interactive: dict[str, Any] = {
|
|
"type": "cta_url",
|
|
"body": {"text": body_text[:1024]},
|
|
"action": {
|
|
"name": "cta_url",
|
|
"parameters": {"display_text": button_text[:20], "url": url},
|
|
},
|
|
}
|
|
if header_text:
|
|
interactive["header"] = {"type": "text", "text": header_text[:60]}
|
|
if footer_text:
|
|
interactive["footer"] = {"text": footer_text[:60]}
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_cta_url_button failed: {result}")
|
|
|
|
async def send_text_to_group(
|
|
self,
|
|
group_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
await self.send_text(
|
|
group_id, content,
|
|
reply_to_id=reply_to_id,
|
|
account_id=account_id,
|
|
recipient_type="group",
|
|
)
|
|
|
|
# ── 用户管理 ─────────────────────────────────────────────
|
|
|
|
async def block_user(
|
|
self,
|
|
phone_numbers: list[str],
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
phone_number_id = account.get("phone_number_id", "")
|
|
access_token = account.get("access_token", "")
|
|
if not phone_number_id or not access_token:
|
|
logger.error("WhatsApp block_user: missing credentials")
|
|
return
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {"messaging_product": "whatsapp", "blocked_numbers": phone_numbers}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
url = f"{WHATSAPP_API_BASE}/{phone_number_id}/block_users"
|
|
async with session.post(url, json=payload, headers=headers) as resp:
|
|
result = await resp.json()
|
|
if resp.status == 200:
|
|
logger.info(f"Blocked users: {phone_numbers}")
|
|
else:
|
|
logger.error(f"Block users failed: {result}")
|
|
except Exception:
|
|
logger.exception("WhatsApp block_user failed")
|
|
|
|
# ── 模板消息 ─────────────────────────────────────────────
|
|
|
|
async def send_template(
|
|
self,
|
|
target_id: str,
|
|
template_name: str,
|
|
language_code: str,
|
|
*,
|
|
header_params: list[dict] | None = None,
|
|
body_params: list[dict] | None = None,
|
|
button_params: list[dict] | None = None,
|
|
account_id: str | None = None,
|
|
) -> str | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
logger.error("WhatsApp send_template: account not resolved")
|
|
return None
|
|
|
|
components: list[dict] = []
|
|
if header_params:
|
|
components.append({"type": "header", "parameters": header_params})
|
|
if body_params:
|
|
components.append({"type": "body", "parameters": body_params})
|
|
if button_params:
|
|
components.append({"type": "button", "sub_type": "url", "parameters": button_params})
|
|
|
|
payload: dict[str, Any] = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "template",
|
|
"template": {
|
|
"name": template_name,
|
|
"language": {"code": language_code},
|
|
},
|
|
}
|
|
if components:
|
|
payload["template"]["components"] = components
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_template failed: {result}")
|
|
return None
|
|
|
|
msg_id = result.get("messages", [{}])[0].get("id")
|
|
logger.debug(f"WhatsApp send_template OK: msg_id={msg_id}")
|
|
return msg_id
|
|
|
|
# ── 交互式消息 ───────────────────────────────────────────
|
|
|
|
async def send_interactive_buttons(
|
|
self,
|
|
target_id: str,
|
|
body_text: str,
|
|
buttons: list[dict],
|
|
*,
|
|
header_text: str | None = None,
|
|
footer_text: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
if len(buttons) > 3:
|
|
logger.warning("WhatsApp supports max 3 reply buttons, truncating")
|
|
buttons = buttons[:3]
|
|
|
|
interactive: dict[str, Any] = {
|
|
"type": "button",
|
|
"body": {"text": body_text[:1024]},
|
|
"action": {"buttons": [{"type": "reply", "reply": b} for b in buttons]},
|
|
}
|
|
if header_text:
|
|
interactive["header"] = {"type": "text", "text": header_text[:60]}
|
|
if footer_text:
|
|
interactive["footer"] = {"text": footer_text[:60]}
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_interactive_buttons failed: {result}")
|
|
|
|
async def send_interactive_list(
|
|
self,
|
|
target_id: str,
|
|
body_text: str,
|
|
button_text: str,
|
|
sections: list[dict],
|
|
*,
|
|
header_text: str | None = None,
|
|
footer_text: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
interactive: dict[str, Any] = {
|
|
"type": "list",
|
|
"body": {"text": body_text[:1024]},
|
|
"action": {
|
|
"button": button_text[:20],
|
|
"sections": [
|
|
{
|
|
"title": s.get("title", "")[:24],
|
|
"rows": [
|
|
{"id": r["id"], "title": r["title"][:24]}
|
|
for r in s.get("rows", [])[:10]
|
|
],
|
|
}
|
|
for s in sections
|
|
],
|
|
},
|
|
}
|
|
if header_text:
|
|
interactive["header"] = {"type": "text", "text": header_text[:60]}
|
|
if footer_text:
|
|
interactive["footer"] = {"text": footer_text[:60]}
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"recipient_type": "individual",
|
|
"to": target_id,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
|
|
status, result = await self._send_with_retry(
|
|
account.get("phone_number_id", ""),
|
|
account.get("access_token", ""),
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
logger.error(f"WhatsApp send_interactive_list failed: {result}")
|
|
|
|
# ── 已读回执 ─────────────────────────────────────────────
|
|
|
|
async def mark_as_read(
|
|
self,
|
|
message_id: str,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return
|
|
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"status": "read",
|
|
"message_id": message_id,
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
self._build_url(account.get("phone_number_id", "")),
|
|
json=payload,
|
|
headers=self._headers(account.get("access_token", "")),
|
|
) as resp:
|
|
if resp.status != 200:
|
|
logger.error(f"WhatsApp mark_as_read failed: {await resp.json()}")
|
|
|
|
# ── 媒体管理 ─────────────────────────────────────────────
|
|
|
|
async def _upload_media(self, media_url: str, media_type: str, account: dict) -> str:
|
|
phone_number_id = account.get("phone_number_id", "")
|
|
access_token = account.get("access_token", "")
|
|
if not phone_number_id or not access_token:
|
|
logger.error("WhatsApp _upload_media: missing credentials")
|
|
return ""
|
|
|
|
upload_url = f"{WHATSAPP_API_BASE}/{phone_number_id}/media"
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
|
|
try:
|
|
if media_url.startswith(("http://", "https://")):
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(media_url) as resp:
|
|
if resp.status != 200:
|
|
logger.error(f"Failed to download media from {media_url}: HTTP {resp.status}")
|
|
return ""
|
|
media_data = await resp.read()
|
|
|
|
content_type = _mime_for_type(media_type)
|
|
form_data = aiohttp.FormData()
|
|
form_data.add_field("file", media_data, content_type=content_type)
|
|
form_data.add_field("messaging_product", "whatsapp")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(upload_url, data=form_data, headers=headers) as resp:
|
|
result = await resp.json()
|
|
if resp.status == 200:
|
|
media_id = result.get("id", "")
|
|
logger.debug(f"Media uploaded: media_id={media_id}")
|
|
return media_id
|
|
logger.error(f"Media upload failed: {result}")
|
|
return ""
|
|
else:
|
|
logger.error(f"Unsupported media URL scheme: {media_url}")
|
|
return ""
|
|
except Exception:
|
|
logger.exception("WhatsApp _upload_media failed")
|
|
return ""
|
|
|
|
async def download_media(
|
|
self,
|
|
media_id: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> bytes | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return None
|
|
|
|
download_url = f"{WHATSAPP_API_BASE}/{media_id}"
|
|
headers = {"Authorization": f"Bearer {account.get('access_token', '')}"}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(download_url, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return await resp.read()
|
|
logger.error(f"Media download failed: HTTP {resp.status}")
|
|
return None
|
|
except Exception:
|
|
logger.exception("WhatsApp download_media failed")
|
|
return None
|
|
|
|
# ── 文本处理 ─────────────────────────────────────────────
|
|
|
|
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
|
if len(text) <= limit:
|
|
return [text]
|
|
chunks = []
|
|
while len(text) > limit:
|
|
chunks.append(text[:limit])
|
|
text = text[limit:]
|
|
if text:
|
|
chunks.append(text)
|
|
return chunks
|
|
|
|
def sanitize_text(self, text: str, payload: object) -> str:
|
|
from yuxi.channel.extensions.whatsapp.format import markdown_to_whatsapp
|
|
|
|
return markdown_to_whatsapp(text)
|
|
|
|
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
|
|
return False
|
|
|
|
def resolve_target(
|
|
self,
|
|
to: str | None = None,
|
|
*,
|
|
config: dict | None = None,
|
|
allow_from: list[str] | None = None,
|
|
account_id: str | None = None,
|
|
mode: str | None = None,
|
|
) -> tuple[bool, str]:
|
|
if not to:
|
|
return False, "target required"
|
|
return True, to
|