Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11,707 changes: 3,488 additions & 8,219 deletions plugins/platforms/telegram/adapter.py

Large diffs are not rendered by default.

722 changes: 722 additions & 0 deletions plugins/platforms/telegram/telegram_dm_topics.py

Large diffs are not rendered by default.

1,359 changes: 1,359 additions & 0 deletions plugins/platforms/telegram/telegram_inbound.py

Large diffs are not rendered by default.

1,043 changes: 1,043 additions & 0 deletions plugins/platforms/telegram/telegram_messaging.py

Large diffs are not rendered by default.

1,310 changes: 1,310 additions & 0 deletions plugins/platforms/telegram/telegram_polling.py

Large diffs are not rendered by default.

635 changes: 635 additions & 0 deletions plugins/platforms/telegram/telegram_rich.py

Large diffs are not rendered by default.

82 changes: 79 additions & 3 deletions tests/gateway/test_dm_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
sys.modules.pop("plugins.platforms.telegram.adapter", None)

from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.telegram_dm_topics import TelegramDmTopicMixin # noqa: E402
from gateway.platforms.base import BasePlatformAdapter # noqa: E402


def _make_adapter(dm_topics_config=None, group_topics_config=None):
Expand Down Expand Up @@ -192,7 +194,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path):

config_file = tmp_path / ".hermes" / "config.yaml"
config_file.parent.mkdir(parents=True)
with open(config_file, "w") as f:
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f)

adapter = _make_adapter()
Expand All @@ -201,7 +203,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path):
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
adapter._persist_dm_topic_thread_id(111, "General", 999)

with open(config_file) as f:
with open(config_file, encoding="utf-8") as f:
result = yaml.safe_load(f)

topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
Expand Down Expand Up @@ -301,7 +303,7 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):
}
config_file = tmp_path / ".hermes" / "config.yaml"
config_file.parent.mkdir(parents=True)
with open(config_file, "w") as f:
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f)

with patch.object(Path, "home", return_value=tmp_path), \
Expand Down Expand Up @@ -478,3 +480,77 @@ def test_group_topic_skill_binding_second_topic():
# ── _build_message_event: from_user=None fallback in DMs ──


# ── TelegramDmTopicMixin seam identity (adapter god-file slice A1) ──


# Every DM-topic method extracted into TelegramDmTopicMixin. If a future
# slice moves these names again, update the list alongside the extraction.
_DM_TOPIC_MIXIN_METHODS = [
"_metadata_thread_id",
"_metadata_direct_messages_topic_id",
"_metadata_reply_to_message_id",
"_is_private_dm_topic_send",
"_dm_topic_missing_anchor_error",
"_reply_to_message_id_for_send",
"_thread_kwargs_for_send",
"_message_thread_id_for_send",
"_message_thread_id_for_typing",
"_is_thread_not_found_error",
"_prune_stale_dm_topic_binding",
"_is_bad_request_error",
"_should_retry_without_dm_topic_reply_anchor",
"_send_with_dm_topic_reply_anchor_retry",
"_create_dm_topic",
"create_handoff_thread",
"ensure_dm_topic",
"rename_dm_topic",
"_persist_dm_topic_thread_id",
"_setup_dm_topics",
"_reload_dm_topics_from_config",
"_get_dm_topic_info",
"_cache_dm_topic_from_message",
]


def _underlying(cls, name):
"""Resolve the underlying function object for a class attribute.

``getattr(Class, name)`` on a classmethod yields a fresh bound-method
wrapper per class, so identity must be compared on ``__func__``.
"""
attr = getattr(cls, name)
return getattr(attr, "__func__", attr)


def test_dm_topic_mixin_seam_identity():
"""The DM-topic slice must not change any function object.

``TelegramAdapter`` inherits ``TelegramDmTopicMixin``; every extracted
name must resolve through the adapter to the exact same function object
the mixin defines (classmethods unwrapped via ``__func__``). This pins
name resolution for tests and monkeypatches that target the adapter
namespace, and keeps the mixin ahead of ``BasePlatformAdapter`` in the
MRO so overrides like ``create_handoff_thread`` keep winning.
"""
assert TelegramDmTopicMixin in TelegramAdapter.__mro__
assert TelegramAdapter.__mro__.index(TelegramDmTopicMixin) < TelegramAdapter.__mro__.index(
BasePlatformAdapter
)

