ForcePilot/backend/package/yuxi/channel/extensions/workplace/outbound.py
Kris 7215418610 feat(channel): 添加企业微信、微博、WhatsApp 和 Workplace 渠道扩展
新增企业微信、微博、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
2026-05-21 12:01:56 +08:00

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()