Skip to content

Core Components

LXMFBot

The main bot class that handles message routing, command processing, and bot lifecycle management.

from lxmfy import LXMFBot

bot = LXMFBot(
    name="MyBot",
    announce=600,
    announce_immediately=True,
    admins=set(),
    hot_reloading=False,
    rate_limit=5,
    cooldown=60,
    max_warnings=3,
    warning_timeout=300,
    command_prefix="/",
    cogs_dir="cogs",
    cogs_enabled=True,
    permissions_enabled=False,
    storage_type="json", # "json", "sqlite", or "memory"
    storage_path="data",
    first_message_enabled=True,
    event_logging_enabled=True,
    max_logged_events=1000,
    event_middleware_enabled=True,
    announce_enabled=True,
    signature_verification_enabled=False,
    require_message_signatures=False,
    identity_pinning_enabled=False,
    message_persistence_enabled=True,
    dynamic_cogs_enabled=True,
    external_cogs_enabled=True,
    external_cogs_sandbox_enabled=True,
    external_cogs_sandbox_type="auto",  # "auto", "landlock", "bwrap", "firejail", "none"
    external_cogs_timeout=30,
    landlock_enabled=True,
    nlp_enabled=False,
    nlp_threshold=0.5,
    link_support_enabled=False,
    lxmf_commands_enabled=True,
    message_queue_size=50,
    reticulum_config_dir=None,  # or LXMFY_RETICULUM_CONFIG_DIR / "~/.reticulum"
    rrc_enabled=False,
    rrc_hubs=[],
    rrc_rooms=[],
    rrc_nick=None,
    rrc_dest_name="rrc.hub",
    rrc_auto_reconnect=True,
    rrc_persist_sessions=True,
)

Key Methods

  • get_landlock_status(): Return Landlock LSM sandbox availability and activation state for the bot process
  • run(delay=10): Start the bot's main loop
  • send(destination, message, title="Reply", lxmf_fields=None, stamp_cost=None, opportunistic=None): Send a message to a destination, optionally with custom LXMF fields, stamp cost override, and opportunistic sending (tries direct, falls back to propagation immediately if configured).
  • send_with_attachment(destination, message, attachment, title="Reply", stamp_cost=None, opportunistic=None): Send a message with an attachment
  • command(name, description="No description provided", admin_only=False, threaded=False): Decorator for registering commands. Set threaded=True to run the command's callback in a separate thread. Commands support type-hinted arguments for automatic conversion.
  • intent(name, examples): Decorator for registering NLP intent handlers.
  • nlp.export_model(): Export trained NLP model data.
  • nlp.import_model(model_data): Import previously exported NLP model data.
  • request_link(destination_hash, callback=None, app_name="lxmf", *aspects): Request an RNS link to a destination. Allows custom app_name and aspects (defaults to "lxmf" and "delivery").
  • on_link(callback): Register a handler for incoming RNS links.
  • load_extension(name): Load a cog extension module by name (e.g., "cogs.utility").
  • reload_extension(name): Reload a cog extension module.
  • add_cog(cog_instance): Add a cog class instance to the bot.
  • remove_cog(cog_name): Remove a cog from the bot by its class name.
  • on_first_message(): Decorator for handling first messages from users
  • on_message(): Decorator for handling all messages (called before command processing)
  • on_reaction(): Decorator for handling inbound reactions. Handlers receive (sender, reaction) where reaction carries reaction_to, reaction_emoji, and reaction_sender keys
  • react(destination, message_hash, reaction): Send a reaction to a message via the LXMF FIELD_REACTION field
  • validate(): Run validation checks on the bot configuration
  • connect_rrc(hub_hash, rooms=None, nick=None, dest_name=None, auto_reconnect=None): Connect to an RRC hub as a client
  • disconnect_rrc(hub_hash=None): Disconnect one or all RRC hub sessions
  • on_rrc(callback=None): Decorator or register handler for RRC events (handler(event, client, payload))
  • rrc: RRCManager instance for multi-hub sessions

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.

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 BotConfig.

Reactions

Reactions travel as the LXMF FIELD_REACTION (0x40) field on an otherwise empty message. pack_reaction and unpack_reaction build and parse that field.

from lxmfy import pack_reaction, unpack_reaction

# Send a reaction to a message
bot.react(destination_hash, message_hash_hex, "thumbs up emoji")

# Receive reactions
@bot.on_reaction()
def on_reaction(sender, reaction):
    # reaction["reaction_to"]  - hex hash of the target message
    # reaction["reaction_emoji"] - reaction text (up to 16 chars)
    # reaction["reaction_sender"] - sender
    print(f"{sender} reacted {reaction['reaction_emoji']} to {reaction['reaction_to']}")
    return True

Reaction text is capped at 16 printable characters. The raw field stays available in ctx.fields and msg.fields for compatibility.

Reply Threading

Replies can carry the LXMF FIELD_REPLY_TO (0x30), FIELD_REPLY_QUOTE (0x31), and FIELD_THREAD (0x08) fields. Clients that render threads, like MeshChatX and Sideband, show these as proper quote replies instead of flat messages.

msg.reply() threads automatically: it sets FIELD_REPLY_TO to the inbound message hash and FIELD_THREAD to the conversation root.

@bot.command("status")
def status(msg):
    msg.reply("all systems nominal")          # threaded reply
    msg.reply("flat", reply_to=None)          # opt out of threading
    msg.reply("noted", quote=True)            # quote the inbound text

For sends that are not replies, pass the fields explicitly:

bot.send(dest, "see above", reply_to=msg_hash_hex, quote="earlier text")

Inbound replies are parsed onto the message context:

@bot.command("ctx")
def ctx_cmd(msg):
    msg.reply_to      # hex hash this message replies to, or None
    msg.reply_quote   # quoted text carried by the reply, or None
    msg.thread        # hex thread root hash, or None

pack_reply(message_hash, quote=..., thread=...) and unpack_reply(fields) are exported for manual field handling.

Conversations

Commands can ask the sender a question and treat their next message as the answer, instead of dispatching it as a command:

@bot.command("report")
def report(msg):
    title = msg.ask("Report title?", timeout=300)
    if title is None:
        msg.reply("Timed out.")
        return
    body = msg.ask("Describe the issue.", timeout=600)
    if body is None:
        msg.reply("Timed out.")
        return
    msg.reply(f"Filed: {title.content}")

msg.ask(prompt, timeout=..., validator=...) blocks the handler until the answer arrives, the timeout fires, or the conversation is cancelled. It returns an Answer with content, fields, hash, sender, and a reply(text) shortcut.

A validator rejects bad answers and re-prompts:

num = msg.ask(
    "Pick a number",
    validator=lambda a: None if a.content.isdigit() else "Digits only",
)

In async command handlers use await msg.ask_async(...). For long waits, or when many conversations may be open, use the callback style so no thread stays parked:

msg.ask(
    "Send the log file",
    on_answer=lambda ans: ans.reply("received"),
    on_timeout=lambda sender: bot.send(sender, "Too slow."),
    timeout=3600,
)

Notes:

  • Sending a registered command while a question is pending cancels the question and runs the command. Users always have an escape hatch.
  • bot.conversations.pending_count() and bot.conversations.cancel(sender) expose the registry for diagnostics and admin tools.
  • The registry is capped at 1024 pending questions. ask returns None when it is full.
  • Blocking ask parks the delivery thread handling that message. That is safe for direct deliveries, but bots that sync large batches from a propagation node should prefer on_answer callbacks.

Storage

The framework provides three storage backends:

JSONStorage

from lxmfy import JSONStorage

storage = JSONStorage("data")

SQLiteStorage

from lxmfy import SQLiteStorage

storage = SQLiteStorage("data/bot.db")

MemoryStorage

from lxmfy.storage import MemoryStorage

storage = MemoryStorage() # Entirely in-memory

Commands

Command registration and handling:

@bot.command(name="hello", description="Says hello")
def hello(ctx):
    ctx.reply(f"Hello {ctx.sender}!")

Type-Hinted Arguments

Commands automatically parse and convert arguments based on type hints in the callback function.

@bot.command(name="add", description="Adds two numbers")
def add(ctx, a: int, b: int):
    result = a + b
    ctx.reply(f"The result is {result}")

Per-Command Rate Limits

Limit how often a single sender can invoke a command inside the global cooldown window. Hitting the limit rejects the invocation only; it never adds warnings or bans.

@bot.command(name="report", rate_limit=3)
def report(ctx):
    # each sender can call this 3 times per cooldown period
    ...

Requires permissions_enabled=True, like the global rate limit. Users with the admin role or BYPASS_SPAM skip the check.

Help System

The framework includes an interactive help generator that provides beautiful, categorized help menus based on Cog and Command metadata.

# The help command is automatically registered.
# Users can use '/help' or '/help <command>'

Threaded Commands

For long-running or blocking operations that do not interact with the Reticulum Network Stack directly, you can run commands in a separate thread to keep the bot responsive.

import time

@bot.command(name="long_task", description="Performs a long-running task in a separate thread", threaded=True)
def long_task_command(ctx):
    ctx.reply("Starting a long task... please wait.")
    time.sleep(10) # This runs in a separate thread
    ctx.reply("Long task completed!")

Thread safety

Functions marked as threaded=True must not directly interact with the Reticulum Network Stack (RNS) or any components that rely on lxmfy.transport.py, as these are generally not thread-safe. Use ctx.reply() for sending messages back to the user from within a threaded command.

Events

Event system for handling various bot events:

@bot.events.on("message_received", EventPriority.HIGHEST)
def handle_message(event):
    # Handle message event
    pass

Testing

lxmfy.testing.TestBot is an LXMFBot preconfigured for tests. No Reticulum instance starts. Inbound messages go through the real receive pipeline (middleware, spam checks, permissions, dispatch) and outbound sends are captured for assertions.

from lxmfy import TestBot

def test_ping():
    with TestBot() as bot:
        @bot.command("ping")
        def ping(msg):
            msg.reply("pong")

        sent = bot.receive("/ping", sender="alice")
        assert sent[0].content == "pong"
        assert sent[0].destination == bot.sender_hex("alice")
  • bot.receive(content, sender=..., fields=..., message_hash=...) injects a message and returns the SentMessage objects it produced. Senders are named: "alice" maps to a stable fake hash, or pass a hex destination hash directly.
  • bot.drain() pops queued outbound messages. bot.outbox accumulates everything sent. bot.last_sent(sender=...) fetches the latest.
  • bot.wait_sent(n, timeout=...) waits for threaded commands.
  • bot.receive_later(content, sender=..., delay=...) answers blocking msg.ask calls from a daemon thread.
  • fake_message(content, source_hash=..., ...) builds an inbound message for driving bot._message_received directly.

The repository test suite also includes reliability and stress scenarios. Use the repository's test runner to execute them.

Advanced Reliability Suite

The framework includes an extensive suite of automated tests for harsh environments:

  • Manifold Testing: Validates the mathematical topology of NLP intent vector space.
  • Chaos Engineering: Simulates bit-rot, SD card failure, and storage corruption.
  • Temporal Drift: Verifies resilience against system clock jumps (±1 year).
  • Leak Detection: Long-term tracking of memory, file descriptors, and threads.

Permissions

Permission system for controlling access to bot features:

from lxmfy import DefaultPerms

@bot.command(name="admin", description="Admin command", admin_only=True)
def admin_command(ctx):
    if ctx.is_admin:
        ctx.reply("Admin command executed")

Middleware

Middleware system for processing messages and events:

@bot.middleware.register(MiddlewareType.PRE_COMMAND)
def pre_command_middleware(ctx):
    # Process before command execution
    pass

Attachments

Support for sending files, images, and audio:

from lxmfy import Attachment, AttachmentType

attachment = Attachment(
    type=AttachmentType.IMAGE,
    name="image.jpg",
    data=image_data,
    format="jpg"
)
bot.send_with_attachment(destination, "Here's an image", attachment)

Icon Appearance (LXMF Field)

You can set a custom icon for your bot that compliant LXMF clients can display. This uses the LXMF.FIELD_ICON_APPEARANCE.

from lxmfy import IconAppearance, pack_icon_appearance_field
import LXMF # Required for LXMF.FIELD_ICON_APPEARANCE

# Define the icon appearance
icon_data = IconAppearance(
    icon_name="smart_toy",  # Name from Material Symbols
    fg_color=b'\xFF\xFF\xFF',  # White foreground (3 bytes)
    bg_color=b'\x4A\x90\xE2'   # Blue background (3 bytes)
)

# Pack it into the LXMF field format
icon_lxmf_field = pack_icon_appearance_field(icon_data)

# Send a message with this icon
bot.send(
    destination_hash_str,
    "Hello from your friendly bot!",
    title="Bot Message",
    lxmf_fields=icon_lxmf_field
)

# You can also combine it with other fields, like attachments:
# attachment_field = pack_attachment(some_attachment)
# combined_fields = {**icon_lxmf_field, **attachment_field}
# bot.send(destination, "Message with icon and attachment", lxmf_fields=combined_fields)

Scheduler

Task scheduling system:

@bot.scheduler.schedule(name="daily_task", cron_expr="0 0 * * *")
def daily_task():
    # Run daily at midnight
    pass

Signatures

LXMFy provides configuration options for LXMF's built-in cryptographic message signing and verification:

from lxmfy import LXMFBot

bot = LXMFBot(
    name="SecureBot",
    signature_verification_enabled=True,  # Enable signature checks
    require_message_signatures=False      # Set to True to reject unsigned messages
)

Signature handling