for name in _DM_TOPIC_MIXIN_METHODS:
mixin_attr = _underlying(TelegramDmTopicMixin, name)
adapter_attr = _underlying(TelegramAdapter, name)
assert adapter_attr is mixin_attr, f"seam broken for {name}: adapter resolves a different object"
assert name not in TelegramAdapter.__dict__, f"adapter shadows {name} in its own __dict__"

# Behavior through the adapter namespace still resolves and executes.
assert TelegramAdapter._message_thread_id_for_send("1") is None
assert TelegramAdapter._message_thread_id_for_send("7") == 7
assert TelegramAdapter._thread_kwargs_for_send(
"111", "7", {"telegram_dm_topic_reply_fallback": True}, reply_to_message_id=42
) == {"message_thread_id": 7}
assert TelegramAdapter._dm_topic_missing_anchor_error().startswith(
"Telegram DM topic delivery requires a reply anchor"
)


55 changes: 36 additions & 19 deletions tests/gateway/test_telegram_webhook_secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,55 @@ class TestTelegramWebhookSecretRequired:
"""

def _get_source(self) -> str:
path = Path(_repo) / "plugins" / "platforms" / "telegram" / "adapter.py"
return path.read_text(encoding="utf-8")
"""Return adapter + polling-mixin sources concatenated.

The webhook-start block (and its secret guard) moved into
``telegram_polling.py`` as ``_start_webhook`` during the adapter
god-file slice; scanning both files keeps this pin valid across
either layout.
"""
repo = Path(_repo)
adapter = (repo / "plugins" / "platforms" / "telegram" / "adapter.py").read_text(encoding="utf-8")
polling = (repo / "plugins" / "platforms" / "telegram" / "telegram_polling.py").read_text(encoding="utf-8")
return adapter + "\n" + polling

def test_webhook_branch_checks_secret(self):
"""The webhook-mode branch of connect() must read
TELEGRAM_WEBHOOK_SECRET and refuse when empty."""
"""The webhook branch must read TELEGRAM_WEBHOOK_SECRET and refuse
when empty (GHSA-3vpc-7q5r-276h)."""
src = self._get_source()
# The guard must appear after TELEGRAM_WEBHOOK_URL is set
assert re.search(
r'TELEGRAM_WEBHOOK_SECRET.*?\.strip\(\)\s*\n\s*if not webhook_secret:',
src, re.DOTALL,
), (
"TelegramAdapter.connect() must strip TELEGRAM_WEBHOOK_SECRET "
"and raise when the secret is empty — see GHSA-3vpc-7q5r-276h"
"The webhook transport (_start_webhook) must strip "
"TELEGRAM_WEBHOOK_SECRET and raise when the secret is empty — "
"see GHSA-3vpc-7q5r-276h"
)


def test_polling_branch_has_no_secret_guard(self):
"""Polling mode (else-branch) must NOT require the webhook secret —
polling authenticates via the bot token, not a webhook secret."""
"""Polling mode must NOT require the webhook secret — polling
authenticates via the bot token, not a webhook secret."""
src = self._get_source()
# The guard should appear inside the `if webhook_url:` branch,
# not the `else:` polling branch. Rough check: the raise is
# followed (within ~60 lines) by an `else:` that starts the
# polling branch, and there's no secret-check in that polling
# branch.
# The guard must live inside the webhook-start block
# (_start_webhook's `if webhook_url:` branch), not in the polling
# branch that connect() falls into when webhook mode is off.
webhook_block = re.search(
r'if webhook_url:\s*\n(.*?)\n else:\s*\n(.*?)\n',
r'if webhook_url:\s*\n(.*?)\n\s*return bool\(webhook_url\)',
src, re.DOTALL,
)
assert webhook_block, (
"telegram_polling.py _start_webhook() must gate webhook startup "
"on TELEGRAM_WEBHOOK_URL (see GHSA-3vpc-7q5r-276h)"
)
webhook_body = webhook_block.group(1)
assert "TELEGRAM_WEBHOOK_SECRET" in webhook_body
# The polling branch in connect() (after the _start_webhook dispatch)
# must not contain the secret guard.
polling_branch = re.search(
r'if not webhook_started:\s*\n(.*?)\n\s*self\._mark_connected\(\)',
src, re.DOTALL,
)
if webhook_block:
webhook_body = webhook_block.group(1)
polling_body = webhook_block.group(2)
assert "TELEGRAM_WEBHOOK_SECRET" in webhook_body
assert "TELEGRAM_WEBHOOK_SECRET" not in polling_body
if polling_branch:
assert "TELEGRAM_WEBHOOK_SECRET" not in polling_branch.group(1)
Loading