From bf4da5d770806e03cde3b0a11bc7361cc0a8b55c Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 6 May 2026 00:51:15 -0500 Subject: [PATCH] Update vendored LXMFy --- vendor/README.txt | 2 +- vendor/lxmfy/CHANGELOG.md | 12 + vendor/lxmfy/docs/source/api-reference.rst | 30 ++- vendor/lxmfy/docs/source/creating-bots.rst | 62 +++++ vendor/lxmfy/lxmfy/__init__.py | 5 + vendor/lxmfy/lxmfy/config.py | 1 + vendor/lxmfy/lxmfy/core.py | 153 +++++++---- vendor/lxmfy/lxmfy/lxmf_fields.py | 53 ++++ vendor/lxmfy/tests/test_lxmf_commands.py | 296 +++++++++++++++++++++ 9 files changed, 556 insertions(+), 58 deletions(-) create mode 100644 vendor/lxmfy/lxmfy/lxmf_fields.py create mode 100644 vendor/lxmfy/tests/test_lxmf_commands.py diff --git a/vendor/README.txt b/vendor/README.txt index 9cd9a3d..db42ad8 100644 --- a/vendor/README.txt +++ b/vendor/README.txt @@ -2,7 +2,7 @@ Vendored third-party trees shipped inside the reticulum-meshchatx distribution. lxmfy/ Upstream: https://git.quad4.io/LXMFy/LXMFy - Bundled revision: a44b08f005bb32bda80bbd9f3c7b5c2baf57f580 + Bundled revision: 0a6ba8c9fd0f306be614d0edce44e4e805c025b0 Declared version (pyproject): see vendor/lxmfy/pyproject.toml Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed. diff --git a/vendor/lxmfy/CHANGELOG.md b/vendor/lxmfy/CHANGELOG.md index a8f1555..0a0864b 100644 --- a/vendor/lxmfy/CHANGELOG.md +++ b/vendor/lxmfy/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.6.3] - 2026-05-06 + +### Features +- **LXMF FIELD_COMMANDS / FIELD_RESULTS support**: Bots can now receive structured commands and requests sent via LXMF message fields (`FIELD_COMMANDS = 0x09`) and automatically reply with results (`FIELD_RESULTS = 0x0A`). + - `unpack_commands(fields)` helper parses `FIELD_COMMANDS` whether sent as a single dict or a list of dicts. + - `pack_result(result, request_id, status)` helper builds `FIELD_RESULTS` reply payloads. + - Incoming field commands are routed through the existing command registry, sharing the same permission checks, type-hinted argument parsing, threading, and middleware hooks as text commands. + - `ctx.reply("...")` automatically includes `FIELD_RESULTS` in the outgoing LXMF message when responding to a field command. If the incoming command contained a `request_id`, it is preserved in the result for request/response correlation. + - The `msg` context object passed to command callbacks now exposes `.fields` (raw LXMF fields dict) and `.request_id`. + - New `BotConfig` option: `lxmf_commands_enabled` (default `True`). Set to `False` to ignore field commands and fall back to text-only processing. + - New exports: `FIELD_COMMANDS`, `FIELD_RESULTS`, `pack_result`, `unpack_commands` from `lxmfy` package. + ## [1.6.2] - 2026-04-15 ### Features diff --git a/vendor/lxmfy/docs/source/api-reference.rst b/vendor/lxmfy/docs/source/api-reference.rst index 6e44d4f..488833d 100644 --- a/vendor/lxmfy/docs/source/api-reference.rst +++ b/vendor/lxmfy/docs/source/api-reference.rst @@ -42,7 +42,8 @@ The main bot class that handles message routing, command processing, and bot lif external_cogs_timeout=30, nlp_enabled=False, nlp_threshold=0.5, - link_support_enabled=False + link_support_enabled=False, + lxmf_commands_enabled=True ) Key Methods @@ -65,6 +66,33 @@ Key Methods - :code:`on_message()`: Decorator for handling all messages (called before command processing) - :code:`validate()`: Run validation checks on the bot configuration +Structured Commands via LXMF Fields +----------------------------------- + +Bots can receive commands sent via LXMF ``FIELD_COMMANDS`` (``0x09``) and automatically reply with ``FIELD_RESULTS`` (``0x0A``). This enables structured request/response workflows alongside normal text commands. + +Incoming ``FIELD_COMMANDS`` are parsed and routed through the same command registry as text commands, sharing permission checks, type-hinted argument parsing, threading, and middleware. + +.. code-block:: python + + from lxmfy import LXMFBot, FIELD_COMMANDS, FIELD_RESULTS, pack_result, unpack_commands + + bot = LXMFBot(name="FieldBot") + + @bot.command(name="status", description="Return bot status") + def status_cmd(ctx): + # ctx.fields contains the raw LXMF fields dict + # ctx.request_id is set automatically if the command included one + ctx.reply("Bot is online") + + # Sending a structured command from another LXMF client: + # lxm.fields[FIELD_COMMANDS] = {"command": "status", "args": [], "request_id": "abc123"} + # router.handle_outbound(lxm) + + # The bot reply automatically includes FIELD_RESULTS with the response and request_id. + +To disable field command processing, set ``lxmf_commands_enabled=False`` in :code:`BotConfig`. + Storage ------- diff --git a/vendor/lxmfy/docs/source/creating-bots.rst b/vendor/lxmfy/docs/source/creating-bots.rst index 2ed137e..7ad139d 100644 --- a/vendor/lxmfy/docs/source/creating-bots.rst +++ b/vendor/lxmfy/docs/source/creating-bots.rst @@ -148,6 +148,68 @@ Then, you can define and use the icon: This :code:`self.bot_icon_field` can be pre-calculated and reused for all messages sent by the bot. +Structured Commands via LXMF Fields +----------------------------------- + +In addition to text-based commands, LXMFy supports commands sent through LXMF message fields using ``FIELD_COMMANDS`` (``0x09``). This is useful for structured request/response workflows between LXMF clients and bots. + +When a message contains ``FIELD_COMMANDS``, the bot extracts the command name and arguments, routes them through the same command registry as text commands, and automatically includes ``FIELD_RESULTS`` (``0x0A``) in the reply. + +**Receiving structured commands** + +.. code-block:: python + + from lxmfy import LXMFBot + + bot = LXMFBot(name="FieldBot") + + @bot.command(name="add", description="Add two numbers") + def add_command(ctx): + if len(ctx.args) >= 2: + try: + result = float(ctx.args[0]) + float(ctx.args[1]) + ctx.reply(str(result)) + except ValueError: + ctx.reply("Invalid numbers") + else: + ctx.reply("Usage: add ") + +The ``ctx`` object in field command callbacks includes: + +- :code:`ctx.fields` — the raw LXMF fields dict from the incoming message +- :code:`ctx.request_id` — the ``request_id`` from the incoming ``FIELD_COMMANDS`` (if any) + +**Sending a structured command from an LXMF client** + +.. code-block:: python + + import LXMF + from lxmfy import FIELD_COMMANDS + + lxm = LXMF.LXMessage( + destination, + source, + b"", # content can be empty for field-only commands + desired_method=LXMF.LXMessage.DIRECT, + ) + lxm.fields[FIELD_COMMANDS] = { + "command": "add", + "args": ["3", "5"], + "request_id": "req-42", # optional, for correlation + } + router.handle_outbound(lxm) + +**Disabling field commands** + +If you want the bot to ignore ``FIELD_COMMANDS`` and only process text commands, set: + +.. code-block:: python + + bot = LXMFBot( + name="TextOnlyBot", + lxmf_commands_enabled=False, + ) + Using Cogs (Extensions) ----------------------- diff --git a/vendor/lxmfy/lxmfy/__init__.py b/vendor/lxmfy/lxmfy/__init__.py index 56abb45..391cc5e 100644 --- a/vendor/lxmfy/lxmfy/__init__.py +++ b/vendor/lxmfy/lxmfy/__init__.py @@ -17,6 +17,7 @@ from .config import BotConfig from .core import LXMFBot, BOT_DISPLAY_NAME_FILE from .events import Event, EventManager, EventPriority from .help import HelpFormatter, HelpSystem +from .lxmf_fields import FIELD_COMMANDS, FIELD_RESULTS, pack_result, unpack_commands from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType from .permissions import DefaultPerms, PermissionManager, Role from .scheduler import ScheduledTask, TaskScheduler @@ -32,6 +33,8 @@ __all__ = [ "Event", "EventManager", "EventPriority", + "FIELD_COMMANDS", + "FIELD_RESULTS", "HelpFormatter", "HelpSystem", "IconAppearance", @@ -53,6 +56,8 @@ __all__ = [ "load_cogs_from_directory", "pack_attachment", "pack_icon_appearance_field", + "pack_result", + "unpack_commands", "validate_bot", ] diff --git a/vendor/lxmfy/lxmfy/config.py b/vendor/lxmfy/lxmfy/config.py index d378f44..9442ffd 100644 --- a/vendor/lxmfy/lxmfy/config.py +++ b/vendor/lxmfy/lxmfy/config.py @@ -95,6 +95,7 @@ class BotConfig: nlp_threshold: float = 0.5 link_support_enabled: bool = False opportunistic_sending: bool = True + lxmf_commands_enabled: bool = True def __post_init__(self): """Post-initialization to ensure admins is a set.""" diff --git a/vendor/lxmfy/lxmfy/core.py b/vendor/lxmfy/lxmfy/core.py index a36715b..3a12ac0 100644 --- a/vendor/lxmfy/lxmfy/core.py +++ b/vendor/lxmfy/lxmfy/core.py @@ -26,6 +26,7 @@ from .commands import Command from .config import BotConfig from .events import Event, EventManager, EventPriority from .help import HelpSystem +from .lxmf_fields import FIELD_RESULTS, pack_result, unpack_commands from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType from .moderation import SpamProtection from .nlp import IntentClassifier @@ -481,14 +482,89 @@ class LXMFBot: self._reset_delivery_attempts(sender) + def _execute_command(self, cmd_name: str, args: list, msg) -> bool: + """Execute a registered command by name. + + Returns: + True if the command was found and executed (or raised an error), + False if no command with that name exists. + + """ + if cmd_name not in self.commands: + return False + + cmd = self.commands[cmd_name] + + if not self.permissions.has_permission(msg.sender, cmd.permissions): + self.send(msg.sender, "You don't have permission to use this command.") + return True + + try: + sig = inspect.signature(cmd.callback) + params = list(sig.parameters.values()) + + converted_args = [] + for i, arg_val in enumerate(args): + param_idx = i + 1 + if param_idx < len(params): + param = params[param_idx] + annotation = param.annotation + if ( + annotation != inspect.Parameter.empty + and hasattr(annotation, "__call__") + and not isinstance(annotation, str) + ): + try: + converted_args.append(annotation(arg_val)) + except (ValueError, TypeError): + converted_args.append(arg_val) + else: + converted_args.append(arg_val) + else: + converted_args.append(arg_val) + + msg.args = converted_args + msg.is_admin = msg.sender in self.admins + + if cmd.threaded: + self.thread_pool.submit(cmd.callback, msg) + else: + cmd.callback(msg) + + self.middleware.execute(MiddlewareType.POST_COMMAND, msg) + return True + + except Exception as e: + self.logger.error( + "Error executing command %s: %s", + cmd_name, + str(e), + ) + self.send(msg.sender, f"Error executing command: {e}") + return True + def _process_message(self, message, sender): """Process an incoming message.""" try: - content = message.content.decode("utf-8") + content = message.content.decode("utf-8") if message.content else "" receipt = RNS.hexrep(message.hash, delimit=False) + msg_fields = getattr(message, "fields", None) or {} + + field_commands = unpack_commands(msg_fields) + request_id = None + if field_commands: + request_id = field_commands[0].get("request_id") + has_field_commands = bool(field_commands) def reply(response, **kwargs): """Helper function to reply to a message.""" + lxmf_fields = kwargs.pop("lxmf_fields", None) or {} + if has_field_commands or request_id is not None: + lxmf_fields[FIELD_RESULTS] = pack_result( + response, request_id, kwargs.pop("status", "ok") + ) + if lxmf_fields: + kwargs["lxmf_fields"] = lxmf_fields self.send(sender, response, **kwargs) if self.config.first_message_enabled: @@ -518,6 +594,8 @@ class LXMFBot: "sender": sender, "content": content, "hash": receipt, + "fields": msg_fields, + "request_id": request_id, } msg = SimpleNamespace(**msg_ctx) @@ -525,67 +603,30 @@ class LXMFBot: if self.middleware.execute(MiddlewareType.PRE_COMMAND, ctx) is None: return + # Process structured commands from LXMF fields + if getattr(self.config, "lxmf_commands_enabled", True) and field_commands: + for cmd_data in field_commands: + cmd_name = cmd_data.get("command") or cmd_data.get("cmd") + cmd_args = cmd_data.get("args", []) + if isinstance(cmd_args, str): + cmd_args = [cmd_args] + if not isinstance(cmd_args, list): + cmd_args = [] + if self._execute_command(cmd_name, cmd_args, msg): + return + else: + reply(f"Unknown command: {cmd_name}", status="error") + return + if self.command_prefix is None or content.startswith(self.command_prefix): command_name = ( content.split()[0][len(self.command_prefix) :] if self.command_prefix else content.split()[0] ) - if command_name in self.commands: - cmd = self.commands[command_name] - - if not self.permissions.has_permission(sender, cmd.permissions): - self.send( - sender, - "You don't have permission to use this command.", - ) - return - - try: - args = content.split()[1:] if len(content.split()) > 1 else [] - - sig = inspect.signature(cmd.callback) - params = list(sig.parameters.values()) - - converted_args = [] - for i, arg_val in enumerate(args): - param_idx = i + 1 - if param_idx < len(params): - param = params[param_idx] - annotation = param.annotation - if ( - annotation != inspect.Parameter.empty - and hasattr(annotation, "__call__") - and not isinstance(annotation, str) - ): - try: - converted_args.append(annotation(arg_val)) - except (ValueError, TypeError): - converted_args.append(arg_val) - else: - converted_args.append(arg_val) - else: - converted_args.append(arg_val) - - msg.args = converted_args - msg.is_admin = sender in self.admins - - if cmd.threaded: - self.thread_pool.submit(cmd.callback, msg) - else: - cmd.callback(msg) - - self.middleware.execute(MiddlewareType.POST_COMMAND, msg) - return - - except Exception as e: - self.logger.error( - "Error executing command %s: %s", - command_name, - str(e), - ) - self.send(sender, "Error executing command: %s", str(e)) - return + args = content.split()[1:] if len(content.split()) > 1 else [] + if self._execute_command(command_name, args, msg): + return # NLP Intent matching if self.config.nlp_enabled: diff --git a/vendor/lxmfy/lxmfy/lxmf_fields.py b/vendor/lxmfy/lxmfy/lxmf_fields.py new file mode 100644 index 0000000..4aefd87 --- /dev/null +++ b/vendor/lxmfy/lxmfy/lxmf_fields.py @@ -0,0 +1,53 @@ +"""LXMF field constants and helpers for structured commands and results.""" + +try: + from LXMF.LXMF import FIELD_COMMANDS, FIELD_RESULTS +except ImportError: + FIELD_COMMANDS = 0x09 + FIELD_RESULTS = 0x0A + + +def unpack_commands(fields: dict | None) -> list[dict]: + """Extract command entries from LXMF message fields. + + Supports ``FIELD_COMMANDS`` being a single dict or a list of dicts. + Each dict should contain at least a ``command`` or ``cmd`` key. + + Args: + fields: The ``fields`` dict from an :class:`LXMF.LXMessage`. + + Returns: + A list of command dicts. + + """ + if not fields: + return [] + + raw = fields.get(FIELD_COMMANDS) + if raw is None: + return [] + + if isinstance(raw, dict): + return [raw] + if isinstance(raw, list): + return [entry for entry in raw if isinstance(entry, dict)] + + return [] + + +def pack_result(result, request_id: str | None = None, status: str = "ok") -> dict: + """Pack a result into a ``FIELD_RESULTS``-compatible dict. + + Args: + result: The result payload (any serialisable value). + request_id: Optional correlation ID from the original request. + status: Status string, e.g. ``"ok"`` or ``"error"``. + + Returns: + A dict suitable for ``FIELD_RESULTS``. + + """ + data = {"result": result, "status": status} + if request_id is not None: + data["request_id"] = request_id + return data diff --git a/vendor/lxmfy/tests/test_lxmf_commands.py b/vendor/lxmfy/tests/test_lxmf_commands.py new file mode 100644 index 0000000..44d6e9d --- /dev/null +++ b/vendor/lxmfy/tests/test_lxmf_commands.py @@ -0,0 +1,296 @@ +"""Tests for LXMF FIELD_COMMANDS and FIELD_RESULTS support.""" + +from unittest.mock import Mock + +import pytest + +from lxmfy import BotConfig, LXMFBot +from lxmfy.lxmf_fields import ( + FIELD_COMMANDS, + FIELD_RESULTS, + pack_result, + unpack_commands, +) + + +class TestUnpackCommands: + """Test unpack_commands helper.""" + + def test_empty_fields(self): + assert unpack_commands(None) == [] + assert unpack_commands({}) == [] + + def test_single_dict(self): + fields = {FIELD_COMMANDS: {"command": "ping", "args": []}} + assert unpack_commands(fields) == [{"command": "ping", "args": []}] + + def test_list_of_dicts(self): + fields = {FIELD_COMMANDS: [{"command": "ping"}, {"command": "pong"}]} + result = unpack_commands(fields) + assert len(result) == 2 + assert result[0]["command"] == "ping" + assert result[1]["command"] == "pong" + + def test_ignores_non_dict_entries(self): + fields = {FIELD_COMMANDS: [{"command": "ping"}, "bad_entry", 123]} + result = unpack_commands(fields) + assert len(result) == 1 + + def test_no_command_key(self): + fields = {FIELD_COMMANDS: {"cmd": "ping", "args": []}} + assert unpack_commands(fields) == [{"cmd": "ping", "args": []}] + + +class TestPackResult: + """Test pack_result helper.""" + + def test_basic_result(self): + result = pack_result("pong") + assert result["result"] == "pong" + assert result["status"] == "ok" + assert "request_id" not in result + + def test_result_with_request_id(self): + result = pack_result("pong", request_id="abc123") + assert result["result"] == "pong" + assert result["request_id"] == "abc123" + + def test_error_status(self): + result = pack_result("bad", status="error") + assert result["status"] == "error" + + +class TestLXMFBotFieldCommands: + """Test LXMFBot handling of FIELD_COMMANDS.""" + + @pytest.fixture(scope="function") + def bot(self, tmp_path): + config = BotConfig( + name="TestBot", + test_mode=True, + first_message_enabled=False, + lxmf_commands_enabled=True, + command_prefix="/", + ) + bot = LXMFBot(**config.__dict__) + bot.config_path = str(tmp_path) + yield bot + try: + bot.cleanup() + except Exception: + pass + + def test_field_command_executes_registered_command(self, bot): + """A message with FIELD_COMMANDS should trigger the matching command.""" + responses = [] + + @bot.command(name="ping") + def ping_cmd(ctx): + responses.append(ctx.content) + ctx.reply("pong") + + mock_message = Mock() + mock_message.content = b"" + mock_message.hash = b"msg_hash_1" + mock_message.fields = {FIELD_COMMANDS: {"command": "ping", "args": []}} + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert len(responses) == 1 + assert len(sent) == 1 + dest, msg, lxmf_fields = sent[0] + assert msg == "pong" + assert lxmf_fields is not None + assert FIELD_RESULTS in lxmf_fields + + def test_field_command_with_request_id(self, bot): + """Replies to field commands should include FIELD_RESULTS with request_id.""" + + @bot.command(name="echo") + def echo_cmd(ctx): + ctx.reply(" ".join(ctx.args)) + + mock_message = Mock() + mock_message.content = b"" + mock_message.hash = b"msg_hash_2" + mock_message.fields = { + FIELD_COMMANDS: { + "command": "echo", + "args": ["hello", "world"], + "request_id": "req-42", + } + } + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert len(sent) == 1 + _dest, msg, lxmf_fields = sent[0] + assert msg == "hello world" + assert FIELD_RESULTS in lxmf_fields + assert lxmf_fields[FIELD_RESULTS]["request_id"] == "req-42" + assert lxmf_fields[FIELD_RESULTS]["status"] == "ok" + + def test_field_command_unknown_command(self, bot): + """Unknown field commands should get an error reply with FIELD_RESULTS.""" + mock_message = Mock() + mock_message.content = b"" + mock_message.hash = b"msg_hash_3" + mock_message.fields = {FIELD_COMMANDS: {"command": "nope", "args": []}} + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert len(sent) == 1 + _dest, msg, lxmf_fields = sent[0] + assert "Unknown command" in msg + assert FIELD_RESULTS in lxmf_fields + assert lxmf_fields[FIELD_RESULTS]["status"] == "error" + + def test_field_command_disabled(self, bot): + """When lxmf_commands_enabled is False, field commands should be ignored.""" + bot.config.lxmf_commands_enabled = False + + text_responses = [] + + @bot.command(name="textonly") + def text_cmd(ctx): + text_responses.append(ctx.content) + + # Also set up a normal text message handler path + mock_message = Mock() + mock_message.content = b"/textonly hello" + mock_message.hash = b"msg_hash_4" + mock_message.fields = {FIELD_COMMANDS: {"command": "textonly", "args": []}} + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + # Should have processed the text command, not the field command + assert len(text_responses) == 1 + assert text_responses[0] == "/textonly hello" + + def test_field_command_with_string_args(self, bot): + """FIELD_COMMANDS args sent as a single string should be wrapped in a list.""" + + @bot.command(name="greet") + def greet_cmd(ctx): + ctx.reply(f"Hi {ctx.args[0]}") + + mock_message = Mock() + mock_message.content = b"" + mock_message.hash = b"msg_hash_5" + mock_message.fields = {FIELD_COMMANDS: {"command": "greet", "args": "Alice"}} + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert len(sent) == 1 + _dest, msg, _fields = sent[0] + assert msg == "Hi Alice" + + def test_msg_context_has_fields_and_request_id(self, bot): + """The msg object passed to commands should expose fields and request_id.""" + captured = {} + + @bot.command(name="capture") + def capture_cmd(ctx): + captured["fields"] = ctx.fields + captured["request_id"] = ctx.request_id + ctx.reply("ok") + + mock_message = Mock() + mock_message.content = b"" + mock_message.hash = b"msg_hash_6" + mock_message.fields = { + FIELD_COMMANDS: { + "command": "capture", + "args": [], + "request_id": "req-99", + } + } + + original_send = bot.send + bot.send = lambda *a, **k: None + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert captured["fields"] == mock_message.fields + assert captured["request_id"] == "req-99" + + def test_text_command_does_not_add_field_results(self, bot): + """Normal text commands without FIELD_COMMANDS should not inject FIELD_RESULTS.""" + + @bot.command(name="plain") + def plain_cmd(ctx): + ctx.reply("plain reply") + + mock_message = Mock() + mock_message.content = b"/plain" + mock_message.hash = b"msg_hash_7" + mock_message.fields = {} + + sent = [] + + def mock_send(dest, msg, title=None, **kwargs): + sent.append((dest, msg, kwargs.get("lxmf_fields"))) + + original_send = bot.send + bot.send = mock_send + + bot._process_message(mock_message, "sender_hash") + + bot.send = original_send + + assert len(sent) == 1 + _dest, _msg, lxmf_fields = sent[0] + # lxmf_fields should be None because there's no request_id + assert lxmf_fields is None