LXMF automatically handles all cryptographic signing and verification using RNS identities. LXMFy's SignatureManager is a configuration layer that:

  • Controls whether to enforce signature verification
  • Determines policy for unsigned messages (accept or reject)
  • Integrates with the permission system (e.g., bypass verification for trusted users)

The actual cryptographic operations are performed by LXMF/RNS, not by LXMFy.

Landlock LSM Sandbox

On Linux kernels with Landlock support (5.13+), LXMFy can restrict filesystem access for the bot process and for external script cogs.

Bot process sandbox

When landlock_enabled=True (default) and not running in test_mode, the bot calls apply_landlock_sandbox() during initialization. System directories are read-only; bot storage, config, cogs, Reticulum config, and temp paths remain writable.

bot = LXMFBot(
    name="SecureBot",
    landlock_enabled=True,
)

status = bot.get_landlock_status()
# status keys: landlock_kernel_supported, landlock_requested,
# landlock_auto_enabled, landlock_disabled_by_env, landlock_active

Environment override

  • LXMFY_LANDLOCK=0: disable Landlock even on supported kernels
  • LXMFY_LANDLOCK=1: attempt Landlock on Linux regardless of auto-detection
  • unset: follow landlock_enabled and kernel auto-detection

External cog sandbox

Script cogs use external_cogs_sandbox_type. In auto mode, Landlock is preferred when available because it requires no external tools. See the Creating Bots guide for the full sandbox option list.

Identity Pinning

LXMFy supports optional identity pinning to prevent impersonation if an identity is rotated or compromised. When enabled, the bot "pins" an LXMF address to its first-seen public key.

bot = LXMFBot(
    identity_pinning_enabled=True
)

SignatureManager Methods

The SignatureManager is available as bot.signature_manager when signature_verification_enabled=True:

  • should_verify_message(sender): Determine if a message from the given sender should be verified
  • handle_unsigned_message(sender, message_hash): Handle messages that lack valid signatures based on policy

How LXMF Signatures Work

LXMF automatically signs all outgoing messages using the sender's RNS identity during the pack() operation. When messages are received, LXMF validates signatures and provides:

  • message.signature_validated: Boolean indicating if the signature is valid
  • message.unverified_reason: Reason code if validation failed (e.g., SIGNATURE_INVALID, SOURCE_UNKNOWN)

LXMFy uses these built-in LXMF properties to enforce your bot's signature policy.

Message Delivery

LXMFy provides advanced message delivery features including propagation nodes and automatic retries:

Propagation Nodes

Send messages through specific propagation nodes for improved reliability on the Reticulum network:

# Configure the propagation node once at config/runtime level
bot.set_propagation_node("<propagation_node_hash>")

# Send using configured delivery behavior
bot.send(
    destination_hash,
    "Message content"
)

# The propagation node hash should be a valid LXMF propagation node
# on the Reticulum network

Automatic Retries

Configure automatic retry attempts for failed direct deliveries:

bot = LXMFBot(
    name="ReliableBot",
    direct_delivery_retries=5,  # Retry direct delivery up to 5 times
    propagation_fallback_enabled=True
)

bot.send(destination_hash, "Important message")

# Default direct_delivery_retries is 3
# Retry logic automatically handles delivery callbacks

The retry system tracks delivery attempts per destination and automatically retries failed deliveries. Successful deliveries reset the retry counter for that destination.

Message Persistence

Outgoing messages can be persisted to disk to ensure they are delivered even after a bot restart. Persistence is enabled by default. The in-memory outbound queue is bounded (message_queue_size, default 50) and drops the oldest message when full. Invalid destination hashes are not restored.

bot = LXMFBot(
    message_persistence_enabled=True,
    message_queue_size=50,
)

Delivery Events

bot.delivery records a bounded stream of outbound lifecycle events so you can watch message flow without reading logs. Stages: queued, deferred, dispatched, delivered, failed, cancelled, dropped. The recent tail is persisted to storage and restored on startup.

@bot.on_delivery_event()
def watch(event):
    print(event["stage"], event.get("destination"), event.get("reason"))

# Or inspect directly
recent = bot.delivery.recent(20)
failures = bot.delivery.recent(stage="failed")
to_peer = bot.delivery.recent(destination="aa11bb...")

Each event is a dict with ts, stage, and optional destination, message_id, hash, method, attempts, reason, title.

Admins get a /delivery [limit] command that renders the same timeline in chat, and lxmfy debug shows a delivery timeline summary in the send pipeline checks.

Built-in Admin Commands

These commands are registered automatically and require the sender to be in admins when permissions are enabled:

Command Action
/queue Show router outbound queue, internal queue, and held sends
/cancel <id|all> Cancel pending outbound messages
/inbox [cancel <hash|all>] List or cancel active inbound transfers
/delivery [n] Show the last n delivery events (default 15, max 50)
/loadext <name> Load a cog extension
/reloadext <name> Reload a loaded cog extension

Router Controls

Thin wrappers over the underlying LXMRouter for sender control, tickets, outbound queue management, and propagation node sync. All take destination hashes as hex strings and return False when the router is not running (for example in test_mode).

Sender control (inbound)

  • ignore_destination(destination) / unignore_destination(destination) / is_ignored(destination): Drop inbound messages from a sender
  • allow_destination(destination) / disallow_destination(destination): Whitelist management when the router runs in allow-list mode
  • prioritise_destination(destination) / unprioritise_destination(destination): Prioritized sender list
  • set_inbound_stamp_cost(stamp_cost): Require a stamp cost on inbound messages (None clears)
  • enforce_stamps() / ignore_stamps(): Inbound stamp enforcement toggles

Tickets

  • generate_ticket(destination, expiry=None): Issue an inbound stamp ticket for a sender
  • get_inbound_tickets(destination): Tickets held for a sender
  • get_outbound_ticket(destination) / get_outbound_ticket_expiry(destination) / get_outbound_stamp_cost(destination): Outbound ticket state learned from the network

Outbound queue

  • outbound_queue(): Snapshot of pending outbound messages
  • get_outbound_progress(lxm_hash): Delivery progress for a message hash, or None
  • cancel_outbound(message_id): Remove a queued message before delivery
  • delivery_link_available(destination): Whether an active RNS link exists to the destination

Inbound queue

  • has_message(message_hash): Whether an inbound LXM hash was already delivered
  • inbound_count(): Active inbound resource transfers in progress
  • inbound_transfers(): Snapshot of each transfer with hash, size, progress, and status
  • cancel_inbound(resource_hash): Abort an active inbound transfer
  • cancel_all_inbound(): Abort every active inbound transfer, returns the count cancelled

Peer discovery

Announce metadata for destinations this node has heard:

  • get_peer_app_data(destination): Raw announced app_data bytes
  • get_peer_lxmf_data(destination): Decoded LXMF announce metadata (display_name, stamp_cost, capabilities), or None when the peer has not announced valid LXMF data
  • get_peer_announce(destination): Full announce record with hops, received_at, interface, and app_data
  • list_peer_announces(limit=100): All heard announces, newest first

Propagation

  • sync_propagation_node(max_messages=None): Pull messages from the configured propagation node
  • cancel_propagation_sync(): Stop an in-progress sync
  • get_propagation_stats(): Node transfer state and limits, or None
  • set_retain_on_node(retain): Keep delivered messages on the node
  • announce_propagation_node(): Announce this node as a propagation node
  • allow_control_identity(destination) / disallow_control_identity(destination): Propagation control channel whitelist

Ingest

  • ingest_lxm_uri(uri): Import an lxm:// URI message into the inbound queue

Message Handlers

LXMFy provides decorators for handling different types of incoming messages:

First Message Handler

Handle the first message from each user:

@bot.on_first_message()
def welcome_user(sender, message):
    content = message.content.decode("utf-8")
    bot.send(sender, f"Welcome! You said: {content}")
    return True  # Return True to stop further processing

General Message Handler

Handle all incoming messages before command processing:

@bot.on_message()
def handle_all_messages(sender, message):
    content = message.content.decode("utf-8").strip()

    # Custom logic here
    if content.startswith("echo:"):
        bot.send(sender, content[5:])
        return True  # Stop further processing

    return False  # Continue to command processing

Message handlers are called in this order: 1. First message handler (if this is the first message from this sender) 2. General message handlers (registered with on_message()) 3. Command processing (if message starts with command prefix)

Reticulum Relay Chat (RRC)

Bots can join RRC hubs over RNS Links with CBOR envelopes. Package: lxmfy.rrc.

BotConfig options

  • rrc_enabled (bool, default False): Connect configured hubs on startup
  • rrc_hubs (list of hex hashes): Hub destination hashes
  • rrc_rooms (list of str): Rooms to auto-join after WELCOME
  • rrc_nick (str or None): Nickname on HELLO and room messages
  • rrc_dest_name (str, default "rrc.hub"): Destination name used to build the hub destination
  • rrc_auto_reconnect (bool, default True): Reconnect after link loss
  • rrc_persist_sessions (bool, default True): Persist hubs and rooms across restarts
  • reticulum_config_dir (str or None): Reticulum config directory. Also set via LXMFY_RETICULUM_CONFIG_DIR. Use the same config as MeshChatX (often ~/.reticulum) so hub announces are visible.

Example

from lxmfy import LXMFBot, RRCMessage

bot = LXMFBot(
    name="RoomBot",
    reticulum_config_dir="~/.reticulum",
    rrc_enabled=True,
    rrc_hubs=["664fc0e8d2e448658e37bb3f34e6c88f"],
    rrc_rooms=["general"],
    rrc_nick="RoomBot",
)

@bot.on_rrc
def on_rrc(event, client, payload):
    if event == "msg" and isinstance(payload, RRCMessage) and payload.mention:
        client.send_message(payload.room, f"Hi {payload.nick}")

# Runtime API
# bot.connect_rrc(hub_hash, rooms=["general"])
# bot.rrc.send_message("general", "hello")
# bot.rrc.send_notice("general", "notice")
# bot.rrc.send_action("general", "waves")
# bot.rrc.join("ops")
# bot.rrc.part("ops")
# bot.rrc.status()
# bot.disconnect_rrc()

Exported types

  • RRCClient: Single-hub session
  • RRCManager: Multi-hub manager (bot.rrc)
  • RRCMessage: Room event payload (kind, room, text, nick, src, mention, ...)
  • RRC_VERSION: Wire protocol version constant

Common events passed to @bot.on_rrc handlers include status, welcome, joined, parted, msg, notice, action, motd, error, and rtt.

Templates

The framework includes several ready-to-use bot templates:

EchoBot

Simple echo bot that repeats messages:

from lxmfy.templates import EchoBot

bot = EchoBot()
bot.run()

NoteBot

Note-taking bot with JSON storage:

from lxmfy.templates import NoteBot

bot = NoteBot()
bot.run()

ReminderBot

Reminder bot with SQLite storage:

from lxmfy.templates import ReminderBot

bot = ReminderBot()
bot.run()

RRCBot

RRC room bot that joins configured hubs and replies to @mentions. Defaults to hub 664fc0e8d2e448658e37bb3f34e6c88f, room #general, and ~/.reticulum when available.

from lxmfy.templates import RRCBot

bot = RRCBot(
    hubs=["664fc0e8d2e448658e37bb3f34e6c88f"],
    rooms=["general"],
    nick="RRCBot",
    reticulum_config_dir="~/.reticulum",
)
bot.run()

CLI Tools

The framework provides command-line tools for bot management:

# Create a new bot
lxmfy create mybot

# Create a bot from template
lxmfy create --template echo mybot
lxmfy create --template rrc my_rrc_bot

# Run a template bot
lxmfy run echo
lxmfy run rrc

# Test signature verification with a message
lxmfy signatures test

# Enable signature verification
lxmfy signatures enable

# Disable signature verification
lxmfy signatures disable

Error Handling

Catch shutdown and runtime failures around bot.run():

try:
    bot.run()
except KeyboardInterrupt:
    bot.cleanup()
except Exception as e:
    logger.error(f"Error running bot: {str(e)}")

Module Reference

Generated from source docstrings.

Bases: AnnounceMixin, DispatchMixin, CogMixin, InboundMixin, OutboundMixin, LinkMixin, RRCMixin, PropagationMixin

Main bot class for handling LXMF messages and commands.

This class manages the bot's lifecycle, including: - Message routing and delivery - Command registration and execution - Cog (extension) loading and management - Spam protection - Admin privileges

