"""BlueBubbles iMessage platform adapter. Uses the local BlueBubbles macOS server for outbound REST sends and inbound webhooks: text, media attachments, typing indicators, and read receipts. """ import asyncio import json import logging import os import re import uuid from collections import OrderedDict from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import parse_qs, quote import httpx from gateway.config import Platform, PlatformConfig from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, cache_image_from_bytes, cache_audio_from_bytes, cache_document_from_bytes, ) from .media_cache import ext_for_mime from gateway.platforms.helpers import compile_mention_patterns, strip_markdown # Historical BlueBubbles mime→ext maps, preserved verbatim as overrides for # the shared dispatch in gateway.platforms.media_cache. Both maps are # CLOSED: unlisted mimes fall back to .jpg / .mp3 (never mimetypes). _BLUEBUBBLES_IMAGE_EXT_OVERRIDES = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/heic": ".jpg", "image/heif": ".jpg", "image/tiff": ".jpg", # historical mapping } _BLUEBUBBLES_AUDIO_EXT_OVERRIDES = { "audio/mp3": ".mp3", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/x-caf": ".mp3", "audio/mp4": ".m4a", "audio/aac": ".m4a", # historical mapping (shared table says .aac) } logger = logging.getLogger(__name__) DEFAULT_WEBHOOK_HOST = "127.0.0.1" # Webhook events are small JSON/form payloads; attachments come through the # REST API. 1 MiB keeps oversized/chunked bodies from buffering unbounded. _WEBHOOK_MAX_BODY_BYTES = 1_048_576 DEFAULT_WEBHOOK_PORT = 8645 DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" MAX_TEXT_LENGTH = 4000 # iMessage has no stable bot mention identity (unlike <@U...>/@botname/MXID), # so `require_mention: true` without custom aliases uses Hermes wake words. DEFAULT_MENTION_PATTERNS = [ r"(? str: """Redact phone numbers and emails from log output.""" return _EMAIL_RE.sub("[REDACTED]", _PHONE_RE.sub("[REDACTED]", text)) def check_bluebubbles_requirements() -> bool: try: import aiohttp # noqa: F401 import httpx # noqa: F401 except ImportError: return False return True def _normalize_server_url(raw: str) -> str: value = (raw or "").strip() if not value: return "" if not re.match(r"^https?://", value, flags=re.I): value = f"http://{value}" return value.rstrip("/") def _closed_ext(mime: str, overrides: Dict[str, str], fallback: str) -> str: """Historical maps were closed: unlisted mimes fall back without consulting mimetypes.""" return ext_for_mime( mime, overrides=overrides, use_defaults=False, use_mimetypes=False, fallback=fallback ) or fallback def _setting(extra: Dict[str, Any], key: str, env: str, default: str = "") -> Any: """Config ``extra[key]`` wins over env var ``env`` (falsy values fall through).""" return extra.get(key) or os.getenv(env, default) def _temp_guid() -> str: return f"temp-{datetime.utcnow().timestamp()}" class BlueBubblesAdapter(BasePlatformAdapter): platform = Platform.BLUEBUBBLES SUPPORTS_MESSAGE_EDITING = False MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) def __init__(self, config: PlatformConfig): super().__init__(config, Platform.BLUEBUBBLES) extra = config.extra or {} self.server_url = _normalize_server_url(_setting(extra, "server_url", "BLUEBUBBLES_SERVER_URL")) self.password = extra.get("password") or _get_scoped_secret("BLUEBUBBLES_PASSWORD", "") self.webhook_host = _setting(extra, "webhook_host", "BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) self.webhook_port = int( _setting(extra, "webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) ) self.webhook_path = _setting(extra, "webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH) if not str(self.webhook_path).startswith("/"): self.webhook_path = f"/{self.webhook_path}" self.send_read_receipts = bool(extra.get("send_read_receipts", True)) _require_mention = extra.get("require_mention") if _require_mention is None: _require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION") self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"} self._mention_patterns = self._compile_mention_patterns( extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS") ) self.client: Optional[httpx.AsyncClient] = None self._runner = None self._private_api_enabled: Optional[bool] = None self._helper_connected: bool = False self._guid_cache: OrderedDict[str, str] = OrderedDict() # --- API helpers --- def _api_url(self, path: str) -> str: sep = "&" if "?" in path else "?" return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}" @staticmethod def _compile_mention_patterns(raw: Any) -> List[re.Pattern]: """Compile group-mention wake words; ``raw`` is a list, a raw env string (JSON list or comma/newline-separated), or None (Hermes defaults).""" return compile_mention_patterns( raw, log_prefix="bluebubbles", defaults=DEFAULT_MENTION_PATTERNS, logger_=logger ) def _message_matches_mention_patterns(self, text: str) -> bool: if not text or not self._mention_patterns: return False return any(pattern.search(text) for pattern in self._mention_patterns) def _clean_mention_text(self, text: str) -> str: """Strip a leading wake word only — patterns are regexes, so stripping anywhere later in the prompt could delete ordinary words.""" if not text: return text for pattern in self._mention_patterns: match = pattern.match(text.lstrip()) if match: cleaned = text.lstrip()[match.end():].lstrip(" ,:-") return cleaned or text return text async def _api_json(self, method: str, path: str, **kwargs) -> Dict[str, Any]: assert self.client is not None res = await getattr(self.client, method)(self._api_url(path), **kwargs) res.raise_for_status() return res.json() async def _api_get(self, path: str) -> Dict[str, Any]: return await self._api_json("get", path) async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: return await self._api_json("post", path, json=payload) async def _post_message(self, path: str, payload: Dict[str, Any]) -> SendResult: """POST a message payload and wrap the outcome as a SendResult.""" try: res = await self._api_post(path, payload) data = res.get("data") or {} msg_id = str(data.get("guid") or data.get("messageGuid") or "ok") return SendResult(success=True, message_id=msg_id, raw_response=res) except Exception as exc: return SendResult(success=False, error=str(exc) or type(exc).__name__) async def _private_api_chat_call(self, chat_id: str, action: str, method: str) -> bool: """Fire a private-API chat action (typing/read); True only if the call was made.""" if not self._private_api_enabled or not self._helper_connected or not self.client: return False try: guid = await self._resolve_chat_guid(chat_id) if guid: url = self._api_url(f"/api/v1/chat/{quote(guid, safe='')}/{action}") await getattr(self.client, method)(url, timeout=5) return True except Exception: pass return False # --- Lifecycle --- async def connect(self, *, is_reconnect: bool = False) -> bool: if not self.server_url or not self.password: logger.error("[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required") return False from aiohttp import web # Tighter keepalive so idle CLOSE_WAIT drains promptly. from gateway.platforms._http_client_limits import platform_httpx_limits self.client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) try: await self._api_get("/api/v1/ping") info = await self._api_get("/api/v1/server/info") server_data = (info or {}).get("data", {}) self._private_api_enabled = bool(server_data.get("private_api")) self._helper_connected = bool(server_data.get("helper_connected")) logger.info("[bluebubbles] connected to %s (private_api=%s, helper=%s)", self.server_url, self._private_api_enabled, self._helper_connected) except Exception as exc: logger.error("[bluebubbles] cannot reach server at %s: %s", self.server_url, exc) if self.client: await self.client.aclose() self.client = None return False # client_max_size makes aiohttp enforce the cap on every read path, # including chunked requests with no Content-Length. app = web.Application(client_max_size=_WEBHOOK_MAX_BODY_BYTES) app.router.add_get("/health", lambda _: web.Response(text="ok")) app.router.add_post(self.webhook_path, self._handle_webhook) # The webhook auth value rides in the query string (BlueBubbles cannot # send custom headers) — keep it out of aiohttp access logs. self._runner = web.AppRunner(app, access_log=None) await self._runner.setup() site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port) await site.start() self._mark_connected() logger.info("[bluebubbles] webhook listening on http://%s:%s%s", self.webhook_host, self.webhook_port, self.webhook_path) # The server only sends events to webhooks registered via its API. await self._register_webhook() # Plugin-registered native handlers (ctx.register_platform_handler). self._wire_plugin_handlers(None) return True async def disconnect(self) -> None: await self._unregister_webhook() if self.client: await self.client.aclose() self.client = None if self._runner: await self._runner.cleanup() self._runner = None self._mark_disconnected() @property def _webhook_url(self) -> str: """External webhook URL for BlueBubbles registration (local binds → localhost).""" host = "localhost" if self.webhook_host in _LOCAL_HOSTS else self.webhook_host return f"http://{host}:{self.webhook_port}{self.webhook_path}" def _webhook_register_url_with(self, password_param: str) -> str: base = self._webhook_url return f"{base}?password={password_param}" if self.password else base @property def _webhook_register_url(self) -> str: """Registered webhook URL, password embedded as a query param. BlueBubbles posts to the exact registered URL and its registration API cannot set custom headers, so this is the only way to authenticate inbound webhooks without disabling auth. """ return self._webhook_register_url_with(quote(self.password, safe="")) @property def _webhook_register_url_for_log(self) -> str: return self._webhook_register_url_with("***") async def _find_registered_webhooks(self, url: str) -> list: """Return list of BB webhook entries matching *url*.""" try: res = await self._api_get("/api/v1/webhook") data = res.get("data") if isinstance(data, list): return [wh for wh in data if wh.get("url") == url] except Exception: pass return [] async def _register_webhook(self) -> bool: """Register this webhook URL, reusing an existing registration if present (crash resilience — avoids duplicates after an unclean shutdown).""" if not self.client: return False webhook_url = self._webhook_register_url log_url = self._webhook_register_url_for_log if await self._find_registered_webhooks(webhook_url): logger.info("[bluebubbles] webhook already registered: %s", log_url) return True payload = {"url": webhook_url, "events": ["new-message", "updated-message"]} try: res = await self._api_post("/api/v1/webhook", payload) status = res.get("status", 0) if 200 <= status < 300: logger.info("[bluebubbles] webhook registered with server: %s", log_url) return True logger.warning("[bluebubbles] webhook registration returned status %s: %s", status, res.get("message")) return False except Exception as exc: logger.warning("[bluebubbles] failed to register webhook with server: %s", exc) return False async def _unregister_webhook(self) -> bool: """Remove *all* registrations matching our URL (cleans up crash duplicates).""" if not self.client: return False removed = False try: for wh in await self._find_registered_webhooks(self._webhook_register_url): wh_id = wh.get("id") if wh_id: res = await self.client.delete(self._api_url(f"/api/v1/webhook/{wh_id}")) res.raise_for_status() removed = True if removed: logger.info("[bluebubbles] webhook unregistered: %s", self._webhook_register_url_for_log) except Exception as exc: logger.debug("[bluebubbles] failed to unregister webhook (non-critical): %s", exc) return removed # --- Chat GUID resolution --- async def _resolve_chat_guid(self, target: str) -> Optional[str]: """Resolve an email/phone to a chat GUID (raw ``a;-;b`` GUIDs pass through). Matches strictly on ``chatIdentifier`` / ``identifier``. Participant membership is intentionally NOT a fallback: the same contact appears in a 1:1 DM and any number of groups, so a participant match could leak a DM reply into a group thread. Return ``None`` and let the caller create a fresh DM via ``_create_chat_for_handle``. """ target = (target or "").strip() if not target: return None if ";" in target: return target if target in self._guid_cache: self._guid_cache.move_to_end(target) return self._guid_cache[target] try: payload = await self._api_post("/api/v1/chat/query", {"limit": 100, "offset": 0}) for chat in payload.get("data", []) or []: guid = chat.get("guid") or chat.get("chatGuid") identifier = chat.get("chatIdentifier") or chat.get("identifier") if identifier == target: if guid: self._guid_cache[target] = guid while len(self._guid_cache) > _GUID_CACHE_SIZE: self._guid_cache.popitem(last=False) return guid except Exception: pass return None async def _create_chat_for_handle(self, address: str, message: str) -> SendResult: """Create a new chat by sending the first message to *address*.""" payload = {"addresses": [address], "message": message, "tempGuid": _temp_guid()} return await self._post_message("/api/v1/chat/new", payload) # --- Text sending --- @staticmethod def truncate_message(content: str, max_length: int = MAX_TEXT_LENGTH) -> List[str]: # Base splitter minus "(1/3)" pagination suffixes — iMessage bubbles flow naturally. chunks = BasePlatformAdapter.truncate_message(content, max_length) return [re.sub(r"\s*\(\d+/\d+\)$", "", c) for c in chunks] async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: text = self.format_message(content) if not text: return SendResult(success=False, error="BlueBubbles send requires text") # Each paragraph becomes its own iMessage bubble; truncate any still too long. paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] chunks: List[str] = [] for para in (paragraphs or [text]): if len(para) <= self.MAX_MESSAGE_LENGTH: chunks.append(para) else: chunks.extend(self.truncate_message(para, max_length=self.MAX_MESSAGE_LENGTH)) last = SendResult(success=True) for chunk in chunks: guid = await self._resolve_chat_guid(chat_id) if not guid: # If the target looks like an address, try creating a new chat if self._private_api_enabled and ("@" in chat_id or re.match(r"^\+\d+", chat_id)): return await self._create_chat_for_handle(chat_id, chunk) return SendResult(success=False, error=f"BlueBubbles chat not found for target: {chat_id}") payload: Dict[str, Any] = {"chatGuid": guid, "tempGuid": _temp_guid(), "message": chunk} if reply_to and self._private_api_enabled and self._helper_connected: payload["method"] = "private-api" payload["selectedMessageGuid"] = reply_to payload["partIndex"] = 0 last = await self._post_message("/api/v1/message/text", payload) if not last.success: return last return last # --- Media sending (outbound) --- async def _send_attachment( self, chat_id: str, file_path: str, filename: Optional[str] = None, caption: Optional[str] = None, is_audio_message: bool = False, ) -> SendResult: """Send a file attachment via BlueBubbles multipart upload.""" if not self.client: return SendResult(success=False, error="Not connected") if not await asyncio.to_thread(os.path.isfile, file_path): return SendResult(success=False, error=f"File not found: {file_path}") guid = await self._resolve_chat_guid(chat_id) if not guid: return SendResult(success=False, error=f"Chat not found: {chat_id}") fname = filename or os.path.basename(file_path) try: # httpx's async multipart iterator reads file objects through a sync # chunk generator — read the bytes off the event-loop thread first. payload = await asyncio.to_thread(Path(file_path).read_bytes) files = {"attachment": (fname, payload, "application/octet-stream")} data: Dict[str, str] = {"chatGuid": guid, "name": fname, "tempGuid": uuid.uuid4().hex} if is_audio_message: data["isAudioMessage"] = "true" res = await self.client.post( self._api_url("/api/v1/message/attachment"), files=files, data=data, timeout=120 ) res.raise_for_status() result = res.json() if caption: await self.send(chat_id, caption) if result.get("status") == 200: rdata = result.get("data") or {} msg_id = rdata.get("guid") if isinstance(rdata, dict) else None return SendResult(success=True, message_id=msg_id, raw_response=result) return SendResult(success=False, error=result.get("message", "Attachment upload failed")) except Exception as e: return SendResult(success=False, error=str(e)) async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: try: from gateway.platforms.base import cache_image_from_url local_path = await cache_image_from_url(image_url) return await self._send_attachment(chat_id, local_path, caption=caption) except Exception: return await super().send_image(chat_id, image_url, caption, reply_to) async def send_image_file(self, chat_id, image_path, caption=None, reply_to=None, **kw) -> SendResult: return await self._send_attachment(chat_id, image_path, caption=caption) async def send_voice(self, chat_id, audio_path, caption=None, reply_to=None, **kw) -> SendResult: return await self._send_attachment(chat_id, audio_path, caption=caption, is_audio_message=True) async def send_video(self, chat_id, video_path, caption=None, reply_to=None, **kw) -> SendResult: return await self._send_attachment(chat_id, video_path, caption=caption) async def send_document( self, chat_id, file_path, caption=None, file_name=None, reply_to=None, **kw ) -> SendResult: return await self._send_attachment(chat_id, file_path, filename=file_name, caption=caption) async def send_animation( self, chat_id, animation_url, caption=None, reply_to=None, metadata=None ) -> SendResult: return await self.send_image(chat_id, animation_url, caption, reply_to, metadata) # --- Typing indicators / read receipts (private API only) --- async def send_typing(self, chat_id: str, metadata=None) -> None: await self._private_api_chat_call(chat_id, "typing", "post") async def stop_typing(self, chat_id: str) -> None: await self._private_api_chat_call(chat_id, "typing", "delete") async def mark_read(self, chat_id: str) -> bool: return await self._private_api_chat_call(chat_id, "read", "post") # --- Chat info --- async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: is_group = ";+;" in (chat_id or "") info: Dict[str, Any] = {"name": chat_id, "type": "group" if is_group else "dm"} try: guid = await self._resolve_chat_guid(chat_id) if guid: res = await self._api_get(f"/api/v1/chat/{quote(guid, safe='')}?with=participants") data = (res or {}).get("data", {}) info["name"] = data.get("displayName") or data.get("chatIdentifier") or chat_id participants = [ addr for p in data.get("participants", []) or [] if (addr := (p.get("address") or "").strip()) ] if participants: info["participants"] = participants except Exception: pass return info def format_message(self, content: str) -> str: return strip_markdown(content) # --- Inbound attachment downloading --- async def _download_attachment(self, att_guid: str, att_meta: Dict[str, Any]) -> Optional[str]: """Download an attachment and cache it locally; local path or None on failure.""" if not self.client: return None try: resp = await self.client.get( self._api_url(f"/api/v1/attachment/{quote(att_guid, safe='')}/download"), timeout=60, follow_redirects=True, ) resp.raise_for_status() data = resp.content mime = (att_meta.get("mimeType") or "").lower() if mime.startswith("image/"): ext = _closed_ext(mime, _BLUEBUBBLES_IMAGE_EXT_OVERRIDES, ".jpg") return cache_image_from_bytes(data, ext) if mime.startswith("audio/"): ext = _closed_ext(mime, _BLUEBUBBLES_AUDIO_EXT_OVERRIDES, ".mp3") return cache_audio_from_bytes(data, ext) # Videos, documents, and everything else filename = att_meta.get("transferName", "") or f"file_{uuid.uuid4().hex[:8]}" return cache_document_from_bytes(data, filename) except Exception as exc: logger.warning("[bluebubbles] failed to download attachment %s: %s", _redact(att_guid), exc) return None # --- Webhook handling --- def _extract_payload_record(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: data = payload.get("data") if isinstance(data, dict): return data if isinstance(data, list): for item in data: if isinstance(item, dict): return item if isinstance(payload.get("message"), dict): return payload.get("message") return payload if isinstance(payload, dict) else None @staticmethod def _value(*candidates: Any) -> Optional[str]: for candidate in candidates: if isinstance(candidate, str) and candidate.strip(): return candidate.strip() return None @staticmethod def _parse_webhook_body(raw: bytes) -> Any: """Decode a webhook body: JSON, else form-encoded with a JSON field.""" body = raw.decode("utf-8", errors="replace") try: return json.loads(body) except Exception: form = parse_qs(body) payload_str = (form.get("payload") or form.get("data") or form.get("message") or [""])[0] return json.loads(payload_str) if payload_str else {} async def _collect_attachments(self, record: Dict[str, Any]): """Download inbound attachments; returns (media_urls, media_types, msg_type).""" media_urls: List[str] = [] media_types: List[str] = [] msg_type = MessageType.TEXT for att in record.get("attachments") or []: att_guid = att.get("guid", "") if not att_guid: continue cached = await self._download_attachment(att_guid, att) if not cached: continue mime = (att.get("mimeType") or "").lower() media_urls.append(cached) media_types.append(mime) if mime.startswith("image/"): msg_type = MessageType.PHOTO elif mime.startswith("audio/") or (att.get("uti") or "").endswith("caf"): msg_type = MessageType.VOICE elif mime.startswith("video/"): msg_type = MessageType.VIDEO else: msg_type = MessageType.DOCUMENT # With multiple attachments, prefer PHOTO if any images present if len(media_urls) > 1 and "image" in {(m or "").split("/")[0] for m in media_types}: msg_type = MessageType.PHOTO return media_urls, media_types, msg_type async def _handle_webhook(self, request): from aiohttp import web token = ( request.query.get("password") or request.query.get("guid") or request.headers.get("x-password") or request.headers.get("x-guid") or request.headers.get("x-bluebubbles-guid") ) if token != self.password: return web.json_response({"error": "unauthorized"}, status=401) try: payload = self._parse_webhook_body(await request.read()) except Exception as exc: logger.error("[bluebubbles] webhook parse error: %s", exc) return web.json_response({"error": "invalid payload"}, status=400) event_type = self._value(payload.get("type"), payload.get("event")) or "" # Only process message events; silently acknowledge everything else if event_type and event_type not in _MESSAGE_EVENTS: return web.Response(text="ok") record = self._extract_payload_record(payload) or {} if record.get("isFromMe") or record.get("fromMe") or record.get("is_from_me"): return web.Response(text="ok") # Skip tapback reactions delivered as messages assoc_type = record.get("associatedMessageType") if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES: return web.Response(text="ok") text = self._value(record.get("text"), record.get("message"), record.get("body")) or "" media_urls, media_types, msg_type = await self._collect_attachments(record) if not text and media_urls: text = "(attachment)" chat_guid = self._value( record.get("chatGuid"), payload.get("chatGuid"), record.get("chat_guid"), payload.get("chat_guid"), payload.get("guid"), ) # BlueBubbles v1.9+ payloads omit top-level chatGuid; it's nested under data.chats[0].guid. if not chat_guid: _chats = record.get("chats") or [] if _chats and isinstance(_chats[0], dict): chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") chat_identifier = self._value( record.get("chatIdentifier"), record.get("identifier"), payload.get("chatIdentifier"), payload.get("identifier"), ) handle = record.get("handle") sender = ( self._value( handle.get("address") if isinstance(handle, dict) else None, record.get("sender"), record.get("from"), record.get("address"), ) or chat_identifier or chat_guid ) if not (chat_guid or chat_identifier) and sender: chat_identifier = sender if not sender or not (chat_guid or chat_identifier) or not text: return web.json_response({"error": "missing message fields"}, status=400) session_chat_id = chat_guid or chat_identifier is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) if is_group and self.require_mention: if not self._message_matches_mention_patterns(text): logger.debug( "[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)" ) return web.Response(text="ok") text = self._clean_mention_text(text) source = self.build_source( chat_id=session_chat_id, chat_name=chat_identifier or sender, chat_type="group" if is_group else "dm", user_id=sender, user_name=sender, chat_id_alt=chat_identifier, ) event = MessageEvent( text=text, message_type=msg_type, source=source, raw_message=payload, message_id=self._value(record.get("guid"), record.get("messageGuid"), record.get("id")), reply_to_message_id=self._value( record.get("threadOriginatorGuid"), record.get("associatedMessageGuid") ), media_urls=media_urls, media_types=media_types, ) task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) # Fire-and-forget read receipt if self.send_read_receipts and session_chat_id: asyncio.create_task(self.mark_read(session_chat_id)) return web.Response(text="ok")