新增企业微信、微博、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
396 lines
12 KiB
Python
396 lines
12 KiB
Python
import hashlib
|
|
import hmac
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.workplace.errors import classify_error
|
|
from yuxi.channel.extensions.workplace.types import WorkplaceOutboundResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WORKPLACE_API_BASE = "https://graph.facebook.com/v24.0"
|
|
|
|
TEXT_MAX_CHARS = 2000
|
|
QUICK_REPLY_MAX = 13
|
|
|
|
|
|
class WorkplaceOutbound:
|
|
delivery_mode = "direct"
|
|
chunker_mode = "length"
|
|
text_chunk_limit: int = TEXT_MAX_CHARS
|
|
poll_max_options: int | None = None
|
|
supports_poll_duration_seconds = False
|
|
supports_anonymous_polls = False
|
|
extract_markdown_images = False
|
|
presentation_capabilities = None
|
|
delivery_capabilities = None
|
|
|
|
def __init__(self):
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
async def _ensure_client(self):
|
|
if self._client is None:
|
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
|
|
|
|
async def _close_client(self):
|
|
if self._client:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
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,
|
|
) -> list[dict]:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
logger.error("Workplace send_text: account not resolved")
|
|
return [{"success": False, "error": "account not resolved"}]
|
|
|
|
chunks = self._chunk_text(content, TEXT_MAX_CHARS)
|
|
results = []
|
|
|
|
for chunk in chunks:
|
|
message: dict[str, Any] = {"text": chunk}
|
|
if reply_to_id:
|
|
message["reply_to"] = {"mid": reply_to_id}
|
|
payload: dict[str, Any] = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"message": message,
|
|
}
|
|
result = await self._post(account, payload)
|
|
results.append(result)
|
|
if not result.get("success"):
|
|
break
|
|
|
|
return results
|
|
|
|
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,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
messenger_types = {"image": "image", "video": "video", "voice": "audio", "file": "file"}
|
|
fb_type = messenger_types.get(media_type, "file")
|
|
|
|
message: dict[str, Any] = {
|
|
"attachment": {
|
|
"type": fb_type,
|
|
"payload": {"url": media_url, "is_reusable": True},
|
|
}
|
|
}
|
|
if reply_to_id:
|
|
message["reply_to"] = {"mid": reply_to_id}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"message": message,
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
|
await self.send_sender_action(target_id, "typing_on")
|
|
|
|
async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
|
await self.send_sender_action(target_id, "typing_off")
|
|
|
|
async def send_sender_action(
|
|
self,
|
|
target_id: str,
|
|
action: str,
|
|
account_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
) -> WorkplaceOutboundResult:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return WorkplaceOutboundResult(success=False)
|
|
|
|
payload = {
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"sender_action": action,
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return WorkplaceOutboundResult(
|
|
message_id=result.get("message_id"),
|
|
recipient_id=target_id,
|
|
success=result.get("success", False),
|
|
)
|
|
|
|
async def send_template(
|
|
self,
|
|
target_id: str,
|
|
template_type: str,
|
|
elements: list[dict],
|
|
*,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"message": {
|
|
"attachment": {
|
|
"type": "template",
|
|
"payload": {
|
|
"template_type": template_type,
|
|
"elements": elements,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
async def send_quick_replies(
|
|
self,
|
|
target_id: str,
|
|
text: str,
|
|
quick_replies: list[dict],
|
|
*,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"message": {
|
|
"text": text[:2000],
|
|
"quick_replies": quick_replies[:QUICK_REPLY_MAX],
|
|
},
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
async def create_group_chat(
|
|
self,
|
|
user_ids: list[str],
|
|
initial_message: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"recipient": {"ids": user_ids},
|
|
"message": {"text": initial_message[:2000]},
|
|
}
|
|
url = f"{account.get('api_base_url', 'https://graph.facebook.com/v24.0')}/me/messages"
|
|
params = {"access_token": account["access_token"]}
|
|
|
|
app_secret = account.get("app_secret", "")
|
|
if app_secret:
|
|
params["appsecret_proof"] = _compute_appsecret_proof(account["access_token"], app_secret)
|
|
|
|
try:
|
|
await self._ensure_client()
|
|
resp = await self._client.post(url, json=payload, params=params)
|
|
data = resp.json()
|
|
if resp.status_code == 200:
|
|
thread_id = data.get("thread_id", data.get("message_id", ""))
|
|
logger.info("Group chat created: thread_id=%s", thread_id)
|
|
return {"success": True, "thread_id": thread_id, "data": data}
|
|
error = classify_error(resp.status_code, data)
|
|
return {"success": False, "error": str(error)}
|
|
except httpx.RequestError as exc:
|
|
logger.error("Workplace create_group_chat network error: %s", exc)
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def _build_recipient(self, target_id: str, thread_id: str | None = None) -> dict:
|
|
if thread_id:
|
|
return {"thread_key": thread_id}
|
|
return {"id": target_id}
|
|
|
|
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
|
return self._chunk_text(text, limit)
|
|
|
|
def sanitize_text(self, text: str, payload: object) -> str:
|
|
return 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
|
|
|
|
@staticmethod
|
|
def _chunk_text(text: str, limit: int) -> list[str]:
|
|
if not text:
|
|
return [""]
|
|
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
|
|
|
|
async def _post(self, account: dict, payload: dict) -> dict:
|
|
await self._ensure_client()
|
|
|
|
api_version = account.get("graph_api_version", "v24.0")
|
|
access_token = account.get("access_token", "")
|
|
|
|
url = f"https://graph.facebook.com/{api_version}/me/messages"
|
|
params = {"access_token": access_token}
|
|
|
|
app_secret = account.get("app_secret", "")
|
|
if app_secret:
|
|
params["appsecret_proof"] = _compute_appsecret_proof(access_token, app_secret)
|
|
|
|
try:
|
|
resp = await self._client.post(url, json=payload, params=params)
|
|
data = resp.json()
|
|
|
|
if resp.status_code == 200:
|
|
return {
|
|
"success": True,
|
|
"message_id": data.get("message_id", ""),
|
|
"recipient_id": data.get("recipient_id", ""),
|
|
}
|
|
else:
|
|
error = classify_error(resp.status_code, data)
|
|
return {
|
|
"success": False,
|
|
"message_id": None,
|
|
"error": error,
|
|
"is_rate_limited": resp.status_code == 429 or error.subcode == 613,
|
|
}
|
|
except httpx.RequestError as exc:
|
|
logger.error("Workplace send error: %s", exc)
|
|
return {"success": False, "message_id": None, "error": str(exc)}
|
|
|
|
@staticmethod
|
|
async def _resolve_account(account_id: str | None = None) -> dict | None:
|
|
from yuxi.channel.extensions.workplace.config import WorkplaceConfigAdapter
|
|
|
|
adapter = WorkplaceConfigAdapter()
|
|
aid = account_id or adapter.default_account_id()
|
|
return await adapter.resolve_account(aid)
|
|
|
|
async def send_location(
|
|
self,
|
|
target_id: str,
|
|
lat: float,
|
|
lng: float,
|
|
*,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"message": {
|
|
"attachment": {
|
|
"type": "location",
|
|
"payload": {"coordinates": {"lat": lat, "long": lng}},
|
|
}
|
|
},
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
async def send_react(
|
|
self,
|
|
target_id: str,
|
|
message_id: str,
|
|
reaction: str = "smile",
|
|
*,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"sender_action": "react",
|
|
"payload": {
|
|
"reaction": reaction,
|
|
"message_id": message_id,
|
|
},
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
async def send_unreact(
|
|
self,
|
|
target_id: str,
|
|
message_id: str,
|
|
*,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict:
|
|
account = await self._resolve_account(account_id)
|
|
if not account:
|
|
return {"success": False, "error": "account not resolved"}
|
|
|
|
payload = {
|
|
"messaging_type": "RESPONSE",
|
|
"recipient": self._build_recipient(target_id, thread_id),
|
|
"sender_action": "unreact",
|
|
"payload": {"message_id": message_id},
|
|
}
|
|
|
|
result = await self._post(account, payload)
|
|
return result
|
|
|
|
|
|
def _compute_appsecret_proof(access_token: str, app_secret: str) -> str:
|
|
return hmac.new(
|
|
app_secret.encode("utf-8"),
|
|
access_token.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|