Merge branch 'simp/r3-33-D' into simp/integration3
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Let the agent react to a message with an emoji in the Hermes desktop app.
|
||||
|
||||
The conversational counterpart to the user's tapback: same reaction store,
|
||||
same one-per-author semantics, written with ``author="agent"``. Lives in the
|
||||
``desktop_ui`` toolset so it costs nothing on other surfaces (platform adapters
|
||||
expose reactions via ``send_message(action="react")``). Defaults to the message
|
||||
that triggered this turn so the model needn't thread row ids through tool calls,
|
||||
and emits ``message.reaction`` so the renderer paints it without a resume.
|
||||
The conversational counterpart to the user's tapback: same reaction store, same
|
||||
one-per-author semantics, written with ``author="agent"``. Lives in the ``desktop_ui``
|
||||
toolset so it costs nothing on other surfaces (platform adapters expose reactions via
|
||||
``send_message(action="react")``). Defaults to the message that triggered this turn so
|
||||
the model needn't thread row ids through tool calls, and emits ``message.reaction`` so
|
||||
the renderer paints it without a resume.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -30,15 +30,14 @@ def _react(emoji: str, message_row_id, messages_back, *, db, session_key: str) -
|
||||
row_id = message_row_id
|
||||
target_role = "user"
|
||||
if row_id is None:
|
||||
# Default target: the latest user message. `messages_back` steps to earlier
|
||||
# user turns (1 = the one before) — ids aren't visible to the model and
|
||||
# quoting text is ambiguous, but "two messages ago" is how a person thinks.
|
||||
# Default target: the latest user message. `messages_back` steps to earlier user
|
||||
# turns (1 = the one before) — ids aren't visible to the model and quoting text is
|
||||
# ambiguous, but "two messages ago" is how a person thinks.
|
||||
back = max(0, int(messages_back or 0))
|
||||
row_id = db.latest_message_row_id(session_key, role="user", offset=back)
|
||||
if row_id is None:
|
||||
return tool_error(
|
||||
f"No user message found {back} back." if back else "No user message to react to yet."
|
||||
)
|
||||
f"No user message found {back} back." if back else "No user message to react to yet.")
|
||||
else:
|
||||
target_role = db.get_message_role(session_key, int(row_id)) or "user"
|
||||
|
||||
@@ -49,28 +48,22 @@ def _react(emoji: str, message_row_id, messages_back, *, db, session_key: str) -
|
||||
if reactions is None:
|
||||
return tool_error(f"Message {row_id} is not part of this conversation.")
|
||||
|
||||
# Paint it live. A missing bridge (non-desktop surface) is not an error — the
|
||||
# reaction is persisted and shows on next load. `role` lets the renderer match
|
||||
# a live message that doesn't know its durable row id yet (learned on resume).
|
||||
# Paint it live. A missing bridge (non-desktop surface) is not an error — the reaction
|
||||
# is persisted and shows on next load. `role` lets the renderer match a live message
|
||||
# that doesn't know its durable row id yet (learned on resume).
|
||||
try:
|
||||
desktop_ui.emit(
|
||||
"message.reaction",
|
||||
{"row_id": int(row_id), "reactions": reactions, "role": target_role},
|
||||
)
|
||||
"message.reaction", {"row_id": int(row_id), "reactions": reactions, "role": target_role})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps(
|
||||
{"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False
|
||||
)
|
||||
return json.dumps({"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False)
|
||||
|
||||
|
||||
def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -> str:
|
||||
"""Attach (or with an empty ``emoji`` retract) the agent's reaction."""
|
||||
emoji = (emoji or "").strip()
|
||||
session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env(
|
||||
"HERMES_SESSION_ID", ""
|
||||
)
|
||||
session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env("HERMES_SESSION_ID", "")
|
||||
if not session_key:
|
||||
return tool_error("No active session — reactions need a persisted conversation.")
|
||||
|
||||
|
||||
+226
-338
@@ -14,67 +14,52 @@ _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's sendAudio only accepts MP3 / M4A; other audio goes via sendVoice
|
||||
# (Opus/OGG) or falls 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",
|
||||
}
|
||||
# Extensions that carry a native caption on the media bubble (photo/video/document).
|
||||
# Voice/audio notes are excluded: a caption on a voice note reads as a separate label,
|
||||
# so the accompanying text stays 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.
|
||||
# Native caption length limits (characters). Telegram's photo/video cap is 1024;
|
||||
# WhatsApp/Discord are far more generous, so one conservative shared ceiling.
|
||||
_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 deciding whether text rides on the media bubble as its caption.
|
||||
|
||||
Single chokepoint for ``MEDIA:<path> 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.
|
||||
``(caption, "")`` only for exactly one captionable file (not a voice/audio note)
|
||||
whose text fits ``max_caption_len``; otherwise ``(None, text)`` — multi-file
|
||||
caption→file association is ambiguous. Length is codepoints, which never
|
||||
under-counts Telegram's UTF-16 units for BMP text (over-counting 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:
|
||||
if not stripped or len(media) != 1 or len(stripped) > max_caption_len:
|
||||
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,
|
||||
)
|
||||
re.IGNORECASE)
|
||||
_GENERIC_SECRET_ASSIGN_RE = re.compile(
|
||||
r"\b(access_token|api[_-]?key|auth[_-]?token|signature|sig)\s*=\s*([^\s,;]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
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
|
||||
return _GENERIC_SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", redacted)
|
||||
|
||||
|
||||
def _error(message: str) -> dict:
|
||||
@@ -82,6 +67,14 @@ def _error(message: str) -> dict:
|
||||
return {"error": _sanitize_error_text(message)}
|
||||
|
||||
|
||||
def _success(platform: str, chat_id, warnings=None, **fields) -> dict:
|
||||
"""Standard success payload; ``warnings`` is only included when non-empty."""
|
||||
result = {"success": True, "platform": platform, "chat_id": chat_id, **fields}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
|
||||
|
||||
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:"):
|
||||
@@ -89,16 +82,16 @@ def _display_chat_id(platform_name: str, chat_id: str) -> str:
|
||||
return chat_id
|
||||
|
||||
|
||||
_NO_DELIVERABLE = "No deliverable text or media remained after processing MEDIA tags"
|
||||
|
||||
_TELEGRAM_TRANSIENT_MARKERS = (
|
||||
"bad gateway", "502", "too many requests", "429",
|
||||
"service unavailable", "503", "gateway timeout", "504",
|
||||
)
|
||||
"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."""
|
||||
"""Seconds to wait before retrying, or None when final. Honours ``retry_after``;
|
||||
timeouts are never retried (the send may have gone through); 5xx/429 back off."""
|
||||
retry_after = getattr(exc, "retry_after", None)
|
||||
if retry_after is not None:
|
||||
try:
|
||||
@@ -125,11 +118,7 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs)
|
||||
raise
|
||||
logger.warning(
|
||||
"Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s",
|
||||
attempt + 1,
|
||||
attempts,
|
||||
delay,
|
||||
_sanitize_error_text(exc),
|
||||
)
|
||||
attempt + 1, attempts, delay, _sanitize_error_text(exc))
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
@@ -139,9 +128,8 @@ def _is_telegram_thread_not_found(error: Exception) -> bool:
|
||||
|
||||
|
||||
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."""
|
||||
"""Bot honouring TELEGRAM_PROXY (``telegram.proxy_url``) — without it the standalone
|
||||
path times out where api.telegram.org is blocked. Falls back to a direct connection."""
|
||||
from telegram import Bot
|
||||
|
||||
try:
|
||||
@@ -153,20 +141,16 @@ def _telegram_bot(token):
|
||||
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),
|
||||
)
|
||||
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."""
|
||||
"""Topic id -> ``message_thread_id`` kwargs. Forum "General" is thread "1" inbound but
|
||||
the Bot API rejects message_thread_id=1, so it maps to no thread — same as the adapter."""
|
||||
if thread_id is None:
|
||||
return {}
|
||||
try:
|
||||
@@ -188,8 +172,8 @@ def _strip_mdv2_safe(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."""
|
||||
"""Bot API media method by extension: photo (unless forced 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:
|
||||
@@ -202,43 +186,37 @@ async def _telegram_send_media(bot, chat_id, f, ext, is_voice, force_document, *
|
||||
|
||||
|
||||
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:
|
||||
"""One formatted text chunk with adapter-matching fallbacks: thread-not-found -> retry
|
||||
without ``message_thread_id`` (dropped from ``text_kwargs`` for later chunks too);
|
||||
parse failure -> plain text."""
|
||||
async def send(text, mode):
|
||||
return await _send_telegram_message_with_retry(
|
||||
bot, chat_id=chat_id, text=chunk, parse_mode=parse_mode, **text_kwargs
|
||||
)
|
||||
bot, chat_id=chat_id, text=text, parse_mode=mode, **text_kwargs)
|
||||
|
||||
try:
|
||||
return await send(chunk, parse_mode)
|
||||
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.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
|
||||
)
|
||||
return await send(chunk, parse_mode)
|
||||
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
|
||||
)
|
||||
parse_mode, _sanitize_error_text(md_error))
|
||||
return await send(chunk if has_html else _strip_mdv2_safe(chunk), None)
|
||||
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."""
|
||||
"""Upload one file with adapter-matching fallbacks (thread-not-found -> no
|
||||
``message_thread_id``; caption parse failure -> plain caption). Retries re-seek
|
||||
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:
|
||||
@@ -266,8 +244,7 @@ async def _telegram_send_one_media(
|
||||
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"],
|
||||
)
|
||||
media_kwargs["message_thread_id"])
|
||||
f.seek(0)
|
||||
media_kwargs.pop("message_thread_id", None)
|
||||
return await _send(**media_kwargs)
|
||||
@@ -275,8 +252,7 @@ async def _telegram_send_one_media(
|
||||
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),
|
||||
)
|
||||
_sanitize_error_text(media_err))
|
||||
f.seek(0)
|
||||
media_kwargs.pop("parse_mode", None)
|
||||
if not has_html and media_kwargs.get("caption"):
|
||||
@@ -286,17 +262,12 @@ async def _telegram_send_one_media(
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""One-shot Telegram Bot API send. Markdown -> MarkdownV2 via the adapter's
|
||||
``format_message``; text already containing HTML tags is sent as HTML instead.
|
||||
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
|
||||
@@ -324,75 +295,55 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
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.
|
||||
# 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 cap, so re-check in UTF-16 units and fall back to a separate body.
|
||||
_tg_caption = None
|
||||
from gateway.platforms.base import BasePlatformAdapter, utf16_len
|
||||
_cap, _ = _media_caption_split(
|
||||
message, media_files, max_caption_len=_TELEGRAM_CAPTION_LIMIT
|
||||
)
|
||||
_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".
|
||||
# Chunk *after* formatting, in UTF-16 units: MarkdownV2/HTML escaping inflates
|
||||
# text, so a raw-<4096 message can exceed the limit once formatted.
|
||||
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
|
||||
)
|
||||
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.
|
||||
# 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
|
||||
)
|
||||
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),
|
||||
)
|
||||
_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,
|
||||
)
|
||||
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
|
||||
return {"error": _NO_DELIVERABLE, **({"warnings": warnings} if warnings else {})}
|
||||
return _success("telegram", chat_id, warnings, message_id=str(last_msg.message_id))
|
||||
except ImportError:
|
||||
return {"error": "python-telegram-bot not installed. Run: pip install python-telegram-bot"}
|
||||
except Exception as e:
|
||||
@@ -408,9 +359,24 @@ def _live_runner():
|
||||
return None
|
||||
|
||||
|
||||
def _live_adapter(platform, *, lookup_failed_warning=None):
|
||||
"""Return ``(runner, adapter)`` for the running gateway, or ``(runner, None)``.
|
||||
A runner whose adapter lookup raises is logged when a warning is given, never
|
||||
silently swallowed (a silent fall-through could recreate a reconnect storm)."""
|
||||
runner = _live_runner()
|
||||
if runner is None:
|
||||
return None, None
|
||||
try:
|
||||
return runner, runner.adapters.get(platform)
|
||||
except Exception:
|
||||
if lookup_failed_warning:
|
||||
logger.warning(lookup_failed_warning, exc_info=True)
|
||||
return runner, 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."""
|
||||
"""``(standalone_sender_fn, None)`` for a registered plugin or ``(None, error_dict)``;
|
||||
``discover`` runs the idempotent plugin scan first."""
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
if discover:
|
||||
@@ -423,8 +389,7 @@ def _plugin_standalone_sender(platform_name, *, label=None, discover=True):
|
||||
|
||||
|
||||
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_<platform>`` helpers now live in the plugins)."""
|
||||
"""One-shot text send through a plugin's ``standalone_sender_fn``."""
|
||||
sender, err = _plugin_standalone_sender(platform_name)
|
||||
if err:
|
||||
return err
|
||||
@@ -432,13 +397,10 @@ async def _registry_standalone_send(platform_name, pconfig, chat_id, message, th
|
||||
|
||||
|
||||
async def _resolve_slack_user_target(token, chat_id):
|
||||
"""Resolve ``user:U...`` / ``user_name:<handle>`` 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)``.
|
||||
"""
|
||||
"""Resolve ``user:U...`` / ``user_name:<handle>`` to a D... DM conversation
|
||||
(chat.postMessage needs a conversation ID). ``user_name:`` maps to a user id via
|
||||
users.list first (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:
|
||||
@@ -447,19 +409,17 @@ async def _resolve_slack_user_target(token, chat_id):
|
||||
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"
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url())
|
||||
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:
|
||||
async with session.post(
|
||||
f"https://slack.com/api/{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
|
||||
matches, cursor = [], None
|
||||
for _page in range(20):
|
||||
payload = {"limit": 200}
|
||||
if cursor:
|
||||
@@ -470,8 +430,7 @@ async def _resolve_slack_user_target(token, chat_id):
|
||||
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.
|
||||
# Stable handle only: display/real names are mutable and non-unique.
|
||||
if str(member.get("name", "")).strip().lower() == query:
|
||||
matches.append(member)
|
||||
cursor = (data.get("response_metadata") or {}).get("next_cursor")
|
||||
@@ -495,8 +454,7 @@ async def _resolve_slack_user_target(token, chat_id):
|
||||
if not opened.get("ok"):
|
||||
return None, _error(
|
||||
f"Slack conversations.open error: {opened.get('error', 'unknown')}. "
|
||||
"Check bot permissions (im:write)."
|
||||
)
|
||||
"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")
|
||||
@@ -505,28 +463,60 @@ async def _resolve_slack_user_target(token, chat_id):
|
||||
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.
|
||||
async def _signal_send_batch(post, scheduler, rl, idx, n_batches, att_batch, batch_message):
|
||||
"""One Signal batch under the scheduler with rate-limit retries. None on success,
|
||||
False when retries were exhausted (batch lost), error dict for a non-rate-limit RPC error."""
|
||||
n = len(att_batch)
|
||||
for attempt in range(1, rl.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)
|
||||
return None
|
||||
|
||||
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.
|
||||
"""
|
||||
err = data["error"]
|
||||
if not rl._is_signal_rate_limit_error(err):
|
||||
return _error(f"Signal RPC error on batch {idx + 1}/{n_batches}: {err}")
|
||||
|
||||
server_retry_after = rl._extract_retry_after_seconds(err)
|
||||
scheduler.feedback(server_retry_after, n)
|
||||
retry_after_label = f"{server_retry_after:.0f}s" if server_retry_after else "unknown"
|
||||
|
||||
if attempt >= rl.SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
"Signal: rate-limit retries exhausted on batch %d/%d "
|
||||
"(%d attachments lost, server retry_after=%s)",
|
||||
idx + 1, n_batches, n, retry_after_label)
|
||||
return False
|
||||
logger.warning(
|
||||
"Signal: rate-limited on batch %d/%d "
|
||||
"(attempt %d/%d, server retry_after=%s); "
|
||||
"scheduler will pace the retry",
|
||||
idx + 1, n_batches, attempt, rl.SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, retry_after_label)
|
||||
except Exception as e:
|
||||
if attempt >= rl.SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
"Signal: send error on batch %d/%d after %d attempts: %s",
|
||||
idx + 1, n_batches, attempt, str(e))
|
||||
return False
|
||||
logger.warning(
|
||||
"Signal: transient error on batch %d/%d (attempt %d/%d): %s; will retry",
|
||||
idx + 1, n_batches, attempt, rl.SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, str(e))
|
||||
|
||||
|
||||
async def _send_signal(extra, chat_id, message, media_files=None):
|
||||
"""signal-cli JSON-RPC send. Attachments go in SIGNAL_MAX_ATTACHMENTS_PER_MSG batches
|
||||
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 import signal_rate_limit as rl
|
||||
from gateway.platforms.signal_format import markdown_to_signal
|
||||
|
||||
try:
|
||||
@@ -536,129 +526,74 @@ async def _send_signal(extra, chat_id, message, media_files=None):
|
||||
return {"error": "Signal account not configured"}
|
||||
|
||||
valid_media = media_files or []
|
||||
attachment_paths = []
|
||||
attachment_paths = [path for path, _is_voice in valid_media if os.path.exists(path)]
|
||||
for media_path, _is_voice in valid_media:
|
||||
if os.path.exists(media_path):
|
||||
attachment_paths.append(media_path)
|
||||
else:
|
||||
if not os.path.exists(media_path):
|
||||
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 = [[]]
|
||||
per_batch = rl.SIGNAL_MAX_ATTACHMENTS_PER_MSG
|
||||
att_batches = [
|
||||
attachment_paths[i:i + per_batch] for i in range(0, len(attachment_paths), per_batch)
|
||||
] or [[]]
|
||||
n_batches = len(att_batches)
|
||||
|
||||
plain_text, text_styles = markdown_to_signal(message)
|
||||
|
||||
def _rpc_params(text):
|
||||
async def _rpc_send(text, *, id_prefix, timeout, attachments=None, styled=False):
|
||||
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 styled and text 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)
|
||||
if attachments:
|
||||
params["attachments"] = attachments
|
||||
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):
|
||||
resp = await _rpc_send(
|
||||
batch_message, id_prefix="send", timeout=rl._signal_send_timeout(len(batch_attachments)),
|
||||
attachments=batch_attachments, styled=True)
|
||||
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()
|
||||
scheduler = rl.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),
|
||||
)
|
||||
scheduler.state(), len(attachment_paths), n_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)}.)"
|
||||
)
|
||||
if estimated >= rl.SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
|
||||
# Best-effort one-shot RPC for a user-facing pacing notice.
|
||||
notice = (
|
||||
f"(More images coming — pausing ~{rl._format_wait(estimated)} "
|
||||
f"for Signal rate limit, batch {idx + 1}/{n_batches}.)")
|
||||
try:
|
||||
await _rpc_send(notice, id_prefix="notice", timeout=30.0)
|
||||
except Exception as _e:
|
||||
logger.warning("Signal: inline notice failed: %s", _e)
|
||||
|
||||
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)
|
||||
)
|
||||
outcome = await _signal_send_batch(
|
||||
_post, scheduler, rl, idx, n_batches, att_batch, plain_text if idx == 0 else "")
|
||||
if outcome is False:
|
||||
failed_batches.append(idx + 1)
|
||||
elif outcome is not None:
|
||||
return outcome
|
||||
|
||||
warnings = []
|
||||
if len(attachment_paths) < len(valid_media):
|
||||
@@ -666,56 +601,35 @@ async def _send_signal(extra, chat_id, message, media_files=None):
|
||||
if failed_batches:
|
||||
warnings.append(
|
||||
f"Signal rate-limited {len(failed_batches)} batch(es) "
|
||||
f"(#{', #'.join(str(b) for b in failed_batches)})"
|
||||
)
|
||||
f"(#{', #'.join(str(b) for b in failed_batches)})")
|
||||
|
||||
if failed_batches and len(failed_batches) == len(att_batches):
|
||||
if failed_batches and len(failed_batches) == n_batches:
|
||||
return _error(
|
||||
f"Signal: every batch ({len(att_batches)}) hit rate limit; "
|
||||
f"no attachments delivered"
|
||||
)
|
||||
f"Signal: every batch ({n_batches}) hit rate limit; no attachments delivered")
|
||||
|
||||
result = {"success": True, "platform": "signal", "chat_id": _display_chat_id("signal", chat_id)}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
return _success("signal", _display_chat_id("signal", chat_id), warnings)
|
||||
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.
|
||||
"""
|
||||
"""Matrix adapter send (native media preserved). Prefer the live gateway adapter's
|
||||
persistent olm/megolm session: 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
|
||||
|
||||
from gateway.config import Platform
|
||||
_, live_adapter = _live_adapter(
|
||||
Platform.MATRIX,
|
||||
lookup_failed_warning=(
|
||||
"Matrix: live gateway adapter lookup failed; falling back to an "
|
||||
"ephemeral connect (may re-init E2EE per send)"))
|
||||
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
|
||||
)
|
||||
return await _matrix_send_core(live_adapter, chat_id, message, media_files, metadata)
|
||||
|
||||
# --- Fallback: ephemeral adapter (standalone / cron context) ---
|
||||
try:
|
||||
@@ -725,12 +639,9 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None,
|
||||
|
||||
adapter = MatrixAdapter(pconfig)
|
||||
try:
|
||||
connected = await adapter.connect()
|
||||
if not connected:
|
||||
if not await adapter.connect():
|
||||
return _error("Matrix connect failed")
|
||||
return await _matrix_send_core(
|
||||
adapter, chat_id, message, media_files, metadata
|
||||
)
|
||||
return await _matrix_send_core(adapter, chat_id, message, media_files, metadata)
|
||||
except Exception as e:
|
||||
return _error(f"Matrix send failed: {e}")
|
||||
finally:
|
||||
@@ -763,19 +674,12 @@ async def _matrix_send_core(adapter, chat_id, message, media_files, metadata):
|
||||
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,
|
||||
}
|
||||
return {"error": _NO_DELIVERABLE}
|
||||
return _success("matrix", chat_id, message_id=last_result.message_id)
|
||||
|
||||
|
||||
async def _send_weixin(pconfig, chat_id, message, media_files=None):
|
||||
@@ -789,12 +693,8 @@ async def _send_weixin(pconfig, chat_id, message, media_files=None):
|
||||
|
||||
try:
|
||||
return await send_weixin_direct(
|
||||
extra=pconfig.extra,
|
||||
token=pconfig.token,
|
||||
chat_id=chat_id,
|
||||
message=message,
|
||||
media_files=media_files,
|
||||
)
|
||||
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}")
|
||||
|
||||
@@ -810,16 +710,14 @@ async def _send_bluebubbles(extra, chat_id, message):
|
||||
|
||||
try:
|
||||
from gateway.config import PlatformConfig
|
||||
pconfig = PlatformConfig(extra=extra)
|
||||
adapter = BlueBubblesAdapter(pconfig)
|
||||
connected = await adapter.connect()
|
||||
if not connected:
|
||||
adapter = BlueBubblesAdapter(PlatformConfig(extra=extra))
|
||||
if not await adapter.connect():
|
||||
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}
|
||||
return _success("bluebubbles", chat_id, message_id=result.message_id)
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
except Exception as e:
|
||||
@@ -833,8 +731,7 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
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.
|
||||
# Profile-scoped lookup so a multiplex profile never borrows another's QQ credentials.
|
||||
from gateway.config import _getenv
|
||||
|
||||
extra = pconfig.extra or {}
|
||||
@@ -846,36 +743,28 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
|
||||
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)},
|
||||
)
|
||||
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")
|
||||
access_token = token_resp.json().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.
|
||||
# Separate endpoints for guild channels, C2C (private) and groups; first 2xx wins.
|
||||
headers = {
|
||||
"Authorization": f"QQBot {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
"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"),
|
||||
)
|
||||
("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")}
|
||||
return _success("qqbot", 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:
|
||||
@@ -883,8 +772,8 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
|
||||
|
||||
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:<code>``, ``direct:<id>`` or ``<id>``."""
|
||||
"""Send via the running Yuanbao adapter's persistent WebSocket (no throwaway client
|
||||
possible). chat_id: ``group:<code>``, ``direct:<id>`` or ``<id>``."""
|
||||
try:
|
||||
from gateway.platforms.yuanbao import get_active_adapter, send_yuanbao_direct
|
||||
except ImportError:
|
||||
@@ -894,8 +783,7 @@ async def _send_yuanbao(chat_id, message, media_files=None):
|
||||
if adapter is None:
|
||||
return _error(
|
||||
"Yuanbao adapter is not running. "
|
||||
"Start the gateway with yuanbao platform enabled first."
|
||||
)
|
||||
"Start the gateway with yuanbao platform enabled first.")
|
||||
|
||||
try:
|
||||
return await send_yuanbao_direct(adapter, chat_id, message, media_files=media_files)
|
||||
|
||||
+77
-127
@@ -7,12 +7,10 @@ logger = logging.getLogger("tools.send_message_tool")
|
||||
|
||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
|
||||
# Slack conversation IDs: C (public channel), G (private/group channel), D (DM).
|
||||
# Must be uppercase alphanumeric, 9+ chars. User IDs (U...) are parsed as
|
||||
# explicit user targets (``user:U...``) and are converted to D... conversations
|
||||
# via conversations.open before chat.postMessage — posting directly to a U/W
|
||||
# ID fails because the API requires a conversation ID. ``@handle`` targets are
|
||||
# resolved through users.list first (``user_name:...``).
|
||||
# Slack conversation IDs: C (public), G (private/group), D (DM); uppercase alnum, 9+ chars.
|
||||
# User IDs (U...) become ``user:U...`` and are opened as D... conversations before
|
||||
# chat.postMessage (posting straight to a U/W id fails); ``@handle`` -> ``user_name:...``
|
||||
# is resolved through users.list first.
|
||||
_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$")
|
||||
_SLACK_USER_ID_RE = re.compile(r"^\s*(U[A-Z0-9]{8,})\s*$")
|
||||
_SLACK_USER_NAME_RE = re.compile(r"^\s*@([A-Za-z0-9._-]{1,80})\s*$")
|
||||
@@ -23,47 +21,32 @@ _WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Z
|
||||
_YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$")
|
||||
# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
|
||||
_NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
|
||||
# Platforms that address recipients by phone number and accept E.164 format
|
||||
# (with a leading '+'). Without this, "+15551234567" fails the isdigit() check
|
||||
# below and falls through to channel-name resolution, which has no way to
|
||||
# resolve a raw phone number. Keeping the '+' preserves the E.164 form that
|
||||
# downstream adapters (signal, etc.) expect.
|
||||
# Platforms addressing recipients by E.164 phone number ("+1555..."): without this the
|
||||
# '+' fails the isdigit() rule and falls through to channel-name resolution, which cannot
|
||||
# resolve a raw number. The '+' is kept because the adapters expect the E.164 form.
|
||||
_PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"})
|
||||
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
|
||||
# Photon DM chat GUID (mirrors _DM_CHAT_GUID_RE in the photon adapter).
|
||||
_PHOTON_DM_GUID_RE = re.compile(r"^any;-;\+\d{6,}$")
|
||||
# WhatsApp JIDs: group chats (<digits>@g.us), individual users
|
||||
# (<phone>@s.whatsapp.net), linked identities (<id>@lid), and broadcast /
|
||||
# newsletter chats. These are explicit native targets the bridge accepts
|
||||
# verbatim — they must NOT fall through to home-channel resolution.
|
||||
# WhatsApp JIDs (groups <digits>@g.us, users <phone>@s.whatsapp.net, linked ids @lid,
|
||||
# broadcast/newsletter): native targets the bridge accepts verbatim — never home-channel.
|
||||
_WHATSAPP_JID_RE = re.compile(
|
||||
r"^\s*[\w-]+@(?:g\.us|s\.whatsapp\.net|lid|broadcast|newsletter)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Buzz channels and DMs use native UUID identifiers. They are explicit
|
||||
# targets and must never substitute the configured home channel.
|
||||
r"^\s*[\w-]+@(?:g\.us|s\.whatsapp\.net|lid|broadcast|newsletter)\s*$", re.IGNORECASE)
|
||||
# Buzz channels/DMs are native UUIDs: explicit targets, never the home channel.
|
||||
_BUZZ_UUID_RE = re.compile(
|
||||
r"^\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Email addresses — a valid email like "user@domain.com" should be treated as
|
||||
# an explicit target for the email platform, not fall through to channel-name
|
||||
# resolution which has no way to resolve a raw address.
|
||||
r"^\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s*$", re.IGNORECASE)
|
||||
# A valid address is an explicit email target, not a channel name to resolve.
|
||||
_EMAIL_TARGET_RE = re.compile(r"^\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\s*$")
|
||||
# Most platforms read their home channel from "<PLATFORM>_HOME_CHANNEL", but a
|
||||
# few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the
|
||||
# generic "<PLATFORM>_HOME_CHANNEL" hint would point users at a variable that is
|
||||
# never read. Map the exceptions so the error guidance is actually actionable.
|
||||
# Home-channel env var exceptions to "<PLATFORM>_HOME_CHANNEL" (email reads
|
||||
# EMAIL_HOME_ADDRESS, see gateway/config.py) so the error guidance is actionable.
|
||||
_HOME_CHANNEL_ENV_OVERRIDES = {"email": "EMAIL_HOME_ADDRESS"}
|
||||
|
||||
|
||||
_UNRESOLVED = object() # sentinel: stop parsing, target is NOT explicit (skip generic rules)
|
||||
|
||||
|
||||
# Per-platform explicit-target parsers: target_ref -> (chat_id, thread_id),
|
||||
# None to fall through to the generic rules in _parse_target_ref, or
|
||||
# _UNRESOLVED. Order inside each parser matters (e.g. Slack thread form
|
||||
# before bare conversation id).
|
||||
# Per-platform explicit-target parsers: target_ref -> (chat_id, thread_id), None to fall
|
||||
# through to the generic rules in _parse_target_ref, or _UNRESOLVED. Order inside each
|
||||
# parser matters (e.g. Slack thread form before bare conversation id).
|
||||
def _parse_regex_groups(regex, *, thread_group=True):
|
||||
"""Explicit when ``regex`` fully matches: chat_id = group 1, thread = group 2 (or None)."""
|
||||
def parse(ref):
|
||||
@@ -74,6 +57,13 @@ def _parse_regex_groups(regex, *, thread_group=True):
|
||||
return parse
|
||||
|
||||
|
||||
def _parse_regex_stripped(regex):
|
||||
"""Explicit when ``regex`` fully matches; returns the stripped ref verbatim."""
|
||||
def parse(ref):
|
||||
return (ref.strip(), None) if regex.fullmatch(ref) else None
|
||||
return parse
|
||||
|
||||
|
||||
def _parse_telegram(ref):
|
||||
# "<chat_id>[:<topic_id>]" or an @username (usernames must not be force-int'd).
|
||||
parsed = _parse_regex_groups(_TELEGRAM_TOPIC_TARGET_RE)(ref)
|
||||
@@ -85,24 +75,26 @@ def _parse_telegram(ref):
|
||||
return (username, None) if username else None
|
||||
|
||||
|
||||
# (regex, chat_id template, thread comes from group 2) — thread form before bare id.
|
||||
_SLACK_FORMS = (
|
||||
(_SLACK_THREAD_TARGET_RE, "{}", True),
|
||||
(_SLACK_TARGET_RE, "{}", False),
|
||||
(_SLACK_USER_ID_RE, "user:{}", False),
|
||||
(_SLACK_MENTION_RE, "user:{}", False),
|
||||
(_SLACK_USER_NAME_RE, "user_name:{}", False))
|
||||
|
||||
|
||||
def _parse_slack(ref):
|
||||
match = _SLACK_THREAD_TARGET_RE.fullmatch(ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
match = _SLACK_TARGET_RE.fullmatch(ref)
|
||||
if match:
|
||||
return match.group(1), None
|
||||
match = _SLACK_USER_ID_RE.fullmatch(ref) or _SLACK_MENTION_RE.fullmatch(ref)
|
||||
if match:
|
||||
return f"user:{match.group(1)}", None
|
||||
match = _SLACK_USER_NAME_RE.fullmatch(ref)
|
||||
return (f"user_name:{match.group(1)}", None) if match else None
|
||||
for regex, template, has_thread in _SLACK_FORMS:
|
||||
match = regex.fullmatch(ref)
|
||||
if match:
|
||||
return template.format(match.group(1)), (match.group(2) if has_thread else None)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_matrix(ref):
|
||||
# "<room>:$<event_id>" addresses a thread (rfind: room ids contain ':').
|
||||
# Bare "!room" / "@user" are explicit too, but via the generic rule so the
|
||||
# numeric check keeps precedence exactly as before.
|
||||
# "<room>:$<event_id>" addresses a thread (rfind: room ids contain ':'). Bare "!room" /
|
||||
# "@user" are explicit too, but via the generic rule so the numeric check keeps precedence.
|
||||
trimmed = ref.strip()
|
||||
split_idx = trimmed.rfind(":$")
|
||||
if split_idx > 0:
|
||||
@@ -110,16 +102,9 @@ def _parse_matrix(ref):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_regex_stripped(regex):
|
||||
"""Explicit when ``regex`` fully matches; returns the stripped ref verbatim."""
|
||||
def parse(ref):
|
||||
return (ref.strip(), None) if regex.fullmatch(ref) else None
|
||||
return parse
|
||||
|
||||
|
||||
def _parse_yuanbao(ref):
|
||||
# "group:<code>" / "direct:<id>"; a bare number is a group code, never a
|
||||
# generic numeric chat id (yuanbao never falls through to the generic rules).
|
||||
# "group:<code>" / "direct:<id>"; a bare number is a group code, never a generic
|
||||
# numeric chat id (yuanbao never falls through to the generic rules).
|
||||
match = _YUANBAO_TARGET_RE.fullmatch(ref)
|
||||
if match:
|
||||
return match.group(1), None
|
||||
@@ -129,8 +114,8 @@ def _parse_yuanbao(ref):
|
||||
|
||||
|
||||
def _parse_nonempty(ref):
|
||||
# ntfy topics and WeCom ids (wr/wc groups, wo/bare users — the adapter
|
||||
# picks the send command) are explicit whenever non-empty.
|
||||
# ntfy topics and WeCom ids (wr/wc groups, wo/bare users — the adapter picks the
|
||||
# send command) are explicit whenever non-empty.
|
||||
stripped = ref.strip()
|
||||
return (stripped, None) if stripped else None
|
||||
|
||||
@@ -160,18 +145,15 @@ _PLATFORM_PARSERS = {
|
||||
"signal": _parse_signal,
|
||||
"wecom": _parse_nonempty,
|
||||
# Photon DM GUIDs are adapter-native ids (mirrors the react handler).
|
||||
"photon": _parse_regex_stripped(_PHOTON_DM_GUID_RE),
|
||||
}
|
||||
"photon": _parse_regex_stripped(_PHOTON_DM_GUID_RE)}
|
||||
|
||||
|
||||
def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
"""Parse a tool target into (chat_id, thread_id, explicit).
|
||||
|
||||
Platform parser first, then the shared rules in this order: E.164 phone
|
||||
numbers (keeping the '+' the signal/sms/whatsapp adapters expect), bare
|
||||
numeric ids, Matrix ``!room``/``@user``, XMPP JIDs (user@server or
|
||||
room@conference.server). Anything else is not explicit and goes to
|
||||
channel-directory resolution.
|
||||
Platform parser first, then the shared rules in this order: E.164 phone numbers
|
||||
(keeping the '+' the signal/sms/whatsapp adapters expect), bare numeric ids, Matrix
|
||||
``!room``/``@user``, XMPP JIDs. Anything else goes to channel-directory resolution.
|
||||
"""
|
||||
parser = _PLATFORM_PARSERS.get(platform_name)
|
||||
if parser is not None:
|
||||
@@ -196,22 +178,18 @@ def resolve_send_target(
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Resolve one send target the same way for every caller (model tool, CLI, cron).
|
||||
|
||||
Channel-directory IDs are trusted. Plugin platforms must explicitly parse
|
||||
native target syntax; for the model-facing send tool (the default), a
|
||||
target that can't be resolved is an error — the model can read the error
|
||||
and pick a listed target instead.
|
||||
Channel-directory IDs are trusted. Plugin platforms must explicitly parse native
|
||||
target syntax; for the model-facing send tool (the default) an unresolvable target
|
||||
is an error the model can read and pick a listed target instead.
|
||||
|
||||
``pass_unresolved_references=True`` restores the old pass-through behavior for
|
||||
callers that have no model in the loop (cron delivering a stored job's
|
||||
output, react/unreact on platform-native message ids): if the target
|
||||
can't be resolved and the platform is built in, or is a plugin platform
|
||||
that declares no parser, the string is handed to the adapter exactly as
|
||||
written and the adapter decides whether it's valid. A plugin platform
|
||||
that DOES declare a parser stays strict for every caller — its parser is
|
||||
the authority on native syntax.
|
||||
``pass_unresolved_references=True`` is for callers with no model in the loop (cron
|
||||
delivering a stored job's output, react/unreact on platform-native message ids): an
|
||||
unresolvable target on a built-in platform, or on a plugin platform that declares no
|
||||
parser, is handed to the adapter exactly as written and the adapter decides. A plugin
|
||||
platform that DOES declare a parser stays strict for every caller.
|
||||
|
||||
The optional validator has the final say over parser-normalized,
|
||||
directory-resolved, and passed-through IDs alike.
|
||||
The optional validator has the final say over parser-normalized, directory-resolved,
|
||||
and passed-through IDs alike.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
from gateway.platform_registry import platform_registry
|
||||
@@ -224,9 +202,7 @@ def resolve_send_target(
|
||||
try:
|
||||
verdict = entry.validate_target_ref_fn(candidate)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Plugin target validator failed for %s", platform_name, exc_info=True
|
||||
)
|
||||
logger.debug("Plugin target validator failed for %s", platform_name, exc_info=True)
|
||||
return f"Target validator failed for platform '{platform_name}'"
|
||||
if verdict is True:
|
||||
return None
|
||||
@@ -234,13 +210,15 @@ def resolve_send_target(
|
||||
return f"Invalid target '{target_ref}' on {platform_name}: {verdict}"
|
||||
return f"Invalid target '{target_ref}' on {platform_name}"
|
||||
|
||||
def _validated(chat_id, thread_id):
|
||||
error = _validate(chat_id)
|
||||
return (None, None, error) if error else (chat_id, thread_id, None)
|
||||
|
||||
if entry is not None and entry.parse_target_ref_fn is not None:
|
||||
try:
|
||||
parsed = entry.parse_target_ref_fn(target_ref)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Plugin target parser failed for %s", platform_name, exc_info=True
|
||||
)
|
||||
logger.debug("Plugin target parser failed for %s", platform_name, exc_info=True)
|
||||
return None, None, f"Target parser failed for platform '{platform_name}'"
|
||||
if parsed is not None:
|
||||
if (
|
||||
@@ -248,31 +226,15 @@ def resolve_send_target(
|
||||
or len(parsed) != 2
|
||||
or not isinstance(parsed[0], str)
|
||||
or not parsed[0]
|
||||
or (parsed[1] is not None and not isinstance(parsed[1], str))
|
||||
):
|
||||
or (parsed[1] is not None and not isinstance(parsed[1], str))):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
f"Target parser for platform '{platform_name}' returned an invalid result",
|
||||
)
|
||||
parsed_chat_id, parsed_thread_id = parsed
|
||||
error = _validate(parsed_chat_id)
|
||||
return (None, None, error) if error else (
|
||||
parsed_chat_id,
|
||||
parsed_thread_id,
|
||||
None,
|
||||
)
|
||||
None, None,
|
||||
f"Target parser for platform '{platform_name}' returned an invalid result")
|
||||
return _validated(*parsed)
|
||||
|
||||
parsed_chat_id, parsed_thread_id, explicit = _parse_target_ref(
|
||||
platform_name, target_ref
|
||||
)
|
||||
parsed_chat_id, parsed_thread_id, explicit = _parse_target_ref(platform_name, target_ref)
|
||||
if explicit and parsed_chat_id is not None:
|
||||
error = _validate(parsed_chat_id)
|
||||
return (None, None, error) if error else (
|
||||
parsed_chat_id,
|
||||
parsed_thread_id,
|
||||
None,
|
||||
)
|
||||
return _validated(parsed_chat_id, parsed_thread_id)
|
||||
|
||||
resolution_failed = False
|
||||
try:
|
||||
@@ -283,16 +245,8 @@ def resolve_send_target(
|
||||
resolved = None
|
||||
resolution_failed = True
|
||||
if resolved:
|
||||
parsed_chat_id, parsed_thread_id, _ = _parse_target_ref(
|
||||
platform_name, resolved
|
||||
)
|
||||
chat_id = parsed_chat_id or resolved
|
||||
error = _validate(chat_id)
|
||||
return (None, None, error) if error else (
|
||||
chat_id,
|
||||
parsed_thread_id,
|
||||
None,
|
||||
)
|
||||
parsed_chat_id, parsed_thread_id, _ = _parse_target_ref(platform_name, resolved)
|
||||
return _validated(parsed_chat_id or resolved, parsed_thread_id)
|
||||
|
||||
is_builtin = platform_name in {member.value for member in Platform}
|
||||
if entry is None and not is_builtin:
|
||||
@@ -306,24 +260,20 @@ def resolve_send_target(
|
||||
logger.debug(
|
||||
"Handing unresolved target '%s' to the %s adapter unchanged "
|
||||
"(the adapter validates it)",
|
||||
target_ref, platform_name,
|
||||
)
|
||||
target_ref, platform_name)
|
||||
return target_ref, None, None
|
||||
|
||||
if entry is not None and entry.source == "plugin" and not is_builtin:
|
||||
if pass_unresolved_references and entry.parse_target_ref_fn is None:
|
||||
return _pass_through_unresolved()
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
None, None,
|
||||
f"Could not resolve '{target_ref}' on {platform_name}. "
|
||||
"The plugin parser did not recognize it and no channel-directory entry matched.",
|
||||
)
|
||||
"The plugin parser did not recognize it and no channel-directory entry matched.")
|
||||
if pass_unresolved_references:
|
||||
return _pass_through_unresolved()
|
||||
hint = (
|
||||
"Try using a numeric channel ID instead."
|
||||
if resolution_failed
|
||||
else "Use send_message(action='list') to see available targets."
|
||||
)
|
||||
else "Use send_message(action='list') to see available targets.")
|
||||
return None, None, f"Could not resolve '{target_ref}' on {platform_name}. {hint}"
|
||||
|
||||
+201
-382
@@ -1,7 +1,7 @@
|
||||
"""Send Message Tool -- cross-channel messaging via platform APIs.
|
||||
|
||||
Sends a message to a user or channel on any connected messaging platform
|
||||
(Telegram, Discord, Slack). Supports listing available targets and resolving
|
||||
(Telegram, Discord, Slack, ...), lists available targets and resolves
|
||||
human-friendly channel names to IDs. Works in both CLI and gateway contexts.
|
||||
"""
|
||||
|
||||
@@ -14,58 +14,22 @@ from agent.secret_scope import get_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Re-exported: tests and sibling modules import these via tools.send_message_tool.
|
||||
from tools.send_message_targets import ( # noqa: F401
|
||||
_BUZZ_UUID_RE,
|
||||
_E164_TARGET_RE,
|
||||
_EMAIL_TARGET_RE,
|
||||
_FEISHU_TARGET_RE,
|
||||
_HOME_CHANNEL_ENV_OVERRIDES,
|
||||
_NUMERIC_TOPIC_RE,
|
||||
_PHONE_PLATFORMS,
|
||||
_PHOTON_DM_GUID_RE,
|
||||
_SLACK_MENTION_RE,
|
||||
_SLACK_TARGET_RE,
|
||||
_SLACK_THREAD_TARGET_RE,
|
||||
_SLACK_USER_ID_RE,
|
||||
_SLACK_USER_NAME_RE,
|
||||
_TELEGRAM_TOPIC_TARGET_RE,
|
||||
_WEIXIN_TARGET_RE,
|
||||
_WHATSAPP_JID_RE,
|
||||
_YUANBAO_TARGET_RE,
|
||||
_parse_target_ref,
|
||||
resolve_send_target,
|
||||
)
|
||||
_HOME_CHANNEL_ENV_OVERRIDES, _SLACK_USER_ID_RE, _parse_target_ref, resolve_send_target)
|
||||
from tools.send_message_senders import ( # noqa: F401
|
||||
_AUDIO_EXTS,
|
||||
_CAPTIONABLE_EXTS,
|
||||
_DEFAULT_CAPTION_LIMIT,
|
||||
_GENERIC_SECRET_ASSIGN_RE,
|
||||
_IMAGE_EXTS,
|
||||
_TELEGRAM_CAPTION_LIMIT,
|
||||
_TELEGRAM_SEND_AUDIO_EXTS,
|
||||
_URL_SECRET_QUERY_RE,
|
||||
_VIDEO_EXTS,
|
||||
_VOICE_EXTS,
|
||||
_display_chat_id,
|
||||
_error,
|
||||
_is_telegram_thread_not_found,
|
||||
_live_runner,
|
||||
_matrix_send_core,
|
||||
_media_caption_split,
|
||||
_plugin_standalone_sender,
|
||||
_registry_standalone_send,
|
||||
_resolve_slack_user_target,
|
||||
_sanitize_error_text,
|
||||
_send_bluebubbles,
|
||||
_send_matrix_via_adapter,
|
||||
_send_qqbot,
|
||||
_send_signal,
|
||||
_send_telegram,
|
||||
_send_telegram_message_with_retry,
|
||||
_send_weixin,
|
||||
_send_yuanbao,
|
||||
_telegram_retry_delay,
|
||||
)
|
||||
_AUDIO_EXTS, _DEFAULT_CAPTION_LIMIT, _IMAGE_EXTS, _TELEGRAM_CAPTION_LIMIT, _VIDEO_EXTS,
|
||||
_VOICE_EXTS, _error, _live_adapter, _media_caption_split, _plugin_standalone_sender,
|
||||
_registry_standalone_send, _resolve_slack_user_target, _sanitize_error_text,
|
||||
_send_bluebubbles, _send_matrix_via_adapter, _send_qqbot, _send_signal, _send_telegram,
|
||||
_send_weixin, _send_yuanbao)
|
||||
from tools.registry import tool_error
|
||||
|
||||
# NOTE: ``send_message`` is intentionally NOT registered as an agent-callable model
|
||||
# tool (the agent must not fire cross-platform messages on its own). The send engine
|
||||
# here is the shared transport for cron delivery, the ``hermes send`` CLI, the gateway
|
||||
# kanban notifier and the opt-in MCP server, which import the helpers directly.
|
||||
|
||||
|
||||
def prepare_send_message_platforms() -> None:
|
||||
"""Load enabled standalone plugins before tool schemas/cache keys are built."""
|
||||
@@ -90,10 +54,16 @@ def _split_target(target: str):
|
||||
return parts[0].strip().lower(), (parts[1].strip() if len(parts) > 1 else None)
|
||||
|
||||
|
||||
def _live_adapter(platform):
|
||||
"""Return the running gateway's adapter for ``platform``, or None."""
|
||||
runner = _live_runner()
|
||||
return runner.adapters.get(platform) if runner is not None else None
|
||||
def _resolve_tool_target(target: str, *, pass_unresolved_references: bool = False):
|
||||
"""Split and resolve a tool ``target``; returns ``(platform_name, chat_id, thread_id, error)``.
|
||||
``chat_id`` is None when no ref was given (caller falls back to the home channel)."""
|
||||
platform_name, target_ref = _split_target(target)
|
||||
prepare_send_message_platforms()
|
||||
if not target_ref:
|
||||
return platform_name, None, None, None
|
||||
chat_id, thread_id, resolution_error = resolve_send_target(
|
||||
platform_name, target_ref, pass_unresolved_references=pass_unresolved_references)
|
||||
return platform_name, chat_id, thread_id, resolution_error
|
||||
|
||||
|
||||
def _handle_list():
|
||||
@@ -106,32 +76,23 @@ def _handle_list():
|
||||
|
||||
|
||||
def _handle_react(args, remove=False):
|
||||
"""Attach (or with ``remove=True`` retract) an emoji reaction via a live
|
||||
gateway adapter exposing ``add_reaction(chat_id, emoji, message_id)`` /
|
||||
``remove_reaction(chat_id, message_id)`` (e.g. photon/iMessage tapbacks).
|
||||
No standalone fallback: reacting needs the adapter's live message-id state.
|
||||
"""
|
||||
"""Attach (or with ``remove=True`` retract) an emoji reaction via a live gateway
|
||||
adapter exposing ``add_reaction(chat_id, emoji, message_id)`` /
|
||||
``remove_reaction(chat_id, message_id)`` (e.g. photon/iMessage tapbacks). No
|
||||
standalone fallback: reacting needs the adapter's live message-id state."""
|
||||
target = args.get("target", "")
|
||||
emoji = (args.get("emoji") or "").strip()
|
||||
message_id = (args.get("message_id") or "").strip() or None
|
||||
if not target or (not remove and not emoji):
|
||||
return tool_error(
|
||||
"Both 'target' and 'emoji' are required when action='react'"
|
||||
if not remove
|
||||
else "'target' is required when action='unreact'"
|
||||
)
|
||||
return tool_error("'target' is required when action='unreact'" if remove
|
||||
else "Both 'target' and 'emoji' are required when action='react'")
|
||||
|
||||
platform_name, target_ref = _split_target(target)
|
||||
chat_id = None
|
||||
prepare_send_message_platforms()
|
||||
if target_ref:
|
||||
# Platform-native ids (e.g. photon GUIDs) match no parser/directory
|
||||
# entry; hand them to the adapter unchanged and let it validate.
|
||||
chat_id, _thread_id, resolution_error = resolve_send_target(
|
||||
platform_name, target_ref, pass_unresolved_references=True
|
||||
)
|
||||
if resolution_error:
|
||||
return tool_error(resolution_error)
|
||||
# Platform-native ids (e.g. photon GUIDs) match no parser/directory entry; hand
|
||||
# them to the adapter unchanged and let it validate.
|
||||
platform_name, chat_id, _thread_id, resolution_error = _resolve_tool_target(
|
||||
target, pass_unresolved_references=True)
|
||||
if resolution_error:
|
||||
return tool_error(resolution_error)
|
||||
|
||||
try:
|
||||
from gateway.config import Platform, load_gateway_config
|
||||
@@ -147,21 +108,17 @@ def _handle_react(args, remove=False):
|
||||
if not home:
|
||||
return tool_error(
|
||||
f"No chat specified and no home channel set for {platform_name}. "
|
||||
f"Use '{platform_name}:chat_id'."
|
||||
)
|
||||
f"Use '{platform_name}:chat_id'.")
|
||||
chat_id = home.chat_id
|
||||
|
||||
adapter = _live_adapter(platform)
|
||||
_, adapter = _live_adapter(platform)
|
||||
if adapter is None:
|
||||
return tool_error(
|
||||
f"Reactions require a live {platform_name} adapter in the running "
|
||||
"gateway (not available from cron/standalone contexts)."
|
||||
)
|
||||
"gateway (not available from cron/standalone contexts).")
|
||||
react_fn = getattr(adapter, "remove_reaction" if remove else "add_reaction", None)
|
||||
if not callable(react_fn):
|
||||
return tool_error(
|
||||
f"Platform '{platform_name}' does not support message reactions."
|
||||
)
|
||||
return tool_error(f"Platform '{platform_name}' does not support message reactions.")
|
||||
|
||||
kwargs = {"chat_id": chat_id, "message_id": message_id}
|
||||
if not remove:
|
||||
@@ -171,9 +128,7 @@ def _handle_react(args, remove=False):
|
||||
result = _run_async(react_fn(**kwargs))
|
||||
except Exception as e:
|
||||
return json.dumps(_error(f"Reaction failed: {e}"))
|
||||
if isinstance(result, dict):
|
||||
return json.dumps(result)
|
||||
return json.dumps({"success": bool(result)})
|
||||
return json.dumps(result if isinstance(result, dict) else {"success": bool(result)})
|
||||
|
||||
|
||||
def _handle_send(args):
|
||||
@@ -183,17 +138,9 @@ def _handle_send(args):
|
||||
if not target or not message:
|
||||
return tool_error("Both 'target' and 'message' are required when action='send'")
|
||||
|
||||
platform_name, target_ref = _split_target(target)
|
||||
chat_id = None
|
||||
thread_id = None
|
||||
|
||||
prepare_send_message_platforms()
|
||||
if target_ref:
|
||||
chat_id, thread_id, resolution_error = resolve_send_target(
|
||||
platform_name, target_ref
|
||||
)
|
||||
if resolution_error:
|
||||
return tool_error(resolution_error)
|
||||
platform_name, chat_id, thread_id, resolution_error = _resolve_tool_target(target)
|
||||
if resolution_error:
|
||||
return tool_error(resolution_error)
|
||||
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
@@ -211,9 +158,9 @@ def _handle_send(args):
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# Capture [[as_document]] before extract_media strips it: image files then
|
||||
# go through send_document so the original bytes survive (Telegram's
|
||||
# sendPhoto recompresses to 1280px).
|
||||
# Capture [[as_document]] before extract_media strips it: image files then go
|
||||
# through send_document so the original bytes survive (Telegram's sendPhoto
|
||||
# recompresses to 1280px).
|
||||
force_document_attachments = "[[as_document]]" in message
|
||||
|
||||
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
|
||||
@@ -237,18 +184,13 @@ def _handle_send(args):
|
||||
|
||||
try:
|
||||
from model_tools import _run_async
|
||||
send_kwargs = {
|
||||
"thread_id": thread_id,
|
||||
"media_files": media_files,
|
||||
"force_document": force_document_attachments,
|
||||
}
|
||||
send_kwargs = {"thread_id": thread_id, "media_files": media_files,
|
||||
"force_document": force_document_attachments}
|
||||
# Only custom plugin handlers receive the complete typed request; the
|
||||
# built-in call contract stays exact.
|
||||
if entry is not None and entry.send_message_handler is not None:
|
||||
send_kwargs["args"] = args
|
||||
result = _run_async(
|
||||
_send_to_platform(platform, pconfig, chat_id, cleaned_message, **send_kwargs)
|
||||
)
|
||||
result = _run_async(_send_to_platform(platform, pconfig, chat_id, cleaned_message, **send_kwargs))
|
||||
if isinstance(result, dict) and result.get("success"):
|
||||
if used_home_channel:
|
||||
result["note"] = f"Sent to {platform_name} home channel (chat_id: {chat_id})"
|
||||
@@ -265,9 +207,9 @@ def _handle_send(args):
|
||||
def _resolve_platform_config(platform_name, config):
|
||||
"""Return ``(platform, pconfig, registry_entry, error)`` for a send.
|
||||
|
||||
Plugin platforms must be registered; disabled/missing platforms error,
|
||||
except Weixin, which may be configured purely via .env (synthesized
|
||||
pconfig so send_message and cron delivery work without a gateway.yaml entry).
|
||||
Plugin platforms must be registered; disabled/missing platforms error, except
|
||||
Weixin, which may be configured purely via .env (synthesized pconfig so
|
||||
send_message and cron delivery work without a gateway.yaml entry).
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
from gateway.platform_registry import platform_registry
|
||||
@@ -287,8 +229,7 @@ def _resolve_platform_config(platform_name, config):
|
||||
if pconfig is None:
|
||||
return None, None, None, (
|
||||
f"Platform '{platform_name}' is not configured. Set up credentials in "
|
||||
"~/.hermes/config.yaml or environment variables."
|
||||
)
|
||||
"~/.hermes/config.yaml or environment variables.")
|
||||
return platform, pconfig, entry, None
|
||||
|
||||
|
||||
@@ -303,21 +244,17 @@ def _home_chat_id(config, platform, platform_name):
|
||||
home = HomeChannel(platform=platform, chat_id=wx_home, name="Weixin Home")
|
||||
if home:
|
||||
return home.chat_id, None
|
||||
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
|
||||
platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
|
||||
)
|
||||
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(platform_name, f"{platform_name.upper()}_HOME_CHANNEL")
|
||||
return None, (
|
||||
f"No home channel set for {platform_name} to determine where to send the message. "
|
||||
f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
|
||||
f"or set a home channel via: hermes config set {home_env} <channel_id>"
|
||||
)
|
||||
f"or set a home channel via: hermes config set {home_env} <channel_id>")
|
||||
|
||||
|
||||
def _slack_dm_chat_id(pconfig, chat_id):
|
||||
"""Open Slack user targets as DM conversations: ``user:U...`` /
|
||||
``user_name:@handle`` from the parser, or a bare U... id from session
|
||||
metadata / home-channel config. chat.postMessage needs a conversation ID.
|
||||
Returns ``(chat_id, None)`` or ``(None, error_dict)``."""
|
||||
"""Open Slack user targets as DM conversations: ``user:U...`` / ``user_name:@handle``
|
||||
from the parser, or a bare U... id from session metadata / home-channel config.
|
||||
chat.postMessage needs a conversation ID. Returns ``(chat_id, None)`` or ``(None, error_dict)``."""
|
||||
dm_target = chat_id
|
||||
if dm_target.startswith("U") and _SLACK_USER_ID_RE.fullmatch(dm_target):
|
||||
dm_target = f"user:{dm_target}"
|
||||
@@ -333,13 +270,10 @@ def _mirror_sent_message(platform_name, chat_id, mirror_text, thread_id):
|
||||
from gateway.mirror import mirror_to_session
|
||||
from gateway.session_context import get_session_env
|
||||
return bool(mirror_to_session(
|
||||
platform_name,
|
||||
chat_id,
|
||||
mirror_text,
|
||||
platform_name, chat_id, mirror_text,
|
||||
source_label=get_session_env("HERMES_SESSION_PLATFORM", "cli"),
|
||||
thread_id=thread_id,
|
||||
user_id=get_session_env("HERMES_SESSION_USER_ID", "") or None,
|
||||
))
|
||||
user_id=get_session_env("HERMES_SESSION_USER_ID", "") or None))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -351,15 +285,10 @@ def _weixin_env_pconfig():
|
||||
if not (wx_token and wx_account):
|
||||
return None
|
||||
from gateway.config import PlatformConfig
|
||||
return PlatformConfig(
|
||||
enabled=True,
|
||||
token=wx_token,
|
||||
extra={
|
||||
"account_id": wx_account,
|
||||
"base_url": get_secret("WEIXIN_BASE_URL", "").strip(),
|
||||
"cdn_base_url": get_secret("WEIXIN_CDN_BASE_URL", "").strip(),
|
||||
},
|
||||
)
|
||||
return PlatformConfig(enabled=True, token=wx_token, extra={
|
||||
"account_id": wx_account,
|
||||
"base_url": get_secret("WEIXIN_BASE_URL", "").strip(),
|
||||
"cdn_base_url": get_secret("WEIXIN_CDN_BASE_URL", "").strip()})
|
||||
|
||||
|
||||
def _describe_media_for_mirror(media_files):
|
||||
@@ -378,165 +307,110 @@ def _describe_media_for_mirror(media_files):
|
||||
return "[Sent document attachment]"
|
||||
|
||||
|
||||
def _get_cron_auto_delivery_target():
|
||||
"""Return the cron scheduler's auto-delivery target for the current run, if any."""
|
||||
from gateway.session_context import get_session_env
|
||||
platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
|
||||
chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
|
||||
if not platform or not chat_id:
|
||||
return None
|
||||
thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
|
||||
return {"platform": platform, "chat_id": chat_id, "thread_id": thread_id}
|
||||
|
||||
|
||||
def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id: str | None):
|
||||
"""Skip redundant cron send_message calls when the scheduler will auto-deliver there."""
|
||||
auto_target = _get_cron_auto_delivery_target()
|
||||
if not auto_target or not (
|
||||
auto_target["platform"] == platform_name
|
||||
and str(auto_target["chat_id"]) == str(chat_id)
|
||||
and auto_target.get("thread_id") == thread_id
|
||||
):
|
||||
from gateway.session_context import get_session_env
|
||||
auto_platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
|
||||
auto_chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
|
||||
if not auto_platform or not auto_chat_id:
|
||||
return None
|
||||
auto_thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
|
||||
if not (auto_platform == platform_name and auto_chat_id == str(chat_id) and auto_thread_id == thread_id):
|
||||
return None
|
||||
|
||||
target_label = f"{platform_name}:{chat_id}"
|
||||
if thread_id is not None:
|
||||
target_label += f":{thread_id}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"reason": "cron_auto_delivery_duplicate_target",
|
||||
"success": True, "skipped": True, "reason": "cron_auto_delivery_duplicate_target",
|
||||
"target": target_label,
|
||||
"note": (
|
||||
f"Skipped send_message to {target_label}. This cron job will already auto-deliver "
|
||||
"its final response to that same target. Put the intended user-facing content in "
|
||||
"your final response instead, or use a different target if you want an additional message."
|
||||
),
|
||||
}
|
||||
)}
|
||||
|
||||
|
||||
def _bounded_send_error(detail, max_chars=900):
|
||||
"""Bound untrusted adapter/plugin error detail returned by send_message."""
|
||||
text = str(detail or "send failed")
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return f"{text[: max_chars - 3]}..."
|
||||
return text if len(text) <= max_chars else f"{text[: max_chars - 3]}..."
|
||||
|
||||
|
||||
def _live_media_method(ext, is_voice, force_document):
|
||||
"""Adapter media method name and kind for one live-adapter media file."""
|
||||
if force_document:
|
||||
return "send_document", "document"
|
||||
if ext in _IMAGE_EXTS:
|
||||
return "send_image_file", "image"
|
||||
if ext in _VIDEO_EXTS:
|
||||
return "send_video", "video"
|
||||
if is_voice or ext in _AUDIO_EXTS:
|
||||
return "send_voice", "audio"
|
||||
return "send_document", "document"
|
||||
|
||||
|
||||
async def _send_live_adapter_media(
|
||||
adapter,
|
||||
chat_id,
|
||||
message,
|
||||
media_files,
|
||||
*,
|
||||
thread_id=None,
|
||||
metadata=None,
|
||||
force_document=False,
|
||||
):
|
||||
adapter, chat_id, message, media_files, *, thread_id=None, metadata=None, force_document=False):
|
||||
"""Deliver text and every media descriptor through adapter media APIs.
|
||||
Adapters that only inherit the BasePlatformAdapter stub for a media kind
|
||||
are treated as unsupported rather than silently no-op'd."""
|
||||
caption, separate_text = _media_caption_split(
|
||||
message, media_files, max_caption_len=_DEFAULT_CAPTION_LIMIT
|
||||
)
|
||||
message, media_files, max_caption_len=_DEFAULT_CAPTION_LIMIT)
|
||||
last_result = None
|
||||
if separate_text and separate_text.strip():
|
||||
last_result = await adapter.send(
|
||||
chat_id=chat_id, content=separate_text, metadata=metadata
|
||||
)
|
||||
last_result = await adapter.send(chat_id=chat_id, content=separate_text, metadata=metadata)
|
||||
if not last_result.success:
|
||||
return {"error": f"Adapter send failed: {_bounded_send_error(last_result.error)}"}
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
total = len(media_files)
|
||||
for index, descriptor in enumerate(media_files):
|
||||
invalid = {"error": f"Adapter media send failed: invalid media descriptor {index + 1}/{total}"}
|
||||
if not isinstance(descriptor, (list, tuple)) or not descriptor:
|
||||
return {"error": f"Adapter media send failed: invalid media descriptor {index + 1}/{total}"}
|
||||
return invalid
|
||||
media_path = descriptor[0]
|
||||
is_voice = bool(descriptor[1]) if len(descriptor) > 1 else False
|
||||
if not isinstance(media_path, str) or not media_path:
|
||||
return {"error": f"Adapter media send failed: invalid media descriptor {index + 1}/{total}"}
|
||||
return invalid
|
||||
if not os.path.exists(media_path):
|
||||
return {"error": f"Adapter media send failed: media file {index + 1}/{total} was not found"}
|
||||
|
||||
ext = os.path.splitext(media_path)[1].lower()
|
||||
kwargs = {
|
||||
"caption": caption if index == 0 else None,
|
||||
"reply_to": thread_id,
|
||||
"metadata": metadata,
|
||||
}
|
||||
if force_document:
|
||||
method_name, media_kind = "send_document", "document"
|
||||
elif ext in _IMAGE_EXTS:
|
||||
method_name, media_kind = "send_image_file", "image"
|
||||
elif ext in _VIDEO_EXTS:
|
||||
method_name, media_kind = "send_video", "video"
|
||||
elif is_voice or ext in _AUDIO_EXTS:
|
||||
method_name, media_kind = "send_voice", "audio"
|
||||
else:
|
||||
method_name, media_kind = "send_document", "document"
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
method_name, media_kind = _live_media_method(ext, is_voice, force_document)
|
||||
adapter_method = getattr(type(adapter), method_name, None)
|
||||
base_fallback = getattr(BasePlatformAdapter, method_name)
|
||||
if adapter_method is None or adapter_method is base_fallback:
|
||||
if adapter_method is None or adapter_method is getattr(BasePlatformAdapter, method_name):
|
||||
return {
|
||||
"error": (
|
||||
f"Live adapter does not implement native {media_kind} delivery; "
|
||||
f"media file {index + 1}/{total} was not sent"
|
||||
)
|
||||
}
|
||||
f"media file {index + 1}/{total} was not sent")}
|
||||
kwargs = {"caption": caption if index == 0 else None, "reply_to": thread_id, "metadata": metadata}
|
||||
try:
|
||||
last_result = await getattr(adapter, method_name)(chat_id, media_path, **kwargs)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return {
|
||||
"error": (
|
||||
f"Adapter media send failed after {index}/{total} files: "
|
||||
f"{_bounded_send_error(exc)}"
|
||||
)
|
||||
}
|
||||
if not last_result.success:
|
||||
detail = _bounded_send_error(exc)
|
||||
else:
|
||||
if last_result.success:
|
||||
continue
|
||||
detail = _bounded_send_error(last_result.error or "media send failed")
|
||||
return {
|
||||
"error": f"Adapter media send failed after {index}/{total} files: {detail}"
|
||||
}
|
||||
return {"error": f"Adapter media send failed after {index}/{total} files: {detail}"}
|
||||
|
||||
if last_result is None:
|
||||
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": last_result.message_id,
|
||||
"media_delivered": True,
|
||||
}
|
||||
return {"success": True, "message_id": last_result.message_id, "media_delivered": True}
|
||||
|
||||
|
||||
async def _send_via_adapter(
|
||||
platform,
|
||||
pconfig,
|
||||
chat_id,
|
||||
chunk,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Send via the live in-process gateway adapter (``_gateway_runner_ref``),
|
||||
else the plugin's ``standalone_sender_fn`` (gateway not in this process,
|
||||
e.g. cron — the runner weakref is None), else a descriptive error naming
|
||||
both options. Media descriptors go through the adapter's native media
|
||||
APIs under the same cross-loop rules as text.
|
||||
"""
|
||||
platform, pconfig, chat_id, chunk, *, thread_id=None, media_files=None, force_document=False):
|
||||
"""Send via the live in-process gateway adapter (``_gateway_runner_ref``), else the
|
||||
plugin's ``standalone_sender_fn`` (gateway not in this process, e.g. cron — the runner
|
||||
weakref is None), else a descriptive error naming both options. Media descriptors go
|
||||
through the adapter's native media APIs under the same cross-loop rules as text."""
|
||||
platform_name = platform.value if hasattr(platform, "value") else str(platform)
|
||||
runner = _live_runner()
|
||||
adapter = None
|
||||
if runner is not None:
|
||||
try:
|
||||
adapter = runner.adapters.get(platform)
|
||||
except Exception:
|
||||
adapter = None
|
||||
runner, adapter = _live_adapter(platform)
|
||||
if adapter is not None:
|
||||
try:
|
||||
metadata = {}
|
||||
@@ -545,9 +419,9 @@ async def _send_via_adapter(
|
||||
if platform_name == "ntfy" and chat_id:
|
||||
metadata["publish_topic"] = chat_id
|
||||
metadata = metadata or None
|
||||
# adapter.send() uses queues/tasks bound to the gateway's loop.
|
||||
# Awaiting it from another loop (the tool worker thread) deadlocks
|
||||
# on a cross-loop Future, so dispatch onto the gateway loop instead.
|
||||
# adapter.send() uses queues/tasks bound to the gateway's loop. Awaiting it
|
||||
# from another loop (the tool worker thread) deadlocks on a cross-loop
|
||||
# Future, so dispatch onto the gateway loop instead.
|
||||
gateway_loop = getattr(runner, "_gateway_loop", None)
|
||||
try:
|
||||
current_loop = asyncio.get_running_loop()
|
||||
@@ -562,28 +436,23 @@ async def _send_via_adapter(
|
||||
return {"error": "Gateway loop is not running; cannot dispatch adapter send"}
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(
|
||||
make_coro(), gateway_loop, logger=logger, log_message=log_message
|
||||
)
|
||||
make_coro(), gateway_loop, logger=logger, log_message=log_message)
|
||||
if fut is None:
|
||||
return {"error": "Gateway loop unavailable for send dispatch"}
|
||||
# shield: a cancelled caller (agent interrupt) must not cancel the
|
||||
# already-enqueued gateway send, or a retry would duplicate it.
|
||||
# No timeout here — the adapter's request timeout and the outer
|
||||
# _run_async timeout bound the wait.
|
||||
# already-enqueued gateway send, or a retry would duplicate it. No timeout
|
||||
# here — the adapter's request timeout and the outer _run_async bound the wait.
|
||||
return await asyncio.shield(asyncio.wrap_future(fut))
|
||||
|
||||
if media_files:
|
||||
return await _dispatch(
|
||||
lambda: _send_live_adapter_media(
|
||||
adapter, chat_id, chunk, media_files,
|
||||
thread_id=thread_id, metadata=metadata, force_document=force_document,
|
||||
),
|
||||
"send_message: failed to schedule media send on gateway loop",
|
||||
)
|
||||
thread_id=thread_id, metadata=metadata, force_document=force_document),
|
||||
"send_message: failed to schedule media send on gateway loop")
|
||||
result = await _dispatch(
|
||||
lambda: adapter.send(chat_id=chat_id, content=chunk, metadata=metadata),
|
||||
"send_message: failed to schedule on gateway loop",
|
||||
)
|
||||
"send_message: failed to schedule on gateway loop")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -599,43 +468,29 @@ async def _send_via_adapter(
|
||||
entry = platform_registry.get(platform_name)
|
||||
except Exception:
|
||||
entry = None
|
||||
|
||||
if entry is not None and entry.standalone_sender_fn is not None:
|
||||
try:
|
||||
result = await entry.standalone_sender_fn(
|
||||
pconfig,
|
||||
chat_id,
|
||||
chunk,
|
||||
thread_id=thread_id,
|
||||
media_files=media_files,
|
||||
force_document=force_document,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Plugin standalone send for %s raised", platform_name, exc_info=True)
|
||||
return {"error": f"Plugin standalone send failed: {_bounded_send_error(e)}"}
|
||||
|
||||
if isinstance(result, dict) and (result.get("success") or result.get("error")):
|
||||
if result.get("error"):
|
||||
return {**result, "error": _bounded_send_error(result["error"])}
|
||||
return result
|
||||
return {
|
||||
"error": (
|
||||
f"Plugin standalone send for '{platform_name}' returned an "
|
||||
f"invalid result: expected a dict with 'success' or 'error' "
|
||||
f"keys, got {type(result).__name__}"
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
"error": (
|
||||
if entry is None or entry.standalone_sender_fn is None:
|
||||
return {"error": (
|
||||
f"No live adapter for platform '{platform_name}'. Is the gateway "
|
||||
f"running with this platform connected? For out-of-process delivery "
|
||||
f"(e.g. cron in a separate process), the platform plugin must "
|
||||
f"register a standalone_sender_fn on its PlatformEntry."
|
||||
)
|
||||
}
|
||||
f"register a standalone_sender_fn on its PlatformEntry.")}
|
||||
try:
|
||||
result = await entry.standalone_sender_fn(
|
||||
pconfig, chat_id, chunk,
|
||||
thread_id=thread_id, media_files=media_files, force_document=force_document)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Plugin standalone send for %s raised", platform_name, exc_info=True)
|
||||
return {"error": f"Plugin standalone send failed: {_bounded_send_error(e)}"}
|
||||
if isinstance(result, dict) and (result.get("success") or result.get("error")):
|
||||
if result.get("error"):
|
||||
return {**result, "error": _bounded_send_error(result["error"])}
|
||||
return result
|
||||
return {"error": (
|
||||
f"Plugin standalone send for '{platform_name}' returned an "
|
||||
f"invalid result: expected a dict with 'success' or 'error' "
|
||||
f"keys, got {type(result).__name__}")}
|
||||
|
||||
|
||||
async def _send_chunks(chunks, send_one):
|
||||
@@ -651,10 +506,10 @@ async def _send_chunks(chunks, send_one):
|
||||
|
||||
|
||||
def _platform_max_length(platform):
|
||||
"""Max message length for chunking: the adapter constant for Signal (its
|
||||
raw JSON-RPC path never sees SignalAdapter's own chunking, so signal-cli
|
||||
would reject long sends whole), the registry's ``max_message_length`` for
|
||||
plugins (Slack, Feishu, ...), else None (no chunking)."""
|
||||
"""Max message length for chunking: the adapter constant for Signal (its raw
|
||||
JSON-RPC path never sees SignalAdapter's own chunking, so signal-cli would reject
|
||||
long sends whole), the registry's ``max_message_length`` for plugins (Slack,
|
||||
Feishu, ...), else None (no chunking)."""
|
||||
from gateway.config import Platform
|
||||
|
||||
if platform == Platform.SIGNAL:
|
||||
@@ -673,20 +528,18 @@ def _platform_max_length(platform):
|
||||
return None
|
||||
|
||||
|
||||
# Plugin platforms whose media (and, for Discord, all) sends go straight to the
|
||||
# registry ``standalone_sender_fn`` — bypassing the live adapter on purpose:
|
||||
# Discord's plugin ``_standalone_send`` handles forum channels, threads and
|
||||
# multipart uploads (historically the only Discord path); Slack uploads via
|
||||
# files_upload_v2; WhatsApp posts each file to the Baileys bridge /send-media so
|
||||
# images/videos/audio arrive as native bubbles, not documents.
|
||||
# Plugin platforms whose media (and, for Discord, all) sends go straight to the registry
|
||||
# ``standalone_sender_fn`` — bypassing the live adapter on purpose: Discord's plugin
|
||||
# ``_standalone_send`` handles forum channels, threads and multipart uploads
|
||||
# (historically the only Discord path); Slack uploads via files_upload_v2; WhatsApp
|
||||
# posts each file to the Baileys bridge /send-media so media arrive as native bubbles.
|
||||
# platform -> (error label, run discover_plugins first, caption-capable,
|
||||
# media_files sentinel for non-final chunks, forward force_document)
|
||||
_PLUGIN_STANDALONE_MEDIA = {
|
||||
"discord": ("Discord", False, True, [], False),
|
||||
"feishu": ("Feishu", True, False, None, False),
|
||||
"slack": ("Slack", True, True, [], False),
|
||||
"whatsapp": ("WhatsApp", True, True, None, True),
|
||||
}
|
||||
"whatsapp": ("WhatsApp", True, True, None, True)}
|
||||
|
||||
|
||||
async def _send_plugin_standalone(
|
||||
@@ -702,36 +555,35 @@ async def _send_plugin_standalone(
|
||||
if captionable:
|
||||
# Cap on the platform's own message limit so the caption is deliverable.
|
||||
caption, _ = _media_caption_split(
|
||||
message, media_files, max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT)
|
||||
)
|
||||
message, media_files, max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT))
|
||||
if caption is not None:
|
||||
return await sender(
|
||||
pconfig, chat_id, "", thread_id=thread_id, media_files=media_files,
|
||||
caption=caption, **extra,
|
||||
)
|
||||
caption=caption, **extra)
|
||||
return await _send_chunks(
|
||||
chunks,
|
||||
lambda chunk, is_last: sender(
|
||||
pconfig, chat_id, chunk, thread_id=thread_id,
|
||||
media_files=media_files if is_last else empty_media, **extra,
|
||||
),
|
||||
)
|
||||
media_files=media_files if is_last else empty_media, **extra))
|
||||
|
||||
|
||||
# Native-media chunked routes for built-in platforms; media rides on the final
|
||||
# chunk, non-final chunks get the platform's empty-media sentinel.
|
||||
# Native-media chunked routes for built-in platforms; media rides on the final chunk,
|
||||
# non-final chunks get the platform's empty-media sentinel.
|
||||
# platform -> (media required, empty-media sentinel,
|
||||
# sender(platform, pconfig, chat_id, chunk, media, thread_id, force_document))
|
||||
# Matrix: ALL sends go through the native adapter so text is encrypted in E2EE
|
||||
# rooms too (the raw-HTTP standalone path is not encryption-aware).
|
||||
# Matrix: ALL sends go through the native adapter so text is encrypted in E2EE rooms
|
||||
# too (the raw-HTTP standalone path is not encryption-aware).
|
||||
# Signal: attachments ride the JSON-RPC ``attachments`` param.
|
||||
# Yuanbao: media needs the running gateway adapter's WebSocket.
|
||||
# Slack (text; media was intercepted above): prefer the live adapter — it is
|
||||
# multi-workspace aware and honors adapter gates like ignored_channels, while
|
||||
# the standalone Web-API path may only have a token list — else the plugin's
|
||||
# standalone sender, keeping MEDIA delivery on the cron fallback.
|
||||
# Slack (text; media was intercepted above): prefer the live adapter — multi-workspace
|
||||
# aware and honors adapter gates like ignored_channels, while the standalone Web-API
|
||||
# path may only have a token list — else the plugin's standalone sender.
|
||||
# WeCom: native media only through the live gateway adapter.
|
||||
# Names resolve at call time so tests can monkeypatch e.g. ``_send_signal``.
|
||||
def _via_adapter_route(p, pc, cid, chunk, media, tid, fd):
|
||||
return _send_via_adapter(p, pc, cid, chunk, thread_id=tid, media_files=media, force_document=fd)
|
||||
|
||||
|
||||
_CHUNKED_ROUTES = {
|
||||
"matrix": (False, [], lambda p, pc, cid, chunk, media, tid, fd: _send_matrix_via_adapter(
|
||||
pc, cid, chunk, media_files=media, thread_id=tid)),
|
||||
@@ -739,26 +591,22 @@ _CHUNKED_ROUTES = {
|
||||
pc.extra, cid, chunk, media_files=media)),
|
||||
"yuanbao": (True, None, lambda p, pc, cid, chunk, media, tid, fd: _send_yuanbao(
|
||||
cid, chunk, media_files=media)),
|
||||
"slack": (False, [], lambda p, pc, cid, chunk, media, tid, fd: _send_via_adapter(
|
||||
p, pc, cid, chunk, thread_id=tid, media_files=media, force_document=fd)),
|
||||
"wecom": (True, None, lambda p, pc, cid, chunk, media, tid, fd: _send_via_adapter(
|
||||
p, pc, cid, chunk, thread_id=tid, media_files=media, force_document=fd)),
|
||||
}
|
||||
"slack": (False, [], _via_adapter_route),
|
||||
"wecom": (True, None, _via_adapter_route)}
|
||||
|
||||
# Text-only senders for built-in platforms (generic path; media is dropped
|
||||
# with a warning). Signature: (pconfig, chat_id, chunk, thread_id) -> result.
|
||||
|
||||
def _registry_text_sender(name):
|
||||
return lambda pc, cid, chunk, tid: _registry_standalone_send(name, pc, cid, chunk, tid)
|
||||
|
||||
|
||||
# Text-only senders for built-in platforms (generic path; media is dropped with a
|
||||
# warning). Signature: (pconfig, chat_id, chunk, thread_id) -> result.
|
||||
_TEXT_SENDERS = {
|
||||
"whatsapp": lambda pc, cid, chunk, tid: _registry_standalone_send("whatsapp", pc, cid, chunk, tid),
|
||||
**{name: _registry_text_sender(name) for name in ("whatsapp", "email", "sms", "dingtalk", "feishu", "wecom")},
|
||||
"signal": lambda pc, cid, chunk, tid: _send_signal(pc.extra, cid, chunk),
|
||||
"email": lambda pc, cid, chunk, tid: _registry_standalone_send("email", pc, cid, chunk, tid),
|
||||
"sms": lambda pc, cid, chunk, tid: _registry_standalone_send("sms", pc, cid, chunk, tid),
|
||||
"dingtalk": lambda pc, cid, chunk, tid: _registry_standalone_send("dingtalk", pc, cid, chunk, tid),
|
||||
"feishu": lambda pc, cid, chunk, tid: _registry_standalone_send("feishu", pc, cid, chunk, tid),
|
||||
"wecom": lambda pc, cid, chunk, tid: _registry_standalone_send("wecom", pc, cid, chunk, tid),
|
||||
"bluebubbles": lambda pc, cid, chunk, tid: _send_bluebubbles(pc.extra, cid, chunk),
|
||||
"qqbot": lambda pc, cid, chunk, tid: _send_qqbot(pc, cid, chunk),
|
||||
"yuanbao": lambda pc, cid, chunk, tid: _send_yuanbao(cid, chunk),
|
||||
}
|
||||
"yuanbao": lambda pc, cid, chunk, tid: _send_yuanbao(cid, chunk)}
|
||||
|
||||
_MEDIA_PLATFORMS_NOTE = "telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp and slack"
|
||||
|
||||
@@ -766,12 +614,11 @@ _MEDIA_PLATFORMS_NOTE = "telegram, discord, matrix, weixin, signal, yuanbao, fei
|
||||
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False, args=None):
|
||||
"""Route a message to the appropriate platform sender.
|
||||
|
||||
Long messages are chunked with the adapters' smart splitter (code-block
|
||||
aware, part indicators). Branch order matters: Weixin first (its native
|
||||
helper must not be blocked by unrelated optional imports such as
|
||||
lark-oapi's heavy Feishu path), Telegram (chunks itself), plugin
|
||||
standalone media routes, native chunked routes, then the generic text
|
||||
path that drops media with a warning.
|
||||
Long messages are chunked with the adapters' smart splitter (code-block aware,
|
||||
part indicators). Branch order matters: Weixin first (its native helper must not
|
||||
be blocked by unrelated optional imports such as lark-oapi's heavy Feishu path),
|
||||
Telegram (chunks itself), plugin standalone media routes, native chunked routes,
|
||||
then the generic text path that drops media with a warning.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
|
||||
@@ -781,20 +628,14 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
if platform == Platform.WEIXIN:
|
||||
return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
|
||||
|
||||
# Telegram chunks internally on the *formatted* text in UTF-16 units
|
||||
# (MarkdownV2/HTML escaping inflates length), so it gets the whole
|
||||
# message; media attaches after all text chunks.
|
||||
# Telegram chunks internally on the *formatted* text in UTF-16 units (MarkdownV2/HTML
|
||||
# escaping inflates length), so it gets the whole message; media attaches after all text.
|
||||
if platform == Platform.TELEGRAM:
|
||||
disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews"))
|
||||
return await _send_telegram(
|
||||
pconfig.token,
|
||||
chat_id,
|
||||
message,
|
||||
media_files=media_files,
|
||||
thread_id=thread_id,
|
||||
disable_link_previews=disable_link_previews,
|
||||
force_document=force_document,
|
||||
)
|
||||
pconfig.token, chat_id, message,
|
||||
media_files=media_files, thread_id=thread_id,
|
||||
disable_link_previews=disable_link_previews, force_document=force_document)
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
@@ -804,33 +645,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
if platform_name == "discord" or (media_files and platform_name in _PLUGIN_STANDALONE_MEDIA):
|
||||
return await _send_plugin_standalone(
|
||||
platform_name, pconfig, chat_id, message, chunks, media_files,
|
||||
thread_id=thread_id, max_len=max_len, force_document=force_document,
|
||||
)
|
||||
thread_id=thread_id, max_len=max_len, force_document=force_document)
|
||||
|
||||
route = _CHUNKED_ROUTES.get(platform_name)
|
||||
if route is not None and (media_files or not route[0]):
|
||||
_, empty_media, sender = route
|
||||
return await _send_chunks(chunks, lambda chunk, is_last: sender(
|
||||
platform, pconfig, chat_id, chunk,
|
||||
media_files if is_last else empty_media, thread_id, force_document,
|
||||
))
|
||||
media_files if is_last else empty_media, thread_id, force_document))
|
||||
|
||||
# --- Generic path: text only. Buzz is a plugin platform with verified
|
||||
# native media delivery through _send_via_adapter (media-only sends
|
||||
# included), so it is exempt from the media error/warning.
|
||||
if media_files and platform_name != "buzz" and not message.strip():
|
||||
return {
|
||||
"error": (
|
||||
f"send_message MEDIA delivery is currently only supported for {_MEDIA_PLATFORMS_NOTE}; "
|
||||
f"target {platform_name} had only media attachments"
|
||||
)
|
||||
}
|
||||
# --- Generic path: text only. Buzz is a plugin platform with verified native media
|
||||
# delivery through _send_via_adapter (media-only sends included), so it is exempt
|
||||
# from the media error/warning.
|
||||
warning = None
|
||||
if media_files and platform_name != "buzz":
|
||||
if not message.strip():
|
||||
return {"error": (
|
||||
f"send_message MEDIA delivery is currently only supported for {_MEDIA_PLATFORMS_NOTE}; "
|
||||
f"target {platform_name} had only media attachments")}
|
||||
warning = (
|
||||
f"MEDIA attachments were omitted for {platform_name}; "
|
||||
f"native send_message media delivery is currently only supported for {_MEDIA_PLATFORMS_NOTE}"
|
||||
)
|
||||
f"native send_message media delivery is currently only supported for {_MEDIA_PLATFORMS_NOTE}")
|
||||
|
||||
text_sender = _TEXT_SENDERS.get(platform_name)
|
||||
if text_sender is not None:
|
||||
@@ -852,27 +687,11 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
except Exception as e:
|
||||
return {"error": f"Plugin send_message handler failed: {e}"}
|
||||
# Plugin platform: live gateway adapter if available, else standalone_sender_fn.
|
||||
send_one = lambda chunk, is_last: _send_via_adapter( # noqa: E731
|
||||
platform, pconfig, chat_id, chunk, thread_id=thread_id,
|
||||
media_files=media_files if is_last else [], force_document=force_document,
|
||||
)
|
||||
send_one = lambda chunk, is_last: _via_adapter_route( # noqa: E731
|
||||
platform, pconfig, chat_id, chunk, media_files if is_last else [], thread_id, force_document)
|
||||
|
||||
last_result = await _send_chunks(chunks, send_one)
|
||||
if (
|
||||
warning
|
||||
and isinstance(last_result, dict)
|
||||
and last_result.get("success")
|
||||
and not last_result.get("media_delivered")
|
||||
):
|
||||
if (warning and isinstance(last_result, dict) and last_result.get("success")
|
||||
and not last_result.get("media_delivered")):
|
||||
last_result["warnings"] = [*last_result.get("warnings", []), warning]
|
||||
return last_result
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
from tools.registry import tool_error
|
||||
|
||||
# NOTE: ``send_message`` is intentionally NOT registered as an agent-callable
|
||||
# model tool (the agent must not fire cross-platform messages on its own).
|
||||
# The send engine here is the shared transport for cron delivery, the
|
||||
# ``hermes send`` CLI, the gateway kanban notifier and the opt-in MCP server,
|
||||
# which import the helpers directly.
|
||||
|
||||
Reference in New Issue
Block a user