新增 Zalo OA、Zoom Chat、Zulip 三个渠道扩展。 Zalo OA 渠道扩展主要模块:sidecar_client, config, gateway, outbound, streaming, pairing, security, auth, dedupe, directory, monitor, status, session, reactions, tools Zoom Chat 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, crypto, dedupe, actions, media, mentions, monitor, status, session, reactions, threading Zulip 渠道扩展主要模块:client, config, gateway, outbound, streaming, pairing, security, monitor, status
169 lines
5.9 KiB
Python
169 lines
5.9 KiB
Python
import asyncio
|
|
import base64
|
|
import time
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_REFRESH_BUFFER_SECONDS = 120
|
|
ZOOM_TOKEN_URL = "https://zoom.us/oauth/token"
|
|
|
|
|
|
class ZoomGateway:
|
|
def __init__(self):
|
|
self._client: httpx.AsyncClient | None = None
|
|
self._access_token: str = ""
|
|
self._token_expires_at: float = 0
|
|
self._server_task: asyncio.Task | None = None
|
|
self._queue: asyncio.Queue | None = None
|
|
self._cancel_event: asyncio.Event | None = None
|
|
self._account: dict = {}
|
|
self._running: bool = False
|
|
|
|
async def start(self, ctx) -> object:
|
|
raw_account = getattr(ctx, "account", None)
|
|
account_id = getattr(ctx, "account_id", "default")
|
|
config = getattr(ctx, "config", {})
|
|
|
|
from yuxi.channel.extensions.zoomchat.config import ZoomConfigAdapter
|
|
|
|
adapter = ZoomConfigAdapter()
|
|
|
|
if isinstance(raw_account, dict) and raw_account:
|
|
resolved = raw_account
|
|
else:
|
|
resolved = await adapter.resolve_account(account_id)
|
|
if config and "accounts" in config:
|
|
raw_cfg = config["accounts"].get(account_id, {})
|
|
for k, v in raw_cfg.items():
|
|
if k not in resolved or not resolved.get(k):
|
|
resolved[k] = v
|
|
|
|
self._account = resolved
|
|
|
|
if not adapter.is_configured(resolved):
|
|
logger.warning("Zoom Chat account %s not configured, skipping start", account_id)
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
self._client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(30.0),
|
|
base_url=self._account.get("api_base_url", "https://api.zoom.us/v2"),
|
|
)
|
|
|
|
await self._refresh_token()
|
|
|
|
if not self._account.get("bot_user_id"):
|
|
self._account["bot_user_id"] = await self._resolve_bot_user_id()
|
|
|
|
self._queue = asyncio.Queue(maxsize=1000)
|
|
self._cancel_event = asyncio.Event()
|
|
self._running = True
|
|
self._server_task = asyncio.create_task(self._run_webhook_server())
|
|
|
|
logger.info(
|
|
"Zoom Gateway started for account %s (bot_user_id=%s)",
|
|
self._account.get("account_id", "unknown"),
|
|
self._account.get("bot_user_id", "unknown"),
|
|
)
|
|
|
|
return {
|
|
"gateway": self,
|
|
"queue": self._queue,
|
|
"account": self._account,
|
|
"get_token": self._get_token,
|
|
"client": self._client,
|
|
}
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
|
|
if self._cancel_event:
|
|
self._cancel_event.set()
|
|
|
|
if self._server_task and not self._server_task.done():
|
|
self._server_task.cancel()
|
|
try:
|
|
await self._server_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
if self._client:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
self._access_token = ""
|
|
self._token_expires_at = 0
|
|
logger.info("Zoom Gateway stopped")
|
|
|
|
async def _get_token(self) -> str:
|
|
if not self._access_token or time.time() >= self._token_expires_at - TOKEN_REFRESH_BUFFER_SECONDS:
|
|
await self._refresh_token()
|
|
return self._access_token
|
|
|
|
async def _refresh_token(self) -> str:
|
|
client_id = self._account.get("client_id", "")
|
|
client_secret = self._account.get("client_secret", "")
|
|
account_id = self._account.get("account_id", "")
|
|
|
|
if not client_id or not client_secret or not account_id:
|
|
raise RuntimeError("Missing Zoom OAuth credentials (client_id, client_secret, account_id)")
|
|
|
|
raw_auth = f"{client_id}:{client_secret}"
|
|
auth_header = base64.b64encode(raw_auth.encode()).decode()
|
|
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
|
|
resp = await client.post(
|
|
ZOOM_TOKEN_URL,
|
|
params={"grant_type": "account_credentials", "account_id": account_id},
|
|
headers={
|
|
"Authorization": f"Basic {auth_header}",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
self._access_token = data["access_token"]
|
|
expires_in = data.get("expires_in", 3600)
|
|
self._token_expires_at = time.time() + expires_in
|
|
|
|
logger.info("Zoom OAuth token refreshed, expires_in=%s", expires_in)
|
|
return self._access_token
|
|
|
|
async def _resolve_bot_user_id(self) -> str:
|
|
token = await self._get_token()
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
|
resp = await client.get(
|
|
"https://api.zoom.us/v2/users/me",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return data.get("id", "")
|
|
logger.warning("Failed to resolve bot_user_id via /users/me: status=%s", resp.status_code)
|
|
return "me"
|
|
|
|
async def _run_webhook_server(self):
|
|
from yuxi.channel.extensions.zoomchat.webhook import create_webhook_app
|
|
import uvicorn
|
|
|
|
webhook_host = self._account.get("webhook_host", "0.0.0.0")
|
|
webhook_port = int(self._account.get("webhook_port", 0) or 0)
|
|
|
|
app = create_webhook_app(
|
|
account=self._account,
|
|
webhook_secret=self._account.get("webhook_secret", ""),
|
|
queue=self._queue,
|
|
cancel_event=self._cancel_event,
|
|
)
|
|
|
|
config = uvicorn.Config(app, host=webhook_host, port=webhook_port or 8000, log_level="warning")
|
|
server = uvicorn.Server(config)
|
|
try:
|
|
await server.serve()
|
|
except asyncio.CancelledError:
|
|
logger.info("Zoom Webhook server shutting down")
|
|
raise
|