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