ForcePilot/backend/package/yuxi/channel/extensions/intercom/gateway.py
Kris 2ddcf1cc55 feat(channel): 添加 Intercom 渠道扩展
新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。

包含以下功能模块:
- client: Intercom API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- streaming: 流式消息处理
- format: 消息格式转换
- outbound: 外发消息管理
- handoff: 转人工会话切换
- pairing: 用户配对与绑定
- security: 安全签名校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session_guard: 会话防护
- types: 类型定义
2026-05-21 11:01:49 +08:00

129 lines
4.2 KiB
Python

import hashlib
import hmac
import logging
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.intercom.client import IntercomClient
from yuxi.channel.extensions.intercom.config import IntercomConfigAdapter
from yuxi.channel.extensions.intercom.dedupe import DedupeCache
from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode
from yuxi.channel.extensions.intercom.types import IntercomAdminIdentity
logger = logging.getLogger(__name__)
class IntercomGateway:
delivery_mode = "direct"
def __init__(self):
self._accounts: dict[str, dict] = {}
self._dedupe = DedupeCache()
async def start(self, ctx: ChannelContext) -> object:
adapter = IntercomConfigAdapter()
config = getattr(ctx, "raw_config", None) or {}
account = await adapter.resolve_account(ctx.account_id, config)
if not account.get("access_token"):
raise IntercomError(IntercomErrorCode.CONFIG_ERROR, "access_token is required")
client = IntercomClient(account["access_token"], datacenter=account.get("datacenter", "us"))
identity = await self._auth_test(client, account)
ctx._intercom_identity = identity
self._accounts[ctx.account_id] = {
"account": account,
"client": client,
"identity": identity,
}
logger.info(
"Intercom gateway started for account %s (admin=%s, app=%s)",
ctx.account_id,
identity.admin_id,
identity.app_id,
)
return {
"account": account,
"identity": identity,
"client": client,
}
async def stop(self, ctx: ChannelContext) -> None:
entry = self._accounts.pop(ctx.account_id, None)
if entry is None:
return
await entry["client"].close()
for attr in ("_intercom_identity",):
try:
delattr(ctx, attr)
except AttributeError:
pass
logger.info("Intercom gateway stopped for account %s", ctx.account_id)
async def _auth_test(self, client: IntercomClient, account: dict) -> IntercomAdminIdentity:
try:
me = await client.get_me()
except Exception as e:
raise IntercomError(IntercomErrorCode.AUTH_ERROR, f"Token 验证失败: {e}") from e
admin_id = account.get("admin_id") or str(me.get("id", ""))
return IntercomAdminIdentity(
admin_id=admin_id,
app_id=str(me.get("app", {}).get("id_code", "")),
name=me.get("name", ""),
email=me.get("email", ""),
)
def verify_webhook_signature(self, body: bytes, x_hub_signature: str, webhook_secret: str) -> bool:
if not webhook_secret:
logger.warning("Webhook secret not configured, skipping signature verification")
return True
if not x_hub_signature:
return False
if not x_hub_signature.startswith("sha256="):
return False
received = x_hub_signature[7:]
computed = hmac.new(
webhook_secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, received)
def get_account(self, account_id: str) -> dict | None:
return self._accounts.get(account_id)
def get_client(self, account_id: str) -> IntercomClient | None:
entry = self._accounts.get(account_id)
return entry["client"] if entry else None
def get_identity(self, account_id: str) -> IntercomAdminIdentity | None:
entry = self._accounts.get(account_id)
return entry["identity"] if entry else None
def is_duplicate(self, event_id: str) -> bool:
return self._dedupe.is_duplicate(event_id)
def check_duplicate(self, event_id: str) -> bool:
return self._dedupe.check_duplicate(event_id)
def mark_seen(self, event_id: str) -> None:
self._dedupe.mark_seen(event_id)
def reset_dedupe(self) -> None:
self._dedupe.reset()
@staticmethod
async def resolve_gateway_auth_bypass_paths(config: dict) -> list[str]:
return ["/webhook/intercom"]