"""Standalone per-platform senders and error helpers for send_message.""" import asyncio import logging import os import re import time from agent.redact import redact_sensitive_text logger = logging.getLogger("tools.send_message_tool") _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".m2a", ".wav", ".m4a", ".flac"} _VOICE_EXTS = {".ogg", ".opus"} # Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio # formats either route through sendVoice (Opus/OGG) or fall back to # document delivery. _TELEGRAM_SEND_AUDIO_EXTS = {".mp3", ".m4a"} # Extensions that carry a native caption on the media bubble itself # (photo/video/document). Voice/audio notes are excluded: a caption on a # voice note reads as a separate label rather than a bubble caption, and the # established convention is to keep the accompanying text as its own message. _CAPTIONABLE_EXTS = _IMAGE_EXTS | _VIDEO_EXTS | { ".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx", ".zip", } # Per-platform native caption length limits (characters). Text longer than # the limit can't ride on the media bubble and stays a separate body message. # Telegram's photo/video caption cap is 1024; WhatsApp and Discord are far # more generous, so a conservative shared ceiling keeps behavior predictable. _TELEGRAM_CAPTION_LIMIT = 1024 _DEFAULT_CAPTION_LIMIT = 4096 def _media_caption_split(text, media_files, *, max_caption_len): """Decide whether the accompanying text rides on the media bubble. Single chokepoint for ``MEDIA: caption`` across every sender. Returns ``(caption, "")`` — text becomes the native caption, no separate body message — only for exactly one media file of a captionable kind (image/video/document, not a voice/audio note) whose text fits ``max_caption_len``. Otherwise ``(None, text)``: text goes as a separate message (multi-file caption→file association is ambiguous). Length is measured in codepoints: never under-counts Telegram's UTF-16 units for BMP text, so over-counting only fails safe. The Telegram sender re-checks the *formatted* caption since escaping inflates it. """ stripped = (text or "").strip() media = media_files or [] if not stripped or len(media) != 1: return None, text media_path, is_voice = media[0] if is_voice or os.path.splitext(media_path)[1].lower() not in _CAPTIONABLE_EXTS: return None, text if len(stripped) > max_caption_len: return None, text return stripped, "" _URL_SECRET_QUERY_RE = re.compile( r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)", re.IGNORECASE, ) _GENERIC_SECRET_ASSIGN_RE = re.compile( r"\b(access_token|api[_-]?key|auth[_-]?token|signature|sig)\s*=\s*([^\s,;]+)", re.IGNORECASE, ) def _sanitize_error_text(text) -> str: """Redact secrets from error text before surfacing it to users/models.""" redacted = redact_sensitive_text(text) redacted = _URL_SECRET_QUERY_RE.sub(lambda m: f"{m.group(1)}***", redacted) redacted = _GENERIC_SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", redacted) return redacted def _error(message: str) -> dict: """Build a standardized error payload with redacted content.""" return {"error": _sanitize_error_text(message)} def _display_chat_id(platform_name: str, chat_id: str) -> str: """Return a result-safe chat identifier for tool transcripts/log consumers.""" if platform_name == "signal" and str(chat_id).startswith("group:"): return "group:***" return chat_id _TELEGRAM_TRANSIENT_MARKERS = ( "bad gateway", "502", "too many requests", "429", "service unavailable", "503", "gateway timeout", "504", ) def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None: """Seconds to wait before retrying, or None when the error is final. Honours Telegram's ``retry_after``; timeouts are never retried (the send may have gone through); 5xx/429 back off exponentially.""" retry_after = getattr(exc, "retry_after", None) if retry_after is not None: try: return max(float(retry_after), 0.0) except (TypeError, ValueError): return 1.0 text = str(exc).lower() if "timed out" in text or "timeout" in text: return None if any(marker in text for marker in _TELEGRAM_TRANSIENT_MARKERS): return float(2 ** attempt) return None async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs): """``bot.send_message`` with bounded retries on transient failures.""" for attempt in range(attempts): try: return await bot.send_message(**kwargs) except Exception as exc: delay = _telegram_retry_delay(exc, attempt) if delay is None or attempt >= attempts - 1: raise logger.warning( "Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s", attempt + 1, attempts, delay, _sanitize_error_text(exc), ) await asyncio.sleep(delay) def _is_telegram_thread_not_found(error: Exception) -> bool: """Mirror of the gateway adapter's ``_is_thread_not_found_error``.""" return "thread not found" in str(error).lower() def _telegram_bot(token): """Build a Bot honouring TELEGRAM_PROXY (config ``telegram.proxy_url``); without it the standalone path bypasses the proxy and times out where api.telegram.org is blocked. Falls back to a direct connection.""" from telegram import Bot try: from gateway.platforms.base import resolve_proxy_url proxy = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=["api.telegram.org"]) except Exception: proxy = None if proxy: try: from telegram.request import HTTPXRequest logger.info("send_message: standalone Telegram send routed through proxy %s", proxy) return Bot( token=token, request=HTTPXRequest(proxy=proxy), get_updates_request=HTTPXRequest(proxy=proxy), ) except Exception as proxy_err: logger.warning("send_message: failed to attach Telegram proxy (%s), falling back to direct connection", proxy_err) return Bot(token=token) def _telegram_thread_kwargs(thread_id): """Map a topic id to ``message_thread_id`` kwargs. Forum "General" is thread "1" on incoming updates but Bot API rejects message_thread_id=1 ("Message thread not found"), so it maps to no thread — same as the adapter.""" if thread_id is None: return {} try: from plugins.platforms.telegram.adapter import TelegramAdapter effective = TelegramAdapter._message_thread_id_for_send(str(thread_id)) except Exception: # Explicit mapping if the adapter import fails (python-telegram-bot missing). effective = None if str(thread_id) == "1" else int(thread_id) return {} if effective is None else {"message_thread_id": effective} def _strip_mdv2_safe(text): """Strip MarkdownV2 escapes for the plain-text fallback; identity if unavailable.""" try: from plugins.platforms.telegram.adapter import _strip_mdv2 return _strip_mdv2(text) except Exception: return text async def _telegram_send_media(bot, chat_id, f, ext, is_voice, force_document, **kwargs): """Pick the Bot API media method by extension: photo (unless forced to document), video, voice note, sendAudio (MP3/M4A only), else document.""" if ext in _IMAGE_EXTS and not force_document: return await bot.send_photo(chat_id=chat_id, photo=f, **kwargs) if ext in _VIDEO_EXTS: return await bot.send_video(chat_id=chat_id, video=f, **kwargs) if ext in _VOICE_EXTS and is_voice: return await bot.send_voice(chat_id=chat_id, voice=f, **kwargs) if ext in _TELEGRAM_SEND_AUDIO_EXTS: return await bot.send_audio(chat_id=chat_id, audio=f, **kwargs) return await bot.send_document(chat_id=chat_id, document=f, **kwargs) async def _telegram_send_text_chunk(bot, chat_id, chunk, parse_mode, has_html, text_kwargs): """Send one formatted text chunk with the adapter-matching fallbacks: thread-not-found -> retry without ``message_thread_id`` (dropped from ``text_kwargs`` for later chunks too); parse failure -> plain text.""" try: return await _send_telegram_message_with_retry( bot, chat_id=chat_id, text=chunk, parse_mode=parse_mode, **text_kwargs ) except Exception as md_error: if _is_telegram_thread_not_found(md_error) and text_kwargs.get("message_thread_id") is not None: logger.warning( "Thread %s not found in _send_telegram, retrying without message_thread_id", text_kwargs.get("message_thread_id"), ) text_kwargs.pop("message_thread_id", None) return await _send_telegram_message_with_retry( bot, chat_id=chat_id, text=chunk, parse_mode=parse_mode, **text_kwargs ) err_text = str(md_error).lower() if "parse" in err_text or "markdown" in err_text or "html" in err_text: logger.warning( "Parse mode %s failed in _send_telegram, falling back to plain text: %s", parse_mode, _sanitize_error_text(md_error), ) plain = chunk if has_html else _strip_mdv2_safe(chunk) return await _send_telegram_message_with_retry( bot, chat_id=chat_id, text=plain, parse_mode=None, **text_kwargs ) raise async def _telegram_send_one_media( bot, chat_id, media_path, is_voice, *, caption, parse_mode, has_html, thread_kwargs, force_document ): """Upload one file with the adapter-matching fallbacks (thread-not-found -> no ``message_thread_id``; caption parse failure -> plain caption). Each retry re-seeks the file because the first attempt consumed it.""" ext = os.path.splitext(media_path)[1].lower() voice_note = ext in _VOICE_EXTS and is_voice with open(media_path, "rb") as f: media_kwargs = dict(thread_kwargs) # ``caption`` is only set for a single captionable file, so this never # double-captions a multi-file send or a voice note. if caption is not None and not voice_note: media_kwargs["caption"] = caption media_kwargs["parse_mode"] = parse_mode if voice_note or ext in _TELEGRAM_SEND_AUDIO_EXTS: try: from plugins.platforms.telegram.adapter import _probe_voice_duration_seconds duration = await asyncio.to_thread(_probe_voice_duration_seconds, media_path) if duration is not None: media_kwargs["duration"] = duration except Exception: pass async def _send(**kw): return await _telegram_send_media(bot, chat_id, f, ext, is_voice, force_document, **kw) try: return await _send(**media_kwargs) except Exception as media_err: if _is_telegram_thread_not_found(media_err) and media_kwargs.get("message_thread_id"): logger.warning( "Thread %s not found for media send, retrying without message_thread_id", media_kwargs["message_thread_id"], ) f.seek(0) media_kwargs.pop("message_thread_id", None) return await _send(**media_kwargs) err_text = str(media_err).lower() if media_kwargs.get("parse_mode") and ("parse" in err_text or "caption" in err_text): logger.warning( "Caption parse failed for media send, retrying plain: %s", _sanitize_error_text(media_err), ) f.seek(0) media_kwargs.pop("parse_mode", None) if not has_html and media_kwargs.get("caption"): media_kwargs["caption"] = _strip_mdv2_safe(media_kwargs["caption"]) return await _send(**media_kwargs) raise async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False): """Send via Telegram Bot API (one-shot, no polling needed). Markdown is converted to MarkdownV2 via the gateway adapter's ``format_message`` so bold/links/headers render; a message that already contains HTML tags skips that and is sent with ``parse_mode='HTML'``. Parse failures fall back to plain text so the message still delivers. """ try: from telegram.constants import ParseMode # Auto-detect HTML tags: if present, skip MarkdownV2 and send as HTML. _has_html = bool(re.search(r'<[a-zA-Z/][^>]*>', message)) if _has_html: formatted = message send_parse_mode = ParseMode.HTML else: try: from plugins.platforms.telegram.adapter import TelegramAdapter formatted = TelegramAdapter.__new__(TelegramAdapter).format_message(message) except Exception: formatted = message # formatting unavailable: send as-is send_parse_mode = ParseMode.MARKDOWN_V2 bot = _telegram_bot(token) from plugins.platforms.telegram.telegram_ids import normalize_telegram_chat_id # Telegram accepts a numeric chat_id OR an @username string; never force-int. int_chat_id = normalize_telegram_chat_id(chat_id) media_files = media_files or [] thread_kwargs = _telegram_thread_kwargs(thread_id) # disable_web_page_preview is only valid for send_message, not media sends. text_kwargs = dict(thread_kwargs) if disable_link_previews: text_kwargs["disable_web_page_preview"] = True last_msg = None warnings = [] # MEDIA caption: a single captionable file + short text rides on the # bubble as its *formatted* caption. Formatting can inflate a raw # <1024 string past Telegram's 1024 cap, so re-check in UTF-16 units # and fall back to a separate body message. _tg_caption = None from gateway.platforms.base import BasePlatformAdapter, utf16_len _cap, _ = _media_caption_split( message, media_files, max_caption_len=_TELEGRAM_CAPTION_LIMIT ) if _cap is not None and utf16_len(formatted) <= _TELEGRAM_CAPTION_LIMIT: _tg_caption = formatted formatted = "" # suppress the separate text send below if formatted.strip(): # Chunk *after* formatting, in UTF-16 units: MarkdownV2/HTML # escaping inflates text, so a raw-<4096 message can exceed the # limit once formatted and be rejected as "Message is too long". for chunk in BasePlatformAdapter.truncate_message(formatted, 4096, len_fn=utf16_len): last_msg = await _telegram_send_text_chunk( bot, int_chat_id, chunk, send_parse_mode, _has_html, text_kwargs ) for media_path, is_voice in media_files: if not os.path.exists(media_path): warning = f"Media file not found, skipping: {media_path}" logger.warning(warning) warnings.append(warning) # Caption mode suppressed the text send; if the file it was # meant to caption is gone, deliver the words on their own. if _tg_caption is not None and last_msg is None: try: last_msg = await _send_telegram_message_with_retry( bot, chat_id=int_chat_id, text=_tg_caption, parse_mode=send_parse_mode, **text_kwargs ) _tg_caption = None # delivered — don't re-caption a later file except Exception as _cap_err: logger.warning( "Telegram caption-fallback send failed for missing media: %s", _sanitize_error_text(_cap_err), ) continue try: last_msg = await _telegram_send_one_media( bot, int_chat_id, media_path, is_voice, caption=_tg_caption, parse_mode=send_parse_mode, has_html=_has_html, thread_kwargs=thread_kwargs, force_document=force_document, ) except Exception as e: warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}") logger.error(warning) warnings.append(warning) if last_msg is None: error = "No deliverable text or media remained after processing MEDIA tags" if warnings: return {"error": error, "warnings": warnings} return {"error": error} result = { "success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(last_msg.message_id), } if warnings: result["warnings"] = warnings return result except ImportError: return {"error": "python-telegram-bot not installed. Run: pip install python-telegram-bot"} except Exception as e: return _error(f"Telegram send failed: {e}") def _live_runner(): """Return the in-process gateway runner, or None (standalone/cron).""" try: from gateway.run import _gateway_runner_ref return _gateway_runner_ref() except Exception: return None def _plugin_standalone_sender(platform_name, *, label=None, discover=True): """Return ``(standalone_sender_fn, None)`` for a registered platform plugin, or ``(None, error_dict)``. ``discover`` runs the idempotent plugin scan first.""" from gateway.platform_registry import platform_registry if discover: from hermes_cli.plugins import discover_plugins discover_plugins() entry = platform_registry.get(platform_name) if entry is None or entry.standalone_sender_fn is None: return None, {"error": f"{label or platform_name} plugin not registered or missing standalone_sender_fn"} return entry.standalone_sender_fn, None async def _registry_standalone_send(platform_name, pconfig, chat_id, message, thread_id=None): """One-shot text send through a migrated plugin's ``standalone_sender_fn`` (the former inline ``_send_`` helpers now live in the plugins).""" sender, err = _plugin_standalone_sender(platform_name) if err: return err return await sender(pconfig, chat_id, message, thread_id=thread_id) async def _resolve_slack_user_target(token, chat_id): """Resolve ``user:U...`` / ``user_name:`` to a D... DM conversation. chat.postMessage needs a conversation ID, so user targets go through conversations.open; ``user_name:`` first maps to a user ID via users.list (stable handle match only). Other ids pass through unchanged. Returns ``(chat_id, None)`` or ``(None, error_dict)``. """ if not (chat_id.startswith("user:") or chat_id.startswith("user_name:")): return chat_id, None try: import aiohttp except ImportError: return None, {"error": "aiohttp not installed. Run: pip install aiohttp"} try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url() _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) base_url = "https://slack.com/api" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async def post_api(session, method, payload): async with session.post(f"{base_url}/{method}", headers=headers, json=payload, **_req_kw) as resp: return await resp.json() async def resolve_user_name(session, name): query = name.strip().lstrip("@").lower() matches = [] cursor = None for _page in range(20): payload = {"limit": 200} if cursor: payload["cursor"] = cursor data = await post_api(session, "users.list", payload) if not data.get("ok"): return None, f"Slack users.list error: {data.get('error', 'unknown')}" for member in data.get("members", []): if member.get("deleted") or member.get("is_bot"): continue # Match the stable handle only: display/real names are # mutable and non-unique enough to DM the wrong person. if str(member.get("name", "")).strip().lower() == query: matches.append(member) cursor = (data.get("response_metadata") or {}).get("next_cursor") if not cursor: break if not matches: return None, f"Could not resolve Slack user '@{name}'." if len(matches) > 1: return None, f"Slack user '@{name}' matched multiple Slack users. Use a Slack user ID instead." return matches[0].get("id"), None async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: if chat_id.startswith("user_name:"): user_id, error = await resolve_user_name(session, chat_id[len("user_name:"):]) if error: return None, _error(error) chat_id = f"user:{user_id}" user_id = chat_id[len("user:"):] opened = await post_api(session, "conversations.open", {"users": user_id}) if not opened.get("ok"): return None, _error( f"Slack conversations.open error: {opened.get('error', 'unknown')}. " "Check bot permissions (im:write)." ) dm_id = (opened.get("channel") or {}).get("id") if not dm_id: return None, _error("Slack conversations.open did not return a DM channel ID") return dm_id, None except Exception as e: return None, _error(f"Slack DM resolution failed: {e}") async def _send_signal(extra, chat_id, message, media_files=None): """Send via signal-cli JSON-RPC, text and/or attachments. Attachments go in batches of SIGNAL_MAX_ATTACHMENTS_PER_MSG metered by the process-wide SignalAttachmentScheduler — the same bucket the gateway adapter uses, so tool sends and inbound replies share rate-limit state. """ try: import httpx except ImportError: return {"error": "httpx not installed"} from gateway.platforms.signal_rate_limit import ( SIGNAL_BATCH_PACING_NOTICE_THRESHOLD, SIGNAL_MAX_ATTACHMENTS_PER_MSG, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, _extract_retry_after_seconds, _format_wait, _is_signal_rate_limit_error, _signal_send_timeout, get_scheduler, ) from gateway.platforms.signal_format import markdown_to_signal try: http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") account = extra.get("account", "") if not account: return {"error": "Signal account not configured"} valid_media = media_files or [] attachment_paths = [] for media_path, _is_voice in valid_media: if os.path.exists(media_path): attachment_paths.append(media_path) else: logger.warning("Signal media file not found, skipping: %s", media_path) # No attachments still means one (text-only) batch; with attachments # the text rides on batch #0 so it isn't repeated per batch. if attachment_paths: att_batches = [ attachment_paths[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG] for i in range(0, len(attachment_paths), SIGNAL_MAX_ATTACHMENTS_PER_MSG) ] else: att_batches = [[]] plain_text, text_styles = markdown_to_signal(message) def _rpc_params(text): params = {"account": account, "message": text} if chat_id.startswith("group:"): params["groupId"] = chat_id[6:] else: params["recipient"] = [chat_id] return params async def _rpc_send(params, *, id_prefix, timeout): payload = { "jsonrpc": "2.0", "method": "send", "params": params, "id": f"{id_prefix}_{int(time.time() * 1000)}", } async with httpx.AsyncClient(timeout=timeout) as client: return await client.post(f"{http_url}/api/v1/rpc", json=payload) async def _post(batch_attachments, batch_message): params = _rpc_params(batch_message) if batch_message and text_styles: if len(text_styles) == 1: params["textStyle"] = text_styles[0] else: params["textStyles"] = text_styles if batch_attachments: params["attachments"] = batch_attachments timeout = _signal_send_timeout(len(batch_attachments) if batch_attachments else 0) resp = await _rpc_send(params, id_prefix="send", timeout=timeout) resp.raise_for_status() return resp.json() async def _send_inline_notice(text: str) -> None: """Best-effort one-shot RPC for a user-facing pacing notice.""" try: await _rpc_send(_rpc_params(text), id_prefix="notice", timeout=30.0) except Exception as _e: logger.warning("Signal: inline notice failed: %s", _e) scheduler = get_scheduler() logger.info( "send_message Signal: scheduler state=%s, %d attachment(s) in %d batch(es)", scheduler.state(), len(attachment_paths), len(att_batches), ) failed_batches: list[int] = [] for idx, att_batch in enumerate(att_batches): n = len(att_batch) if n > 0: estimated = scheduler.estimate_wait(n) if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD: await _send_inline_notice( f"(More images coming — pausing ~{_format_wait(estimated)} " f"for Signal rate limit, batch {idx + 1}/{len(att_batches)}.)" ) batch_message = plain_text if idx == 0 else "" for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1): try: await scheduler.acquire(n) _rpc_t0 = time.monotonic() data = await _post(att_batch, batch_message) _rpc_duration = time.monotonic() - _rpc_t0 if "error" not in data: await scheduler.report_rpc_duration(_rpc_duration, n) break err = data["error"] if not _is_signal_rate_limit_error(err): return _error(f"Signal RPC error on batch {idx + 1}/{len(att_batches)}: {err}") server_retry_after = _extract_retry_after_seconds(err) scheduler.feedback(server_retry_after, n) if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS: failed_batches.append(idx + 1) logger.error( "Signal: rate-limit retries exhausted on batch %d/%d " "(%d attachments lost, server retry_after=%s)", idx + 1, len(att_batches), n, f"{server_retry_after:.0f}s" if server_retry_after else "unknown", ) break logger.warning( "Signal: rate-limited on batch %d/%d " "(attempt %d/%d, server retry_after=%s); " "scheduler will pace the retry", idx + 1, len(att_batches), attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, f"{server_retry_after:.0f}s" if server_retry_after else "unknown", ) except Exception as e: if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS: failed_batches.append(idx + 1) logger.error( "Signal: send error on batch %d/%d after %d attempts: %s", idx + 1, len(att_batches), attempt, str(e) ) break logger.warning( "Signal: transient error on batch %d/%d (attempt %d/%d): %s; will retry", idx + 1, len(att_batches), attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, str(e) ) warnings = [] if len(attachment_paths) < len(valid_media): warnings.append("Some media files were skipped (not found on disk)") if failed_batches: warnings.append( f"Signal rate-limited {len(failed_batches)} batch(es) " f"(#{', #'.join(str(b) for b in failed_batches)})" ) if failed_batches and len(failed_batches) == len(att_batches): return _error( f"Signal: every batch ({len(att_batches)}) hit rate limit; " f"no attachments delivered" ) result = {"success": True, "platform": "signal", "chat_id": _display_chat_id("signal", chat_id)} if warnings: result["warnings"] = warnings return result except Exception as e: return _error(f"Signal send failed: {e}") async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via the Matrix adapter so native media uploads are preserved. Prefer the live gateway adapter: one persistent olm/megolm session for all sends. Ephemeral per-send connects re-init E2EE and claim one-time keys, which under bursts exhausts recipient OTKs and silently drops messages — so the ephemeral connect/disconnect path is only for standalone/cron. """ media_files = media_files or [] metadata = {"thread_id": thread_id} if thread_id else None # A runner that exists but whose adapter lookup fails is logged, not # swallowed: a silent fall-through would recreate the reconnect storm. live_adapter = None runner = _live_runner() if runner is not None: try: from gateway.config import Platform live_adapter = runner.adapters.get(Platform.MATRIX) except Exception: logger.warning( "Matrix: live gateway adapter lookup failed; falling back to an " "ephemeral connect (may re-init E2EE per send)", exc_info=True, ) live_adapter = None if live_adapter is not None: # Owned by the gateway — must NOT be disconnected; return before the # ephemeral adapter (and its ``finally`` disconnect) exists. return await _matrix_send_core( live_adapter, chat_id, message, media_files, metadata ) # --- Fallback: ephemeral adapter (standalone / cron context) --- try: from plugins.platforms.matrix.adapter import MatrixAdapter except ImportError: return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} adapter = MatrixAdapter(pconfig) try: connected = await adapter.connect() if not connected: return _error("Matrix connect failed") return await _matrix_send_core( adapter, chat_id, message, media_files, metadata ) except Exception as e: return _error(f"Matrix send failed: {e}") finally: try: await adapter.disconnect() except Exception: pass async def _matrix_send_core(adapter, chat_id, message, media_files, metadata): """Core send logic shared by live and ephemeral Matrix adapters.""" last_result = None if message.strip(): last_result = await adapter.send(chat_id, message, metadata=metadata) if not last_result.success: return _error(f"Matrix send failed: {last_result.error}") for media_path, is_voice in media_files: if not os.path.exists(media_path): return _error(f"Media file not found: {media_path}") ext = os.path.splitext(media_path)[1].lower() if ext in _IMAGE_EXTS: method = adapter.send_image_file elif ext in _VIDEO_EXTS: method = adapter.send_video elif (ext in _VOICE_EXTS and is_voice) or ext in _AUDIO_EXTS: method = adapter.send_voice else: method = adapter.send_document last_result = await method(chat_id, media_path, metadata=metadata) if not last_result.success: return _error(f"Matrix media send failed: {last_result.error}") if last_result is None: return {"error": "No deliverable text or media remained after processing MEDIA tags"} return { "success": True, "platform": "matrix", "chat_id": chat_id, "message_id": last_result.message_id, } async def _send_weixin(pconfig, chat_id, message, media_files=None): """Send via Weixin iLink using the native adapter helper.""" try: from gateway.platforms.weixin import check_weixin_requirements, send_weixin_direct if not check_weixin_requirements(): return {"error": "Weixin requirements not met. Need aiohttp + cryptography."} except ImportError: return {"error": "Weixin adapter not available."} try: return await send_weixin_direct( extra=pconfig.extra, token=pconfig.token, chat_id=chat_id, message=message, media_files=media_files, ) except Exception as e: return _error(f"Weixin send failed: {e}") async def _send_bluebubbles(extra, chat_id, message): """Send via BlueBubbles iMessage server using the adapter's REST API.""" try: from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements if not check_bluebubbles_requirements(): return {"error": "BlueBubbles requirements not met (need aiohttp + httpx)."} except ImportError: return {"error": "BlueBubbles adapter not available."} try: from gateway.config import PlatformConfig pconfig = PlatformConfig(extra=extra) adapter = BlueBubblesAdapter(pconfig) connected = await adapter.connect() if not connected: return _error("BlueBubbles: failed to connect to server") try: result = await adapter.send(chat_id, message) if not result.success: return _error(f"BlueBubbles send failed: {result.error}") return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": result.message_id} finally: await adapter.disconnect() except Exception as e: return _error(f"BlueBubbles send failed: {e}") async def _send_qqbot(pconfig, chat_id, message): """Send via the QQ Bot Open Platform REST API (no WebSocket needed).""" try: import httpx except ImportError: return _error("QQBot direct send requires httpx. Run: pip install httpx") # Profile-scoped secret lookup so a multiplex profile never borrows # another profile's QQ credentials. from gateway.config import _getenv extra = pconfig.extra or {} appid = extra.get("app_id") or _getenv("QQ_APP_ID", "") secret = (pconfig.token or extra.get("client_secret") or _getenv("QQ_CLIENT_SECRET", "")) if not appid or not secret: return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.") try: async with httpx.AsyncClient(timeout=15) as client: # Step 1: Get access token token_resp = await client.post( "https://bots.qq.com/app/getAppAccessToken", json={"appId": str(appid), "clientSecret": str(secret)}, ) if token_resp.status_code != 200: return _error(f"QQBot token request failed: {token_resp.status_code}") token_data = token_resp.json() access_token = token_data.get("access_token") if not access_token: return _error("QQBot: no access_token in response") # Step 2: QQ Bot API has separate endpoints for guild channels, C2C # (private) and groups; try them in that order, first 2xx wins. headers = { "Authorization": f"QQBot {access_token}", "Content-Type": "application/json", } payload = {"content": message[:4000], "msg_type": 0} endpoints = ( ("channel", f"https://api.sgroup.qq.com/channels/{chat_id}/messages"), ("c2c", f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"), ("group", f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"), ) statuses = [] for kind, url in endpoints: resp = await client.post(url, json=payload, headers=headers) if resp.status_code in {200, 201}: return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": resp.json().get("id")} statuses.append(f"{kind}={resp.status_code}") return _error(f"QQBot send failed: {' '.join(statuses)}") except Exception as e: return _error(f"QQBot send failed: {e}") async def _send_yuanbao(chat_id, message, media_files=None): """Send via the running Yuanbao adapter's persistent WebSocket (no throwaway client possible). chat_id: ``group:``, ``direct:`` or ````.""" try: from gateway.platforms.yuanbao import get_active_adapter, send_yuanbao_direct except ImportError: return _error("Yuanbao adapter module not available.") adapter = get_active_adapter() if adapter is None: return _error( "Yuanbao adapter is not running. " "Start the gateway with yuanbao platform enabled first." ) try: return await send_yuanbao_direct(adapter, chat_id, message, media_files=media_files) except Exception as e: return _error(f"Yuanbao send failed: {e}")