From 35e0f56fbf1b1f9331b675fd1e83fa58f3f193c7 Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Fri, 26 Jun 2026 21:34:51 +0800 Subject: [PATCH] fix(matrix): make outbound message length configurable (#53026) Raise the Matrix adapter default chunk size from 4,000 to 16,000 characters and allow overrides via config.yaml or MATRIX_MAX_MESSAGE_LENGTH. Fixes #53026 --- plugins/platforms/matrix/adapter.py | 58 +++++++++++--- tests/gateway/test_matrix_message_length.py | 85 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 tests/gateway/test_matrix_message_length.py diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3835d8e31efa..f86a45366f7c 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -41,6 +41,7 @@ Environment variables: MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false) + MATRIX_MAX_MESSAGE_LENGTH Outbound message chunk size in characters (default: 16000) MATRIX_APPROVAL_REQUIRE_SENDER Require reaction controls to come from the original requester when requester metadata is available (default: true) @@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt: bot_reaction_events: dict[str, str] = field(default_factory=dict) -# Matrix message size limit (4000 chars practical, spec has no hard limit -# but clients render poorly above this). -MAX_MESSAGE_LENGTH = 4000 +# Matrix message size limit. The spec allows large events (~65 KB), but very +# large bodies can render poorly in some clients. The previous 4,000-char +# default was overly conservative and split Markdown tables mid-row (#53026). +DEFAULT_MAX_MESSAGE_LENGTH = 16000 +MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535 + + +def _resolve_max_message_length(config) -> int: + """Resolve outbound chunk size from config, env, or plugin registry.""" + extra = getattr(config, "extra", {}) or {} + raw = extra.get("max_message_length") + if raw is None: + raw = os.getenv("MATRIX_MAX_MESSAGE_LENGTH") + if raw is None: + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get("matrix") + if entry and entry.max_message_length: + raw = entry.max_message_length + except Exception: + pass + if raw is None: + return DEFAULT_MAX_MESSAGE_LENGTH + try: + value = int(raw) + except (TypeError, ValueError): + return DEFAULT_MAX_MESSAGE_LENGTH + return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING)) + + +# Back-compat alias for callers/tests that import the module constant. +MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH # Store directory for E2EE keys and sync state. # Uses get_hermes_home() so each profile gets its own Matrix store. @@ -799,21 +830,22 @@ class MatrixAdapter(BasePlatformAdapter): """Gateway adapter for Matrix (any homeserver).""" supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown) - splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) + splits_long_messages = True # send() chunks via truncate_message(max_message_length) # Matrix clients commonly reserve typed "/" for client-local commands; # the adapter accepts "!command" as the alias that always reaches Hermes # (see _normalize_matrix_bang_command), so instruction text shows "!". typed_command_prefix = "!" - # Threshold for detecting Matrix client-side message splits. - # When a chunk is near the ~4000-char practical limit, a continuation - # is almost certain. - _SPLIT_THRESHOLD = 3900 - def __init__(self, config: PlatformConfig): super().__init__(config, Platform.MATRIX) + self.max_message_length = _resolve_max_message_length(config) + # Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH. + self.MAX_MESSAGE_LENGTH = self.max_message_length + # When a chunk is near the outbound limit, a continuation is almost certain. + self._split_threshold = max(100, self.max_message_length - 100) + self._homeserver: str = ( config.extra.get("homeserver", "") or os.getenv("MATRIX_HOMESERVER", "") ).rstrip("/") @@ -1612,7 +1644,7 @@ class MatrixAdapter(BasePlatformAdapter): return SendResult(success=True) formatted = self.format_message(content) - chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) + chunks = self.truncate_message(formatted, self.max_message_length) last_event_id = None for i, chunk in enumerate(chunks): @@ -3607,7 +3639,7 @@ class MatrixAdapter(BasePlatformAdapter): try: pending = self._pending_text_batches.get(key) last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 - if last_len >= self._SPLIT_THRESHOLD: + if last_len >= self._split_threshold: delay = self._text_batch_split_delay_seconds else: delay = self._text_batch_delay_seconds @@ -4690,6 +4722,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None: os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower() + if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"): + os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"]) return None @@ -4735,7 +4769,7 @@ def register(ctx) -> None: allow_all_env="MATRIX_ALLOW_ALL_USERS", cron_deliver_env_var="MATRIX_HOME_ROOM", standalone_sender_fn=_standalone_send, - max_message_length=4000, + max_message_length=DEFAULT_MAX_MESSAGE_LENGTH, emoji="🔐", allow_update_command=True, ) diff --git a/tests/gateway/test_matrix_message_length.py b/tests/gateway/test_matrix_message_length.py new file mode 100644 index 000000000000..095993b46bd0 --- /dev/null +++ b/tests/gateway/test_matrix_message_length.py @@ -0,0 +1,85 @@ +"""Tests for Matrix outbound message length configuration (#53026).""" +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig + + +def _make_adapter(**extra): + from plugins.platforms.matrix.adapter import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + **extra, + }, + ) + return MatrixAdapter(config) + + +class TestMatrixMaxMessageLength: + def test_default_limit_is_16000(self): + adapter = _make_adapter() + assert adapter.max_message_length == 16000 + assert adapter._split_threshold == 15900 + + def test_extra_override(self): + adapter = _make_adapter(max_message_length=12000) + assert adapter.max_message_length == 12000 + assert adapter._split_threshold == 11900 + + def test_env_override(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000") + adapter = _make_adapter() + assert adapter.max_message_length == 20000 + + def test_extra_beats_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000") + adapter = _make_adapter(max_message_length=10000) + assert adapter.max_message_length == 10000 + + def test_invalid_values_fall_back_to_default(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "not-a-number") + adapter = _make_adapter() + assert adapter.max_message_length == 16000 + + def test_values_are_clamped(self): + adapter = _make_adapter(max_message_length=100) + assert adapter.max_message_length == 500 + adapter = _make_adapter(max_message_length=999999) + assert adapter.max_message_length == 65535 + + def test_apply_yaml_config_sets_env(self, monkeypatch): + from plugins.platforms.matrix.adapter import _apply_yaml_config + + monkeypatch.delenv("MATRIX_MAX_MESSAGE_LENGTH", raising=False) + _apply_yaml_config({}, {"max_message_length": 12000}) + assert os.getenv("MATRIX_MAX_MESSAGE_LENGTH") == "12000" + + def test_register_uses_default_limit(self): + from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register + + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs["max_message_length"] == DEFAULT_MAX_MESSAGE_LENGTH + + def test_send_uses_configured_limit(self): + adapter = _make_adapter(max_message_length=5000) + adapter._client = MagicMock() + adapter._client.send_message_event = AsyncMock(return_value="evt") + long_text = "x" * 12000 + + async def _run(): + with patch.object(adapter, "truncate_message", wraps=adapter.truncate_message) as trunc: + await adapter.send("!room:example.org", long_text) + trunc.assert_called_once() + assert trunc.call_args[0][1] == 5000 + + asyncio.run(_run())