Files
hermes-agent/tools/send_message_tool.py
T
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00

836 lines
33 KiB
Python

"""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
human-friendly channel names to IDs. Works in both CLI and gateway contexts.
"""
import asyncio
import json
import logging
import os
from agent.secret_scope import get_secret
logger = logging.getLogger(__name__)
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,
)
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,
)
def prepare_send_message_platforms() -> None:
"""Load enabled standalone plugins before tool schemas/cache keys are built."""
from hermes_cli.plugins import discover_plugins
discover_plugins()
def send_message_tool(args, **kw):
"""Handle cross-channel send_message tool calls."""
action = args.get("action", "send")
if action == "list":
return _handle_list()
if action in ("react", "unreact"):
return _handle_react(args, remove=action == "unreact")
return _handle_send(args)
def _split_target(target: str):
"""Split ``platform[:ref]`` into ``(platform_name, target_ref)``."""
parts = target.split(":", 1)
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 _handle_list():
"""Return formatted list of available messaging targets."""
try:
from gateway.channel_directory import format_directory_for_display
return json.dumps({"targets": format_directory_for_display()})
except Exception as e:
return json.dumps(_error(f"Failed to load channel directory: {e}"))
def _handle_react(args, remove=False):
"""Attach (or with ``remove=True`` retract) an emoji reaction via a live
gateway adapter exposing ``add_reaction`` / ``remove_reaction``. 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'"
)
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)
try:
from gateway.config import Platform, load_gateway_config
platform = Platform(platform_name)
except (ValueError, KeyError):
return tool_error(f"Unknown platform: {platform_name}")
if not chat_id:
try:
home = load_gateway_config().get_home_channel(platform)
except Exception:
home = None
if not home:
return tool_error(
f"No chat specified and no home channel set for {platform_name}. "
f"Use '{platform_name}:chat_id'."
)
chat_id = home.chat_id
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)."
)
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."
)
kwargs = {"chat_id": chat_id, "message_id": message_id}
if not remove:
kwargs["emoji"] = emoji
try:
from model_tools import _run_async
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)})
def _handle_send(args):
"""Send a message to a platform target."""
target = args.get("target", "")
message = args.get("message", "")
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)
from tools.interrupt import is_interrupted
if is_interrupted():
return tool_error("Interrupted")
try:
from gateway.config import load_gateway_config, Platform
config = load_gateway_config()
except Exception as e:
return json.dumps(_error(f"Failed to load gateway config: {e}"))
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform_name)
is_builtin = platform_name in {member.value for member in Platform}
if not is_builtin and entry is None:
return tool_error(
f"Unknown or unregistered plugin platform: {platform_name}"
)
try:
platform = Platform(platform_name)
except (ValueError, KeyError):
return tool_error(f"Unknown platform: {platform_name}")
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
# Weixin can be configured purely via .env; synthesize a pconfig so
# send_message and cron delivery work without a gateway.yaml entry.
pconfig = _weixin_env_pconfig() if platform_name == "weixin" else None
if pconfig is None:
return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.")
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 recompression.
force_document_attachments = "[[as_document]]" in message
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
used_home_channel = False
if not chat_id:
home = config.get_home_channel(platform)
if not home and platform_name == "weixin":
wx_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip()
if wx_home:
from gateway.config import HomeChannel
home = HomeChannel(platform=platform, chat_id=wx_home, name="Weixin Home")
if home:
chat_id = home.chat_id
used_home_channel = True
else:
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
)
return tool_error(
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>"
)
duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
if duplicate_skip:
return json.dumps(duplicate_skip)
# Slack: ``user:U...`` / ``user_name:@handle`` targets (and bare U... ids
# from session metadata / home-channel config) must be opened as DM
# conversations first — chat.postMessage needs a conversation ID.
if platform_name == "slack" and chat_id:
_slack_dm_target = chat_id
if _slack_dm_target.startswith("U") and _SLACK_USER_ID_RE.fullmatch(_slack_dm_target):
_slack_dm_target = f"user:{_slack_dm_target}"
if _slack_dm_target.startswith(("user:", "user_name:")):
from model_tools import _run_async
_resolved, _resolve_err = _run_async(
_resolve_slack_user_target(pconfig.token, _slack_dm_target)
)
if _resolve_err:
return json.dumps(_resolve_err)
chat_id = _resolved
try:
from model_tools import _run_async
send_kwargs = {
"thread_id": thread_id,
"media_files": media_files,
"force_document": force_document_attachments,
}
# Only custom plugin handlers receive the complete typed request.
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)
)
if used_home_channel and isinstance(result, dict) and result.get("success"):
result["note"] = f"Sent to {platform_name} home channel (chat_id: {chat_id})"
# Mirror the sent message into the target's gateway session
if isinstance(result, dict) and result.get("success") and mirror_text:
try:
from gateway.mirror import mirror_to_session
from gateway.session_context import get_session_env
source_label = get_session_env("HERMES_SESSION_PLATFORM", "cli")
user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None
if mirror_to_session(
platform_name,
chat_id,
mirror_text,
source_label=source_label,
thread_id=thread_id,
user_id=user_id,
):
result["mirrored"] = True
except Exception:
pass
if isinstance(result, dict) and "error" in result:
result["error"] = _sanitize_error_text(result["error"])
return json.dumps(result)
except Exception as e:
return json.dumps(_error(f"Send failed: {e}"))
def _weixin_env_pconfig():
"""Synthesize a Weixin PlatformConfig from .env secrets, or None."""
wx_token = get_secret("WEIXIN_TOKEN", "").strip()
wx_account = get_secret("WEIXIN_ACCOUNT_ID", "").strip()
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(),
},
)
def _describe_media_for_mirror(media_files):
"""Return a human-readable mirror summary when a message only contains media."""
if not media_files:
return ""
if len(media_files) != 1:
return f"[Sent {len(media_files)} media attachments]"
media_path, is_voice = media_files[0]
ext = os.path.splitext(media_path)[1].lower()
if is_voice and ext in _VOICE_EXTS:
return "[Sent voice message]"
for exts, kind in ((_IMAGE_EXTS, "image"), (_VIDEO_EXTS, "video"), (_AUDIO_EXTS, "audio")):
if ext in exts:
return f"[Sent {kind} attachment]"
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
):
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",
"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]}..."
async def _send_live_adapter_media(
adapter,
chat_id,
message,
media_files,
*,
thread_id=None,
metadata=None,
force_document=False,
):
"""Deliver text and every media descriptor through adapter media APIs."""
caption, separate_text = _media_caption_split(
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
)
if not last_result.success:
return {"error": f"Adapter send failed: {_bounded_send_error(last_result.error)}"}
total = len(media_files)
for index, descriptor in enumerate(media_files):
if not isinstance(descriptor, (list, tuple)) or not descriptor:
return {"error": f"Adapter media send failed: invalid media descriptor {index + 1}/{total}"}
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}"}
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
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:
return {
"error": (
f"Live adapter does not implement native {media_kind} delivery; "
f"media file {index + 1}/{total} was not sent"
)
}
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(last_result.error or "media send failed")
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,
}
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, else the plugin's
``standalone_sender_fn`` (out-of-process callers such as cron), else a
descriptive error naming both options.
"""
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
if adapter is not None:
try:
metadata = {}
if thread_id:
metadata["thread_id"] = thread_id
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.
gateway_loop = getattr(runner, "_gateway_loop", None)
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
cross_loop = gateway_loop is not None and current_loop is not gateway_loop
async def _dispatch(make_coro, log_message):
if not cross_loop:
return await make_coro()
if not gateway_loop.is_running():
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
)
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.
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",
)
result = await _dispatch(
lambda: adapter.send(chat_id=chat_id, content=chunk, metadata=metadata),
"send_message: failed to schedule on gateway loop",
)
except asyncio.CancelledError:
raise
except Exception as e:
return {"error": f"Plugin platform send failed: {_bounded_send_error(e)}"}
if isinstance(result, dict):
return result
if result.success:
return {"success": True, "message_id": result.message_id}
return {"error": f"Adapter send failed: {_bounded_send_error(result.error)}"}
try:
from gateway.platform_registry import platform_registry
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": (
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."
)
}
async def _send_chunks(chunks, send_one):
"""Send chunks in order via ``send_one(chunk, is_last)``; stop at the first
error dict, otherwise return the last result."""
last_result = None
for i, chunk in enumerate(chunks):
result = await send_one(chunk, i == len(chunks) - 1)
if isinstance(result, dict) and result.get("error"):
return result
last_result = result
return last_result
def _platform_max_length(platform):
"""Max message length for chunking: the adapter constant for Signal (its
raw JSON-RPC path never sees the adapter's own chunking), the registry's
``max_message_length`` for plugins (Slack, Feishu, ...), else None."""
from gateway.config import Platform
if platform == Platform.SIGNAL:
try:
from gateway.platforms.signal import MAX_MESSAGE_LENGTH
return MAX_MESSAGE_LENGTH
except ImportError:
return 8000
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if entry and entry.max_message_length > 0:
return entry.max_message_length
except Exception:
pass
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.
# 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),
}
async def _send_plugin_standalone(
platform_name, pconfig, chat_id, message, chunks, media_files, *, thread_id, max_len, force_document
):
"""Chunked send through a plugin's standalone_sender_fn, with the single
captionable file + short text case riding as the media caption."""
label, discover, captionable, empty_media, pass_force = _PLUGIN_STANDALONE_MEDIA[platform_name]
sender, err = _plugin_standalone_sender(platform_name, label=label, discover=discover)
if err:
return err
extra = {"force_document": force_document} if pass_force else {}
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)
)
if caption is not None:
return await sender(
pconfig, chat_id, "", thread_id=thread_id, media_files=media_files,
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,
),
)
# Text-only senders for built-in platforms (generic, non-media path).
# Signature: (pconfig, chat_id, chunk, thread_id) -> awaitable result dict.
# Names resolve at call time so tests can monkeypatch e.g. ``_send_signal``.
_TEXT_SENDERS = {
"whatsapp": lambda pc, cid, chunk, tid: _registry_standalone_send("whatsapp", pc, cid, chunk, tid),
"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),
}
_MEDIA_PLATFORMS_NOTE = "telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp and slack"
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 (avoids
unrelated optional imports), then native-media platforms, then the
generic text path that drops media with a warning.
"""
from gateway.config import Platform
platform_name = platform.value if hasattr(platform, "value") else str(platform)
media_files = media_files or []
if platform == Platform.WEIXIN:
return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
# Telegram chunks internally on the *formatted* text (MarkdownV2/HTML
# escaping inflates length), so it gets the whole message; media follows.
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,
)
from gateway.platforms.base import BasePlatformAdapter
max_len = _platform_max_length(platform)
chunks = BasePlatformAdapter.truncate_message(message, max_len) if max_len else [message]
# Media rides on the final chunk in every chunked branch below.
if platform == Platform.DISCORD or (
media_files and platform_name in ("feishu", "slack", "whatsapp")
):
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,
)
# Matrix: every send goes through the native adapter so text is encrypted
# in E2EE rooms too (the raw-HTTP standalone path is not encryption-aware).
if platform == Platform.MATRIX:
return await _send_chunks(chunks, lambda chunk, is_last: _send_matrix_via_adapter(
pconfig, chat_id, chunk, media_files=media_files if is_last else [], thread_id=thread_id,
))
if platform == Platform.SIGNAL and media_files:
return await _send_chunks(chunks, lambda chunk, is_last: _send_signal(
pconfig.extra, chat_id, chunk, media_files=media_files if is_last else [],
))
if platform == Platform.YUANBAO and media_files:
return await _send_chunks(chunks, lambda chunk, is_last: _send_yuanbao(
chat_id, chunk, media_files=media_files if is_last else None,
))
# Slack text: prefer the live adapter (multi-workspace aware, honors
# adapter-side gates) and fall back to the plugin's standalone sender.
# WeCom media: native delivery through the live gateway adapter.
if platform == Platform.SLACK or (platform == Platform.WECOM and media_files):
empty_media = [] if platform == Platform.SLACK else None
return await _send_chunks(chunks, lambda chunk, is_last: _send_via_adapter(
platform, pconfig, chat_id, chunk, thread_id=thread_id,
media_files=media_files if is_last else empty_media, force_document=force_document,
))
# --- Generic path: text only. Buzz is a plugin platform with verified
# native media delivery through _send_via_adapter, so it is exempt.
if media_files and not message.strip() and platform.value != "buzz":
return {
"error": (
f"send_message MEDIA delivery is currently only supported for {_MEDIA_PLATFORMS_NOTE}; "
f"target {platform.value} had only media attachments"
)
}
warning = None
if media_files and platform.value != "buzz":
warning = (
f"MEDIA attachments were omitted for {platform.value}; "
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:
send_one = lambda chunk, is_last: text_sender(pconfig, chat_id, chunk, thread_id) # noqa: E731
else:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform_name)
handler = entry.send_message_handler if entry is not None else None
if handler is not None:
# Custom handler receives the full request once (not per chunk).
try:
import inspect
result = handler(args or {}, chat_id, platform_name, pconfig)
if inspect.isawaitable(result):
result = await result
return result
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,
)
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")
):
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.