新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。 包含以下功能模块: - client: Intercom API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - streaming: 流式消息处理 - format: 消息格式转换 - outbound: 外发消息管理 - handoff: 转人工会话切换 - pairing: 用户配对与绑定 - security: 安全签名校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session_guard: 会话防护 - types: 类型定义
129 lines
4.2 KiB
Python
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"]
|