Files
hermes-agent/tests/tools/test_send_message_tool.py
shannonsands a6ee31f55a feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)
* feat(wisdom): add trusted publish and install foundation

* feat(wisdom): add private contribution loop

* feat(wisdom): add managed consumption workflows

* fix(wisdom): close cross-repository safety gaps

* fix(wisdom): align local package and lifecycle policy

* fix(wisdom): require explicit profile setup

* docs(wisdom): repin reconciled gateway head

* fix(wisdom): fence content downloads and approval receipts

* docs(wisdom): record generation-fenced downloads

* docs(wisdom): record unified delivery PR

* fix(ci): stop passing invalid classifier inputs

* docs(wisdom): remove internal requirements ledger

* feat(wisdom): localize dashboard and desktop copy

* feat(wisdom): complete local contribution and consumption UX

* style(wisdom): satisfy desktop lint

* chore(wisdom): refresh requirements pin

* test(dashboard): allow formatted profile copy

* test(wisdom): stabilize desktop interaction coverage

* fix(wisdom): surface dashboard action failures

* fix(wisdom): add repeatable Portal demo login

* feat(wisdom): add actionable skill notifications

* feat(wisdom): add notification install and update actions

* fix(wisdom): make Telegram skill alerts actionable

* fix(wisdom): always refresh demo Agent login

* feat(wisdom): embed Telegram notification actions

* fix(wisdom): preserve Telegram notifications after actions

* fix(wisdom): keep Telegram notification cards readable

* feat(wisdom): add Telegram candidate approval flow

* feat(wisdom): explain Telegram qualification reasons

* fix(wisdom): reconcile cross-surface candidate actions

* feat(telegram): add Collective Wisdom management command

* chore(wisdom): refresh Gateway contract pin

* chore(wisdom): advance Gateway contract pin

* feat(wisdom): align command UX across clients

* feat(slack): add Collective Wisdom management parity

* feat(wisdom): add security and professionalism reviews

* feat(wisdom): add first-time qualification guidance

* feat(wisdom): simplify qualification sharing choices

* feat(skills): add optional editorial metadata

* feat(wisdom): enrich legacy skill presentation

* fix(wisdom): harden review and update boundaries

* fix(wisdom): emit canonical review timestamps

* fix(wisdom): align with merged gateway and main

* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)

- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
  7-day evidence builder that excludes bundled/hub/managed skills and
  dismissed/handled/recently-suggested content hashes, strict pydantic
  schemas for agent output with repair-or-reject, fixed copy templates
  (Share / Teammate / Published / Update / Mute), idempotent retried
  delivery ledger with stale-action resolution, weekly review job,
  resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.

* wisdom: agent-led renderers and button action dispatcher

- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
  is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
  ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
  packaging flow, Install/Update -> plan command. Never publishes/installs.

* wisdom: CLI verbs, agent_led config default, conversational catalog skill

- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
  verbs, share/install flows and fixed notification templates.

* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons

- gateway housekeeping tick calls maybe_run_weekly_review with a home
  channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
  duration keyboard, send_wisdom_agent_recommendation rich card + fallback.

* fix(wisdom): integrate local mediation and harden model and setup boundaries

* fix(wisdom): honor authoritative recommendation policy and defer on failure

* fix(wisdom): synchronize opaque suppression and recheck delivery preferences

* feat(wisdom): route weekly selection through the session-owned assessment queue

* fix(wisdom): prepare and submit the reviewed generated share package

* feat(wisdom): separate native Share preparation from publication consent

* feat(wisdom): sync native mute choices through a leased preference outbox

* feat(wisdom): bind native mute controls to durable preference choices

* feat(wisdom): add scoped desktop and dashboard notification settings

* fix(wisdom): revalidate feed recommendations before assessment and delivery

* fix(wisdom): persist validated delivery receipts before completing notices

* feat(wisdom): add private notification claim and receipt client

* Persist Wisdom send reservations and recover delivery acknowledgements

* Route legacy Wisdom controls through current native review

* Add typed private Wisdom operation outcome client

* fix(wisdom): make agent-led advice usable in the local demo

* fix(wisdom): keep requested consent outside proactive limits

* fix(wisdom): distinguish unavailable assessments and preserve digest text

* fix(wisdom): assess ongoing usefulness beyond the current task

* fix(wisdom): restore immediate qualification sharing controls

* fix(wisdom): separate qualification review from installation advice

* fix(wisdom): collapse review checklists and simplify sharing copy

* fix(wisdom): show compact sharing progress and publication receipts

* fix(wisdom): require credential prefixes rather than matching skill names

* fix(wisdom): finish package checks before presenting sharing consent

* fix(wisdom): scan local skills before qualification cards

* fix(wisdom): update moderation results on existing sharing cards

* fix(wisdom): keep sharing review accessible from receipt cards

* fix(wisdom): align mediated review cards and collapsible checks

* fix(wisdom): clarify clean security summary wording

* fix(wisdom): normalize consent plans and add explicit recheck

* fix(wisdom): keep install and update receipts concise

* fix(wisdom): collapse assessments and deduplicate operation cards

* fix(wisdom): restore private Portal review from native cards

* fix(wisdom): sync Portal publication to original consent card

* fix(wisdom): show local skill version on sharing cards

* fix(wisdom): skip agent recommendations for self-published versions

* fix(wisdom): simplify candidate notices and local-edit recovery copy

* feat(wisdom): submit locally reviewed packages with one confirmation

* feat(wisdom): expose safe receipt and outcome sync recovery

* wisdom: onboarding notice says detect and share, names the user's own skill

Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark

Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.

* wisdom: one opener, no approval line, ask to share after the skill is shown

Product owner review of the candidate card.

- The Hermes written card now opens with the same sentence as the fixed card
  ("Your organisation has enabled Collective Wisdom, a feature designed to
  automatically detect and share useful skills across all team members.")
  instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
  and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
  It is now the last line, after the skill name, description, why suggested
  and the checks, and reads "Would you like to share it?" (matching the
  agent led template wording).

Tests updated for the new order; proposalNotice removed from all desktop locales.

* wisdom: American spelling, organization

Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.

* wisdom: candidate card copy round 4 (owner review)

Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:

1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
   the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
   card (Telegram rich card and plain fallback, legacy agent-led share
   template).
3. The skill name and description are labelled: "Skill name: <name>" and
   "What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
   inappropriate content found)" with no per-check bullets and no "Pass";
   a failed review reads "Needs a look before sharing at work (possible
   inappropriate content)" and lists only the checks that flagged
   something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
   details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
   days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
   editorial_name, a simple one_line_description and a compelling
   why_coworkers_benefit under 300 characters; "Be concise and
   convincing." becomes "Be concise and compelling: the goal is that the
   user wants to share it."

Tests updated for the new strings; review_text() gains direct coverage.

* wisdom: re-apply owner copy after rebase

- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice

* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors

Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.

* fix(wisdom): reconcile optional SDK tests and frontend lint

* fix(wisdom): default to agent-written notification summaries

* fix(wisdom): restore deferred install review and browse controls

* feat(wisdom): inspect installed setup with exact package provenance

* feat(wisdom): run native-approved installed setup steps with durable evidence

* fix(wisdom): recover interrupted setup with explicit native consent

* feat(wisdom): hand native installs into guided setup review

* fix(wisdom): continue requested setup with fixed notification copy

* fix(wisdom): preserve setup while waiting for a session model

* fix(wisdom): expose canonical setup review controls on desktop

* fix(wisdom): resume setup after recorded automatic updates

* fix(wisdom): make missing setup prerequisites recheckable

* chore(wisdom): align Agent with verified Gateway contract

* fix(wisdom): stop guessing team slugs in portal links

* fix(wisdom): retire pending advice on account sign-out

* fix(wisdom): cancel advice after terminal account revocation

* fix(wisdom): fence feed responses across account sign-out

* fix(wisdom): checkpoint signed-out feed before reactivation

* fix(wisdom): link proactive advice to scoped notification settings

* fix(wisdom): coalesce queued publication recommendations by version

* fix(wisdom): keep package review navigation local and deferable

* fix(wisdom): reflect installed state in discovery controls

* fix(wisdom): show exact checks before command confirmation

* chore(wisdom): pin bounded analytics privacy contract

* chore(wisdom): pin retired legacy notification contract

* feat(wisdom): review publisher usage with exact sharing copy

* fix(wisdom): align discovery and review check summaries

* fix(wisdom): show expired consent before confirmation

* fix(wisdom): require fresh review for legacy install controls

* fix(wisdom): preserve review expiry across check toggles

* fix(wisdom): retain update policy in native install reviews

* fix(wisdom): surface failed native card edits

* fix(wisdom): persist local command approval reviews

* fix(wisdom): use saved approvals for messaging commands

* test(wisdom): provide scan result in setup handoff fixture

* test(wisdom): exercise Telegram approvals with saved review state

* fix(wisdom): retain suppression policy for offline deferral

* fix(wisdom): reconsider candidates after deferred suppression expires

* fix(wisdom): bind review checks and report verified readiness separately

* fix(wisdom): persist accepted publication intent and recover exact outcomes

* fix(sync): pin UTF-8 tree ordering across writers

* chore(wisdom): pin organisation-scoped Gateway authorization

* fix(wisdom): restrict consent delivery to user-facing sessions

* chore(wisdom): refresh reviewed Gateway contract pin

