ForcePilot/backend/package/yuxi/channel/extensions/workplace/gateway.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

162 lines
5.9 KiB
Python

from __future__ import annotations
import asyncio
import logging
import httpx
from yuxi.channel.extensions.workplace.dedupe import WorkplaceDeduplicator
from yuxi.channel.extensions.workplace.errors import classify_error, ErrorCategory
from yuxi.channel.extensions.workplace.monitor import WorkplaceMonitor
logger = logging.getLogger(__name__)
WORKPLACE_API_BASE = "https://graph.facebook.com"
_target_queue: asyncio.Queue | None = None
_active_gateway: WorkplaceGateway | None = None
def _get_webhook_queue() -> asyncio.Queue | None:
return _active_gateway._queue if _active_gateway else None
def _get_active_gateway() -> WorkplaceGateway | None:
return _active_gateway
def set_target_queue(queue: asyncio.Queue) -> None:
global _target_queue
_target_queue = queue
class WorkplaceGateway:
def __init__(self, deduplicator: WorkplaceDeduplicator | None = None):
self._running = False
self._tasks: list[asyncio.Task] = []
self._account: dict = {}
self._queue: asyncio.Queue | None = None
self._deduplicator = deduplicator or WorkplaceDeduplicator(max_size=10000, ttl_seconds=300)
self._monitor = WorkplaceMonitor()
async def start(self, ctx) -> object:
global _active_gateway
account = await self._resolve_account(ctx)
if not account.is_configured():
logger.warning("Workplace gateway: account not configured, id=%s", account.account_id)
return {"running": False, "reason": "not-configured", "account_id": account.account_id}
await self._probe_community(account)
self._queue = asyncio.Queue(maxsize=2000)
self._running = True
_active_gateway = self
cancel_event = getattr(ctx, "cancel_event", asyncio.Event())
task = asyncio.create_task(
_consume_loop(self._queue, cancel_event, account, self._deduplicator, self._monitor),
name=f"workplace-consume-{account.account_id}",
)
self._tasks.append(task)
logger.info(
"Workplace gateway started: community_id=%s, account_id=%s",
account.community_id,
account.account_id,
)
return {"running": True, "account_id": account.account_id, "queue": self._queue}
async def stop(self, ctx) -> None:
global _active_gateway
self._running = False
for task in self._tasks:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._tasks.clear()
self._queue = None
_active_gateway = None
logger.info("Workplace gateway stopped")
async def _resolve_account(self, ctx):
from yuxi.channel.extensions.workplace.types import WorkplaceAccount
account_id = getattr(ctx, "account_id", "default")
config = getattr(ctx, "config", {})
from yuxi.channel.extensions.workplace.config import WorkplaceConfigAdapter
adapter = WorkplaceConfigAdapter()
raw = await adapter.resolve_account(account_id, config)
return WorkplaceAccount(
account_id=raw["account_id"],
community_id=raw.get("community_id", ""),
page_id=raw.get("page_id", ""),
access_token=raw.get("access_token", ""),
app_id=raw.get("app_id", ""),
app_secret=raw.get("app_secret", ""),
verify_token=raw.get("verify_token", ""),
graph_api_version=raw.get("graph_api_version", "v24.0"),
)
async def _probe_community(self, account) -> None:
url = f"{WORKPLACE_API_BASE}/{account.graph_api_version}/community"
async with httpx.AsyncClient(timeout=15.0) as client:
try:
resp = await client.get(url, params={"access_token": account.access_token})
if resp.status_code == 200:
data = resp.json()
account.community_id = data.get("id", account.community_id)
logger.info("Workplace community probed: id=%s", account.community_id)
else:
error = classify_error(resp.status_code, resp.json() if resp.content else {})
if error.category == ErrorCategory.TOKEN:
logger.error("Workplace token invalid: %s", error.message)
else:
logger.warning("Workplace probe returned %d: %s", resp.status_code, error.message)
except httpx.RequestError as exc:
logger.error("Workplace probe network error: %s", exc)
async def _consume_loop(
queue: asyncio.Queue,
cancel_event: asyncio.Event,
account,
deduplicator: WorkplaceDeduplicator,
monitor: WorkplaceMonitor,
) -> None:
logger.info("Workplace consume loop started for account_id=%s", account.account_id)
while not cancel_event.is_set():
try:
event = await asyncio.wait_for(queue.get(), timeout=1.0)
except TimeoutError:
continue
except asyncio.CancelledError:
break
try:
if "field" in event and "value" in event:
unified = monitor.parse_change_to_unified(event, account)
else:
msg_id = event.get("message", {}).get("mid", "")
if msg_id and deduplicator.is_duplicate(msg_id):
logger.debug("Duplicate message skipped: mid=%s", msg_id)
continue
unified = monitor.parse_to_unified(event, account)
if unified is None:
logger.debug("Event not convertible to UnifiedMessage: type=%s", event.get("event_type"))
continue
if _target_queue is not None:
await _target_queue.put(unified)
logger.debug("Workplace message dispatched: sender=%s", unified.sender.id)
except Exception:
logger.exception("Error processing Workplace event: %s", event)
logger.info("Workplace consume loop stopped")