1. 新增poll命令支持创建、关闭、列出投票 2. 新增审计日志记录功能 3. 优化远程附件URL安全校验逻辑 4. 修复表格匹配正则,支持包含竖线的表格分隔行 5. 新增媒体AI处理能力,支持图片描述和音频转录 6. 完善配置校验和错误处理 7. 重构文本发送逻辑,增加重试机制 8. 新增投票投票处理逻辑,支持数字快捷投票
1290 lines
53 KiB
Python
1290 lines
53 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 .audit import AuditLogger
|
|
from .bridge_client import BlueBubblesClient
|
|
from .commands import is_authorized_for_commands, parse_command
|
|
from .config_schema import validate_config
|
|
from .contact_resolver import resolve_contact
|
|
from .echo_cache import EchoCache
|
|
from .formatter import convert_markdown_tables, sanitize_imessage_text
|
|
from .media_ai import describe_image, transcribe_audio
|
|
from .media_security import validate_attachment_path
|
|
from .monitor import IMessageMonitor
|
|
from .normalize import parse_forwarded
|
|
from .pairing import IMessagePairingManager
|
|
from .polls import IMessagePollManager
|
|
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=True,
|
|
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"))
|
|
|
|
try:
|
|
validate_config(self.config)
|
|
except Exception as e:
|
|
raise ValueError(f"iMessage configuration validation failed: {e}") from e
|
|
|
|
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._chat_to_account: dict[str, str] = {}
|
|
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._audit_logger = AuditLogger(log_dir=self.config.get("auditDir", self.config.get("audit_dir", None)))
|
|
self._poll_manager = IMessagePollManager(adapter=self)
|
|
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] = {}
|
|
|
|
media_ai_cfg = self.config.get("mediaAi", self.config.get("media_ai", {}))
|
|
self._media_ai_enabled = media_ai_cfg.get("enabled", False)
|
|
self._media_ai_config = {
|
|
"vision_api_url": media_ai_cfg.get("visionApiUrl", media_ai_cfg.get("vision_api_url", "")),
|
|
"vision_api_key": media_ai_cfg.get("visionApiKey", media_ai_cfg.get("vision_api_key", "")),
|
|
"transcription_api_url": media_ai_cfg.get(
|
|
"transcriptionApiUrl", media_ai_cfg.get("transcription_api_url", "")
|
|
),
|
|
"transcription_api_key": media_ai_cfg.get(
|
|
"transcriptionApiKey", media_ai_cfg.get("transcription_api_key", "")
|
|
),
|
|
}
|
|
|
|
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:
|
|
if len(self._clients) <= 1:
|
|
return self._primary_bridge
|
|
if chat_guid and chat_guid in self._chat_to_account:
|
|
account_id = self._chat_to_account[chat_guid]
|
|
return self._clients.get(account_id, self._primary_bridge)
|
|
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():
|
|
wrapped_handler = self._build_account_handler(account_id)
|
|
monitor.on_message(wrapped_handler)
|
|
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()
|
|
await self._audit_logger.flush()
|
|
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:
|
|
result = await self._send_chunked(
|
|
chat_guid=chat_guid,
|
|
content=content,
|
|
reply_to=reply_to,
|
|
)
|
|
|
|
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_chunked(
|
|
self,
|
|
chat_guid: str,
|
|
content: str,
|
|
reply_to: str | None = None,
|
|
max_retries: int = 2,
|
|
) -> DeliveryResult:
|
|
chunks = _chunk_text(content, self.text_chunk_limit)
|
|
results: list[DeliveryResult] = []
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
chunk_reply_to = reply_to if i == 0 else None
|
|
|
|
chunk_result = None
|
|
for attempt in range(max_retries + 1):
|
|
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 chunk_result.success:
|
|
break
|
|
if attempt < max_retries:
|
|
logger.warning(
|
|
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed "
|
|
f"(attempt {attempt + 1}/{max_retries + 1}): {chunk_result.error}"
|
|
)
|
|
await asyncio.sleep(1 * (attempt + 1))
|
|
|
|
results.append(chunk_result)
|
|
|
|
if not chunk_result.success:
|
|
if i == 0:
|
|
return chunk_result
|
|
logger.error(
|
|
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed after "
|
|
f"{max_retries + 1} attempts: {chunk_result.error}"
|
|
)
|
|
|
|
all_success = all(r.success for r in results)
|
|
if all_success:
|
|
return results[-1]
|
|
|
|
failed_indices = [i + 1 for i, r in enumerate(results) if not r.success]
|
|
return DeliveryResult(
|
|
success=False,
|
|
error=f"Chunks {failed_indices} failed to send",
|
|
metadata={"failed_chunks": failed_indices, "total_chunks": len(chunks)},
|
|
)
|
|
|
|
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:
|
|
# 注意: BlueBubbles 当前不提供原生 sticker API。
|
|
# Sticker 以 URL 文本消息形式发送,而非使用 iMessage 原生贴纸协议。
|
|
# 待 BlueBubbles 支持原生 sticker API 后可升级此实现。
|
|
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)
|
|
|
|
if self._media_ai_enabled and attachments:
|
|
ai_descriptions = await self._run_media_ai(attachments)
|
|
if ai_descriptions:
|
|
content = content + "\n" + "\n".join(ai_descriptions)
|
|
|
|
reply_context = self._parse_reply_context(data)
|
|
|
|
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:
|
|
self._audit_logger.record_dm_access(
|
|
sender=sender,
|
|
allowed=False,
|
|
reason=access.reject_reason or "unknown",
|
|
)
|
|
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)
|
|
|
|
self._audit_logger.record_dm_access(sender=sender, allowed=True)
|
|
|
|
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:
|
|
self._audit_logger.record_group_access(
|
|
chat_guid=chat_guid,
|
|
allowed=False,
|
|
reason=access.reject_reason or "unknown",
|
|
)
|
|
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)
|
|
|
|
self._audit_logger.record_group_access(chat_guid=chat_guid, allowed=True)
|
|
|
|
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)
|
|
|
|
if content and content.strip().isdigit():
|
|
await self._handle_poll_vote(chat_guid, sender, content.strip())
|
|
|
|
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 == "poll":
|
|
sub_action = args[0].lower() if args else ""
|
|
if sub_action == "create":
|
|
remaining = " ".join(args[1:]) if len(args) > 1 else ""
|
|
parts = _parse_quoted_args(remaining)
|
|
if len(parts) >= 3:
|
|
question = parts[0]
|
|
option_labels = parts[1:]
|
|
poll = await self._poll_manager.create_poll(
|
|
chat_guid=chat_guid,
|
|
question=question,
|
|
option_labels=option_labels,
|
|
expires_in_s=300.0,
|
|
)
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content=f"Poll created! ID: {poll.poll_id}",
|
|
)
|
|
else:
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content='Usage: /poll create "Question" "Option1" "Option2" ...',
|
|
)
|
|
elif sub_action == "close":
|
|
poll_id = args[1] if len(args) > 1 else ""
|
|
if poll_id:
|
|
result = await self._poll_manager.close_poll(poll_id)
|
|
if result:
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="Poll closed.",
|
|
)
|
|
else:
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="Usage: /poll close <poll_id>",
|
|
)
|
|
elif sub_action == "list":
|
|
active = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
|
|
if active:
|
|
lines = ["Active polls:"]
|
|
for p in active:
|
|
lines.append(f" {p.poll_id}: {p.question}")
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="\n".join(lines),
|
|
)
|
|
else:
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="No active polls in this chat.",
|
|
)
|
|
else:
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="/poll create|close|list",
|
|
)
|
|
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"
|
|
'/poll create "Q" "A" "B" — Create a poll\n'
|
|
"/poll close <id> — Close a poll\n"
|
|
"/poll list — List active polls\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)
|
|
|
|
async def create_poll(
|
|
self,
|
|
chat_id: str,
|
|
question: str,
|
|
options: list[str],
|
|
duration_minutes: int = 0,
|
|
) -> dict[str, Any]:
|
|
poll = await self._poll_manager.create_poll(
|
|
chat_guid=chat_id,
|
|
question=question,
|
|
option_labels=options,
|
|
expires_in_s=duration_minutes * 60 if duration_minutes > 0 else 300.0,
|
|
)
|
|
return {
|
|
"poll_id": poll.poll_id,
|
|
"chat_guid": poll.chat_guid,
|
|
"question": poll.question,
|
|
"options": [{"index": o.index + 1, "label": o.label} for o in poll.options],
|
|
}
|
|
|
|
async def close_poll(self, chat_id: str, poll_id: str) -> dict[str, Any]:
|
|
result = await self._poll_manager.close_poll(poll_id)
|
|
if result is None:
|
|
return {"error": f"Poll not found: {poll_id}"}
|
|
return result
|
|
|
|
async def vote_poll(self, poll_id: str, user_id: str, option_index: int) -> dict[str, Any]:
|
|
result = await self._poll_manager.vote(poll_id, user_id, option_index)
|
|
if result is None:
|
|
return {"error": f"Poll not found: {poll_id}"}
|
|
return result
|
|
|
|
def get_poll(self, poll_id: str) -> dict[str, Any] | None:
|
|
poll = self._poll_manager.get_poll(poll_id)
|
|
if poll is None:
|
|
return None
|
|
return {
|
|
"poll_id": poll.poll_id,
|
|
"chat_guid": poll.chat_guid,
|
|
"question": poll.question,
|
|
"closed": poll.closed,
|
|
"options": [{"index": o.index + 1, "label": o.label, "vote_count": o.vote_count} for o in poll.options],
|
|
}
|
|
|
|
async def _handle_poll_vote(self, chat_guid: str, sender: str, digit_str: str) -> None:
|
|
active_polls = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
|
|
if not active_polls:
|
|
return
|
|
|
|
option_index = int(digit_str) - 1
|
|
poll = active_polls[-1]
|
|
|
|
result = await self._poll_manager.vote(poll.poll_id, sender, option_index)
|
|
if result and result.get("success"):
|
|
logger.info(f"[iMessage/Poll] Vote recorded: user={sender}, poll={poll.poll_id}, option={option_index + 1}")
|
|
elif result and result.get("poll_closed"):
|
|
await self._get_client().send_text_message(
|
|
chat_guid=chat_guid,
|
|
content="This poll is already closed.",
|
|
)
|
|
|
|
async def _run_media_ai(self, attachments: list[Attachment]) -> list[str]:
|
|
descriptions: list[str] = []
|
|
cfg = self._media_ai_config
|
|
|
|
for att in attachments:
|
|
try:
|
|
async with asyncio.timeout(10):
|
|
if att.type in (MessageType.IMAGE.value, "image"):
|
|
if att.url and cfg["vision_api_url"]:
|
|
image_bytes = await self._get_client().download_attachment(att.url)
|
|
desc = await describe_image(
|
|
image_bytes,
|
|
vision_api_url=cfg["vision_api_url"],
|
|
vision_api_key=cfg["vision_api_key"],
|
|
)
|
|
if desc:
|
|
descriptions.append(f"[图片描述: {desc}]")
|
|
elif att.type in (MessageType.AUDIO.value, "audio"):
|
|
if att.url and cfg["transcription_api_url"]:
|
|
audio_bytes = await self._get_client().download_attachment(att.url)
|
|
transcript = await transcribe_audio(
|
|
audio_bytes,
|
|
transcription_api_url=cfg["transcription_api_url"],
|
|
transcription_api_key=cfg["transcription_api_key"],
|
|
)
|
|
if transcript:
|
|
descriptions.append(f"[音频转录: {transcript}]")
|
|
except TimeoutError:
|
|
logger.warning(f"[iMessage/AI] Media AI timed out for {att.url}")
|
|
except Exception as e:
|
|
logger.warning(f"[iMessage/AI] Media AI failed for {att.url}: {e}")
|
|
|
|
return descriptions
|
|
|
|
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,
|
|
)
|
|
|
|
def _build_account_handler(self, account_id: str):
|
|
async def handler(raw_payload: dict[str, Any]) -> None:
|
|
data = raw_payload.get("data", raw_payload)
|
|
chat_guid = data.get("chatGuid", "")
|
|
if chat_guid:
|
|
self._chat_to_account[chat_guid] = account_id
|
|
await self._handle_ws_event(raw_payload)
|
|
|
|
return handler
|
|
|
|
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")
|
|
|
|
|
|
def _parse_quoted_args(text: str) -> list[str]:
|
|
import shlex
|
|
|
|
try:
|
|
return shlex.split(text)
|
|
except ValueError:
|
|
return text.split()
|