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", {})