ForcePilot/backend/package/yuxi/channel/extensions/webex/gateway.py
Kris 7b5d1e5c69 feat(channel): 添加 Webex、微信 iLink 和微信小程序渠道扩展
新增 Webex、微信 iLink、微信小程序三个渠道扩展。

Webex 渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- media: 媒体资源处理

微信 iLink 渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- context_store: 上下文存储
- aes_ecb: AES-ECB 加解密
- media: 媒体资源处理
- typing: 输入状态

微信小程序渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- dedupe: 消息去重
- message: 消息处理
- passive_reply: 被动回复
- media: 媒体资源处理
- status: 会话状态管理
2026-05-21 11:59:04 +08:00

302 lines
11 KiB
Python

import asyncio
import hashlib
import hmac
import logging
import uvicorn
from yuxi.channel.extensions.webex.monitor import payload_to_unified_message
from yuxi.channel.extensions.webex.types import WebexAccount, WebexMessage
from yuxi.channel.extensions.webex.webhook import create_webhook_app
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class WebexGateway:
def __init__(self, account: WebexAccount, ctx, dedupe=None):
self._account = account
self._ctx = ctx
self._api = None
self._dedupe = dedupe
self._bot_person_id: str = ""
self._server_task: asyncio.Task | None = None
self._running = False
@property
def api(self):
return self._api
@property
def bot_person_id(self) -> str:
return self._bot_person_id
async def start(self) -> asyncio.Task:
if self._running:
raise RuntimeError("Webex gateway already running")
from webexpythonsdk import WebexAPI
self._api = WebexAPI(access_token=self._account.bot_access_token)
self._running = True
me = self._api.people.me()
self._bot_person_id = me.id
self._account.bot_person_id = me.id
self._account.bot_person_email = me.emails[0] if me.emails else ""
app = create_webhook_app(
verify_signature=self._verify_signature,
handle_webhook=self._handle_webhook,
webhook_path=self._account.webhook_path,
)
config = uvicorn.Config(
app,
host=self._account.webhook_host,
port=self._account.webhook_port,
log_level="warning",
access_log=False,
)
server = uvicorn.Server(config)
self._server_task = asyncio.create_task(server.serve())
await self._ensure_webhook_registered()
return self._server_task
async def stop(self):
self._running = False
if self._server_task:
self._server_task.cancel()
try:
await self._server_task
except asyncio.CancelledError:
pass
async def _ensure_webhook_registered(self):
if not self._account.webhook_host or self._account.webhook_host == "0.0.0.0":
logger.warning(
"Webhook host is '%s'. Automatic webhook registration is disabled. "
"To receive messages in development, set WEBEX_WEBHOOK_HOST to a public URL "
"(e.g. via ngrok) or manually create a webhook at "
"https://developer.webex.com/docs/webhooks.",
self._account.webhook_host,
)
return
target_url = f"https://{self._account.webhook_host}{self._account.webhook_path}"
try:
existing = list(self._api.webhooks.list())
except Exception:
return
my_webhooks = [w for w in existing if w.targetUrl == target_url]
if my_webhooks:
for w in my_webhooks:
if w.status != "active":
try:
self._api.webhooks.update(w.id, status="active")
except Exception:
pass
self._register_additional_webhooks(existing, target_url)
return
try:
self._api.webhooks.create(
name="ForcePilot Webex Webhook",
targetUrl=target_url,
resource="messages",
event="created",
secret=self._account.webhook_secret,
)
except Exception:
pass
self._register_additional_webhooks([], target_url)
def _register_additional_webhooks(self, existing: list, target_url: str):
for resource, event, name_suffix in [
("attachmentActions", "created", "Attachment Actions"),
("messages", "deleted", "Message Deleted"),
("memberships", "created", "Membership Created"),
("memberships", "deleted", "Membership Deleted"),
("rooms", "created", "Room Created"),
("rooms", "updated", "Room Updated"),
]:
try:
already = [w for w in existing if w.resource == resource and w.event == event]
if not already:
self._api.webhooks.create(
name=f"ForcePilot Webex {name_suffix}",
targetUrl=target_url,
resource=resource,
event=event,
secret=self._account.webhook_secret,
)
logger.info("Registered webhook: %s:%s", resource, event)
except Exception:
logger.debug("Failed to register webhook %s:%s", resource, event)
def _verify_signature(self, body: bytes, x_spark_signature: str) -> bool:
if not self._account.webhook_secret:
return True
computed = hmac.new(
self._account.webhook_secret.encode(),
body,
hashlib.sha1,
).hexdigest()
return hmac.compare_digest(computed, x_spark_signature)
async def _handle_webhook(self, payload: dict) -> dict:
resource = payload.get("resource", "")
event_type = payload.get("event", "")
if resource == "attachmentActions" and event_type == "created":
return await self._handle_attachment_action(payload)
if resource == "rooms":
if event_type == "created":
return await self._handle_room_created(payload)
if event_type == "updated":
return await self._handle_room_updated(payload)
return {"status": "ignored", "reason": "event_not_relevant"}
if resource == "memberships":
if event_type == "created":
return await self._handle_membership_created(payload)
if event_type == "deleted":
return await self._handle_membership_deleted(payload)
return {"status": "ignored", "reason": "event_not_relevant"}
if resource == "messages" and event_type == "deleted":
return await self._handle_message_deleted(payload)
if resource != "messages" or event_type != "created":
return {"status": "ignored", "reason": "event_not_relevant"}
data = payload.get("data", {})
msg_id = data.get("id", "")
person_id = data.get("personId", "")
if person_id == self._bot_person_id:
return {"status": "ignored", "reason": "echo"}
if self._dedupe and self._dedupe.is_duplicate(msg_id):
return {"status": "ignored", "reason": "duplicate"}
webex_msg = WebexMessage(
id=data.get("id", ""),
room_id=data.get("roomId", ""),
room_type=data.get("roomType", ""),
person_id=data.get("personId", ""),
person_email=data.get("personEmail", ""),
text=data.get("text", ""),
markdown=data.get("markdown", ""),
html=data.get("html", ""),
files=data.get("files", []),
mentions=data.get("mentions", []),
parent_id=data.get("parentId"),
created=data.get("created", ""),
raw=payload,
)
unified_msg = payload_to_unified_message(webex_msg, self._bot_person_id, self._account.account_id)
if unified_msg and self._ctx.queue:
self._ctx.queue.put_nowait(unified_msg)
return {"status": "ok"}
async def _handle_attachment_action(self, payload: dict) -> dict:
data = payload.get("data", {})
action_id = data.get("id", "")
person_id = data.get("personId", "")
if person_id == self._bot_person_id:
return {"status": "ignored", "reason": "echo"}
if self._dedupe and self._dedupe.is_duplicate(f"action:{action_id}"):
return {"status": "ignored", "reason": "duplicate"}
inputs = data.get("inputs", {})
room_id = data.get("roomId", "")
message_id = data.get("messageId", "")
action_summary = ", ".join(f"{k}={v}" for k, v in inputs.items()) if inputs else "(no inputs)"
content = f"[card_action:submit] inputs: {{{action_summary}}}"
unified_msg = UnifiedMessage(
msg_id=f"webex:action:{action_id}",
channel_type="webex",
account_id=self._account.account_id,
content=content,
sender=PeerInfo(
kind=PeerKind.GROUP if data.get("roomType") != "direct" else PeerKind.DIRECT,
id=person_id,
display_name=data.get("personEmail", ""),
),
message_type=MessageType.EVENT,
metadata={
"InteractionType": "attachment_action",
"ActionType": data.get("type", "submit"),
"Inputs": inputs,
"room_id": room_id,
"message_id": message_id,
"debounce_key": f"webex:action:{person_id}:{room_id}",
},
raw_payload=payload,
)
if self._ctx.queue:
self._ctx.queue.put_nowait(unified_msg)
return {"status": "ok", "action": "attachment_action"}
async def _handle_membership_created(self, payload: dict) -> dict:
data = payload.get("data", {})
person_id = data.get("personId", "")
room_id = data.get("roomId", "")
if person_id != self._bot_person_id:
return {"status": "ignored", "reason": "not_self"}
logger.info("Bot added to space: %s", room_id)
return {"status": "ok", "action": "membership_created", "room_id": room_id}
async def _handle_membership_deleted(self, payload: dict) -> dict:
data = payload.get("data", {})
person_id = data.get("personId", "")
room_id = data.get("roomId", "")
if person_id != self._bot_person_id:
return {"status": "ignored", "reason": "not_self"}
logger.info("Bot removed from space: %s", room_id)
return {"status": "ok", "action": "membership_deleted", "room_id": room_id}
async def _handle_message_deleted(self, payload: dict) -> dict:
data = payload.get("data", {})
msg_id = data.get("id", "")
logger.info("Message deleted in Webex: %s", msg_id)
return {"status": "ok", "action": "message_deleted", "message_id": msg_id}
async def _handle_room_created(self, payload: dict) -> dict:
data = payload.get("data", {})
room_id = data.get("id", "")
room_title = data.get("title", "")
logger.info("Room created in Webex: %s (%s)", room_title, room_id)
return {"status": "ok", "action": "room_created", "room_id": room_id}
async def _handle_room_updated(self, payload: dict) -> dict:
data = payload.get("data", {})
room_id = data.get("id", "")
room_title = data.get("title", "")
logger.info("Room updated in Webex: %s (%s)", room_title, room_id)
return {"status": "ok", "action": "room_updated", "room_id": room_id}