ForcePilot/backend/package/yuxi/channels/adapters/imessage/adapter.py
Kris 068cf70fe9 feat(imessage): 实现完整的 iMessage 适配器基础模块
新增 iMessage 通道适配器完整实现,包含:
1. 核心适配器与工具工厂导出
2. 运行时存储、反射防护、会话路由等基础组件
3. 消息信封、线程管理、回复上下文格式化
4. Tapback 表情反应处理、自定义异常体系
5. 审批按钮、联系人解析、速率限制功能
6. 文本净化、目标解析、缓存管理模块
7. 配置 schema、多账户支持、安装向导等配置模块
8. 审计日志、媒体AI处理等扩展功能
2026-05-12 00:44:44 +08:00

1032 lines
42 KiB
Python

from __future__ import annotations
import asyncio
import datetime as dt
import os
import tempfile
import time
from collections.abc import AsyncIterator
from typing import Any
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MentionsInfo,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .accounts import list_enabled_accounts
from .bridge_client import BlueBubblesClient
from .commands import is_authorized_for_commands, parse_command
from .contact_resolver import resolve_contact
from .echo_cache import EchoCache
from .envelope import format_inbound_envelope
from .formatter import convert_markdown_tables, sanitize_imessage_text
from .media_security import validate_attachment_path
from .normalize import parse_forwarded
from .monitor import IMessageMonitor
from .pairing import IMessagePairingManager
from .probe import run_doctor_diagnosis
from .rate_limiter import LoopRateLimiter
from .reflection_guard import ReflectionGuard
from .sanitize import media_placeholder
from .security import IMessageSecurityPolicy
from .session import resolve_chat_type, resolve_thread_key
from .sticker_cache import StickerCache
from .tapback import describe_tapback, emoji_to_tapback, resolve_tapback_value
from .threading import format_reply_context
DEBOUNCE_WINDOW_S = 1.0
@register_builtin_adapter
class IMessageAdapter(BaseChannelAdapter):
channel_id = "imessage"
channel_type = ChannelType.IMESSAGE
text_chunk_limit = 4096
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "partial", "block"]
max_media_size_mb = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "partial", "block"],
text_chunk_limit=4096,
max_media_size_mb=100,
reply=True,
reactions=True,
edit=True,
unsend=True,
media=True,
polls=False,
threads=False,
pin=False,
unpin=False,
list_pins=False,
group_management=False,
native_commands=False,
effects=False,
)
meta = ChannelMeta(
id="imessage",
label="iMessage",
aliases=["imsg"],
selection_label="iMessage (via BlueBubbles)",
blurb="Connect to iMessage via BlueBubbles bridge server",
markdown_capable=True,
)
def __init__(self, config: dict[str, Any] | None = None):
self.config = config or {}
self.config.setdefault("server_url", os.getenv("IMESSAGE_SERVER_URL", "http://localhost:1234"))
self.config.setdefault("password", os.getenv("IMESSAGE_PASSWORD", ""))
self._account_id = self.config.get("accountId", self.config.get("account_id", "default"))
super().__init__(self.config)
self._block_streaming = self.config.get("blockStreaming", self.config.get("block_streaming", False))
if self._block_streaming:
self.supports_streaming = False
self.streaming_modes = ["off"]
self.capabilities = self.capabilities.model_copy(
update={
"supports_streaming": False,
"streaming_modes": ["off"],
"block_streaming": True,
}
)
self._status = ChannelStatus.DISCONNECTED
self._accounts_info = list_enabled_accounts(self.config)
self._clients: dict[str, BlueBubblesClient] = {}
self._monitors: dict[str, IMessageMonitor] = {}
account_configs = self.config.get("accounts", {})
if account_configs:
for acct in self._accounts_info:
acct_config = self._build_account_config(acct)
self._clients[acct.account_id] = BlueBubblesClient(acct_config)
self._monitors[acct.account_id] = IMessageMonitor(acct_config, self._clients[acct.account_id])
primary_id = self._accounts_info[0].account_id if self._accounts_info else "default"
else:
self._clients["default"] = BlueBubblesClient(self.config)
self._monitors["default"] = IMessageMonitor(self.config, self._clients["default"])
primary_id = "default"
self._primary_bridge = self._clients[primary_id]
self._self_handle: str | None = None
self._self_handles: dict[str, str] = {}
self._pending_messages: dict[str, str] = {}
self._stream_lock = asyncio.Lock()
self._include_attachments = self.config.get("includeAttachments", self.config.get("include_attachments", False))
self._attachment_roots = self.config.get("attachmentRoots", self.config.get("attachment_roots", []))
self._security = IMessageSecurityPolicy(
self.config,
config_writes=self.config.get("configWrites", self.config.get("config_writes", True)),
)
self._pairing = IMessagePairingManager(
config_writes=self.config.get("configWrites", self.config.get("config_writes", True))
)
self._echo_cache = EchoCache()
self._sticker_cache = StickerCache()
self._last_inbound: dict[str, tuple[float, str]] = {}
self._reflection_guard = ReflectionGuard()
self._loop_limiter = LoopRateLimiter(
max_loops=self.config.get("loopRateLimit", self.config.get("loop_rate_limit", 5)),
window_s=self.config.get("loopRateWindowS", self.config.get("loop_rate_window_s", 30.0)),
cooldown_s=self.config.get("loopCooldownS", self.config.get("loop_cooldown_s", 60.0)),
)
self._merge_window_s = self.config.get("mergeWindowMs", 1000) / 1000.0
self._merge_buffers: dict[str, list[ChannelMessage]] = {}
self._merge_tasks: dict[str, asyncio.Task] = {}
def _build_account_config(self, acct) -> dict[str, Any]:
return {
"server_url": acct.server_url,
"password": acct.password,
"http_timeout_s": self.config.get("http_timeout_s", self.config.get("httpTimeoutS", 30.0)),
"max_retries": self.config.get("max_retries", 3),
"probeTimeoutMs": self.config.get("probeTimeoutMs", self.config.get("probe_timeout_ms", 10000)),
"ws_reconnect_initial_delay": self.config.get("ws_reconnect_initial_delay", 5.0),
"ws_reconnect_max_delay": self.config.get("ws_reconnect_max_delay", 60.0),
}
def _get_client(self, chat_guid: str = "") -> BlueBubblesClient:
return self._primary_bridge
def _get_monitor(self, account_id: str = "") -> IMessageMonitor:
if account_id and account_id in self._monitors:
return self._monitors[account_id]
if len(self._monitors) == 1:
return next(iter(self._monitors.values()))
return next(iter(self._monitors.values()))
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[iMessage] Starting channel '{self.config.get('name', self.channel_id)}'")
try:
server_info = await self._primary_bridge.probe_server()
logger.info(f"[iMessage] BlueBubbles server v{server_info.get('version')}")
except Exception as e:
raise ChannelAuthenticationError() from e
handle_info = await self._primary_bridge.get_handle()
if not handle_info.get("connected"):
raise ChannelAuthenticationError()
self._self_handle = handle_info.get("handle", "")
logger.info(f"[iMessage] Connected as {self._self_handle}")
await self._pairing.load_allowlist()
await self._pairing.load_pending()
await self._security.load_allowlist()
logger.info(f"[iMessage/Pairing] Loaded {len(self._pairing.get_paired_handles())} paired handles")
logger.info(
f"[iMessage/Security] Loaded {len(self._security.allow_list)} allowlist entries, "
f"{len(self._security.group_allow_list)} group allowlist entries"
)
for account_id, monitor in self._monitors.items():
monitor.on_message(self._handle_ws_event)
await monitor.start()
logger.info(f"[iMessage] Monitor started for account '{account_id}'")
self._status = ChannelStatus.CONNECTED
logger.info("[iMessage] Channel started")
async def pre_connect(self) -> dict:
try:
server_info = await self._primary_bridge.probe_server()
handle_info = await self._primary_bridge.get_handle()
return {
"status": "ok",
"imessage_connected": handle_info.get("connected", False),
"handle": handle_info.get("handle", ""),
"server_version": server_info.get("version", "unknown"),
"bridge_os_version": server_info.get("os_version", "unknown"),
"probe_server": server_info,
"probe_handle": handle_info,
"configured": bool(self.config.get("server_url") and self.config.get("password")),
"enabled": True,
}
except Exception as e:
return {"status": "error", "message": str(e), "configured": False, "enabled": False}
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[iMessage] Stopping channel '{self.config.get('name', self.channel_id)}'")
for monitor in self._monitors.values():
await monitor.stop()
for client in self._clients.values():
await client.close()
self._status = ChannelStatus.DISCONNECTED
self._pending_messages.clear()
async def send(self, response: ChannelResponse) -> DeliveryResult:
chat_guid = response.identity.channel_chat_id
reply_to = response.reply_to_message_id
mentions = response.metadata.get("mentions")
disable_notification = response.metadata.get("disable_notification", False)
content = sanitize_imessage_text(convert_markdown_tables(response.content))
content_len = len(content)
if content_len <= self.text_chunk_limit:
result = await self._get_client().send_text_message(
chat_guid=chat_guid,
content=content,
reply_to=reply_to,
is_html=self.supports_markdown,
mentions=mentions,
disable_notification=disable_notification,
)
else:
chunks = _chunk_text(content, self.text_chunk_limit)
last_result = DeliveryResult(success=True)
for i, chunk in enumerate(chunks):
chunk_reply_to = reply_to if i == 0 else None
chunk_result = await self._get_client().send_text_message(
chat_guid=chat_guid,
content=chunk,
reply_to=chunk_reply_to,
is_html=self.supports_markdown,
)
if not chunk_result.success:
if i == 0:
return chunk_result
last_result = chunk_result
else:
last_result = chunk_result
result = last_result
if result.success and result.message_id:
self._pending_messages[result.message_id] = content
self._echo_cache.record_sent(result.message_id, content, chat_guid)
return result
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
suffix_map = {"image": ".jpg", "video": ".mp4", "audio": ".caf", "file": ""}
suffix = suffix_map.get(media_type, "")
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
f.write(data if isinstance(data, bytes) else data.encode() if isinstance(data, str) else b"")
tmp_path = f.name
try:
result = await self._get_client().send_attachment(
chat_guid=chat_id,
file_path=tmp_path,
)
return result
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
result = await self._get_client().edit_message(msg_id, content)
if result.success:
self._pending_messages[msg_id] = content
return result
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._get_client().delete_message(chat_id, msg_id)
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
tapback_name = emoji_to_tapback(emoji) or emoji
tapback = resolve_tapback_value(tapback_name)
if tapback is None:
return DeliveryResult(success=False, error=f"Unsupported tapback emoji: {emoji}")
return await self._get_client().send_reaction(
chat_guid=chat_id,
message_guid=msg_id,
tapback=tapback,
)
async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
tapback_name = emoji_to_tapback(emoji) or emoji
tapback = resolve_tapback_value(tapback_name)
if tapback is None:
return DeliveryResult(success=False, error=f"Unsupported tapback emoji: {emoji}")
return await self._get_client().send_reaction(
chat_guid=chat_id,
message_guid=msg_id,
tapback=-tapback if tapback else 0,
)
async def send_sticker(self, chat_guid: str, sticker_id: str) -> DeliveryResult:
sticker_entry = self._sticker_cache.get(sticker_id)
if not sticker_entry:
return DeliveryResult(success=False, error=f"Sticker not found: {sticker_id}")
sticker_url = sticker_entry.get("url", "")
if not sticker_url:
return DeliveryResult(success=False, error=f"Sticker URL missing: {sticker_id}")
return await self._get_client().send_text_message(
chat_guid=chat_guid,
content=sticker_url,
)
async def send_typing_indicator(self, chat_id: str, display: bool = True) -> DeliveryResult:
return await self._get_client().send_typing_indicator(chat_id, display)
async def send_read_receipt(self, chat_id: str) -> DeliveryResult:
return await self._get_client().send_read_receipt(chat_id)
async def unsend_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._get_client().unsend_message(msg_id)
async def list_chats(self) -> list[dict[str, Any]]:
return await self._get_client().get_chats()
async def get_chat_history(self, chat_guid: str, limit: int = 50) -> list[dict[str, Any]]:
return await self._get_client().get_chat_messages(chat_guid, limit)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
async with self._stream_lock:
if finished:
self._pending_messages.pop(msg_id, None)
return await self._get_client().edit_message(msg_id, chunk)
current = self._pending_messages.get(msg_id, "")
accumulated = current + chunk
result = await self._get_client().edit_message(msg_id, accumulated)
if result.success:
self._pending_messages[msg_id] = accumulated
return result
async def receive(self) -> AsyncIterator[ChannelMessage]:
return
yield # type: ignore[misc]
async def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
data = raw.get("data", raw)
event_type = raw.get("type", "new-message")
chat_guid = data.get("chatGuid", "unknown")
chat_type = resolve_chat_type(chat_guid)
if event_type == "typing-indicator":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("senderGuid", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.TYPING,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "typing", "display": data.get("display", False)},
)
if event_type == "chat-read-receipt":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("senderGuid", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.READ_RECEIPT,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "read_receipt"},
)
if event_type == "chat-name-change":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="system",
channel_chat_id=chat_guid,
),
event_type=EventType.MESSAGE_UPDATED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "chat_name_change", "new_name": data.get("newName", "")},
)
if event_type == "message-deleted":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="system",
channel_chat_id=chat_guid,
),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "message_deleted", "deleted_guid": data.get("guid", "")},
)
if event_type == "participant-added":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("participant", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.MEMBER_JOINED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "member_joined", "participant": data.get("participant", "")},
)
if event_type == "participant-removed":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("participant", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.MEMBER_LEFT,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "member_left", "participant": data.get("participant", "")},
)
msg_guid = data.get("guid")
sender = data.get("sender", data.get("handle", {}).get("id", "unknown"))
is_from_me = data.get("isFromMe", False)
if is_from_me and not data.get("isTapback"):
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if self._echo_cache.is_self_chat_echo(sender, self._self_handle or "", chat_guid):
logger.debug(f"[iMessage] Self-chat echo filtered: {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if data.get("isTapback"):
tapback_value = data.get("tapback", -1)
content = describe_tapback(tapback_value)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content=content,
metadata={
"event": "tapback",
"tapback_value": tapback_value,
"sender_handle": sender,
},
)
content, msg_type, attachments = self._extract_content(data, include_attachments=self._include_attachments)
mentions = self._parse_mentions(data)
reply_context = self._parse_reply_context(data)
sender_name = await resolve_contact(self._get_client(), sender)
envelope = format_inbound_envelope(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
sender_name=sender_name or "",
chat_name=chat_guid,
reply_context=reply_context,
)
if reply_context:
reply_tag = format_reply_context(
reply_to_message_id=reply_context.get("reply_to_message_id"),
reply_to_text=reply_context.get("reply_to_text"),
)
if reply_tag:
content = reply_tag + content
if content and content.startswith("/"):
cmd = parse_command(content)
if cmd:
authorized = is_authorized_for_commands(sender, self._security.allow_list)
if authorized:
result = await self._handle_control_command(cmd, chat_guid, sender)
if result:
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
else:
logger.info(f"[iMessage] Unauthorized command attempt from {sender}: {content[:50]}")
if self._reflection_guard.is_reflection(content):
logger.debug(f"[iMessage] Reflection detected for {sender} in {chat_guid}")
self._reflection_guard.mark_blocked()
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
loop_key = f"{chat_guid}:{sender}"
if self._loop_limiter.check(loop_key, content):
logger.warning(f"[iMessage] Loop rate limit triggered for {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
debounce_key = f"{chat_guid}:{sender}:{content[:200]}"
if self._check_debounce(debounce_key):
logger.debug(f"[iMessage] Debounce triggered for {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if msg_type == MessageType.TEXT and self._echo_cache.is_echo(
message_id=msg_guid, text=content, chat_guid=chat_guid
):
logger.debug(f"[iMessage] Echo detected for msg_id={msg_guid} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if chat_type == ChatType.DIRECT:
access = self._security.check_dm_access(sender)
if not access.allowed:
logger.info(f"[iMessage] DM blocked: sender={sender}, reason={access.reject_reason}")
if access.reply:
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=access.reply,
)
)
if self._security.dm_policy == "pairing" and access.reject_reason == "not_in_dm_allowlist":
pairing_code = self._pairing.request_pairing(
sender_handle=sender,
channel_user_id=self._clean_handle(sender),
chat_guid=chat_guid,
)
if pairing_code:
reply_msg = (
f"Welcome! To chat with this bot, please provide this pairing code "
f"to the bot administrator: {pairing_code}"
)
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=reply_msg,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if not self._pairing.is_approved(self._clean_handle(sender)):
if self._security.dm_policy == "pairing":
pairing_code = self._pairing.request_pairing(
sender_handle=sender,
channel_user_id=self._clean_handle(sender),
chat_guid=chat_guid,
)
if pairing_code:
reply_msg = (
f"Welcome! To chat with this bot, please provide this pairing code "
f"to the bot administrator: {pairing_code}"
)
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=reply_msg,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
elif chat_type == ChatType.GROUP:
access = self._security.check_group_access(chat_guid)
if not access.allowed:
logger.info(f"[iMessage] Group blocked: chat={chat_guid}, reason={access.reject_reason}")
if access.reply:
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=access.reply,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
mention_check = self._security.check_mention_required(chat_guid, mentions, self._self_handle or "")
if not mention_check.allowed:
logger.debug(f"[iMessage] Mention required but bot not mentioned in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=self._map_event_type(event_type),
message_type=msg_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
timestamp=self._parse_timestamp(data.get("dateCreated")),
metadata={
"sender_handle": sender,
"item_type": data.get("itemType"),
"subject": data.get("subject", ""),
"country": data.get("country", ""),
"is_from_me": is_from_me,
"chat_type": chat_type.value if isinstance(chat_type, ChatType) else chat_type,
**({"reply_context": reply_context} if reply_context else {}),
**({"forwarded": parse_forwarded(data)} if parse_forwarded(data) else {}),
},
)
def _extract_content(
self, data: dict, include_attachments: bool = False
) -> tuple[str, MessageType, list[Attachment]]:
text = data.get("text", "")
attachments_raw = data.get("attachments", []) if include_attachments else []
attachments = []
for att in attachments_raw:
att_path = att.get("url") or att.get("path") or ""
if self._attachment_roots and att_path and not validate_attachment_path(att_path, self._attachment_roots):
logger.debug(f"[iMessage] Attachment rejected: {att_path} not in allowed roots")
continue
att_type = self._map_attachment_type(att.get("mimeType", ""))
attachments.append(
Attachment(
type=att_type.value if isinstance(att_type, MessageType) else att_type,
url=att.get("url"),
filename=att.get("transferName"),
mime_type=att.get("mimeType"),
size_bytes=att.get("totalBytes", 0),
)
)
if attachments:
msg_type = attachments[0].type if attachments else MessageType.FILE
msg_type_enum = MessageType(msg_type) if isinstance(msg_type, str) else msg_type
media_type = _msg_type_to_media_str(msg_type_enum)
content = text or media_placeholder(media_type)
return content, msg_type_enum, attachments
return text, MessageType.TEXT, attachments
@staticmethod
def _map_attachment_type(mime_type: str) -> MessageType:
if mime_type.startswith("image/"):
return MessageType.IMAGE
if mime_type.startswith("video/"):
return MessageType.VIDEO
if mime_type.startswith("audio/"):
return MessageType.AUDIO
return MessageType.FILE
@staticmethod
def _parse_mentions(data: dict) -> MentionsInfo | None:
raw_mentions = data.get("mentions")
if not raw_mentions:
return None
user_ids: list[str] = []
for m in raw_mentions:
if isinstance(m, dict):
uid = m.get("mention") or m.get("id") or ""
if uid:
user_ids.append(uid)
elif isinstance(m, str):
user_ids.append(m)
return MentionsInfo(mentioned_user_ids=user_ids) if user_ids else None
@staticmethod
def _parse_reply_context(data: dict) -> dict[str, Any] | None:
reply_to_guid = data.get("replyToGuid")
thread_originator_guid = data.get("threadOriginatorGuid")
thread_originator_text = data.get("threadOriginatorText")
if not reply_to_guid and not thread_originator_guid:
return None
context: dict[str, Any] = {}
if reply_to_guid:
context["reply_to_message_id"] = reply_to_guid
if thread_originator_guid:
context["thread_originator_id"] = thread_originator_guid
if thread_originator_text:
context["reply_to_text"] = thread_originator_text[:500]
return context if context else None
@staticmethod
def _clean_handle(handle: str) -> str:
return handle.strip().replace(" ", "").lstrip("+")
@staticmethod
def _map_event_type(raw_type: str) -> EventType:
mapping = {
"new-message": EventType.MESSAGE_RECEIVED,
"message-updated": EventType.MESSAGE_UPDATED,
"message-deleted": EventType.MESSAGE_DELETED,
"typing-indicator": EventType.TYPING,
"read-receipt": EventType.READ_RECEIPT,
}
return mapping.get(raw_type, EventType.MESSAGE_RECEIVED)
@staticmethod
def _parse_timestamp(timestamp_ms: int | None) -> dt.datetime:
if timestamp_ms:
return dt.datetime.fromtimestamp(timestamp_ms / 1000, tz=dt.UTC)
return utc_now_naive()
def _make_passthrough_event(
self, chat_guid: str, msg_guid: str | None, sender: str, event_type: str
) -> ChannelMessage:
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=self._map_event_type(event_type),
message_type=MessageType.TEXT,
chat_type=resolve_chat_type(chat_guid),
content="(self-sent message)",
metadata={"sender_handle": sender, "is_from_me": True},
)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
result: dict[str, Any] = {
"chatGuid": response.identity.channel_chat_id,
"content": response.content,
}
if response.reply_to_message_id:
result["replyTo"] = response.reply_to_message_id
return result
async def health_check(self) -> HealthStatus:
try:
server_info = await self._get_client().probe_server()
handle_info = await self._get_client().get_handle()
return HealthStatus(
status="healthy" if handle_info.get("connected") else "degraded",
metadata={
"handle": handle_info.get("handle"),
"connected": handle_info.get("connected"),
"bridge_version": server_info.get("version"),
"adapter_status": self._status.value,
},
last_connected_at=utc_now_naive() if self._status == ChannelStatus.CONNECTED else None,
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
return await resolve_contact(self._get_client(), channel_user_id)
async def download_media(self, file_id: str) -> bytes:
return await self._get_client().download_attachment(file_id)
def thread_key_for(self, identity: ChannelIdentity) -> str:
return resolve_thread_key(identity)
def _check_debounce(self, key: str) -> bool:
now = time.monotonic()
last = self._last_inbound.get(key)
if last is not None and now - last[0] < DEBOUNCE_WINDOW_S:
return True
self._last_inbound[key] = (now, key)
self._cleanup_debounce()
return False
def _cleanup_debounce(self) -> None:
now = time.monotonic()
expired = [k for k, (ts, _) in self._last_inbound.items() if now - ts > DEBOUNCE_WINDOW_S * 3]
for k in expired:
del self._last_inbound[k]
async def _handle_control_command(self, cmd: dict[str, Any], chat_guid: str, sender: str) -> bool:
action = cmd.get("action", "")
args = cmd.get("args", [])
if action == "status":
snapshot = self.get_channel_snapshot()
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Status: {snapshot['status']}\nHandle: {snapshot.get('self_handle', 'N/A')}",
)
return True
if action == "health":
async with asyncio.timeout(10):
health = await self.health_check()
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Health: {health.status}\n{health.metadata}",
)
return True
if action == "pairing":
pending = self.list_pending_pairings()
if not pending:
await self._get_client().send_text_message(chat_guid=chat_guid, content="No pending pairing requests.")
else:
lines = ["Pending pairing requests:"]
for p in pending:
lines.append(f" Code: {p['code']}{p['sender_handle']}")
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="\n".join(lines),
)
return True
if action == "approve" and args:
ok = self.approve_pairing(args[0])
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Pairing {'approved' if ok else 'failed — invalid or expired code'}.",
)
return True
if action == "reject" and args:
ok = self.reject_pairing(args[0])
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Pairing {'rejected' if ok else 'failed — invalid or expired code'}.",
)
return True
if action == "help":
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="Available commands:\n"
"/status — Show adapter status\n"
"/health — Health check\n"
"/pairing — List pending pairing requests\n"
"/approve <code> — Approve a pairing\n"
"/reject <code> — Reject a pairing\n"
"/help — Show this help",
)
return True
return False
def approve_pairing(self, code: str) -> bool:
return self._pairing.approve(code)
def reject_pairing(self, code: str) -> bool:
return self._pairing.reject(code)
def list_pending_pairings(self) -> list[dict[str, Any]]:
return self._pairing.list_pending()
def is_paired(self, channel_user_id: str) -> bool:
return self._pairing.is_paired(channel_user_id)
def get_security_warnings(self) -> list[str]:
return self._security.collect_warnings()
async def run_doctor(self) -> dict[str, Any]:
pairing_info = {
"pending": len(self.list_pending_pairings()),
"paired": len(self._pairing.get_paired_handles()),
}
return await run_doctor_diagnosis(
bridge_client=self._primary_bridge,
security_warnings=self.get_security_warnings(),
adapter_status=self._status.value,
self_handle=self._self_handle,
pairing_info=pairing_info,
)
async def _handle_ws_event(self, raw_payload: dict[str, Any]) -> None:
if self._message_handler is None:
return
channel_msg = await self.normalize_inbound(raw_payload)
if channel_msg.content == "(self-sent message)":
return
await self._maybe_merge_and_dispatch(channel_msg)
def get_channel_snapshot(self) -> dict[str, Any]:
"""返回渠道状态快照"""
return {
"channel_id": self.channel_id,
"account_id": self._account_id,
"status": self._status.value,
"configured": bool(self.config.get("server_url") and self.config.get("password")),
"enabled": True,
"self_handle": self._self_handle,
"streaming_modes": self.streaming_modes,
"blocked_reflections": self._reflection_guard.blocked_count,
"active_stream_id": (list(self._pending_messages.keys())[0] if self._pending_messages else None),
}
async def _maybe_merge_and_dispatch(self, msg: ChannelMessage) -> None:
merge_key = f"{msg.identity.channel_chat_id}:{msg.identity.channel_user_id}"
if merge_key not in self._merge_buffers:
self._merge_buffers[merge_key] = []
self._merge_buffers[merge_key].append(msg)
if merge_key in self._merge_tasks and not self._merge_tasks[merge_key].done():
return
self._merge_tasks[merge_key] = asyncio.create_task(self._flush_merged(merge_key, self._merge_window_s))
async def _flush_merged(self, merge_key: str, delay: float) -> None:
await asyncio.sleep(delay)
msgs = self._merge_buffers.pop(merge_key, [])
self._merge_tasks.pop(merge_key, None)
if not msgs:
return
if len(msgs) == 1:
await self._message_handler(msgs[0])
return
merged_text = "\n".join(msg.content for msg in msgs if msg.content)
merged_msg = ChannelMessage(
identity=msgs[0].identity,
event_type=msgs[0].event_type,
message_type=msgs[0].message_type,
chat_type=msgs[0].chat_type,
content=merged_text,
attachments=[att for msg in msgs for att in msg.attachments],
mentions=msgs[-1].mentions,
timestamp=msgs[-1].timestamp,
metadata={"merged_messages": len(msgs), **msgs[0].metadata},
)
await self._message_handler(merged_msg)
def _chunk_text(text: str, limit: int) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
pos = 0
while pos < len(text):
end = min(pos + limit, len(text))
if end < len(text):
split_at = text.rfind("\n", pos, end)
if split_at == -1 or split_at <= pos:
split_at = text.rfind(". ", pos, end)
if split_at == -1 or split_at <= pos:
split_at = text.rfind(" ", pos, end)
if split_at == -1 or split_at <= pos:
split_at = end
else:
split_at += 1
else:
split_at = end
chunks.append(text[pos:split_at].strip())
pos = split_at
return chunks
def _msg_type_to_media_str(msg_type) -> str:
mapping = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
return mapping.get(msg_type, "file")