mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-22 14:21:25 +00:00
Update vendored LXMFy
This commit is contained in:
9 files changed
+556
-58
No files matched your search
Vendored
+1
-1
@@ -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.
|
||||
Vendored
+12
@@ -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
|
||||
|
||||
+29
-1
@@ -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
|
||||
-------
|
||||
|
||||
|
||||
+62
@@ -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 <a> <b>")
|
||||
|
||||
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)
|
||||
-----------------------
|
||||
|
||||
|
||||
Vendored
+5
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Vendored
+1
@@ -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."""
|
||||
|
||||
Vendored
+97
-56
@@ -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:
|
||||
|
||||
Vendored
+53
@@ -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
|
||||
+296
@@ -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
|
||||
Reference in new issue
Block a user