Source code in lxmfy/core.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
class LXMFBot(
    AnnounceMixin,
    DispatchMixin,
    CogMixin,
    InboundMixin,
    OutboundMixin,
    LinkMixin,
    RRCMixin,
    PropagationMixin,
):
    """Main bot class for handling LXMF messages and commands.

    This class manages the bot's lifecycle, including:
    - Message routing and delivery
    - Command registration and execution
    - Cog (extension) loading and management
    - Spam protection
    - Admin privileges
    """

    def __init__(self, name: str | None = None, **kwargs: Any):
        """Initialize a new LXMFBot instance.

        Args:
            name: Optional bot name, same as the name config key.
            **kwargs: Override default configuration settings

        """
        if name is not None:
            kwargs["name"] = name
        self.config = BotConfig(**kwargs)
        self.commands = {}
        self.cogs = {}
        self.first_message_handlers = []
        self.message_handlers = []
        self.reaction_handlers = []
        self.delivery_callbacks = []
        self.receipts = []
        self._receive_lock = threading.Lock()
        self._delivery_lock = threading.Lock()
        self._last_pending_flush = 0.0
        queue_size = max(1, int(getattr(self.config, "message_queue_size", 50) or 50))
        self.queue = Queue(maxsize=queue_size)
        self.announce_time = 600
        self.router = None
        self.local = None
        self.logger = logging.getLogger("lxmfy")
        self._configure_logging()
        self.thread_pool = ThreadPoolExecutor(
            max_workers=5,
        )  # For offloading CPU-bound or blocking I/O tasks
        self.scheduler = TaskScheduler(self)  # Initialize the scheduler

        if self.config.config_path:
            self.config_path = self.config.config_path
        else:
            self.config_path = os.path.join(os.getcwd(), "config")

        os.makedirs(self.config_path, exist_ok=True)
        if self.config.test_mode and not self.config.reticulum_config_dir:
            self.reticulum_config_dir = os.path.abspath(self.config_path)
        else:
            self.reticulum_config_dir = resolve_reticulum_config_dir(
                self.config.reticulum_config_dir,
                self.config_path,
            )
        os.makedirs(self.reticulum_config_dir, exist_ok=True)
        if not self.config.test_mode and is_isolated_reticulum_dir(
            self.reticulum_config_dir,
            self.config_path,
        ):
            ensure_isolated_share_instance_disabled(self.reticulum_config_dir)

        if self.config.storage_type == "json":
            self.storage = Storage(JSONStorage(self.config.storage_path))
        elif self.config.storage_type == "sqlite":
            self.storage = Storage(SQLiteStorage(self.config.storage_path))
        elif self.config.storage_type == "memory":
            self.storage = Storage(MemoryStorage())
        else:
            raise ValueError(
                f"Unknown storage_type {self.config.storage_type!r}; "
                "expected 'json', 'sqlite', or 'memory'",
            )

        self.admins = set(self.config.admins or [])
        self.permissions = PermissionManager(
            storage=self.storage,
            enabled=self.config.permissions_enabled,
            admins=self.admins,
        )

        self.events = EventManager(self.storage)

        self._register_builtin_events()

        self.middleware = MiddlewareManager()

        self.cogs_dir = os.path.join(self.config_path, self.config.cogs_dir)
        if self.config.cogs_enabled:
            os.makedirs(self.cogs_dir, exist_ok=True)
            init_file = os.path.join(self.cogs_dir, "__init__.py")
            if not os.path.exists(init_file):
                open(init_file, "w", encoding="utf-8").close()

        self.transport = Transport(self, self.storage)
        self.delivery = DeliveryTracker(storage=self.storage)
        self.delivery.load_persisted()
        self.spam_protection = SpamProtection(
            storage=self.storage,
            bot=self,
            rate_limit=self.config.rate_limit,
            cooldown=self.config.cooldown,
            max_warnings=self.config.max_warnings,
            warning_timeout=self.config.warning_timeout,
        )

        self.delivery_attempts = {}
        self._load_delivery_attempts()
        self.conversations = ConversationManager(self)

        self.landlock_active = False
        if not self.config.test_mode:
            storage_dir = os.path.abspath(
                os.path.expanduser(self.config.storage_path),
            )
            self.landlock_active = apply_landlock_sandbox(
                storage_dir=storage_dir,
                reticulum_config_dir=self.reticulum_config_dir,
                config_dir=self.config_path,
                cogs_dir=self.cogs_dir,
                config_enabled=self.config.landlock_enabled,
            )

        identity_file = os.path.join(self.config_path, "identity")

        self._owns_reticulum = False
        if not self.config.test_mode:
            # Initialize Reticulum (will raise exception if already running)
            if RNS.Reticulum.get_instance() is None:
                try:
                    RNS.Reticulum(
                        configdir=self.reticulum_config_dir,
                        loglevel=self.config.loglevel,
                    )
                    self._owns_reticulum = True
                except OSError:
                    if RNS.Reticulum.get_instance() is None:
                        raise

            if not os.path.isfile(identity_file):
                RNS.log("No Primary Identity file found, creating new...", RNS.LOG_INFO)
                identity = RNS.Identity(True)
                identity.to_file(identity_file)
            identity = RNS.Identity.from_file(identity_file)
            if identity is None:
                raise RuntimeError(f"Failed to load identity from {identity_file}")
            self.identity = identity
            RNS.log("Loaded identity from file", RNS.LOG_INFO)

            self.router = LXMRouter(
                identity=self.identity,
                storagepath=self.config_path,
                autopeer=self.config.autopeer_propagation,
                autopeer_maxdepth=self.config.autopeer_maxdepth,
                enforce_stamps=self.config.require_stamps,
            )
            self.local = self.router.register_delivery_identity(
                self.identity,
                display_name=self.config.name,
                stamp_cost=self.config.stamp_cost,
            )
            if self.local is None:
                raise RuntimeError("Failed to register delivery identity")
            self._sync_delivery_display_name()
            self.router.register_delivery_callback(self._message_received)
            self.local.set_link_established_callback(
                self._delivery_link_established,
            )

            if self.config.pending_sends_enabled:
                RNS.Transport.register_announce_handler(
                    PendingSendAnnounceHandler(self),
                )

        self._configure_propagation()

        if self.local:
            RNS.log(
                f"LXMF Router ready to receive on: {RNS.prettyhexrep(self.local.hash)}",
                RNS.LOG_INFO,
            )
        else:
            # Test mode - create mock components
            if os.path.isfile(identity_file):
                identity = RNS.Identity.from_file(identity_file)
                if identity is None:
                    raise RuntimeError(
                        f"Failed to load identity from {identity_file}",
                    )
                self.identity = identity
            else:
                self.identity = RNS.Identity()  # Create a basic identity for testing
                if self.config.config_path:
                    self.identity.to_file(identity_file)

            self.router = None
            self.local = None

        self.announce_enabled = self.config.announce_enabled
        self.announce_time = self.config.announce

        if self.announce_enabled and not self.config.test_mode:
            if self.announce_time > 0:
                # Schedule the announce task. announce_now throttles on the
                # announce interval file, so a sub-minute cron step still
                # respects announce_time.
                minutes = max(1, self.announce_time // 60)
                self.scheduler.add_task(
                    "announce_task",
                    self.announce_now,
                    f"*/{minutes} * * * *",  # Convert seconds to minutes for cron
                )
            if self.config.announce_immediately:
                self.announce_now(force=True)
                RNS.log("Initial announce sent", RNS.LOG_INFO)

        self.hot_reloading = self.config.hot_reloading
        self.command_prefix = self.config.command_prefix

        self.help_system = HelpSystem(self)
        register_admin_commands(self)

        self.nlp = IntentClassifier(threshold=self.config.nlp_threshold)
        self.intents = {}  # {intent_name: callback}

        self.link_handlers = []
        self.links = {}  # {dest_hash: Link}

        self.rrc = None
        self.rrc_handlers = []
        self._init_rrc()

        self.signature_manager = SignatureManager(
            self,
            verification_enabled=self.config.signature_verification_enabled,
            require_signatures=self.config.require_message_signatures,
            request_unknown_identities=self.config.request_unknown_identities,
        )

        self._load_persisted_queue()

        if self.config.cogs_enabled:
            load_cogs_from_directory(self)

    def _configure_logging(self) -> None:
        level = self.config.log_level
        if level is None:
            return
        if isinstance(level, str):
            level = logging.getLevelNamesMapping().get(level.upper())
            if level is None:
                raise ValueError(
                    f"Unknown log_level {self.config.log_level!r}",
                )
        logger = logging.getLogger("lxmfy")
        if not logger.handlers:
            handler = logging.StreamHandler()
            handler.setFormatter(
                logging.Formatter(
                    "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
                    "%H:%M:%S",
                ),
            )
            logger.addHandler(handler)
        logger.setLevel(level)

    def get_debugger(self):
        """Return a Debugger bound to this bot."""
        from .debugger import Debugger

        return Debugger(bot=self)

    def diagnose_destination(
        self,
        destination: str,
        *,
        request_path: bool = False,
        wait: float = 0.0,
    ) -> dict:
        """Probe path and identity for a destination hash.

        Args:
            destination: Hex destination hash.
            request_path: Whether to request a path if missing.
            wait: Seconds to wait for a path after requesting.

        Returns:
            Dict describing identity/path status and hints.

        """
        return (
            self.get_debugger()
            .probe_destination(
                destination,
                request_path=request_path,
                wait=wait,
            )
            .to_dict()
        )

    def diagnose_connectivity(
        self,
        destination: str | None = None,
        *,
        request_path: bool = False,
        wait: float = 0.0,
    ) -> dict:
        """Run a connectivity doctor report for this bot.

        Args:
            destination: Optional peer hash to include in send diagnosis.
            request_path: Request a path when probing destination.
            wait: Seconds to wait for path discovery.

        Returns:
            Structured doctor report dict.

        """
        return (
            self.get_debugger()
            .run_doctor(
                destination,
                request_path=request_path,
                wait=wait,
            )
            .to_dict()
        )

    def on_delivery_event(self, callback=None):
        """Subscribe to the outbound delivery event stream.

        Usable as bot.on_delivery_event(fn) or as a decorator
        @bot.on_delivery_event(). Subscribers receive event dicts with
        ts, stage (queued, deferred, dispatched, delivered, failed,
        cancelled, dropped), and destination/message_id/hash/reason
        fields when available.
        """
        if callback is None:
            return self.delivery.subscribe
        return self.delivery.subscribe(callback)

    def get_landlock_status(self) -> dict[str, bool]:
        """Return Landlock LSM sandbox availability and activation state."""
        return landlock_status_dict(
            active=self.landlock_active,
            config_enabled=self.config.landlock_enabled,
        )

    def run(self, delay=10):
        """Run the bot"""
        self.scheduler.start()  # Start the scheduler
        try:
            while True:
                # Process outgoing queue with a timeout to prevent hanging
                while not self.queue.empty():
                    try:
                        lxm = self.queue.get(block=False)
                    except Exception:
                        break
                    try:
                        if self.router:
                            self.router.handle_outbound(lxm)
                            self.delivery.record(
                                "dispatched",
                                destination=_delivery_dest_hex(lxm),
                                message_id=_delivery_id_hex(lxm),
                                hash=_delivery_hex(lxm),
                            )
                        self._persist_queue()
                    except Exception as e:
                        self.logger.exception(
                            "Outbound send failed, requeueing",
                        )
                        self.delivery.record(
                            "failed",
                            destination=_delivery_dest_hex(lxm),
                            reason=str(e)[:120],
                        )
                        if not self._enqueue_outbound(lxm):
                            self.logger.exception(
                                "Failed to requeue after outbound error"
                            )
                        break

                retry_interval = max(
                    0,
                    int(getattr(self.config, "pending_sends_retry", 300) or 0),
                )
                if retry_interval and (
                    time.time() - self._last_pending_flush >= retry_interval
                ):
                    self._last_pending_flush = time.time()
                    self._flush_pending_sends()

                time.sleep(delay)

        except KeyboardInterrupt:
            pass
        finally:
            self.cleanup()

    def request_page(
        self,
        destination_hash: str,
        page_path: str,
        field_data: dict | None = None,
    ) -> dict:
        """Request a page from a destination.

        Args:
            destination_hash: The destination hash.
            page_path: The path to the page.
            field_data: Optional field data to send with the request.

        Returns:
            The response from the destination.

        """
        try:
            dest_hash_bytes = bytes.fromhex(destination_hash)
            return self.transport.request_page(dest_hash_bytes, page_path, field_data)
        except Exception:
            self.logger.exception("Error requesting page")
            raise

    def cleanup(self):
        """Clean up resources."""
        RNS.log("Cleaning up LXMFBot...", RNS.LOG_DEBUG)
        try:
            self._persist_queue()
        except Exception:
            self.logger.exception("Failed to persist queue during cleanup")
        self.conversations.cancel_all()
        if hasattr(self, "rrc") and self.rrc:
            try:
                self.rrc.shutdown()
            except Exception:
                self.logger.exception("RRC shutdown failed")
        self.transport.cleanup()
        self.thread_pool.shutdown(wait=False)
        self.scheduler.stop()
        if hasattr(self, "router") and self.router:
            try:
                self.router.exit_handler()
            except Exception as e:
                self.logger.debug("Router exit handler failed: %s", e)

        # Ensure Reticulum exits cleanly, but only if this bot started it.
        # A shared instance may still be serving other bots or tests.
        if not self.config.test_mode and self._owns_reticulum:
            try:
                RNS.Reticulum.exit_handler()
            except Exception as e:
                self.logger.debug("Reticulum exit handler failed: %s", e)
        RNS.log("LXMFBot cleanup complete", RNS.LOG_DEBUG)

    def validate(self) -> str:
        """Run validation checks and return formatted results."""
        results = validate_bot(self)
        return format_validation_results(results)

__init__(name=None, **kwargs)

Initialize a new LXMFBot instance.

Parameters:

Name Type Description Default
name str | None

Optional bot name, same as the name config key.

None
**kwargs Any

Override default configuration settings

{}
Source code in lxmfy/core.py
def __init__(self, name: str | None = None, **kwargs: Any):
    """Initialize a new LXMFBot instance.

    Args:
        name: Optional bot name, same as the name config key.
        **kwargs: Override default configuration settings

    """
    if name is not None:
        kwargs["name"] = name
    self.config = BotConfig(**kwargs)
    self.commands = {}
    self.cogs = {}
    self.first_message_handlers = []
    self.message_handlers = []
    self.reaction_handlers = []
    self.delivery_callbacks = []
    self.receipts = []
    self._receive_lock = threading.Lock()
    self._delivery_lock = threading.Lock()
    self._last_pending_flush = 0.0
    queue_size = max(1, int(getattr(self.config, "message_queue_size", 50) or 50))
    self.queue = Queue(maxsize=queue_size)
    self.announce_time = 600
    self.router = None
    self.local = None
    self.logger = logging.getLogger("lxmfy")
    self._configure_logging()
    self.thread_pool = ThreadPoolExecutor(
        max_workers=5,
    )  # For offloading CPU-bound or blocking I/O tasks
    self.scheduler = TaskScheduler(self)  # Initialize the scheduler

    if self.config.config_path:
        self.config_path = self.config.config_path
    else:
        self.config_path = os.path.join(os.getcwd(), "config")

    os.makedirs(self.config_path, exist_ok=True)
    if self.config.test_mode and not self.config.reticulum_config_dir:
        self.reticulum_config_dir = os.path.abspath(self.config_path)
    else:
        self.reticulum_config_dir = resolve_reticulum_config_dir(
            self.config.reticulum_config_dir,
            self.config_path,
        )
    os.makedirs(self.reticulum_config_dir, exist_ok=True)
    if not self.config.test_mode and is_isolated_reticulum_dir(
        self.reticulum_config_dir,
        self.config_path,
    ):
        ensure_isolated_share_instance_disabled(self.reticulum_config_dir)

    if self.config.storage_type == "json":
        self.storage = Storage(JSONStorage(self.config.storage_path))
    elif self.config.storage_type == "sqlite":
        self.storage = Storage(SQLiteStorage(self.config.storage_path))
    elif self.config.storage_type == "memory":
        self.storage = Storage(MemoryStorage())
    else:
        raise ValueError(
            f"Unknown storage_type {self.config.storage_type!r}; "
            "expected 'json', 'sqlite', or 'memory'",
        )

    self.admins = set(self.config.admins or [])
    self.permissions = PermissionManager(
        storage=self.storage,
        enabled=self.config.permissions_enabled,
        admins=self.admins,
    )

    self.events = EventManager(self.storage)

    self._register_builtin_events()

    self.middleware = MiddlewareManager()

    self.cogs_dir = os.path.join(self.config_path, self.config.cogs_dir)
    if self.config.cogs_enabled:
        os.makedirs(self.cogs_dir, exist_ok=True)
        init_file = os.path.join(self.cogs_dir, "__init__.py")
        if not os.path.exists(init_file):
            open(init_file, "w", encoding="utf-8").close()

    self.transport = Transport(self, self.storage)
    self.delivery = DeliveryTracker(storage=self.storage)
    self.delivery.load_persisted()
    self.spam_protection = SpamProtection(
        storage=self.storage,
        bot=self,
        rate_limit=self.config.rate_limit,
        cooldown=self.config.cooldown,
        max_warnings=self.config.max_warnings,
        warning_timeout=self.config.warning_timeout,
    )

    self.delivery_attempts = {}
    self._load_delivery_attempts()
    self.conversations = ConversationManager(self)

    self.landlock_active = False
    if not self.config.test_mode:
        storage_dir = os.path.abspath(
            os.path.expanduser(self.config.storage_path),
        )
        self.landlock_active = apply_landlock_sandbox(
            storage_dir=storage_dir,
            reticulum_config_dir=self.reticulum_config_dir,
            config_dir=self.config_path,
            cogs_dir=self.cogs_dir,
            config_enabled=self.config.landlock_enabled,
        )

    identity_file = os.path.join(self.config_path, "identity")

    self._owns_reticulum = False
    if not self.config.test_mode:
        # Initialize Reticulum (will raise exception if already running)
        if RNS.Reticulum.get_instance() is None:
            try:
                RNS.Reticulum(
                    configdir=self.reticulum_config_dir,
                    loglevel=self.config.loglevel,
                )
                self._owns_reticulum = True
            except OSError:
                if RNS.Reticulum.get_instance() is None:
                    raise

        if not os.path.isfile(identity_file):
            RNS.log("No Primary Identity file found, creating new...", RNS.LOG_INFO)
            identity = RNS.Identity(True)
            identity.to_file(identity_file)
        identity = RNS.Identity.from_file(identity_file)
        if identity is None:
            raise RuntimeError(f"Failed to load identity from {identity_file}")
        self.identity = identity
        RNS.log("Loaded identity from file", RNS.LOG_INFO)

        self.router = LXMRouter(
            identity=self.identity,
            storagepath=self.config_path,
            autopeer=self.config.autopeer_propagation,
            autopeer_maxdepth=self.config.autopeer_maxdepth,
            enforce_stamps=self.config.require_stamps,
        )
        self.local = self.router.register_delivery_identity(
            self.identity,
            display_name=self.config.name,
            stamp_cost=self.config.stamp_cost,
        )
        if self.local is None:
            raise RuntimeError("Failed to register delivery identity")
        self._sync_delivery_display_name()
        self.router.register_delivery_callback(self._message_received)
        self.local.set_link_established_callback(
            self._delivery_link_established,
        )

        if self.config.pending_sends_enabled:
            RNS.Transport.register_announce_handler(
                PendingSendAnnounceHandler(self),
            )

    self._configure_propagation()

    if self.local:
        RNS.log(
            f"LXMF Router ready to receive on: {RNS.prettyhexrep(self.local.hash)}",
            RNS.LOG_INFO,
        )
    else:
        # Test mode - create mock components
        if os.path.isfile(identity_file):
            identity = RNS.Identity.from_file(identity_file)
            if identity is None:
                raise RuntimeError(
                    f"Failed to load identity from {identity_file}",
                )
            self.identity = identity
        else:
            self.identity = RNS.Identity()  # Create a basic identity for testing
            if self.config.config_path:
                self.identity.to_file(identity_file)

        self.router = None
        self.local = None

    self.announce_enabled = self.config.announce_enabled
    self.announce_time = self.config.announce

    if self.announce_enabled and not self.config.test_mode:
        if self.announce_time > 0:
            # Schedule the announce task. announce_now throttles on the
            # announce interval file, so a sub-minute cron step still
            # respects announce_time.
            minutes = max(1, self.announce_time // 60)
            self.scheduler.add_task(
                "announce_task",
                self.announce_now,
                f"*/{minutes} * * * *",  # Convert seconds to minutes for cron
            )
        if self.config.announce_immediately:
            self.announce_now(force=True)
            RNS.log("Initial announce sent", RNS.LOG_INFO)

    self.hot_reloading = self.config.hot_reloading
    self.command_prefix = self.config.command_prefix

    self.help_system = HelpSystem(self)
    register_admin_commands(self)

    self.nlp = IntentClassifier(threshold=self.config.nlp_threshold)
    self.intents = {}  # {intent_name: callback}

    self.link_handlers = []
    self.links = {}  # {dest_hash: Link}

    self.rrc = None
    self.rrc_handlers = []
    self._init_rrc()

    self.signature_manager = SignatureManager(
        self,
        verification_enabled=self.config.signature_verification_enabled,
        require_signatures=self.config.require_message_signatures,
        request_unknown_identities=self.config.request_unknown_identities,
    )

    self._load_persisted_queue()

    if self.config.cogs_enabled:
        load_cogs_from_directory(self)

cleanup()

Clean up resources.

Source code in lxmfy/core.py
def cleanup(self):
    """Clean up resources."""
    RNS.log("Cleaning up LXMFBot...", RNS.LOG_DEBUG)
    try:
        self._persist_queue()
    except Exception:
        self.logger.exception("Failed to persist queue during cleanup")
    self.conversations.cancel_all()
    if hasattr(self, "rrc") and self.rrc:
        try:
            self.rrc.shutdown()
        except Exception:
            self.logger.exception("RRC shutdown failed")
    self.transport.cleanup()
    self.thread_pool.shutdown(wait=False)
    self.scheduler.stop()
    if hasattr(self, "router") and self.router:
        try:
            self.router.exit_handler()
        except Exception as e:
            self.logger.debug("Router exit handler failed: %s", e)

    # Ensure Reticulum exits cleanly, but only if this bot started it.
    # A shared instance may still be serving other bots or tests.
    if not self.config.test_mode and self._owns_reticulum:
        try:
            RNS.Reticulum.exit_handler()
        except Exception as e:
            self.logger.debug("Reticulum exit handler failed: %s", e)
    RNS.log("LXMFBot cleanup complete", RNS.LOG_DEBUG)

diagnose_connectivity(destination=None, *, request_path=False, wait=0.0)

Run a connectivity doctor report for this bot.

Parameters:

Name Type Description Default
destination str | None

Optional peer hash to include in send diagnosis.

None
request_path bool

Request a path when probing destination.

False
wait float

Seconds to wait for path discovery.

0.0

Returns:

Type Description
dict

Structured doctor report dict.

Source code in lxmfy/core.py
def diagnose_connectivity(
    self,
    destination: str | None = None,
    *,
    request_path: bool = False,
    wait: float = 0.0,
) -> dict:
    """Run a connectivity doctor report for this bot.

    Args:
        destination: Optional peer hash to include in send diagnosis.
        request_path: Request a path when probing destination.
        wait: Seconds to wait for path discovery.

    Returns:
        Structured doctor report dict.

    """
    return (
        self.get_debugger()
        .run_doctor(
            destination,
            request_path=request_path,
            wait=wait,
        )
        .to_dict()
    )

diagnose_destination(destination, *, request_path=False, wait=0.0)

Probe path and identity for a destination hash.

Parameters:

Name Type Description Default
destination str

Hex destination hash.

required
request_path bool

Whether to request a path if missing.

False
wait float

Seconds to wait for a path after requesting.

0.0

Returns:

Type Description
dict

Dict describing identity/path status and hints.

Source code in lxmfy/core.py
def diagnose_destination(
    self,
    destination: str,
    *,
    request_path: bool = False,
    wait: float = 0.0,
) -> dict:
    """Probe path and identity for a destination hash.

    Args:
        destination: Hex destination hash.
        request_path: Whether to request a path if missing.
        wait: Seconds to wait for a path after requesting.

    Returns:
        Dict describing identity/path status and hints.

    """
    return (
        self.get_debugger()
        .probe_destination(
            destination,
            request_path=request_path,
            wait=wait,
        )
        .to_dict()
    )

get_debugger()

Return a Debugger bound to this bot.

Source code in lxmfy/core.py
def get_debugger(self):
    """Return a Debugger bound to this bot."""
    from .debugger import Debugger

    return Debugger(bot=self)

get_landlock_status()

Return Landlock LSM sandbox availability and activation state.

Source code in lxmfy/core.py
def get_landlock_status(self) -> dict[str, bool]:
    """Return Landlock LSM sandbox availability and activation state."""
    return landlock_status_dict(
        active=self.landlock_active,
        config_enabled=self.config.landlock_enabled,
    )

on_delivery_event(callback=None)

Subscribe to the outbound delivery event stream.

Usable as bot.on_delivery_event(fn) or as a decorator @bot.on_delivery_event(). Subscribers receive event dicts with ts, stage (queued, deferred, dispatched, delivered, failed, cancelled, dropped), and destination/message_id/hash/reason fields when available.

Source code in lxmfy/core.py
def on_delivery_event(self, callback=None):
    """Subscribe to the outbound delivery event stream.

    Usable as bot.on_delivery_event(fn) or as a decorator
    @bot.on_delivery_event(). Subscribers receive event dicts with
    ts, stage (queued, deferred, dispatched, delivered, failed,
    cancelled, dropped), and destination/message_id/hash/reason
    fields when available.
    """
    if callback is None:
        return self.delivery.subscribe
    return self.delivery.subscribe(callback)

request_page(destination_hash, page_path, field_data=None)

Request a page from a destination.

Parameters:

Name Type Description Default
destination_hash str

The destination hash.

required
page_path str

The path to the page.

required
field_data dict | None

Optional field data to send with the request.

None

Returns:

Type Description
dict

The response from the destination.

Source code in lxmfy/core.py
def request_page(
    self,
    destination_hash: str,
    page_path: str,
    field_data: dict | None = None,
) -> dict:
    """Request a page from a destination.

    Args:
        destination_hash: The destination hash.
        page_path: The path to the page.
        field_data: Optional field data to send with the request.

    Returns:
        The response from the destination.

    """
    try:
        dest_hash_bytes = bytes.fromhex(destination_hash)
        return self.transport.request_page(dest_hash_bytes, page_path, field_data)
    except Exception:
        self.logger.exception("Error requesting page")
        raise

run(delay=10)

Run the bot

Source code in lxmfy/core.py
def run(self, delay=10):
    """Run the bot"""
    self.scheduler.start()  # Start the scheduler
    try:
        while True:
            # Process outgoing queue with a timeout to prevent hanging
            while not self.queue.empty():
                try:
                    lxm = self.queue.get(block=False)
                except Exception:
                    break
                try:
                    if self.router:
                        self.router.handle_outbound(lxm)
                        self.delivery.record(
                            "dispatched",
                            destination=_delivery_dest_hex(lxm),
                            message_id=_delivery_id_hex(lxm),
                            hash=_delivery_hex(lxm),
                        )
                    self._persist_queue()
                except Exception as e:
                    self.logger.exception(
                        "Outbound send failed, requeueing",
                    )
                    self.delivery.record(
                        "failed",
                        destination=_delivery_dest_hex(lxm),
                        reason=str(e)[:120],
                    )
                    if not self._enqueue_outbound(lxm):
                        self.logger.exception(
                            "Failed to requeue after outbound error"
                        )
                    break

            retry_interval = max(
                0,
                int(getattr(self.config, "pending_sends_retry", 300) or 0),
            )
            if retry_interval and (
                time.time() - self._last_pending_flush >= retry_interval
            ):
                self._last_pending_flush = time.time()
                self._flush_pending_sends()

            time.sleep(delay)

    except KeyboardInterrupt:
        pass
    finally:
        self.cleanup()

validate()

Run validation checks and return formatted results.

Source code in lxmfy/core.py
def validate(self) -> str:
    """Run validation checks and return formatted results."""
    results = validate_bot(self)
    return format_validation_results(results)

Configuration settings for LXMFBot.

Attributes:

Name Type Description
name str

The name of the bot. Defaults to "LXMFBot".

announce int

The announce interval in seconds. Defaults to 600.

announce_immediately bool

Whether to announce immediately on startup. Defaults to True.

admins set

A set of admin identity hashes. Defaults to an empty set.

hot_reloading bool

Whether to enable hot reloading of cogs. Defaults to False.

rate_limit int

The maximum number of messages allowed per cooldown period. Defaults to 5.

cooldown int

The cooldown period in seconds. Defaults to 60.

max_warnings int

The maximum number of spam warnings before action is taken. Defaults to 3.

warning_timeout int

The duration in seconds for which a spam warning is active. Defaults to 300.

command_prefix str

The prefix for bot commands. Defaults to "/".

cogs_dir str

The directory to load cogs from. Defaults to "cogs".

cogs_enabled bool

Whether to enable cogs. Defaults to True.

permissions_enabled bool

Whether to enable the permission system. Defaults to False.

storage_type str

The type of storage to use ("json" or "sqlite"). Defaults to "json".

storage_path str

The path to the storage file or directory. Defaults to "data".

first_message_enabled bool

Whether to enable first message handling. Defaults to True.

event_logging_enabled bool

Whether to enable event logging. Defaults to True.

max_logged_events int

The maximum number of events to log. Defaults to 1000.

event_middleware_enabled bool

Whether to enable event middleware. Defaults to True.

announce_enabled bool

Whether to enable bot announcements. Defaults to True.

signature_verification_enabled bool

Whether to enable cryptographic signature verification for incoming messages. Defaults to False.

require_message_signatures bool

Whether to reject unsigned messages when signature verification is enabled. Defaults to False.

require_stamps bool

Whether to reject messages with invalid stamps. Defaults to False.

request_unknown_identities bool

Whether to request unknown identities from the network when a message is received from an unknown source. Defaults to False.

stamp_cost int

Stamp cost required for messages to this bot. Outbound cost still comes from the peer announce unless send() overrides it. None disables inbound stamps. Defaults to None.

include_tickets bool

Include reply tickets on outbound messages so peers that require stamps can answer without stamp generation. Defaults to True.

direct_delivery_retries int

Number of times to retry direct delivery before falling back to propagation. Defaults to 3.

propagation_fallback_enabled bool

Whether to use propagation nodes as fallback after direct delivery fails. Defaults to True.

propagation_node str

The destination hash of the outbound propagation node. If None and autopeer_propagation is True, automatically discovers nodes. Defaults to None.

autopeer_propagation bool

Whether to automatically discover and peer with propagation nodes from announces. Defaults to False.

autopeer_maxdepth int

Maximum hop depth for auto-peering with propagation nodes. None = no limit. Defaults to 4.

enable_propagation_node bool

Whether to run this bot as a propagation node. Defaults to False.

message_storage_limit_mb float

Maximum storage for propagation node messages in megabytes. Only applies when enable_propagation_node is True. Defaults to 500 MB.

config_path str

The path to the bot configuration directory. If None, defaults to "config" in the current working directory. Defaults to None.

reticulum_config_dir str

The Reticulum config directory used for RNS shared instance/auth state. If None, uses LXMFY_RETICULUM_CONFIG_DIR when set, otherwise discovers the user/system Reticulum config (/etc/reticulum, ~/.config/reticulum, ~/.reticulum), and only then falls back to config_path. Isolated bot configs force share_instance=No to avoid RPC digest rejection with NomadNet/Columba.

test_mode bool

Whether to run in test mode (skips RNS initialization). Defaults to False.

log_level str or int or None

Logging level for lxmfy's own logger ("DEBUG", "INFO", "WARNING", ...). None leaves logging untouched. Defaults to "INFO".

loglevel int

RNS log level (0-7) passed to RNS.Reticulum. None uses the Reticulum config file setting. Defaults to None.

pending_sends_enabled bool

Hold outbound messages when the destination identity is not yet known, retrying on announces and periodic sweeps. Defaults to True.

pending_sends_max int

Maximum deferred messages kept for unknown destinations. Oldest are dropped beyond this. Defaults to 200.

pending_sends_ttl int

Seconds a deferred message is kept before being dropped. Defaults to 604800 (7 days).

pending_sends_retry int

Minimum seconds between deferred-send sweeps in run(). Defaults to 300.

announce_display_name_file str

Optional filename under config_path whose UTF-8 contents override the bot display name for LXMF delivery announces. If unset, bot_display_name.txt is read when present. Otherwise name is used.

Source code in lxmfy/config.py
@dataclass
class BotConfig:
    """Configuration settings for LXMFBot.

    Attributes:
        name (str): The name of the bot. Defaults to "LXMFBot".
        announce (int): The announce interval in seconds. Defaults to 600.
        announce_immediately (bool): Whether to announce immediately on startup. Defaults to True.
        admins (set): A set of admin identity hashes. Defaults to an empty set.
        hot_reloading (bool): Whether to enable hot reloading of cogs. Defaults to False.
        rate_limit (int): The maximum number of messages allowed per cooldown period. Defaults to 5.
        cooldown (int): The cooldown period in seconds. Defaults to 60.
        max_warnings (int): The maximum number of spam warnings before action is taken. Defaults to 3.
        warning_timeout (int): The duration in seconds for which a spam warning is active. Defaults to 300.
        command_prefix (str): The prefix for bot commands. Defaults to "/".
        cogs_dir (str): The directory to load cogs from. Defaults to "cogs".
        cogs_enabled (bool): Whether to enable cogs. Defaults to True.
        permissions_enabled (bool): Whether to enable the permission system. Defaults to False.
        storage_type (str): The type of storage to use ("json" or "sqlite"). Defaults to "json".
        storage_path (str): The path to the storage file or directory. Defaults to "data".
        first_message_enabled (bool): Whether to enable first message handling. Defaults to True.
        event_logging_enabled (bool): Whether to enable event logging. Defaults to True.
        max_logged_events (int): The maximum number of events to log. Defaults to 1000.
        event_middleware_enabled (bool): Whether to enable event middleware. Defaults to True.
        announce_enabled (bool): Whether to enable bot announcements. Defaults to True.
        signature_verification_enabled (bool): Whether to enable cryptographic signature verification for incoming messages. Defaults to False.
        require_message_signatures (bool): Whether to reject unsigned messages when signature verification is enabled. Defaults to False.
        require_stamps (bool): Whether to reject messages with invalid stamps. Defaults to False.
        request_unknown_identities (bool): Whether to request unknown identities from the network when a message is received from an unknown source. Defaults to False.
        stamp_cost (int): Stamp cost required for messages to this bot.
            Outbound cost still comes from the peer announce unless send()
            overrides it. None disables inbound stamps. Defaults to None.
        include_tickets (bool): Include reply tickets on outbound messages so
            peers that require stamps can answer without stamp generation.
            Defaults to True.
        direct_delivery_retries (int): Number of times to retry direct delivery before falling back to propagation. Defaults to 3.
        propagation_fallback_enabled (bool): Whether to use propagation nodes as fallback after direct delivery fails. Defaults to True.
        propagation_node (str): The destination hash of the outbound propagation node. If None and autopeer_propagation is True, automatically discovers nodes. Defaults to None.
        autopeer_propagation (bool): Whether to automatically discover and peer with propagation nodes from announces. Defaults to False.
        autopeer_maxdepth (int): Maximum hop depth for auto-peering with propagation nodes. None = no limit. Defaults to 4.
        enable_propagation_node (bool): Whether to run this bot as a propagation node. Defaults to False.
        message_storage_limit_mb (float): Maximum storage for propagation node messages in megabytes. Only applies when enable_propagation_node is True. Defaults to 500 MB.
        config_path (str): The path to the bot configuration directory. If None, defaults to "config" in the current working directory. Defaults to None.
        reticulum_config_dir (str): The Reticulum config directory used for RNS shared instance/auth state. If None, uses LXMFY_RETICULUM_CONFIG_DIR when set, otherwise discovers the user/system Reticulum config (/etc/reticulum, ~/.config/reticulum, ~/.reticulum), and only then falls back to config_path. Isolated bot configs force share_instance=No to avoid RPC digest rejection with NomadNet/Columba.
        test_mode (bool): Whether to run in test mode (skips RNS initialization). Defaults to False.
        log_level (str or int or None): Logging level for lxmfy's own logger ("DEBUG", "INFO", "WARNING", ...). None leaves logging untouched. Defaults to "INFO".
        loglevel (int): RNS log level (0-7) passed to RNS.Reticulum. None uses the Reticulum config file setting. Defaults to None.
        pending_sends_enabled (bool): Hold outbound messages when the destination identity is not yet known, retrying on announces and periodic sweeps. Defaults to True.
        pending_sends_max (int): Maximum deferred messages kept for unknown destinations. Oldest are dropped beyond this. Defaults to 200.
        pending_sends_ttl (int): Seconds a deferred message is kept before being dropped. Defaults to 604800 (7 days).
        pending_sends_retry (int): Minimum seconds between deferred-send sweeps in run(). Defaults to 300.
        announce_display_name_file (str): Optional filename under config_path whose UTF-8 contents override the bot display name for LXMF delivery announces. If unset, ``bot_display_name.txt`` is read when present. Otherwise ``name`` is used.

    """

    name: str = "LXMFBot"
    announce: int = 600
    announce_immediately: bool = True
    admins: set[str] | None = None
    hot_reloading: bool = False
    rate_limit: int = 5
    cooldown: int = 60
    max_warnings: int = 3
    warning_timeout: int = 300
    command_prefix: str = "/"
    cogs_dir: str = "cogs"
    cogs_enabled: bool = True
    permissions_enabled: bool = False
    storage_type: str = "json"
    storage_path: str = "data"
    first_message_enabled: bool = True
    event_logging_enabled: bool = True
    max_logged_events: int = 1000
    event_middleware_enabled: bool = True
    announce_enabled: bool = True
    signature_verification_enabled: bool = False
    require_message_signatures: bool = False
    require_stamps: bool = False
    request_unknown_identities: bool = False
    stamp_cost: int | None = None
    include_tickets: bool = True
    direct_delivery_retries: int = 3
    propagation_fallback_enabled: bool = True
    propagation_node: str | None = None
    autopeer_propagation: bool = False
    autopeer_maxdepth: int = 4
    enable_propagation_node: bool = False
    message_storage_limit_mb: float = 500.0
    config_path: str | None = None
    reticulum_config_dir: str | None = None
    announce_display_name_file: str | None = None
    test_mode: bool = False
    log_level: str | int | None = "INFO"
    loglevel: int | None = None
    identity_pinning_enabled: bool = False
    message_persistence_enabled: bool = True
    message_queue_size: int = 50
    pending_sends_enabled: bool = True
    pending_sends_max: int = 200
    pending_sends_ttl: int = 604800
    pending_sends_retry: int = 300
    dynamic_cogs_enabled: bool = True
    external_cogs_enabled: bool = True
    external_cogs_sandbox_enabled: bool = True
    external_cogs_sandbox_type: str = (
        "auto"  # 'auto', 'landlock', 'bwrap', 'firejail', 'none'
    )
    external_cogs_timeout: int = 30
    landlock_enabled: bool = True
    nlp_enabled: bool = False
    nlp_threshold: float = 0.5
    link_support_enabled: bool = False
    opportunistic_sending: bool = True
    lxmf_commands_enabled: bool = True
    rrc_enabled: bool = False
    rrc_hubs: list[str] | None = None
    rrc_rooms: list[str] | None = None
    rrc_nick: str | None = None
    rrc_dest_name: str = "rrc.hub"
    rrc_auto_reconnect: bool = True
    rrc_persist_sessions: bool = True

    def __post_init__(self):
        """Post-initialization to ensure admins is a set."""
        if self.admins is None:
            self.admins = set()
        if self.reticulum_config_dir is None:
            self.reticulum_config_dir = os.environ.get("LXMFY_RETICULUM_CONFIG_DIR")
        if self.rrc_hubs is None:
            self.rrc_hubs = []
        if self.rrc_rooms is None:
            self.rrc_rooms = []

    def __str__(self):
        """Return a string representation of the BotConfig object."""
        return f"BotConfig(name={self.name}, announce={self.announce}, announce_immediately={self.announce_immediately}, admins={self.admins}, hot_reloading={self.hot_reloading}, rate_limit={self.rate_limit}, cooldown={self.cooldown}, max_warnings={self.max_warnings}, warning_timeout={self.warning_timeout}, command_prefix={self.command_prefix}, cogs_dir={self.cogs_dir}, cogs_enabled={self.cogs_enabled}, permissions_enabled={self.permissions_enabled}, storage_type={self.storage_type}, storage_path={self.storage_path}, first_message_enabled={self.first_message_enabled}, event_logging_enabled={self.event_logging_enabled}, max_logged_events={self.max_logged_events}, event_middleware_enabled={self.event_middleware_enabled}, announce_enabled={self.announce_enabled}, signature_verification_enabled={self.signature_verification_enabled}, require_message_signatures={self.require_message_signatures}, require_stamps={self.require_stamps}, request_unknown_identities={self.request_unknown_identities}, stamp_cost={self.stamp_cost}, test_mode={self.test_mode}, identity_pinning_enabled={self.identity_pinning_enabled}, message_persistence_enabled={self.message_persistence_enabled}, dynamic_cogs_enabled={self.dynamic_cogs_enabled})"

__post_init__()

Post-initialization to ensure admins is a set.

Source code in lxmfy/config.py
def __post_init__(self):
    """Post-initialization to ensure admins is a set."""
    if self.admins is None:
        self.admins = set()
    if self.reticulum_config_dir is None:
        self.reticulum_config_dir = os.environ.get("LXMFY_RETICULUM_CONFIG_DIR")
    if self.rrc_hubs is None:
        self.rrc_hubs = []
    if self.rrc_rooms is None:
        self.rrc_rooms = []

__str__()

Return a string representation of the BotConfig object.

Source code in lxmfy/config.py
def __str__(self):
    """Return a string representation of the BotConfig object."""
    return f"BotConfig(name={self.name}, announce={self.announce}, announce_immediately={self.announce_immediately}, admins={self.admins}, hot_reloading={self.hot_reloading}, rate_limit={self.rate_limit}, cooldown={self.cooldown}, max_warnings={self.max_warnings}, warning_timeout={self.warning_timeout}, command_prefix={self.command_prefix}, cogs_dir={self.cogs_dir}, cogs_enabled={self.cogs_enabled}, permissions_enabled={self.permissions_enabled}, storage_type={self.storage_type}, storage_path={self.storage_path}, first_message_enabled={self.first_message_enabled}, event_logging_enabled={self.event_logging_enabled}, max_logged_events={self.max_logged_events}, event_middleware_enabled={self.event_middleware_enabled}, announce_enabled={self.announce_enabled}, signature_verification_enabled={self.signature_verification_enabled}, require_message_signatures={self.require_message_signatures}, require_stamps={self.require_stamps}, request_unknown_identities={self.request_unknown_identities}, stamp_cost={self.stamp_cost}, test_mode={self.test_mode}, identity_pinning_enabled={self.identity_pinning_enabled}, message_persistence_enabled={self.message_persistence_enabled}, dynamic_cogs_enabled={self.dynamic_cogs_enabled})"

Represents a generic attachment.

Attributes:

Name Type Description
type AttachmentType

The type of the attachment (AttachmentType).

name str

The name of the attachment.

data bytes

The binary data of the attachment.

format str | None

Optional format specifier (e.g., "png" for images).

Source code in lxmfy/attachments.py
@dataclass
class Attachment:
    """Represents a generic attachment.

    Attributes:
        type: The type of the attachment (AttachmentType).
        name: The name of the attachment.
        data: The binary data of the attachment.
        format: Optional format specifier (e.g., "png" for images).

    """

    type: AttachmentType
    name: str
    data: bytes
    format: str | None = None

Bases: IntEnum

Enumerates the different types of attachments supported.

FILE: Represents a generic file attachment. IMAGE: Represents an image attachment. AUDIO: Represents an audio attachment.

Source code in lxmfy/attachments.py
class AttachmentType(IntEnum):
    """Enumerates the different types of attachments supported.

    FILE: Represents a generic file attachment.
    IMAGE: Represents an image attachment.
    AUDIO: Represents an audio attachment.
    """

    FILE = 0x05
    IMAGE = 0x06
    AUDIO = 0x07

Bases: Flag

Default permission set

Source code in lxmfy/permissions.py
class DefaultPerms(Flag):
    """Default permission set"""

    NONE = 0
    # Basic permissions
    USE_BOT = auto()
    SEND_MESSAGES = auto()
    USE_COMMANDS = auto()

    # Elevated permissions
    MANAGE_MESSAGES = auto()
    MANAGE_COMMANDS = auto()
    MANAGE_USERS = auto()

    # Special permissions
    BYPASS_RATELIMIT = auto()
    BYPASS_SPAM = auto()
    VIEW_ADMIN_COMMANDS = auto()

    # Event system permissions
    VIEW_EVENTS = auto()
    MANAGE_EVENTS = auto()
    BYPASS_EVENT_CHECKS = auto()

    # Combined permissions
    ALL = (
        USE_BOT
        | SEND_MESSAGES
        | USE_COMMANDS
        | MANAGE_MESSAGES
        | MANAGE_COMMANDS
        | MANAGE_USERS
        | BYPASS_RATELIMIT
        | BYPASS_SPAM
        | VIEW_ADMIN_COMMANDS
        | VIEW_EVENTS
        | MANAGE_EVENTS
        | BYPASS_EVENT_CHECKS
    )

Manages permissions, roles, and user assignments

Source code in lxmfy/permissions.py
@dataclass
class PermissionManager:
    """Manages permissions, roles, and user assignments"""

    storage: Any
    enabled: bool = False
    admins: set[str] | None = None
    default_role: Role = field(
        default_factory=lambda: Role(
            "user",
            DefaultPerms.USE_BOT
            | DefaultPerms.SEND_MESSAGES
            | DefaultPerms.USE_COMMANDS,
        ),
    )
    admin_role: Role = field(
        default_factory=lambda: Role("admin", DefaultPerms.ALL, priority=100),
    )

    def __post_init__(self):
        self.roles: dict[str, Role] = {
            "user": self.default_role,
            "admin": self.admin_role,
        }
        self.user_roles: dict[str, set[str]] = {}
        self.load_data()

    def load_data(self):
        """Load permission data from storage"""
        stored_roles = self.storage.get("permissions:roles", {})
        stored_user_roles = self.storage.get("permissions:user_roles", {})

        # Convert stored roles back to Role objects
        for role_name, role_data in stored_roles.items():
            if role_name not in ["user", "admin"]:  # Don't override default/admin
                self.roles[role_name] = Role(
                    name=role_data["name"],
                    permissions=DefaultPerms(role_data["permissions"]),
                    priority=role_data["priority"],
                    description=role_data.get("description"),
                )

        self.user_roles = {
            user: set(roles) for user, roles in stored_user_roles.items()
        }

    def save_data(self):
        """Save permission data to storage"""
        # Convert roles to serializable format
        roles_data = {
            name: {
                "name": role.name,
                "permissions": role.permissions.value,
                "priority": role.priority,
                "description": role.description,
            }
            for name, role in self.roles.items()
        }

        self.storage.set("permissions:roles", roles_data)
        self.storage.set(
            "permissions:user_roles",
            {user: list(roles) for user, roles in self.user_roles.items()},
        )

    def create_role(
        self,
        name: str,
        permissions: DefaultPerms,
        priority: int = 0,
        description: str | None = None,
    ) -> Role:
        """Create a new role"""
        if name in self.roles:
            raise ValueError(f"Role {name} already exists")

        role = Role(name, permissions, priority, description)
        self.roles[name] = role
        self.save_data()
        return role

    def delete_role(self, name: str) -> bool:
        """Delete a role"""
        if name in ["user", "admin"]:
            raise ValueError("Cannot delete default or admin roles")

        if name in self.roles:
            del self.roles[name]
            # Remove role from all users
            for user_roles in self.user_roles.values():
                user_roles.discard(name)
            self.save_data()
            return True
        return False

    def assign_role(self, user: str, role_name: str):
        """Assign a role to a user"""
        if role_name not in self.roles:
            raise ValueError(f"Role {role_name} does not exist")

        if user not in self.user_roles:
            self.user_roles[user] = {self.default_role.name}

        self.user_roles[user].add(role_name)
        self.save_data()

    def remove_role(self, user: str, role_name: str):
        """Remove a role from a user"""
        if (
            user in self.user_roles
            and role_name in self.user_roles[user]
            and role_name != self.default_role.name
        ):
            self.user_roles[user].remove(role_name)
            self.save_data()

    def get_user_permissions(self, user: str) -> DefaultPerms:
        """Get combined permissions for a user"""
        if self.admins and user in self.admins:
            return self.admin_role.permissions

        if user not in self.user_roles:
            return self.default_role.permissions

        perms = DefaultPerms.NONE
        for role_name in self.user_roles[user]:
            if role_name in self.roles:
                perms |= self.roles[role_name].permissions

        return perms

    def has_permission(self, user: str, permission: DefaultPerms) -> bool:
        """Check if user has specific permission"""
        if not self.enabled:
            return True
        user_perms = self.get_user_permissions(user)
        return (user_perms & permission) == permission

assign_role(user, role_name)

Assign a role to a user

Source code in lxmfy/permissions.py
def assign_role(self, user: str, role_name: str):
    """Assign a role to a user"""
    if role_name not in self.roles:
        raise ValueError(f"Role {role_name} does not exist")

    if user not in self.user_roles:
        self.user_roles[user] = {self.default_role.name}

    self.user_roles[user].add(role_name)
    self.save_data()

create_role(name, permissions, priority=0, description=None)

Create a new role

Source code in lxmfy/permissions.py
def create_role(
    self,
    name: str,
    permissions: DefaultPerms,
    priority: int = 0,
    description: str | None = None,
) -> Role:
    """Create a new role"""
    if name in self.roles:
        raise ValueError(f"Role {name} already exists")

    role = Role(name, permissions, priority, description)
    self.roles[name] = role
    self.save_data()
    return role

delete_role(name)

Delete a role

Source code in lxmfy/permissions.py
def delete_role(self, name: str) -> bool:
    """Delete a role"""
    if name in ["user", "admin"]:
        raise ValueError("Cannot delete default or admin roles")

    if name in self.roles:
        del self.roles[name]
        # Remove role from all users
        for user_roles in self.user_roles.values():
            user_roles.discard(name)
        self.save_data()
        return True
    return False

get_user_permissions(user)

Get combined permissions for a user

Source code in lxmfy/permissions.py
def get_user_permissions(self, user: str) -> DefaultPerms:
    """Get combined permissions for a user"""
    if self.admins and user in self.admins:
        return self.admin_role.permissions

    if user not in self.user_roles:
        return self.default_role.permissions

    perms = DefaultPerms.NONE
    for role_name in self.user_roles[user]:
        if role_name in self.roles:
            perms |= self.roles[role_name].permissions

    return perms

has_permission(user, permission)

Check if user has specific permission

Source code in lxmfy/permissions.py
def has_permission(self, user: str, permission: DefaultPerms) -> bool:
    """Check if user has specific permission"""
    if not self.enabled:
        return True
    user_perms = self.get_user_permissions(user)
    return (user_perms & permission) == permission

load_data()

Load permission data from storage

Source code in lxmfy/permissions.py
def load_data(self):
    """Load permission data from storage"""
    stored_roles = self.storage.get("permissions:roles", {})
    stored_user_roles = self.storage.get("permissions:user_roles", {})

    # Convert stored roles back to Role objects
    for role_name, role_data in stored_roles.items():
        if role_name not in ["user", "admin"]:  # Don't override default/admin
            self.roles[role_name] = Role(
                name=role_data["name"],
                permissions=DefaultPerms(role_data["permissions"]),
                priority=role_data["priority"],
                description=role_data.get("description"),
            )

    self.user_roles = {
        user: set(roles) for user, roles in stored_user_roles.items()
    }

remove_role(user, role_name)

Remove a role from a user

Source code in lxmfy/permissions.py
def remove_role(self, user: str, role_name: str):
    """Remove a role from a user"""
    if (
        user in self.user_roles
        and role_name in self.user_roles[user]
        and role_name != self.default_role.name
    ):
        self.user_roles[user].remove(role_name)
        self.save_data()

save_data()

Save permission data to storage

Source code in lxmfy/permissions.py
def save_data(self):
    """Save permission data to storage"""
    # Convert roles to serializable format
    roles_data = {
        name: {
            "name": role.name,
            "permissions": role.permissions.value,
            "priority": role.priority,
            "description": role.description,
        }
        for name, role in self.roles.items()
    }

    self.storage.set("permissions:roles", roles_data)
    self.storage.set(
        "permissions:user_roles",
        {user: list(roles) for user, roles in self.user_roles.items()},
    )

Manages scheduled tasks and background processes.

Source code in lxmfy/scheduler.py
class TaskScheduler:
    """Manages scheduled tasks and background processes."""

    def __init__(self, bot: Any):
        """Initialize the TaskScheduler.

        Args:
            bot: The bot instance.

        """
        self.bot = bot
        self.tasks: dict[str, ScheduledTask] = {}
        self.background_tasks: list[Thread] = []
        self.stop_event = Event()
        self.logger = logging.getLogger(__name__)

    def schedule(self, name: str, cron_expr: str):
        """Decorator to schedule a task.

        Args:
            name (str): The name of the task.
            cron_expr (str): The cron expression for the task.

        """

        def decorator(func):
            """Adds the task to the scheduler."""
            self.add_task(name, func, cron_expr)
            return func

        return decorator

    def add_task(self, name: str, callback: Callable, cron_expr: str):
        """Add a scheduled task.

        Args:
            name (str): The name of the task.
            callback (Callable): The function to execute when the task runs.
            cron_expr (str): A cron-style expression defining when the task should run.

        """
        self.tasks[name] = ScheduledTask(name, callback, cron_expr)

    def remove_task(self, name: str):
        """Remove a scheduled task.

        Args:
            name (str): The name of the task to remove.

        """
        self.tasks.pop(name, None)

    def start(self):
        """Start the scheduler."""
        self.stop_event.clear()
        scheduler_thread = Thread(target=self._scheduler_loop, daemon=True)
        scheduler_thread.start()
        self.background_tasks.append(scheduler_thread)

    def stop(self):
        """Stop the scheduler."""
        self.stop_event.set()
        for task in self.background_tasks:
            task.join()
        self.background_tasks.clear()

    def _scheduler_loop(self):
        """Main scheduler loop.  Checks and runs tasks based on their cron expressions."""
        while not self.stop_event.is_set():
            current_time = datetime.now()  # noqa: DTZ005 - cron matches local wall time

            for task in list(self.tasks.values()):
                try:
                    if task.should_run(current_time):
                        run_sync(task.callback)
                        task.last_run = current_time
                except Exception:
                    self.logger.exception("Error running task %s", task.name)

            self.stop_event.wait(
                max(0, 60 - datetime.now().second)  # noqa: DTZ005
            )

__init__(bot)

Initialize the TaskScheduler.

Parameters:

Name Type Description Default
bot Any

The bot instance.

required
Source code in lxmfy/scheduler.py
def __init__(self, bot: Any):
    """Initialize the TaskScheduler.

    Args:
        bot: The bot instance.

    """
    self.bot = bot
    self.tasks: dict[str, ScheduledTask] = {}
    self.background_tasks: list[Thread] = []
    self.stop_event = Event()
    self.logger = logging.getLogger(__name__)

add_task(name, callback, cron_expr)

Add a scheduled task.

Parameters:

Name Type Description Default
name str

The name of the task.

required
callback Callable

The function to execute when the task runs.

required
cron_expr str

A cron-style expression defining when the task should run.

required
Source code in lxmfy/scheduler.py
def add_task(self, name: str, callback: Callable, cron_expr: str):
    """Add a scheduled task.

    Args:
        name (str): The name of the task.
        callback (Callable): The function to execute when the task runs.
        cron_expr (str): A cron-style expression defining when the task should run.

    """
    self.tasks[name] = ScheduledTask(name, callback, cron_expr)

remove_task(name)

Remove a scheduled task.

Parameters:

Name Type Description Default
name str

The name of the task to remove.

required
Source code in lxmfy/scheduler.py
def remove_task(self, name: str):
    """Remove a scheduled task.

    Args:
        name (str): The name of the task to remove.

    """
    self.tasks.pop(name, None)

schedule(name, cron_expr)

Decorator to schedule a task.

Parameters:

Name Type Description Default
name str

The name of the task.

required
cron_expr str

The cron expression for the task.

required
Source code in lxmfy/scheduler.py
def schedule(self, name: str, cron_expr: str):
    """Decorator to schedule a task.

    Args:
        name (str): The name of the task.
        cron_expr (str): The cron expression for the task.

    """

    def decorator(func):
        """Adds the task to the scheduler."""
        self.add_task(name, func, cron_expr)
        return func

    return decorator

start()

Start the scheduler.

Source code in lxmfy/scheduler.py
def start(self):
    """Start the scheduler."""
    self.stop_event.clear()
    scheduler_thread = Thread(target=self._scheduler_loop, daemon=True)
    scheduler_thread.start()
    self.background_tasks.append(scheduler_thread)

stop()

Stop the scheduler.

Source code in lxmfy/scheduler.py
def stop(self):
    """Stop the scheduler."""
    self.stop_event.set()
    for task in self.background_tasks:
        task.join()
    self.background_tasks.clear()

A scheduled task with cron-style timing.

Attributes:

Name Type Description
name str

The name of the task.

callback Callable

The function to execute when the task runs.

cron_expr str

A cron-style expression defining when the task should run (min hour day month weekday).

last_run Optional[datetime]

The last time the task was run.

enabled bool

Whether the task is currently enabled.

Source code in lxmfy/scheduler.py
@dataclass
class ScheduledTask:
    """A scheduled task with cron-style timing.

    Attributes:
        name (str): The name of the task.
        callback (Callable): The function to execute when the task runs.
        cron_expr (str): A cron-style expression defining when the task should run (min hour day month weekday).
        last_run (Optional[datetime]): The last time the task was run.
        enabled (bool): Whether the task is currently enabled.

    """

    name: str
    callback: Callable
    cron_expr: str
    last_run: datetime | None = None
    enabled: bool = True

    def should_run(self, current_time: datetime) -> bool:
        """Check if the task should run at the given time.

        Args:
            current_time (datetime): The current datetime.

        Returns:
            bool: True if the task should run, False otherwise.

        """
        if not self.enabled:
            return False

        if self.last_run and current_time - self.last_run < timedelta(minutes=1):
            return False

        return self._match_cron(current_time)

    def _match_cron(self, dt: datetime) -> bool:
        """Match the datetime against the cron expression.

        Args:
            dt (datetime): The datetime to match.

        Returns:
            bool: True if the datetime matches the cron expression, False otherwise.

        """
        parts = self.cron_expr.split()
        if len(parts) != 5:
            return False

        minute, hour, day, month, weekday = parts

        return (
            self._match_field(minute, dt.minute, 0, 59)
            and self._match_field(hour, dt.hour, 0, 23)
            and self._match_field(day, dt.day, 1, 31)
            and self._match_field(month, dt.month, 1, 12)
            and ScheduledTask._match_field(weekday, dt.weekday(), 0, 6)
        )

    @staticmethod
    def _match_field(pattern: str, value: int, min_val: int, max_val: int) -> bool:
        """Match a cron field pattern.

        Args:
            pattern (str): The cron field pattern to match.
            value (int): The value to check against the pattern.
            min_val (int): The minimum allowed value.
            max_val (int): The maximum allowed value.

        Returns:
            bool: True if the value matches the pattern, False otherwise.

        """
        if pattern == "*":
            return True

        parts = pattern.split(",")
        for part in parts:
            try:
                if "-" in part:
                    start, end = map(int, part.split("-"))
                    if min_val <= start <= value <= end <= max_val:
                        return True
                elif "/" in part:
                    step = int(part.split("/")[1])
                    if step > 0 and value % step == 0:
                        return True
                elif int(part) == value:
                    return True
            except ValueError:
                continue

        return False

should_run(current_time)

Check if the task should run at the given time.

Parameters:

Name Type Description Default
current_time datetime

The current datetime.

required

Returns:

Name Type Description
bool bool

True if the task should run, False otherwise.

Source code in lxmfy/scheduler.py
def should_run(self, current_time: datetime) -> bool:
    """Check if the task should run at the given time.

    Args:
        current_time (datetime): The current datetime.

    Returns:
        bool: True if the task should run, False otherwise.

    """
    if not self.enabled:
        return False

    if self.last_run and current_time - self.last_run < timedelta(minutes=1):
        return False

    return self._match_cron(current_time)

Facade for the underlying storage backend.

Source code in lxmfy/storage.py
class Storage:
    """Facade for the underlying storage backend."""

    def __init__(self, backend: StorageBackend):
        """Initialize a new Storage instance.

        Args:
            backend: The storage backend to use.

        """
        self.backend = backend

    def get(self, key: str, default: Any = None) -> Any:
        """Retrieve a value from storage.

        Args:
            key: The key to retrieve.
            default: The default value to return if the key is not found.

        Returns:
            The value associated with the key, or the default value if not found.

        """
        value = self.backend.get(key, default)
        return deserialize_value(value)

    def set(self, key: str, value: Any) -> None:
        """Store a value in storage.

        Args:
            key: The key to store the value under.
            value: The value to store.

        """
        serialized = serialize_value(value)
        self.backend.set(key, serialized)

    def delete(self, key: str) -> None:
        """Delete a value from storage.

        Args:
            key: The key to delete.

        """
        self.backend.delete(key)

    def exists(self, key: str) -> bool:
        """Check if a key exists in storage.

        Args:
            key: The key to check.

        Returns:
            True if the key exists, False otherwise.

        """
        return self.backend.exists(key)

    def scan(self, prefix: str) -> list:
        """Scan for keys with a given prefix.

        Args:
            prefix: The prefix to scan for.

        Returns:
            A list of keys that start with the prefix.

        """
        return self.backend.scan(prefix)

    def get_role_data(self, role_name: str) -> dict:
        """Helper method for permission system.

        Args:
            role_name: The name of the role.

        Returns:
            The role data.

        """
        return self.get(f"roles:{role_name}", {})

    def set_role_data(self, role_name: str, data: dict):
        """Helper method for permission system.

        Args:
            role_name: The name of the role.
            data: The role data.

        """
        self.set(f"roles:{role_name}", data)

    def get_user_roles(self, user_hash: str) -> list[str]:
        """Helper method for permission system.

        Args:
            user_hash: The hash of the user.

        Returns:
            The list of roles for the user.

        """
        return self.get(f"user_roles:{user_hash}", [])

    def set_user_roles(self, user_hash: str, roles: list[str]):
        """Helper method for permission system.

        Args:
            user_hash: The hash of the user.
            roles: The list of roles for the user.

        """
        self.set(f"user_roles:{user_hash}", roles)

__init__(backend)

Initialize a new Storage instance.

Parameters:

Name Type Description Default
backend StorageBackend

The storage backend to use.

required
Source code in lxmfy/storage.py
def __init__(self, backend: StorageBackend):
    """Initialize a new Storage instance.

    Args:
        backend: The storage backend to use.

    """
    self.backend = backend

delete(key)

Delete a value from storage.

Parameters:

Name Type Description Default
key str

The key to delete.

required
Source code in lxmfy/storage.py
def delete(self, key: str) -> None:
    """Delete a value from storage.

    Args:
        key: The key to delete.

    """
    self.backend.delete(key)

exists(key)

Check if a key exists in storage.

Parameters:

Name Type Description Default
key str

The key to check.

required

Returns:

Type Description
bool

True if the key exists, False otherwise.

Source code in lxmfy/storage.py
def exists(self, key: str) -> bool:
    """Check if a key exists in storage.

    Args:
        key: The key to check.

    Returns:
        True if the key exists, False otherwise.

    """
    return self.backend.exists(key)

get(key, default=None)

Retrieve a value from storage.

Parameters:

Name Type Description Default
key str

The key to retrieve.

required
default Any

The default value to return if the key is not found.

None

Returns:

Type Description
Any

The value associated with the key, or the default value if not found.

Source code in lxmfy/storage.py
def get(self, key: str, default: Any = None) -> Any:
    """Retrieve a value from storage.

    Args:
        key: The key to retrieve.
        default: The default value to return if the key is not found.

    Returns:
        The value associated with the key, or the default value if not found.

    """
    value = self.backend.get(key, default)
    return deserialize_value(value)

get_role_data(role_name)

Helper method for permission system.

Parameters:

Name Type Description Default
role_name str

The name of the role.

required

Returns:

Type Description
dict

The role data.

Source code in lxmfy/storage.py
def get_role_data(self, role_name: str) -> dict:
    """Helper method for permission system.

    Args:
        role_name: The name of the role.

    Returns:
        The role data.

    """
    return self.get(f"roles:{role_name}", {})

get_user_roles(user_hash)

Helper method for permission system.

Parameters:

Name Type Description Default
user_hash str

The hash of the user.

required

Returns:

Type Description
list[str]

The list of roles for the user.

Source code in lxmfy/storage.py
def get_user_roles(self, user_hash: str) -> list[str]:
    """Helper method for permission system.

    Args:
        user_hash: The hash of the user.

    Returns:
        The list of roles for the user.

    """
    return self.get(f"user_roles:{user_hash}", [])

scan(prefix)

Scan for keys with a given prefix.

Parameters:

Name Type Description Default
prefix str

The prefix to scan for.

required

Returns:

Type Description
list

A list of keys that start with the prefix.

Source code in lxmfy/storage.py
def scan(self, prefix: str) -> list:
    """Scan for keys with a given prefix.

    Args:
        prefix: The prefix to scan for.

    Returns:
        A list of keys that start with the prefix.

    """
    return self.backend.scan(prefix)

set(key, value)

Store a value in storage.

Parameters:

Name Type Description Default
key str

The key to store the value under.

required
value Any

The value to store.

required
Source code in lxmfy/storage.py
def set(self, key: str, value: Any) -> None:
    """Store a value in storage.

    Args:
        key: The key to store the value under.
        value: The value to store.

    """
    serialized = serialize_value(value)
    self.backend.set(key, serialized)

set_role_data(role_name, data)

Helper method for permission system.

Parameters:

Name Type Description Default
role_name str

The name of the role.

required
data dict

The role data.

required
Source code in lxmfy/storage.py
def set_role_data(self, role_name: str, data: dict):
    """Helper method for permission system.

    Args:
        role_name: The name of the role.
        data: The role data.

    """
    self.set(f"roles:{role_name}", data)

set_user_roles(user_hash, roles)

Helper method for permission system.

Parameters:

Name Type Description Default
user_hash str

The hash of the user.

required
roles list[str]

The list of roles for the user.

required
Source code in lxmfy/storage.py
def set_user_roles(self, user_hash: str, roles: list[str]):
    """Helper method for permission system.

    Args:
        user_hash: The hash of the user.
        roles: The list of roles for the user.

    """
    self.set(f"user_roles:{user_hash}", roles)

Bases: StorageBackend

JSON file-based storage backend.

Source code in lxmfy/storage.py
class JSONStorage(StorageBackend):
    """JSON file-based storage backend."""

    def __init__(self, directory: str):
        """Initialize a new JSONStorage instance.

        Args:
            directory: The directory to store the JSON files in.

        """
        self.directory = Path(directory)
        self.directory.mkdir(parents=True, exist_ok=True)
        self.cache: dict[str, Any] = {}
        self.logger = logging.getLogger(__name__)

    def get(self, key: str, default: Any = None) -> Any:
        """Retrieve a value from storage.

        Args:
            key: The key to retrieve.
            default: The default value to return if the key is not found.

        Returns:
            The value associated with the key, or the default value if not found.

        """
        if key in self.cache:
            return self.cache[key]

        file_path = self.directory / f"{key}.json"
        try:
            if file_path.exists():
                with open(file_path) as f:
                    data = json.load(f)
                    self.cache[key] = data
                    return data
        except Exception:
            self.logger.exception("Error reading %s", key)
        return default

    def set(self, key: str, value: Any) -> None:
        """Store a value in storage.

        Args:
            key: The key to store the value under.
            value: The value to store.

        """
        file_path = self.directory / f"{key}.json"
        try:
            with open(file_path, "w") as f:
                json.dump(value, f, indent=2)
            self.cache[key] = value
        except Exception:
            self.logger.exception("Error writing %s", key)
            raise

    def delete(self, key: str) -> None:
        """Delete a value from storage.

        Args:
            key: The key to delete.

        """
        file_path = self.directory / f"{key}.json"
        try:
            if file_path.exists():
                file_path.unlink()
            self.cache.pop(key, None)
        except Exception:
            self.logger.exception("Error deleting %s", key)
            raise

    def exists(self, key: str) -> bool:
        """Check if a key exists in storage.

        Args:
            key: The key to check.

        Returns:
            True if the key exists, False otherwise.

        """
        return (self.directory / f"{key}.json").exists()

    def scan(self, prefix: str) -> list:
        """Scan for keys with a given prefix.

        Args:
            prefix: The prefix to scan for.

        Returns:
            A list of keys that start with the prefix.

        """
        results = []
        try:
            for file in self.directory.glob(f"{prefix}*.json"):
                key = file.stem
                if key.startswith(prefix):
                    results.append(key)
        except Exception:
            self.logger.exception("Error scanning with prefix %s", prefix)
        return results

__init__(directory)

Initialize a new JSONStorage instance.

Parameters:

Name Type Description Default
directory str

The directory to store the JSON files in.

required
Source code in lxmfy/storage.py
def __init__(self, directory: str):
    """Initialize a new JSONStorage instance.

    Args:
        directory: The directory to store the JSON files in.

    """
    self.directory = Path(directory)
    self.directory.mkdir(parents=True, exist_ok=True)
    self.cache: dict[str, Any] = {}
    self.logger = logging.getLogger(__name__)

delete(key)

Delete a value from storage.

Parameters:

Name Type Description Default
key str

The key to delete.

required
Source code in lxmfy/storage.py
def delete(self, key: str) -> None:
    """Delete a value from storage.

    Args:
        key: The key to delete.

    """
    file_path = self.directory / f"{key}.json"
    try:
        if file_path.exists():
            file_path.unlink()
        self.cache.pop(key, None)
    except Exception:
        self.logger.exception("Error deleting %s", key)
        raise

exists(key)

Check if a key exists in storage.

Parameters:

Name Type Description Default
key str

The key to check.

required

Returns:

Type Description
bool

True if the key exists, False otherwise.

Source code in lxmfy/storage.py
def exists(self, key: str) -> bool:
    """Check if a key exists in storage.

    Args:
        key: The key to check.

    Returns:
        True if the key exists, False otherwise.

    """
    return (self.directory / f"{key}.json").exists()

get(key, default=None)

Retrieve a value from storage.

Parameters:

Name Type Description Default
key str

The key to retrieve.

required
default Any

The default value to return if the key is not found.

None

Returns:

Type Description
Any

The value associated with the key, or the default value if not found.

Source code in lxmfy/storage.py
def get(self, key: str, default: Any = None) -> Any:
    """Retrieve a value from storage.

    Args:
        key: The key to retrieve.
        default: The default value to return if the key is not found.

    Returns:
        The value associated with the key, or the default value if not found.

    """
    if key in self.cache:
        return self.cache[key]

    file_path = self.directory / f"{key}.json"
    try:
        if file_path.exists():
            with open(file_path) as f:
                data = json.load(f)
                self.cache[key] = data
                return data
    except Exception:
        self.logger.exception("Error reading %s", key)
    return default

scan(prefix)

Scan for keys with a given prefix.

Parameters:

Name Type Description Default
prefix str

The prefix to scan for.

required

Returns:

Type Description
list

A list of keys that start with the prefix.

Source code in lxmfy/storage.py
def scan(self, prefix: str) -> list:
    """Scan for keys with a given prefix.

    Args:
        prefix: The prefix to scan for.

    Returns:
        A list of keys that start with the prefix.

    """
    results = []
    try:
        for file in self.directory.glob(f"{prefix}*.json"):
            key = file.stem
            if key.startswith(prefix):
                results.append(key)
    except Exception:
        self.logger.exception("Error scanning with prefix %s", prefix)
    return results

set(key, value)

Store a value in storage.

Parameters:

Name Type Description Default
key str

The key to store the value under.

required
value Any

The value to store.

required
Source code in lxmfy/storage.py
def set(self, key: str, value: Any) -> None:
    """Store a value in storage.

    Args:
        key: The key to store the value under.
        value: The value to store.

    """
    file_path = self.directory / f"{key}.json"
    try:
        with open(file_path, "w") as f:
            json.dump(value, f, indent=2)
        self.cache[key] = value
    except Exception:
        self.logger.exception("Error writing %s", key)
        raise

Bases: StorageBackend

SQLite database storage backend.

Source code in lxmfy/storage.py
class SQLiteStorage(StorageBackend):
    """SQLite database storage backend."""

    def __init__(self, database_path: str):
        """Initialize a new SQLiteStorage instance.

        Args:
            database_path: The path to the SQLite database file.

        """
        self.database_path = database_path
        self.cache: dict[str, Any] = {}
        self.logger = logging.getLogger(__name__)
        self._ensure_db_dir()
        self._init_db()

    def _ensure_db_dir(self):
        """Ensure the database directory exists."""
        db_path = Path(self.database_path)
        db_dir = db_path.parent
        try:
            db_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            self.logger.exception("Failed to create database directory %s", db_dir)
            raise

    def _init_db(self):
        """Initialize the database table."""
        try:
            with sqlite3.connect(self.database_path) as conn:
                conn.execute("""
                    CREATE TABLE IF NOT EXISTS key_value (
                        key TEXT PRIMARY KEY,
                        value TEXT,
                        type TEXT,
                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                    )
                """)
                conn.execute("""
                    CREATE INDEX IF NOT EXISTS idx_key_prefix ON key_value(key)
                """)
        except sqlite3.OperationalError:
            self.logger.exception(
                "Failed to initialize database at %s", self.database_path
            )
            raise
        except Exception:
            self.logger.exception("Unexpected error initializing database")
            raise

    def get(self, key: str, default: Any = None) -> Any:
        """Retrieve a value from storage.

        Args:
            key: The key to retrieve.
            default: The default value to return if the key is not found.

        Returns:
            The value associated with the key, or the default value if not found.

        """
        if key in self.cache:
            return self.cache[key]

        try:
            with sqlite3.connect(self.database_path) as conn:
                cursor = conn.execute(
                    "SELECT value FROM key_value WHERE key = ?",
                    (key,),
                )
                row = cursor.fetchone()
                if row:
                    try:
                        value = json.loads(row[0])
                        self.cache[key] = value
                        return value
                    except json.JSONDecodeError:
                        return row[0]
        except Exception:
            self.logger.exception("Error reading %s", key)
        return default

    def set(self, key: str, value: Any) -> None:
        """Store a value in storage.

        Args:
            key: The key to store the value under.
            value: The value to store.

        """
        try:
            if isinstance(value, (dict, list)):
                serialized = json.dumps(value)
            else:
                serialized = str(value)

            with sqlite3.connect(self.database_path) as conn:
                conn.execute(
                    """
                    INSERT OR REPLACE INTO key_value (key, value, type, updated_at)
                    VALUES (?, ?, ?, CURRENT_TIMESTAMP)
                """,
                    (key, serialized, type(value).__name__),
                )
            self.cache[key] = value
        except Exception:
            self.logger.exception("Error writing %s", key)
            raise

    def delete(self, key: str) -> None:
        """Delete a value from storage.

        Args:
            key: The key to delete.

        """
        try:
            with sqlite3.connect(self.database_path) as conn:
                conn.execute("DELETE FROM key_value WHERE key = ?", (key,))
            self.cache.pop(key, None)
        except Exception:
            self.logger.exception("Error deleting %s", key)
            raise

    def exists(self, key: str) -> bool:
        """Check if a key exists in storage.

        Args:
            key: The key to check.

        Returns:
            True if the key exists, False otherwise.

        """
        try:
            with sqlite3.connect(self.database_path) as conn:
                cursor = conn.execute("SELECT 1 FROM key_value WHERE key = ?", (key,))
                return cursor.fetchone() is not None
        except Exception:
            self.logger.exception("Error checking existence of %s", key)
            return False

    def scan(self, prefix: str) -> list:
        """Scan for keys with a given prefix.

        Args:
            prefix: The prefix to scan for.

        Returns:
            A list of keys that start with the prefix.

        """
        try:
            with sqlite3.connect(self.database_path) as conn:
                cursor = conn.execute(
                    "SELECT key FROM key_value WHERE key LIKE ? ORDER BY key",
                    (f"{prefix}%",),
                )
                return [row[0] for row in cursor.fetchall()]
        except Exception:
            self.logger.exception("Error scanning with prefix %s", prefix)
            return []

__init__(database_path)

Initialize a new SQLiteStorage instance.

Parameters:

Name Type Description Default
database_path str

The path to the SQLite database file.

required
Source code in lxmfy/storage.py
def __init__(self, database_path: str):
    """Initialize a new SQLiteStorage instance.

    Args:
        database_path: The path to the SQLite database file.

    """
    self.database_path = database_path
    self.cache: dict[str, Any] = {}
    self.logger = logging.getLogger(__name__)
    self._ensure_db_dir()
    self._init_db()

delete(key)

Delete a value from storage.

Parameters:

Name Type Description Default
key str

The key to delete.

required
Source code in lxmfy/storage.py
def delete(self, key: str) -> None:
    """Delete a value from storage.

    Args:
        key: The key to delete.

    """
    try:
        with sqlite3.connect(self.database_path) as conn:
            conn.execute("DELETE FROM key_value WHERE key = ?", (key,))
        self.cache.pop(key, None)
    except Exception:
        self.logger.exception("Error deleting %s", key)
        raise

exists(key)

Check if a key exists in storage.

Parameters:

Name Type Description Default
key str

The key to check.

required

Returns:

Type Description
bool

True if the key exists, False otherwise.

Source code in lxmfy/storage.py
def exists(self, key: str) -> bool:
    """Check if a key exists in storage.

    Args:
        key: The key to check.

    Returns:
        True if the key exists, False otherwise.

    """
    try:
        with sqlite3.connect(self.database_path) as conn:
            cursor = conn.execute("SELECT 1 FROM key_value WHERE key = ?", (key,))
            return cursor.fetchone() is not None
    except Exception:
        self.logger.exception("Error checking existence of %s", key)
        return False

get(key, default=None)

Retrieve a value from storage.

Parameters:

Name Type Description Default
key str

The key to retrieve.

required
default Any

The default value to return if the key is not found.

None

Returns:

Type Description
Any

The value associated with the key, or the default value if not found.

Source code in lxmfy/storage.py
def get(self, key: str, default: Any = None) -> Any:
    """Retrieve a value from storage.

    Args:
        key: The key to retrieve.
        default: The default value to return if the key is not found.

    Returns:
        The value associated with the key, or the default value if not found.

    """
    if key in self.cache:
        return self.cache[key]

    try:
        with sqlite3.connect(self.database_path) as conn:
            cursor = conn.execute(
                "SELECT value FROM key_value WHERE key = ?",
                (key,),
            )
            row = cursor.fetchone()
            if row:
                try:
                    value = json.loads(row[0])
                    self.cache[key] = value
                    return value
                except json.JSONDecodeError:
                    return row[0]
    except Exception:
        self.logger.exception("Error reading %s", key)
    return default

scan(prefix)

Scan for keys with a given prefix.

Parameters:

Name Type Description Default
prefix str

The prefix to scan for.

required

Returns:

Type Description
list

A list of keys that start with the prefix.

Source code in lxmfy/storage.py
def scan(self, prefix: str) -> list:
    """Scan for keys with a given prefix.

    Args:
        prefix: The prefix to scan for.

    Returns:
        A list of keys that start with the prefix.

    """
    try:
        with sqlite3.connect(self.database_path) as conn:
            cursor = conn.execute(
                "SELECT key FROM key_value WHERE key LIKE ? ORDER BY key",
                (f"{prefix}%",),
            )
            return [row[0] for row in cursor.fetchall()]
    except Exception:
        self.logger.exception("Error scanning with prefix %s", prefix)
        return []

set(key, value)

Store a value in storage.

Parameters:

Name Type Description Default
key str

The key to store the value under.

required
value Any

The value to store.

required
Source code in lxmfy/storage.py
def set(self, key: str, value: Any) -> None:
    """Store a value in storage.

    Args:
        key: The key to store the value under.
        value: The value to store.

    """
    try:
        if isinstance(value, (dict, list)):
            serialized = json.dumps(value)
        else:
            serialized = str(value)

        with sqlite3.connect(self.database_path) as conn:
            conn.execute(
                """
                INSERT OR REPLACE INTO key_value (key, value, type, updated_at)
                VALUES (?, ?, ?, CURRENT_TIMESTAMP)
            """,
                (key, serialized, type(value).__name__),
            )
        self.cache[key] = value
    except Exception:
        self.logger.exception("Error writing %s", key)
        raise