From 2ddcf1cc55e8d8a28732537aef2824bf53d39c05 Mon Sep 17 00:00:00 2001
From: Kris <2893855659@qq.com>
Date: Thu, 21 May 2026 11:01:49 +0800
Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A0=20Intercom?=
=?UTF-8?q?=20=E6=B8=A0=E9=81=93=E6=89=A9=E5=B1=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。
包含以下功能模块:
- client: Intercom API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- streaming: 流式消息处理
- format: 消息格式转换
- outbound: 外发消息管理
- handoff: 转人工会话切换
- pairing: 用户配对与绑定
- security: 安全签名校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session_guard: 会话防护
- types: 类型定义
---
.../channel/extensions/intercom/__init__.py | 490 +++++++++++++++
.../channel/extensions/intercom/client.py | 579 ++++++++++++++++++
.../channel/extensions/intercom/config.py | 192 ++++++
.../channel/extensions/intercom/constants.py | 72 +++
.../channel/extensions/intercom/dedupe.py | 62 ++
.../channel/extensions/intercom/errors.py | 51 ++
.../channel/extensions/intercom/format.py | 106 ++++
.../channel/extensions/intercom/gateway.py | 128 ++++
.../channel/extensions/intercom/handoff.py | 54 ++
.../channel/extensions/intercom/monitor.py | 54 ++
.../channel/extensions/intercom/outbound.py | 371 +++++++++++
.../channel/extensions/intercom/pairing.py | 43 ++
.../channel/extensions/intercom/plugin.json | 28 +
.../channel/extensions/intercom/security.py | 66 ++
.../extensions/intercom/session_guard.py | 12 +
.../channel/extensions/intercom/status.py | 187 ++++++
.../channel/extensions/intercom/streaming.py | 67 ++
.../yuxi/channel/extensions/intercom/types.py | 71 +++
.../channel/extensions/intercom/webhook.py | 265 ++++++++
19 files changed, 2898 insertions(+)
create mode 100644 backend/package/yuxi/channel/extensions/intercom/__init__.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/client.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/config.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/constants.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/dedupe.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/errors.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/format.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/gateway.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/handoff.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/monitor.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/outbound.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/pairing.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/plugin.json
create mode 100644 backend/package/yuxi/channel/extensions/intercom/security.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/session_guard.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/status.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/streaming.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/types.py
create mode 100644 backend/package/yuxi/channel/extensions/intercom/webhook.py
diff --git a/backend/package/yuxi/channel/extensions/intercom/__init__.py b/backend/package/yuxi/channel/extensions/intercom/__init__.py
new file mode 100644
index 00000000..168a64e0
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/__init__.py
@@ -0,0 +1,490 @@
+import logging
+
+from yuxi.channel.capabilities import ChannelCapabilities
+from yuxi.channel.extensions.base import BaseChannelPlugin
+from yuxi.channel.extensions.intercom.config import IntercomConfigAdapter
+from yuxi.channel.extensions.intercom.errors import IntercomError
+from yuxi.channel.extensions.intercom.format import get_agent_prompt_rules
+from yuxi.channel.extensions.intercom.gateway import IntercomGateway
+from yuxi.channel.extensions.intercom.monitor import IntercomMonitor
+from yuxi.channel.extensions.intercom.outbound import IntercomOutbound
+from yuxi.channel.extensions.intercom.pairing import IntercomPairing
+from yuxi.channel.extensions.intercom.security import IntercomSecurity
+from yuxi.channel.extensions.intercom.status import IntercomStatus
+from yuxi.channel.extensions.intercom.streaming import IntercomStreaming
+from yuxi.channel.plugins.registry import ChannelPluginRegistry
+from yuxi.channel.protocols import ClassifiedError, ErrorSeverity
+
+logger = logging.getLogger(__name__)
+
+
+class IntercomPlugin(BaseChannelPlugin):
+ id = "intercom"
+ name = "Intercom"
+ order = 65
+ label = "Intercom"
+ aliases = ["intercom-messenger"]
+
+ def __init__(self):
+ self._config = IntercomConfigAdapter()
+ self._gateway = IntercomGateway()
+ self._outbound = IntercomOutbound(gateway=self._gateway)
+ self._monitor = IntercomMonitor()
+ self._streaming = IntercomStreaming()
+ self._security = IntercomSecurity()
+ self._pairing = IntercomPairing()
+ self._status = IntercomStatus()
+
+ @property
+ def capabilities(self) -> ChannelCapabilities:
+ return ChannelCapabilities(
+ chat_types=["direct"],
+ message_types=["text", "image", "file"],
+ interactions=["button"],
+ reactions=False,
+ typing_indicator=False,
+ threads=False,
+ edit=False,
+ unsend=False,
+ reply=True,
+ media=True,
+ native_commands=False,
+ polls=False,
+ group_management=False,
+ streaming=True,
+ streaming_mode="block",
+ block_streaming=True,
+ tts=None,
+ )
+
+ # ── Config 委托 ──────────────────────────────────────
+
+ def list_account_ids(self, config: dict) -> list[str]:
+ return self._config.list_account_ids(config)
+
+ async def resolve_account(self, account_id: str, config: dict | None = None) -> dict:
+ return await self._config.resolve_account(account_id, config)
+
+ def is_configured(self, account: dict) -> bool:
+ return self._config.is_configured(account)
+
+ def is_enabled(self, account: dict, config: dict) -> bool:
+ return self._config.is_enabled(account, config)
+
+ def disabled_reason(self, account: dict, config: dict) -> str:
+ return self._config.disabled_reason(account, config)
+
+ def unconfigured_reason(self, account: dict, config: dict) -> str:
+ return self._config.unconfigured_reason(account, config)
+
+ def describe_account(self, account: dict, config: dict) -> dict:
+ return self._config.describe_account(account, config)
+
+ def default_account_id(self, config: dict) -> str:
+ return self._config.default_account_id(config)
+
+ def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
+ return self._config.resolve_allow_from(config, account_id)
+
+ def format_allow_from(self, config: dict, account_id: str | None, allow_from: list[str | int]) -> list[str]:
+ return self._config.format_allow_from(config, account_id, allow_from)
+
+ def has_configured_state(self, config: dict) -> bool:
+ return self._config.has_configured_state(config)
+
+ def has_persisted_auth_state(self, config: dict) -> bool:
+ return self._config.has_persisted_auth_state(config)
+
+ def config_schema(self) -> dict:
+ return self._config.config_schema()
+
+ def apply_config_fixes(self, config: dict) -> dict:
+ return self._security.apply_config_fixes(config)
+
+ def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
+ return self._security.collect_warnings(config, account_id, account)
+
+ # ── Gateway 委托 ─────────────────────────────────────
+
+ @property
+ def delivery_mode(self):
+ return self._gateway.delivery_mode
+
+ async def start(self, ctx) -> object:
+ result = await self._gateway.start(ctx)
+
+ from yuxi.channel.extensions.intercom.webhook import set_gateway_instance
+
+ set_gateway_instance(self._gateway)
+
+ return result
+
+ async def stop(self, ctx) -> None:
+ await self._gateway.stop(ctx)
+
+ async def resolve_gateway_auth_bypass_paths(self, config: dict) -> list[str]:
+ return await self._gateway.resolve_gateway_auth_bypass_paths(config)
+
+ # ── DedupeProtocol 委托 ──────────────────────────────
+
+ def is_duplicate(self, key: str) -> bool:
+ return self._gateway.check_duplicate(key)
+
+ def mark_seen(self, key: str) -> None:
+ self._gateway.mark_seen(key)
+
+ def reset_dedupe(self) -> None:
+ self._gateway.reset_dedupe()
+
+ # ── ErrorHandlingProtocol ─────────────────────────────
+
+ def classify_error(self, error: BaseException) -> ClassifiedError:
+ if isinstance(error, IntercomError):
+ return ClassifiedError(
+ severity=ErrorSeverity.RETRYABLE if error.retryable else ErrorSeverity.FATAL,
+ error_message=str(error),
+ original_error=error,
+ )
+ return ClassifiedError(
+ severity=ErrorSeverity.RETRYABLE,
+ error_message=str(error),
+ original_error=error,
+ )
+
+ def is_retryable(self, error: BaseException) -> bool:
+ if isinstance(error, IntercomError):
+ return error.retryable
+ return True
+
+ # ── Outbound 委托 ────────────────────────────────────
+
+ @property
+ def text_chunk_limit(self) -> int | None:
+ return self._outbound.text_chunk_limit
+
+ @property
+ def chunker_mode(self) -> str | None:
+ return self._outbound.chunker_mode
+
+ @property
+ def poll_max_options(self) -> int | None:
+ return self._outbound.poll_max_options
+
+ @property
+ def supports_poll_duration_seconds(self) -> bool:
+ return self._outbound.supports_poll_duration_seconds
+
+ @property
+ def supports_anonymous_polls(self) -> bool:
+ return self._outbound.supports_anonymous_polls
+
+ @property
+ def extract_markdown_images(self) -> bool:
+ return self._outbound.extract_markdown_images
+
+ @property
+ def presentation_capabilities(self):
+ return self._outbound.presentation_capabilities
+
+ @property
+ def delivery_capabilities(self):
+ return self._outbound.delivery_capabilities
+
+ def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
+ return self._outbound.chunker(text, limit, ctx)
+
+ 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:
+ await self._outbound.send_text(
+ target_id,
+ content,
+ reply_to_id=reply_to_id,
+ thread_id=thread_id,
+ account_id=account_id,
+ )
+
+ 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:
+ await self._outbound.send_media(
+ target_id,
+ media_url,
+ media_type,
+ reply_to_id=reply_to_id,
+ thread_id=thread_id,
+ account_id=account_id,
+ )
+
+ async def send_note(
+ self,
+ target_id: str,
+ content: str,
+ *,
+ account_id: str | None = None,
+ ) -> None:
+ await self._outbound.send_note(target_id, content, account_id=account_id)
+
+ async def add_tags(
+ self,
+ target_id: str,
+ tags: list[str],
+ *,
+ account_id: str | None = None,
+ ) -> None:
+ await self._outbound.add_tags(target_id, tags, account_id=account_id)
+
+ async def remove_tags(
+ self,
+ target_id: str,
+ tag_ids: list[str],
+ *,
+ account_id: str | None = None,
+ ) -> None:
+ await self._outbound.remove_tags(target_id, tag_ids, account_id=account_id)
+
+ async def send_quick_replies(
+ self,
+ target_id: str,
+ content: str,
+ options: list[dict],
+ *,
+ account_id: str | None = None,
+ ) -> None:
+ await self._outbound.send_quick_replies(target_id, content, options, account_id=account_id)
+
+ async def close_conversation(self, target_id: str, *, account_id: str | None = None) -> bool:
+ return await self._outbound.close_conversation(target_id, account_id=account_id)
+
+ async def open_conversation(self, target_id: str, *, account_id: str | None = None) -> bool:
+ return await self._outbound.open_conversation(target_id, account_id=account_id)
+
+ async def create_conversation(
+ self,
+ contact_id: str,
+ body: str,
+ *,
+ account_id: str | None = None,
+ ) -> dict | None:
+ return await self._outbound.create_conversation(contact_id, body, account_id=account_id)
+
+ async def convert_to_ticket(self, conversation_id: str, *, account_id: str | None = None) -> dict | None:
+ return await self._outbound.convert_to_ticket(conversation_id, account_id=account_id)
+
+ async def send_payload(self, ctx: object) -> object:
+ return await self._outbound.send_payload(ctx)
+
+ async def send_poll(self, ctx: object) -> object:
+ return await self._outbound.send_poll(ctx)
+
+ def sanitize_text(self, text: str, payload: object) -> str:
+ return self._outbound.sanitize_text(text, payload)
+
+ def should_skip_plain_text_sanitization(self, payload: object) -> bool:
+ return self._outbound.should_skip_plain_text_sanitization(payload)
+
+ def normalize_payload(self, payload, config, account_id=None):
+ return self._outbound.normalize_payload(payload, config, account_id)
+
+ def resolve_effective_text_chunk_limit(self, config, account_id=None, fallback_limit=None):
+ return self._outbound.resolve_effective_text_chunk_limit(config, account_id, fallback_limit)
+
+ # ── Streaming 委托 ───────────────────────────────────
+
+ @property
+ def streaming_mode(self) -> str:
+ return self._streaming.streaming_mode
+
+ @property
+ def preview_stream_throttle_ms(self) -> int:
+ return self._streaming.preview_stream_throttle_ms
+
+ @property
+ def preview_min_initial_chars(self) -> int:
+ return self._streaming.preview_min_initial_chars
+
+ @property
+ def block_streaming_enabled(self) -> bool:
+ return self._streaming.block_streaming_enabled
+
+ @property
+ def block_streaming_break(self) -> str:
+ return self._streaming.block_streaming_break
+
+ @property
+ def block_streaming_chunk_min_chars(self) -> int:
+ return self._streaming.block_streaming_chunk_min_chars
+
+ @property
+ def block_streaming_chunk_max_chars(self) -> int:
+ return self._streaming.block_streaming_chunk_max_chars
+
+ @property
+ def block_streaming_chunk_break_preference(self) -> str:
+ return self._streaming.block_streaming_chunk_break_preference
+
+ @property
+ def block_streaming_coalesce_defaults(self):
+ return self._streaming.block_streaming_coalesce_defaults
+
+ def create_draft_stream_session(self, target_id: str) -> object:
+ return self._streaming.create_draft_stream_session(target_id)
+
+ def create_block_chunker(self) -> object:
+ return self._streaming.create_block_chunker()
+
+ # ── Security 委托 ────────────────────────────────────
+
+ def resolve_dm_policy(self) -> dict:
+ return self._security.resolve_dm_policy()
+
+ async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
+ return await self._security.check_allowlist(peer_id, channel_type)
+
+ def resolve_dm_policy_for_account(self, account: dict) -> dict:
+ return self._security.resolve_dm_policy_for_account(account)
+
+ def collect_audit_findings(
+ self,
+ config,
+ account_id=None,
+ account=None,
+ source_config=None,
+ ordered_account_ids=None,
+ has_explicit_account_path=False,
+ ) -> list[dict]:
+ return self._security.collect_audit_findings(
+ config,
+ account_id,
+ account,
+ source_config,
+ ordered_account_ids,
+ has_explicit_account_path,
+ )
+
+ # ── Pairing 委托 ─────────────────────────────────────
+
+ @property
+ def id_label(self) -> str:
+ return self._pairing.id_label
+
+ async def generate_code(self, peer_id: str) -> str:
+ return await self._pairing.generate_code(peer_id)
+
+ async def verify_code(self, peer_id: str, code: str) -> bool:
+ return await self._pairing.verify_code(peer_id, code)
+
+ def normalize_allow_entry(self, entry: str) -> str:
+ return self._pairing.normalize_allow_entry(entry)
+
+ async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None:
+ await self._pairing.notify_approval(config, peer_id, account_id)
+
+ # ── Status 委托 ──────────────────────────────────────
+
+ async def probe(self, account: dict | None = None) -> bool:
+ return await self._status.probe(account)
+
+ def build_summary(self, snapshot: object) -> dict:
+ return self._status.build_summary(snapshot)
+
+ def build_channel_summary(self, account: dict, config: dict, default_account_id: str, snapshot) -> dict:
+ return self._status.build_channel_summary(account, config, default_account_id, snapshot)
+
+ def build_account_snapshot(self, account: dict, config: dict, runtime=None, probe=None, audit=None):
+ return self._status.build_account_snapshot(account, config, runtime, probe, audit)
+
+ def format_capabilities_probe(self, probe: object) -> list[dict]:
+ return self._status.format_capabilities_probe(probe)
+
+ async def audit_account(self, account: dict, timeout_ms: int, config: dict, probe: object | None = None) -> object:
+ return await self._status.audit_account(account, timeout_ms, config, probe)
+
+ async def build_capabilities_diagnostics(
+ self,
+ account: dict,
+ timeout_ms: int,
+ config: dict,
+ probe=None,
+ audit=None,
+ target=None,
+ ) -> dict | None:
+ return await self._status.build_capabilities_diagnostics(
+ account,
+ timeout_ms,
+ config,
+ probe,
+ audit,
+ target,
+ )
+
+ def log_self_id(self, account: dict, config: dict, runtime: object, include_channel_prefix: bool = False) -> None:
+ self._status.log_self_id(account, config, runtime, include_channel_prefix)
+
+ def resolve_account_state(self, account: dict, config: dict, configured: bool, enabled: bool) -> str:
+ return self._status.resolve_account_state(account, config, configured, enabled)
+
+ def collect_status_issues(self, accounts: list) -> list:
+ return self._status.collect_status_issues(accounts)
+
+ # ── Agent Prompt ──────────────────────────────────────
+
+ @property
+ def channel_format_instructions(self) -> str | None:
+ return "\n".join(get_agent_prompt_rules())
+
+ def build_system_prompt(self, context) -> str | None:
+ lines = get_agent_prompt_rules()
+ return "\n".join(lines)
+
+ # ── Session ───────────────────────────────────────────
+
+ def resolve_session(self, msg) -> object:
+ from yuxi.channel.protocols import SessionResolution
+
+ conversation_id = ""
+ if hasattr(msg, "group") and msg.group and msg.group.id:
+ conversation_id = msg.group.id
+
+ return SessionResolution(kind="direct", conversation_id=conversation_id)
+
+ # ── Lifecycle ─────────────────────────────────────────
+
+ async def run_startup_maintenance(self, cfg: dict) -> None:
+ channel_cfg = cfg.get("channels", {}).get("intercom", {})
+ if not channel_cfg:
+ logger.info("Intercom not configured, skipping startup maintenance")
+ return
+
+ account_ids = self._config.list_account_ids(cfg)
+ for aid in account_ids:
+ account = await self._config.resolve_account(aid, cfg)
+ if not self._config.is_configured(account):
+ logger.warning("Intercom account [%s] is not configured", aid)
+ continue
+
+ warnings = self._security.collect_warnings(cfg, aid, account)
+ for w in warnings:
+ logger.warning("Intercom [%s]: %s", aid, w)
+
+ if not account.get("webhook_secret"):
+ logger.warning(
+ "Intercom [%s]: webhookSecret not configured. Webhook signature verification will be skipped.",
+ aid,
+ )
+
+
+intercom_plugin = ChannelPluginRegistry.register(IntercomPlugin())
diff --git a/backend/package/yuxi/channel/extensions/intercom/client.py b/backend/package/yuxi/channel/extensions/intercom/client.py
new file mode 100644
index 00000000..a5143741
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/client.py
@@ -0,0 +1,579 @@
+import logging
+
+import httpx
+
+from yuxi.channel.extensions.intercom.constants import (
+ DEFAULT_DATACENTER,
+ INTERCOM_API_BASES,
+ INTERCOM_API_VERSION,
+ INTERCOM_DEFAULT_TIMEOUT,
+)
+from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode
+
+logger = logging.getLogger(__name__)
+
+
+class IntercomClient:
+ def __init__(
+ self,
+ access_token: str,
+ *,
+ datacenter: str = DEFAULT_DATACENTER,
+ timeout: float = INTERCOM_DEFAULT_TIMEOUT,
+ ):
+ self._access_token = access_token
+ base_url = INTERCOM_API_BASES.get(datacenter, INTERCOM_API_BASES[DEFAULT_DATACENTER])
+ self._client = httpx.AsyncClient(
+ base_url=base_url,
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Accept": "application/json",
+ "Intercom-Version": INTERCOM_API_VERSION,
+ },
+ timeout=httpx.Timeout(timeout),
+ )
+
+ async def get_me(self) -> dict:
+ resp = await self._request("GET", "/me")
+ return resp
+
+ async def reply_conversation(
+ self,
+ conversation_id: str,
+ body: str,
+ *,
+ message_type: str = "comment",
+ reply_type: str = "admin",
+ admin_id: str | None = None,
+ attachment_urls: list[str] | None = None,
+ reply_options: dict | None = None,
+ ) -> dict:
+ payload: dict = {
+ "message_type": message_type,
+ "type": reply_type,
+ "body": body,
+ }
+ if admin_id:
+ payload["admin_id"] = admin_id
+ if attachment_urls:
+ payload["attachment_urls"] = attachment_urls
+ if reply_options:
+ payload["reply_options"] = reply_options
+
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/reply",
+ json=payload,
+ )
+ return resp
+
+ async def get_conversation(self, conversation_id: str) -> dict:
+ resp = await self._request("GET", f"/conversations/{conversation_id}")
+ return resp
+
+ async def get_conversation_parts(self, conversation_id: str) -> list[dict]:
+ resp = await self._request(
+ "GET",
+ f"/conversations/{conversation_id}",
+ params={"display_as": "plaintext"},
+ )
+ return resp.get("conversation_parts", {}).get("conversation_parts", [])
+
+ async def manage_conversation(
+ self,
+ conversation_id: str,
+ action: str,
+ admin_id: str | None = None,
+ ) -> dict:
+ payload: dict = {"type": "admin"}
+ if admin_id:
+ payload["admin_id"] = admin_id
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/manage",
+ json=payload,
+ params={"action": action},
+ )
+ return resp
+
+ async def create_conversation(
+ self,
+ contact_id: str,
+ body: str,
+ *,
+ admin_id: str | None = None,
+ message_type: str = "comment",
+ attachment_urls: list[str] | None = None,
+ ) -> dict:
+ payload: dict = {
+ "from": {"type": "contact", "id": contact_id},
+ "body": body,
+ }
+ if admin_id:
+ payload["admin_id"] = admin_id
+ if message_type:
+ payload["message_type"] = message_type
+ if attachment_urls:
+ payload["attachment_urls"] = attachment_urls
+ resp = await self._request("POST", "/conversations", json=payload)
+ return resp
+
+ async def search_conversations(
+ self,
+ query: dict,
+ *,
+ page: int = 1,
+ per_page: int = 20,
+ ) -> dict:
+ payload = {
+ "query": query,
+ "pagination": {"page": page, "per_page": min(per_page, 150)},
+ }
+ resp = await self._request("POST", "/conversations/search", json=payload)
+ return resp
+
+ async def get_contact(self, contact_id: str) -> dict:
+ resp = await self._request("GET", f"/contacts/{contact_id}")
+ return resp
+
+ async def create_contact(self, email: str, *, name: str | None = None, **attrs) -> dict:
+ payload: dict = {"email": email}
+ if name:
+ payload["name"] = name
+ payload.update(attrs)
+ resp = await self._request("POST", "/contacts", json=payload)
+ return resp
+
+ async def update_contact(self, contact_id: str, **attrs) -> dict:
+ resp = await self._request("PUT", f"/contacts/{contact_id}", json=attrs)
+ return resp
+
+ async def delete_contact(self, contact_id: str) -> dict:
+ resp = await self._request("DELETE", f"/contacts/{contact_id}")
+ return resp
+
+ async def list_contacts(self, *, page: int = 1, per_page: int = 20, email: str | None = None) -> dict:
+ params: dict = {"page": page, "per_page": min(per_page, 150)}
+ if email:
+ params["email"] = email
+ resp = await self._request("GET", "/contacts", params=params)
+ return resp
+
+ async def search_contacts(
+ self,
+ query: dict,
+ *,
+ page: int = 1,
+ per_page: int = 20,
+ ) -> dict:
+ payload = {
+ "query": query,
+ "pagination": {"page": page, "per_page": min(per_page, 150)},
+ }
+ resp = await self._request("POST", "/contacts/search", json=payload)
+ return resp
+
+ async def archive_contact(self, contact_id: str) -> dict:
+ resp = await self._request("POST", f"/contacts/{contact_id}/archive")
+ return resp
+
+ async def add_conversation_tags(self, conversation_id: str, tags: list[str]) -> dict:
+ payload = {"tags": [{"id": tag} for tag in tags]}
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/tags",
+ json=payload,
+ )
+ return resp
+
+ async def remove_conversation_tag(self, conversation_id: str, tag_id: str) -> dict:
+ resp = await self._request(
+ "DELETE",
+ f"/conversations/{conversation_id}/tags/{tag_id}",
+ )
+ return resp
+
+ async def list_tags(self) -> list[dict]:
+ resp = await self._request("GET", "/tags")
+ return resp.get("data", [])
+
+ async def update_conversation(self, conversation_id: str, **kwargs) -> dict:
+ resp = await self._request(
+ "PUT",
+ f"/conversations/{conversation_id}",
+ json=kwargs,
+ )
+ return resp
+
+ async def snooze_conversation(self, conversation_id: str, snooze_until: int) -> dict:
+ return await self.update_conversation(conversation_id, snoozed_until=snooze_until)
+
+ async def assign_conversation(self, conversation_id: str, admin_id: str) -> dict:
+ return await self.update_conversation(conversation_id, assignee_id=admin_id, assignee_type="admin")
+
+ async def delete_conversation_part(self, conversation_id: str, part_id: str) -> dict:
+ resp = await self._request(
+ "DELETE",
+ f"/conversations/{conversation_id}/parts/{part_id}",
+ )
+ return resp
+
+ async def create_ticket(
+ self,
+ subject: str,
+ message_body: str,
+ contact_id: str,
+ *,
+ admin_id: str | None = None,
+ ticket_type_id: str | None = None,
+ ticket_attributes: dict | None = None,
+ ) -> dict:
+ payload: dict = {
+ "contacts": [{"id": contact_id}],
+ "subject": subject,
+ "ticket_type": {"body": message_body},
+ }
+ if admin_id:
+ payload["admin_assignee_id"] = admin_id
+ if ticket_type_id:
+ payload["ticket_type_id"] = ticket_type_id
+ if ticket_attributes:
+ payload["ticket_attributes"] = ticket_attributes
+ resp = await self._request("POST", "/tickets", json=payload)
+ return resp
+
+ async def reply_ticket(
+ self,
+ ticket_id: str,
+ body: str,
+ *,
+ admin_id: str | None = None,
+ attachment_urls: list[str] | None = None,
+ ) -> dict:
+ payload: dict = {
+ "type": "admin",
+ "body": body,
+ }
+ if admin_id:
+ payload["admin_id"] = admin_id
+ if attachment_urls:
+ payload["attachment_urls"] = attachment_urls
+ resp = await self._request("POST", f"/tickets/{ticket_id}/reply", json=payload)
+ return resp
+
+ async def get_ticket(self, ticket_id: str) -> dict:
+ resp = await self._request("GET", f"/tickets/{ticket_id}")
+ return resp
+
+ async def update_ticket(self, ticket_id: str, **kwargs) -> dict:
+ resp = await self._request("PUT", f"/tickets/{ticket_id}", json=kwargs)
+ return resp
+
+ async def get_company(self, company_id: str) -> dict:
+ resp = await self._request("GET", f"/companies/{company_id}")
+ return resp
+
+ async def create_company(self, name: str, *, company_id: str | None = None, **attrs) -> dict:
+ payload: dict = {"name": name}
+ if company_id:
+ payload["company_id"] = company_id
+ payload.update(attrs)
+ resp = await self._request("POST", "/companies", json=payload)
+ return resp
+
+ async def update_company(self, company_id: str, **attrs) -> dict:
+ resp = await self._request("PUT", f"/companies/{company_id}", json=attrs)
+ return resp
+
+ async def list_companies(self, *, page: int = 1, per_page: int = 20) -> dict:
+ resp = await self._request(
+ "GET",
+ "/companies",
+ params={"page": page, "per_page": min(per_page, 150)},
+ )
+ return resp
+
+ async def search_companies(
+ self,
+ query: dict,
+ *,
+ page: int = 1,
+ per_page: int = 20,
+ ) -> dict:
+ payload = {
+ "query": query,
+ "pagination": {"page": page, "per_page": min(per_page, 150)},
+ }
+ resp = await self._request("POST", "/companies/search", json=payload)
+ return resp
+
+ async def attach_contact_to_company(self, contact_id: str, company_id: str) -> dict:
+ resp = await self._request(
+ "POST",
+ f"/contacts/{contact_id}/companies",
+ json={"id": company_id},
+ )
+ return resp
+
+ async def detach_contact_from_company(self, contact_id: str, company_id: str) -> dict:
+ resp = await self._request(
+ "DELETE",
+ f"/contacts/{contact_id}/companies/{company_id}",
+ )
+ return resp
+
+ async def list_articles(self, *, page: int = 1, per_page: int = 20, state: str = "published") -> dict:
+ resp = await self._request(
+ "GET",
+ "/articles",
+ params={"page": page, "per_page": per_page, "state": state},
+ )
+ return resp
+
+ async def get_article(self, article_id: str) -> dict:
+ resp = await self._request("GET", f"/articles/{article_id}")
+ return resp
+
+ async def list_brands(self) -> list[dict]:
+ resp = await self._request("GET", "/brands")
+ return resp.get("data", [])
+
+ async def get_brand(self, brand_id: str) -> dict:
+ resp = await self._request("GET", f"/brands/{brand_id}")
+ return resp
+
+ async def list_conversations(self, *, page: int = 1, per_page: int = 20) -> dict:
+ resp = await self._request(
+ "GET",
+ "/conversations",
+ params={"page": page, "per_page": min(per_page, 150)},
+ )
+ return resp
+
+ async def delete_conversation(self, conversation_id: str) -> dict:
+ resp = await self._request("DELETE", f"/conversations/{conversation_id}")
+ return resp
+
+ async def redact_conversation_part(self, conversation_id: str, part_id: str) -> dict:
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/parts/{part_id}/redact",
+ )
+ return resp
+
+ async def convert_conversation_to_ticket(self, conversation_id: str, *, admin_id: str | None = None) -> dict:
+ payload: dict = {}
+ if admin_id:
+ payload["admin_id"] = admin_id
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/convert-to-ticket",
+ json=payload,
+ )
+ return resp
+
+ async def run_assignment_rules(self, conversation_id: str) -> dict:
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/run_assignment_rules",
+ )
+ return resp
+
+ async def attach_contact_to_conversation(self, conversation_id: str, contact_id: str) -> dict:
+ resp = await self._request(
+ "POST",
+ f"/conversations/{conversation_id}/contacts",
+ json={"id": contact_id},
+ )
+ return resp
+
+ async def detach_contact_from_conversation(self, conversation_id: str, contact_id: str) -> dict:
+ resp = await self._request(
+ "DELETE",
+ f"/conversations/{conversation_id}/contacts/{contact_id}",
+ )
+ return resp
+
+ async def list_tickets(self, *, page: int = 1, per_page: int = 20) -> dict:
+ resp = await self._request(
+ "GET",
+ "/tickets",
+ params={"page": page, "per_page": min(per_page, 150)},
+ )
+ return resp
+
+ async def search_tickets(
+ self,
+ query: dict,
+ *,
+ page: int = 1,
+ per_page: int = 20,
+ ) -> dict:
+ payload = {
+ "query": query,
+ "pagination": {"page": page, "per_page": min(per_page, 150)},
+ }
+ resp = await self._request("POST", "/tickets/search", json=payload)
+ return resp
+
+ async def merge_contacts(self, primary_id: str, secondary_id: str) -> dict:
+ resp = await self._request(
+ "POST",
+ "/contacts/merge",
+ json={
+ "primary_contact_id": primary_id,
+ "secondary_contact_id": secondary_id,
+ },
+ )
+ return resp
+
+ async def add_contact_tags(self, contact_id: str, tags: list[str]) -> dict:
+ payload = {"tags": [{"id": tag} for tag in tags]}
+ resp = await self._request(
+ "POST",
+ f"/contacts/{contact_id}/tags",
+ json=payload,
+ )
+ return resp
+
+ async def remove_contact_tag(self, contact_id: str, tag_id: str) -> dict:
+ resp = await self._request(
+ "DELETE",
+ f"/contacts/{contact_id}/tags/{tag_id}",
+ )
+ return resp
+
+ async def get_contact_subscriptions(self, contact_id: str) -> dict:
+ resp = await self._request("GET", f"/contacts/{contact_id}/subscriptions")
+ return resp
+
+ async def update_contact_subscriptions(self, contact_id: str, subscriptions: list[dict]) -> dict:
+ resp = await self._request(
+ "PUT",
+ f"/contacts/{contact_id}/subscriptions",
+ json={"subscriptions": subscriptions},
+ )
+ return resp
+
+ async def send_email(
+ self,
+ to: list[str],
+ subject: str,
+ body_html: str,
+ *,
+ cc: list[str] | None = None,
+ bcc: list[str] | None = None,
+ ) -> dict:
+ payload: dict = {
+ "to": to,
+ "subject": subject,
+ "body": body_html,
+ }
+ if cc:
+ payload["cc"] = cc
+ if bcc:
+ payload["bcc"] = bcc
+ resp = await self._request("POST", "/emails", json=payload)
+ return resp
+
+ async def send_message(
+ self,
+ contact_id: str,
+ body: str,
+ *,
+ admin_id: str | None = None,
+ ) -> dict:
+ payload: dict = {
+ "from": {"type": "admin", "id": admin_id} if admin_id else {"type": "admin"},
+ "to": {"type": "contact", "id": contact_id},
+ "body": body,
+ }
+ resp = await self._request("POST", "/messages", json=payload)
+ return resp
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+ async def _paginated_request(self, method: str, path: str, *, params: dict | None = None, **kwargs) -> list[dict]:
+ params = params or {}
+ results: list[dict] = []
+ while True:
+ resp = await self._request(method, path, params=params, **kwargs)
+ data = resp.get("data", [])
+ results.extend(data)
+ pages_info = resp.get("pages", {})
+ next_cursor = pages_info.get("next", {}).get("starting_after")
+ if not next_cursor:
+ break
+ params["starting_after"] = next_cursor
+ return results
+
+ async def _request(self, method: str, path: str, **kwargs) -> dict:
+ try:
+ resp = await self._client.request(method, path, **kwargs)
+ except httpx.TimeoutException as e:
+ raise IntercomError(
+ IntercomErrorCode.NETWORK_ERROR,
+ f"Request timeout: {method} {path}",
+ retryable=True,
+ ) from e
+ except httpx.ConnectError as e:
+ raise IntercomError(
+ IntercomErrorCode.NETWORK_ERROR,
+ f"Connection error: {method} {path}",
+ retryable=True,
+ ) from e
+ except httpx.HTTPError as e:
+ raise IntercomError(
+ IntercomErrorCode.NETWORK_ERROR,
+ f"HTTP error: {method} {path}: {e}",
+ retryable=True,
+ ) from e
+
+ if resp.status_code == 401:
+ raise IntercomError(
+ IntercomErrorCode.AUTH_ERROR,
+ f"Unauthorized: {resp.text}",
+ retryable=False,
+ )
+ if resp.status_code == 403:
+ raise IntercomError(
+ IntercomErrorCode.AUTH_ERROR,
+ f"Forbidden: {resp.text}",
+ retryable=False,
+ )
+ if resp.status_code == 404:
+ raise IntercomError(
+ IntercomErrorCode.NOT_FOUND,
+ f"Not found: {method} {path}",
+ retryable=False,
+ )
+ if resp.status_code == 429:
+ retry_after = resp.headers.get("Retry-After")
+ raise IntercomError(
+ IntercomErrorCode.RATE_LIMITED,
+ f"Rate limited (Retry-After: {retry_after or 'N/A'}): {resp.text}",
+ retryable=True,
+ )
+ if resp.status_code >= 500:
+ raise IntercomError(
+ IntercomErrorCode.NETWORK_ERROR,
+ f"Server error {resp.status_code}: {resp.text}",
+ retryable=True,
+ )
+ if resp.status_code >= 400:
+ raise IntercomError(
+ IntercomErrorCode.UNKNOWN,
+ f"Client error {resp.status_code}: {resp.text}",
+ retryable=False,
+ )
+
+ remaining = resp.headers.get("X-RateLimit-Remaining")
+ limit = resp.headers.get("X-RateLimit-Limit")
+ if remaining is not None and int(remaining) < 10:
+ logger.warning("Intercom rate limit low: %s/%s", remaining, limit)
+
+ try:
+ return resp.json()
+ except Exception:
+ return {}
diff --git a/backend/package/yuxi/channel/extensions/intercom/config.py b/backend/package/yuxi/channel/extensions/intercom/config.py
new file mode 100644
index 00000000..42656c7b
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/config.py
@@ -0,0 +1,192 @@
+import os
+import logging
+
+from yuxi.channel.extensions.intercom.types import IntercomAccount
+
+logger = logging.getLogger(__name__)
+
+
+class IntercomConfigAdapter:
+ def list_account_ids(self, config: dict) -> list[str]:
+ accounts = config.get("channels", {}).get("intercom", {}).get("accounts", {})
+ if accounts:
+ return list(accounts.keys())
+ if os.getenv("INTERCOM_ACCESS_TOKEN"):
+ return ["default"]
+ return []
+
+ async def resolve_account(self, account_id: str, config: dict | None = None) -> dict:
+ account = self._resolve_account_dataclass(account_id, config or {})
+ return {
+ "account_id": account.account_id,
+ "access_token": account.access_token,
+ "webhook_secret": account.webhook_secret,
+ "admin_id": account.admin_id,
+ "dm_policy": account.dm_policy,
+ "allow_from": account.allow_from,
+ "auto_skip_closed": account.auto_skip_closed,
+ "auto_skip_snoozed": account.auto_skip_snoozed,
+ "handoff_policy": account.handoff_policy,
+ "datacenter": account.datacenter,
+ }
+
+ def _resolve_account_dataclass(self, account_id: str, config: dict) -> IntercomAccount:
+ channel_cfg = config.get("channels", {}).get("intercom", {})
+ accounts = channel_cfg.get("accounts", {})
+ account_cfg = accounts.get(account_id, {}) if accounts else channel_cfg
+
+ access_token = self._resolve_token(account_cfg.get("accessToken"), "INTERCOM_ACCESS_TOKEN")
+ webhook_secret = self._resolve_token(account_cfg.get("webhookSecret"), "INTERCOM_WEBHOOK_SECRET")
+ admin_id = account_cfg.get("adminId") or os.getenv("INTERCOM_ADMIN_ID", "")
+
+ return IntercomAccount(
+ account_id=account_id,
+ access_token=access_token,
+ webhook_secret=webhook_secret,
+ admin_id=admin_id,
+ dm_policy=account_cfg.get("dmPolicy", "open"),
+ allow_from=account_cfg.get("allowFrom", []),
+ auto_skip_closed=account_cfg.get("autoSkipClosed", True),
+ auto_skip_snoozed=account_cfg.get("autoSkipSnoozed", True),
+ handoff_policy=account_cfg.get("handoffPolicy", "ignore"),
+ datacenter=account_cfg.get("datacenter", "us"),
+ )
+
+ @staticmethod
+ def _resolve_token(account_value: str | None, env_key: str) -> str:
+ if account_value is not None:
+ if account_value == "none":
+ return ""
+ if account_value.startswith("$"):
+ env_val = os.environ.get(account_value.strip("${}"), "")
+ return env_val
+ return account_value
+ return os.environ.get(env_key, "")
+
+ def is_configured(self, account: dict) -> bool:
+ return bool(account.get("access_token"))
+
+ def is_enabled(self, account: dict, config: dict) -> bool:
+ return True
+
+ def disabled_reason(self, account: dict, config: dict) -> str:
+ return ""
+
+ def unconfigured_reason(self, account: dict, config: dict) -> str:
+ if self.is_configured(account):
+ return ""
+ return "Missing required credential: accessToken"
+
+ def describe_account(self, account: dict, config: dict) -> dict:
+ return {
+ "account_id": account.get("account_id", ""),
+ "dm_policy": account.get("dm_policy", "open"),
+ "handoff_policy": account.get("handoff_policy", "ignore"),
+ }
+
+ def default_account_id(self, config: dict) -> str:
+ return config.get("channels", {}).get("intercom", {}).get("default_account", "default")
+
+ def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
+ channel_cfg = config.get("channels", {}).get("intercom", {})
+ accounts = channel_cfg.get("accounts", {})
+ if account_id and account_id in accounts:
+ return accounts[account_id].get("allowFrom")
+ return channel_cfg.get("allowFrom")
+
+ def format_allow_from(self, config: dict, account_id: str | None, allow_from: list[str | int]) -> list[str]:
+ return [str(entry) for entry in allow_from]
+
+ def has_configured_state(self, config: dict) -> bool:
+ accounts = config.get("channels", {}).get("intercom", {}).get("accounts", {})
+ if accounts:
+ for account_cfg in accounts.values():
+ if account_cfg.get("accessToken") or account_cfg.get("accessToken") is None:
+ return True
+ return bool(os.getenv("INTERCOM_ACCESS_TOKEN"))
+
+ def has_persisted_auth_state(self, config: dict) -> bool:
+ accounts = config.get("channels", {}).get("intercom", {}).get("accounts", {})
+ if accounts:
+ for account_cfg in accounts.values():
+ if account_cfg.get("accessToken"):
+ return True
+ return False
+
+ def config_schema(self) -> dict:
+ return {
+ "$schema": "https://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "Intercom 渠道配置",
+ "properties": {
+ "accounts": {
+ "type": "object",
+ "title": "账户列表",
+ "description": "Intercom 账户配置,key 为账户 ID",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "accessToken": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "Intercom Access Token (Settings > Developer Hub > Access Tokens)",
+ "x-ui-password": True,
+ },
+ "webhookSecret": {
+ "type": "string",
+ "title": "Webhook Secret",
+ "description": "用于验证 Webhook 请求的 HMAC 密钥",
+ "x-ui-password": True,
+ },
+ "adminId": {
+ "type": "string",
+ "title": "Admin ID",
+ "description": "Bot 的管理员 ID(留空则自动从 API 获取)",
+ },
+ "dmPolicy": {
+ "type": "string",
+ "title": "DM 安全策略",
+ "enum": ["open", "pairing", "allowlist", "disabled"],
+ "default": "open",
+ },
+ "allowFrom": {
+ "type": "array",
+ "title": "白名单 Contact ID",
+ "items": {"type": "string"},
+ "description": "仅 dmPolicy=allowlist 时生效,格式: contact_xxx 或 lead_xxx",
+ },
+ "autoSkipClosed": {
+ "type": "boolean",
+ "title": "跳过已关闭会话",
+ "default": True,
+ },
+ "autoSkipSnoozed": {
+ "type": "boolean",
+ "title": "跳过已暂停会话",
+ "default": True,
+ },
+ "handoffPolicy": {
+ "type": "string",
+ "title": "人工接管策略",
+ "enum": ["ignore", "note_only", "continue"],
+ "default": "ignore",
+ "description": "检测到人工接管后的行为",
+ },
+ "datacenter": {
+ "type": "string",
+ "title": "数据中心",
+ "enum": ["us", "eu", "au"],
+ "default": "us",
+ "description": "Intercom 数据中心区域",
+ },
+ },
+ "required": ["accessToken"],
+ },
+ },
+ "default_account": {
+ "type": "string",
+ "title": "默认账户",
+ "default": "default",
+ },
+ },
+ }
diff --git a/backend/package/yuxi/channel/extensions/intercom/constants.py b/backend/package/yuxi/channel/extensions/intercom/constants.py
new file mode 100644
index 00000000..af20ca5f
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/constants.py
@@ -0,0 +1,72 @@
+INTERCOM_API_BASES = {
+ "us": "https://api.intercom.io",
+ "eu": "https://api.eu.intercom.io",
+ "au": "https://api.au.intercom.io",
+}
+DEFAULT_DATACENTER = "us"
+INTERCOM_API_VERSION = "2.15"
+
+INTERCOM_DEFAULT_TIMEOUT = 30.0
+INTERCOM_MAX_RETRIES = 3
+INTERCOM_RETRY_BASE_DELAY = 1.0
+INTERCOM_RETRY_MAX_DELAY = 8.0
+
+INTERCOM_DEDUPE_TTL_SECONDS = 60
+INTERCOM_DEDUPE_MAX_SIZE = 10000
+
+INTERCOM_TEXT_CHUNK_LIMIT = 40000
+
+INTERCOM_BLOCK_STREAMING_CHUNK_MIN_CHARS = 500
+INTERCOM_BLOCK_STREAMING_CHUNK_MAX_CHARS = 2000
+
+INTERCOM_WEBHOOK_TOPICS_HANDLED = frozenset(
+ [
+ # 用户消息事件
+ "conversation.user.created",
+ "conversation.user.replied",
+ # 管理员事件(P0 新增)
+ "conversation.admin.closed",
+ "conversation.admin.opened",
+ "conversation.admin.snoozed",
+ "conversation.admin.unsnoozed",
+ "conversation.admin.assigned",
+ "conversation.admin.replied",
+ "conversation.admin.single.created",
+ # 其他 Bot / Operator 回复(P0 新增)
+ "conversation.operator.replied",
+ # 会话状态事件(P1 新增)
+ "conversation.deleted",
+ "conversation.priority.updated",
+ "conversation.rating.added",
+ "conversation.read",
+ # 消息事件(P1 新增)
+ "conversation_part.redacted",
+ "conversation_part.tag.created",
+ ]
+)
+
+INTERCOM_CONVERSATION_STATES = frozenset(["open", "snoozed", "closed"])
+
+INTERCOM_PART_TYPES = frozenset(["comment", "note", "assignment", "close", "open", "snooze"])
+
+INTERCOM_DM_POLICY_OPEN = "open"
+INTERCOM_DM_POLICY_PAIRING = "pairing"
+INTERCOM_DM_POLICY_ALLOWLIST = "allowlist"
+INTERCOM_DM_POLICY_DISABLED = "disabled"
+
+INTERCOM_HANDOFF_POLICY_IGNORE = "ignore"
+INTERCOM_HANDOFF_POLICY_NOTE_ONLY = "note_only"
+INTERCOM_HANDOFF_POLICY_CONTINUE = "continue"
+
+INTERCOM_AGENT_PROMPT_RULES = [
+ "Your response will be sent to Intercom Messenger as an admin reply.",
+ "The message format is HTML. Use for bold, for italic.",
+ 'Use text for links.',
+ "Use heading {heading_match.group(1)}
for unordered lists.",
+ "Use
for ordered lists.",
+ "Use for inline code and for code blocks.",
+ "Do not use markdown headings (use for table-like text).",
+ "Keep responses friendly, professional, and solution-oriented.",
+ "Identify yourself as an AI assistant when relevant.",
+]
diff --git a/backend/package/yuxi/channel/extensions/intercom/dedupe.py b/backend/package/yuxi/channel/extensions/intercom/dedupe.py
new file mode 100644
index 00000000..6b4f8476
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/dedupe.py
@@ -0,0 +1,62 @@
+import time
+import logging
+
+from yuxi.channel.extensions.intercom.constants import (
+ INTERCOM_DEDUPE_TTL_SECONDS,
+ INTERCOM_DEDUPE_MAX_SIZE,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class DedupeCache:
+ def __init__(self, ttl_seconds: int = INTERCOM_DEDUPE_TTL_SECONDS, max_size: int = INTERCOM_DEDUPE_MAX_SIZE):
+ self._ttl = ttl_seconds
+ self._max_size = max_size
+ self._cache: dict[str, float] = {}
+
+ def is_duplicate(self, event_id: str) -> bool:
+ if not event_id:
+ return False
+
+ now = time.monotonic()
+
+ if event_id in self._cache:
+ if now - self._cache[event_id] < self._ttl:
+ return True
+
+ self._cache[event_id] = now
+ self._evict_expired(now)
+ return False
+
+ def check_duplicate(self, event_id: str) -> bool:
+ if not event_id:
+ return False
+ now = time.monotonic()
+ if event_id in self._cache:
+ if now - self._cache[event_id] < self._ttl:
+ return True
+ return False
+
+ def mark_seen(self, event_id: str) -> None:
+ if not event_id:
+ return
+ self._cache[event_id] = time.monotonic()
+ self._evict_expired(time.monotonic())
+
+ def reset(self) -> None:
+ self._cache.clear()
+
+ def _evict_expired(self, now: float) -> None:
+ if len(self._cache) <= self._max_size:
+ expired = [k for k, v in self._cache.items() if now - v >= self._ttl]
+ for k in expired:
+ del self._cache[k]
+
+ if len(self._cache) > self._max_size:
+ sorted_keys = sorted(self._cache, key=self._cache.get)
+ for k in sorted_keys[: len(self._cache) - self._max_size]:
+ del self._cache[k]
+
+ def clear(self) -> None:
+ self._cache.clear()
diff --git a/backend/package/yuxi/channel/extensions/intercom/errors.py b/backend/package/yuxi/channel/extensions/intercom/errors.py
new file mode 100644
index 00000000..535c2713
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/errors.py
@@ -0,0 +1,51 @@
+from enum import StrEnum
+
+
+class IntercomErrorCode(StrEnum):
+ CONFIG_ERROR = "config_error"
+ AUTH_ERROR = "auth_error"
+ RATE_LIMITED = "rate_limited"
+ NOT_FOUND = "not_found"
+ NETWORK_ERROR = "network_error"
+ CONVERSATION_CLOSED = "conversation_closed"
+ CONVERSATION_SNOOZED = "conversation_snoozed"
+ HANDOFF_DETECTED = "handoff_detected"
+ SEND_FAILED = "send_failed"
+ UNKNOWN = "unknown"
+
+
+class IntercomError(Exception):
+ def __init__(self, code: IntercomErrorCode, message: str, retryable: bool = False):
+ self.code = code
+ self.retryable = retryable
+ super().__init__(f"[{code.value}] {message}")
+
+
+_ERROR_RETRY_MAP: dict[str, tuple[bool, float | None]] = {
+ "rate_limited": (True, None),
+ "service_unavailable": (True, 1.0),
+ "internal_server_error": (True, 3.0),
+ "timeout": (True, 0.5),
+ "network_error": (True, 1.0),
+ "not_found": (False, None),
+ "unauthorized": (False, None),
+ "forbidden": (False, None),
+ "bad_request": (False, None),
+}
+
+
+def classify_intercom_error(status_code: int, error_body: str = "") -> tuple[bool, float | None]:
+ if status_code == 429:
+ return (True, None)
+ if status_code >= 500:
+ return (True, 2.0)
+ if status_code in (401, 403):
+ return (False, None)
+ if status_code == 404:
+ return (False, None)
+ return (False, None)
+
+
+def is_retryable_status(status_code: int) -> bool:
+ retryable, _ = classify_intercom_error(status_code)
+ return retryable
diff --git a/backend/package/yuxi/channel/extensions/intercom/format.py b/backend/package/yuxi/channel/extensions/intercom/format.py
new file mode 100644
index 00000000..bb7950c1
--- /dev/null
+++ b/backend/package/yuxi/channel/extensions/intercom/format.py
@@ -0,0 +1,106 @@
+import html
+import re
+from html.parser import HTMLParser
+
+
+class HTMLToTextParser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self._parts: list[str] = []
+ self._skip_depth = 0
+ self._current_href: str | None = None
+
+ def handle_starttag(self, tag, attrs):
+ if tag in ("script", "style", "head", "meta", "link"):
+ self._skip_depth += 1
+ if tag == "a":
+ for name, value in attrs:
+ if name == "href":
+ self._current_href = value
+ break
+
+ def handle_endtag(self, tag):
+ if tag in ("script", "style", "head", "meta", "link") and self._skip_depth > 0:
+ self._skip_depth -= 1
+ if tag in ("p", "br", "li", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
+ self._parts.append("\n")
+ if tag == "a" and self._current_href:
+ self._parts.append(f" ({self._current_href})")
+ self._current_href = None
+
+ def handle_data(self, data):
+ if self._skip_depth == 0:
+ self._parts.append(data)
+
+ def get_text(self) -> str:
+ return "".join(self._parts).strip()
+
+
+def html_to_text(html_body: str) -> str:
+ parser = HTMLToTextParser()
+ parser.feed(html_body)
+ return parser.get_text()
+
+
+def markdown_to_html(md_text: str) -> str:
+ text = html.escape(md_text)
+
+ text = re.sub(r"```(\w+)?\n?(.*?)```", r"
", text, flags=re.DOTALL)
+ text = re.sub(r"`([^`]+)`", r"\2\1", text)
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"\*(.+?)\*", r"\1", text)
+ text = re.sub(r"~~(.+?)~~", r"\1", text)
+ text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text)
+
+ lines = text.split("\n")
+ result: list[str] = []
+ list_buffer: list[str] = []
+ in_list = False
+
+ for line in lines:
+ stripped = line.strip()
+
+ ul_match = re.match(r"^[\-\*]\s+(.+)$", stripped)
+ ol_match = re.match(r"^\d+\.\s+(.+)$", stripped)
+
+ if ul_match:
+ if not in_list:
+ in_list = True
+ list_buffer = []
+ list_buffer.append(f"{''.join(list_buffer)}
")
+ list_buffer = []
+
+ heading_match = re.match(r"^#{1,6}\s+(.+)$", stripped)
+ if heading_match:
+ result.append(f"
")
+ elif stripped.startswith("<") and not stripped.startswith("
{stripped}