ForcePilot/backend/package/yuxi/channel/extensions/intercom/outbound.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

372 lines
12 KiB
Python

import asyncio
import logging
import random
from yuxi.channel.extensions.intercom.client import IntercomClient
from yuxi.channel.extensions.intercom.config import IntercomConfigAdapter
from yuxi.channel.extensions.intercom.constants import (
INTERCOM_MAX_RETRIES,
INTERCOM_RETRY_BASE_DELAY,
INTERCOM_RETRY_MAX_DELAY,
INTERCOM_TEXT_CHUNK_LIMIT,
)
from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode
logger = logging.getLogger(__name__)
class IntercomOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit = INTERCOM_TEXT_CHUNK_LIMIT
poll_max_options = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = False
presentation_capabilities = None
delivery_capabilities = None
def __init__(self, gateway=None):
self._gateway = gateway
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
if not text:
return []
if len(text) <= limit:
return [text]
chunks: list[str] = []
paragraphs = text.split("\n\n")
for para in paragraphs:
if not para:
continue
if len(para) <= limit:
chunks.append(para)
else:
sentences = para.split("\n")
for sentence in sentences:
if len(sentence) <= limit:
chunks.append(sentence)
else:
while sentence:
chunks.append(sentence[:limit])
sentence = sentence[limit:]
merged: list[str] = []
for chunk in chunks:
if not merged:
merged.append(chunk)
continue
last = merged[-1]
candidate = last + "\n\n" + chunk
if len(candidate) <= limit:
merged[-1] = candidate
else:
merged.append(chunk)
return merged
async def _get_client(self, account_id: str | None) -> tuple[IntercomClient, dict]:
if self._gateway:
entry = self._gateway.get_account(account_id or "default")
if entry and entry.get("client"):
return entry["client"], entry["account"]
adapter = IntercomConfigAdapter()
config = {}
account = await adapter.resolve_account(account_id or "default", config)
client = IntercomClient(account["access_token"], datacenter=account.get("datacenter", "us"))
return client, account
def _owns_client(self, client: IntercomClient, account_id: str | None) -> bool:
if not self._gateway:
return True
entry = self._gateway.get_account(account_id or "default")
return not (entry and entry.get("client") is client)
async def _retry_with_backoff(self, operation, max_attempts: int | None = None):
max_attempts = max_attempts or INTERCOM_MAX_RETRIES
for attempt in range(max_attempts):
try:
return await operation()
except IntercomError as e:
if e.code == IntercomErrorCode.RATE_LIMITED and attempt < max_attempts - 1:
delay = random.uniform(1, 4) * (2**attempt)
logger.warning("Intercom rate limited, waiting %.1fs", delay)
await asyncio.sleep(delay)
continue
if e.code in (IntercomErrorCode.AUTH_ERROR, IntercomErrorCode.NOT_FOUND):
raise
if attempt < max_attempts - 1:
delay = min(
INTERCOM_RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1),
INTERCOM_RETRY_MAX_DELAY,
)
logger.warning("Intercom API error, retrying in %.1fs", delay)
await asyncio.sleep(delay)
continue
raise
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
from yuxi.channel.extensions.intercom.format import markdown_to_html
html_body = markdown_to_html(content)
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
await self._retry_with_backoff(
lambda: client.reply_conversation(
conversation_id=target_id,
body=html_body,
message_type="comment",
reply_type="admin",
)
)
finally:
if owns_client:
await client.close()
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
attachment_urls = [media_url] if media_url else []
try:
await self._retry_with_backoff(
lambda: client.reply_conversation(
conversation_id=target_id,
body="",
message_type="comment",
reply_type="admin",
attachment_urls=attachment_urls,
)
)
finally:
if owns_client:
await client.close()
async def send_note(
self,
target_id: str,
content: str,
*,
account_id: str | None = None,
) -> None:
from yuxi.channel.extensions.intercom.format import markdown_to_html
html_body = markdown_to_html(content)
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
await self._retry_with_backoff(
lambda: client.reply_conversation(
conversation_id=target_id,
body=html_body,
message_type="note",
reply_type="admin",
)
)
finally:
if owns_client:
await client.close()
async def send_quick_replies(
self,
target_id: str,
content: str,
options: list[dict],
*,
account_id: str | None = None,
) -> None:
from yuxi.channel.extensions.intercom.format import markdown_to_html
html_body = markdown_to_html(content)
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
reply_options = {
"type": "quick_replies",
"quick_reply_type": options[0].get("type", "button") if options else "button",
"quick_replies": [
{"label": opt["label"], "value": opt.get("value", opt["label"])}
for opt in options[:10]
],
}
await self._retry_with_backoff(
lambda: client.reply_conversation(
conversation_id=target_id,
body=html_body,
message_type="comment",
reply_type="admin",
reply_options=reply_options,
)
)
finally:
if owns_client:
await client.close()
async def add_tags(
self,
target_id: str,
tags: list[str],
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
await self._retry_with_backoff(lambda: client.add_conversation_tags(target_id, tags))
finally:
if owns_client:
await client.close()
async def remove_tags(
self,
target_id: str,
tag_ids: list[str],
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
for tag_id in tag_ids:
await self._retry_with_backoff(lambda tid=tag_id: client.remove_conversation_tag(target_id, tid))
finally:
if owns_client:
await client.close()
async def close_conversation(
self,
target_id: str,
*,
account_id: str | None = None,
) -> bool:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
await self._retry_with_backoff(
lambda: client.manage_conversation(target_id, "close", admin_id=account.get("admin_id"))
)
return True
except Exception:
logger.exception("Failed to close conversation %s", target_id)
return False
finally:
if owns_client:
await client.close()
async def open_conversation(
self,
target_id: str,
*,
account_id: str | None = None,
) -> bool:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
await self._retry_with_backoff(
lambda: client.manage_conversation(target_id, "open", admin_id=account.get("admin_id"))
)
return True
except Exception:
logger.exception("Failed to open conversation %s", target_id)
return False
finally:
if owns_client:
await client.close()
async def create_conversation(
self,
contact_id: str,
body: str,
*,
account_id: str | None = None,
) -> dict | None:
from yuxi.channel.extensions.intercom.format import markdown_to_html
html_body = markdown_to_html(body)
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
result = await self._retry_with_backoff(
lambda: client.create_conversation(
contact_id=contact_id,
body=html_body,
admin_id=account.get("admin_id"),
)
)
return result
except Exception:
logger.exception("Failed to create conversation for contact %s", contact_id)
return None
finally:
if owns_client:
await client.close()
async def convert_to_ticket(
self,
conversation_id: str,
*,
account_id: str | None = None,
) -> dict | None:
client, account = await self._get_client(account_id)
owns_client = self._owns_client(client, account_id)
try:
result = await self._retry_with_backoff(
lambda: client.convert_conversation_to_ticket(
conversation_id,
admin_id=account.get("admin_id"),
)
)
return result
except Exception:
logger.exception("Failed to convert conversation %s to ticket", conversation_id)
return None
finally:
if owns_client:
await client.close()
async def send_payload(self, ctx: object) -> object:
return None
async def send_poll(self, ctx: object) -> object:
return None
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def normalize_payload(self, payload, config, account_id=None):
return payload
def resolve_effective_text_chunk_limit(self, config, account_id=None, fallback_limit=None):
return fallback_limit or INTERCOM_TEXT_CHUNK_LIMIT