* 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>
2221 lines
87 KiB
Python
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 <available></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&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&T <tag> test",
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
sent_text = send.await_args.args[2]
|
|
assert "&amp;" not in sent_text
|
|
assert "&lt;" not in sent_text
|
|
assert "AT&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)
|