新增 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: 会话状态管理
302 lines
11 KiB
Python
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}
|