ForcePilot/backend/package/yuxi/channel/extensions/imessage/outbound.py
Kris 4d52f634fe feat(imessage): 新增iMessage渠道插件完整实现
该提交新增了基于BlueBubbles的iMessage渠道插件,支持单聊和群组消息,包含文本、图片、语音、文件和视频消息收发,支持消息编辑、撤回、回复、 reactions和输入状态提示,同时实现了账号配置、安全校验、配对授权、消息格式化与分片等完整功能。
2026-05-21 10:50:15 +08:00

681 lines
25 KiB
Python

from __future__ import annotations
import base64
import logging
import os
import httpx
from yuxi.channel.extensions.imessage.errors import IMessageSendError, classify_http_error
from yuxi.channel.extensions.imessage.format import IMessageFormatAdapter
from yuxi.channel.protocols import OutboundDeliveryMode, OutboundDeliveryCapabilities, OutboundPresentationCapabilities
logger = logging.getLogger(__name__)
DEFAULT_CHUNK_LIMIT = 4000
class IMessageOutboundAdapter:
delivery_mode = OutboundDeliveryMode.DIRECT
chunker_mode = "newline"
text_chunk_limit = DEFAULT_CHUNK_LIMIT
poll_max_options = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = False
presentation_capabilities = OutboundPresentationCapabilities(
supported=False,
buttons=False,
selects=False,
context=False,
divider=False,
)
delivery_capabilities = OutboundDeliveryCapabilities(
pin=False,
durable_final_text=True,
durable_final_media=False,
)
def __init__(self, echo_cache=None):
self._format = IMessageFormatAdapter()
self._echo_cache = echo_cache
self._http: httpx.AsyncClient | None = None
async def _get_http(self) -> httpx.AsyncClient:
if self._http is None:
self._http = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return self._http
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return self._format.chunk_text(text, limit)
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return True
def normalize_payload(self, payload: object, config: dict, account_id: str | None = None) -> object | None:
return payload
def resolve_effective_text_chunk_limit(
self, config: dict, account_id: str | None = None, fallback_limit: int | None = None
) -> int | None:
return self.text_chunk_limit
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,
_account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
formatted = self._format.format_for_imessage(content)
if self._echo_cache:
self._echo_cache.remember(target_id, formatted)
payload = {
"text": formatted,
"chatGuid": target_id,
}
if reply_to_id:
payload["replyToGuid"] = reply_to_id
try:
resp = await http.post(
f"{server_url}/api/v1/message/text",
json=payload,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send text: {e}") from e
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,
_account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
media_resp = await http.get(media_url)
media_resp.raise_for_status()
media_data = media_resp.content
except Exception as e:
raise IMessageSendError(f"Failed to download media: {e}") from e
media_b64 = base64.b64encode(media_data).decode("utf-8")
content_type = media_resp.headers.get("content-type", media_type)
payload = {
"chatGuid": target_id,
"base64Data": media_b64,
"mimeType": content_type,
}
try:
resp = await http.post(
f"{server_url}/api/v1/message/attachment",
json=payload,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send media: {e}") from e
async def send_reaction(
self,
target_id: str,
message_id: str,
reaction: str,
*,
account_id: str | None = None,
_account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
payload = {
"chatGuid": target_id,
"reaction": reaction,
}
try:
resp = await http.post(
f"{server_url}/api/v1/message/{message_id}/reaction",
json=payload,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send reaction: {e}") from e
async def edit_message(
self,
target_id: str,
message_id: str,
content: str,
*,
thread_id: str | None = None,
account_id: str | None = None,
_account: dict | None = None,
) -> str | None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
formatted = self._format.format_for_imessage(content)
payload = {"text": formatted}
try:
resp = await http.patch(
f"{server_url}/api/v1/message/{message_id}",
json=payload,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return message_id
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to edit message: {e}") from e
async def unsend_message(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
_account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.delete(
f"{server_url}/api/v1/message/{message_id}",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return True
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to unsend message: {e}") from e
async def fetch_chat_history(
self,
chat_guid: str,
*,
limit: int = 50,
before: int | None = None,
after: int | None = None,
account_id: str | None = None,
_account: dict | None = None,
) -> list[dict]:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
params: dict = {"limit": limit}
if before:
params["before"] = before
if after:
params["after"] = after
try:
resp = await http.get(
f"{server_url}/api/v1/chat/{chat_guid}/message",
params=params,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
data = resp.json()
return data.get("data", [])
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to fetch chat history: {e}") from e
async def send_typing_indicator(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/typing",
headers={"Password": password},
)
except httpx.RequestError as e:
logger.warning("Failed to send typing indicator: %s", e)
async def send_read_receipt(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/readreceipt",
headers={"Password": password},
)
except httpx.RequestError as e:
logger.warning("Failed to send read receipt: %s", e)
async def rename_group(
self, chat_guid: str, name: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.patch(
f"{server_url}/api/v1/chat/{chat_guid}/rename",
json={"name": name},
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to rename group: {e}") from e
async def add_participant(
self, chat_guid: str, handle: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/addparticipant",
json={"address": handle},
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to add participant: {e}") from e
async def remove_participant(
self, chat_guid: str, handle: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/removeparticipant",
json={"address": handle},
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to remove participant: {e}") from e
async def leave_group(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/leave",
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to leave group: {e}") from e
async def list_chats(
self, *, account_id: str | None = None, _account: dict | None = None,
) -> list[dict]:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.get(
f"{server_url}/api/v1/chat",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
data = resp.json()
return data.get("data", [])
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to list chats: {e}") from e
async def get_chat(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> dict | None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.get(
f"{server_url}/api/v1/chat/{chat_guid}",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to get chat: {e}") from e
async def send_sms(
self, target_id: str, content: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
formatted = self._format.format_for_imessage(content)
payload = {"text": formatted, "chatGuid": target_id, "method": "sms"}
try:
resp = await http.post(
f"{server_url}/api/v1/message/text",
json=payload,
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send SMS: {e}") from e
async def send_mention(
self, chat_guid: str, message_id: str, mentions: list[str],
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/message/{message_id}/sendmention",
json={"mentions": mentions},
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send mention: {e}") from e
async def send_with_effect(
self, chat_guid: str, message_id: str, effect: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
valid_effects = {"slam", "loud", "gentle", "invisibleink"}
if effect not in valid_effects:
raise ValueError(f"Invalid effect: {effect}. Must be one of {valid_effects}")
try:
resp = await http.post(
f"{server_url}/api/v1/message/{message_id}/sendwitheffect",
json={"effect": effect},
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send effect: {e}") from e
async def send_with_subject(
self, chat_guid: str, message_id: str, subject: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/message/{message_id}/sendwithsubject",
json={"subject": subject},
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to send subject: {e}") from e
async def list_handles(
self, *, account_id: str | None = None, _account: dict | None = None,
) -> list[dict]:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
resp = await http.get(
f"{server_url}/api/v1/handle",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json().get("data", [])
async def get_handle(
self, address: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> dict | None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
resp = await http.get(
f"{server_url}/api/v1/handle/{address}",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
async def check_focus_status(
self, *, account_id: str | None = None, _account: dict | None = None,
) -> dict:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
resp = await http.get(
f"{server_url}/api/v1/focus",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
async def check_facetime_status(
self, *, account_id: str | None = None, _account: dict | None = None,
) -> dict:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
resp = await http.get(
f"{server_url}/api/v1/facetime",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
async def create_chat(
self, handles: list[str],
*, account_id: str | None = None, _account: dict | None = None,
) -> dict | None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/new",
json={"addresses": handles},
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to create chat: {e}") from e
async def delete_chat(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.delete(
f"{server_url}/api/v1/chat/{chat_guid}",
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to delete chat: {e}") from e
async def mark_chat_read(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/markread",
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
logger.warning("Failed to mark chat read: %s", e)
return False
async def mark_chat_unread(
self, chat_guid: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/markunread",
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
logger.warning("Failed to mark chat unread: %s", e)
return False
async def force_notify(
self, message_id: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/message/{message_id}/forcenotify",
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to force notify: {e}") from e
async def set_group_icon(
self, chat_guid: str, icon_path: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> bool:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
try:
resp = await http.post(
f"{server_url}/api/v1/chat/{chat_guid}/seticon",
json={"path": icon_path},
headers={"Password": password},
)
return resp.status_code < 400
except httpx.RequestError as e:
raise IMessageSendError(f"Failed to set group icon: {e}") from e
async def get_digital_touch(
self, message_id: str,
*, account_id: str | None = None, _account: dict | None = None,
) -> dict | None:
http = await self._get_http()
server_url, password = await self._resolve_server(account_id, _account)
resp = await http.get(
f"{server_url}/api/v1/message/{message_id}/digitaltouch",
headers={"Password": password},
)
if resp.status_code >= 400:
raise classify_http_error(resp.status_code, resp.text)
return resp.json()
async def send_card(
self,
target_id: str,
card_content: dict,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> str | None:
return None
async def edit_card(
self,
target_id: str,
message_id: str,
card_content: dict,
*,
thread_id: str | None = None,
account_id: str | None = None,
) -> str | None:
return None
async def send_payload(self, ctx: object) -> object:
return None
async def send_poll(self, ctx: object) -> object:
return None
async def render_presentation(self, payload: object, presentation: object, ctx: object) -> object | None:
return None
async def pin_delivered_message(self, config: dict, target_ref: object, message_id: str, pin: object) -> None:
pass
async def before_deliver_payload(
self, config: dict, target_ref: object, payload: object, hint: object | None = None
) -> None:
pass
async def after_deliver_payload(self, config: dict, target_ref: object, payload: object, results: list) -> None:
pass
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "Target is required"
return True, to
def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool:
return True
async def _resolve_server(self, account_id: str | None = None, _account: dict | None = None) -> tuple[str, str]:
if _account:
server_url = _account.get("server_url", "")
password = _account.get("password", "")
if server_url and password:
return server_url, password
server_url = os.getenv("IMESSAGE_SERVER_URL", "http://localhost:1234")
password = os.getenv("IMESSAGE_SERVER_PASSWORD", "")
return server_url, password