Update vendored LXMFy

This commit is contained in:
Ivan committed 2026-05-06 00:51:15 -05:00
1 parent 944094f98f
commit bf4da5d770
9 files changed
+556 -58

No files matched your search

+1 -1
View File
@@ -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.
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-----------------------
+5
View File
@@ -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",
]
+1
View File
@@ -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."""
+97 -56
View File
@@ -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:
+53
View File
@@ -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
View File
@@ -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