ForcePilot/backend/package/yuxi/channel/extensions/bluebubbles/outbound.py
Kris b018e3eda4 feat(bluebubbles): 新增BlueBubbles(iMessage)渠道插件完整实现
该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力:
1.  支持私聊和群聊会话管理,自动区分会话类型
2.  完整的消息收发支持,包括文本、图片、语音、文件、视频消息
3.  支持消息反应、已读回执、消息编辑与撤回
4.  内置去重、防抖处理机制
5.  支持Webhook和WebSocket两种事件接收方式
6.  完善的权限与安全校验机制
7.  历史消息同步与抓包功能
8.  TTS语音合成与发送支持
9.  群组管理能力,包括重命名、修改头像、增减成员等
2026-05-21 10:40:33 +08:00

375 lines
11 KiB
Python

import json
import re
import uuid
def strip_markdown(text: str) -> str:
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"`(.+?)`", r"\1", text)
text = re.sub(r"~~(.+?)~~", r"\1", text)
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
if not text.strip():
raise ValueError("Message body is empty after stripping markdown")
return text
async def send_text_message(
client,
chat_guid: str,
text: str,
*,
reply_to_id: str | None = None,
private_api_available: bool = False,
effect_id: str | None = None,
subject: str | None = None,
mentions: list[str] | None = None,
) -> dict:
cleaned = strip_markdown(text)
temp_guid = str(uuid.uuid4())
payload = {
"chatGuid": chat_guid,
"message": cleaned,
"tempGuid": temp_guid,
"method": "private-api" if private_api_available else "apple-script",
}
if reply_to_id:
payload["replyToGuid"] = reply_to_id
if effect_id:
payload["effectId"] = effect_id
if subject:
payload["subject"] = subject
if mentions and private_api_available:
payload["mentions"] = mentions
resp = await client.post("/api/v1/message/text", json=payload)
resp.raise_for_status()
return resp.json()
async def send_attachment(
client,
chat_guid: str,
file_path: str,
*,
mime_type: str | None = None,
as_voice: bool = False,
reply_to_id: str | None = None,
private_api_available: bool = False,
) -> dict:
import os
filename = os.path.basename(file_path)
files = {"attachment": (filename, open(file_path, "rb"))}
data = {
"chatGuid": chat_guid,
"method": "private-api" if private_api_available else "apple-script",
}
if reply_to_id:
data["replyToGuid"] = reply_to_id
if as_voice:
data["asVoice"] = "true"
try:
resp = await client.post(f"/api/v1/chat/{chat_guid}/message", data=data, files=files)
resp.raise_for_status()
return resp.json()
finally:
for f in files.values():
if hasattr(f[1], "close"):
f[1].close()
async def send_reaction(
client,
chat_guid: str,
message_guid: str,
reaction: str,
) -> dict:
payload = {"chatGuid": chat_guid, "reaction": reaction}
resp = await client.post(f"/api/v1/message/{message_guid}/reaction", json=payload)
resp.raise_for_status()
return resp.json()
async def edit_message(
client,
chat_guid: str,
message_guid: str,
edited_text: str,
*,
part_index: int = 0,
) -> dict:
trimmed = strip_markdown(edited_text)
payload = {
"chatGuid": chat_guid,
"editedMessage": trimmed,
"backwardsCompatibilityMessage": f"Edited to: {trimmed}",
"partIndex": part_index,
}
resp = await client.post(f"/api/v1/message/{message_guid}/edit", json=payload)
resp.raise_for_status()
return resp.json()
async def unsend_message(
client,
chat_guid: str,
message_guid: str,
*,
part_index: int = 0,
) -> dict:
payload = {"chatGuid": chat_guid, "partIndex": part_index}
resp = await client.post(f"/api/v1/message/{message_guid}/unsend", json=payload)
resp.raise_for_status()
return resp.json()
async def mark_chat_read(client, chat_guid: str) -> dict:
resp = await client.post(f"/api/v1/chat/{chat_guid}/read")
resp.raise_for_status()
return resp.json()
async def mark_chat_unread(client, chat_guid: str) -> dict:
resp = await client.post(f"/api/v1/chat/{chat_guid}/unread")
resp.raise_for_status()
return resp.json()
async def send_typing_indicator(client, chat_guid: str) -> dict:
resp = await client.post(f"/api/v1/chat/{chat_guid}/typing")
resp.raise_for_status()
return resp.json()
async def stop_typing_indicator(client, chat_guid: str) -> dict:
resp = await client.delete(f"/api/v1/chat/{chat_guid}/typing")
resp.raise_for_status()
return resp.json()
async def rename_group(client, chat_guid: str, new_name: str) -> dict:
payload = {"name": new_name}
resp = await client.put(f"/api/v1/chat/{chat_guid}", json=payload)
resp.raise_for_status()
return resp.json()
async def set_group_icon(client, chat_guid: str, icon_path: str) -> dict:
import os
filename = os.path.basename(icon_path)
files = {"icon": (filename, open(icon_path, "rb"))}
try:
resp = await client.post(f"/api/v1/chat/{chat_guid}/icon", files=files)
resp.raise_for_status()
return resp.json()
finally:
for f in files.values():
if hasattr(f[1], "close"):
f[1].close()
async def remove_group_icon(client, chat_guid: str) -> dict:
resp = await client.delete(f"/api/v1/chat/{chat_guid}/icon")
resp.raise_for_status()
return resp.json()
async def add_participant(client, chat_guid: str, address: str) -> dict:
payload = {"address": address}
resp = await client.post(f"/api/v1/chat/{chat_guid}/participant", json=payload)
resp.raise_for_status()
return resp.json()
async def remove_participant(client, chat_guid: str, address: str) -> dict:
resp = await client.delete(f"/api/v1/chat/{chat_guid}/participant?address={address}")
resp.raise_for_status()
return resp.json()
async def leave_group(client, chat_guid: str) -> dict:
resp = await client.post(f"/api/v1/chat/{chat_guid}/leave")
resp.raise_for_status()
return resp.json()
async def delete_chat(client, chat_guid: str) -> dict:
resp = await client.delete(f"/api/v1/chat/{chat_guid}")
resp.raise_for_status()
return resp.json()
async def delete_message(client, chat_guid: str, message_guid: str) -> dict:
resp = await client.delete(f"/api/v1/chat/{chat_guid}/{message_guid}")
resp.raise_for_status()
return resp.json()
async def force_notify(client, message_guid: str) -> dict:
resp = await client.post(f"/api/v1/message/{message_guid}/notify")
resp.raise_for_status()
return resp.json()
async def get_embedded_media(client, message_guid: str) -> dict:
resp = await client.get(f"/api/v1/message/{message_guid}/embedded-media")
resp.raise_for_status()
return resp.json()
async def send_multipart_message(
client,
chat_guid: str,
text: str,
file_paths: list[str],
*,
private_api_available: bool = False,
subject: str | None = None,
) -> dict:
cleaned = strip_markdown(text)
temp_guid = str(uuid.uuid4())
payload = {
"chatGuid": chat_guid,
"message": cleaned,
"tempGuid": temp_guid,
"method": "private-api" if private_api_available else "apple-script",
}
if subject:
payload["subject"] = subject
import os
files = []
try:
open_files = []
for fp in file_paths:
filename = os.path.basename(fp)
f = open(fp, "rb")
open_files.append(f)
files.append(("attachments", (filename, f)))
data = {"payload": json.dumps(payload)}
resp = await client.post("/api/v1/message/multipart", data=data, files=files)
resp.raise_for_status()
return resp.json()
finally:
for f in open_files:
if hasattr(f, "close"):
f.close()
async def create_new_chat(client, address: str, message: str) -> dict:
payload = {
"addresses": [address],
"message": message,
"tempGuid": str(uuid.uuid4()),
}
resp = await client.post("/api/v1/chat/new", json=payload)
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
# Scheduled messages (P2 — implemented on-demand)
# ---------------------------------------------------------------------------
async def schedule_message(
client,
chat_guid: str,
text: str,
scheduled_at: str,
*,
private_api_available: bool = False,
subject: str | None = None,
) -> dict:
cleaned = strip_markdown(text)
payload = {
"chatGuid": chat_guid,
"message": cleaned,
"scheduledAt": scheduled_at,
"method": "private-api" if private_api_available else "apple-script",
}
if subject:
payload["subject"] = subject
resp = await client.post("/api/v1/message/schedule", json=payload)
resp.raise_for_status()
return resp.json()
async def list_scheduled_messages(client) -> list[dict]:
resp = await client.get("/api/v1/message/schedule")
resp.raise_for_status()
return resp.json().get("data", [])
async def update_scheduled_message(client, schedule_id: str, scheduled_at: str) -> dict:
payload = {"scheduledAt": scheduled_at}
resp = await client.put(f"/api/v1/message/schedule/{schedule_id}", json=payload)
resp.raise_for_status()
return resp.json()
async def delete_scheduled_message(client, schedule_id: str) -> dict:
resp = await client.delete(f"/api/v1/message/schedule/{schedule_id}")
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
# Query / lookup helpers (P2 — implemented on-demand)
# ---------------------------------------------------------------------------
async def query_chats(client, query_params: dict | None = None) -> list[dict]:
params = query_params or {}
resp = await client.post("/api/v1/chat/query", json=params)
resp.raise_for_status()
return resp.json().get("data", [])
async def get_chat_info(client, chat_guid: str) -> dict:
resp = await client.get(f"/api/v1/chat/{chat_guid}")
resp.raise_for_status()
return resp.json().get("data", resp.json())
async def query_messages(client, query_params: dict) -> list[dict]:
resp = await client.post("/api/v1/message/query", json=query_params)
resp.raise_for_status()
return resp.json().get("data", [])
async def get_message_info(client, message_guid: str) -> dict:
resp = await client.get(f"/api/v1/message/{message_guid}")
resp.raise_for_status()
return resp.json().get("data", resp.json())
async def get_message_count(client, chat_guid: str | None = None) -> int:
params = {"chatGuid": chat_guid} if chat_guid else {}
resp = await client.get("/api/v1/message/count", params=params)
resp.raise_for_status()
return resp.json().get("data", {}).get("count", 0)
async def download_attachment(client, attachment_guid: str) -> bytes:
resp = await client.get(f"/api/v1/attachment/{attachment_guid}/download")
resp.raise_for_status()
return resp.content
async def get_chat_icon(client, chat_guid: str) -> bytes | None:
resp = await client.get(f"/api/v1/chat/{chat_guid}/icon")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.content
async def query_handle_availability(client, handles: list[str]) -> dict:
resp = await client.post("/api/v1/handle/availability/bulk", json={"handles": handles})
resp.raise_for_status()
return resp.json().get("data", {})