新增企业微信、微博、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
162 lines
5.9 KiB
Python
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")
|