from __future__ import annotations import logging from enum import StrEnum from .config import _apply_env_overrides, _dict_to_account from .errors import classify_error from .utils import chunk_text, get_nio, sanitize_matrix_text logger = logging.getLogger(__name__) class OutboundDeliveryMode(StrEnum): DIRECT = "direct" GATEWAY = "gateway" HYBRID = "hybrid" delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = None text_chunk_limit = 4000 presentation_capabilities = None delivery_capabilities = None def chunker(text: str, limit: int, ctx=None) -> list[str]: return chunk_text(text, max(limit, 100)) def sanitize_text(text: str, payload=None) -> str: return sanitize_matrix_text(text) async def send_text( target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: body = { "msgtype": "m.text", "body": content, "format": "org.matrix.custom.html", "formatted_body": content, } if reply_to_id: body["m.relates_to"] = { "m.in_reply_to": {"event_id": reply_to_id}, } if thread_id: relates = body.setdefault("m.relates_to", {}) relates["rel_type"] = "m.thread" relates["event_id"] = thread_id resp = await client.room_send(target_id, "m.room.message", body) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_text failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_media( target_id: str, media_url: str, media_type: str = "file", *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) msgtype_map = { "image": "m.image", "file": "m.file", "audio": "m.audio", "voice": "m.audio", "video": "m.video", } content_type_map = { "image": "image/png", "file": "application/octet-stream", "audio": "audio/ogg", "voice": "audio/ogg", "video": "video/mp4", } try: mime_type = content_type_map.get(media_type, "application/octet-stream") mxc_uri, _ = await client.upload(media_url, content_type=mime_type) body = { "msgtype": msgtype_map.get(media_type, "m.file"), "body": media_url.split("/")[-1] if "/" in media_url else "file", "url": mxc_uri, } if reply_to_id: body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} if thread_id: relates = body.setdefault("m.relates_to", {}) relates["rel_type"] = "m.thread" relates["event_id"] = thread_id resp = await client.room_send(target_id, "m.room.message", body) return {"event_id": resp.event_id, "room_id": target_id, "mxc_uri": mxc_uri} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_media failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def edit_message( target_id: str, message_id: str, content: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: body = { "msgtype": "m.text", "body": content, "m.new_content": { "msgtype": "m.text", "body": content, }, "m.relates_to": { "rel_type": "m.replace", "event_id": message_id, }, } resp = await client.room_send(target_id, "m.room.message", body) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix edit_message failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def unsend_message( target_id: str, message_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: resp = await client.room_redact(target_id, message_id) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix unsend_message failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_reaction( target_id: str, message_id: str, emoji: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: body = { "m.relates_to": { "rel_type": "m.annotation", "event_id": message_id, "key": emoji, } } resp = await client.room_send(target_id, "m.reaction", body) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_reaction failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_typing( target_id: str, typing_state: bool, *, account_id: str | None = None, config: dict | None = None, client: object = None ) -> None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_typing(target_id, typing_state=typing_state) except Exception: logger.debug("Matrix send_typing failed for room %s", target_id) finally: if _should_close: await client.close() async def send_read_receipt( target_id: str, event_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_read_markers(target_id, fully_read_event=event_id, read_event=event_id) except Exception: logger.debug("Matrix send_read_receipt failed for room %s", target_id) finally: if _should_close: await client.close() async def send_poll( target_id: str, question: str, options: list[str], *, is_anonymous: bool = True, allows_multiple_answers: bool = False, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: kind = "org.matrix.msc3381.poll.disclosed" poll_content = { "org.matrix.msc3381.poll.start": { "kind": kind, "max_selections": len(options) if allows_multiple_answers else 1, "question": { "org.matrix.msc1767.text": question, "body": question, }, "answers": [ { "id": f"opt_{i}", "org.matrix.msc1767.text": opt, } for i, opt in enumerate(options) ], }, "org.matrix.msc1767.text": question, } resp = await client.room_send(target_id, "m.poll.start", poll_content) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_poll failed: severity=%s retry_after=%dms error=%s", matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() poll_max_options = 10 supports_poll_duration_seconds = False supports_anonymous_polls = True def _resolve_account(config: dict | None, account_id: str | None): if config is None: config = {} aid = account_id or "default" account_data = config.get("accounts", {}).get(aid, {}) account = _dict_to_account(account_data) return _apply_env_overrides(account) def _create_client(account, nio): client = nio.AsyncClient( homeserver=account.homeserver, user=account.user_id, device_id=account.device_id, ) client.access_token = account.access_token return client async def _with_client( config: dict | None, account_id: str | None, client: object | None, operation_name: str, operation_fn, ): nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: return await operation_fn(nio, client) except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix %s failed: severity=%s retry_after=%dms error=%s", operation_name, matrix_err.severity, matrix_err.retry_after_ms, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_notice( target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: async def _op(nio, client): body = {"msgtype": "m.notice", "body": content} if reply_to_id: body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} if thread_id: relates = body.setdefault("m.relates_to", {}) relates["rel_type"] = "m.thread" relates["event_id"] = thread_id resp = await client.room_send(target_id, "m.room.message", body) return {"event_id": resp.event_id, "room_id": target_id} return await _with_client(config, account_id, client, "send_notice", _op) async def create_room( name: str | None = None, topic: str | None = None, invite_users: list[str] | None = None, *, is_direct: bool = False, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: async def _op(nio, client): resp = await client.room_create( name=name, topic=topic, invite=invite_users or [], is_direct=is_direct ) return {"room_id": resp.room_id} return await _with_client(config, account_id, client, "create_room", _op) async def join_room( room_id_or_alias: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: async def _op(nio, client): resp = await client.join(room_id_or_alias) return {"room_id": resp.room_id} return await _with_client(config, account_id, client, "join_room", _op) async def leave_room( room_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_leave(room_id) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix leave_room failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def invite_user( room_id: str, user_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_invite(room_id, user_id) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix invite_user failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def kick_user( room_id: str, user_id: str, reason: str = "", *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_kick(room_id, user_id, reason=reason) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix kick_user failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def ban_user( room_id: str, user_id: str, reason: str = "", *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_ban(room_id, user_id, reason=reason) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix ban_user failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def set_display_name( display_name: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.set_displayname(display_name) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_display_name failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def set_avatar( avatar_url: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.set_avatar(avatar_url) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_avatar failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_emote( target_id: str, content: str, *, reply_to_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: body = {"msgtype": "m.emote", "body": content} if reply_to_id: body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} resp = await client.room_send(target_id, "m.room.message", body) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_emote failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def set_room_name( room_id: str, name: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_put_state(room_id, "m.room.name", {"name": name}) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_room_name failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def set_room_topic( room_id: str, topic: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_put_state(room_id, "m.room.topic", {"topic": topic}) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_room_topic failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def get_room_members( room_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> list[dict] | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: resp = await client.joined_members(room_id) members_data = resp.members if hasattr(resp, "members") else {} return [ {"user_id": uid, "display_name": info.display_name, "avatar_url": info.avatar_url} for uid, info in (members_data.items() if isinstance(members_data, dict) else []) ] except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix get_room_members failed: severity=%s error=%s", matrix_err.severity, e, ) return None finally: if _should_close: await client.close() async def update_dm_mapping( user_id: str, room_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: resp = await client.get_account_data("m.direct") dm_map = resp.content if hasattr(resp, "content") else {} dm_map = dict(dm_map) if isinstance(dm_map, dict) else {} existing = dm_map.get(user_id, []) if room_id not in existing: dm_map[user_id] = existing + [room_id] await client.set_account_data("m.direct", dm_map) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix update_dm_mapping failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_location( location_url: str, body: str = "", geo_uri: str = "", *, target_id: str = "", reply_to_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: location_content = {"body": body or "Location", "geo_uri": geo_uri, "msgtype": "m.location"} if reply_to_id: location_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} resp = await client.room_send(target_id, "m.room.message", location_content) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_location failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_sticker( target_id: str, sticker_url: str, *, body: str = "Sticker", reply_to_id: str | None = None, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: sticker_content = {"body": body, "url": sticker_url, "msgtype": "m.sticker"} if reply_to_id: sticker_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} resp = await client.room_send(target_id, "m.sticker", sticker_content) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_sticker failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def send_voice( target_id: str, audio_url: str, *, body: str = "Voice message", reply_to_id: str | None = None, duration_ms: int = 0, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: voice_content = { "body": body, "msgtype": "m.voice", "url": audio_url, "org.matrix.msc3245.voice": {}, } if duration_ms > 0: voice_content["org.matrix.msc3245.voice"]["duration"] = duration_ms if reply_to_id: voice_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}} resp = await client.room_send(target_id, "m.room.message", voice_content) return {"event_id": resp.event_id, "room_id": target_id} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix send_voice failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def set_room_avatar( room_id: str, avatar_url: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.room_put_state(room_id, "m.room.avatar", {"url": avatar_url}) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_room_avatar failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close() async def get_user_profile( user_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: resp = await client.get_profile(user_id) return { "user_id": user_id, "displayname": getattr(resp, "displayname", None), "avatar_url": getattr(resp, "avatar_url", None), } except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix get_user_profile failed: severity=%s error=%s", matrix_err.severity, e, ) return None finally: if _should_close: await client.close() async def get_room_tags( room_id: str, *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: resp = await client.get_account_data(f"m.tag/{room_id}") return getattr(resp, "content", {}).get("tags", {}) if hasattr(resp, "content") else {} except Exception as e: matrix_err = classify_error(e) if "M_NOT_FOUND" not in str(e): logger.error( "Matrix get_room_tags failed: severity=%s error=%s", matrix_err.severity, e, ) return {} finally: if _should_close: await client.close() async def set_room_tags( room_id: str, tags: dict[str, dict | None], *, account_id: str | None = None, config: dict | None = None, client: object = None, ) -> dict | None: nio = get_nio() account = _resolve_account(config, account_id) _should_close = client is None if client is None: client = _create_client(account, nio) try: await client.set_account_data(f"m.tag/{room_id}", {"tags": tags}) return {"ok": True} except Exception as e: matrix_err = classify_error(e) logger.error( "Matrix set_room_tags failed: severity=%s error=%s", matrix_err.severity, e, ) return {"error": str(e)} finally: if _should_close: await client.close()