该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力: 1. 支持私聊和群聊会话管理,自动区分会话类型 2. 完整的消息收发支持,包括文本、图片、语音、文件、视频消息 3. 支持消息反应、已读回执、消息编辑与撤回 4. 内置去重、防抖处理机制 5. 支持Webhook和WebSocket两种事件接收方式 6. 完善的权限与安全校验机制 7. 历史消息同步与抓包功能 8. TTS语音合成与发送支持 9. 群组管理能力,包括重命名、修改头像、增减成员等
375 lines
11 KiB
Python
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", {})
|