* fix(wisdom): preserve kept tools in Blank Slate exclusions

* test(auth): reset anonymous fixture with a profile-scoped cache

* fix(wisdom): gate local surfaces and work on current profile entitlement

* fix(wisdom): invalidate quiet tool cache on entitlement changes

* test(wisdom): authorize local consent gateway fixtures

* fix(wisdom): keep entitlement decoding free of native crypto imports

* test(wisdom): provide local entitlement to demo CLI subprocess

* ci: leave upstream workflow unchanged in Wisdom PR

* fix(wisdom): ship package and contracts in Nix wheels

---------

Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
2026-09-11 19:04:06 +10:00

2221 lines
87 KiB
Python

"""Tests for tools/send_message_tool.py."""
import asyncio
import json
import os
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# python-telegram-bot is an optional dep — skip the entire module when
# it isn't installed (e.g. CI bare env). Tests that patch telegram.Bot
# or call _send_telegram need it; tests for other platforms don't but
# keeping the whole file consistent is simpler.
_HAS_TELEGRAM = pytest.importorskip("telegram", reason="python-telegram-bot not installed") is not None
@pytest.fixture(autouse=True)
def _reset_signal_scheduler():
"""Drop the process-wide attachment scheduler so each test gets a
fresh token bucket."""
from gateway.platforms.signal_rate_limit import _reset_scheduler
_reset_scheduler()
yield
_reset_scheduler()
from gateway.config import Platform
from tools.send_message_tool import (
_send_to_platform,
send_message_tool,
)
from tools.send_message_senders import (
_resolve_slack_user_target,
_send_matrix_via_adapter,
_send_signal,
_send_telegram,
)
from tools.wisdom_notifications import (
send_slack_wisdom_notification_pane,
send_telegram_notification_pane,
)
from tools.send_message_targets import _parse_target_ref
# Discord helpers moved to the plugin in #24325. Import from the new path
# and provide a thin ``_send_discord(token, ...)`` shim that mirrors the
# pre-migration signature so the existing test bodies keep working.
from plugins.platforms.discord.adapter import (
_DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,
_derive_forum_thread_name,
_probe_is_forum_cached,
_standalone_send,
)
async def _send_discord(
token,
chat_id,
message,
*,
thread_id=None,
media_files=None,
):
"""Pre-migration ``(token, chat_id, message, …)`` adapter around the
plugin's ``_standalone_send(pconfig, …)``. Lets test bodies continue
to call ``_send_discord("tok", ...)`` without rewriting every signature.
"""
pconfig = SimpleNamespace(token=token, extra={})
return await _standalone_send(
pconfig,
chat_id,
message,
thread_id=thread_id,
media_files=media_files,
)
class _StreamingAiohttpContent:
def __init__(self, body: bytes):
self._body = body
self.read_sizes = []
async def read(self, size=-1):
self.read_sizes.append(size)
if not self._body:
return b""
if size is None or size < 0:
chunk = self._body
self._body = b""
return chunk
chunk = self._body[:size]
self._body = self._body[size:]
return chunk
class _StreamingAiohttpResponse:
def __init__(self, status: int, body: bytes):
self.status = status
self.content = _StreamingAiohttpContent(body)
self.closed = False
self.json = AsyncMock(side_effect=AssertionError("resp.json() should not be used"))
self.text = AsyncMock(side_effect=AssertionError("resp.text() should not be used"))
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
def close(self):
self.closed = True
class _StreamingAiohttpSession:
def __init__(self, response: _StreamingAiohttpResponse):
self.response = response
self.post = MagicMock(return_value=response)
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
def _discord_entry():
"""Return the live Discord PlatformEntry, importing lazily so plugin
discovery is forced exactly once and patches survive across tests."""
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins()
return platform_registry.get("discord")
class _patch_discord_sender:
"""Patch the Discord registry entry's ``standalone_sender_fn`` with the
given mock and translate the production ``(pconfig, ...)`` call shape
back to the pre-migration ``(token, ...)`` shape the test mocks expect.
Use as a context manager:
send_mock = AsyncMock(return_value={...})
with _patch_discord_sender(send_mock):
asyncio.run(_send_to_platform(Platform.DISCORD, ...))
send_mock.assert_awaited_once_with("tok", "chat", "msg",
thread_id=None, media_files=[])
"""
def __init__(self, mock):
self._mock = mock
self._entry = None
self._original = None
async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None, caption=None):
token = getattr(pconfig, "token", None)
# Only forward caption= when set, so mocks written against the
# pre-caption signature (no caption kwarg) keep working.
extra = {"caption": caption} if caption is not None else {}
return await self._mock(
token, chat_id, message,
thread_id=thread_id, media_files=media_files, **extra,
)
def __enter__(self):
self._entry = _discord_entry()
self._original = self._entry.standalone_sender_fn
self._entry.standalone_sender_fn = self._adapter
return self._mock
def __exit__(self, exc_type, exc, tb):
if self._entry is not None:
self._entry.standalone_sender_fn = self._original
return False
def _slack_entry():
"""Return the live Slack PlatformEntry, importing lazily so plugin
discovery is forced exactly once and patches survive across tests."""
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins()
return platform_registry.get("slack")
def _make_recording_slack_sender():
"""Return a plain AsyncMock used to record the formatted Slack text.
Paired with ``_patch_slack_standalone_sender``, which wraps it so the
production ``(pconfig, chat_id, raw_text, thread_id=...)`` call is
translated into the pre-migration ``(token, chat_id, formatted_text,
thread_ts=...)`` shape — applying ``SlackAdapter.format_message`` exactly
as the real plugin ``_standalone_send`` does. Tests can then assert on
``send.await_args.args[2]`` (the formatted mrkdwn) as before.
"""
return AsyncMock(return_value={"success": True, "platform": "slack", "message_id": "1"})
class _patch_slack_standalone_sender:
"""Patch the Slack registry entry's ``standalone_sender_fn`` with a wrapper
that replicates the plugin's mrkdwn formatting then delegates to the given
mock in the pre-migration call shape. Mirrors ``_patch_discord_sender``.
Slack mrkdwn formatting moved INTO the plugin's ``_standalone_send`` when
the adapter migrated (#41112) — previously ``_send_to_platform`` formatted
the message before calling the old ``_send_slack`` helper. This wrapper
keeps the "markdown → Slack mrkdwn reaches the wire" behavior tests valid.
"""
def __init__(self, mock):
self._mock = mock
self._entry = None
self._original = None
async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, **_kw):
from plugins.platforms.slack.adapter import SlackAdapter
formatted = message
if message:
try:
formatted = SlackAdapter.__new__(SlackAdapter).format_message(message)
except Exception:
pass
token = getattr(pconfig, "token", None)
return await self._mock(token, chat_id, formatted, thread_ts=thread_id)
def __enter__(self):
self._entry = _slack_entry()
self._original = self._entry.standalone_sender_fn
self._entry.standalone_sender_fn = self._adapter
return self._mock
def __exit__(self, exc_type, exc, tb):
if self._entry is not None:
self._entry.standalone_sender_fn = self._original
return False
def _run_async_immediately(coro):
return asyncio.run(coro)
def _make_config():
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
return SimpleNamespace(
platforms={Platform.TELEGRAM: telegram_cfg},
get_home_channel=lambda _platform: None,
), telegram_cfg
def _install_telegram_mock(monkeypatch, bot):
parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML")
constants_mod = SimpleNamespace(ParseMode=parse_mode)
class InlineKeyboardButton:
def __init__(self, text, *, url=None, callback_data=None):
self.text = text
self.url = url
self.callback_data = callback_data
def to_dict(self):
data = {"text": self.text}
if self.url is not None:
data["url"] = self.url
if self.callback_data is not None:
data["callback_data"] = self.callback_data
return data
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard):
self.inline_keyboard = inline_keyboard
def to_dict(self):
return {
"inline_keyboard": [
[button.to_dict() for button in row] for row in self.inline_keyboard
]
}
# MessageEntity needed by #27865 mention-detection path; tests don't
# inspect it but the import must succeed.
_MessageEntity = lambda **_kw: SimpleNamespace(**_kw)
telegram_mod = SimpleNamespace(
Bot=lambda token: bot,
InlineKeyboardButton=InlineKeyboardButton,
InlineKeyboardMarkup=InlineKeyboardMarkup,
MessageEntity=_MessageEntity,
constants=constants_mod,
)
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
def _ensure_slack_mock(monkeypatch):
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
monkeypatch.setitem(sys.modules, name, mod)
class TestSendMessageTool:
def test_wisdom_notification_pane_allows_only_bounded_trusted_callbacks(self):
telegram_cfg = SimpleNamespace(enabled=True, token="tok", extra={})
config = SimpleNamespace(
platforms={Platform.TELEGRAM: telegram_cfg},
get_home_channel=lambda _platform: SimpleNamespace(chat_id="123"),
)
with patch("tools.send_message_tool.prepare_send_message_platforms"), \
patch("gateway.config.load_gateway_config", return_value=config), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.wisdom_notifications._send_telegram", new=AsyncMock(return_value={"success": True})) as send_mock:
result = send_telegram_notification_pane(
message="Collective Wisdom",
button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-3",
},
{"label": "Unsafe", "callback_data": "ea:always:1"},
],
],
)
assert result == {"success": True}
send_mock.assert_awaited_once_with(
"tok",
"123",
"Collective Wisdom",
disable_link_previews=True,
action_button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-3",
}
]
],
rich_message_html=None,
)
def test_wisdom_notification_pane_embeds_controls_in_rich_items(self):
telegram_cfg = SimpleNamespace(enabled=True, token="tok", extra={})
config = SimpleNamespace(
platforms={Platform.TELEGRAM: telegram_cfg},
get_home_channel=lambda _platform: SimpleNamespace(chat_id="123"),
)
with patch("tools.send_message_tool.prepare_send_message_platforms"), \
patch("gateway.config.load_gateway_config", return_value=config), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.wisdom_notifications._send_telegram", new=AsyncMock(return_value={"success": True})) as send_mock:
result = send_telegram_notification_pane(
message="Collective Wisdom\ngateway-pull-canary · v3",
items=[
{
"heading": "⬆️ Update <available>",
"detail": "gateway-pull-canary · v3\nPrivate keys: ✅ Pass",
}
],
button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-3",
},
{
"label": "View",
"url": "https://portal.example/orgs/team/wisdom/skills/skill-3?version=3&from=telegram",
},
]
],
)
assert result == {"success": True}
rich_html = send_mock.await_args.kwargs["rich_message_html"]
assert rich_html == (
"<h3>Hermes Collective Wisdom</h3><p>1 new notification</p>"
"<p><b>⬆️ Update &lt;available&gt;</b><br/>"
"gateway-pull-canary · v3<br/>Private keys: ✅ Pass</p>"
'<tg-button-row align="left">'
'<tg-button type="callback_data" style="primary" '
'data="wi:plan:update:skill-3">Update</tg-button> '
'<tg-button type="url" '
'url="https://portal.example/orgs/team/wisdom/skills/'
'skill-3?version=3&amp;from=telegram">View</tg-button>'
"</tg-button-row>"
)
def test_slack_wisdom_notification_keeps_local_portal_action(self, monkeypatch):
_ensure_slack_mock(monkeypatch)
slack_cfg = SimpleNamespace(enabled=True, token="xoxb-test", extra={})
home = SimpleNamespace(chat_id="D123456789", thread_id=None)
config = SimpleNamespace(
platforms={Platform.SLACK: slack_cfg},
get_home_channel=lambda platform: home if platform == Platform.SLACK else None,
)
client = SimpleNamespace(
chat_postMessage=AsyncMock(return_value={"ok": True, "ts": "1.2"})
)
with patch("tools.send_message_tool.prepare_send_message_platforms"), \
patch("gateway.config.load_gateway_config", return_value=config), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("slack_sdk.web.async_client.AsyncWebClient", return_value=client):
result = send_slack_wisdom_notification_pane(
message="Collective Wisdom",
items=[{"heading": "New skill", "detail": "Team Runbook · v1"}],
button_rows=[
[
{
"label": "View in Portal",
"url": "http://127.0.0.1:3111/orgs/wisdom-local/wisdom/skills/skill-1?version=1",
},
{
"label": "Install",
"callback_data": "wi:plan:install:skill-1",
},
]
],
)
assert result == {"success": True, "message_id": "1.2", "private": True}
blocks = client.chat_postMessage.await_args.kwargs["blocks"]
assert blocks[-1] == {
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View in Portal",
"emoji": True,
},
"action_id": "hermes_wisdom_feed_0_0",
"url": "http://127.0.0.1:3111/orgs/wisdom-local/wisdom/skills/skill-1?version=1",
"value": "wisdom:portal",
},
{
"type": "button",
"text": {
"type": "plain_text",
"text": "Install",
"emoji": True,
},
"action_id": "hermes_wisdom_feed_0_1",
"value": "wi:plan:install:skill-1",
"style": "primary",
},
],
}
def test_ntfy_topic_target_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("ntfy", "alerts-channel")
assert chat_id == "alerts-channel"
assert thread_id is None
assert is_explicit is True
def test_ntfy_topic_target_bypasses_channel_directory(self):
ntfy_platform = Platform("ntfy")
ntfy_cfg = SimpleNamespace(enabled=True, token=None, extra={"topic": "hermes-in"})
config = SimpleNamespace(
platforms={ntfy_platform: ntfy_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch("gateway.channel_directory.resolve_channel_name", side_effect=AssertionError("should not resolve ntfy topics")), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("gateway.mirror.mirror_to_session", return_value=True):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "ntfy:alerts-channel",
"message": "done",
}
)
)
assert result["success"] is True
send_mock.assert_awaited_once_with(
ntfy_platform,
ntfy_cfg,
"alerts-channel",
"done",
thread_id=None,
media_files=[],
force_document=False,
)
def test_media_tag_outside_allowed_roots_is_not_sent(self, tmp_path, monkeypatch):
# This test exercises the strict-allowlist path; force strict mode on
# and disable recency trust so the freshly-written tmp_path file is
# not auto-accepted by the trust window. (Recency trust is covered
# in test_platform_base.py. The public default flipped to non-strict
# in 2026-05; this test pins strict on explicitly.)
monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1")
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
config, telegram_cfg = _make_config()
secret = tmp_path / "secret.pdf"
secret.write_bytes(b"%PDF secret")
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("gateway.mirror.mirror_to_session", return_value=True):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "telegram:12345",
"message": f"hello\nMEDIA:{secret}",
}
)
)
assert result["success"] is True
send_mock.assert_awaited_once_with(
Platform.TELEGRAM,
telegram_cfg,
"12345",
"hello",
thread_id=None,
media_files=[],
force_document=False,
)
def test_top_level_send_failure_redacts_query_token(self):
config, _telegram_cfg = _make_config()
leaked = "very-secret-query-token-123456"
def _raise_and_close(coro):
coro.close()
raise RuntimeError(
f"transport error: https://api.example.com/send?access_token={leaked}"
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch("model_tools._run_async", side_effect=_raise_and_close):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "telegram:-1001",
"message": "hello",
}
)
)
assert "error" in result
assert leaked not in result["error"]
assert "access_token=***" in result["error"]
class TestSendTelegramMediaDelivery:
def test_sends_photo_with_caption_for_media_tag(self, tmp_path, monkeypatch):
# A single captionable image + short text now rides as the photo's
# native caption (MEDIA:<path> caption), not a separate text message.
image_path = tmp_path / "photo.png"
image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
bot = MagicMock()
bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2))
bot.send_video = AsyncMock()
bot.send_voice = AsyncMock()
bot.send_audio = AsyncMock()
bot.send_document = AsyncMock()
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_telegram(
"token",
"12345",
"Hello there",
media_files=[(str(image_path), False)],
)
)
assert result["success"] is True
assert result["message_id"] == "2"
# No separate text send — the caption rides the photo bubble.
bot.send_message.assert_not_awaited()
bot.send_photo.assert_awaited_once()
assert bot.send_photo.await_args.kwargs.get("caption") == "Hello there"
def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch):
voice_path = tmp_path / "voice.ogg"
voice_path.write_bytes(b"OggS" + b"\x00" * 32)
bot = MagicMock()
bot.send_message = AsyncMock()
bot.send_photo = AsyncMock()
bot.send_video = AsyncMock()
bot.send_voice = AsyncMock(return_value=SimpleNamespace(message_id=7))
bot.send_audio = AsyncMock()
bot.send_document = AsyncMock()
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_telegram(
"token",
"12345",
"",
media_files=[(str(voice_path), True)],
)
)
assert result["success"] is True
bot.send_voice.assert_awaited_once()
bot.send_audio.assert_not_awaited()
bot.send_message.assert_not_awaited()
def test_missing_media_returns_error_without_leaking_raw_tag(self, monkeypatch):
bot = MagicMock()
bot.send_message = AsyncMock()
bot.send_photo = AsyncMock()
bot.send_video = AsyncMock()
bot.send_voice = AsyncMock()
bot.send_audio = AsyncMock()
bot.send_document = AsyncMock()
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_telegram(
"token",
"12345",
"",
media_files=[("/tmp/does-not-exist.png", False)],
)
)
assert "error" in result
assert "No deliverable text or media remained" in result["error"]
bot.send_message.assert_not_awaited()
# ---------------------------------------------------------------------------
# Regression: long messages are chunked before platform dispatch
# ---------------------------------------------------------------------------
class TestSendToPlatformChunking:
def test_long_message_is_chunked(self):
"""Messages exceeding the platform limit are split into multiple sends."""
send = AsyncMock(return_value={"success": True, "message_id": "1"})
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
with _patch_discord_sender(send):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
SimpleNamespace(enabled=True, token="***", extra={}),
"ch", long_msg,
)
)
assert result["success"] is True
assert send.await_count >= 3
for call in send.await_args_list:
assert len(call.args[2]) <= 2020 # each chunk fits the limit
def test_signal_long_message_is_chunked(self, monkeypatch):
"""Standalone Signal sends split at the adapter's 8000-char limit.
The standalone path (hermes send / cron / MCP) speaks raw JSON-RPC via
_send_signal and bypasses SignalAdapter.send(), so the shared
truncate_message() pass in _send_to_platform must know Signal's limit
(regression for #67279 / #57929 — long sends were rejected whole).
"""
from gateway.platforms.signal import MAX_MESSAGE_LENGTH as SIGNAL_MAX
import tools.send_message_tool as smt
sent = []
async def fake_send_signal(extra, chat_id, chunk, media_files=None):
sent.append(chunk)
return {"success": True, "platform": "signal", "chat_id": chat_id}
monkeypatch.setattr(smt, "_send_signal", fake_send_signal)
long_msg = "word " * ((SIGNAL_MAX // 5) + 500) # comfortably over limit
result = asyncio.run(
_send_to_platform(
Platform.SIGNAL,
SimpleNamespace(enabled=True, token=None,
extra={"http_url": "http://localhost:8080",
"account": "+15551234567"}),
"+15557654321", long_msg,
)
)
assert result["success"] is True
assert len(sent) >= 2, "long Signal message must be split, not sent whole"
assert all(len(chunk) <= SIGNAL_MAX for chunk in sent)
# No truncation footer — content is delivered in full across chunks
assert all("truncated, full output saved to" not in c for c in sent)
def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
"""Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
_ensure_slack_mock(monkeypatch)
import plugins.platforms.slack.adapter as slack_mod
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
send = _make_recording_slack_sender()
with _patch_slack_standalone_sender(send):
result = asyncio.run(
_send_to_platform(
Platform.SLACK,
SimpleNamespace(enabled=True, token="***", extra={}),
"C123",
"AT&amp;T &lt;tag&gt; test",
)
)
assert result["success"] is True
sent_text = send.await_args.args[2]
assert "&amp;amp;" not in sent_text
assert "&amp;lt;" not in sent_text
assert "AT&amp;T" in sent_text
def test_telegram_markdown_expansion_is_chunked_before_send(self, monkeypatch):
"""Telegram chunking must account for MarkdownV2 escaping expansion.
A raw message under 4096 UTF-16 units can inflate past the limit once
MarkdownV2-escaped (each `!`/`.`/`-` becomes `\\!`/`\\.`/`\\-`). The
send path must chunk the *formatted* text so no single send exceeds
4096 (issue #28557).
"""
from gateway.platforms.base import utf16_len
send_lengths = []
async def fake_send_message(**kwargs):
text = kwargs["text"]
send_lengths.append(utf16_len(text))
if utf16_len(text) > 4096:
raise Exception("Message is too long")
return SimpleNamespace(message_id=len(send_lengths))
bot = MagicMock()
bot.send_message = AsyncMock(side_effect=fake_send_message)
bot.send_photo = AsyncMock()
bot.send_video = AsyncMock()
bot.send_voice = AsyncMock()
bot.send_audio = AsyncMock()
bot.send_document = AsyncMock()
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_to_platform(
Platform.TELEGRAM,
SimpleNamespace(enabled=True, token="tok", extra={}),
"123",
"!" * 4096, # raw 4096 -> ~8192 after MarkdownV2 escaping
)
)
assert result["success"] is True
assert bot.send_message.await_count >= 2
assert max(send_lengths) <= 4096
def test_matrix_media_uses_native_adapter_helper(self, tmp_path):
doc_path = tmp_path / "test-send-message-matrix.pdf"
doc_path.write_bytes(b"%PDF-1.4 test")
try:
helper = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:example.com", "message_id": "$evt"})
with patch("tools.send_message_tool._send_matrix_via_adapter", helper):
result = asyncio.run(
_send_to_platform(
Platform.MATRIX,
SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}),
"!room:example.com",
"here you go",
media_files=[(str(doc_path), False)],
)
)
assert result["success"] is True
helper.assert_awaited_once()
call = helper.await_args
assert call.args[1] == "!room:example.com"
assert call.args[2] == "here you go"
assert call.kwargs["media_files"] == [(str(doc_path), False)]
finally:
doc_path.unlink(missing_ok=True)
class TestMatrixMediaLiveAdapterReuse:
"""Verify _send_matrix_via_adapter reuses the live gateway adapter
when available, avoiding per-message E2EE re-init storms (#46310)."""
def test_live_adapter_skips_connect_disconnect(self, tmp_path):
"""When a live gateway adapter exists, no connect() or disconnect()
should be called — the persistent E2EE session is reused."""
img_path = tmp_path / "photo.png"
img_path.write_bytes(b"\x89PNG\r\n")
calls = []
class LiveAdapter:
async def send(self, chat_id, message, metadata=None):
calls.append(("send", chat_id, message))
return SimpleNamespace(success=True, message_id="$text")
async def send_image_file(self, chat_id, path, metadata=None):
calls.append(("send_image_file", chat_id, path))
return SimpleNamespace(success=True, message_id="$img")
live_adapter = LiveAdapter()
fake_runner = SimpleNamespace(
adapters={Platform.MATRIX: live_adapter}
)
with patch(
"gateway.run._gateway_runner_ref",
return_value=fake_runner,
), patch.dict(
sys.modules, {"plugins.platforms.matrix.adapter": SimpleNamespace()}
):
result = asyncio.run(
_send_matrix_via_adapter(
SimpleNamespace(enabled=True, token="tok", extra={}),
"!room:example.com",
"here is an image",
media_files=[(str(img_path), False)],
)
)
assert result["success"] is True
assert result["message_id"] == "$img"
# Only send + send_image_file; no connect / disconnect
assert calls == [
("send", "!room:example.com", "here is an image"),
("send_image_file", "!room:example.com", str(img_path)),
]
def test_live_adapter_not_available_falls_back_to_ephemeral(self, tmp_path):
"""When _gateway_runner_ref returns None, the ephemeral adapter
path (connect + disconnect) is used as before."""
doc_path = tmp_path / "doc.pdf"
doc_path.write_bytes(b"%PDF-1.4")
calls = []
class EphemeralAdapter:
def __init__(self, _config):
pass
async def connect(self):
calls.append(("connect",))
return True
async def send(self, chat_id, message, metadata=None):
calls.append(("send", chat_id, message))
return SimpleNamespace(success=True, message_id="$txt")
async def send_document(self, chat_id, path, metadata=None):
calls.append(("send_document", chat_id, path))
return SimpleNamespace(success=True, message_id="$doc")
async def disconnect(self):
calls.append(("disconnect",))
fake_module = SimpleNamespace(MatrixAdapter=EphemeralAdapter)
with patch(
"gateway.run._gateway_runner_ref", return_value=None
), patch.dict(sys.modules, {"plugins.platforms.matrix.adapter": fake_module}):
result = asyncio.run(
_send_matrix_via_adapter(
SimpleNamespace(enabled=True, token="tok", extra={}),
"!room:example.com",
"report attached",
media_files=[(str(doc_path), False)],
)
)
assert result["success"] is True
assert calls == [
("connect",),
("send", "!room:example.com", "report attached"),
("send_document", "!room:example.com", str(doc_path)),
("disconnect",),
]
# ---------------------------------------------------------------------------
# HTML auto-detection in Telegram send
# ---------------------------------------------------------------------------
class TestSendToPlatformWhatsapp:
def test_whatsapp_routes_via_local_bridge_sender(self):
"""WhatsApp delivery routes through the plugin's registry
standalone_sender_fn (was tools.send_message_tool._send_whatsapp
before the #41112 plugin migration)."""
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins()
chat_id = "test-user@lid"
async_mock = AsyncMock(return_value={"success": True, "platform": "whatsapp", "chat_id": chat_id, "message_id": "abc123"})
wa_entry = platform_registry.get("whatsapp")
original_sender = wa_entry.standalone_sender_fn
wa_entry.standalone_sender_fn = async_mock
try:
result = asyncio.run(
_send_to_platform(
Platform.WHATSAPP,
SimpleNamespace(enabled=True, token=None, extra={"bridge_port": 3000}),
chat_id,
"hello from hermes",
)
)
finally:
wa_entry.standalone_sender_fn = original_sender
assert result["success"] is True
# _registry_standalone_send passes (pconfig, chat_id, message, thread_id=None)
async_mock.assert_awaited_once()
_call = async_mock.await_args
assert _call.args[1] == chat_id
assert _call.args[2] == "hello from hermes"
class TestSendTelegramHtmlDetection:
"""Verify that messages containing HTML tags are sent with parse_mode=HTML
and that plain / markdown messages use MarkdownV2."""
def _make_bot(self):
bot = MagicMock()
bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
bot.send_photo = AsyncMock()
bot.send_video = AsyncMock()
bot.send_voice = AsyncMock()
bot.send_audio = AsyncMock()
bot.send_document = AsyncMock()
return bot
def test_html_message_uses_html_parse_mode(self, monkeypatch):
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
asyncio.run(
_send_telegram("tok", "123", "<b>Hello</b> world")
)
bot.send_message.assert_awaited_once()
kwargs = bot.send_message.await_args.kwargs
assert kwargs["parse_mode"] == "HTML"
assert kwargs["text"] == "<b>Hello</b> world"
def test_notification_url_buttons_render_as_action_rows(self, monkeypatch):
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
asyncio.run(
_send_telegram(
"tok",
"123",
"<b>Collective Wisdom</b>",
url_buttons=[
{
"label": "View team-runbook v3",
"url": "https://portal.example/orgs/team/wisdom/skills/skill-3?version=3",
},
{
"label": "Review release-checklist",
"url": "https://portal.example/orgs/team/wisdom/review/draft-4",
},
],
)
)
reply_markup = bot.send_message.await_args.kwargs["reply_markup"]
assert reply_markup.to_dict() == {
"inline_keyboard": [
[
{
"text": "View team-runbook v3",
"url": "https://portal.example/orgs/team/wisdom/skills/skill-3?version=3",
}
],
[
{
"text": "Review release-checklist",
"url": "https://portal.example/orgs/team/wisdom/review/draft-4",
}
],
]
}
def test_notification_action_buttons_share_their_requested_rows(self, monkeypatch):
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
asyncio.run(
_send_telegram(
"tok",
"123",
"<b>Collective Wisdom</b>",
action_button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-3",
},
{
"label": "View ↗",
"url": "https://portal.example/orgs/team/wisdom/skills/skill-3?version=3",
},
],
],
)
)
reply_markup = bot.send_message.await_args.kwargs["reply_markup"]
assert reply_markup.to_dict() == {
"inline_keyboard": [
[
{
"callback_data": "wi:plan:update:skill-3",
"text": "Update",
},
{
"text": "View ↗",
"url": "https://portal.example/orgs/team/wisdom/skills/skill-3?version=3",
},
],
]
}
@pytest.mark.parametrize("missing_thread", [False, True])
def test_notification_chunks_keep_controls_on_final_chunk(self, monkeypatch, missing_thread):
bot = self._make_bot()
delivered = []
async def send(**kwargs):
if missing_thread and "message_thread_id" in kwargs:
raise RuntimeError("Bad Request: message thread not found")
delivered.append(kwargs)
return SimpleNamespace(message_id=len(delivered))
bot.send_message = AsyncMock(side_effect=send)
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(_send_telegram(
"tok", "123", "A" * 9000, thread_id="7",
action_buttons=[{"label": "Update", "callback_data": "wi:plan:update:skill-3"}],
))
assert result["success"] is True
assert len(delivered) >= 3
assert sum(chunk["text"].count("A") for chunk in delivered) == 9000
assert all(len(chunk["text"]) <= 4096 for chunk in delivered)
assert all("reply_markup" not in chunk for chunk in delivered[:-1])
assert delivered[-1]["reply_markup"].to_dict() == {
"inline_keyboard": [[{"text": "Update", "callback_data": "wi:plan:update:skill-3"}]]
}
if missing_thread:
attempts = bot.send_message.await_args_list
assert attempts[0].kwargs["message_thread_id"] == 7
assert all("message_thread_id" not in attempt.kwargs for attempt in attempts[1:])
else:
assert all(chunk["message_thread_id"] == 7 for chunk in delivered)
def test_rich_notification_embeds_buttons_in_the_message(self, monkeypatch):
bot = self._make_bot()
bot.do_api_request = AsyncMock(return_value={"message_id": 77})
_install_telegram_mock(monkeypatch, bot)
rich_html = (
"<h3>Collective Wisdom</h3>"
'<p><b>Update available</b><br/>managed-skill · v2<br/>'
'<tg-button type="callback_data" style="primary" '
'data="wi:plan:update:skill-1">Update</tg-button></p>'
)
result = asyncio.run(
_send_telegram(
"tok",
"123",
"Collective Wisdom",
disable_link_previews=True,
action_button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-1",
}
]
],
rich_message_html=rich_html,
)
)
assert result == {"success": True, "message_id": "77"}
bot.do_api_request.assert_awaited_once_with(
"sendRichMessage",
api_kwargs={
"chat_id": 123,
"rich_message": {"html": rich_html},
"link_preview_options": {"is_disabled": True},
},
)
bot.send_message.assert_not_awaited()
def test_rich_notification_falls_back_after_permanent_rejection(
self, monkeypatch
):
class BadRequest(Exception):
pass
bot = self._make_bot()
bot.do_api_request = AsyncMock(
side_effect=BadRequest("Bad Request: unsupported rich message")
)
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_telegram(
"tok",
"123",
"Collective Wisdom",
action_button_rows=[
[
{
"label": "Update",
"callback_data": "wi:plan:update:skill-1",
}
]
],
rich_message_html="<p>Update</p>",
)
)
assert result["success"] is True
bot.send_message.assert_awaited_once()
reply_markup = bot.send_message.await_args.kwargs["reply_markup"]
assert reply_markup.to_dict()["inline_keyboard"][0][0] == {
"callback_data": "wi:plan:update:skill-1",
"text": "Update",
}
def test_rich_notification_does_not_duplicate_after_transient_failure(
self, monkeypatch
):
bot = self._make_bot()
bot.do_api_request = AsyncMock(side_effect=Exception("502 Bad Gateway"))
_install_telegram_mock(monkeypatch, bot)
result = asyncio.run(
_send_telegram(
"tok",
"123",
"Collective Wisdom",
rich_message_html="<p>Update</p>",
)
)
assert "Telegram rich notification failed" in result["error"]
bot.send_message.assert_not_awaited()
def test_transient_bad_gateway_retries_text_send(self, monkeypatch):
bot = self._make_bot()
bot.send_message = AsyncMock(
side_effect=[
Exception("502 Bad Gateway"),
SimpleNamespace(message_id=2),
]
)
_install_telegram_mock(monkeypatch, bot)
with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock:
result = asyncio.run(_send_telegram("tok", "123", "hello"))
assert result["success"] is True
assert bot.send_message.await_count == 2
sleep_mock.assert_awaited_once()
class TestSendTelegramThreadIdMapping:
"""General-topic mapping in _send_telegram (issue #22267).
Telegram forum supergroups address the General topic as
``message_thread_id="1"`` on incoming updates, but the Bot API rejects
sends with ``message_thread_id=1`` ("Message thread not found"). The
gateway adapter's ``_message_thread_id_for_send`` helper maps "1" to
``None`` for that reason; the standalone ``_send_telegram`` helper used
by the ``send_message`` tool needs the same mapping.
"""
def _make_bot(self):
bot = MagicMock()
bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
return bot
def test_general_topic_thread_id_omitted(self, monkeypatch):
"""thread_id="1" must be dropped before calling the Bot API."""
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
asyncio.run(_send_telegram("tok", "-1001234567890", "hello", thread_id="1"))
bot.send_message.assert_awaited_once()
kwargs = bot.send_message.await_args.kwargs
assert "message_thread_id" not in kwargs
def test_non_general_topic_thread_id_preserved(self, monkeypatch):
"""Real forum-topic thread ids (>1) still pass through as ints."""
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
asyncio.run(_send_telegram("tok", "-1001234567890", "hello", thread_id="17585"))
kwargs = bot.send_message.await_args.kwargs
assert kwargs["message_thread_id"] == 17585
def test_thread_not_found_retries_without_message_thread_id(self, monkeypatch):
"""When send_message raises "thread not found", retry without thread_id (#27012)."""
bot = self._make_bot()
_install_telegram_mock(monkeypatch, bot)
# First call raises thread-not-found, second succeeds
bot.send_message = AsyncMock(side_effect=[
Exception("Bad Request: message thread not found"),
SimpleNamespace(message_id=2),
])
asyncio.run(
_send_telegram("tok", "-1001234567890", "hello", thread_id="17585")
)
assert bot.send_message.await_count == 2
# First call: should include message_thread_id=17585
call1_kwargs = bot.send_message.await_args_list[0].kwargs
assert call1_kwargs["message_thread_id"] == 17585
# Second call (retry): should NOT include message_thread_id
call2_kwargs = bot.send_message.await_args_list[1].kwargs
assert "message_thread_id" not in call2_kwargs
def test_thread_not_found_for_media_retries_without_message_thread_id(self, monkeypatch, tmp_path):
"""Media send with stale thread_id retries without it (#27012)."""
bot = self._make_bot()
# Mock send_document to fail with thread-not-found, then succeed
bot.send_document = AsyncMock(side_effect=[
Exception("Bad Request: message thread not found"),
SimpleNamespace(message_id=3),
])
_install_telegram_mock(monkeypatch, bot)
# Create a test file
test_file = tmp_path / "doc.txt"
test_file.write_text("test content")
asyncio.run(
_send_telegram(
"tok", "-1001234567890", "",
media_files=[(str(test_file), False)],
thread_id="17585",
)
)
assert bot.send_document.await_count == 2
# First call: should include message_thread_id=17585
call1_kwargs = bot.send_document.await_args_list[0].kwargs
assert call1_kwargs["message_thread_id"] == 17585
# Second call (retry): should NOT include message_thread_id
call2_kwargs = bot.send_document.await_args_list[1].kwargs
assert "message_thread_id" not in call2_kwargs
# ---------------------------------------------------------------------------
# Tests for Discord thread_id support
# ---------------------------------------------------------------------------
class TestParseTargetRef:
"""_parse_target_ref extracts (chat_id, thread_id, is_explicit) per platform.
The tables below cover every explicit target form each platform accepts,
the forms that must fall through to directory resolution, and the
cross-platform scoping guards (a Slack ID is not a Discord target, a
WhatsApp JID is not a Signal target, ...).
"""
def test_explicit_targets_round_trip_chat_and_thread(self):
cases = [
# Discord: snowflake, optional :thread, surrounding whitespace
("discord", "-1001234567890:17585", "-1001234567890", "17585"),
("discord", "1003724596514", "1003724596514", None),
("discord", " 123456:789 ", "123456", "789"),
# Matrix: room id, room:thread-event, user MXID
("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org:$thread123:matrix.org",
"!HLOQwxYGgFPMPJUSNR:matrix.org", "$thread123:matrix.org"),
("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org",
"!HLOQwxYGgFPMPJUSNR:matrix.org", None),
("matrix", "@hermes:matrix.org", "@hermes:matrix.org", None),
# Phone platforms: E.164 keeps its '+' for signal-cli; groups and
# bare digits also resolve.
("signal", "+41791234567", "+41791234567", None),
("signal", " group:abc123 ", "group:abc123", None),
("signal", "15551234567", "15551234567", None),
("sms", "+15551234567", "+15551234567", None),
("whatsapp", "+15551234567", "+15551234567", None),
("photon", "+15551234567", "+15551234567", None),
# WhatsApp native JIDs. Regression: group (@g.us) and linked-identity
# (@lid) JIDs matched no branch and silently fell through to the
# configured home DM instead of the requested group.
("whatsapp", "120363408391911677@g.us", "120363408391911677@g.us", None),
("whatsapp", "19255551234@s.whatsapp.net", "19255551234@s.whatsapp.net", None),
("whatsapp", "149606612619433@lid", "149606612619433@lid", None),
("whatsapp", "status@broadcast", "status@broadcast", None),
("whatsapp", "120363000000000000@newsletter",
"120363000000000000@newsletter", None),
# Slack: channel/group/DM ids, thread ts, and user targets that the
# caller must open as a DM.
("slack", "C0B0QV5434G:171.000001", "C0B0QV5434G", "171.000001"),
("slack", "C0B0QV5434G", "C0B0QV5434G", None),
("slack", "G123ABCDEF", "G123ABCDEF", None),
("slack", "D123ABCDEF", "D123ABCDEF", None),
("slack", "U123ABCDEF", "user:U123ABCDEF", None),
("slack", "@alice", "user_name:alice", None),
("slack", " C0B0QV5434G ", "C0B0QV5434G", None),
# Email
("email", "user@example.com", "user@example.com", None),
("email", "first.last@example.co.uk", "first.last@example.co.uk", None),
("email", "user+tag@gmail.com", "user+tag@gmail.com", None),
("email", " user@example.com ", "user@example.com", None),
]
for platform, target, expected_chat, expected_thread in cases:
chat_id, thread_id, is_explicit = _parse_target_ref(platform, target)
label = f"{platform}:{target}"
assert is_explicit is True, label
assert chat_id == expected_chat, label
assert thread_id == expected_thread, label
def test_non_explicit_targets_fall_through_to_resolution(self):
cases = [
("matrix", "#general:matrix.org"), # alias needs resolution
("signal", " group: "), # empty group id
("signal", "+123"), # E.164 too short
("signal", "+1234567890123456"), # E.164 too long
("signal", "+12abc4567890"), # non-numeric
("signal", "+"),
("whatsapp", "general"), # friendly name, not a JID
("slack", "W123ABCDEF"), # workspace id is not sendable
("slack", "c0b0qv5434g"), # lowercase
("slack", "C123"), # too short
("slack", "X0B0QV5434G"), # unknown prefix
("email", "not-an-email"),
("email", "@example.com"),
("email", "user@"),
("email", "user@.com"),
]
for platform, target in cases:
chat_id, _, is_explicit = _parse_target_ref(platform, target)
assert is_explicit is False, f"{platform}:{target}"
def test_prefixes_and_suffixes_are_platform_scoped(self):
"""A form that is explicit on one platform must not leak to another."""
cases = [
("telegram", "!something"),
("discord", "@someone"),
("telegram", "+15551234567"),
("discord", "+15551234567"),
("matrix", "+15551234567"),
("telegram", "120363408391911677@g.us"),
("signal", "149606612619433@lid"),
("discord", "C0B0QV5434G"),
("telegram", "C0B0QV5434G"),
("telegram", "user@example.com"),
("discord", "user@example.com"),
("slack", "user@example.com"),
]
for platform, target in cases:
assert _parse_target_ref(platform, target)[2] is False, f"{platform}:{target}"
class TestEmailHomeChannelErrorHint:
"""The no-home-channel error for email points at the real env var.
Email reads its home channel from EMAIL_HOME_ADDRESS (gateway/config.py),
not the generic EMAIL_HOME_CHANNEL. The error guidance must name the
variable that is actually consulted so users who follow it succeed.
"""
def test_email_error_names_email_home_address(self):
email_cfg = SimpleNamespace(enabled=True, token="", extra={})
config = SimpleNamespace(
platforms={Platform.EMAIL: email_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "email",
"message": "hi",
}
)
)
assert "EMAIL_HOME_ADDRESS" in result["error"]
assert "EMAIL_HOME_CHANNEL" not in result["error"]
class TestResolveSlackUserTargets:
"""_resolve_slack_user_target opens user targets as DMs before sending.
Adapted from #19237's ``_send_slack`` tests: main moved Slack delivery to
the plugin's ``_standalone_send`` (#41112), so the salvaged DM-open logic
lives in a resolution helper that runs before any send path.
"""
@staticmethod
def _mock_response(data):
response = MagicMock()
response.json = AsyncMock(return_value=data)
response.__aenter__ = AsyncMock(return_value=response)
response.__aexit__ = AsyncMock(return_value=None)
return response
@staticmethod
def _mock_session(*responses):
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
session.post = MagicMock(side_effect=responses)
return session
def test_conversation_ids_pass_through_without_api_calls(self):
for cid in ("C0B0QV5434G", "G123ABCDEF", "D123ABCDEF"):
chat_id, err = asyncio.run(_resolve_slack_user_target("tok", cid))
assert chat_id == cid
assert err is None
def test_conversations_open_failure_surfaces_error(self):
session = self._mock_session(
self._mock_response({"ok": False, "error": "missing_scope"}),
)
with patch("aiohttp.ClientSession", return_value=session):
chat_id, err = asyncio.run(
_resolve_slack_user_target("tok", "user:U123ABCDEF")
)
assert chat_id is None
assert "missing_scope" in err["error"]
assert "im:write" in err["error"]
class TestSendDiscordThreadId:
"""_send_discord uses thread_id when provided."""
@staticmethod
def _build_mock(response_status, response_data=None, response_text="error body"):
"""Build a properly-structured aiohttp mock chain.
session.post() returns a context manager yielding mock_resp.
"""
mock_resp = MagicMock()
mock_resp.status = response_status
mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"})
mock_resp.text = AsyncMock(return_value=response_text)
# mock_resp as async context manager (for "async with session.post(...) as resp")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=mock_resp)
return mock_session, mock_resp
def _run(self, token, chat_id, message, thread_id=None):
return asyncio.run(_send_discord(token, chat_id, message, thread_id=thread_id))
def test_with_thread_id_uses_thread_endpoint(self):
"""When thread_id is provided, sends to /channels/{thread_id}/messages."""
mock_session, _ = self._build_mock(200)
with patch("aiohttp.ClientSession", return_value=mock_session):
self._run("tok", "999888777", "hello from thread", thread_id="555444333")
call_url = mock_session.post.call_args.args[0]
assert call_url == "https://discord.com/api/v10/channels/555444333/messages"
def test_success_response_json_read_is_bounded(self):
"""Standalone Discord sends parse success JSON through the bounded reader."""
body = b'{"id":"bounded-json"}'
response = _StreamingAiohttpResponse(200, body)
session = _StreamingAiohttpSession(response)
with patch("aiohttp.ClientSession", return_value=session):
result = self._run("tok", "111", "hi", thread_id="999")
assert result["success"] is True
assert result["message_id"] == "bounded-json"
assert response.content.read_sizes[0] == _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES + 1
response.json.assert_not_awaited()
response.text.assert_not_awaited()
class TestSendToPlatformDiscordThread:
"""_send_to_platform passes thread_id through to _send_discord."""
def test_discord_thread_id_passed_to_send_discord(self):
"""Discord platform with thread_id passes it to _send_discord."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
SimpleNamespace(enabled=True, token="tok", extra={}),
"-1001234567890",
"hello thread",
thread_id="17585",
)
)
assert result["success"] is True
send_mock.assert_awaited_once()
_, call_kwargs = send_mock.await_args
assert call_kwargs["thread_id"] == "17585"
# ---------------------------------------------------------------------------
# Discord media attachment support
# ---------------------------------------------------------------------------
class TestSendDiscordMedia:
"""_send_discord uploads media files via multipart/form-data."""
@staticmethod
def _build_mock(response_status, response_data=None, response_text="error body"):
"""Build a properly-structured aiohttp mock chain."""
mock_resp = MagicMock()
mock_resp.status = response_status
mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"})
mock_resp.text = AsyncMock(return_value=response_text)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=mock_resp)
return mock_session, mock_resp
def test_text_and_media_sends_both(self, tmp_path):
"""Text message is sent first, then each media file as multipart."""
img = tmp_path / "photo.png"
img.write_bytes(b"\x89PNG fake image data")
mock_session, _ = self._build_mock(200, {"id": "msg999"})
with patch("aiohttp.ClientSession", return_value=mock_session):
result = asyncio.run(
_send_discord("tok", "111", "hello", media_files=[(str(img), False)])
)
assert result["success"] is True
assert result["message_id"] == "msg999"
# Two POSTs: one text JSON, one multipart upload
assert mock_session.post.call_count == 2
def test_no_text_no_media_returns_error(self):
"""Empty text with no media returns error dict."""
mock_session, _ = self._build_mock(200)
with patch("aiohttp.ClientSession", return_value=mock_session):
result = asyncio.run(
_send_discord("tok", "555", "", media_files=[])
)
# Text is empty but media_files is empty, so text POST fires
# (the "skip text if media present" condition isn't met)
assert result["success"] is True
class TestSendToPlatformDiscordMedia:
"""_send_to_platform routes Discord media correctly."""
def test_media_files_passed_on_last_chunk_only(self):
"""Discord media_files are only passed on the final chunk."""
call_log = []
async def mock_send_discord(token, chat_id, message, thread_id=None, media_files=None):
call_log.append({"message": message, "media_files": media_files or []})
return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": "1"}
# A message long enough to get chunked (Discord limit is 2000)
long_msg = "A" * 1900 + " " + "B" * 1900
with _patch_discord_sender(AsyncMock(side_effect=mock_send_discord)):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
SimpleNamespace(enabled=True, token="tok", extra={}),
"999",
long_msg,
media_files=[("/fake/img.png", False)],
)
)
assert result["success"] is True
assert len(call_log) == 2 # Message was chunked
assert call_log[0]["media_files"] == [] # First chunk: no media
assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached
class TestSendMatrixUrlEncoding:
"""The matrix plugin's _standalone_send URL-encodes Matrix room IDs in the
API path (was tools.send_message_tool._send_matrix before #41112)."""
def test_room_id_is_percent_encoded_in_url(self):
"""Matrix room IDs with ! and : are percent-encoded in the PUT URL."""
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"event_id": "$evt123"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.put = MagicMock(return_value=mock_resp)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
with patch("aiohttp.ClientSession", return_value=mock_session):
from plugins.platforms.matrix.adapter import _standalone_send
result = asyncio.get_event_loop().run_until_complete(
_standalone_send(
SimpleNamespace(token="test_token", extra={"homeserver": "https://matrix.example.org"}),
"!HLOQwxYGgFPMPJUSNR:matrix.org",
"hello",
)
)
assert result["success"] is True
# Verify the URL was called with percent-encoded room ID
put_url = mock_session.put.call_args[0][0]
assert "%21HLOQwxYGgFPMPJUSNR%3Amatrix.org" in put_url
assert "!HLOQwxYGgFPMPJUSNR:matrix.org" not in put_url
# ---------------------------------------------------------------------------
# Tests for _derive_forum_thread_name
# ---------------------------------------------------------------------------
class TestDeriveForumThreadName:
def test_first_line_heading_and_fallback_handling(self):
cases = [
("Hello world", "Hello world"),
("First line\nSecond line", "First line"),
(" Title \nBody", "Title"),
("## My Heading", "My Heading"),
("### Deep heading", "Deep heading"),
("", "New Post"),
(" \n ", "New Post"),
("###", "New Post"),
]
for message, expected in cases:
assert _derive_forum_thread_name(message) == expected, repr(message)
# Titles are capped at Discord's 100-char thread-name limit.
assert len(_derive_forum_thread_name("A" * 200)) == 100
# ---------------------------------------------------------------------------
# Tests for _send_discord with forum channel support
# ---------------------------------------------------------------------------
class TestSendDiscordForum:
"""_send_discord creates thread posts for forum channels."""
@staticmethod
def _build_mock(response_status, response_data=None, response_text="error body"):
mock_resp = MagicMock()
mock_resp.status = response_status
mock_resp.json = AsyncMock(return_value=response_data or {})
mock_resp.text = AsyncMock(return_value=response_text)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.post = MagicMock(return_value=mock_resp)
mock_session.get = MagicMock(return_value=mock_resp)
return mock_session, mock_resp
def test_directory_forum_creates_thread(self):
"""Directory says 'forum' — creates a thread post."""
thread_data = {
"id": "t123",
"message": {"id": "m456"},
}
mock_session, _ = self._build_mock(200, response_data=thread_data)
with patch("aiohttp.ClientSession", return_value=mock_session), \
patch("gateway.channel_directory.lookup_channel_type", return_value="forum"):
result = asyncio.run(
_send_discord("tok", "forum_ch", "Hello forum")
)
assert result["success"] is True
assert result["thread_id"] == "t123"
assert result["message_id"] == "m456"
# Should POST to threads endpoint, not messages
call_url = mock_session.post.call_args.args[0]
assert "/threads" in call_url
assert "/messages" not in call_url
def test_forum_thread_creation_error(self):
"""Forum thread creation returning non-200/201 returns an error dict."""
mock_session, _ = self._build_mock(403, response_text="Forbidden")
with patch("aiohttp.ClientSession", return_value=mock_session), \
patch("gateway.channel_directory.lookup_channel_type", return_value="forum"):
result = asyncio.run(
_send_discord("tok", "forum_ch", "Hello")
)
assert "error" in result
assert "403" in result["error"]
class TestSendToPlatformDiscordForum:
"""_send_to_platform delegates forum detection to _send_discord."""
def test_send_to_platform_discord_delegates_to_send_discord(self):
"""Discord messages are routed through _send_discord, which handles forum detection."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
SimpleNamespace(enabled=True, token="tok", extra={}),
"forum_ch",
"Hello forum",
)
)
assert result["success"] is True
send_mock.assert_awaited_once_with(
"tok", "forum_ch", "Hello forum", media_files=[], thread_id=None,
)
# ---------------------------------------------------------------------------
# Tests for _send_discord forum + media multipart upload
# ---------------------------------------------------------------------------
class TestSendDiscordForumMedia:
"""_send_discord uploads media as part of the starter message when the target is a forum."""
@staticmethod
def _build_thread_resp(thread_id="th_999", msg_id="msg_500"):
resp = MagicMock()
resp.status = 201
resp.json = AsyncMock(return_value={"id": thread_id, "message": {"id": msg_id}})
resp.text = AsyncMock(return_value="")
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=None)
return resp
def test_forum_with_media_uses_multipart(self, tmp_path, monkeypatch):
"""Forum + media → single multipart POST to /threads carrying the starter + files."""
from tools import send_message_tool as smt
img = tmp_path / "photo.png"
img.write_bytes(b"\x89PNGbytes")
monkeypatch.setattr(smt, "lookup_channel_type", lambda p, cid: "forum", raising=False)
monkeypatch.setattr(
"gateway.channel_directory.lookup_channel_type", lambda p, cid: "forum"
)
thread_resp = self._build_thread_resp()
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
session.post = MagicMock(return_value=thread_resp)
post_calls = []
orig_post = session.post
def track_post(url, **kwargs):
post_calls.append({"url": url, "kwargs": kwargs})
return thread_resp
session.post = MagicMock(side_effect=track_post)
with patch("aiohttp.ClientSession", return_value=session):
result = asyncio.run(
_send_discord("tok", "forum_ch", "Thread title\nbody", media_files=[(str(img), False)])
)
assert result["success"] is True
assert result["thread_id"] == "th_999"
assert result["message_id"] == "msg_500"
# Exactly one POST — the combined thread-creation + attachments call
assert len(post_calls) == 1
assert post_calls[0]["url"].endswith("/threads")
# Multipart form, not JSON
assert post_calls[0]["kwargs"].get("data") is not None
assert post_calls[0]["kwargs"].get("json") is None
def test_forum_missing_media_file_collected_as_warning(self, tmp_path, monkeypatch):
"""Missing media files produce warnings but the thread is still created."""
monkeypatch.setattr(
"gateway.channel_directory.lookup_channel_type", lambda p, cid: "forum"
)
thread_resp = self._build_thread_resp()
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
session.post = MagicMock(return_value=thread_resp)
with patch("aiohttp.ClientSession", return_value=session):
result = asyncio.run(
_send_discord(
"tok", "forum_ch", "hi",
media_files=[("/nonexistent/does-not-exist.png", False)],
)
)
assert result["success"] is True
assert "warnings" in result
assert any("not found" in w for w in result["warnings"])
# ---------------------------------------------------------------------------
# Tests for the process-local forum-probe cache
# ---------------------------------------------------------------------------
class TestForumProbeCache:
"""_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results."""
def setup_method(self):
from plugins.platforms.discord import adapter as discord_adapter
discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
def test_probe_result_is_memoized(self, monkeypatch):
"""An API-probed channel type is cached so subsequent sends skip the probe."""
monkeypatch.setattr(
"gateway.channel_directory.lookup_channel_type", lambda p, cid: None
)
# First probe response: type=15 (forum)
probe_resp = MagicMock()
probe_resp.status = 200
probe_resp.json = AsyncMock(return_value={"type": 15})
probe_resp.__aenter__ = AsyncMock(return_value=probe_resp)
probe_resp.__aexit__ = AsyncMock(return_value=None)
thread_resp = MagicMock()
thread_resp.status = 201
thread_resp.json = AsyncMock(return_value={"id": "t1", "message": {"id": "m1"}})
thread_resp.__aenter__ = AsyncMock(return_value=thread_resp)
thread_resp.__aexit__ = AsyncMock(return_value=None)
probe_session = MagicMock()
probe_session.__aenter__ = AsyncMock(return_value=probe_session)
probe_session.__aexit__ = AsyncMock(return_value=None)
probe_session.get = MagicMock(return_value=probe_resp)
thread_session = MagicMock()
thread_session.__aenter__ = AsyncMock(return_value=thread_session)
thread_session.__aexit__ = AsyncMock(return_value=None)
thread_session.post = MagicMock(return_value=thread_resp)
# Two _send_discord calls: first does probe + thread-create; second should skip probe
from plugins.platforms.discord import adapter as discord_adapter
sessions_created = []
def session_factory(**kwargs):
# Alternate: each new ClientSession() call returns a probe_session, thread_session pair
idx = len(sessions_created)
sessions_created.append(idx)
# Returns the same mocks; the real code opens a probe session then a thread session.
# Hand out probe_session if this is the first time called within _send_discord,
# otherwise thread_session.
if idx % 2 == 0:
return probe_session
return thread_session
with patch("aiohttp.ClientSession", side_effect=session_factory):
result1 = asyncio.run(_send_discord("tok", "ch1", "first"))
assert result1["success"] is True
assert discord_adapter._probe_is_forum_cached("ch1") is True
# Second call: cache hits, no new probe session needed. We need to only
# return thread_session now since probe is skipped.
sessions_created.clear()
with patch("aiohttp.ClientSession", return_value=thread_session):
result2 = asyncio.run(_send_discord("tok", "ch1", "second"))
assert result2["success"] is True
# Only one session opened (thread creation) — no probe session this time
# (verified by not raising from our side_effect exhaustion)
# ---------------------------------------------------------------------------
# _send_signal — chunking + 429 retry (mirrors gateway adapter behavior)
# ---------------------------------------------------------------------------
class _FakeSignalHttp:
"""Stand-in for httpx.AsyncClient used as an async context manager.
Pops a response from the queue per `post` call. Each entry is either
a dict (returned from .json()) or an exception instance (raised).
Captures (url, payload) per call.
"""
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def __call__(self, *_a, **_kw):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_a):
return False
async def post(self, url, json=None):
self.calls.append({"url": url, "payload": json})
if not self.responses:
raise AssertionError("Unexpected extra POST")
item = self.responses.pop(0)
if isinstance(item, BaseException):
raise item
resp = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda data=item: data,
)
return resp
def _install_signal_http(monkeypatch, fake):
"""Patch httpx.AsyncClient at the module level so the lazy import in
_send_signal picks it up.
"""
import httpx
monkeypatch.setattr(httpx, "AsyncClient", fake)
def _patch_sendmsg_sleep_and_time(monkeypatch, capture: list):
"""Mock asyncio.sleep + time.monotonic in the signal_rate_limit
module so the scheduler's acquire loop sees synthetic time advancing
during sleep calls, and report_rpc_duration sees the same clock.
Zero-second sleeps (event-loop yields from fake HTTP posts) are
delegated to the real asyncio.sleep so they don't pollute the
capture list.
"""
import asyncio as _aio
_real_sleep = _aio.sleep
offset = [0.0]
async def fake_sleep(seconds):
if seconds > 0:
capture.append(seconds)
offset[0] += seconds
else:
await _real_sleep(0)
monkeypatch.setattr(
"gateway.platforms.signal_rate_limit.asyncio.sleep", fake_sleep
)
monkeypatch.setattr(
"gateway.platforms.signal_rate_limit.time.monotonic", lambda: offset[0]
)
class TestSendSignalChunking:
def test_text_only_single_rpc(self, monkeypatch):
fake = _FakeSignalHttp([{"result": {"timestamp": 1}}])
_install_signal_http(monkeypatch, fake)
result = asyncio.run(
_send_signal(
{"http_url": "http://localhost:8080", "account": "+15551234567"},
"+15557654321",
"hello",
)
)
assert result["success"] is True
assert result["platform"] == "signal"
assert result["chat_id"].endswith("4321")
assert len(fake.calls) == 1
params = fake.calls[0]["payload"]["params"]
assert params["message"] == "hello"
assert "attachments" not in params
assert "textStyle" not in params
assert "textStyles" not in params
def test_skipped_missing_files_reported_in_warnings(self, tmp_path, monkeypatch):
good = tmp_path / "ok.png"
good.write_bytes(b"\x89PNG" + b"\x00" * 16)
fake = _FakeSignalHttp([{"result": {"timestamp": 1}}])
_install_signal_http(monkeypatch, fake)
result = asyncio.run(
_send_signal(
{"http_url": "http://localhost:8080", "account": "+15551234567"},
"+15557654321",
"msg",
media_files=[(str(good), False), (str(tmp_path / "missing.png"), False)],
)
)
assert result["success"] is True
assert "warnings" in result
# Only the existing file made it into the RPC
params = fake.calls[0]["payload"]["params"]
assert len(params["attachments"]) == 1
# ── _send_via_adapter standalone fallback ────────────────────────────────
class _FakePlatform:
"""Stand-in for the gateway.config.Platform enum. Holds the .value
attribute consulted by ``_send_via_adapter`` for registry lookups."""
def __init__(self, value):
self.value = value
class TestSendViaAdapterStandaloneFallback:
"""Coverage for the out-of-process plugin-platform send path.
When the gateway runner is not in this process (e.g. ``hermes cron``
runs separately from ``hermes gateway``), ``_send_via_adapter`` should
fall through to the plugin's ``standalone_sender_fn`` registered on
its ``PlatformEntry``. Without the hook, the existing error string
is returned (with a more helpful tail).
"""
@staticmethod
def _make_entry(send_fn):
from gateway.platform_registry import PlatformEntry
return PlatformEntry(
name="fakeplatform",
label="Fake",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
standalone_sender_fn=send_fn,
)
@pytest.mark.asyncio
async def test_live_ntfy_adapter_receives_explicit_publish_topic(self, monkeypatch):
from tools.send_message_tool import _send_via_adapter
platform = Platform("ntfy")
recorded = {}
class Adapter:
async def send(self, *, chat_id, content, metadata=None):
recorded["chat_id"] = chat_id
recorded["content"] = content
recorded["metadata"] = metadata
return SimpleNamespace(success=True, message_id="ntfy-id")
runner = SimpleNamespace(adapters={platform: Adapter()})
fake_gateway_run = ModuleType("gateway.run")
fake_gateway_run._gateway_runner_ref = lambda: runner
monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run)
result = await _send_via_adapter(
platform,
SimpleNamespace(extra={"publish_topic": "configured-topic"}),
"alerts-channel",
"done",
)
assert result == {"success": True, "message_id": "ntfy-id"}
assert recorded["chat_id"] == "alerts-channel"
assert recorded["content"] == "done"
assert recorded["metadata"] == {"publish_topic": "alerts-channel"}
@pytest.mark.asyncio
async def test_standalone_sender_fn_raises_is_caught_and_formatted(self, monkeypatch):
"""Hook raises: error dict has 'Plugin standalone send failed: ...'"""
from tools.send_message_tool import _send_via_adapter
from gateway.platform_registry import platform_registry
async def boom(pconfig, chat_id, message, **kwargs):
raise ValueError("boom!")
platform_registry.register(self._make_entry(boom))
try:
monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None)
result = await _send_via_adapter(
_FakePlatform("fakeplatform"),
SimpleNamespace(extra={}),
"chat-1",
"hi",
)
finally:
platform_registry.unregister("fakeplatform")
assert result == {"error": "Plugin standalone send failed: boom!"}
class TestSendTelegramThreadNotFoundRetry:
"""Tests for thread-not-found retry behaviour in _send_telegram (#27012)."""
def test_text_send_retries_without_thread_id_on_thread_not_found(self):
"""When thread is not found, the text send should retry without
message_thread_id."""
call_args = []
async def fake_retry(bot, *, chat_id, text, parse_mode, **kwargs):
call_args.append(dict(kwargs, chat_id=chat_id, text=text))
if len(call_args) == 1:
raise Exception("Bad Request: message thread not found")
return SimpleNamespace(message_id=42)
async def run_test():
with patch(
"tools.send_message_senders._send_telegram_message_with_retry",
fake_retry,
):
# _send_telegram imports Bot locally; we only need to mock
# the send path, not Bot itself (Bot import falls through
# normally since python-telegram-bot is installed).
return await _send_telegram(
"fake-token", "-100123", "hello from topic 17585",
thread_id="17585",
)
result = asyncio.run(run_test())
assert result["success"] is True
assert result["message_id"] == "42"
assert len(call_args) == 2, f"expected 2 calls, got {len(call_args)}"
# First call should have message_thread_id
assert call_args[0].get("message_thread_id") is not None
# Second call (retry) should NOT have message_thread_id
assert "message_thread_id" not in call_args[1], \
"retry should drop message_thread_id after thread-not-found"
def test_disable_web_page_preview_not_leaked_to_media_sends(self):
"""disable_web_page_preview must never leak into a media send.
A single captionable file + short text now rides as the document's
caption (no separate text send), so the invariant to protect is that
the captioned send_document does not inherit disable_web_page_preview
(valid only for send_message).
"""
media_kwargs_seen = []
class FakeBot:
async def send_message(self, **kwargs):
return SimpleNamespace(message_id=1)
async def send_document(self, **kwargs):
media_kwargs_seen.append(kwargs)
return SimpleNamespace(message_id=2)
import tempfile
media_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
tf.write(b"%PDF-1.4 test content")
media_path = tf.name
async def run_test():
with patch("telegram.Bot", return_value=FakeBot()):
return await _send_telegram(
"fake-token", "-100123", "check preview",
media_files=[(media_path, False)],
disable_link_previews=True,
)
result = asyncio.run(run_test())
assert result["success"] is True
# Caption rides the document bubble.
assert media_kwargs_seen[0].get("caption") == "check preview"
# Media send must NOT carry disable_web_page_preview.
assert "disable_web_page_preview" not in media_kwargs_seen[0], \
"disable_web_page_preview leaked into send_document kwargs"
finally:
if media_path and os.path.exists(media_path):
os.unlink(media_path)