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
This commit is contained in:
nankingjing 2026-06-26 21:34:51 +08:00 committed by Teknium
parent 08626c18be
commit 35e0f56fbf
2 changed files with 131 additions and 12 deletions

View file

@ -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,
)

View file

@ -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())