Skip to content

核心组件

LXMFBot

主机器人类,负责消息路由、命令处理和机器人生命周期管理。

from lxmfy import LXMFBot

bot = LXMFBot(
    name="MyBot",
    command_prefix="/",
    admins=set(),
    config_path=None,                 # 默认为工作目录下的 "config"
    reticulum_config_dir=None,        # 或 LXMFY_RETICULUM_CONFIG_DIR / "~/.reticulum"
    test_mode=False,                  # 跳过 RNS 启动,用于测试
    log_level="INFO",                 # lxmfy 日志级别,None 则不动日志配置
    loglevel=None,                    # RNS 日志级别 0-7,None 使用 reticulum 配置

    # Announce
    announce=600,
    announce_immediately=True,
    announce_enabled=True,
    announce_display_name_file=None,  # config_path 下覆盖 announced 显示名的文件
                                      # (默认文件: bot_display_name.txt)

    # 反垃圾保护
    rate_limit=5,
    cooldown=60,
    max_warnings=3,
    warning_timeout=300,

    # Cogs
    cogs_dir="cogs",
    cogs_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,
    hot_reloading=False,

    # 存储, 事件, 权限
    storage_type="json",              # "json", "sqlite", "msgpack" 或 "memory"
    storage_path="data",
    permissions_enabled=False,
    first_message_enabled=True,
    event_logging_enabled=True,
    max_logged_events=1000,
    event_middleware_enabled=True,

    # 安全
    signature_verification_enabled=False,
    require_message_signatures=False,
    require_stamps=False,             # 拒绝 stamp 无效的消息
    request_unknown_identities=False, # 向网络请求发送者身份
    stamp_cost=None,                  # 入站 stamp 成本, None 表示关闭
    include_tickets=True,             # 为出站消息附带回复 ticket
    identity_pinning_enabled=False,
    landlock_enabled=True,

    # 可选功能
    nlp_enabled=False,
    nlp_threshold=0.5,
    link_support_enabled=False,
    lxmf_commands_enabled=True,

    # 投递
    message_persistence_enabled=True,
    message_queue_size=50,
    opportunistic_sending=True,
    direct_delivery_retries=3,
    propagation_fallback_enabled=True,
    propagation_node=None,            # 出站传播节点的 hash
    autopeer_propagation=False,       # 从 announce 中发现传播节点
    autopeer_maxdepth=4,              # 自动发现的最大 hop 深度, None = 不限
    enable_propagation_node=False,    # 将本 bot 作为传播节点运行
    message_storage_limit_mb=500,     # 节点存储上限, 仅节点模式

    # 延迟发送
    pending_sends_enabled=True,       # 为未知目的地保留发送
    pending_sends_max=200,
    pending_sends_ttl=604800,         # 7 天
    pending_sends_retry=300,          # 重试扫描间隔秒数

    # RRC
    rrc_enabled=False,
    rrc_hubs=[],
    rrc_rooms=[],
    rrc_nick=None,
    rrc_dest_name="rrc.hub",
    rrc_auto_reconnect=True,
    rrc_persist_sessions=True,
)

所有这些选项都是 BotConfig 的字段。LXMFBot(**kwargs) 会透传 每个关键字参数, 因此 bot.config 包含解析后的值。

主要方法

  • run(delay=10): 启动 bot 的主循环
  • cleanup(): 持久化队列, 取消对话, 关闭调度器, 路由器和 RNS。 退出 run() 时自动调用。
  • send(destination, message, title="Reply", lxmf_fields=None, stamp_cost=None, opportunistic=None, method=None, include_ticket=None, defer=None, reply_to=None, quote=None, thread=None): 向目的地发送消息。stamp_cost 覆盖此消息的出站成本, opportunistic 覆盖 opportunistic_sending, include_ticket 覆盖 include_tickets, defer 覆盖 pending_sends_enabled。 reply_to, quote 和 thread 设置回复 threading 字段。
  • send_with_attachment(destination, message, attachment, title="Reply", stamp_cost=None, opportunistic=None): 发送带附件的消息
  • command(name, description="No description provided", admin_only=False, permissions=None, usage=None, examples=None, category=None, aliases=None, threaded=False, rate_limit=None): 注册命令的装饰器。permissions 覆盖 DefaultPerms 门槛 (admin_only 时为 ALL, 否则为 USE_COMMANDS), threaded 在工作线程中执行回调, rate_limit 限制每个发送者在 cooldown 窗口内的调用次数, usage, examples, category, aliases 提供给帮助系统。命令支持类型注解参数的自动转换。
  • intent(name, examples): 注册 NLP intent 处理器的装饰器。
  • nlp.export_model(): 导出训练好的 NLP 模型数据。
  • nlp.import_model(model_data): 导入先前导出的 NLP 模型数据。
  • request_link(destination_hash, callback=None, app_name="lxmf", *aspects): 向目的地请求 RNS link。允许自定义 app_name 和 aspects (默认为 "lxmf" 和 "delivery")。
  • on_link(callback): 注册入站 RNS link 的处理器。
  • load_extension(name): 按名称加载 cog 扩展模块 (例如 "cogs.utility")。
  • reload_extension(name): 重新加载 cog 扩展模块。
  • add_cog(cog_instance): 向 bot 添加 cog 类实例。
  • remove_cog(cog_name): 按类名移除 cog。
  • on_first_message(): 处理用户首条消息的装饰器
  • on_message(): 处理所有消息的装饰器 (在命令处理之前调用)
  • received(function): 注册一个回调, 对每条走完流水线但未被 命令或 intent 消费的入站消息, 以消息上下文调用
  • on_reaction(): 处理入站回应的装饰器。处理器接收 (sender, reaction), 其中 reaction 带有 reaction_to, reaction_emoji 和 reaction_sender 键
  • react(destination, message_hash, reaction): 通过 LXMF 字段 FIELD_REACTION 对消息发送回应
  • validate(): 对 bot 配置执行校验检查
  • get_landlock_status(): 返回 bot 进程 Landlock LSM 沙箱的 可用性与启用状态
  • diagnose_destination(destination, request_path=False, wait=0.0): 探测目标 hash 的身份与路径状态
  • diagnose_connectivity(destination=None, request_path=False, wait=0.0): 执行完整 doctor 报告并以 dict 返回
  • get_debugger(): 返回绑定到本 bot 的 Debugger
  • set_propagation_node(node_hash): 固定出站传播节点
  • get_propagation_node_status(): 已配置, 已发现以及当前使用的 出站传播节点状态
  • set_message_storage_limit(megabytes): 作为传播节点运行时的 存储上限
  • get_propagation_storage_stats(): 节点存储用量, 或解释不可用 原因的 dict
  • connect_rrc(hub_hash, rooms=None, nick=None, dest_name=None, auto_reconnect=None): 以客户端身份连接 RRC hub
  • disconnect_rrc(hub_hash=None): 断开一个或全部 RRC hub 会话
  • on_rrc(callback=None): RRC 事件的装饰器或处理器注册 (handler(event, client, payload))
  • on_delivery_event(callback=None): 订阅出站投递事件流, 可作 装饰器或直接调用

属性

  • config: 解析后的 BotConfig
  • commands, cogs: 命令与 cog 注册表
  • storage: 当前存储后端
  • scheduler: 用于类 cron 任务的 TaskScheduler
  • events: 事件处理器与 dispatch 的 EventManager
  • middleware: 命令中间件的 MiddlewareManager
  • permissions: 角色与标志位的 PermissionManager
  • spam_protection: 速率限制, 警告和封禁的 SpamProtection
  • signature_manager: 签名策略层
  • nlp: intent 分类器 (仅在 nlp_enabled 时匹配)
  • delivery: DeliveryTracker, 出站事件流
  • conversations: msg.ask 问题的 ConversationManager
  • rrc: 多 hub 会话的 RRCManager, 在启用 RRC 或运行 connect_rrc() 之前为 None
  • local: bot 的 RNS.Destination (其 LXMF 地址为 bot.local.hash)

Announce 显示名

对端看到的名字来自 name, 但 announce 存在两个覆盖途径。若设置 了 announce_display_name_file 且该文件存在于 config_path 下, 其内容优先。否则, 若存在 config_path 下的 bot_display_name.txt 则读取它。两种情况下, 赋值 bot.name = "New Name" 都会在运行时重新同步 announced 显示名。

这样运营者可以不改代码就重命名 bot, 且 announced 名字可以与 配置中的内部名不同。

通过 LXMF 字段实现结构化命令

机器人可以接收通过 LXMF FIELD_COMMANDS(0x09)发送的命令, 并自动以 FIELD_RESULTS(0x0A)回复。这让结构化请求/响应 工作流可以与普通文本命令并存。

传入的 FIELD_COMMANDS 会被解析并路由到与文本命令相同的命令 注册表,共享权限检查、类型注解参数解析、线程化和中间件。

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 包含原始 LXMF 字段字典
    # 如果命令带有 request_id,ctx.request_id 会自动设置
    ctx.reply("Bot is online")

# 从另一个 LXMF 客户端发送结构化命令:
# lxm.fields[FIELD_COMMANDS] = {"command": "status", "args": [], "request_id": "abc123"}
# router.handle_outbound(lxm)

# 机器人的回复会自动包含 FIELD_RESULTS,其中有响应内容和 request_id。

要禁用字段命令处理,在 BotConfig 中设置 lxmf_commands_enabled=False。

回应

回应以 LXMF FIELD_REACTION(0x40)字段的形式承载在一条 其余部分为空的消息上。pack_reaction 和 unpack_reaction 用于构造和解析该字段。

from lxmfy import pack_reaction, unpack_reaction

# 对一条消息发送回应
bot.react(destination_hash, message_hash_hex, "thumbs up emoji")

# 接收回应
@bot.on_reaction()
def on_reaction(sender, reaction):
    # reaction["reaction_to"]  - 目标消息的十六进制哈希
    # reaction["reaction_emoji"] - 回应文本(最多 16 个字符)
    # reaction["reaction_sender"] - 发送者
    print(f"{sender} reacted {reaction['reaction_emoji']} to {reaction['reaction_to']}")
    return True

回应文本上限为 16 个可打印字符。原始字段仍可在 ctx.fields 和 msg.fields 中访问以保持兼容。

回复 Threading

回复可以携带 LXMF 字段 FIELD_REPLY_TO (0x30), FIELD_REPLY_QUOTE (0x31) 和 FIELD_THREAD (0x08)。渲染 thread 的客户端, 如 MeshChatX 和 Sideband, 会将其显示为真正的 引用回复, 而不是平铺消息。

msg.reply() 自动 threading: 将 FIELD_REPLY_TO 设为入站消息的 hash, FIELD_THREAD 设为会话根。

@bot.command("status")
def status(msg):
    msg.reply("all systems nominal")          # threaded 回复
    msg.reply("flat", reply_to=None)          # 退出 threading
    msg.reply("noted", quote=True)            # 引用入站文本

对于并非回复的发送, 显式传入字段:

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

入站回复会解析到消息上下文上:

@bot.command("ctx")
def ctx_cmd(msg):
    msg.reply_to      # 此消息回复的 hex hash, 或 None
    msg.reply_quote   # 回复携带的引用文本, 或 None
    msg.thread        # thread 根的 hex hash, 或 None

pack_reply(message_hash, quote=..., thread=...) 和 unpack_reply(fields) 已导出, 供手动处理字段使用。

对话

命令可以向发送者提问, 并把其下一条消息当作回答, 而不是作为 命令分发:

@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=...) 阻塞处理器, 直到 回答到达, 超时触发或对话被取消。它返回一个 Answer, 带有 content, fields, hash, sender 和 reply(text) 快捷方法。

验证器会拒绝不合格的回答并重新提问:

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

在异步命令处理器中使用 await msg.ask_async(...)。对于较长的 等待, 或可能同时存在大量对话的情况, 使用回调风格, 避免线程 被挂起:

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

注意:

  • 在问题等待期间发送已注册的命令会取消该问题并执行命令。 用户始终有退出途径。
  • bot.conversations.pending_count() 和 bot.conversations.cancel(sender) 为诊断和管理工具开放了 注册表。
  • 注册表上限为 1024 个待回答问题。满了之后 ask 返回 None。
  • 阻塞式 ask 会挂起处理该消息的投递线程。对直接投递是安全 的, 但从传播节点同步大批量消息的 bot 应优先使用 on_answer 回调。

存储

框架提供三种存储后端:

JSONStorage

from lxmfy import JSONStorage

storage = JSONStorage("data")

SQLiteStorage

from lxmfy import SQLiteStorage

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

MsgPackStorage

from lxmfy.storage import MsgPackStorage

storage = MsgPackStorage("data") # {key}.msgpack files

MemoryStorage

from lxmfy.storage import MemoryStorage

storage = MemoryStorage() # 完全在内存中

命令

命令注册和处理:

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

帮助元数据和访问控制来自装饰器的额外 kwargs:

from lxmfy import DefaultPerms

@bot.command(
    name="purge",
    description="Clear stored data",
    permissions=DefaultPerms.MANAGE_MESSAGES,
    usage="/purge <key>",
    examples=["/purge cache"],
    category="Admin",
    aliases=["clear"],
)
def purge(ctx, key: str):
    bot.storage.delete(key)
    ctx.reply(f"Deleted {key}")

permissions 覆盖默认门槛: 普通命令为 USE_COMMANDS, admin_only 为 ALL。category 在 /help 输出中对命令分组。 aliases 只是帮助元数据: 别名会展示给用户, 但不会注册到分发, 所以 /clear 不会执行 purge, 除非你再注册一个同名命令。

类型注解参数

命令会根据回调函数中的类型注解自动解析和转换参数。

@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}")

单命令速率限制

限制同一发送者在全局 cooldown 窗口内调用某命令的次数。达到 上限时仅拒绝本次调用, 不会产生警告或封禁。

@bot.command(name="report", rate_limit=3)
def report(ctx):
    # 每个发送者每个 cooldown 周期可调用 3 次
    ...

与全局速率限制一样需要 permissions_enabled=True。拥有 admin 角色或 BYPASS_SPAM 的用户会跳过检查。

反垃圾保护

bot.spam_protection 执行全局限制: 发送者在每个 cooldown 窗口内最多发送 rate_limit 条消息。超限会增加一次警告并拒绝 该消息。达到 max_warnings 后发送者被封禁。警告在 warning_timeout 秒无违规后过期。

垃圾检查在 message_received 事件内运行, 需要 permissions_enabled=True。

bot = LXMFBot(
    name="GuardedBot",
    permissions_enabled=True,
    rate_limit=5,        # 每个 cooldown 窗口的消息数
    cooldown=60,         # 窗口时长 (秒)
    max_warnings=3,      # 封禁前的警告次数
    warning_timeout=300, # 警告重置前的秒数
)

# 手动解除封禁
bot.spam_protection.unban(sender_hash)

警告, 封禁和计数器持久化在配置的存储后端中, 因此封禁在重启后 仍然有效。拥有 BYPASS_SPAM 的发送者永远不会被限速或封禁。 @bot.command 上的单命令 rate_limit 更温和: 仅拒绝调用, 从 不警告或封禁。

帮助系统

框架内置交互式帮助生成器,基于 Cog 和 Command 元数据生成 美观、分类的帮助菜单。

# help 命令会自动注册。
# 用户可以使用 '/help' 或 '/help <command>'

线程化命令

对于不直接与 Reticulum Network Stack 交互的耗时或阻塞操作, 可以让命令在独立线程中运行,保持机器人响应性。

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) # 这在独立线程中运行
    ctx.reply("Long task completed!")

线程安全

标记为 threaded=True 的函数不得直接与 Reticulum Network Stack(RNS)或任何依赖 lxmfy.transport.py 的组件 交互,因为它们通常不是线程安全的。在线程化命令中向用户 回发消息请使用 ctx.reply()。

事件

用于处理各类 bot 事件的事件系统:

@bot.events.on("message_received", EventPriority.HIGHEST)
def handle_message(event):
    # event.data 携带负载, 如 sender 和 message
    event.cancel()  # 停止后续处理器和其余处理

处理器按 EventPriority 顺序运行: HIGHEST, HIGH, NORMAL, LOW。垃圾检查本身是一个 HIGHEST 的 message_received 处理器, 因此取消该事件就是限速器丢弃消息的机制。

分发你自己的事件:

from lxmfy import Event

bot.events.dispatch(Event("order_placed", data={"user": ctx.sender}))

event_logging_enabled, max_logged_events 和 event_middleware_enabled 存在于 BotConfig, 但未接线: 事件不 会写入存储, bot.events.use() 是空实现。请将其视为保留项。

测试

lxmfy.testing.TestBot 是为测试预配置的 LXMFBot。不启动任何 Reticulum 实例。入站消息会走真实的接收流水线 (中间件, 垃圾检 查, 权限, 分发), 出站发送被捕获以供断言。

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=...) 注入一条消息并返回产生的 SentMessage 对象。发送者是命名 的: "alice" 映射到一个稳定的假 hash, 或直接传入 hex 目标 hash。
  • bot.drain() 取出排队的出站消息。bot.outbox 累积所有已发 送内容。bot.last_sent(sender=...) 获取最近一条。
  • bot.wait_sent(n, timeout=...) 等待线程化命令。
  • bot.receive_later(content, sender=..., delay=...) 从 daemon 线程回应阻塞式 msg.ask 调用。
  • fake_message(content, source_hash=..., ...) 构造一条入站消息, 用于直接驱动 bot._message_received。

SentMessage 封装每条捕获的出站消息: destination (hex), content, title, fields, method, include_ticket, stamp_cost 和 raw (底层对象)。

仓库的测试套件还包括可靠性和压力场景。使用仓库的测试运行器 执行。

高级可靠性测试套件

框架包含一套针对严苛环境的大规模自动化测试:

  • Manifold Testing:校验 NLP 意图向量空间的数学拓扑。
  • Chaos Engineering:模拟位衰减、SD 卡故障和存储损坏。
  • Temporal Drift:验证对系统时钟跳变(±1 年)的抵抗力。
  • Leak Detection:长期跟踪内存、文件描述符和线程。

权限

用于控制机器人功能访问的权限系统:

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")

通过 permissions_enabled=True 启用。DefaultPerms 标志位:

  • 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: 事件系统
  • NONE, ALL: 快捷方式

bot.permissions 管理角色与指派:

bot.permissions.create_role("moderator", DefaultPerms.MANAGE_MESSAGES | DefaultPerms.BYPASS_SPAM)
bot.permissions.assign_role(user_hash, "moderator")
bot.permissions.remove_role(user_hash, "moderator")
bot.permissions.has_permission(user_hash, DefaultPerms.USE_COMMANDS)

角色与指派持久化在配置的存储后端中。存在两个不可删除的内置 角色: user (默认) 和 admin, 后者自动授予 admins 中的每个 hash。

中间件

用于处理消息和事件的中间件系统:

from lxmfy import MiddlewareType

@bot.middleware.register(MiddlewareType.PRE_COMMAND)
def pre_command_middleware(ctx):
    # ctx 封装消息上下文, ctx.cancelled 会丢弃它
    if "spamword" in ctx.data.content:
        ctx.cancel()

流水线中有三个点会执行中间件:

  • PRE_COMMAND: 在命令分发之前, 垃圾检查之后。若链条返回 None, 消息被完全丢弃。
  • POST_COMMAND: 在命令回调之后 (包括线程化命令, 它们在工作 线程中触发)。
  • PRE_EVENT: 在 message_received 事件分发之前。

POST_EVENT, REQUEST 和 RESPONSE 存在于 MiddlewareType, 但目前流水线中没有任何环节执行它们。

附件

支持发送文件、图片和音频:

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)

图标外观(LXMF 字段)

你可以为机器人设置自定义图标,兼容的 LXMF 客户端可以显示它。 这使用 LXMF.FIELD_ICON_APPEARANCE。

from lxmfy import IconAppearance, pack_icon_appearance_field
import LXMF # 需要 LXMF.FIELD_ICON_APPEARANCE

# 定义图标外观
icon_data = IconAppearance(
    icon_name="smart_toy",  # 来自 Material Symbols 的名称
    fg_color=b'\xFF\xFF\xFF',  # 白色前景(3 字节)
    bg_color=b'\x4A\x90\xE2'   # 蓝色背景(3 字节)
)

# 打包成 LXMF 字段格式
icon_lxmf_field = pack_icon_appearance_field(icon_data)

# 发送带此图标的消息
bot.send(
    destination_hash_str,
    "Hello from your friendly bot!",
    title="Bot Message",
    lxmf_fields=icon_lxmf_field
)

# 也可以与其他字段组合,例如附件:
# 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)

调度器

任务调度系统:

@bot.scheduler.schedule(name="daily_task", cron_expr="0 0 * * *")
def daily_task():
    # 每天午夜运行
    pass

签名

LXMFy 为 LXMF 内置的加密消息签名与校验提供配置选项:

from lxmfy import LXMFBot

bot = LXMFBot(
    name="SecureBot",
    signature_verification_enabled=True,  # 启用签名检查
    require_message_signatures=False      # 设为 True 可拒绝未签名消息
)

签名处理

LXMF 使用 RNS 身份自动处理所有加密签名和校验。LXMFy 的 SignatureManager 是一个配置层,负责:

  • 控制是否强制执行签名校验
  • 确定对未签名消息的策略(接受或拒绝)
  • 与权限系统集成(例如可信用户可跳过校验)

实际的加密操作由 LXMF/RNS 执行,而不是 LXMFy。

Landlock LSM 沙箱

在支持 Landlock 的 Linux 内核(5.13+)上,LXMFy 可以限制 机器人进程和外部脚本 Cog 的文件系统访问。

机器人进程沙箱

当 landlock_enabled=True(默认)且未运行于 test_mode 时, 机器人会在初始化期间调用 apply_landlock_sandbox()。系统目录 为只读;机器人存储、配置、Cog、Reticulum 配置和临时路径保持 可写。

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

status = bot.get_landlock_status()
# status 的键:landlock_kernel_supported、landlock_requested、
# landlock_auto_enabled、landlock_disabled_by_env、landlock_active

环境变量覆盖

  • LXMFY_LANDLOCK=0:即使内核支持也禁用 Landlock
  • LXMFY_LANDLOCK=1:在 Linux 上无论自动检测结果如何都尝试 Landlock
  • 未设置:遵循 landlock_enabled 和内核自动检测

外部 Cog 沙箱

脚本 Cog 使用 external_cogs_sandbox_type。在 auto 模式下 优先使用 Landlock(若可用),因为它不需要外部工具。完整的 沙箱选项列表见创建机器人指南。

身份固定

LXMFy 支持可选的身份固定,防止身份被轮换或泄露后遭到冒充。 启用后,机器人会把一个 LXMF 地址“固定”到首次见到的公钥。

bot = LXMFBot(
    identity_pinning_enabled=True
)

SignatureManager 方法

当 signature_verification_enabled=True 时,可通过 bot.signature_manager 访问 SignatureManager:

  • should_verify_message(sender):判断来自给定发送者的消息 是否应校验
  • handle_unsigned_message(sender, message_hash):按策略处理 缺少有效签名的消息

LXMF 签名的工作原理

LXMF 在 pack() 操作期间使用发送者的 RNS 身份自动为所有出站 消息签名。接收消息时,LXMF 校验签名并提供:

  • message.signature_validated:布尔值,表示签名是否有效
  • message.unverified_reason:校验失败时的原因码(例如 SIGNATURE_INVALID、SOURCE_UNKNOWN)

LXMFy 使用这些 LXMF 内置属性来执行机器人的签名策略。

消息投递

LXMFy 提供高级消息投递功能,包括传播节点和自动重试:

传播节点

通过特定传播节点发送消息,提高在 Reticulum 网络上的可靠性:

# 在配置/运行时层面只设置一次传播节点
bot.set_propagation_node("<propagation_node_hash>")

# 按配置好的投递行为发送
bot.send(
    destination_hash,
    "Message content"
)

# 传播节点哈希应该是 Reticulum 网络上
# 一个有效的 LXMF 传播节点

也可以在构造时用 propagation_node="<hash>" 固定节点, 或让 bot 自行发现节点:

bot = LXMFBot(
    name="AutoBot",
    autopeer_propagation=True, # 从 announce 中学习节点
    autopeer_maxdepth=4,       # 忽略深度超过 4 跳的节点
)

bot.get_propagation_node_status() 报告手动节点, 发现的节点以及 当前使用的出站节点。

自动重试

为失败的直接投递配置自动重试次数:

bot = LXMFBot(
    name="ReliableBot",
    direct_delivery_retries=5,  # 直接投递最多重试 5 次
    propagation_fallback_enabled=True
)

bot.send(destination_hash, "Important message")

# direct_delivery_retries 默认为 3
# 重试逻辑会自动处理投递回调

重试系统会跟踪每个目标的投递尝试次数,并自动重试失败的投递。 投递成功后会重置该目标的重试计数器。

延迟发送

向节点尚未听闻其身份的目的地发送通常会直接失败。在 pending_sends_enabled (默认) 下, 消息会被保留在存储中, 并在 目的地 announce 时或周期性扫描时自动发出。

bot = LXMFBot(
    pending_sends_enabled=True,
    pending_sends_max=200,     # 超过此数后最旧的保留消息被丢弃
    pending_sends_ttl=604800,  # 保留消息 7 天后过期
    pending_sends_retry=300,   # run() 中扫描间隔秒数
)

# 单次发送覆盖
bot.send(dest, "hold this", defer=True)
bot.send(dest, "send or drop", defer=False)

被保留的发送在管理命令 /queue 中显示为 held (unknown peers), 并在投递跟踪器中产生 deferred 事件。

消息持久化

出站消息可以持久化到磁盘,确保机器人重启后仍能送达。持久化 默认开启。内存出站队列有上限(message_queue_size,默认 50), 满了之后会丢弃最旧的消息。无效的目标哈希不会被恢复。

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

Stamps 与 tickets

Stamp 成本让发送者在消息被接受前支付 proof-of-work, 从而抑制 不受欢迎的流量。LXMFy 暴露了该机制的两端。

bot = LXMFBot(
    stamp_cost=16,          # 要求此入站 stamp 成本
    require_stamps=True,    # 拒绝 stamp 无效的消息
    include_tickets=True,   # 允许对端回复而无需生成 stamp
)
  • stamp_cost 是入站要求。发送的出站成本仍来自对端的 announce, 除非向 bot.send() 传入 stamp_cost=。
  • include_tickets (默认 True) 为出站消息附带回复 ticket, 使 要求 stamp 的对端可以免费回复。可用 include_ticket= 按发送 覆盖。
  • request_unknown_identities=True 在收到未知来源的消息时向 网络请求发送者身份, 帮助 stamp 和签名校验解析身份而不是盲目 失败。

运行时控制在 《路由器控制》 一节: set_inbound_stamp_cost, enforce_stamps, ignore_stamps, generate_ticket 以及 ticket 检查方法。

作为传播节点运行

一个 bot 可以同时充当 LXMF 传播节点, 为离线的对端存储消息:

bot = LXMFBot(
    enable_propagation_node=True,
    message_storage_limit_mb=500,
)

bot.set_message_storage_limit(750)
stats = bot.get_propagation_storage_stats()

get_propagation_node_status() 对两种角色都有效: 报告本 bot 使用的出站节点以及它自身是否作为节点服务。相关控制: announce_propagation_node() 对外宣传该节点, set_retain_on_node() 在节点上保留已投递消息, allow_control_identity() / disallow_control_identity() 管理 哪些身份可以使用节点的控制通道。

投递事件

bot.delivery 记录一个有界的出站生命周期事件流, 无需读日志 即可观察消息流。阶段: queued, deferred, dispatched, delivered, failed, cancelled, dropped。最近的一段会持久 化到存储并在启动时恢复。

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

# 或直接检查
recent = bot.delivery.recent(20)
failures = bot.delivery.recent(stage="failed")
to_peer = bot.delivery.recent(destination="aa11bb...")

每个事件是一个 dict, 含 ts, stage 以及可选的 destination, message_id, hash, method, attempts, reason, title。

管理员有 /delivery [limit] 命令, 可在聊天中渲染同一时间线, lxmfy debug 在发送流水线检查中显示投递时间线摘要。

内置管理命令

这些命令自动注册, 并在启用权限时要求发送者在 admins 中:

命令 作用
/queue 显示路由器出站队列, 内部队列和保留的发送
/cancel <id|all> 取消待投递的出站消息
/inbox [cancel <hash|all>] 列出或取消活跃的入站传输
/delivery [n] 显示最近 n 条投递事件 (默认 15, 最多 50)
/loadext <name> 加载一个 cog 扩展
/reloadext <name> 重新加载已加载的 cog 扩展

路由器控制

对底层 LXMRouter 的轻封装, 覆盖发送者控制, ticket, 出站队列 管理和传播节点同步。所有方法接受 hex 字符串形式的目标 hash, 在路由器未运行时 (例如 test_mode) 返回 False。

发送者控制 (入站)

  • ignore_destination(destination) / unignore_destination(destination) / is_ignored(destination): 丢弃来自某发送者的入站消息
  • allow_destination(destination) / disallow_destination(destination): 路由器处于 allow-list 模式时的白名单管理
  • prioritise_destination(destination) / unprioritise_destination(destination): 优先发送者名单
  • set_inbound_stamp_cost(stamp_cost): 对入站消息要求 stamp 成本 (None 清除)
  • enforce_stamps() / ignore_stamps(): 入站 stamp 强制开关

Tickets

  • generate_ticket(destination, expiry=None): 为发送者签发入站 stamp ticket
  • get_inbound_tickets(destination): 为发送者持有的 ticket
  • get_outbound_ticket(destination) / get_outbound_ticket_expiry(destination) / get_outbound_stamp_cost(destination): 从网络学到的出站 ticket 状态

出站队列

  • outbound_queue(): 待投递出站消息的快照
  • get_outbound_progress(lxm_hash): 某消息 hash 的投递进度或 None
  • cancel_outbound(message_id): 在投递前移除排队消息
  • delivery_link_available(destination): 是否存在指向目标的活跃 RNS link

入站队列

  • has_message(message_hash): 入站 LXM hash 是否已投递
  • inbound_count(): 进行中的活跃入站资源传输数
  • inbound_transfers(): 每个传输的快照, 含 hash, 大小, 进度和 状态
  • cancel_inbound(resource_hash): 中止一个活跃的入站传输
  • cancel_all_inbound(): 中止所有活跃的入站传输, 返回取消数量

对端发现

本节点听闻过的目的地的 announce 元数据:

  • get_peer_app_data(destination): 原始 announced app_data 字节
  • get_peer_lxmf_data(destination): 解码后的 LXMF announce 元 数据 (display_name, stamp_cost, capabilities), 对端未 announce 有效 LXMF 数据时为 None
  • get_peer_announce(destination): 完整 announce 记录, 含 hops, received_at, interface 和 app_data
  • list_peer_announces(limit=100): 所有听闻的 announce, 最新者 在前

传播

  • sync_propagation_node(max_messages=None): 从配置的传播节点拉 取消息
  • cancel_propagation_sync(): 停止进行中的同步
  • get_propagation_stats(): 节点传输状态与限制, 或 None
  • set_retain_on_node(retain): 在节点上保留已投递消息
  • announce_propagation_node(): 将本节点 announce 为传播节点
  • allow_control_identity(destination) / disallow_control_identity(destination): 传播控制通道白名单

导入

  • ingest_lxm_uri(uri): 将 lxm:// URI 消息导入入站队列

诊断

当消息不通时, 调试器会检查整条链路而不是靠猜: Reticulum 配置, 共享实例状态, 接口, 身份, announce 行为, 投递配置和发送流水 线。

lxmfy debug                          # 完整 doctor 报告, 保存到文件
lxmfy debug probe <hash> --request-path --wait 30
lxmfy debug send <hash>              # 跟踪一次测试发送
lxmfy debug receive                  # 检查接收就绪状态
lxmfy debug compare <hash_a> <hash_b>
lxmfy debug tips                     # 常见故障的修复建议

报告默认做了隐私脱敏: home 路径和 hash 会被截断。--json 输出 机器可读结果, -o FILE 写出报告, --no-save 跳过文件, --no-privacy 保留完整值供本地使用, --no-color 或 NO_COLOR 关闭 ANSI 输出。

同样的检查也可以在代码中调用:

report = bot.diagnose_connectivity()            # doctor 报告的 dict
probe = bot.diagnose_destination(               # 身份与路径探测
    "<peer_hash>", request_path=True, wait=30,
)

debugger = bot.get_debugger()                   # 完整 API
checks = debugger.check_send_pipeline()
verdict = debugger.run_doctor(destination)

lxmfy/debugger.py 还导出了独立的 diagnose_destination(hash, ...) 帮助函数, 以及报告类型 CheckResult, DestinationProbe, DoctorReport 和 MessageDebugger, 供自建工具使用。

消息处理器

LXMFy 提供用于处理不同类型传入消息的装饰器:

首条消息处理器

处理每个用户的首条消息:

@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  # 返回 True 停止进一步处理

通用消息处理器

在命令处理之前处理所有传入消息:

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

    # 此处为自定义逻辑
    if content.startswith("echo:"):
        bot.send(sender, content[5:])
        return True  # 停止进一步处理

    return False  # 继续到命令处理

消息处理器按以下顺序调用:1. 首条消息处理器(如果这是该 发送者的首条消息)2. 通用消息处理器(通过 on_message() 注册)3. 命令处理(如果消息以命令前缀开头)

兜底回调

bot.received(fn) 注册一个回调, 在流水线末尾处理未被任何其他 环节消费的消息: 没有首条消息处理器, 没有返回 True 的 on_message 处理器, 也没有匹配的命令或 NLP intent。回调收到 与命令相同的消息上下文, 包括 msg.sender, msg.content, msg.reply() 等。

@bot.received
def fallback(msg):
    msg.reply("Sorry, I did not understand that.")

可作为自由文本输入的兜底处理。

Reticulum Relay Chat (RRC)

机器人可以通过 RNS Link 以 CBOR 封包加入 RRC hub。包:lxmfy.rrc。

BotConfig 选项

  • rrc_enabled(bool,默认 False):启动时连接已配置的 hub
  • rrc_hubs(十六进制哈希列表):hub 目标哈希
  • rrc_rooms(str 列表):WELCOME 之后自动加入的房间
  • rrc_nick(str 或 None):HELLO 和房间消息中的昵称
  • rrc_dest_name(str,默认 "rrc.hub"):用于构造 hub 目标的目标名称
  • rrc_auto_reconnect(bool,默认 True):link 断开后自动 重连
  • rrc_persist_sessions(bool,默认 True):跨重启持久化 hub 和房间
  • reticulum_config_dir(str 或 None):Reticulum 配置目录。 也可用 LXMFY_RETICULUM_CONFIG_DIR 设置。使用与 MeshChatX 相同的配置(通常是 ~/.reticulum),这样 hub 的 announce 才可见。

示例

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}")

# 运行时 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()

导出类型

  • RRCClient:单 hub 会话
  • RRCManager:多 hub 管理器(bot.rrc)
  • RRCMessage:房间事件负载(kind、room、text、 nick、src、mention 等)
  • RRC_VERSION:线路协议版本常量
  • DEFAULT_DEST_NAME: 默认 hub 目标名 ("rrc.hub")
  • make_envelope, encode_envelope, decode_envelope, validate_envelope, normalize_room: 供直接与 hub 通信的工具 使用的 wire 格式帮助函数

传给 @bot.on_rrc 处理器的常见事件包括 status、welcome、 joined、parted、msg、notice、action、motd、 error 和 rtt。

模板

框架包含若干可直接使用的机器人模板:

EchoBot

简单的 echo 机器人,复述收到的消息:

from lxmfy.templates import EchoBot

bot = EchoBot()
bot.run()

NoteBot

使用 JSON 存储的笔记机器人:

from lxmfy.templates import NoteBot

bot = NoteBot()
bot.run()

ReminderBot

使用 SQLite 存储的提醒机器人:

from lxmfy.templates import ReminderBot

bot = ReminderBot()
bot.run()

RRCBot

RRC 房间机器人,加入配置的 hub 并回复 @提及。默认 hub 为 664fc0e8d2e448658e37bb3f34e6c88f,房间为 #general,并在 可用时使用 ~/.reticulum。

from lxmfy.templates import RRCBot

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

CLI 工具

框架提供了用于 bot 管理的命令行工具。不带参数运行 lxmfy 会 打开交互式菜单。

# 交互式 scaffold 完整项目
lxmfy init mybot                 # 项目目录, bot.py, cogs/, README
lxmfy init --here --yes          # 当前目录, 接受全部默认值

# 创建单个 bot 文件
lxmfy create mybot
lxmfy create --template echo mybot
lxmfy create --template rrc my_rrc_bot
lxmfy create mybot --no-cogs     # 跳过 cogs 包
lxmfy create --output dir/bot.py --name MyBot

# 运行模板 bot
lxmfy run echo
lxmfy run rrc
lxmfy run reminder --name "MyReminder"

# 诊断连通性 (见诊断一节)
lxmfy debug
lxmfy debug probe <hash> --request-path --wait 30

# 用一条消息测试签名校验
lxmfy signatures test

# 启用签名校验
lxmfy signatures enable

# 禁用签名校验
lxmfy signatures disable

lxmfy init 会询问项目名称, 模板, 存储后端, 命令前缀和 admin hash。每个问题都可以改用 flag: --dir, --bot-name, --template, --storage, --prefix, --admins, --no-cogs, --force, --yes。在非 TTY 的 stdin 上则直接采用默认值。

create 和 run 的模板: basic, echo, reminder, note, cogtest, rrc。

错误处理

在 bot.run() 周围捕获关停和运行时故障:

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

模块参考

由源码 docstring 生成。

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
530
531
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 == "msgpack":
            self.storage = Storage(MsgPackStorage(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', 'msgpack', 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 == "msgpack":
        self.storage = Storage(MsgPackStorage(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', 'msgpack', 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", "sqlite", "msgpack", or "memory"). 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", "sqlite", "msgpack", or "memory"). 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})"

A decorator class for bot commands.

This class is used to mark methods as bot commands and provide metadata about the command such as its name, description, and permission requirements.

Attributes:

Name Type Description
name str

The name of the command

description str

A description of what the command does

admin_only bool

Whether the command is restricted to admin users

callback callable

The function that implements the command

Source code in lxmfy/commands.py
class Command:
    """A decorator class for bot commands.

    This class is used to mark methods as bot commands and provide metadata
    about the command such as its name, description, and permission requirements.

    Attributes:
        name (str): The name of the command
        description (str): A description of what the command does
        admin_only (bool): Whether the command is restricted to admin users
        callback (callable): The function that implements the command

    """

    def __init__(
        self,
        name: str,
        description: str = "No description provided",
        admin_only: bool = False,
        permissions: DefaultPerms | None = None,
        usage: str | None = None,
        examples: list[str] | None = None,
        category: str | None = None,
        aliases: list[str] | None = None,
        threaded: bool = False,
        rate_limit: int | None = None,
    ) -> None:
        """Initialize a new Command.

        Args:
            name (str): The name of the command
            description (str, optional): Description of the command. Defaults to "No description provided"
            admin_only (bool, optional): Whether the command requires admin privileges. Defaults to False
            rate_limit (int, optional): Max invocations per sender per
                cooldown window for this command. None disables the
                per-command limit; the global message rate limit still
                applies.

        """
        self.name = name
        self.description = description
        self.admin_only = admin_only
        self.permissions = permissions or (
            DefaultPerms.ALL if admin_only else DefaultPerms.USE_COMMANDS
        )
        self.threaded = threaded
        self.rate_limit = rate_limit
        self.callback = None
        self.help = CommandHelp(
            name=name,
            description=description,
            usage=usage,
            examples=examples or [],
            category=category,
            aliases=aliases or [],
        )

    def __call__(self, func):
        """Decorate a function as a command.

        Args:
            func (callable): The function to be decorated

        Returns:
            callable: The decorated function

        """
        self.callback = func
        func.command = self
        return func

    def __get__(self, obj, objtype=None):
        """Support instance methods in command definitions.

        This method enables the command decorator to work with instance methods
        by properly binding the method to the instance.

        Args:
            obj: The instance that the command is bound to
            objtype: The type of the instance

        Returns:
            Command: A new Command instance bound to the object

        """
        if obj is None:
            return self
        if self.callback is None:
            return self
        new_cmd = self.__class__(
            name=self.name,
            description=self.description,
            admin_only=self.admin_only,
            permissions=self.permissions,
            usage=self.help.usage,
            examples=self.help.examples,
            category=self.help.category,
            aliases=self.help.aliases,
            threaded=self.threaded,
            rate_limit=self.rate_limit,
        )
        new_cmd.callback = self.callback.__get__(obj, objtype)
        return new_cmd

__call__(func)

Decorate a function as a command.

Parameters:

Name Type Description Default
func callable

The function to be decorated

required

Returns:

Name Type Description
callable

The decorated function

Source code in lxmfy/commands.py
def __call__(self, func):
    """Decorate a function as a command.

    Args:
        func (callable): The function to be decorated

    Returns:
        callable: The decorated function

    """
    self.callback = func
    func.command = self
    return func

__get__(obj, objtype=None)

Support instance methods in command definitions.

This method enables the command decorator to work with instance methods by properly binding the method to the instance.

Parameters:

Name Type Description Default
obj

The instance that the command is bound to

required
objtype

The type of the instance

None

Returns:

Name Type Description
Command

A new Command instance bound to the object

Source code in lxmfy/commands.py
def __get__(self, obj, objtype=None):
    """Support instance methods in command definitions.

    This method enables the command decorator to work with instance methods
    by properly binding the method to the instance.

    Args:
        obj: The instance that the command is bound to
        objtype: The type of the instance

    Returns:
        Command: A new Command instance bound to the object

    """
    if obj is None:
        return self
    if self.callback is None:
        return self
    new_cmd = self.__class__(
        name=self.name,
        description=self.description,
        admin_only=self.admin_only,
        permissions=self.permissions,
        usage=self.help.usage,
        examples=self.help.examples,
        category=self.help.category,
        aliases=self.help.aliases,
        threaded=self.threaded,
        rate_limit=self.rate_limit,
    )
    new_cmd.callback = self.callback.__get__(obj, objtype)
    return new_cmd

__init__(name, description='No description provided', admin_only=False, permissions=None, usage=None, examples=None, category=None, aliases=None, threaded=False, rate_limit=None)

Initialize a new Command.

Parameters:

Name Type Description Default
name str

The name of the command

required
description str

Description of the command. Defaults to "No description provided"

'No description provided'
admin_only bool

Whether the command requires admin privileges. Defaults to False

False
rate_limit int

Max invocations per sender per cooldown window for this command. None disables the per-command limit; the global message rate limit still applies.

None
Source code in lxmfy/commands.py
def __init__(
    self,
    name: str,
    description: str = "No description provided",
    admin_only: bool = False,
    permissions: DefaultPerms | None = None,
    usage: str | None = None,
    examples: list[str] | None = None,
    category: str | None = None,
    aliases: list[str] | None = None,
    threaded: bool = False,
    rate_limit: int | None = None,
) -> None:
    """Initialize a new Command.

    Args:
        name (str): The name of the command
        description (str, optional): Description of the command. Defaults to "No description provided"
        admin_only (bool, optional): Whether the command requires admin privileges. Defaults to False
        rate_limit (int, optional): Max invocations per sender per
            cooldown window for this command. None disables the
            per-command limit; the global message rate limit still
            applies.

    """
    self.name = name
    self.description = description
    self.admin_only = admin_only
    self.permissions = permissions or (
        DefaultPerms.ALL if admin_only else DefaultPerms.USE_COMMANDS
    )
    self.threaded = threaded
    self.rate_limit = rate_limit
    self.callback = None
    self.help = CommandHelp(
        name=name,
        description=description,
        usage=usage,
        examples=examples or [],
        category=category,
        aliases=aliases or [],
    )

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

Represents LXMF icon appearance data.

Source code in lxmfy/attachments.py
@dataclass
class IconAppearance:
    """Represents LXMF icon appearance data."""

    icon_name: str
    fg_color: bytes  # Must be 3 bytes, e.g., b'\xff\x00\x00' for red
    bg_color: bytes  # Must be 3 bytes

Data class representing an event.

Attributes:

Name Type Description
name str

The name of the event.

data dict

A dictionary containing event-specific data.

cancelled bool

A flag indicating whether the event has been cancelled.

Source code in lxmfy/events.py
@dataclass(frozen=True)
class Event:
    """Data class representing an event.

    Attributes:
        name (str): The name of the event.
        data (dict): A dictionary containing event-specific data.
        cancelled (bool): A flag indicating whether the event has been cancelled.

    """

    name: str
    data: dict = field(default_factory=dict)
    cancelled: bool = False

    def __hash__(self):
        """Returns the hash value of the event based on its name.

        Returns:
            int: Hash value of the event name.

        """
        return hash(self.name)

    def __eq__(self, other):
        """Compares this event to another object for equality.

        Args:
            other (Any): The object to compare to.

        Returns:
            bool: True if the other object is an Event instance and has the same name, False otherwise.

        """
        if not isinstance(other, Event):
            return False
        return self.name == other.name

    def cancel(self):
        """Cancels the event, preventing further processing."""
        object.__setattr__(self, "cancelled", True)

__eq__(other)

Compares this event to another object for equality.

Parameters:

Name Type Description Default
other Any

The object to compare to.

required

Returns:

Name Type Description
bool

True if the other object is an Event instance and has the same name, False otherwise.

Source code in lxmfy/events.py
def __eq__(self, other):
    """Compares this event to another object for equality.

    Args:
        other (Any): The object to compare to.

    Returns:
        bool: True if the other object is an Event instance and has the same name, False otherwise.

    """
    if not isinstance(other, Event):
        return False
    return self.name == other.name

__hash__()

Returns the hash value of the event based on its name.

Returns:

Name Type Description
int

Hash value of the event name.

Source code in lxmfy/events.py
def __hash__(self):
    """Returns the hash value of the event based on its name.

    Returns:
        int: Hash value of the event name.

    """
    return hash(self.name)

cancel()

Cancels the event, preventing further processing.

Source code in lxmfy/events.py
def cancel(self):
    """Cancels the event, preventing further processing."""
    object.__setattr__(self, "cancelled", True)

Manages event registration, dispatching, and logging.

Source code in lxmfy/events.py
class EventManager:
    """Manages event registration, dispatching, and logging."""

    def __init__(self, storage):
        """Initializes the EventManager.

        Args:
            storage: The storage object used for logging events.

        """
        self.storage = storage
        self.handlers = {}
        self.logger = logging.getLogger(__name__)

    def on(self, event_name: str, priority: EventPriority = EventPriority.NORMAL):
        """Registers an event handler for a specific event.

        Args:
            event_name (str): The name of the event to handle.
            priority (EventPriority): The priority of the event handler (default: EventPriority.NORMAL).

        Returns:
            Callable: A decorator that registers the decorated function as an event handler.

        """

        def decorator(func):
            """Registers the decorated function as an event handler."""
            if event_name not in self.handlers:
                self.handlers[event_name] = []
            self.handlers[event_name].append((priority, func))
            self.handlers[event_name].sort(key=lambda x: x[0].value, reverse=True)
            return func

        return decorator

    def use(self, middleware: Callable):
        """Adds middleware to the event pipeline.

        Args:
            middleware (Callable): The middleware function to add.

        """

    def dispatch(self, event: Event):
        """Dispatches an event to all registered handlers.

        Args:
            event (Event): The event to dispatch.

        """
        try:
            if event.name in self.handlers:
                for _priority, handler in self.handlers[event.name]:
                    try:
                        run_sync(handler, event)
                        if event.cancelled:
                            break
                    except Exception:
                        self.logger.exception(
                            "Error in event handler %s",
                            handler.__name__,
                        )
        except Exception:
            self.logger.exception("Error dispatching event")

    def _log_event(self, event: Event):
        """Logs an event to storage.

        Args:
            event (Event): The event to log.

        """
        try:
            events = self.storage.get("events:log", [])
            events.append(
                {
                    "name": event.name,
                    "data": event.data,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )
            self.storage.set("events:log", events[-1000:])
        except Exception:
            self.logger.exception("Error logging event")

__init__(storage)

Initializes the EventManager.

Parameters:

Name Type Description Default
storage

The storage object used for logging events.

required
Source code in lxmfy/events.py
def __init__(self, storage):
    """Initializes the EventManager.

    Args:
        storage: The storage object used for logging events.

    """
    self.storage = storage
    self.handlers = {}
    self.logger = logging.getLogger(__name__)

dispatch(event)

Dispatches an event to all registered handlers.

Parameters:

Name Type Description Default
event Event

The event to dispatch.

required
Source code in lxmfy/events.py
def dispatch(self, event: Event):
    """Dispatches an event to all registered handlers.

    Args:
        event (Event): The event to dispatch.

    """
    try:
        if event.name in self.handlers:
            for _priority, handler in self.handlers[event.name]:
                try:
                    run_sync(handler, event)
                    if event.cancelled:
                        break
                except Exception:
                    self.logger.exception(
                        "Error in event handler %s",
                        handler.__name__,
                    )
    except Exception:
        self.logger.exception("Error dispatching event")

on(event_name, priority=EventPriority.NORMAL)

Registers an event handler for a specific event.

Parameters:

Name Type Description Default
event_name str

The name of the event to handle.

required
priority EventPriority

The priority of the event handler (default: EventPriority.NORMAL).

NORMAL

Returns:

Name Type Description
Callable

A decorator that registers the decorated function as an event handler.

Source code in lxmfy/events.py
def on(self, event_name: str, priority: EventPriority = EventPriority.NORMAL):
    """Registers an event handler for a specific event.

    Args:
        event_name (str): The name of the event to handle.
        priority (EventPriority): The priority of the event handler (default: EventPriority.NORMAL).

    Returns:
        Callable: A decorator that registers the decorated function as an event handler.

    """

    def decorator(func):
        """Registers the decorated function as an event handler."""
        if event_name not in self.handlers:
            self.handlers[event_name] = []
        self.handlers[event_name].append((priority, func))
        self.handlers[event_name].sort(key=lambda x: x[0].value, reverse=True)
        return func

    return decorator

use(middleware)

Adds middleware to the event pipeline.

Parameters:

Name Type Description Default
middleware Callable

The middleware function to add.

required
Source code in lxmfy/events.py
def use(self, middleware: Callable):
    """Adds middleware to the event pipeline.

    Args:
        middleware (Callable): The middleware function to add.

    """

Bases: Enum

Enumeration of event priority levels.

Members

HIGHEST: Highest priority. HIGH: High priority. NORMAL: Normal priority. LOW: Low priority.

Source code in lxmfy/events.py
class EventPriority(Enum):
    """Enumeration of event priority levels.

    Members:
        HIGHEST: Highest priority.
        HIGH: High priority.
        NORMAL: Normal priority.
        LOW: Low priority.
    """

    HIGHEST = 3
    HIGH = 2
    NORMAL = 1
    LOW = 0

Context passed through middleware chain

Source code in lxmfy/middleware.py
@dataclass
class MiddlewareContext:
    """Context passed through middleware chain"""

    type: MiddlewareType
    data: Any
    metadata: dict = field(default_factory=dict)
    cancelled: bool = False

    def cancel(self):
        """Cancel middleware processing"""
        self.cancelled = True

cancel()

Cancel middleware processing

Source code in lxmfy/middleware.py
def cancel(self):
    """Cancel middleware processing"""
    self.cancelled = True

Manages middleware registration and execution

Source code in lxmfy/middleware.py
class MiddlewareManager:
    """Manages middleware registration and execution"""

    def __init__(self):
        self.middleware: dict[MiddlewareType, list[Callable]] = {
            t: [] for t in MiddlewareType
        }
        self.message_tracker = MessageTracker()
        self.logger = logging.getLogger(__name__)

    def register(self, middleware_type: MiddlewareType, func: Callable | None = None):
        """Register a middleware function"""
        if func is None:
            # Decorator usage: @middleware.register(MiddlewareType.PRE_COMMAND)
            def decorator(f):
                self.middleware[middleware_type].append(f)
                return f

            return decorator
        # Direct usage: middleware.register(MiddlewareType.PRE_COMMAND, func)
        self.middleware[middleware_type].append(func)
        return func

    def remove(self, middleware_type: MiddlewareType, func: Callable):
        """Remove a middleware function"""
        if func in self.middleware[middleware_type]:
            self.middleware[middleware_type].remove(func)

    def execute(self, mw_type: MiddlewareType, data: Any) -> Any:
        """Execute middleware chain for given type"""
        try:
            # If data is already a MiddlewareContext, use it directly
            if isinstance(data, MiddlewareContext):
                ctx = data
            else:
                ctx = MiddlewareContext(mw_type, data)

            for mw in self.middleware.get(mw_type, []):
                try:
                    mw(ctx)
                    if ctx.cancelled:
                        break
                except Exception:
                    self.logger.exception("Error in middleware %s", mw.__name__)

            return None if ctx.cancelled else ctx.data

        except Exception:
            self.logger.exception("Error executing middleware chain")
            return data

execute(mw_type, data)

Execute middleware chain for given type

Source code in lxmfy/middleware.py
def execute(self, mw_type: MiddlewareType, data: Any) -> Any:
    """Execute middleware chain for given type"""
    try:
        # If data is already a MiddlewareContext, use it directly
        if isinstance(data, MiddlewareContext):
            ctx = data
        else:
            ctx = MiddlewareContext(mw_type, data)

        for mw in self.middleware.get(mw_type, []):
            try:
                mw(ctx)
                if ctx.cancelled:
                    break
            except Exception:
                self.logger.exception("Error in middleware %s", mw.__name__)

        return None if ctx.cancelled else ctx.data

    except Exception:
        self.logger.exception("Error executing middleware chain")
        return data

register(middleware_type, func=None)

Register a middleware function

Source code in lxmfy/middleware.py
def register(self, middleware_type: MiddlewareType, func: Callable | None = None):
    """Register a middleware function"""
    if func is None:
        # Decorator usage: @middleware.register(MiddlewareType.PRE_COMMAND)
        def decorator(f):
            self.middleware[middleware_type].append(f)
            return f

        return decorator
    # Direct usage: middleware.register(MiddlewareType.PRE_COMMAND, func)
    self.middleware[middleware_type].append(func)
    return func

remove(middleware_type, func)

Remove a middleware function

Source code in lxmfy/middleware.py
def remove(self, middleware_type: MiddlewareType, func: Callable):
    """Remove a middleware function"""
    if func in self.middleware[middleware_type]:
        self.middleware[middleware_type].remove(func)

Bases: Enum

Types of middleware execution points

Source code in lxmfy/middleware.py
class MiddlewareType(Enum):
    """Types of middleware execution points"""

    PRE_COMMAND = "pre_command"
    POST_COMMAND = "post_command"
    PRE_EVENT = "pre_event"
    POST_EVENT = "post_event"
    REQUEST = "request"
    RESPONSE = "response"

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()},
    )

Role definition with permissions

Source code in lxmfy/permissions.py
@dataclass
class Role:
    """Role definition with permissions"""

    name: str
    permissions: DefaultPerms
    priority: int = 0
    description: str | None = None

Default help formatter for commands.

Source code in lxmfy/help.py
@dataclass
class HelpFormatter:
    """Default help formatter for commands."""

    @staticmethod
    def format_command(command) -> str:
        """Format a single command's help.

        Args:
            command: The command object to format.

        Returns:
            A formatted string containing the command's help information.

        """
        help_text = [
            f"**Command**: `{command.name}`",
            f"**Description**: {command.help.description}",
        ]

        if command.help.aliases:
            help_text.append(
                f"**Aliases**: {', '.join(f'`{a}`' for a in command.help.aliases)}",
            )

        if command.help.usage:
            help_text.append(f"**Usage**: `{command.help.usage}`")

        if command.help.examples:
            help_text.append("**Examples**:")
            help_text.extend(f"  - `{ex}`" for ex in command.help.examples)

        if command.permissions != DefaultPerms.USE_COMMANDS:
            perms = [
                perm.name
                for perm in DefaultPerms
                if (perm.value & command.permissions.value)
                and perm != DefaultPerms.NONE
            ]
            if perms:
                help_text.append("**Required Permissions**:")
                help_text.extend(f"  - {p}" for p in perms)

        if command.admin_only:
            help_text.append("*(Admin only)*")

        return "\n".join(help_text)

    @staticmethod
    def format_category(category: str, commands: list) -> str:
        """Format a category of commands.

        Args:
            category (str): The name of the category.
            commands (list): A list of command objects in the category.

        Returns:
            str: A formatted string containing the category's help information.

        """
        # Deduplicate commands (aliases point to same command)
        unique_cmds = {}
        for cmd in commands:
            unique_cmds[cmd.name] = cmd

        help_text = [f"\n**{category}**"]
        help_text.extend(
            f"  * `{cmd.name}`: {cmd.help.description}"
            for cmd in sorted(unique_cmds.values(), key=lambda x: x.name)
        )
        return "\n".join(help_text)

    @staticmethod
    def format_all_commands(categories: dict[str, list]) -> str:
        """Format the complete help listing.

        Args:
            categories (dict): A dictionary where keys are category names and values are lists of command objects.

        Returns:
            str: A formatted string containing help information for all commands.

        """
        help_text = ["**Bot Help Menu**", "Use `help <command>` for more details."]

        help_text.extend(
            HelpFormatter.format_category(category, categories[category])
            for category in sorted(categories.keys())
        )

        return "\n".join(help_text)

format_all_commands(categories) staticmethod

Format the complete help listing.

Parameters:

Name Type Description Default
categories dict

A dictionary where keys are category names and values are lists of command objects.

required

Returns:

Name Type Description
str str

A formatted string containing help information for all commands.

Source code in lxmfy/help.py
@staticmethod
def format_all_commands(categories: dict[str, list]) -> str:
    """Format the complete help listing.

    Args:
        categories (dict): A dictionary where keys are category names and values are lists of command objects.

    Returns:
        str: A formatted string containing help information for all commands.

    """
    help_text = ["**Bot Help Menu**", "Use `help <command>` for more details."]

    help_text.extend(
        HelpFormatter.format_category(category, categories[category])
        for category in sorted(categories.keys())
    )

    return "\n".join(help_text)

format_category(category, commands) staticmethod

Format a category of commands.

Parameters:

Name Type Description Default
category str

The name of the category.

required
commands list

A list of command objects in the category.

required

Returns:

Name Type Description
str str

A formatted string containing the category's help information.

Source code in lxmfy/help.py
@staticmethod
def format_category(category: str, commands: list) -> str:
    """Format a category of commands.

    Args:
        category (str): The name of the category.
        commands (list): A list of command objects in the category.

    Returns:
        str: A formatted string containing the category's help information.

    """
    # Deduplicate commands (aliases point to same command)
    unique_cmds = {}
    for cmd in commands:
        unique_cmds[cmd.name] = cmd

    help_text = [f"\n**{category}**"]
    help_text.extend(
        f"  * `{cmd.name}`: {cmd.help.description}"
        for cmd in sorted(unique_cmds.values(), key=lambda x: x.name)
    )
    return "\n".join(help_text)

format_command(command) staticmethod

Format a single command's help.

Parameters:

Name Type Description Default
command

The command object to format.

required

Returns:

Type Description
str

A formatted string containing the command's help information.

Source code in lxmfy/help.py
@staticmethod
def format_command(command) -> str:
    """Format a single command's help.

    Args:
        command: The command object to format.

    Returns:
        A formatted string containing the command's help information.

    """
    help_text = [
        f"**Command**: `{command.name}`",
        f"**Description**: {command.help.description}",
    ]

    if command.help.aliases:
        help_text.append(
            f"**Aliases**: {', '.join(f'`{a}`' for a in command.help.aliases)}",
        )

    if command.help.usage:
        help_text.append(f"**Usage**: `{command.help.usage}`")

    if command.help.examples:
        help_text.append("**Examples**:")
        help_text.extend(f"  - `{ex}`" for ex in command.help.examples)

    if command.permissions != DefaultPerms.USE_COMMANDS:
        perms = [
            perm.name
            for perm in DefaultPerms
            if (perm.value & command.permissions.value)
            and perm != DefaultPerms.NONE
        ]
        if perms:
            help_text.append("**Required Permissions**:")
            help_text.extend(f"  - {p}" for p in perms)

    if command.admin_only:
        help_text.append("*(Admin only)*")

    return "\n".join(help_text)

A system for providing help information about available commands.

Source code in lxmfy/help.py
class HelpSystem:
    """A system for providing help information about available commands."""

    def __init__(self, bot: "LXMFBot", formatter: HelpFormatter | None = None):
        """Initialize the HelpSystem.

        Args:
            bot: The bot instance.
            formatter: An optional help formatter.  Defaults to HelpFormatter.

        """
        self.bot = bot
        self.formatter = formatter or HelpFormatter()

        @bot.command(name="help", description="Show help for commands")
        def help_command(ctx):
            """Handle the 'help' command.

            Args:
                ctx: The command context.

            """
            args = ctx.args
            if not args:
                categories = self._get_categorized_commands(ctx.is_admin)
                response = self.formatter.format_all_commands(categories)
                ctx.reply(response)
                return

            command_name = args[0]
            if command_name in self.bot.commands:
                command = self.bot.commands[command_name]
                if command.admin_only and not ctx.is_admin:
                    ctx.reply("This command is for administrators only.")
                    return
                response = self.formatter.format_command(command)
                ctx.reply(response)
                return
            ctx.reply(f"Command '{command_name}' not found.")
            return

    def _get_categorized_commands(self, is_admin: bool) -> dict[str, list]:
        """Group commands by category.

        Args:
            is_admin (bool): Whether the user is an admin.

        Returns:
            dict[str, list]: A dictionary where keys are category names and values are lists of command objects.

        """
        categories = {}

        for cmd in self.bot.commands.values():
            if cmd.admin_only and not is_admin:
                continue

            category = cmd.help.category or "General"
            if category not in categories:
                categories[category] = []
            categories[category].append(cmd)

        return categories

__init__(bot, formatter=None)

Initialize the HelpSystem.

Parameters:

Name Type Description Default
bot LXMFBot

The bot instance.

required
formatter HelpFormatter | None

An optional help formatter. Defaults to HelpFormatter.

None
Source code in lxmfy/help.py
def __init__(self, bot: "LXMFBot", formatter: HelpFormatter | None = None):
    """Initialize the HelpSystem.

    Args:
        bot: The bot instance.
        formatter: An optional help formatter.  Defaults to HelpFormatter.

    """
    self.bot = bot
    self.formatter = formatter or HelpFormatter()

    @bot.command(name="help", description="Show help for commands")
    def help_command(ctx):
        """Handle the 'help' command.

        Args:
            ctx: The command context.

        """
        args = ctx.args
        if not args:
            categories = self._get_categorized_commands(ctx.is_admin)
            response = self.formatter.format_all_commands(categories)
            ctx.reply(response)
            return

        command_name = args[0]
        if command_name in self.bot.commands:
            command = self.bot.commands[command_name]
            if command.admin_only and not ctx.is_admin:
                ctx.reply("This command is for administrators only.")
                return
            response = self.formatter.format_command(command)
            ctx.reply(response)
            return
        ctx.reply(f"Command '{command_name}' not found.")
        return

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

Bases: StorageBackend

In-memory storage backend.

Source code in lxmfy/storage.py
class MemoryStorage(StorageBackend):
    """In-memory storage backend."""

    def __init__(self):
        """Initialize a new MemoryStorage instance."""
        self.data: dict[str, Any] = {}
        self.logger = logging.getLogger(__name__)

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

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

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

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

    def scan(self, prefix: str) -> list:
        """Scan for keys with a given prefix."""
        return [k for k in self.data if k.startswith(prefix)]

__init__()

Initialize a new MemoryStorage instance.

Source code in lxmfy/storage.py
def __init__(self):
    """Initialize a new MemoryStorage instance."""
    self.data: dict[str, Any] = {}
    self.logger = logging.getLogger(__name__)

delete(key)

Delete a value from storage.

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

exists(key)

Check if a key exists in storage.

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

get(key, default=None)

Retrieve a value from storage.

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

scan(prefix)

Scan for keys with a given prefix.

Source code in lxmfy/storage.py
def scan(self, prefix: str) -> list:
    """Scan for keys with a given prefix."""
    return [k for k in self.data if k.startswith(prefix)]

set(key, value)

Store a value in storage.

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

Bases: StorageBackend

MsgPack file-based storage backend.

Uses the msgpack implementation vendored with RNS (RNS.vendor.umsgpack), so no extra dependency is needed. Stores each key in its own {key}.msgpack file, mirroring JSONStorage.

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

    Uses the msgpack implementation vendored with RNS
    (``RNS.vendor.umsgpack``), so no extra dependency is needed. Stores
    each key in its own ``{key}.msgpack`` file, mirroring JSONStorage.
    """

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

        Args:
            directory: The directory to store the msgpack 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}.msgpack"
        try:
            if file_path.exists():
                data = msgpack.unpackb(file_path.read_bytes())
                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}.msgpack"
        tmp_path = file_path.with_suffix(".msgpack.tmp")
        try:
            tmp_path.write_bytes(msgpack.packb(value))
            tmp_path.replace(file_path)
            self.cache[key] = value
        except Exception:
            self.logger.exception("Error writing %s", key)
            tmp_path.unlink(missing_ok=True)
            raise

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

        Args:
            key: The key to delete.

        """
        file_path = self.directory / f"{key}.msgpack"
        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}.msgpack").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}*.msgpack"):
                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 MsgPackStorage instance.

Parameters:

Name Type Description Default
directory str

The directory to store the msgpack files in.

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

    Args:
        directory: The directory to store the msgpack 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}.msgpack"
    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}.msgpack").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}.msgpack"
    try:
        if file_path.exists():
            data = msgpack.unpackb(file_path.read_bytes())
            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}*.msgpack"):
            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}.msgpack"
    tmp_path = file_path.with_suffix(".msgpack.tmp")
    try:
        tmp_path.write_bytes(msgpack.packb(value))
        tmp_path.replace(file_path)
        self.cache[key] = value
    except Exception:
        self.logger.exception("Error writing %s", key)
        tmp_path.unlink(missing_ok=True)
        raise

Tracks pending questions per sender and resolves them on reply.

Source code in lxmfy/conversations.py
class ConversationManager:
    """Tracks pending questions per sender and resolves them on reply."""

    def __init__(self, bot: LXMFBot):
        self.bot = bot
        self._pending: dict[str, _Pending] = {}
        self._lock = threading.Lock()

    def has_pending(self, sender: str) -> bool:
        """True when a question is waiting on this sender."""
        with self._lock:
            return sender in self._pending

    def pending_count(self) -> int:
        """Number of senders with a pending question."""
        with self._lock:
            return len(self._pending)

    def pending_senders(self) -> list[str]:
        """Senders with a pending question."""
        with self._lock:
            return list(self._pending)

    def _register(self, pending: _Pending) -> bool:
        replaced = None
        with self._lock:
            if (
                pending.sender not in self._pending
                and len(self._pending) >= MAX_PENDING
            ):
                return False
            replaced = self._pending.pop(pending.sender, None)
            self._pending[pending.sender] = pending
        if replaced is not None:
            self._abort(replaced)
        return True

    def _pop(self, sender: str) -> _Pending | None:
        with self._lock:
            return self._pending.pop(sender, None)

    def _pop_own(self, pending: _Pending) -> None:
        """Remove pending only if it is still the registered entry."""
        with self._lock:
            if self._pending.get(pending.sender) is pending:
                del self._pending[pending.sender]

    @staticmethod
    def _abort(pending: _Pending) -> None:
        pending.cancelled = True
        if pending.timer is not None:
            pending.timer.cancel()
        if pending.event is not None:
            pending.event.set()
        elif pending.future is not None and pending.loop is not None:
            pending.loop.call_soon_threadsafe(pending.future.set_result, None)

    def _send_prompt(self, sender: str, prompt, title: str | None) -> None:
        if prompt is not None:
            self.bot.send(sender, prompt, title=title or "Question")

    def ask(
        self,
        sender: str,
        prompt,
        *,
        timeout: float = DEFAULT_TIMEOUT,
        title: str | None = None,
        validator: Callable | None = None,
    ) -> Answer | None:
        """Send a prompt and block until the sender replies.

        Returns an Answer with content, fields, and hash, or None on
        timeout or cancellation. In async command handlers use
        ``await msg.ask_async(...)`` instead. For long waits prefer the
        callback style with ``on_answer`` so no thread stays parked.
        """
        event = threading.Event()
        pending = _Pending(
            sender=sender,
            created_at=time.time(),
            timeout=timeout,
            validator=validator,
            event=event,
        )
        if not self._register(pending):
            self.bot.logger.warning(
                "Conversation limit reached, dropping ask for %s",
                sender,
            )
            return None
        try:
            self._send_prompt(sender, prompt, title)
            if not event.wait(timeout):
                return None
            return pending.answer
        finally:
            self._pop_own(pending)

    async def ask_async(
        self,
        sender: str,
        prompt,
        *,
        timeout: float = DEFAULT_TIMEOUT,
        title: str | None = None,
        validator: Callable | None = None,
    ) -> Answer | None:
        """Send a prompt and await the sender's reply.

        Same contract as ask, for async command handlers.
        """
        loop = asyncio.get_running_loop()
        future = loop.create_future()
        pending = _Pending(
            sender=sender,
            created_at=time.time(),
            timeout=timeout,
            validator=validator,
            loop=loop,
            future=future,
        )
        if not self._register(pending):
            self.bot.logger.warning(
                "Conversation limit reached, dropping ask for %s",
                sender,
            )
            return None
        try:
            self._send_prompt(sender, prompt, title)
            try:
                return await asyncio.wait_for(future, timeout)
            except TimeoutError:
                return None
        finally:
            self._pop_own(pending)

    def ask_callback(
        self,
        sender: str,
        prompt,
        on_answer: Callable,
        *,
        timeout: float = DEFAULT_TIMEOUT,
        title: str | None = None,
        validator: Callable | None = None,
        on_timeout: Callable | None = None,
    ) -> bool:
        """Send a prompt and invoke on_answer(answer) when the reply arrives.

        Non-blocking. on_timeout(sender) fires if the sender never
        answers. Returns False when the pending limit is reached.
        """
        pending = _Pending(
            sender=sender,
            created_at=time.time(),
            timeout=timeout,
            validator=validator,
            on_answer=on_answer,
            on_timeout=on_timeout,
        )
        if not self._register(pending):
            self.bot.logger.warning(
                "Conversation limit reached, dropping ask for %s",
                sender,
            )
            return False
        timer = threading.Timer(timeout, self._expire, args=(sender,))
        timer.daemon = True
        pending.timer = timer
        timer.start()
        self._send_prompt(sender, prompt, title)
        return True

    def resolve(self, sender: str, message) -> bool:
        """Feed an inbound message to a pending question.

        Returns True when the message was consumed as an answer. When a
        validator rejects the answer the question stays pending.
        """
        with self._lock:
            pending = self._pending.get(sender)
        if pending is None:
            return False

        answer = Answer(sender, message, bot=self.bot)
        if pending.validator is not None:
            pending.attempts += 1
            try:
                error = pending.validator(answer)
            except Exception:
                self.bot.logger.exception(
                    "Conversation validator failed for %s",
                    sender,
                )
                error = "Invalid answer."
            if error:
                self.bot.send(sender, str(error))
                return True

        self._pop(sender)
        if pending.timer is not None:
            pending.timer.cancel()

        if pending.event is not None:
            pending.answer = answer
            pending.event.set()
        elif pending.future is not None and pending.loop is not None:
            pending.loop.call_soon_threadsafe(pending.future.set_result, answer)
        elif pending.on_answer is not None:
            try:
                pending.on_answer(answer)
            except Exception:
                self.bot.logger.exception(
                    "Conversation on_answer failed for %s",
                    sender,
                )
        return True

    def cancel(self, sender: str) -> bool:
        """Cancel a pending question for a sender."""
        pending = self._pop(sender)
        if pending is None:
            return False
        self._abort(pending)
        return True

    def cancel_all(self) -> None:
        """Cancel every pending question."""
        with self._lock:
            senders = list(self._pending)
        for sender in senders:
            self.cancel(sender)

    def _expire(self, sender: str) -> None:
        pending = self._pop(sender)
        if pending is None:
            return
        if pending.on_timeout is not None:
            try:
                pending.on_timeout(sender)
            except Exception:
                self.bot.logger.exception(
                    "Conversation on_timeout failed for %s",
                    sender,
                )

ask(sender, prompt, *, timeout=DEFAULT_TIMEOUT, title=None, validator=None)

Send a prompt and block until the sender replies.

Returns an Answer with content, fields, and hash, or None on timeout or cancellation. In async command handlers use await msg.ask_async(...) instead. For long waits prefer the callback style with on_answer so no thread stays parked.

Source code in lxmfy/conversations.py
def ask(
    self,
    sender: str,
    prompt,
    *,
    timeout: float = DEFAULT_TIMEOUT,
    title: str | None = None,
    validator: Callable | None = None,
) -> Answer | None:
    """Send a prompt and block until the sender replies.

    Returns an Answer with content, fields, and hash, or None on
    timeout or cancellation. In async command handlers use
    ``await msg.ask_async(...)`` instead. For long waits prefer the
    callback style with ``on_answer`` so no thread stays parked.
    """
    event = threading.Event()
    pending = _Pending(
        sender=sender,
        created_at=time.time(),
        timeout=timeout,
        validator=validator,
        event=event,
    )
    if not self._register(pending):
        self.bot.logger.warning(
            "Conversation limit reached, dropping ask for %s",
            sender,
        )
        return None
    try:
        self._send_prompt(sender, prompt, title)
        if not event.wait(timeout):
            return None
        return pending.answer
    finally:
        self._pop_own(pending)

ask_async(sender, prompt, *, timeout=DEFAULT_TIMEOUT, title=None, validator=None) async

Send a prompt and await the sender's reply.

Same contract as ask, for async command handlers.

Source code in lxmfy/conversations.py
async def ask_async(
    self,
    sender: str,
    prompt,
    *,
    timeout: float = DEFAULT_TIMEOUT,
    title: str | None = None,
    validator: Callable | None = None,
) -> Answer | None:
    """Send a prompt and await the sender's reply.

    Same contract as ask, for async command handlers.
    """
    loop = asyncio.get_running_loop()
    future = loop.create_future()
    pending = _Pending(
        sender=sender,
        created_at=time.time(),
        timeout=timeout,
        validator=validator,
        loop=loop,
        future=future,
    )
    if not self._register(pending):
        self.bot.logger.warning(
            "Conversation limit reached, dropping ask for %s",
            sender,
        )
        return None
    try:
        self._send_prompt(sender, prompt, title)
        try:
            return await asyncio.wait_for(future, timeout)
        except TimeoutError:
            return None
    finally:
        self._pop_own(pending)

ask_callback(sender, prompt, on_answer, *, timeout=DEFAULT_TIMEOUT, title=None, validator=None, on_timeout=None)

Send a prompt and invoke on_answer(answer) when the reply arrives.

Non-blocking. on_timeout(sender) fires if the sender never answers. Returns False when the pending limit is reached.

Source code in lxmfy/conversations.py
def ask_callback(
    self,
    sender: str,
    prompt,
    on_answer: Callable,
    *,
    timeout: float = DEFAULT_TIMEOUT,
    title: str | None = None,
    validator: Callable | None = None,
    on_timeout: Callable | None = None,
) -> bool:
    """Send a prompt and invoke on_answer(answer) when the reply arrives.

    Non-blocking. on_timeout(sender) fires if the sender never
    answers. Returns False when the pending limit is reached.
    """
    pending = _Pending(
        sender=sender,
        created_at=time.time(),
        timeout=timeout,
        validator=validator,
        on_answer=on_answer,
        on_timeout=on_timeout,
    )
    if not self._register(pending):
        self.bot.logger.warning(
            "Conversation limit reached, dropping ask for %s",
            sender,
        )
        return False
    timer = threading.Timer(timeout, self._expire, args=(sender,))
    timer.daemon = True
    pending.timer = timer
    timer.start()
    self._send_prompt(sender, prompt, title)
    return True

cancel(sender)

Cancel a pending question for a sender.

Source code in lxmfy/conversations.py
def cancel(self, sender: str) -> bool:
    """Cancel a pending question for a sender."""
    pending = self._pop(sender)
    if pending is None:
        return False
    self._abort(pending)
    return True

cancel_all()

Cancel every pending question.

Source code in lxmfy/conversations.py
def cancel_all(self) -> None:
    """Cancel every pending question."""
    with self._lock:
        senders = list(self._pending)
    for sender in senders:
        self.cancel(sender)

has_pending(sender)

True when a question is waiting on this sender.

Source code in lxmfy/conversations.py
def has_pending(self, sender: str) -> bool:
    """True when a question is waiting on this sender."""
    with self._lock:
        return sender in self._pending

pending_count()

Number of senders with a pending question.

Source code in lxmfy/conversations.py
def pending_count(self) -> int:
    """Number of senders with a pending question."""
    with self._lock:
        return len(self._pending)

pending_senders()

Senders with a pending question.

Source code in lxmfy/conversations.py
def pending_senders(self) -> list[str]:
    """Senders with a pending question."""
    with self._lock:
        return list(self._pending)

resolve(sender, message)

Feed an inbound message to a pending question.

Returns True when the message was consumed as an answer. When a validator rejects the answer the question stays pending.

Source code in lxmfy/conversations.py
def resolve(self, sender: str, message) -> bool:
    """Feed an inbound message to a pending question.

    Returns True when the message was consumed as an answer. When a
    validator rejects the answer the question stays pending.
    """
    with self._lock:
        pending = self._pending.get(sender)
    if pending is None:
        return False

    answer = Answer(sender, message, bot=self.bot)
    if pending.validator is not None:
        pending.attempts += 1
        try:
            error = pending.validator(answer)
        except Exception:
            self.bot.logger.exception(
                "Conversation validator failed for %s",
                sender,
            )
            error = "Invalid answer."
        if error:
            self.bot.send(sender, str(error))
            return True

    self._pop(sender)
    if pending.timer is not None:
        pending.timer.cancel()

    if pending.event is not None:
        pending.answer = answer
        pending.event.set()
    elif pending.future is not None and pending.loop is not None:
        pending.loop.call_soon_threadsafe(pending.future.set_result, answer)
    elif pending.on_answer is not None:
        try:
            pending.on_answer(answer)
        except Exception:
            self.bot.logger.exception(
                "Conversation on_answer failed for %s",
                sender,
            )
    return True

The message that answered a pending question.

Source code in lxmfy/conversations.py
class Answer:
    """The message that answered a pending question."""

    __slots__ = ("_bot", "content", "fields", "hash", "message", "sender")

    def __init__(self, sender: str, message: Any, bot: LXMFBot | None = None):
        self._bot = bot
        self.sender = sender
        self.message = message
        raw = getattr(message, "content", b"") or b""
        self.content = (
            raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
        )
        self.fields = getattr(message, "fields", None) or {}
        msg_hash = getattr(message, "hash", None)
        self.hash = msg_hash.hex() if isinstance(msg_hash, (bytes, bytearray)) else None

    def reply(self, text, **kwargs) -> bool:
        """Reply to the sender who answered, threading to their message."""
        if self._bot is None:
            return False
        kwargs.setdefault("reply_to", self.hash)
        return self._bot.send(self.sender, text, **kwargs)

reply(text, **kwargs)

Reply to the sender who answered, threading to their message.

Source code in lxmfy/conversations.py
def reply(self, text, **kwargs) -> bool:
    """Reply to the sender who answered, threading to their message."""
    if self._bot is None:
        return False
    kwargs.setdefault("reply_to", self.hash)
    return self._bot.send(self.sender, text, **kwargs)

Bounded delivery event stream with subscribers and persistence.

Source code in lxmfy/delivery.py
class DeliveryTracker:
    """Bounded delivery event stream with subscribers and persistence."""

    def __init__(self, storage=None, maxlen: int = 500):
        """Initialize the tracker.

        Args:
            storage: Optional Storage facade for persisting the recent
                tail of the stream. Pass None to keep events in memory.
            maxlen: Maximum in-memory events retained.

        """
        self.events: deque[dict[str, Any]] = deque(maxlen=maxlen)
        self.subscribers: list[Callable] = []
        self._storage = storage

    def record(self, stage: str, **fields) -> dict[str, Any]:
        """Append a delivery event and notify subscribers.

        Args:
            stage: One of STAGES.
            **fields: Optional event fields: destination, message_id,
                hash, method, attempts, reason, title.

        Returns:
            The recorded event dict.

        """
        event: dict[str, Any] = {"ts": time.time(), "stage": stage}
        for key in _EVENT_KEYS:
            value = fields.get(key)
            if value is not None:
                event[key] = value
        self.events.append(event)
        self._persist(event)
        for callback in list(self.subscribers):
            try:
                callback(dict(event))
            except Exception:
                logger.exception("Delivery event subscriber failed")
        return event

    def subscribe(self, callback: Callable) -> Callable:
        """Register a subscriber called with each new event dict."""
        self.subscribers.append(callback)
        return callback

    def unsubscribe(self, callback: Callable) -> bool:
        """Remove a subscriber. Returns True when it was registered."""
        try:
            self.subscribers.remove(callback)
            return True
        except ValueError:
            return False

    def recent(
        self,
        limit: int | None = None,
        *,
        stage: str | None = None,
        destination: str | None = None,
    ) -> list[dict[str, Any]]:
        """Return the most recent events, oldest first.

        Args:
            limit: Maximum events returned, counted from the newest.
            stage: Optional stage filter.
            destination: Optional exact destination filter.

        """
        items = list(self.events)
        if stage is not None:
            items = [e for e in items if e.get("stage") == stage]
        if destination is not None:
            items = [e for e in items if e.get("destination") == destination]
        if limit is not None and limit > 0:
            items = items[-limit:]
        return items

    def load_persisted(self) -> None:
        """Restore the persisted tail into memory at startup."""
        if self._storage is None:
            return
        try:
            stored = self._storage.get(STORAGE_KEY, [])
        except Exception:
            logger.exception("Failed to read persisted delivery events")
            return
        if not isinstance(stored, list):
            return
        for entry in stored:
            if isinstance(entry, dict) and isinstance(entry.get("ts"), (int, float)):
                self.events.append(entry)

    def _persist(self, event: dict[str, Any]) -> None:
        """Write the bounded event tail to storage, best effort."""
        if self._storage is None:
            return
        try:
            tail = list(self.events)[-PERSIST_LIMIT:]
            self._storage.set(STORAGE_KEY, tail)
        except Exception:
            logger.debug("Failed to persist delivery event", exc_info=True)

__init__(storage=None, maxlen=500)

Initialize the tracker.

Parameters:

Name Type Description Default
storage

Optional Storage facade for persisting the recent tail of the stream. Pass None to keep events in memory.

None
maxlen int

Maximum in-memory events retained.

500
Source code in lxmfy/delivery.py
def __init__(self, storage=None, maxlen: int = 500):
    """Initialize the tracker.

    Args:
        storage: Optional Storage facade for persisting the recent
            tail of the stream. Pass None to keep events in memory.
        maxlen: Maximum in-memory events retained.

    """
    self.events: deque[dict[str, Any]] = deque(maxlen=maxlen)
    self.subscribers: list[Callable] = []
    self._storage = storage

load_persisted()

Restore the persisted tail into memory at startup.

Source code in lxmfy/delivery.py
def load_persisted(self) -> None:
    """Restore the persisted tail into memory at startup."""
    if self._storage is None:
        return
    try:
        stored = self._storage.get(STORAGE_KEY, [])
    except Exception:
        logger.exception("Failed to read persisted delivery events")
        return
    if not isinstance(stored, list):
        return
    for entry in stored:
        if isinstance(entry, dict) and isinstance(entry.get("ts"), (int, float)):
            self.events.append(entry)

recent(limit=None, *, stage=None, destination=None)

Return the most recent events, oldest first.

Parameters:

Name Type Description Default
limit int | None

Maximum events returned, counted from the newest.

None
stage str | None

Optional stage filter.

None
destination str | None

Optional exact destination filter.

None
Source code in lxmfy/delivery.py
def recent(
    self,
    limit: int | None = None,
    *,
    stage: str | None = None,
    destination: str | None = None,
) -> list[dict[str, Any]]:
    """Return the most recent events, oldest first.

    Args:
        limit: Maximum events returned, counted from the newest.
        stage: Optional stage filter.
        destination: Optional exact destination filter.

    """
    items = list(self.events)
    if stage is not None:
        items = [e for e in items if e.get("stage") == stage]
    if destination is not None:
        items = [e for e in items if e.get("destination") == destination]
    if limit is not None and limit > 0:
        items = items[-limit:]
    return items

record(stage, **fields)

Append a delivery event and notify subscribers.

Parameters:

Name Type Description Default
stage str

One of STAGES.

required
**fields

Optional event fields: destination, message_id, hash, method, attempts, reason, title.

{}

Returns:

Type Description
dict[str, Any]

The recorded event dict.

Source code in lxmfy/delivery.py
def record(self, stage: str, **fields) -> dict[str, Any]:
    """Append a delivery event and notify subscribers.

    Args:
        stage: One of STAGES.
        **fields: Optional event fields: destination, message_id,
            hash, method, attempts, reason, title.

    Returns:
        The recorded event dict.

    """
    event: dict[str, Any] = {"ts": time.time(), "stage": stage}
    for key in _EVENT_KEYS:
        value = fields.get(key)
        if value is not None:
            event[key] = value
    self.events.append(event)
    self._persist(event)
    for callback in list(self.subscribers):
        try:
            callback(dict(event))
        except Exception:
            logger.exception("Delivery event subscriber failed")
    return event

subscribe(callback)

Register a subscriber called with each new event dict.

Source code in lxmfy/delivery.py
def subscribe(self, callback: Callable) -> Callable:
    """Register a subscriber called with each new event dict."""
    self.subscribers.append(callback)
    return callback

unsubscribe(callback)

Remove a subscriber. Returns True when it was registered.

Source code in lxmfy/delivery.py
def unsubscribe(self, callback: Callable) -> bool:
    """Remove a subscriber. Returns True when it was registered."""
    try:
        self.subscribers.remove(callback)
        return True
    except ValueError:
        return False

Bases: LXMFBot

LXMFBot preconfigured for tests, with message injection.

Parameters:

Name Type Description Default
name str

Bot name.

'TestBot'
**overrides

Any LXMFBot/BotConfig keyword. Test-friendly defaults apply unless overridden.

{}
Source code in lxmfy/testing.py
class TestBot(LXMFBot):
    """LXMFBot preconfigured for tests, with message injection.

    Args:
        name: Bot name.
        **overrides: Any LXMFBot/BotConfig keyword. Test-friendly defaults
            apply unless overridden.
    """

    __test__ = False  # not a pytest test class

    _DEFAULTS: ClassVar[dict] = {
        "test_mode": True,
        "storage_type": "memory",
        "announce_enabled": False,
        "first_message_enabled": False,
        "cogs_enabled": False,
        "signature_verification_enabled": False,
        "require_message_signatures": False,
        "landlock_enabled": False,
        "command_prefix": "/",
        "rate_limit": 1000,
        "cooldown": 0,
        "permissions_enabled": False,
    }

    def __init__(self, name: str = "TestBot", **overrides):
        self._tmp = tempfile.TemporaryDirectory(prefix="lxmfy-test-")
        config = {
            "name": name,
            "config_path": self._tmp.name,
            "storage_path": self._tmp.name + "/storage",
        }
        config.update(self._DEFAULTS)
        config.update(overrides)
        self._sender_hashes: dict[str, bytes] = {}
        self.outbox: list[SentMessage] = []
        super().__init__(**config)

    def sender_hash(self, sender: str = "alice") -> bytes:
        """Stable source hash for a named fake sender.

        Accepts a name (mapped to a fixed random hash per bot) or a hex
        destination hash.
        """
        if sender in self._sender_hashes:
            return self._sender_hashes[sender]
        try:
            raw = bytes.fromhex(sender)
            if len(raw) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
                self._sender_hashes[sender] = raw
                return raw
        except ValueError:
            pass
        raw = secrets.token_bytes(RNS.Reticulum.TRUNCATED_HASHLENGTH // 8)
        self._sender_hashes[sender] = raw
        return raw

    def sender_hex(self, sender: str = "alice") -> str:
        """Hex source hash for a named fake sender."""
        return self.sender_hash(sender).hex()

    def receive(
        self,
        content: str | bytes,
        *,
        sender: str = "alice",
        title: str | bytes | None = None,
        fields: dict | None = None,
        message_hash: bytes | None = None,
    ) -> list[SentMessage]:
        """Inject an inbound message through the real receive pipeline.

        Returns the outbound messages produced while handling it. For
        threaded commands use wait_sent after receive.
        """
        self.drain()
        message = fake_message(
            content,
            source_hash=self.sender_hash(sender),
            title=title,
            fields=fields,
            message_hash=message_hash,
        )
        self._message_received(message)
        return self.drain()

    def receive_later(
        self,
        content: str | bytes,
        *,
        sender: str = "alice",
        delay: float = 0.05,
        **kwargs,
    ) -> threading.Thread:
        """Deliver an inbound message on a daemon thread after a delay.

        Used to answer blocking msg.ask calls inside command handlers.
        """
        thread = threading.Thread(
            target=lambda: (
                time.sleep(delay),
                self.receive(content, sender=sender, **kwargs),
            ),
            daemon=True,
        )
        thread.start()
        return thread

    def drain(self) -> list[SentMessage]:
        """Pop all queued outbound messages and append them to outbox."""
        out = []
        while not self.queue.empty():
            try:
                out.append(SentMessage(self.queue.get(block=False)))
            except Exception:
                break
        self.outbox.extend(out)
        return out

    def wait_sent(self, count: int = 1, timeout: float = 5.0) -> list[SentMessage]:
        """Wait until at least count new outbound messages are queued."""
        collected: list[SentMessage] = []
        deadline = time.time() + timeout
        while time.time() < deadline:
            collected += self.drain()
            if len(collected) >= count:
                return collected
            time.sleep(0.01)
        return collected

    def last_sent(self, sender: str | None = None) -> SentMessage | None:
        """Most recent outbound message, optionally filtered by sender."""
        self.drain()
        sent = self.outbox
        if sender is not None:
            target = self.sender_hex(sender)
            sent = [m for m in sent if m.destination == target]
        return sent[-1] if sent else None

    def close(self) -> None:
        """Shut down the bot and remove temporary storage."""
        try:
            self.cleanup()
        finally:
            self._tmp.cleanup()

    def __enter__(self) -> TestBot:
        return self

    def __exit__(self, *exc) -> None:
        self.close()

close()

Shut down the bot and remove temporary storage.

Source code in lxmfy/testing.py
def close(self) -> None:
    """Shut down the bot and remove temporary storage."""
    try:
        self.cleanup()
    finally:
        self._tmp.cleanup()

drain()

Pop all queued outbound messages and append them to outbox.

Source code in lxmfy/testing.py
def drain(self) -> list[SentMessage]:
    """Pop all queued outbound messages and append them to outbox."""
    out = []
    while not self.queue.empty():
        try:
            out.append(SentMessage(self.queue.get(block=False)))
        except Exception:
            break
    self.outbox.extend(out)
    return out

last_sent(sender=None)

Most recent outbound message, optionally filtered by sender.

Source code in lxmfy/testing.py
def last_sent(self, sender: str | None = None) -> SentMessage | None:
    """Most recent outbound message, optionally filtered by sender."""
    self.drain()
    sent = self.outbox
    if sender is not None:
        target = self.sender_hex(sender)
        sent = [m for m in sent if m.destination == target]
    return sent[-1] if sent else None

receive(content, *, sender='alice', title=None, fields=None, message_hash=None)

Inject an inbound message through the real receive pipeline.

Returns the outbound messages produced while handling it. For threaded commands use wait_sent after receive.

Source code in lxmfy/testing.py
def receive(
    self,
    content: str | bytes,
    *,
    sender: str = "alice",
    title: str | bytes | None = None,
    fields: dict | None = None,
    message_hash: bytes | None = None,
) -> list[SentMessage]:
    """Inject an inbound message through the real receive pipeline.

    Returns the outbound messages produced while handling it. For
    threaded commands use wait_sent after receive.
    """
    self.drain()
    message = fake_message(
        content,
        source_hash=self.sender_hash(sender),
        title=title,
        fields=fields,
        message_hash=message_hash,
    )
    self._message_received(message)
    return self.drain()

receive_later(content, *, sender='alice', delay=0.05, **kwargs)

Deliver an inbound message on a daemon thread after a delay.

Used to answer blocking msg.ask calls inside command handlers.

Source code in lxmfy/testing.py
def receive_later(
    self,
    content: str | bytes,
    *,
    sender: str = "alice",
    delay: float = 0.05,
    **kwargs,
) -> threading.Thread:
    """Deliver an inbound message on a daemon thread after a delay.

    Used to answer blocking msg.ask calls inside command handlers.
    """
    thread = threading.Thread(
        target=lambda: (
            time.sleep(delay),
            self.receive(content, sender=sender, **kwargs),
        ),
        daemon=True,
    )
    thread.start()
    return thread

sender_hash(sender='alice')

Stable source hash for a named fake sender.

Accepts a name (mapped to a fixed random hash per bot) or a hex destination hash.

Source code in lxmfy/testing.py
def sender_hash(self, sender: str = "alice") -> bytes:
    """Stable source hash for a named fake sender.

    Accepts a name (mapped to a fixed random hash per bot) or a hex
    destination hash.
    """
    if sender in self._sender_hashes:
        return self._sender_hashes[sender]
    try:
        raw = bytes.fromhex(sender)
        if len(raw) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
            self._sender_hashes[sender] = raw
            return raw
    except ValueError:
        pass
    raw = secrets.token_bytes(RNS.Reticulum.TRUNCATED_HASHLENGTH // 8)
    self._sender_hashes[sender] = raw
    return raw

sender_hex(sender='alice')

Hex source hash for a named fake sender.

Source code in lxmfy/testing.py
def sender_hex(self, sender: str = "alice") -> str:
    """Hex source hash for a named fake sender."""
    return self.sender_hash(sender).hex()

wait_sent(count=1, timeout=5.0)

Wait until at least count new outbound messages are queued.

Source code in lxmfy/testing.py
def wait_sent(self, count: int = 1, timeout: float = 5.0) -> list[SentMessage]:
    """Wait until at least count new outbound messages are queued."""
    collected: list[SentMessage] = []
    deadline = time.time() + timeout
    while time.time() < deadline:
        collected += self.drain()
        if len(collected) >= count:
            return collected
        time.sleep(0.01)
    return collected

An outbound message captured from the bot queue.

Source code in lxmfy/testing.py
class SentMessage:
    """An outbound message captured from the bot queue."""

    __slots__ = (
        "content",
        "destination",
        "fields",
        "include_ticket",
        "method",
        "raw",
        "stamp_cost",
        "title",
    )

    def __init__(self, lxm):
        self.raw = lxm
        dest = getattr(lxm, "destination_hash", None)
        self.destination = (
            dest.hex() if isinstance(dest, (bytes, bytearray)) else str(dest)
        )
        content = getattr(lxm, "content", b"") or b""
        self.content = (
            content.decode("utf-8", errors="replace")
            if isinstance(content, (bytes, bytearray))
            else str(content)
        )
        title = getattr(lxm, "title", None)
        self.title = (
            title.decode("utf-8", errors="replace")
            if isinstance(title, (bytes, bytearray))
            else title
        )
        self.fields = getattr(lxm, "fields", None) or {}
        self.method = getattr(lxm, "desired_method", None)
        self.include_ticket = getattr(lxm, "include_ticket", None)
        self.stamp_cost = getattr(lxm, "stamp_cost", None)

    def __repr__(self) -> str:
        return f"SentMessage(to={self.destination[:16]}..., content={self.content!r})"

Diagnose LXMF send/receive connectivity problems.

Source code in lxmfy/debugger.py
 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
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
class Debugger:
    """Diagnose LXMF send/receive connectivity problems."""

    def __init__(
        self,
        *,
        reticulum_config_dir: str | None = None,
        config_path: str | None = None,
        bot: Any | None = None,
        privacy: bool = True,
    ):
        """Create a debugger.

        Args:
            reticulum_config_dir: Explicit Reticulum config directory.
            config_path: Bot config directory (identity, announce state).
            bot: Optional running LXMFBot instance to introspect.
            privacy: Redact paths/hashes in reports (default True).

        """
        self.bot = bot
        self.privacy = privacy
        self.config_path = os.path.abspath(
            config_path
            or (getattr(bot, "config_path", None) if bot else None)
            or os.path.join(os.getcwd(), "config"),
        )
        if bot and getattr(bot, "reticulum_config_dir", None):
            self.reticulum_config_dir = os.path.abspath(bot.reticulum_config_dir)
        elif reticulum_config_dir:
            self.reticulum_config_dir = os.path.abspath(
                os.path.expanduser(reticulum_config_dir),
            )
        else:
            self.reticulum_config_dir = resolve_reticulum_config_dir(
                None,
                self.config_path,
            )
        self._reticulum_ready = False
        self._storage_path = self._resolve_storage_path()

    def _resolve_storage_path(self) -> str:
        if self.bot and getattr(self.bot, "config", None):
            return os.path.abspath(
                os.path.expanduser(self.bot.config.storage_path),
            )
        return os.path.abspath(os.path.join(os.getcwd(), "data"))

    def ensure_reticulum(self, loglevel: int | None = None) -> bool:
        """Initialize Reticulum if needed."""
        if self._reticulum_ready:
            return True
        if self.bot and not getattr(self.bot.config, "test_mode", False):
            try:
                import RNS

                if RNS.Reticulum.get_instance() is not None:
                    self._reticulum_ready = True
                    return True
            except Exception:
                logger.debug("shared Reticulum probe failed", exc_info=True)

        try:
            import RNS

            level = loglevel if loglevel is not None else RNS.LOG_ERROR
            try:
                RNS.Reticulum(
                    configdir=self.reticulum_config_dir,
                    loglevel=level,
                )
            except OSError as e:
                if "reinitialise" not in str(e).lower():
                    raise
            self._reticulum_ready = True
            return True
        except Exception:
            logger.exception("Failed to initialize Reticulum")
            return False

    def check_environment(self) -> list[CheckResult]:
        """OS, Python, and package versions."""
        results: list[CheckResult] = []
        os_detail = f"{platform.system()} {platform.release()} ({platform.machine()})"
        results.append(
            CheckResult(
                name="os",
                status="ok",
                detail=os_detail,
                category="environment",
            ),
        )
        results.append(
            CheckResult(
                name="python",
                status="ok",
                detail=platform.python_version(),
                category="environment",
            ),
        )
        results.append(
            CheckResult(
                name="lxmfy",
                status="ok",
                detail=LXMFY_VERSION,
                category="environment",
            ),
        )
        rns_ver = _package_version("rns")
        lxmf_ver = _package_version("lxmf")
        results.append(
            CheckResult(
                name="rns",
                status="ok" if rns_ver != "unknown" else "warn",
                detail=rns_ver,
                category="environment",
            ),
        )
        results.append(
            CheckResult(
                name="lxmf",
                status="ok" if lxmf_ver != "unknown" else "warn",
                detail=lxmf_ver,
                category="environment",
            ),
        )
        results.append(
            CheckResult(
                name="cwd",
                status="info",
                detail=redact_path(os.getcwd()) if self.privacy else os.getcwd(),
                category="environment",
            ),
        )
        return results

    def check_instance(self) -> list[CheckResult]:
        """Shared RNS instance vs owned/isolated bot config."""
        results: list[CheckResult] = []
        discovered = discover_user_reticulum_config_dir()
        config_file = os.path.join(self.reticulum_config_dir, "config")
        exists = os.path.isfile(config_file)
        isolated = is_isolated_reticulum_dir(
            self.reticulum_config_dir,
            self.config_path,
        )
        share = _read_share_instance(config_file) if exists else None

        results.append(
            CheckResult(
                name="reticulum_config",
                status="ok" if exists else "fail",
                detail=redact_path(self.reticulum_config_dir)
                if self.privacy
                else self.reticulum_config_dir,
                hint=None
                if exists
                else "No config file found. Point --config / "
                "LXMFY_RETICULUM_CONFIG_DIR at a valid RNS config.",
                category="instance",
            ),
        )

        if discovered:
            same = os.path.abspath(discovered) == os.path.abspath(
                self.reticulum_config_dir,
            )
            results.append(
                CheckResult(
                    name="user_reticulum_config",
                    status="ok" if same else "info",
                    detail=(
                        (redact_path(discovered) if self.privacy else discovered)
                        + (" (selected)" if same else " (not selected)")
                    ),
                    category="instance",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="user_reticulum_config",
                    status="warn",
                    detail="none discovered",
                    hint="No ~/.reticulum / ~/.config/reticulum / "
                    "/etc/reticulum config. Bot may be isolated.",
                    category="instance",
                ),
            )

        # Clear owned vs shared labeling
        runtime_shared = None
        if self.ensure_reticulum():
            try:
                import RNS

                runtime_shared = bool(
                    getattr(RNS.Transport, "is_shared_instance", False),
                )
            except Exception:
                runtime_shared = None

        if isolated:
            if share in {None, "yes", "true", "1"}:
                mode = "owned_misconfigured"
                status = "fail"
                detail = (
                    f"owned/isolated config but share_instance="
                    f"{share or 'unset (defaults to Yes)'}"
                )
                hint = (
                    "Isolated bot configs must set share_instance=No or they "
                    "collide with NomadNet/Columba (digest sent was rejected)."
                )
            else:
                mode = "owned"
                status = "ok"
                detail = f"owned/isolated, share_instance={share or 'No'}"
                hint = None
        else:
            mode = "shared"
            status = "ok"
            detail = f"shared/user config, share_instance={share or 'default'}"
            hint = None

        results.append(
            CheckResult(
                name="instance_mode",
                status=status,
                detail=f"{mode}: {detail}",
                hint=hint,
                category="instance",
            ),
        )

        if runtime_shared is not None:
            results.append(
                CheckResult(
                    name="runtime_shared_instance",
                    status="info",
                    detail="yes (joined shared RNS)"
                    if runtime_shared
                    else "no (own process/instance)",
                    category="instance",
                ),
            )

        return results

    def check_disk_permissions(self) -> list[CheckResult]:
        """Verify config, storage, and Reticulum dirs are usable."""
        results: list[CheckResult] = []
        targets = [
            ("config_path", self.config_path, True),
            ("reticulum_config_dir", self.reticulum_config_dir, True),
            ("storage_path", self._storage_path, True),
        ]
        for name, path, need_write in targets:
            info = _probe_dir_access(path)
            show = redact_path(path) if self.privacy else path
            if not info["exists"]:
                parent_ok = info["writable"]
                results.append(
                    CheckResult(
                        name=name,
                        status="warn" if parent_ok else "fail",
                        detail=f"missing ({show}), parent_writable={parent_ok}",
                        hint=(
                            "Directory will be created on bot start if parent "
                            "is writable."
                            if parent_ok
                            else "Cannot create directory. Fix ownership/permissions."
                        ),
                        category="disk",
                    ),
                )
                continue

            if need_write and not info["can_create_file"]:
                results.append(
                    CheckResult(
                        name=name,
                        status="fail",
                        detail=(
                            f"{show} exists but not writable "
                            f"({info.get('error') or 'permission denied'})"
                        ),
                        hint="Fix directory ownership/permissions so the bot "
                        "can write identity, announce throttle, and queues.",
                        category="disk",
                    ),
                )
            else:
                results.append(
                    CheckResult(
                        name=name,
                        status="ok",
                        detail=(
                            f"{show} readable={info['readable']} "
                            f"writable={info['writable']}"
                        ),
                        category="disk",
                    ),
                )

        identity = os.path.join(self.config_path, "identity")
        id_info = _probe_file_access(identity)
        show_id = redact_path(identity) if self.privacy else identity
        if id_info["exists"]:
            results.append(
                CheckResult(
                    name="identity_file",
                    status="ok" if id_info["readable"] else "fail",
                    detail=(
                        f"present readable={id_info['readable']} "
                        f"writable={id_info['writable']}"
                    ),
                    hint=None
                    if id_info["readable"]
                    else f"Cannot read identity at {show_id}",
                    category="disk",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="identity_file",
                    status="info",
                    detail="absent (created on first bot run)",
                    category="disk",
                ),
            )

        announce_file = os.path.join(self.config_path, "announce")
        ann_info = _probe_file_access(announce_file)
        if ann_info["exists"] and not ann_info["writable"]:
            results.append(
                CheckResult(
                    name="announce_file",
                    status="fail",
                    detail="exists but not writable",
                    hint="Announce throttle file must be writable or announces "
                    "will fail after the first send.",
                    category="disk",
                ),
            )
        elif ann_info["exists"]:
            results.append(
                CheckResult(
                    name="announce_file",
                    status="ok",
                    detail="present and writable",
                    category="disk",
                ),
            )

        if self.bot and hasattr(self.bot, "get_landlock_status"):
            try:
                ll = self.bot.get_landlock_status()
                active = bool(ll.get("active"))
                results.append(
                    CheckResult(
                        name="landlock",
                        status="info" if active else "ok",
                        detail=(
                            f"active={active} supported={ll.get('supported')} "
                            f"enabled={ll.get('config_enabled')}"
                        ),
                        hint=(
                            "Landlock sandbox is active. Unexpected write paths "
                            "outside config/storage/reticulum will fail."
                            if active
                            else None
                        ),
                        category="disk",
                    ),
                )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="landlock",
                        status="warn",
                        detail=str(e),
                        category="disk",
                    ),
                )

        return results

    def check_interfaces(self) -> list[CheckResult]:
        """Report Reticulum interface status (no hostnames/IPs)."""
        results: list[CheckResult] = []
        if not self.ensure_reticulum():
            return [
                CheckResult(
                    name="interfaces",
                    status="fail",
                    detail="Reticulum not initialized",
                    hint="Fix reticulum config and try again.",
                    category="network",
                ),
            ]

        try:
            import RNS

            interfaces = list(getattr(RNS.Transport, "interfaces", []) or [])
            if not interfaces:
                results.append(
                    CheckResult(
                        name="interfaces",
                        status="fail",
                        detail="no interfaces loaded",
                        hint="Add at least one enabled interface under "
                        "[interfaces] in your Reticulum config.",
                        category="network",
                    ),
                )
                return results

            online = 0
            type_counts: dict[str, list[bool]] = {}
            for iface in interfaces:
                name = str(getattr(iface, "name", type(iface).__name__))
                # Avoid leaking host/IP from interface names when possible
                safe_name = name
                if (
                    self.privacy
                    and any(ch.isdigit() for ch in name)
                    and ("." in name or ":" in name)
                ):
                    safe_name = f"iface_{len(type_counts)}"
                online_flag = bool(getattr(iface, "online", False))
                if online_flag:
                    online += 1
                itype = type(iface).__name__
                type_counts.setdefault(itype, []).append(online_flag)
                results.append(
                    CheckResult(
                        name=f"interface:{safe_name}",
                        status="ok" if online_flag else "warn",
                        detail=f"{itype} online={online_flag}",
                        hint=None
                        if online_flag
                        else "Interface offline. Check remote reachability "
                        "and that the interface is enabled.",
                        category="network",
                    ),
                )

            if online == 0:
                results.append(
                    CheckResult(
                        name="interfaces_online",
                        status="fail",
                        detail=f"0/{len(interfaces)} online",
                        hint="No online interfaces. The bot cannot send or "
                        "receive until at least one interface connects.",
                        category="network",
                    ),
                )
            else:
                results.append(
                    CheckResult(
                        name="interfaces_online",
                        status="ok",
                        detail=f"{online}/{len(interfaces)} online",
                        category="network",
                    ),
                )

            summary = ", ".join(
                f"{t}={sum(1 for x in flags if x)}/{len(flags)}"
                for t, flags in sorted(type_counts.items())
            )
            results.append(
                CheckResult(
                    name="interface_types",
                    status="info",
                    detail=summary,
                    category="network",
                ),
            )

            # Client-only shared instance is a common false "all green"
            only_local_client = (
                len(type_counts) == 1 and "LocalClientInterface" in type_counts
            )
            shared = bool(getattr(RNS.Transport, "is_shared_instance", False))
            if only_local_client or shared:
                results.append(
                    CheckResult(
                        name="shared_instance_role",
                        status="warn" if only_local_client else "info",
                        detail=(
                            "RNS client via LocalClientInterface only"
                            if only_local_client
                            else "joined shared RNS instance"
                        ),
                        hint=(
                            "Radios/TCP live in the host RNS process "
                            "(NomadNet, rnsd, Columba). If that host cannot "
                            "reach the mesh, this bot cannot either."
                            if only_local_client
                            else None
                        ),
                        category="network",
                    ),
                )
        except Exception as e:
            results.append(
                CheckResult(
                    name="interfaces",
                    status="fail",
                    detail=str(e),
                    category="network",
                ),
            )
        return results

    def check_announce(self, *, try_announce: bool = False) -> list[CheckResult]:
        """Announce configuration and optional live announce test."""
        results: list[CheckResult] = []
        enabled = True
        interval = 600
        if self.bot:
            enabled = bool(self.bot.config.announce_enabled)
            interval = int(self.bot.config.announce or 0)

        results.append(
            CheckResult(
                name="announce_enabled",
                status="ok" if enabled else "fail",
                detail=str(enabled),
                hint=None
                if enabled
                else "Peers cannot discover you. Set announce_enabled=True.",
                category="announce",
            ),
        )
        results.append(
            CheckResult(
                name="announce_interval",
                status="ok" if interval >= 300 or interval == 0 else "warn",
                detail=f"{interval}s",
                hint="Intervals under 300s can spam the network."
                if 0 < interval < 300
                else None,
                category="announce",
            ),
        )

        announce_path = os.path.join(self.config_path, "announce")
        if os.path.isfile(announce_path):
            try:
                with open(announce_path, encoding="utf-8") as f:
                    next_at = int(f.readline().strip() or "0")
                remaining = next_at - int(time.time())
                if remaining > 0:
                    detail = f"throttle active, next in ~{remaining}s"
                    status = "ok"
                else:
                    detail = "throttle elapsed (announce due)"
                    status = "info"
                results.append(
                    CheckResult(
                        name="announce_throttle",
                        status=status,
                        detail=detail,
                        category="announce",
                    ),
                )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="announce_throttle",
                        status="warn",
                        detail=f"unreadable: {e}",
                        category="announce",
                    ),
                )
        else:
            results.append(
                CheckResult(
                    name="announce_throttle",
                    status="info",
                    detail="no throttle file yet (bot has not announced)",
                    hint="If the bot never announced, peers will not learn "
                    "your delivery hash.",
                    category="announce",
                ),
            )

        if try_announce:
            if not self.bot:
                results.append(
                    CheckResult(
                        name="announce_test",
                        status="warn",
                        detail="skipped (no live bot)",
                        hint="Run debugger against a running bot, or start "
                        "the bot so announce_now can be called.",
                        category="announce",
                    ),
                )
            elif not enabled:
                results.append(
                    CheckResult(
                        name="announce_test",
                        status="fail",
                        detail="skipped (announce_enabled=False)",
                        category="announce",
                    ),
                )
            elif not getattr(self.bot, "local", None):
                results.append(
                    CheckResult(
                        name="announce_test",
                        status="fail",
                        detail="no local LXMF destination (test_mode?)",
                        category="announce",
                    ),
                )
            else:
                try:
                    self.bot.announce_now(force=True)
                    results.append(
                        CheckResult(
                            name="announce_test",
                            status="ok",
                            detail="announce_now(force=True) completed",
                            category="announce",
                        ),
                    )
                except Exception as e:
                    results.append(
                        CheckResult(
                            name="announce_test",
                            status="fail",
                            detail=str(e),
                            hint="Announce failed. Check interfaces online and "
                            "disk write access to the announce throttle file.",
                            category="announce",
                        ),
                    )
        else:
            results.append(
                CheckResult(
                    name="announce_test",
                    status="info",
                    detail="not run (pass --announce-test to try live announce)",
                    category="announce",
                ),
            )

        return results

    def check_delivery_config(self) -> list[CheckResult]:
        """Report effective delivery knobs (bot config or framework defaults)."""
        from .config import BotConfig

        cfg = self.bot.config if self.bot else BotConfig()
        results: list[CheckResult] = []
        source = "live_bot" if self.bot else "defaults"
        results.append(
            CheckResult(
                name="delivery_config_source",
                status="info",
                detail=source,
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="opportunistic_sending",
                status="ok" if cfg.opportunistic_sending else "warn",
                detail=str(cfg.opportunistic_sending),
                hint=None
                if cfg.opportunistic_sending
                else "DIRECT often fails on public TCP/backbone. Prefer "
                "opportunistic_sending=True.",
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="direct_delivery_retries",
                status="info",
                detail=str(cfg.direct_delivery_retries),
                category="send",
            ),
        )
        has_prop = bool(
            cfg.propagation_node
            or cfg.autopeer_propagation
            or cfg.enable_propagation_node,
        )
        if cfg.propagation_fallback_enabled and not has_prop:
            results.append(
                CheckResult(
                    name="propagation_fallback",
                    status="warn",
                    detail="enabled but no prop node / autopeer",
                    hint="Configure propagation_node, enable "
                    "autopeer_propagation, or disable "
                    "propagation_fallback_enabled.",
                    category="send",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="propagation_fallback",
                    status="ok",
                    detail=(
                        f"enabled={cfg.propagation_fallback_enabled} "
                        f"has_source={has_prop}"
                    ),
                    category="send",
                ),
            )
        results.append(
            CheckResult(
                name="default_delivery_method",
                status="ok",
                detail=("OPPORTUNISTIC" if cfg.opportunistic_sending else "DIRECT"),
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="message_persistence",
                status="ok" if cfg.message_persistence_enabled else "warn",
                detail=str(cfg.message_persistence_enabled),
                hint=(
                    "Disabled: crash mid-send loses queued outbound messages."
                    if not cfg.message_persistence_enabled
                    else None
                ),
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="announce_enabled_config",
                status="ok" if cfg.announce_enabled else "fail",
                detail=str(cfg.announce_enabled),
                category="announce",
            ),
        )
        return results

    def check_storage_history(self) -> list[CheckResult]:
        """Read delivery_attempts / persisted_queue from disk without a live bot."""
        results: list[CheckResult] = []
        storage_dir = self._storage_path
        attempts_path = os.path.join(storage_dir, "delivery_attempts.json")
        queue_path = os.path.join(storage_dir, "persisted_queue.json")

        if self.bot and getattr(self.bot, "storage", None) is not None:
            try:
                attempts = self.bot.storage.get("delivery_attempts", {}) or {}
                persisted = self.bot.storage.get("persisted_queue", []) or []
                return self._format_storage_history(attempts, persisted, "live_storage")
            except Exception as e:
                results.append(
                    CheckResult(
                        name="storage_history",
                        status="warn",
                        detail=f"live storage read failed: {e}",
                        category="send",
                    ),
                )

        attempts: dict = {}
        persisted: list = []
        if os.path.isfile(attempts_path):
            try:
                with open(attempts_path, encoding="utf-8") as f:
                    raw = json.load(f)
                if isinstance(raw, dict):
                    attempts = raw
                else:
                    results.append(
                        CheckResult(
                            name="delivery_attempts_file",
                            status="warn",
                            detail="unexpected JSON type",
                            category="send",
                        ),
                    )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="delivery_attempts_file",
                        status="warn",
                        detail=f"unreadable: {e}",
                        category="send",
                    ),
                )
        if os.path.isfile(queue_path):
            try:
                with open(queue_path, encoding="utf-8") as f:
                    raw = json.load(f)
                if isinstance(raw, list):
                    persisted = raw
                else:
                    results.append(
                        CheckResult(
                            name="persisted_queue_file",
                            status="warn",
                            detail="unexpected JSON type",
                            category="send",
                        ),
                    )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="persisted_queue_file",
                        status="warn",
                        detail=f"unreadable: {e}",
                        category="send",
                    ),
                )

        if not os.path.isdir(storage_dir):
            results.append(
                CheckResult(
                    name="storage_history",
                    status="info",
                    detail="storage dir missing (bot may never have run here)",
                    category="send",
                ),
            )
            return results

        results.extend(
            self._format_storage_history(attempts, persisted, "disk"),
        )
        return results

    def _format_storage_history(
        self,
        attempts: dict,
        persisted: list,
        source: str,
    ) -> list[CheckResult]:
        results: list[CheckResult] = []
        if attempts:
            hot = sorted(
                attempts.items(),
                key=lambda kv: (
                    int(kv[1]) if str(kv[1]).isdigit() or isinstance(kv[1], int) else 0
                ),
                reverse=True,
            )[:8]
            parts = []
            for dest, n in hot:
                label = redact_hash(str(dest)) if self.privacy else str(dest)[:12]
                parts.append(f"{label}={n}")
            results.append(
                CheckResult(
                    name="delivery_attempts",
                    status="warn",
                    detail=f"{source}: " + ", ".join(parts),
                    hint="Non-zero attempts mean recent delivery failures.",
                    category="send",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="delivery_attempts",
                    status="ok",
                    detail=f"{source}: none recorded",
                    category="send",
                ),
            )

        results.append(
            CheckResult(
                name="persisted_queue",
                status="warn" if persisted else "ok",
                detail=f"{source}: {len(persisted)} message(s)",
                hint=(
                    "Messages waiting from prior runs / crash recovery."
                    if persisted
                    else None
                ),
                category="send",
            ),
        )
        return results

    def check_delivery_timeline(self, limit: int = 10) -> list[CheckResult]:
        """Render recent delivery events from the live bot or disk."""
        events: list = []
        source = "none"
        if self.bot and getattr(self.bot, "delivery", None) is not None:
            try:
                events = self.bot.delivery.recent(limit)
                source = "live"
            except Exception:
                logger.debug("live delivery read failed", exc_info=True)
        if not events:
            events_path = os.path.join(self._storage_path, "delivery_events.json")
            if os.path.isfile(events_path):
                try:
                    with open(events_path, encoding="utf-8") as f:
                        raw = json.load(f)
                    if isinstance(raw, list):
                        events = [e for e in raw if isinstance(e, dict)][-limit:]
                        source = "disk"
                except Exception as e:
                    return [
                        CheckResult(
                            name="delivery_timeline",
                            status="warn",
                            detail=f"delivery_events.json unreadable: {e}",
                            category="send",
                        ),
                    ]
        if not events:
            return [
                CheckResult(
                    name="delivery_timeline",
                    status="info",
                    detail="no delivery events recorded yet",
                    category="send",
                ),
            ]

        counts: dict[str, int] = {}
        for event in events:
            stage = str(event.get("stage", "?"))
            counts[stage] = counts.get(stage, 0) + 1
        summary = ", ".join(f"{s}={n}" for s, n in sorted(counts.items()))
        last = events[-1]
        last_line = (
            f"last: {last.get('stage', '?')} "
            f"{str(last.get('destination') or '?')[:16]}"
            + (f" ({last.get('reason')})" if last.get("reason") else "")
        )
        status = "warn" if counts.get("failed") or counts.get("dropped") else "info"
        return [
            CheckResult(
                name="delivery_timeline",
                status=status,
                detail=f"{source}: {summary}. {last_line}",
                hint=(
                    "Recent delivery failures or drops in the stream."
                    if status == "warn"
                    else None
                ),
                category="send",
            ),
        ]

    def check_send_pipeline(self) -> list[CheckResult]:
        """Internal reasons messages would or would not leave the bot."""
        results: list[CheckResult] = []
        results.extend(self.check_delivery_config())
        results.extend(self.check_storage_history())

        if not self.bot:
            results.append(
                CheckResult(
                    name="bot_runtime",
                    status="info",
                    detail="no live bot attached (config-only doctor)",
                    hint="Queue/retry live state needs a running bot. "
                    "Storage history above is read from disk when present.",
                    category="send",
                ),
            )
            results.append(
                CheckResult(
                    name="send_prerequisites",
                    status="info",
                    detail=(
                        "send() requires: valid 32-hex dest, Identity.recall, "
                        "online interface, free outbound queue slot"
                    ),
                    category="send",
                ),
            )
            return results

        cfg = self.bot.config
        results.append(
            CheckResult(
                name="test_mode",
                status="fail" if cfg.test_mode else "ok",
                detail=str(cfg.test_mode),
                hint="test_mode skips real RNS/LXMF delivery."
                if cfg.test_mode
                else None,
                category="send",
            ),
        )

        outbound = None
        peer_count = 0
        if hasattr(self.bot, "get_propagation_node_status"):
            try:
                prop = self.bot.get_propagation_node_status()
                outbound = prop.get("current_outbound_node")
                peer_count = len(prop.get("discovered_peers") or [])
            except Exception:
                logger.debug("propagation status probe failed", exc_info=True)

        if outbound:
            results.append(
                CheckResult(
                    name="outbound_propagation_node",
                    status="ok",
                    detail=redact_hash(outbound) if self.privacy else outbound,
                    category="send",
                ),
            )
        results.append(
            CheckResult(
                name="discovered_prop_peers",
                status="info",
                detail=str(peer_count),
                category="send",
            ),
        )

        queue = getattr(self.bot, "queue", None)
        if queue is not None:
            qsize = queue.qsize()
            maxsize = getattr(queue, "maxsize", 0) or 0
            full = bool(maxsize and qsize >= maxsize)
            results.append(
                CheckResult(
                    name="outbound_queue",
                    status="fail" if full else "ok",
                    detail=f"{qsize}/{maxsize}",
                    hint="Queue full: new sends drop oldest or fail." if full else None,
                    category="send",
                ),
            )

        router = getattr(self.bot, "router", None)
        local = getattr(self.bot, "local", None)
        results.append(
            CheckResult(
                name="lxmf_router",
                status="ok" if router and local else "fail",
                detail="ready" if router and local else "not ready",
                hint=(
                    "Router/local delivery identity missing. Bot cannot "
                    "send or receive on the mesh."
                    if not (router and local)
                    else None
                ),
                category="send",
            ),
        )

        stamp = cfg.stamp_cost
        results.append(
            CheckResult(
                name="inbound_stamp_cost",
                status="info",
                detail=str(stamp),
                hint=(
                    "Applies to inbound delivery only. "
                    "Outbound stamp cost comes from peer announces "
                    "unless send(stamp_cost=...) overrides it."
                ),
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="include_tickets",
                status="ok" if getattr(cfg, "include_tickets", True) else "warn",
                detail=str(getattr(cfg, "include_tickets", True)),
                hint=(
                    None
                    if getattr(cfg, "include_tickets", True)
                    else "Peers that require stamps may not be able to reply quickly."
                ),
                category="send",
            ),
        )
        if cfg.require_stamps:
            results.append(
                CheckResult(
                    name="require_stamps",
                    status="info",
                    detail="True (inbound unsigned/invalid stamps rejected)",
                    category="receive",
                ),
            )

        if router is not None:
            deferred = getattr(router, "pending_deferred_stamps", None) or {}
            lxmf_outbound = getattr(router, "pending_outbound", None) or []
            results.append(
                CheckResult(
                    name="deferred_stamps",
                    status="warn" if deferred else "ok",
                    detail=f"{len(deferred)} pending",
                    hint=(
                        "Outbound messages are waiting on stamp generation. "
                        "High peer stamp costs delay replies."
                        if deferred
                        else None
                    ),
                    category="send",
                ),
            )
            results.append(
                CheckResult(
                    name="pending_outbound",
                    status="info",
                    detail=f"{len(lxmf_outbound)} in LXMF outbound queue",
                    category="send",
                ),
            )

        return results

    def check_bot_identity(self) -> list[CheckResult]:
        """Report bot delivery destination if available."""
        results: list[CheckResult] = []
        bot_hash = self._bot_hash()
        if bot_hash:
            detail = redact_hash(bot_hash) if self.privacy else bot_hash
            results.append(
                CheckResult(
                    name="bot_delivery_hash",
                    status="ok",
                    detail=detail,
                    category="receive",
                ),
            )
        else:
            identity_file = os.path.join(self.config_path, "identity")
            if os.path.isfile(identity_file):
                try:
                    import RNS

                    # Compute hash only. Do NOT construct Destination(IN, ...)
                    # here: that registers lxmf/delivery on Transport and breaks
                    # a later bot start in the same process
                    # ("Attempt to register an already registered destination").
                    identity = RNS.Identity.from_file(identity_file)
                    dest_hash = RNS.Destination.hash(identity, "lxmf", "delivery")
                    h = RNS.hexrep(dest_hash, delimit=False)
                    detail = redact_hash(h) if self.privacy else h
                    results.append(
                        CheckResult(
                            name="bot_delivery_hash",
                            status="ok",
                            detail=detail + " (from identity file)",
                            category="receive",
                        ),
                    )
                except Exception as e:
                    results.append(
                        CheckResult(
                            name="bot_delivery_hash",
                            status="warn",
                            detail=f"failed to load identity: {e}",
                            category="receive",
                        ),
                    )
            else:
                results.append(
                    CheckResult(
                        name="bot_delivery_hash",
                        status="info",
                        detail="no bot identity yet",
                        hint="Pass --config at a bot config dir, or run with "
                        "a live bot.",
                        category="receive",
                    ),
                )

        if self.bot:
            sig = self.bot.config.signature_verification_enabled
            req = self.bot.config.require_message_signatures
            results.append(
                CheckResult(
                    name="signature_policy",
                    status="info",
                    detail=f"verification={sig} require={req}",
                    hint=(
                        "require_message_signatures=True rejects unsigned inbound."
                        if req
                        else None
                    ),
                    category="receive",
                ),
            )

        return results

    def probe_destination(
        self,
        destination: str,
        *,
        request_path: bool = False,
        wait: float = 0.0,
    ) -> DestinationProbe:
        """Probe path and identity knowledge for a destination."""
        cleaned = normalize_destination_hex(destination)
        dest_bytes = parse_destination_hash(cleaned)
        probe = DestinationProbe(
            destination=cleaned or destination,
            valid_hash=dest_bytes is not None,
        )

        if dest_bytes is None:
            probe.notes.append("Invalid destination hash")
            probe.hints.append(
                f"Hash must be {_hash_len() * 2} hex characters ({_hash_len()} bytes).",
            )
            return probe

        if not self.ensure_reticulum():
            probe.notes.append("Reticulum not initialized")
            probe.hints.append("Fix Reticulum config and retry.")
            return probe

        import RNS

        t0 = time.monotonic()
        probe.timeline.append({"t": 0.0, "event": "probe_start"})

        identity = RNS.Identity.recall(dest_bytes)
        probe.identity_known = identity is not None
        probe.timeline.append(
            {
                "t": round(time.monotonic() - t0, 3),
                "event": "identity_recalled"
                if probe.identity_known
                else "identity_unknown",
            },
        )
        probe.has_path = bool(RNS.Transport.has_path(dest_bytes))
        probe.timeline.append(
            {
                "t": round(time.monotonic() - t0, 3),
                "event": "path_present" if probe.has_path else "path_missing",
            },
        )

        if probe.has_path:
            try:
                hops = RNS.Transport.hops_to(dest_bytes)
                probe.hops = int(hops) if hops is not None else None
            except Exception:
                probe.hops = None
            try:
                nh = RNS.Transport.next_hop(dest_bytes)
                if nh:
                    probe.next_hop = RNS.hexrep(nh, delimit=False)
            except Exception:
                logger.debug("next_hop probe failed", exc_info=True)

        try:
            app_data = RNS.Identity.recall_app_data(dest_bytes)
            if app_data:
                if isinstance(app_data, bytes):
                    probe.app_data = app_data.decode("utf-8", errors="replace")
                else:
                    probe.app_data = str(app_data)
        except Exception:
            logger.debug("app_data probe failed", exc_info=True)

        if not probe.identity_known:
            probe.notes.append("Identity not recalled")
            probe.hints.append(
                "LXMFy send() returns False until Identity.recall succeeds. "
                "Peer must announce on a network you share.",
            )
        if not probe.has_path:
            probe.notes.append("No path known")
            probe.hints.append(
                "No route to destination. Ensure interfaces are online and "
                "you share a transport path with the peer.",
            )

        if request_path and (not probe.has_path or not probe.identity_known):
            try:
                RNS.Transport.request_path(dest_bytes)
                probe.path_requested = True
                probe.notes.append("Path requested")
                probe.timeline.append(
                    {
                        "t": round(time.monotonic() - t0, 3),
                        "event": "path_requested",
                    },
                )
            except Exception as e:
                probe.notes.append(f"Path request failed: {e}")
                probe.timeline.append(
                    {
                        "t": round(time.monotonic() - t0, 3),
                        "event": f"path_request_failed:{e}",
                    },
                )

            if wait and wait > 0:
                probe.path_waited = True
                deadline = time.time() + wait
                while time.time() < deadline:
                    if RNS.Transport.has_path(dest_bytes):
                        probe.has_path = True
                        probe.path_found_after_wait = True
                        elapsed = round(time.monotonic() - t0, 3)
                        identity = RNS.Identity.recall(dest_bytes)
                        probe.identity_known = identity is not None
                        try:
                            hops = RNS.Transport.hops_to(dest_bytes)
                            probe.hops = int(hops) if hops is not None else None
                        except Exception:
                            logger.debug("hops_to probe failed", exc_info=True)
                        probe.notes.append(
                            f"Path became available after {elapsed}s",
                        )
                        probe.timeline.append(
                            {"t": elapsed, "event": "path_found"},
                        )
                        if probe.identity_known:
                            probe.timeline.append(
                                {
                                    "t": elapsed,
                                    "event": "identity_recalled_after_path",
                                },
                            )
                        break
                    time.sleep(0.25)
                else:
                    elapsed = round(time.monotonic() - t0, 3)
                    probe.notes.append(
                        f"No path within {wait}s after request (waited {elapsed}s)",
                    )
                    probe.timeline.append(
                        {"t": elapsed, "event": "path_timeout"},
                    )
                    probe.hints.append(
                        "Path request timed out. Peer may be offline, on a "
                        "disjoint network, or not announcing.",
                    )

        if probe.identity_known and probe.has_path:
            probe.hints.append(
                "Identity and path look good. If send still fails, check "
                "delivery retries, opportunistic vs DIRECT, and propagation.",
            )
        elif probe.identity_known and not probe.has_path:
            probe.hints.append(
                "Identity known but no path. Request path and wait.",
            )

        return probe

    def diagnose_send(
        self,
        destination: str,
        *,
        probe: DestinationProbe | None = None,
    ) -> list[CheckResult]:
        """Explain why sending to a destination may fail."""
        if probe is None:
            probe = self.probe_destination(destination)
        results: list[CheckResult] = []
        dest_show = (
            redact_hash(probe.destination) if self.privacy else probe.destination
        )

        results.append(
            CheckResult(
                name="destination_hash",
                status="ok" if probe.valid_hash else "fail",
                detail=dest_show,
                hint=None if probe.valid_hash else f"Need {_hash_len() * 2} hex chars.",
                category="destination",
            ),
        )
        if not probe.valid_hash:
            return results

        results.append(
            CheckResult(
                name="identity_known",
                status="ok" if probe.identity_known else "fail",
                detail=str(probe.identity_known),
                hint=None
                if probe.identity_known
                else "send() aborts when Identity.recall fails. "
                "Use: lxmfy debug probe <hash> --request-path --wait 30",
                category="destination",
            ),
        )
        results.append(
            CheckResult(
                name="has_path",
                status="ok" if probe.has_path else "fail",
                detail=(f"True (hops={probe.hops})" if probe.has_path else "False"),
                hint=None
                if probe.has_path
                else "No route. Bring interfaces online and ensure a shared "
                "Reticulum transport path.",
                category="destination",
            ),
        )
        if probe.next_hop:
            nh = redact_hash(probe.next_hop) if self.privacy else probe.next_hop
            results.append(
                CheckResult(
                    name="next_hop",
                    status="info",
                    detail=nh,
                    category="destination",
                ),
            )
        if probe.app_data:
            ad = redact_display_name(probe.app_data) if self.privacy else probe.app_data
            results.append(
                CheckResult(
                    name="announce_app_data",
                    status="info",
                    detail=ad,
                    category="destination",
                ),
            )

        # Would send() accept this right now?
        can_queue = probe.valid_hash and probe.identity_known
        results.append(
            CheckResult(
                name="send_would_queue",
                status="ok" if can_queue else "fail",
                detail=str(can_queue),
                hint=None
                if can_queue
                else "LXMFy will not enqueue: identity unknown or bad hash.",
                category="destination",
            ),
        )

        if probe.valid_hash:
            try:
                import RNS

                dest_bytes = parse_destination_hash(probe.destination)
                ratchet_id = (
                    RNS.Identity.current_ratchet_id(dest_bytes)
                    if dest_bytes is not None
                    else None
                )
                results.append(
                    CheckResult(
                        name="peer_ratchet",
                        status="ok" if ratchet_id else "warn",
                        detail=(
                            RNS.hexrep(ratchet_id, delimit=False)
                            if ratchet_id
                            else "unknown"
                        ),
                        hint=(
                            None
                            if ratchet_id
                            else "No announced ratchet yet. Opportunistic sends "
                            "use static identity encryption until an announce arrives."
                        ),
                        category="destination",
                    ),
                )
                if self.bot and getattr(self.bot, "router", None):
                    peer_stamp = self.bot.router.get_outbound_stamp_cost(dest_bytes)
                    ticket = self.bot.router.get_outbound_ticket(dest_bytes)
                    results.append(
                        CheckResult(
                            name="peer_stamp_cost",
                            status="info",
                            detail=str(peer_stamp),
                            hint=(
                                "Peer requires stamps. Replies wait on stamp "
                                "generation unless an outbound ticket is available."
                                if peer_stamp
                                else None
                            ),
                            category="destination",
                        ),
                    )
                    results.append(
                        CheckResult(
                            name="outbound_ticket",
                            status="ok" if ticket or not peer_stamp else "warn",
                            detail="present" if ticket else "none",
                            hint=(
                                None
                                if ticket or not peer_stamp
                                else "No ticket from this peer. Stamp generation "
                                "may delay or fail replies to stamp-requiring clients."
                            ),
                            category="destination",
                        ),
                    )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="peer_stamp_ratchet",
                        status="warn",
                        detail=f"check failed: {e}",
                        category="destination",
                    ),
                )

        if self.bot:
            attempts = (getattr(self.bot, "delivery_attempts", None) or {}).get(
                normalize_destination_hex(destination),
                0,
            )
            max_retries = getattr(self.bot.config, "direct_delivery_retries", 3)
            results.append(
                CheckResult(
                    name="delivery_attempts_for_dest",
                    status="warn" if attempts else "ok",
                    detail=f"{attempts}/{max_retries}",
                    hint=(
                        "At or past retries, LXMFy switches to PROPAGATED "
                        "if fallback is enabled."
                        if attempts >= max_retries
                        else None
                    ),
                    category="destination",
                ),
            )
            if attempts >= max_retries and self.bot.config.propagation_fallback_enabled:
                results.append(
                    CheckResult(
                        name="method_for_dest",
                        status="info",
                        detail="PROPAGATED (retry limit reached)",
                        category="destination",
                    ),
                )
            elif self.bot.config.opportunistic_sending:
                results.append(
                    CheckResult(
                        name="method_for_dest",
                        status="info",
                        detail="OPPORTUNISTIC",
                        category="destination",
                    ),
                )
            else:
                results.append(
                    CheckResult(
                        name="method_for_dest",
                        status="info",
                        detail="DIRECT",
                        category="destination",
                    ),
                )

        return results

    def diagnose_receive(self) -> list[CheckResult]:
        """Explain why peers may not be able to reach this bot."""
        results = self.check_bot_identity()
        results.extend(self.check_interfaces())
        results.extend(self.check_announce())

        bot_hash = self._bot_hash()
        if bot_hash and self.ensure_reticulum():
            try:
                import RNS

                dest = bytes.fromhex(bot_hash)
                has = RNS.Transport.has_path(dest)
                results.append(
                    CheckResult(
                        name="local_path_to_self",
                        status="info",
                        detail=str(has),
                        hint="Peers need a path to your delivery hash. "
                        "Announce after interfaces are online.",
                        category="receive",
                    ),
                )
            except Exception:
                logger.debug("local path probe failed", exc_info=True)

        results.append(
            CheckResult(
                name="receive_tip",
                status="info",
                detail="Share your LXMF delivery hash with peers",
                hint="Printed at startup as 'LXMF Router ready to receive on: …'",
                category="receive",
            ),
        )
        return results

    def _collect_blockers(
        self,
        checks: list[CheckResult],
    ) -> tuple[list[str], list[str]]:
        send_keys = {
            "interfaces",
            "interfaces_online",
            "lxmf_router",
            "outbound_queue",
            "test_mode",
            "identity_known",
            "has_path",
            "destination_hash",
            "send_would_queue",
            "share_instance",
            "instance_mode",
            "config_path",
            "reticulum_config_dir",
            "storage_path",
            "reticulum_config",
        }
        recv_keys = {
            "interfaces",
            "interfaces_online",
            "announce_enabled",
            "announce_test",
            "announce_file",
            "lxmf_router",
            "bot_delivery_hash",
            "identity_file",
            "instance_mode",
        }
        send_blockers: list[str] = []
        recv_blockers: list[str] = []
        for c in checks:
            if c.status != "fail":
                continue
            line = f"{c.name}: {c.detail}"
            base = c.name.split(":", 1)[0]
            if (
                c.name in send_keys
                or base in send_keys
                or c.category
                in {
                    "send",
                    "destination",
                    "network",
                    "disk",
                    "instance",
                }
            ):
                send_blockers.append(line)
            if (
                c.name in recv_keys
                or base in recv_keys
                or c.category
                in {
                    "receive",
                    "announce",
                    "network",
                    "disk",
                    "instance",
                }
            ):
                recv_blockers.append(line)
        return send_blockers, recv_blockers

    def run_doctor(
        self,
        destination: str | None = None,
        *,
        request_path: bool = False,
        wait: float = 0.0,
        try_announce: bool = False,
    ) -> DoctorReport:
        """Run a full connectivity doctor report."""
        report = DoctorReport(
            reticulum_config_dir=self.reticulum_config_dir,
            bot_hash=self._bot_hash(),
            tips=list(COMMON_TIPS),
            generated_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
            lxmfy_version=LXMFY_VERSION,
            privacy=self.privacy,
        )
        report.checks.extend(self.check_environment())
        report.checks.extend(self.check_instance())
        report.checks.extend(self.check_disk_permissions())
        report.checks.extend(self.check_interfaces())
        report.checks.extend(self.check_announce(try_announce=try_announce))
        report.checks.extend(self.check_send_pipeline())
        report.checks.extend(self.check_delivery_timeline())
        report.checks.extend(self.check_bot_identity())

        if destination:
            report.probe = self.probe_destination(
                destination,
                request_path=request_path,
                wait=wait,
            )
            report.checks.extend(
                self.diagnose_send(destination, probe=report.probe),
            )

        send_b, recv_b = self._collect_blockers(report.checks)
        report.send_blockers = send_b
        report.receive_blockers = recv_b
        report.checks = sort_checks_by_severity(report.checks)
        verdict, steps = build_verdict(
            report.checks,
            send_blockers=send_b,
            receive_blockers=recv_b,
            probe=report.probe,
        )
        report.verdict = verdict
        report.next_steps = steps
        return report

    def compare_destinations(
        self,
        left: str,
        right: str,
        *,
        request_path: bool = False,
        wait: float = 0.0,
    ) -> dict[str, Any]:
        """Compare path/identity status for two destination hashes."""
        a = self.probe_destination(left, request_path=request_path, wait=wait)
        b = self.probe_destination(right, request_path=request_path, wait=wait)
        result: dict[str, Any] = {
            "left": a.to_dict(privacy=self.privacy),
            "right": b.to_dict(privacy=self.privacy),
            "both_valid": a.valid_hash and b.valid_hash,
            "both_identity_known": a.identity_known and b.identity_known,
            "both_have_path": a.has_path and b.has_path,
            "notes": [],
        }
        if a.valid_hash and b.valid_hash and a.destination == b.destination:
            result["notes"].append("Both hashes normalize to the same destination")
        if a.identity_known and not b.identity_known:
            result["notes"].append("Left identity known, right unknown")
        elif b.identity_known and not a.identity_known:
            result["notes"].append("Right identity known, left unknown")
        if a.has_path and not b.has_path:
            result["notes"].append("Path exists to left only")
        elif b.has_path and not a.has_path:
            result["notes"].append("Path exists to right only")
        if result["both_identity_known"] and result["both_have_path"]:
            result["notes"].append("Both sides look reachable from this node")
        return result

    def format_report_text(self, report: DoctorReport) -> str:
        """Format a plain-text privacy-friendly report for files/sharing."""
        lines: list[str] = []
        data = report.to_dict()
        lines.append("=" * 60)
        lines.append("LXMFy Debugger Report")
        lines.append("=" * 60)
        lines.append(f"generated_at: {data.get('generated_at')}")
        lines.append(f"lxmfy_version: {data.get('lxmfy_version')}")
        lines.append(f"privacy_redacted: {data.get('privacy')}")
        lines.append(f"reticulum_config: {data.get('reticulum_config_dir')}")
        lines.append(f"bot_delivery_hash: {data.get('bot_hash') or '(unknown)'}")
        lines.append(f"verdict: {data.get('verdict')}")
        lines.append("")
        if report.next_steps:
            lines.append("-" * 60)
            lines.append("Next Steps")
            lines.append("-" * 60)
            for i, step in enumerate(report.next_steps, 1):
                lines.append(f"  {i}. {step}")
            lines.append("")

        by_cat: dict[str, list[CheckResult]] = {}
        for check in report.checks:
            by_cat.setdefault(check.category, []).append(check)

        for cat in CATEGORY_ORDER:
            items = by_cat.get(cat) or []
            if not items:
                continue
            lines.append("-" * 60)
            lines.append(CATEGORY_TITLES.get(cat, cat.upper()))
            lines.append("-" * 60)
            for c in sort_checks_by_severity(items):
                detail = c.detail
                if report.privacy:
                    detail = redact_sensitive_text(detail)
                lines.append(f"[{c.status.upper():5}] {c.name}: {detail}")
                if c.hint:
                    hint = c.hint
                    if report.privacy:
                        hint = redact_sensitive_text(hint)
                    lines.append(f"         hint: {hint}")
            lines.append("")

        if report.probe:
            p = report.probe.to_dict(privacy=report.privacy)
            lines.append("-" * 60)
            lines.append("Destination Probe Detail")
            lines.append("-" * 60)
            for key, val in p.items():
                if val in (None, "", [], False) and key not in {
                    "valid_hash",
                    "identity_known",
                    "has_path",
                }:
                    continue
                lines.append(f"  {key}: {val}")
            lines.append("")

        lines.append("-" * 60)
        lines.append("Blockers")
        lines.append("-" * 60)
        if report.send_blockers:
            lines.append("send:")
            lines.extend(
                f"  - {redact_sensitive_text(b) if report.privacy else b}"
                for b in report.send_blockers
            )
        else:
            lines.append("send: (none)")
        if report.receive_blockers:
            lines.append("receive:")
            lines.extend(
                f"  - {redact_sensitive_text(b) if report.privacy else b}"
                for b in report.receive_blockers
            )
        else:
            lines.append("receive: (none)")
        lines.append("")

        lines.append("-" * 60)
        lines.append("Summary")
        lines.append("-" * 60)
        lines.append(
            f"failures={data['failures']} warnings={data['warnings']} ok={data['ok']}",
        )
        lines.append("")
        lines.append("Tips:")
        lines.extend(f"  - {tip}" for tip in report.tips)
        lines.append("")
        lines.append(
            "Share this file when asking for help. It is privacy-redacted "
            "by default (home paths and full hashes truncated).",
        )
        lines.append("")
        return "\n".join(lines)

    def save_report(
        self,
        report: DoctorReport,
        path: str | None = None,
        *,
        as_json: bool = False,
    ) -> str:
        """Write report to a file and return the path used."""
        if path is None:
            stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
            ext = "json" if as_json else "txt"
            path = os.path.abspath(f"lxmfy-debug-{stamp}.{ext}")
        else:
            path = os.path.abspath(os.path.expanduser(path))

        parent = os.path.dirname(path)
        if parent:
            os.makedirs(parent, exist_ok=True)

        if as_json or path.endswith(".json"):
            content = json.dumps(report.to_dict(), indent=2, default=str)
        else:
            content = self.format_report_text(report)

        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
            if not content.endswith("\n"):
                f.write("\n")
        return path

    def print_report(
        self,
        report: DoctorReport,
        *,
        as_json: bool = False,
    ) -> None:
        """Print a doctor report to the terminal."""
        if as_json:
            print(json.dumps(report.to_dict(), indent=2, default=str))
            return

        print_header("LXMFy Debugger")
        print_kv("generated", report.generated_at or "?")
        print_kv("lxmfy", report.lxmfy_version or LXMFY_VERSION)
        print_kv(
            "reticulum_config",
            redact_path(report.reticulum_config_dir)
            if report.privacy
            else (report.reticulum_config_dir or "?"),
        )
        if report.bot_hash:
            shown = redact_hash(report.bot_hash) if report.privacy else report.bot_hash
            print_kv("bot_delivery_hash", shown, ok=True)
        else:
            print_kv("bot_delivery_hash", "(unknown)", ok=None)
        print_kv("privacy_redacted", str(report.privacy))
        print_kv("verdict", report.verdict or "unknown")
        if report.next_steps:
            print_section("Next Steps")
            for i, step in enumerate(report.next_steps, 1):
                print_info(f"{i}. {step}")

        by_cat: dict[str, list[CheckResult]] = {}
        for check in report.checks:
            by_cat.setdefault(check.category, []).append(check)

        for cat in CATEGORY_ORDER:
            items = by_cat.get(cat) or []
            if not items:
                continue
            print_section(CATEGORY_TITLES.get(cat, cat))
            for check in sort_checks_by_severity(items):
                detail = check.detail
                hint = check.hint
                if report.privacy:
                    detail = redact_sensitive_text(detail)
                    if hint:
                        hint = redact_sensitive_text(hint)
                print_check(check.name, check.status, detail, hint)

        if report.probe:
            print_section("Destination Probe")
            p = report.probe
            dest = redact_hash(p.destination) if report.privacy else p.destination
            print_kv("destination", dest)
            print_kv("valid_hash", str(p.valid_hash), ok=p.valid_hash)
            print_kv("identity_known", str(p.identity_known), ok=p.identity_known)
            print_kv("has_path", str(p.has_path), ok=p.has_path)
            if p.hops is not None:
                print_kv("hops", str(p.hops))
            if p.next_hop:
                nh = redact_hash(p.next_hop) if report.privacy else p.next_hop
                print_kv("next_hop", nh)
            if p.app_data:
                ad = redact_display_name(p.app_data) if report.privacy else p.app_data
                print_kv("app_data", ad)
            if p.path_requested:
                print_kv(
                    "path_requested",
                    "yes"
                    + (f", found={p.path_found_after_wait}" if p.path_waited else ""),
                )
            if p.timeline:
                print_section("Path Timeline")
                for entry in p.timeline:
                    print_dim(f"+{entry.get('t', '?')}s  {entry.get('event')}")
            for note in p.notes:
                print_dim(note)
            for hint in p.hints:
                print_warning(hint)

        print_section("Blockers")
        if report.send_blockers:
            print_error("Send blockers:")
            for b in report.send_blockers:
                print_dim(redact_sensitive_text(b) if report.privacy else b)
        else:
            print_success("No hard send blockers detected")
        if report.receive_blockers:
            print_error("Receive blockers:")
            for b in report.receive_blockers:
                print_dim(redact_sensitive_text(b) if report.privacy else b)
        else:
            print_success("No hard receive blockers detected")

        print_section("Summary")
        data = report.to_dict()
        failures = data["failures"]
        warnings = data["warnings"]
        if failures:
            print_error(f"{failures} failure(s), {warnings} warning(s)")
        elif warnings:
            print_warning(f"No failures, {warnings} warning(s)")
        else:
            print_success("No failures or warnings")

        print_section("Common tips")
        for tip in report.tips:
            print_info(tip)

    def print_probe(
        self,
        probe: DestinationProbe,
        *,
        as_json: bool = False,
    ) -> None:
        """Print a destination probe result."""
        if as_json:
            print(json.dumps(probe.to_dict(privacy=self.privacy), indent=2))
            return

        print_header("Destination Probe")
        dest = redact_hash(probe.destination) if self.privacy else probe.destination
        print_kv("destination", dest)
        print_kv("valid_hash", str(probe.valid_hash), ok=probe.valid_hash)
        print_kv(
            "identity_known",
            str(probe.identity_known),
            ok=probe.identity_known,
        )
        print_kv("has_path", str(probe.has_path), ok=probe.has_path)
        if probe.hops is not None:
            print_kv("hops", str(probe.hops))
        if probe.next_hop:
            nh = redact_hash(probe.next_hop) if self.privacy else probe.next_hop
            print_kv("next_hop", nh)
        if probe.app_data:
            ad = redact_display_name(probe.app_data) if self.privacy else probe.app_data
            print_kv("app_data", ad)
        for note in probe.notes:
            print_dim(note)
        for hint in probe.hints:
            print_warning(hint)

        if probe.valid_hash and probe.identity_known and probe.has_path:
            print_success("Ready to attempt send to this destination")
        elif probe.valid_hash:
            print_error("Not ready to send (see hints above)")

    def _bot_hash(self) -> str | None:
        if not self.bot:
            return None
        local = getattr(self.bot, "local", None)
        if local is None or getattr(local, "hash", None) is None:
            return None
        try:
            import RNS

            return RNS.hexrep(local.hash, delimit=False)
        except Exception:
            try:
                return bytes(local.hash).hex()
            except Exception:
                return None

__init__(*, reticulum_config_dir=None, config_path=None, bot=None, privacy=True)

Create a debugger.

Parameters:

Name Type Description Default
reticulum_config_dir str | None

Explicit Reticulum config directory.

None
config_path str | None

Bot config directory (identity, announce state).

None
bot Any | None

Optional running LXMFBot instance to introspect.

None
privacy bool

Redact paths/hashes in reports (default True).

True
Source code in lxmfy/debugger.py
def __init__(
    self,
    *,
    reticulum_config_dir: str | None = None,
    config_path: str | None = None,
    bot: Any | None = None,
    privacy: bool = True,
):
    """Create a debugger.

    Args:
        reticulum_config_dir: Explicit Reticulum config directory.
        config_path: Bot config directory (identity, announce state).
        bot: Optional running LXMFBot instance to introspect.
        privacy: Redact paths/hashes in reports (default True).

    """
    self.bot = bot
    self.privacy = privacy
    self.config_path = os.path.abspath(
        config_path
        or (getattr(bot, "config_path", None) if bot else None)
        or os.path.join(os.getcwd(), "config"),
    )
    if bot and getattr(bot, "reticulum_config_dir", None):
        self.reticulum_config_dir = os.path.abspath(bot.reticulum_config_dir)
    elif reticulum_config_dir:
        self.reticulum_config_dir = os.path.abspath(
            os.path.expanduser(reticulum_config_dir),
        )
    else:
        self.reticulum_config_dir = resolve_reticulum_config_dir(
            None,
            self.config_path,
        )
    self._reticulum_ready = False
    self._storage_path = self._resolve_storage_path()

check_announce(*, try_announce=False)

Announce configuration and optional live announce test.

Source code in lxmfy/debugger.py
def check_announce(self, *, try_announce: bool = False) -> list[CheckResult]:
    """Announce configuration and optional live announce test."""
    results: list[CheckResult] = []
    enabled = True
    interval = 600
    if self.bot:
        enabled = bool(self.bot.config.announce_enabled)
        interval = int(self.bot.config.announce or 0)

    results.append(
        CheckResult(
            name="announce_enabled",
            status="ok" if enabled else "fail",
            detail=str(enabled),
            hint=None
            if enabled
            else "Peers cannot discover you. Set announce_enabled=True.",
            category="announce",
        ),
    )
    results.append(
        CheckResult(
            name="announce_interval",
            status="ok" if interval >= 300 or interval == 0 else "warn",
            detail=f"{interval}s",
            hint="Intervals under 300s can spam the network."
            if 0 < interval < 300
            else None,
            category="announce",
        ),
    )

    announce_path = os.path.join(self.config_path, "announce")
    if os.path.isfile(announce_path):
        try:
            with open(announce_path, encoding="utf-8") as f:
                next_at = int(f.readline().strip() or "0")
            remaining = next_at - int(time.time())
            if remaining > 0:
                detail = f"throttle active, next in ~{remaining}s"
                status = "ok"
            else:
                detail = "throttle elapsed (announce due)"
                status = "info"
            results.append(
                CheckResult(
                    name="announce_throttle",
                    status=status,
                    detail=detail,
                    category="announce",
                ),
            )
        except Exception as e:
            results.append(
                CheckResult(
                    name="announce_throttle",
                    status="warn",
                    detail=f"unreadable: {e}",
                    category="announce",
                ),
            )
    else:
        results.append(
            CheckResult(
                name="announce_throttle",
                status="info",
                detail="no throttle file yet (bot has not announced)",
                hint="If the bot never announced, peers will not learn "
                "your delivery hash.",
                category="announce",
            ),
        )

    if try_announce:
        if not self.bot:
            results.append(
                CheckResult(
                    name="announce_test",
                    status="warn",
                    detail="skipped (no live bot)",
                    hint="Run debugger against a running bot, or start "
                    "the bot so announce_now can be called.",
                    category="announce",
                ),
            )
        elif not enabled:
            results.append(
                CheckResult(
                    name="announce_test",
                    status="fail",
                    detail="skipped (announce_enabled=False)",
                    category="announce",
                ),
            )
        elif not getattr(self.bot, "local", None):
            results.append(
                CheckResult(
                    name="announce_test",
                    status="fail",
                    detail="no local LXMF destination (test_mode?)",
                    category="announce",
                ),
            )
        else:
            try:
                self.bot.announce_now(force=True)
                results.append(
                    CheckResult(
                        name="announce_test",
                        status="ok",
                        detail="announce_now(force=True) completed",
                        category="announce",
                    ),
                )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="announce_test",
                        status="fail",
                        detail=str(e),
                        hint="Announce failed. Check interfaces online and "
                        "disk write access to the announce throttle file.",
                        category="announce",
                    ),
                )
    else:
        results.append(
            CheckResult(
                name="announce_test",
                status="info",
                detail="not run (pass --announce-test to try live announce)",
                category="announce",
            ),
        )

    return results

check_bot_identity()

Report bot delivery destination if available.

Source code in lxmfy/debugger.py
def check_bot_identity(self) -> list[CheckResult]:
    """Report bot delivery destination if available."""
    results: list[CheckResult] = []
    bot_hash = self._bot_hash()
    if bot_hash:
        detail = redact_hash(bot_hash) if self.privacy else bot_hash
        results.append(
            CheckResult(
                name="bot_delivery_hash",
                status="ok",
                detail=detail,
                category="receive",
            ),
        )
    else:
        identity_file = os.path.join(self.config_path, "identity")
        if os.path.isfile(identity_file):
            try:
                import RNS

                # Compute hash only. Do NOT construct Destination(IN, ...)
                # here: that registers lxmf/delivery on Transport and breaks
                # a later bot start in the same process
                # ("Attempt to register an already registered destination").
                identity = RNS.Identity.from_file(identity_file)
                dest_hash = RNS.Destination.hash(identity, "lxmf", "delivery")
                h = RNS.hexrep(dest_hash, delimit=False)
                detail = redact_hash(h) if self.privacy else h
                results.append(
                    CheckResult(
                        name="bot_delivery_hash",
                        status="ok",
                        detail=detail + " (from identity file)",
                        category="receive",
                    ),
                )
            except Exception as e:
                results.append(
                    CheckResult(
                        name="bot_delivery_hash",
                        status="warn",
                        detail=f"failed to load identity: {e}",
                        category="receive",
                    ),
                )
        else:
            results.append(
                CheckResult(
                    name="bot_delivery_hash",
                    status="info",
                    detail="no bot identity yet",
                    hint="Pass --config at a bot config dir, or run with "
                    "a live bot.",
                    category="receive",
                ),
            )

    if self.bot:
        sig = self.bot.config.signature_verification_enabled
        req = self.bot.config.require_message_signatures
        results.append(
            CheckResult(
                name="signature_policy",
                status="info",
                detail=f"verification={sig} require={req}",
                hint=(
                    "require_message_signatures=True rejects unsigned inbound."
                    if req
                    else None
                ),
                category="receive",
            ),
        )

    return results

check_delivery_config()

Report effective delivery knobs (bot config or framework defaults).

Source code in lxmfy/debugger.py
def check_delivery_config(self) -> list[CheckResult]:
    """Report effective delivery knobs (bot config or framework defaults)."""
    from .config import BotConfig

    cfg = self.bot.config if self.bot else BotConfig()
    results: list[CheckResult] = []
    source = "live_bot" if self.bot else "defaults"
    results.append(
        CheckResult(
            name="delivery_config_source",
            status="info",
            detail=source,
            category="send",
        ),
    )
    results.append(
        CheckResult(
            name="opportunistic_sending",
            status="ok" if cfg.opportunistic_sending else "warn",
            detail=str(cfg.opportunistic_sending),
            hint=None
            if cfg.opportunistic_sending
            else "DIRECT often fails on public TCP/backbone. Prefer "
            "opportunistic_sending=True.",
            category="send",
        ),
    )
    results.append(
        CheckResult(
            name="direct_delivery_retries",
            status="info",
            detail=str(cfg.direct_delivery_retries),
            category="send",
        ),
    )
    has_prop = bool(
        cfg.propagation_node
        or cfg.autopeer_propagation
        or cfg.enable_propagation_node,
    )
    if cfg.propagation_fallback_enabled and not has_prop:
        results.append(
            CheckResult(
                name="propagation_fallback",
                status="warn",
                detail="enabled but no prop node / autopeer",
                hint="Configure propagation_node, enable "
                "autopeer_propagation, or disable "
                "propagation_fallback_enabled.",
                category="send",
            ),
        )
    else:
        results.append(
            CheckResult(
                name="propagation_fallback",
                status="ok",
                detail=(
                    f"enabled={cfg.propagation_fallback_enabled} "
                    f"has_source={has_prop}"
                ),
                category="send",
            ),
        )
    results.append(
        CheckResult(
            name="default_delivery_method",
            status="ok",
            detail=("OPPORTUNISTIC" if cfg.opportunistic_sending else "DIRECT"),
            category="send",
        ),
    )
    results.append(
        CheckResult(
            name="message_persistence",
            status="ok" if cfg.message_persistence_enabled else "warn",
            detail=str(cfg.message_persistence_enabled),
            hint=(
                "Disabled: crash mid-send loses queued outbound messages."
                if not cfg.message_persistence_enabled
                else None
            ),
            category="send",
        ),
    )
    results.append(
        CheckResult(
            name="announce_enabled_config",
            status="ok" if cfg.announce_enabled else "fail",
            detail=str(cfg.announce_enabled),
            category="announce",
        ),
    )
    return results

check_delivery_timeline(limit=10)

Render recent delivery events from the live bot or disk.

Source code in lxmfy/debugger.py
def check_delivery_timeline(self, limit: int = 10) -> list[CheckResult]:
    """Render recent delivery events from the live bot or disk."""
    events: list = []
    source = "none"
    if self.bot and getattr(self.bot, "delivery", None) is not None:
        try:
            events = self.bot.delivery.recent(limit)
            source = "live"
        except Exception:
            logger.debug("live delivery read failed", exc_info=True)
    if not events:
        events_path = os.path.join(self._storage_path, "delivery_events.json")
        if os.path.isfile(events_path):
            try:
                with open(events_path, encoding="utf-8") as f:
                    raw = json.load(f)
                if isinstance(raw, list):
                    events = [e for e in raw if isinstance(e, dict)][-limit:]
                    source = "disk"
            except Exception as e:
                return [
                    CheckResult(
                        name="delivery_timeline",
                        status="warn",
                        detail=f"delivery_events.json unreadable: {e}",
                        category="send",
                    ),
                ]
    if not events:
        return [
            CheckResult(
                name="delivery_timeline",
                status="info",
                detail="no delivery events recorded yet",
                category="send",
            ),
        ]

    counts: dict[str, int] = {}
    for event in events:
        stage = str(event.get("stage", "?"))
        counts[stage] = counts.get(stage, 0) + 1
    summary = ", ".join(f"{s}={n}" for s, n in sorted(counts.items()))
    last = events[-1]
    last_line = (
        f"last: {last.get('stage', '?')} "
        f"{str(last.get('destination') or '?')[:16]}"
        + (f" ({last.get('reason')})" if last.get("reason") else "")
    )
    status = "warn" if counts.get("failed") or counts.get("dropped") else "info"
    return [
        CheckResult(
            name="delivery_timeline",
            status=status,
            detail=f"{source}: {summary}. {last_line}",
            hint=(
                "Recent delivery failures or drops in the stream."
                if status == "warn"
                else None
            ),
            category="send",
        ),
    ]

check_disk_permissions()

Verify config, storage, and Reticulum dirs are usable.

Source code in lxmfy/debugger.py
def check_disk_permissions(self) -> list[CheckResult]:
    """Verify config, storage, and Reticulum dirs are usable."""
    results: list[CheckResult] = []
    targets = [
        ("config_path", self.config_path, True),
        ("reticulum_config_dir", self.reticulum_config_dir, True),
        ("storage_path", self._storage_path, True),
    ]
    for name, path, need_write in targets:
        info = _probe_dir_access(path)
        show = redact_path(path) if self.privacy else path
        if not info["exists"]:
            parent_ok = info["writable"]
            results.append(
                CheckResult(
                    name=name,
                    status="warn" if parent_ok else "fail",
                    detail=f"missing ({show}), parent_writable={parent_ok}",
                    hint=(
                        "Directory will be created on bot start if parent "
                        "is writable."
                        if parent_ok
                        else "Cannot create directory. Fix ownership/permissions."
                    ),
                    category="disk",
                ),
            )
            continue

        if need_write and not info["can_create_file"]:
            results.append(
                CheckResult(
                    name=name,
                    status="fail",
                    detail=(
                        f"{show} exists but not writable "
                        f"({info.get('error') or 'permission denied'})"
                    ),
                    hint="Fix directory ownership/permissions so the bot "
                    "can write identity, announce throttle, and queues.",
                    category="disk",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name=name,
                    status="ok",
                    detail=(
                        f"{show} readable={info['readable']} "
                        f"writable={info['writable']}"
                    ),
                    category="disk",
                ),
            )

    identity = os.path.join(self.config_path, "identity")
    id_info = _probe_file_access(identity)
    show_id = redact_path(identity) if self.privacy else identity
    if id_info["exists"]:
        results.append(
            CheckResult(
                name="identity_file",
                status="ok" if id_info["readable"] else "fail",
                detail=(
                    f"present readable={id_info['readable']} "
                    f"writable={id_info['writable']}"
                ),
                hint=None
                if id_info["readable"]
                else f"Cannot read identity at {show_id}",
                category="disk",
            ),
        )
    else:
        results.append(
            CheckResult(
                name="identity_file",
                status="info",
                detail="absent (created on first bot run)",
                category="disk",
            ),
        )

    announce_file = os.path.join(self.config_path, "announce")
    ann_info = _probe_file_access(announce_file)
    if ann_info["exists"] and not ann_info["writable"]:
        results.append(
            CheckResult(
                name="announce_file",
                status="fail",
                detail="exists but not writable",
                hint="Announce throttle file must be writable or announces "
                "will fail after the first send.",
                category="disk",
            ),
        )
    elif ann_info["exists"]:
        results.append(
            CheckResult(
                name="announce_file",
                status="ok",
                detail="present and writable",
                category="disk",
            ),
        )

    if self.bot and hasattr(self.bot, "get_landlock_status"):
        try:
            ll = self.bot.get_landlock_status()
            active = bool(ll.get("active"))
            results.append(
                CheckResult(
                    name="landlock",
                    status="info" if active else "ok",
                    detail=(
                        f"active={active} supported={ll.get('supported')} "
                        f"enabled={ll.get('config_enabled')}"
                    ),
                    hint=(
                        "Landlock sandbox is active. Unexpected write paths "
                        "outside config/storage/reticulum will fail."
                        if active
                        else None
                    ),
                    category="disk",
                ),
            )
        except Exception as e:
            results.append(
                CheckResult(
                    name="landlock",
                    status="warn",
                    detail=str(e),
                    category="disk",
                ),
            )

    return results

check_environment()

OS, Python, and package versions.

Source code in lxmfy/debugger.py
def check_environment(self) -> list[CheckResult]:
    """OS, Python, and package versions."""
    results: list[CheckResult] = []
    os_detail = f"{platform.system()} {platform.release()} ({platform.machine()})"
    results.append(
        CheckResult(
            name="os",
            status="ok",
            detail=os_detail,
            category="environment",
        ),
    )
    results.append(
        CheckResult(
            name="python",
            status="ok",
            detail=platform.python_version(),
            category="environment",
        ),
    )
    results.append(
        CheckResult(
            name="lxmfy",
            status="ok",
            detail=LXMFY_VERSION,
            category="environment",
        ),
    )
    rns_ver = _package_version("rns")
    lxmf_ver = _package_version("lxmf")
    results.append(
        CheckResult(
            name="rns",
            status="ok" if rns_ver != "unknown" else "warn",
            detail=rns_ver,
            category="environment",
        ),
    )
    results.append(
        CheckResult(
            name="lxmf",
            status="ok" if lxmf_ver != "unknown" else "warn",
            detail=lxmf_ver,
            category="environment",
        ),
    )
    results.append(
        CheckResult(
            name="cwd",
            status="info",
            detail=redact_path(os.getcwd()) if self.privacy else os.getcwd(),
            category="environment",
        ),
    )
    return results

check_instance()

Shared RNS instance vs owned/isolated bot config.

Source code in lxmfy/debugger.py
def check_instance(self) -> list[CheckResult]:
    """Shared RNS instance vs owned/isolated bot config."""
    results: list[CheckResult] = []
    discovered = discover_user_reticulum_config_dir()
    config_file = os.path.join(self.reticulum_config_dir, "config")
    exists = os.path.isfile(config_file)
    isolated = is_isolated_reticulum_dir(
        self.reticulum_config_dir,
        self.config_path,
    )
    share = _read_share_instance(config_file) if exists else None

    results.append(
        CheckResult(
            name="reticulum_config",
            status="ok" if exists else "fail",
            detail=redact_path(self.reticulum_config_dir)
            if self.privacy
            else self.reticulum_config_dir,
            hint=None
            if exists
            else "No config file found. Point --config / "
            "LXMFY_RETICULUM_CONFIG_DIR at a valid RNS config.",
            category="instance",
        ),
    )

    if discovered:
        same = os.path.abspath(discovered) == os.path.abspath(
            self.reticulum_config_dir,
        )
        results.append(
            CheckResult(
                name="user_reticulum_config",
                status="ok" if same else "info",
                detail=(
                    (redact_path(discovered) if self.privacy else discovered)
                    + (" (selected)" if same else " (not selected)")
                ),
                category="instance",
            ),
        )
    else:
        results.append(
            CheckResult(
                name="user_reticulum_config",
                status="warn",
                detail="none discovered",
                hint="No ~/.reticulum / ~/.config/reticulum / "
                "/etc/reticulum config. Bot may be isolated.",
                category="instance",
            ),
        )

    # Clear owned vs shared labeling
    runtime_shared = None
    if self.ensure_reticulum():
        try:
            import RNS

            runtime_shared = bool(
                getattr(RNS.Transport, "is_shared_instance", False),
            )
        except Exception:
            runtime_shared = None

    if isolated:
        if share in {None, "yes", "true", "1"}:
            mode = "owned_misconfigured"
            status = "fail"
            detail = (
                f"owned/isolated config but share_instance="
                f"{share or 'unset (defaults to Yes)'}"
            )
            hint = (
                "Isolated bot configs must set share_instance=No or they "
                "collide with NomadNet/Columba (digest sent was rejected)."
            )
        else:
            mode = "owned"
            status = "ok"
            detail = f"owned/isolated, share_instance={share or 'No'}"
            hint = None
    else:
        mode = "shared"
        status = "ok"
        detail = f"shared/user config, share_instance={share or 'default'}"
        hint = None

    results.append(
        CheckResult(
            name="instance_mode",
            status=status,
            detail=f"{mode}: {detail}",
            hint=hint,
            category="instance",
        ),
    )

    if runtime_shared is not None:
        results.append(
            CheckResult(
                name="runtime_shared_instance",
                status="info",
                detail="yes (joined shared RNS)"
                if runtime_shared
                else "no (own process/instance)",
                category="instance",
            ),
        )

    return results

check_interfaces()

Report Reticulum interface status (no hostnames/IPs).

Source code in lxmfy/debugger.py
def check_interfaces(self) -> list[CheckResult]:
    """Report Reticulum interface status (no hostnames/IPs)."""
    results: list[CheckResult] = []
    if not self.ensure_reticulum():
        return [
            CheckResult(
                name="interfaces",
                status="fail",
                detail="Reticulum not initialized",
                hint="Fix reticulum config and try again.",
                category="network",
            ),
        ]

    try:
        import RNS

        interfaces = list(getattr(RNS.Transport, "interfaces", []) or [])
        if not interfaces:
            results.append(
                CheckResult(
                    name="interfaces",
                    status="fail",
                    detail="no interfaces loaded",
                    hint="Add at least one enabled interface under "
                    "[interfaces] in your Reticulum config.",
                    category="network",
                ),
            )
            return results

        online = 0
        type_counts: dict[str, list[bool]] = {}
        for iface in interfaces:
            name = str(getattr(iface, "name", type(iface).__name__))
            # Avoid leaking host/IP from interface names when possible
            safe_name = name
            if (
                self.privacy
                and any(ch.isdigit() for ch in name)
                and ("." in name or ":" in name)
            ):
                safe_name = f"iface_{len(type_counts)}"
            online_flag = bool(getattr(iface, "online", False))
            if online_flag:
                online += 1
            itype = type(iface).__name__
            type_counts.setdefault(itype, []).append(online_flag)
            results.append(
                CheckResult(
                    name=f"interface:{safe_name}",
                    status="ok" if online_flag else "warn",
                    detail=f"{itype} online={online_flag}",
                    hint=None
                    if online_flag
                    else "Interface offline. Check remote reachability "
                    "and that the interface is enabled.",
                    category="network",
                ),
            )

        if online == 0:
            results.append(
                CheckResult(
                    name="interfaces_online",
                    status="fail",
                    detail=f"0/{len(interfaces)} online",
                    hint="No online interfaces. The bot cannot send or "
                    "receive until at least one interface connects.",
                    category="network",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="interfaces_online",
                    status="ok",
                    detail=f"{online}/{len(interfaces)} online",
                    category="network",
                ),
            )

        summary = ", ".join(
            f"{t}={sum(1 for x in flags if x)}/{len(flags)}"
            for t, flags in sorted(type_counts.items())
        )
        results.append(
            CheckResult(
                name="interface_types",
                status="info",
                detail=summary,
                category="network",
            ),
        )

        # Client-only shared instance is a common false "all green"
        only_local_client = (
            len(type_counts) == 1 and "LocalClientInterface" in type_counts
        )
        shared = bool(getattr(RNS.Transport, "is_shared_instance", False))
        if only_local_client or shared:
            results.append(
                CheckResult(
                    name="shared_instance_role",
                    status="warn" if only_local_client else "info",
                    detail=(
                        "RNS client via LocalClientInterface only"
                        if only_local_client
                        else "joined shared RNS instance"
                    ),
                    hint=(
                        "Radios/TCP live in the host RNS process "
                        "(NomadNet, rnsd, Columba). If that host cannot "
                        "reach the mesh, this bot cannot either."
                        if only_local_client
                        else None
                    ),
                    category="network",
                ),
            )
    except Exception as e:
        results.append(
            CheckResult(
                name="interfaces",
                status="fail",
                detail=str(e),
                category="network",
            ),
        )
    return results

check_send_pipeline()

Internal reasons messages would or would not leave the bot.

Source code in lxmfy/debugger.py
def check_send_pipeline(self) -> list[CheckResult]:
    """Internal reasons messages would or would not leave the bot."""
    results: list[CheckResult] = []
    results.extend(self.check_delivery_config())
    results.extend(self.check_storage_history())

    if not self.bot:
        results.append(
            CheckResult(
                name="bot_runtime",
                status="info",
                detail="no live bot attached (config-only doctor)",
                hint="Queue/retry live state needs a running bot. "
                "Storage history above is read from disk when present.",
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="send_prerequisites",
                status="info",
                detail=(
                    "send() requires: valid 32-hex dest, Identity.recall, "
                    "online interface, free outbound queue slot"
                ),
                category="send",
            ),
        )
        return results

    cfg = self.bot.config
    results.append(
        CheckResult(
            name="test_mode",
            status="fail" if cfg.test_mode else "ok",
            detail=str(cfg.test_mode),
            hint="test_mode skips real RNS/LXMF delivery."
            if cfg.test_mode
            else None,
            category="send",
        ),
    )

    outbound = None
    peer_count = 0
    if hasattr(self.bot, "get_propagation_node_status"):
        try:
            prop = self.bot.get_propagation_node_status()
            outbound = prop.get("current_outbound_node")
            peer_count = len(prop.get("discovered_peers") or [])
        except Exception:
            logger.debug("propagation status probe failed", exc_info=True)

    if outbound:
        results.append(
            CheckResult(
                name="outbound_propagation_node",
                status="ok",
                detail=redact_hash(outbound) if self.privacy else outbound,
                category="send",
            ),
        )
    results.append(
        CheckResult(
            name="discovered_prop_peers",
            status="info",
            detail=str(peer_count),
            category="send",
        ),
    )

    queue = getattr(self.bot, "queue", None)
    if queue is not None:
        qsize = queue.qsize()
        maxsize = getattr(queue, "maxsize", 0) or 0
        full = bool(maxsize and qsize >= maxsize)
        results.append(
            CheckResult(
                name="outbound_queue",
                status="fail" if full else "ok",
                detail=f"{qsize}/{maxsize}",
                hint="Queue full: new sends drop oldest or fail." if full else None,
                category="send",
            ),
        )

    router = getattr(self.bot, "router", None)
    local = getattr(self.bot, "local", None)
    results.append(
        CheckResult(
            name="lxmf_router",
            status="ok" if router and local else "fail",
            detail="ready" if router and local else "not ready",
            hint=(
                "Router/local delivery identity missing. Bot cannot "
                "send or receive on the mesh."
                if not (router and local)
                else None
            ),
            category="send",
        ),
    )

    stamp = cfg.stamp_cost
    results.append(
        CheckResult(
            name="inbound_stamp_cost",
            status="info",
            detail=str(stamp),
            hint=(
                "Applies to inbound delivery only. "
                "Outbound stamp cost comes from peer announces "
                "unless send(stamp_cost=...) overrides it."
            ),
            category="send",
        ),
    )
    results.append(
        CheckResult(
            name="include_tickets",
            status="ok" if getattr(cfg, "include_tickets", True) else "warn",
            detail=str(getattr(cfg, "include_tickets", True)),
            hint=(
                None
                if getattr(cfg, "include_tickets", True)
                else "Peers that require stamps may not be able to reply quickly."
            ),
            category="send",
        ),
    )
    if cfg.require_stamps:
        results.append(
            CheckResult(
                name="require_stamps",
                status="info",
                detail="True (inbound unsigned/invalid stamps rejected)",
                category="receive",
            ),
        )

    if router is not None:
        deferred = getattr(router, "pending_deferred_stamps", None) or {}
        lxmf_outbound = getattr(router, "pending_outbound", None) or []
        results.append(
            CheckResult(
                name="deferred_stamps",
                status="warn" if deferred else "ok",
                detail=f"{len(deferred)} pending",
                hint=(
                    "Outbound messages are waiting on stamp generation. "
                    "High peer stamp costs delay replies."
                    if deferred
                    else None
                ),
                category="send",
            ),
        )
        results.append(
            CheckResult(
                name="pending_outbound",
                status="info",
                detail=f"{len(lxmf_outbound)} in LXMF outbound queue",
                category="send",
            ),
        )

    return results

check_storage_history()

Read delivery_attempts / persisted_queue from disk without a live bot.

Source code in lxmfy/debugger.py
def check_storage_history(self) -> list[CheckResult]:
    """Read delivery_attempts / persisted_queue from disk without a live bot."""
    results: list[CheckResult] = []
    storage_dir = self._storage_path
    attempts_path = os.path.join(storage_dir, "delivery_attempts.json")
    queue_path = os.path.join(storage_dir, "persisted_queue.json")

    if self.bot and getattr(self.bot, "storage", None) is not None:
        try:
            attempts = self.bot.storage.get("delivery_attempts", {}) or {}
            persisted = self.bot.storage.get("persisted_queue", []) or []
            return self._format_storage_history(attempts, persisted, "live_storage")
        except Exception as e:
            results.append(
                CheckResult(
                    name="storage_history",
                    status="warn",
                    detail=f"live storage read failed: {e}",
                    category="send",
                ),
            )

    attempts: dict = {}
    persisted: list = []
    if os.path.isfile(attempts_path):
        try:
            with open(attempts_path, encoding="utf-8") as f:
                raw = json.load(f)
            if isinstance(raw, dict):
                attempts = raw
            else:
                results.append(
                    CheckResult(
                        name="delivery_attempts_file",
                        status="warn",
                        detail="unexpected JSON type",
                        category="send",
                    ),
                )
        except Exception as e:
            results.append(
                CheckResult(
                    name="delivery_attempts_file",
                    status="warn",
                    detail=f"unreadable: {e}",
                    category="send",
                ),
            )
    if os.path.isfile(queue_path):
        try:
            with open(queue_path, encoding="utf-8") as f:
                raw = json.load(f)
            if isinstance(raw, list):
                persisted = raw
            else:
                results.append(
                    CheckResult(
                        name="persisted_queue_file",
                        status="warn",
                        detail="unexpected JSON type",
                        category="send",
                    ),
                )
        except Exception as e:
            results.append(
                CheckResult(
                    name="persisted_queue_file",
                    status="warn",
                    detail=f"unreadable: {e}",
                    category="send",
                ),
            )

    if not os.path.isdir(storage_dir):
        results.append(
            CheckResult(
                name="storage_history",
                status="info",
                detail="storage dir missing (bot may never have run here)",
                category="send",
            ),
        )
        return results

    results.extend(
        self._format_storage_history(attempts, persisted, "disk"),
    )
    return results

compare_destinations(left, right, *, request_path=False, wait=0.0)

Compare path/identity status for two destination hashes.

Source code in lxmfy/debugger.py
def compare_destinations(
    self,
    left: str,
    right: str,
    *,
    request_path: bool = False,
    wait: float = 0.0,
) -> dict[str, Any]:
    """Compare path/identity status for two destination hashes."""
    a = self.probe_destination(left, request_path=request_path, wait=wait)
    b = self.probe_destination(right, request_path=request_path, wait=wait)
    result: dict[str, Any] = {
        "left": a.to_dict(privacy=self.privacy),
        "right": b.to_dict(privacy=self.privacy),
        "both_valid": a.valid_hash and b.valid_hash,
        "both_identity_known": a.identity_known and b.identity_known,
        "both_have_path": a.has_path and b.has_path,
        "notes": [],
    }
    if a.valid_hash and b.valid_hash and a.destination == b.destination:
        result["notes"].append("Both hashes normalize to the same destination")
    if a.identity_known and not b.identity_known:
        result["notes"].append("Left identity known, right unknown")
    elif b.identity_known and not a.identity_known:
        result["notes"].append("Right identity known, left unknown")
    if a.has_path and not b.has_path:
        result["notes"].append("Path exists to left only")
    elif b.has_path and not a.has_path:
        result["notes"].append("Path exists to right only")
    if result["both_identity_known"] and result["both_have_path"]:
        result["notes"].append("Both sides look reachable from this node")
    return result

diagnose_receive()

Explain why peers may not be able to reach this bot.

Source code in lxmfy/debugger.py
def diagnose_receive(self) -> list[CheckResult]:
    """Explain why peers may not be able to reach this bot."""
    results = self.check_bot_identity()
    results.extend(self.check_interfaces())
    results.extend(self.check_announce())

    bot_hash = self._bot_hash()
    if bot_hash and self.ensure_reticulum():
        try:
            import RNS

            dest = bytes.fromhex(bot_hash)
            has = RNS.Transport.has_path(dest)
            results.append(
                CheckResult(
                    name="local_path_to_self",
                    status="info",
                    detail=str(has),
                    hint="Peers need a path to your delivery hash. "
                    "Announce after interfaces are online.",
                    category="receive",
                ),
            )
        except Exception:
            logger.debug("local path probe failed", exc_info=True)

    results.append(
        CheckResult(
            name="receive_tip",
            status="info",
            detail="Share your LXMF delivery hash with peers",
            hint="Printed at startup as 'LXMF Router ready to receive on: …'",
            category="receive",
        ),
    )
    return results

diagnose_send(destination, *, probe=None)

Explain why sending to a destination may fail.

Source code in lxmfy/debugger.py
def diagnose_send(
    self,
    destination: str,
    *,
    probe: DestinationProbe | None = None,
) -> list[CheckResult]:
    """Explain why sending to a destination may fail."""
    if probe is None:
        probe = self.probe_destination(destination)
    results: list[CheckResult] = []
    dest_show = (
        redact_hash(probe.destination) if self.privacy else probe.destination
    )

    results.append(
        CheckResult(
            name="destination_hash",
            status="ok" if probe.valid_hash else "fail",
            detail=dest_show,
            hint=None if probe.valid_hash else f"Need {_hash_len() * 2} hex chars.",
            category="destination",
        ),
    )
    if not probe.valid_hash:
        return results

    results.append(
        CheckResult(
            name="identity_known",
            status="ok" if probe.identity_known else "fail",
            detail=str(probe.identity_known),
            hint=None
            if probe.identity_known
            else "send() aborts when Identity.recall fails. "
            "Use: lxmfy debug probe <hash> --request-path --wait 30",
            category="destination",
        ),
    )
    results.append(
        CheckResult(
            name="has_path",
            status="ok" if probe.has_path else "fail",
            detail=(f"True (hops={probe.hops})" if probe.has_path else "False"),
            hint=None
            if probe.has_path
            else "No route. Bring interfaces online and ensure a shared "
            "Reticulum transport path.",
            category="destination",
        ),
    )
    if probe.next_hop:
        nh = redact_hash(probe.next_hop) if self.privacy else probe.next_hop
        results.append(
            CheckResult(
                name="next_hop",
                status="info",
                detail=nh,
                category="destination",
            ),
        )
    if probe.app_data:
        ad = redact_display_name(probe.app_data) if self.privacy else probe.app_data
        results.append(
            CheckResult(
                name="announce_app_data",
                status="info",
                detail=ad,
                category="destination",
            ),
        )

    # Would send() accept this right now?
    can_queue = probe.valid_hash and probe.identity_known
    results.append(
        CheckResult(
            name="send_would_queue",
            status="ok" if can_queue else "fail",
            detail=str(can_queue),
            hint=None
            if can_queue
            else "LXMFy will not enqueue: identity unknown or bad hash.",
            category="destination",
        ),
    )

    if probe.valid_hash:
        try:
            import RNS

            dest_bytes = parse_destination_hash(probe.destination)
            ratchet_id = (
                RNS.Identity.current_ratchet_id(dest_bytes)
                if dest_bytes is not None
                else None
            )
            results.append(
                CheckResult(
                    name="peer_ratchet",
                    status="ok" if ratchet_id else "warn",
                    detail=(
                        RNS.hexrep(ratchet_id, delimit=False)
                        if ratchet_id
                        else "unknown"
                    ),
                    hint=(
                        None
                        if ratchet_id
                        else "No announced ratchet yet. Opportunistic sends "
                        "use static identity encryption until an announce arrives."
                    ),
                    category="destination",
                ),
            )
            if self.bot and getattr(self.bot, "router", None):
                peer_stamp = self.bot.router.get_outbound_stamp_cost(dest_bytes)
                ticket = self.bot.router.get_outbound_ticket(dest_bytes)
                results.append(
                    CheckResult(
                        name="peer_stamp_cost",
                        status="info",
                        detail=str(peer_stamp),
                        hint=(
                            "Peer requires stamps. Replies wait on stamp "
                            "generation unless an outbound ticket is available."
                            if peer_stamp
                            else None
                        ),
                        category="destination",
                    ),
                )
                results.append(
                    CheckResult(
                        name="outbound_ticket",
                        status="ok" if ticket or not peer_stamp else "warn",
                        detail="present" if ticket else "none",
                        hint=(
                            None
                            if ticket or not peer_stamp
                            else "No ticket from this peer. Stamp generation "
                            "may delay or fail replies to stamp-requiring clients."
                        ),
                        category="destination",
                    ),
                )
        except Exception as e:
            results.append(
                CheckResult(
                    name="peer_stamp_ratchet",
                    status="warn",
                    detail=f"check failed: {e}",
                    category="destination",
                ),
            )

    if self.bot:
        attempts = (getattr(self.bot, "delivery_attempts", None) or {}).get(
            normalize_destination_hex(destination),
            0,
        )
        max_retries = getattr(self.bot.config, "direct_delivery_retries", 3)
        results.append(
            CheckResult(
                name="delivery_attempts_for_dest",
                status="warn" if attempts else "ok",
                detail=f"{attempts}/{max_retries}",
                hint=(
                    "At or past retries, LXMFy switches to PROPAGATED "
                    "if fallback is enabled."
                    if attempts >= max_retries
                    else None
                ),
                category="destination",
            ),
        )
        if attempts >= max_retries and self.bot.config.propagation_fallback_enabled:
            results.append(
                CheckResult(
                    name="method_for_dest",
                    status="info",
                    detail="PROPAGATED (retry limit reached)",
                    category="destination",
                ),
            )
        elif self.bot.config.opportunistic_sending:
            results.append(
                CheckResult(
                    name="method_for_dest",
                    status="info",
                    detail="OPPORTUNISTIC",
                    category="destination",
                ),
            )
        else:
            results.append(
                CheckResult(
                    name="method_for_dest",
                    status="info",
                    detail="DIRECT",
                    category="destination",
                ),
            )

    return results

ensure_reticulum(loglevel=None)

Initialize Reticulum if needed.

Source code in lxmfy/debugger.py
def ensure_reticulum(self, loglevel: int | None = None) -> bool:
    """Initialize Reticulum if needed."""
    if self._reticulum_ready:
        return True
    if self.bot and not getattr(self.bot.config, "test_mode", False):
        try:
            import RNS

            if RNS.Reticulum.get_instance() is not None:
                self._reticulum_ready = True
                return True
        except Exception:
            logger.debug("shared Reticulum probe failed", exc_info=True)

    try:
        import RNS

        level = loglevel if loglevel is not None else RNS.LOG_ERROR
        try:
            RNS.Reticulum(
                configdir=self.reticulum_config_dir,
                loglevel=level,
            )
        except OSError as e:
            if "reinitialise" not in str(e).lower():
                raise
        self._reticulum_ready = True
        return True
    except Exception:
        logger.exception("Failed to initialize Reticulum")
        return False

format_report_text(report)

Format a plain-text privacy-friendly report for files/sharing.

Source code in lxmfy/debugger.py
def format_report_text(self, report: DoctorReport) -> str:
    """Format a plain-text privacy-friendly report for files/sharing."""
    lines: list[str] = []
    data = report.to_dict()
    lines.append("=" * 60)
    lines.append("LXMFy Debugger Report")
    lines.append("=" * 60)
    lines.append(f"generated_at: {data.get('generated_at')}")
    lines.append(f"lxmfy_version: {data.get('lxmfy_version')}")
    lines.append(f"privacy_redacted: {data.get('privacy')}")
    lines.append(f"reticulum_config: {data.get('reticulum_config_dir')}")
    lines.append(f"bot_delivery_hash: {data.get('bot_hash') or '(unknown)'}")
    lines.append(f"verdict: {data.get('verdict')}")
    lines.append("")
    if report.next_steps:
        lines.append("-" * 60)
        lines.append("Next Steps")
        lines.append("-" * 60)
        for i, step in enumerate(report.next_steps, 1):
            lines.append(f"  {i}. {step}")
        lines.append("")

    by_cat: dict[str, list[CheckResult]] = {}
    for check in report.checks:
        by_cat.setdefault(check.category, []).append(check)

    for cat in CATEGORY_ORDER:
        items = by_cat.get(cat) or []
        if not items:
            continue
        lines.append("-" * 60)
        lines.append(CATEGORY_TITLES.get(cat, cat.upper()))
        lines.append("-" * 60)
        for c in sort_checks_by_severity(items):
            detail = c.detail
            if report.privacy:
                detail = redact_sensitive_text(detail)
            lines.append(f"[{c.status.upper():5}] {c.name}: {detail}")
            if c.hint:
                hint = c.hint
                if report.privacy:
                    hint = redact_sensitive_text(hint)
                lines.append(f"         hint: {hint}")
        lines.append("")

    if report.probe:
        p = report.probe.to_dict(privacy=report.privacy)
        lines.append("-" * 60)
        lines.append("Destination Probe Detail")
        lines.append("-" * 60)
        for key, val in p.items():
            if val in (None, "", [], False) and key not in {
                "valid_hash",
                "identity_known",
                "has_path",
            }:
                continue
            lines.append(f"  {key}: {val}")
        lines.append("")

    lines.append("-" * 60)
    lines.append("Blockers")
    lines.append("-" * 60)
    if report.send_blockers:
        lines.append("send:")
        lines.extend(
            f"  - {redact_sensitive_text(b) if report.privacy else b}"
            for b in report.send_blockers
        )
    else:
        lines.append("send: (none)")
    if report.receive_blockers:
        lines.append("receive:")
        lines.extend(
            f"  - {redact_sensitive_text(b) if report.privacy else b}"
            for b in report.receive_blockers
        )
    else:
        lines.append("receive: (none)")
    lines.append("")

    lines.append("-" * 60)
    lines.append("Summary")
    lines.append("-" * 60)
    lines.append(
        f"failures={data['failures']} warnings={data['warnings']} ok={data['ok']}",
    )
    lines.append("")
    lines.append("Tips:")
    lines.extend(f"  - {tip}" for tip in report.tips)
    lines.append("")
    lines.append(
        "Share this file when asking for help. It is privacy-redacted "
        "by default (home paths and full hashes truncated).",
    )
    lines.append("")
    return "\n".join(lines)

print_probe(probe, *, as_json=False)

Print a destination probe result.

Source code in lxmfy/debugger.py
def print_probe(
    self,
    probe: DestinationProbe,
    *,
    as_json: bool = False,
) -> None:
    """Print a destination probe result."""
    if as_json:
        print(json.dumps(probe.to_dict(privacy=self.privacy), indent=2))
        return

    print_header("Destination Probe")
    dest = redact_hash(probe.destination) if self.privacy else probe.destination
    print_kv("destination", dest)
    print_kv("valid_hash", str(probe.valid_hash), ok=probe.valid_hash)
    print_kv(
        "identity_known",
        str(probe.identity_known),
        ok=probe.identity_known,
    )
    print_kv("has_path", str(probe.has_path), ok=probe.has_path)
    if probe.hops is not None:
        print_kv("hops", str(probe.hops))
    if probe.next_hop:
        nh = redact_hash(probe.next_hop) if self.privacy else probe.next_hop
        print_kv("next_hop", nh)
    if probe.app_data:
        ad = redact_display_name(probe.app_data) if self.privacy else probe.app_data
        print_kv("app_data", ad)
    for note in probe.notes:
        print_dim(note)
    for hint in probe.hints:
        print_warning(hint)

    if probe.valid_hash and probe.identity_known and probe.has_path:
        print_success("Ready to attempt send to this destination")
    elif probe.valid_hash:
        print_error("Not ready to send (see hints above)")

print_report(report, *, as_json=False)

Print a doctor report to the terminal.

Source code in lxmfy/debugger.py
def print_report(
    self,
    report: DoctorReport,
    *,
    as_json: bool = False,
) -> None:
    """Print a doctor report to the terminal."""
    if as_json:
        print(json.dumps(report.to_dict(), indent=2, default=str))
        return

    print_header("LXMFy Debugger")
    print_kv("generated", report.generated_at or "?")
    print_kv("lxmfy", report.lxmfy_version or LXMFY_VERSION)
    print_kv(
        "reticulum_config",
        redact_path(report.reticulum_config_dir)
        if report.privacy
        else (report.reticulum_config_dir or "?"),
    )
    if report.bot_hash:
        shown = redact_hash(report.bot_hash) if report.privacy else report.bot_hash
        print_kv("bot_delivery_hash", shown, ok=True)
    else:
        print_kv("bot_delivery_hash", "(unknown)", ok=None)
    print_kv("privacy_redacted", str(report.privacy))
    print_kv("verdict", report.verdict or "unknown")
    if report.next_steps:
        print_section("Next Steps")
        for i, step in enumerate(report.next_steps, 1):
            print_info(f"{i}. {step}")

    by_cat: dict[str, list[CheckResult]] = {}
    for check in report.checks:
        by_cat.setdefault(check.category, []).append(check)

    for cat in CATEGORY_ORDER:
        items = by_cat.get(cat) or []
        if not items:
            continue
        print_section(CATEGORY_TITLES.get(cat, cat))
        for check in sort_checks_by_severity(items):
            detail = check.detail
            hint = check.hint
            if report.privacy:
                detail = redact_sensitive_text(detail)
                if hint:
                    hint = redact_sensitive_text(hint)
            print_check(check.name, check.status, detail, hint)

    if report.probe:
        print_section("Destination Probe")
        p = report.probe
        dest = redact_hash(p.destination) if report.privacy else p.destination
        print_kv("destination", dest)
        print_kv("valid_hash", str(p.valid_hash), ok=p.valid_hash)
        print_kv("identity_known", str(p.identity_known), ok=p.identity_known)
        print_kv("has_path", str(p.has_path), ok=p.has_path)
        if p.hops is not None:
            print_kv("hops", str(p.hops))
        if p.next_hop:
            nh = redact_hash(p.next_hop) if report.privacy else p.next_hop
            print_kv("next_hop", nh)
        if p.app_data:
            ad = redact_display_name(p.app_data) if report.privacy else p.app_data
            print_kv("app_data", ad)
        if p.path_requested:
            print_kv(
                "path_requested",
                "yes"
                + (f", found={p.path_found_after_wait}" if p.path_waited else ""),
            )
        if p.timeline:
            print_section("Path Timeline")
            for entry in p.timeline:
                print_dim(f"+{entry.get('t', '?')}s  {entry.get('event')}")
        for note in p.notes:
            print_dim(note)
        for hint in p.hints:
            print_warning(hint)

    print_section("Blockers")
    if report.send_blockers:
        print_error("Send blockers:")
        for b in report.send_blockers:
            print_dim(redact_sensitive_text(b) if report.privacy else b)
    else:
        print_success("No hard send blockers detected")
    if report.receive_blockers:
        print_error("Receive blockers:")
        for b in report.receive_blockers:
            print_dim(redact_sensitive_text(b) if report.privacy else b)
    else:
        print_success("No hard receive blockers detected")

    print_section("Summary")
    data = report.to_dict()
    failures = data["failures"]
    warnings = data["warnings"]
    if failures:
        print_error(f"{failures} failure(s), {warnings} warning(s)")
    elif warnings:
        print_warning(f"No failures, {warnings} warning(s)")
    else:
        print_success("No failures or warnings")

    print_section("Common tips")
    for tip in report.tips:
        print_info(tip)

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

Probe path and identity knowledge for a destination.

Source code in lxmfy/debugger.py
def probe_destination(
    self,
    destination: str,
    *,
    request_path: bool = False,
    wait: float = 0.0,
) -> DestinationProbe:
    """Probe path and identity knowledge for a destination."""
    cleaned = normalize_destination_hex(destination)
    dest_bytes = parse_destination_hash(cleaned)
    probe = DestinationProbe(
        destination=cleaned or destination,
        valid_hash=dest_bytes is not None,
    )

    if dest_bytes is None:
        probe.notes.append("Invalid destination hash")
        probe.hints.append(
            f"Hash must be {_hash_len() * 2} hex characters ({_hash_len()} bytes).",
        )
        return probe

    if not self.ensure_reticulum():
        probe.notes.append("Reticulum not initialized")
        probe.hints.append("Fix Reticulum config and retry.")
        return probe

    import RNS

    t0 = time.monotonic()
    probe.timeline.append({"t": 0.0, "event": "probe_start"})

    identity = RNS.Identity.recall(dest_bytes)
    probe.identity_known = identity is not None
    probe.timeline.append(
        {
            "t": round(time.monotonic() - t0, 3),
            "event": "identity_recalled"
            if probe.identity_known
            else "identity_unknown",
        },
    )
    probe.has_path = bool(RNS.Transport.has_path(dest_bytes))
    probe.timeline.append(
        {
            "t": round(time.monotonic() - t0, 3),
            "event": "path_present" if probe.has_path else "path_missing",
        },
    )

    if probe.has_path:
        try:
            hops = RNS.Transport.hops_to(dest_bytes)
            probe.hops = int(hops) if hops is not None else None
        except Exception:
            probe.hops = None
        try:
            nh = RNS.Transport.next_hop(dest_bytes)
            if nh:
                probe.next_hop = RNS.hexrep(nh, delimit=False)
        except Exception:
            logger.debug("next_hop probe failed", exc_info=True)

    try:
        app_data = RNS.Identity.recall_app_data(dest_bytes)
        if app_data:
            if isinstance(app_data, bytes):
                probe.app_data = app_data.decode("utf-8", errors="replace")
            else:
                probe.app_data = str(app_data)
    except Exception:
        logger.debug("app_data probe failed", exc_info=True)

    if not probe.identity_known:
        probe.notes.append("Identity not recalled")
        probe.hints.append(
            "LXMFy send() returns False until Identity.recall succeeds. "
            "Peer must announce on a network you share.",
        )
    if not probe.has_path:
        probe.notes.append("No path known")
        probe.hints.append(
            "No route to destination. Ensure interfaces are online and "
            "you share a transport path with the peer.",
        )

    if request_path and (not probe.has_path or not probe.identity_known):
        try:
            RNS.Transport.request_path(dest_bytes)
            probe.path_requested = True
            probe.notes.append("Path requested")
            probe.timeline.append(
                {
                    "t": round(time.monotonic() - t0, 3),
                    "event": "path_requested",
                },
            )
        except Exception as e:
            probe.notes.append(f"Path request failed: {e}")
            probe.timeline.append(
                {
                    "t": round(time.monotonic() - t0, 3),
                    "event": f"path_request_failed:{e}",
                },
            )

        if wait and wait > 0:
            probe.path_waited = True
            deadline = time.time() + wait
            while time.time() < deadline:
                if RNS.Transport.has_path(dest_bytes):
                    probe.has_path = True
                    probe.path_found_after_wait = True
                    elapsed = round(time.monotonic() - t0, 3)
                    identity = RNS.Identity.recall(dest_bytes)
                    probe.identity_known = identity is not None
                    try:
                        hops = RNS.Transport.hops_to(dest_bytes)
                        probe.hops = int(hops) if hops is not None else None
                    except Exception:
                        logger.debug("hops_to probe failed", exc_info=True)
                    probe.notes.append(
                        f"Path became available after {elapsed}s",
                    )
                    probe.timeline.append(
                        {"t": elapsed, "event": "path_found"},
                    )
                    if probe.identity_known:
                        probe.timeline.append(
                            {
                                "t": elapsed,
                                "event": "identity_recalled_after_path",
                            },
                        )
                    break
                time.sleep(0.25)
            else:
                elapsed = round(time.monotonic() - t0, 3)
                probe.notes.append(
                    f"No path within {wait}s after request (waited {elapsed}s)",
                )
                probe.timeline.append(
                    {"t": elapsed, "event": "path_timeout"},
                )
                probe.hints.append(
                    "Path request timed out. Peer may be offline, on a "
                    "disjoint network, or not announcing.",
                )

    if probe.identity_known and probe.has_path:
        probe.hints.append(
            "Identity and path look good. If send still fails, check "
            "delivery retries, opportunistic vs DIRECT, and propagation.",
        )
    elif probe.identity_known and not probe.has_path:
        probe.hints.append(
            "Identity known but no path. Request path and wait.",
        )

    return probe

run_doctor(destination=None, *, request_path=False, wait=0.0, try_announce=False)

Run a full connectivity doctor report.

Source code in lxmfy/debugger.py
def run_doctor(
    self,
    destination: str | None = None,
    *,
    request_path: bool = False,
    wait: float = 0.0,
    try_announce: bool = False,
) -> DoctorReport:
    """Run a full connectivity doctor report."""
    report = DoctorReport(
        reticulum_config_dir=self.reticulum_config_dir,
        bot_hash=self._bot_hash(),
        tips=list(COMMON_TIPS),
        generated_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
        lxmfy_version=LXMFY_VERSION,
        privacy=self.privacy,
    )
    report.checks.extend(self.check_environment())
    report.checks.extend(self.check_instance())
    report.checks.extend(self.check_disk_permissions())
    report.checks.extend(self.check_interfaces())
    report.checks.extend(self.check_announce(try_announce=try_announce))
    report.checks.extend(self.check_send_pipeline())
    report.checks.extend(self.check_delivery_timeline())
    report.checks.extend(self.check_bot_identity())

    if destination:
        report.probe = self.probe_destination(
            destination,
            request_path=request_path,
            wait=wait,
        )
        report.checks.extend(
            self.diagnose_send(destination, probe=report.probe),
        )

    send_b, recv_b = self._collect_blockers(report.checks)
    report.send_blockers = send_b
    report.receive_blockers = recv_b
    report.checks = sort_checks_by_severity(report.checks)
    verdict, steps = build_verdict(
        report.checks,
        send_blockers=send_b,
        receive_blockers=recv_b,
        probe=report.probe,
    )
    report.verdict = verdict
    report.next_steps = steps
    return report

save_report(report, path=None, *, as_json=False)

Write report to a file and return the path used.

Source code in lxmfy/debugger.py
def save_report(
    self,
    report: DoctorReport,
    path: str | None = None,
    *,
    as_json: bool = False,
) -> str:
    """Write report to a file and return the path used."""
    if path is None:
        stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
        ext = "json" if as_json else "txt"
        path = os.path.abspath(f"lxmfy-debug-{stamp}.{ext}")
    else:
        path = os.path.abspath(os.path.expanduser(path))

    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    if as_json or path.endswith(".json"):
        content = json.dumps(report.to_dict(), indent=2, default=str)
    else:
        content = self.format_report_text(report)

    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
        if not content.endswith("\n"):
            f.write("\n")
    return path

Full connectivity doctor report.

Source code in lxmfy/debugger.py
@dataclass
class DoctorReport:
    """Full connectivity doctor report."""

    checks: list[CheckResult] = field(default_factory=list)
    probe: DestinationProbe | None = None
    tips: list[str] = field(default_factory=list)
    reticulum_config_dir: str | None = None
    bot_hash: str | None = None
    generated_at: str = ""
    lxmfy_version: str = ""
    privacy: bool = True
    send_blockers: list[str] = field(default_factory=list)
    receive_blockers: list[str] = field(default_factory=list)
    verdict: str = "unknown"
    next_steps: list[str] = field(default_factory=list)
    compare: dict[str, Any] | None = None

    def to_dict(self) -> dict[str, Any]:
        redacted_dir = (
            redact_path(self.reticulum_config_dir)
            if self.privacy and self.reticulum_config_dir
            else self.reticulum_config_dir
        )
        checks_out = []
        for c in self.checks:
            item = _check_to_dict(c)
            if self.privacy:
                item["detail"] = redact_sensitive_text(str(item.get("detail") or ""))
                if item.get("hint"):
                    item["hint"] = redact_sensitive_text(str(item["hint"]))
            checks_out.append(item)
        return {
            "generated_at": self.generated_at,
            "lxmfy_version": self.lxmfy_version,
            "privacy": self.privacy,
            "reticulum_config_dir": redacted_dir,
            "bot_hash": redact_hash(self.bot_hash) if self.privacy else self.bot_hash,
            "checks": checks_out,
            "probe": self.probe.to_dict(privacy=self.privacy) if self.probe else None,
            "send_blockers": self.send_blockers,
            "receive_blockers": self.receive_blockers,
            "verdict": self.verdict,
            "next_steps": list(self.next_steps),
            "compare": self.compare,
            "tips": self.tips,
            "ok": all(c.status != "fail" for c in self.checks),
            "warnings": sum(1 for c in self.checks if c.status == "warn"),
            "failures": sum(1 for c in self.checks if c.status == "fail"),
        }

Path/identity probe for a destination hash.

Source code in lxmfy/debugger.py
@dataclass
class DestinationProbe:
    """Path/identity probe for a destination hash."""

    destination: str
    valid_hash: bool
    identity_known: bool = False
    has_path: bool = False
    hops: int | None = None
    next_hop: str | None = None
    path_requested: bool = False
    path_waited: bool = False
    path_found_after_wait: bool = False
    app_data: str | None = None
    notes: list[str] = field(default_factory=list)
    hints: list[str] = field(default_factory=list)
    timeline: list[dict[str, Any]] = field(default_factory=list)

    def to_dict(self, *, privacy: bool = True) -> dict[str, Any]:
        data = {
            "destination": self.destination,
            "valid_hash": self.valid_hash,
            "identity_known": self.identity_known,
            "has_path": self.has_path,
            "hops": self.hops,
            "next_hop": self.next_hop,
            "path_requested": self.path_requested,
            "path_waited": self.path_waited,
            "path_found_after_wait": self.path_found_after_wait,
            "app_data": self.app_data,
            "notes": list(self.notes),
            "hints": list(self.hints),
            "timeline": list(self.timeline),
        }
        if privacy:
            data["destination"] = redact_hash(self.destination)
            if self.next_hop:
                data["next_hop"] = redact_hash(self.next_hop)
            if self.app_data:
                data["app_data"] = redact_display_name(self.app_data)
        return data

One diagnostic check result.

Source code in lxmfy/debugger.py
@dataclass
class CheckResult:
    """One diagnostic check result."""

    name: str
    status: str
    detail: str = ""
    hint: str | None = None
    category: str = "general"

    @property
    def ok(self) -> bool:
        return self.status in {"ok", "info"}

Client session for a single RRC hub destination.

Source code in lxmfy/rrc/client.py
 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
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
class RRCClient:
    """Client session for a single RRC hub destination."""

    HELLO_ATTEMPTS = 5
    HELLO_INTERVAL_S = 3.0
    CONNECT_TIMEOUT_S = 45.0
    PATH_RETRY_INTERVAL_S = 5.0
    RESOURCE_EXPECTATION_TTL_S = 30.0
    MAX_RESOURCE_BYTES = 262144

    def __init__(
        self,
        identity: RNS.Identity,
        hub_hash: bytes,
        dest_name: str = DEFAULT_DEST_NAME,
        nick: str | None = None,
        auto_reconnect: bool = True,
        client_name: str = CLIENT_NAME,
        client_version: str = CLIENT_VERSION,
        on_event: Callable[[str, RRCClient, Any], None] | None = None,
    ):
        if not isinstance(hub_hash, (bytes, bytearray)) or len(hub_hash) != 16:
            raise ValueError("hub_hash must be a 16-byte destination hash")

        self.identity = identity
        self.hub_hash = bytes(hub_hash)
        self.dest_name = dest_name or DEFAULT_DEST_NAME
        self.nick = nick
        self.auto_reconnect = bool(auto_reconnect)
        self.client_name = client_name
        self.client_version = client_version
        self.on_event = on_event

        self.link: RNS.Link | None = None
        self.status = STATUS_DISCONNECTED
        self.status_text = "Disconnected"
        self.welcomed = False
        self.hub_name: str | None = None
        self.hub_version: str | None = None
        self.hub_caps: dict = {}
        self.motd: str | None = None

        self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES
        self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES
        self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES
        self.max_rooms_per_session = DEFAULT_MAX_ROOMS
        self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE

        self.rooms: set[str] = set()
        self.members: dict[str, set[bytes]] = {}
        self.nicks: dict[bytes, str] = {}

        self._lock = threading.RLock()
        self._sent_ids: deque[bytes] = deque(maxlen=256)
        self._pending_joins: set[str] = set()
        self._pending_parts: set[str] = set()
        self._pending_pings: dict[bytes, int] = {}
        self._auto_join_rooms: list[str] = []
        self._rejoin_rooms: set[str] = set()
        self._send_timestamps: deque[float] = deque(maxlen=512)
        self._resource_expectations: dict[bytes, dict[str, Any]] = {}

        self._stop_hello = threading.Event()
        self._hello_thread: threading.Thread | None = None
        self._manual_disconnect = False
        self._reconnect_attempts = 0
        self._reconnect_timer: threading.Timer | None = None
        self._announce_handler = None
        self._register_announce_handler()

    @property
    def connected(self) -> bool:
        """True when the session is welcomed and the link is active."""
        return self.status == STATUS_CONNECTED and self.welcomed

    def _src_hash(self) -> bytes:
        """Return this client's identity hash as bytes."""
        src = self.identity.hash
        if not isinstance(src, (bytes, bytearray)) or len(src) != 16:
            raise RuntimeError("identity hash unavailable")
        return bytes(src)

    def set_nick(self, nick: str | None) -> None:
        """Set the advisory nickname used on outgoing envelopes."""
        if nick is not None and nick != "":
            if not isinstance(nick, str):
                raise ValueError("nick must be a string")
            if len(nick.encode("utf-8")) > self.max_nick_bytes:
                raise ValueError("nick too long for hub limit")
        with self._lock:
            self.nick = nick if isinstance(nick, str) and nick else None

    def set_auto_join(self, rooms: list[str] | None) -> None:
        """Configure rooms to join automatically after WELCOME."""
        with self._lock:
            self._auto_join_rooms = [
                normalize_room(r) for r in (rooms or []) if isinstance(r, str)
            ]

    def _track_nick(self, src: bytes, nick: str) -> None:
        self.nicks[src] = nick
        while len(self.nicks) > MAX_TRACKED_NICKS:
            self.nicks.pop(next(iter(self.nicks)))

    def _track_member(self, room: str, src: bytes) -> None:
        members = self.members.setdefault(room, set())
        if src in members:
            return
        if len(members) >= MAX_MEMBERS_PER_ROOM:
            return
        members.add(src)

    def _remember_resource_expectation(self, rid: bytes, meta: dict[str, Any]) -> None:
        while len(self._resource_expectations) >= MAX_RESOURCE_EXPECTATIONS:
            oldest = next(iter(self._resource_expectations))
            self._resource_expectations.pop(oldest, None)
        self._resource_expectations[rid] = meta

    def _emit(self, event: str, payload: Any = None) -> None:
        if self.on_event is None:
            return
        try:
            self.on_event(event, self, payload)
        except Exception:
            logger.exception("RRC event handler failed for %s", event)

    def _set_status(self, status: int, text: str | None = None) -> None:
        self.status = status
        if text is not None:
            self.status_text = text
        label = {
            STATUS_DISCONNECTED: "disconnected",
            STATUS_CONNECTING: "connecting",
            STATUS_CONNECTED: "connected",
            STATUS_FAILED: "failed",
        }.get(status, str(status))
        hub = self.hub_hash.hex()
        detail = text or label
        RNS.log(f"RRC [{hub[:12]}…] {detail}", RNS.LOG_INFO)
        self._emit("status", {"status": status, "text": self.status_text})

    def connect(self) -> None:
        """Begin connecting to the hub in a background thread."""
        with self._lock:
            if self.status in (STATUS_CONNECTING, STATUS_CONNECTED):
                return
            self._manual_disconnect = False
            if self._reconnect_timer is not None:
                self._reconnect_timer.cancel()
                self._reconnect_timer = None
            text = (
                f"Reconnecting (attempt {self._reconnect_attempts})"
                if self._reconnect_attempts > 0
                else "Connecting"
            )
            self._set_status(STATUS_CONNECTING, text)

        threading.Thread(target=self._connect_worker, daemon=True).start()

    def _register_announce_handler(self) -> None:
        """Listen for the hub announce so reconnect can proceed once keys arrive."""
        if self._announce_handler is not None:
            return
        client = self

        class _HubAnnounceHandler:
            aspect_filter = None
            receive_path_responses = True

            def received_announce(
                self,
                destination_hash,
                announced_identity,
                app_data,
                *args,
                **kwargs,
            ):
                if destination_hash != client.hub_hash:
                    return
                RNS.log(
                    f"RRC [{client.hub_hash.hex()[:12]}…] hub announce received",
                    RNS.LOG_INFO,
                )
                if (
                    client.status in (STATUS_FAILED, STATUS_DISCONNECTED)
                    and client.auto_reconnect
                    and not client._manual_disconnect
                ):
                    client.connect()

        self._announce_handler = _HubAnnounceHandler()
        try:
            RNS.Transport.register_announce_handler(self._announce_handler)
        except Exception:
            logger.debug("RRC announce handler registration failed", exc_info=True)
            self._announce_handler = None

    def _deregister_announce_handler(self) -> None:
        handler = self._announce_handler
        self._announce_handler = None
        if handler is None:
            return
        try:
            RNS.Transport.deregister_announce_handler(handler)
        except Exception:
            logger.debug("RRC announce handler deregistration failed", exc_info=True)

    def _connect_worker(self) -> None:
        try:
            timeout_s = self.CONNECT_TIMEOUT_S
            self._set_status(
                STATUS_CONNECTING,
                "Waiting for hub path/announce",
            )

            # Prefer the blocking path helper when available.
            path_ok = False
            try:
                path_ok = bool(
                    RNS.Transport.await_path(self.hub_hash, timeout=timeout_s),
                )
            except Exception:
                path_ok = False

            if not path_ok:
                # Fallback: keep requesting while we wait for Identity.recall.
                deadline = time.monotonic() + timeout_s
                next_request = 0.0
                while time.monotonic() < deadline:
                    now = time.monotonic()
                    if now >= next_request:
                        RNS.Transport.request_path(self.hub_hash)
                        next_request = now + self.PATH_RETRY_INTERVAL_S
                    if RNS.Transport.has_path(self.hub_hash):
                        path_ok = True
                        break
                    if RNS.Identity.recall(self.hub_hash) is not None:
                        path_ok = True
                        break
                    time.sleep(0.2)

            hub_identity = RNS.Identity.recall(self.hub_hash)
            if hub_identity is None:
                # Path may exist before keys are fully recalled; wait a bit more.
                deadline = time.monotonic() + min(10.0, timeout_s)
                while time.monotonic() < deadline:
                    hub_identity = RNS.Identity.recall(self.hub_hash)
                    if hub_identity is not None:
                        break
                    time.sleep(0.2)

            if hub_identity is None:
                self._set_status(
                    STATUS_FAILED,
                    "Hub identity unknown (waiting for hub announce on this Reticulum network)",
                )
                self._maybe_reconnect()
                return

            app_name, aspects = RNS.Destination.app_and_aspects_from_name(
                self.dest_name,
            )
            hub_dest = RNS.Destination(
                hub_identity,
                RNS.Destination.OUT,
                RNS.Destination.SINGLE,
                app_name,
                *aspects,
            )
            if hub_dest.hash != self.hub_hash:
                self._set_status(
                    STATUS_FAILED,
                    f"Hash/destination name mismatch (expected {self.dest_name})",
                )
                self._maybe_reconnect()
                return

            self._set_status(STATUS_CONNECTING, "Opening link to hub")
            self._stop_hello.clear()
            link = RNS.Link(
                hub_dest,
                established_callback=self._on_established,
                closed_callback=self._on_closed,
            )
            link.set_packet_callback(lambda data, _pkt: self._on_packet(data))
            with self._lock:
                self.link = link
        except Exception as exc:
            self._set_status(STATUS_FAILED, f"Connect error: {exc}")
            self._maybe_reconnect()

    def _on_established(self, link: RNS.Link) -> None:
        try:
            link.identify(self.identity)
        except Exception:
            logger.exception("RRC identify failed")
            with contextlib.suppress(Exception):
                link.teardown()
            return

        try:
            link.set_resource_strategy(RNS.Link.ACCEPT_APP)
            link.set_resource_callback(self._resource_advertised)
            link.set_resource_started_callback(self._resource_advertised)
            link.set_resource_concluded_callback(self._resource_concluded)
        except Exception:
            logger.debug(
                "RRC resource callbacks unavailable on this link",
                exc_info=True,
            )

        self._set_status(STATUS_CONNECTING, "Identified, sending HELLO")

        def hello_loop() -> None:
            attempts = 0
            while (
                not self._stop_hello.is_set()
                and not self.welcomed
                and attempts < self.HELLO_ATTEMPTS
            ):
                with self._lock:
                    cur_link = self.link
                if cur_link is None or cur_link.status != RNS.Link.ACTIVE:
                    return
                try:
                    self._send_hello(cur_link)
                except Exception:
                    logger.exception("RRC HELLO send failed")
                attempts += 1
                self._stop_hello.wait(timeout=self.HELLO_INTERVAL_S)

            if not self.welcomed and not self._stop_hello.is_set():
                self._set_status(STATUS_FAILED, "WELCOME timeout")
                with contextlib.suppress(Exception), self._lock:
                    if self.link is not None:
                        self.link.teardown()

        self._hello_thread = threading.Thread(target=hello_loop, daemon=True)
        self._hello_thread.start()

    def _send_hello(self, link: RNS.Link) -> None:
        body = {
            B_HELLO_NAME: self.client_name,
            B_HELLO_VER: self.client_version,
            B_HELLO_CAPS: {
                CAP_RESOURCE_ENVELOPE: True,
                CAP_ACTION: True,
            },
        }
        env = make_envelope(T_HELLO, src=self._src_hash(), body=body, nick=self.nick)
        payload = encode_envelope(env)
        RNS.Packet(link, payload).send()

    def _reset_session_state(self, *, preserve_rejoin: bool) -> None:
        with self._lock:
            if preserve_rejoin:
                self._rejoin_rooms |= set(self.rooms)
            else:
                self._rejoin_rooms.clear()
            self.link = None
            self.welcomed = False
            self.motd = None
            self.rooms.clear()
            self.members.clear()
            self.nicks.clear()
            self._pending_joins.clear()
            self._pending_parts.clear()
            self._pending_pings.clear()
            self._resource_expectations.clear()
            self._send_timestamps.clear()

    def _on_closed(self, _link: RNS.Link) -> None:
        self._stop_hello.set()
        with self._lock:
            should_reconnect = self.auto_reconnect and not self._manual_disconnect
            preserve_rejoin = not self._manual_disconnect
        self._reset_session_state(preserve_rejoin=preserve_rejoin)
        self._set_status(STATUS_DISCONNECTED, "Disconnected")
        if should_reconnect:
            self._schedule_reconnect()

    def _maybe_reconnect(self) -> None:
        if self.auto_reconnect and not self._manual_disconnect:
            self._schedule_reconnect()

    def _schedule_reconnect(self) -> None:
        with self._lock:
            self._reconnect_attempts += 1
            backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6)))
            if self._reconnect_timer is not None:
                self._reconnect_timer.cancel()

            def fire() -> None:
                with self._lock:
                    self._reconnect_timer = None
                    if self._manual_disconnect or not self.auto_reconnect:
                        return
                self.connect()

            self._reconnect_timer = threading.Timer(backoff, fire)
            self._reconnect_timer.daemon = True
            self._reconnect_timer.start()
            self._set_status(
                STATUS_DISCONNECTED,
                f"Reconnect in {int(backoff)}s",
            )

    def disconnect(self) -> None:
        """Tear down the session and cancel reconnect."""
        self._stop_hello.set()
        with self._lock:
            self._manual_disconnect = True
            self._reconnect_attempts = 0
            if self._reconnect_timer is not None:
                self._reconnect_timer.cancel()
                self._reconnect_timer = None
            link = self.link
            self.link = None
        if link is not None:
            with contextlib.suppress(Exception):
                link.teardown()
        self._reset_session_state(preserve_rejoin=False)
        self._deregister_announce_handler()
        self._set_status(STATUS_DISCONNECTED, "Disconnected")

    def _packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool:
        try:
            pkt = RNS.Packet(link, payload)
            pkt.pack()
            return True
        except Exception:
            return False

    def _check_rate_limit(self) -> None:
        limit = self.rate_limit_msgs_per_minute
        if not isinstance(limit, int) or limit <= 0:
            return
        now = time.monotonic()
        with self._lock:
            while self._send_timestamps and now - self._send_timestamps[0] > 60.0:
                self._send_timestamps.popleft()
            if len(self._send_timestamps) >= limit:
                raise RuntimeError("client rate limit exceeded")
            self._send_timestamps.append(now)

    def _send_env(
        self,
        env: dict[int, Any],
        *,
        allow_pre_welcome: bool = False,
    ) -> None:
        msg_type = envelope_type(env)
        with self._lock:
            link = self.link
            if link is None or link.status != RNS.Link.ACTIVE:
                raise RuntimeError("not connected")
            if not allow_pre_welcome and not self.welcomed and msg_type != T_PONG:
                raise RuntimeError("session not welcomed yet")
            payload = encode_envelope(env)
            if not self._packet_would_fit(link, payload):
                raise RuntimeError("message exceeds link MTU")
            RNS.Packet(link, payload).send()

    def join(self, room: str, key: str | None = None) -> str:
        """Request membership in a room. Returns the normalized room name."""
        room_n = normalize_room(room)
        if len(room_n.encode("utf-8")) > self.max_room_name_bytes:
            raise ValueError("room name too long for hub limit")
        with self._lock:
            projected = len(self.rooms | self._pending_joins | {room_n})
            if projected > self.max_rooms_per_session:
                raise RuntimeError("max rooms per session exceeded")
            self._pending_joins.add(room_n)
            self._rejoin_rooms.add(room_n)
        body = key if isinstance(key, str) and key else None
        env = make_envelope(
            T_JOIN,
            src=self._src_hash(),
            room=room_n,
            body=body,
            nick=self.nick,
        )
        self._send_env(env)
        return room_n

    def part(self, room: str) -> str:
        """Leave a room. Returns the normalized room name."""
        room_n = normalize_room(room)
        env = make_envelope(
            T_PART,
            src=self._src_hash(),
            room=room_n,
            nick=self.nick,
        )
        with self._lock:
            self._pending_parts.add(room_n)
            self._rejoin_rooms.discard(room_n)
        try:
            self._send_env(env)
        except Exception as exc:
            logger.warning("RRC PART send failed for #%s: %s", room_n, exc)
            with self._lock:
                self._pending_parts.discard(room_n)
                self._rejoin_rooms.add(room_n)
            raise
        with self._lock:
            self.rooms.discard(room_n)
            self.members.pop(room_n, None)
        return room_n

    def send_message(self, room: str, text: str) -> bytes:
        """Send a MSG to a room. Returns the message id."""
        return self._send_room_text(T_MSG, room, text, "msg")

    def send_notice(self, room: str, text: str) -> bytes:
        """Send a NOTICE to a room. Returns the message id."""
        return self._send_room_text(T_NOTICE, room, text, "notice")

    def send_action(self, room: str, text: str) -> bytes:
        """Send an ACTION to a room. Returns the message id."""
        return self._send_room_text(T_ACTION, room, text, "action")

    def _send_room_text(
        self,
        msg_type: int,
        room: str,
        text: str,
        kind: str,
    ) -> bytes:
        room_n = normalize_room(room)
        if not isinstance(text, str) or not text.strip():
            raise ValueError(f"{kind} text must be non-empty")
        if len(text.encode("utf-8")) > self.max_msg_body_bytes:
            raise ValueError(f"{kind} too long for hub limit")
        self._check_rate_limit()
        env = make_envelope(
            msg_type,
            src=self._src_hash(),
            room=room_n,
            body=text,
            nick=self.nick,
        )
        mid = env[K_ID]
        if isinstance(mid, (bytes, bytearray)):
            self._sent_ids.append(bytes(mid))
        self._send_env(env)
        return bytes(mid)

    def send_command(self, text: str, room: str | None = None) -> None:
        """Send a slash command as a MSG body."""
        if not isinstance(text, str) or not text.startswith("/"):
            raise ValueError("command must start with /")
        room_n = normalize_room(room) if room else None
        self._check_rate_limit()
        env = make_envelope(
            T_MSG,
            src=self._src_hash(),
            room=room_n,
            body=text,
            nick=self.nick,
        )
        self._send_env(env)

    def ping(self) -> bytes:
        """Send a PING and return the correlation body."""
        body = os.urandom(8)
        env = make_envelope(T_PING, src=self._src_hash(), body=body)
        with self._lock:
            now = now_ms()
            self._pending_pings[body] = now
            expired = [k for k, v in self._pending_pings.items() if now - v > 15000]
            for key in expired:
                self._pending_pings.pop(key, None)
            while len(self._pending_pings) > MAX_PENDING_PINGS:
                oldest = next(iter(self._pending_pings))
                self._pending_pings.pop(oldest, None)
        self._send_env(env)
        return body

    def _on_packet(self, data: bytes | bytearray) -> None:
        env = decode_envelope(data)
        if env is None:
            logger.debug("RRC decode failed for %d bytes", len(data))
            self._emit("decode_error", {"size": len(data)})
            return
        if not validate_envelope(env):
            logger.debug("RRC envelope failed validation")
            self._emit("decode_error", {"reason": "invalid_envelope"})
            return
        msg_type = envelope_type(env)
        if msg_type is None:
            return

        if msg_type == T_PING:
            try:
                pong = make_envelope(
                    T_PONG,
                    src=self._src_hash(),
                    body=env.get(K_BODY),
                )
                self._send_env(pong, allow_pre_welcome=True)
            except Exception:
                logger.debug("RRC PONG reply failed", exc_info=True)
            return

        if msg_type == T_PONG:
            body = env.get(K_BODY)
            if isinstance(body, (bytes, bytearray)):
                key = bytes(body)
                with self._lock:
                    sent = self._pending_pings.pop(key, None)
                if sent is not None:
                    rtt = max(0, now_ms() - sent)
                    self._emit("pong", {"rtt_ms": rtt, "body": key})
            return

        if msg_type == T_WELCOME:
            self._handle_welcome(env)
            return

        if msg_type == T_JOINED:
            self._handle_joined(env)
            return

        if msg_type == T_PARTED:
            self._handle_parted(env)
            return

        if msg_type in (T_MSG, T_NOTICE, T_ACTION):
            self._handle_room_content(env, msg_type)
            return

        if msg_type == T_ERROR:
            text = envelope_body(env)
            if not isinstance(text, str):
                text = "(error)"
            room = envelope_room(env)
            with self._lock:
                if room and room in self._pending_joins:
                    self._pending_joins.discard(room)
                    self.rooms.discard(room)
                    self._rejoin_rooms.discard(room)
                if room:
                    self._pending_parts.discard(room)
            msg = RRCMessage(
                kind="error",
                room=room,
                src=None,
                nick=None,
                text=text,
                ts=now_ms(),
                hub_hash=self.hub_hash,
                raw=env,
            )
            self._emit("error", msg)
            return

        if msg_type == T_RESOURCE_ENVELOPE:
            self._handle_resource_envelope(env)
            return

    def _handle_resource_envelope(self, env: dict[int, Any]) -> None:
        body = envelope_body(env)
        if not isinstance(body, dict):
            return
        try:
            rid = body.get(B_RES_ID)
            kind = body.get(B_RES_KIND)
            size = body.get(B_RES_SIZE)
            sha256 = body.get(B_RES_SHA256)
            encoding = body.get(B_RES_ENCODING)
            if not isinstance(rid, (bytes, bytearray)):
                return
            if not isinstance(kind, str):
                return
            if not isinstance(size, int) or size <= 0:
                return
            room = envelope_room(env)
            with self._lock:
                self._remember_resource_expectation(
                    bytes(rid),
                    {
                        "kind": kind,
                        "size": size,
                        "sha256": bytes(sha256)
                        if isinstance(sha256, (bytes, bytearray))
                        else None,
                        "encoding": encoding if isinstance(encoding, str) else "utf-8",
                        "room": room,
                        "expires": time.monotonic() + self.RESOURCE_EXPECTATION_TTL_S,
                    },
                )
        except Exception:
            logger.debug("RRC resource envelope parse failed", exc_info=True)

    def _resource_advertised(self, resource) -> bool:
        try:
            if hasattr(resource, "get_data_size"):
                size = resource.get_data_size()
            elif hasattr(resource, "total_size"):
                size = resource.total_size
            else:
                size = getattr(resource, "size", 0)
        except Exception:
            return False
        return isinstance(size, int) and 0 < size <= self.MAX_RESOURCE_BYTES

    def _resource_concluded(self, resource) -> None:
        try:
            if resource.status != RNS.Resource.COMPLETE:
                with contextlib.suppress(Exception):
                    if hasattr(resource, "data") and resource.data:
                        resource.data.close()
                return
            data = None
            try:
                data = resource.data.read()
            finally:
                with contextlib.suppress(Exception):
                    if hasattr(resource, "data") and resource.data:
                        resource.data.close()
            if data is None:
                return

            now = time.monotonic()
            matched = None
            with self._lock:
                expired = [
                    k
                    for k, v in self._resource_expectations.items()
                    if v["expires"] < now
                ]
                for key in expired:
                    self._resource_expectations.pop(key, None)
                for key, exp in list(self._resource_expectations.items()):
                    if exp["size"] == len(data):
                        matched = exp
                        self._resource_expectations.pop(key, None)
                        break

            kind = matched["kind"] if matched else "blob"
            room = matched["room"] if matched else None
            encoding = matched["encoding"] if matched else "utf-8"
            sha = matched["sha256"] if matched else None
            if sha is not None and hashlib.sha256(data).digest() != sha:
                logger.warning("RRC resource SHA256 mismatch")
                return
            if kind in (RES_KIND_NOTICE, RES_KIND_MOTD):
                text = data.decode(encoding, errors="replace")
                if kind == RES_KIND_MOTD:
                    with self._lock:
                        self.motd = text
                    self._emit("motd", text)
                msg = RRCMessage(
                    kind="notice",
                    room=room,
                    src=None,
                    nick=None,
                    text=text,
                    ts=now_ms(),
                    hub_hash=self.hub_hash,
                )
                self._emit("notice", msg)
        except Exception:
            logger.exception("RRC resource handling failed")

    def _handle_welcome(self, env: dict[int, Any]) -> None:
        self.welcomed = True
        body = envelope_body(env)
        if isinstance(body, dict):
            hub_name = body.get(B_WELCOME_HUB)
            if isinstance(hub_name, str):
                self.hub_name = hub_name
            ver = body.get(B_WELCOME_VER)
            if isinstance(ver, str):
                self.hub_version = ver
            caps = body.get(B_WELCOME_CAPS)
            if isinstance(caps, dict):
                self.hub_caps = dict(caps)
            limits = body.get(B_WELCOME_LIMITS)
            if isinstance(limits, dict):
                if L_MAX_NICK_BYTES in limits:
                    self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES])
                if L_MAX_ROOM_NAME_BYTES in limits:
                    self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES])
                if L_MAX_MSG_BODY_BYTES in limits:
                    self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES])
                if L_MAX_ROOMS_PER_SESSION in limits:
                    self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION])
                if L_RATE_LIMIT_MSGS_PER_MINUTE in limits:
                    self.rate_limit_msgs_per_minute = int(
                        limits[L_RATE_LIMIT_MSGS_PER_MINUTE],
                    )
        with self._lock:
            self._reconnect_attempts = 0
            rooms_to_join = sorted(set(self._auto_join_rooms) | set(self._rejoin_rooms))
        self._set_status(STATUS_CONNECTED, "Connected")
        self._emit(
            "welcome",
            {"hub_name": self.hub_name, "hub_version": self.hub_version},
        )
        if rooms_to_join:
            RNS.log(
                f"RRC auto-joining rooms: {', '.join('#' + r for r in rooms_to_join)}",
                RNS.LOG_INFO,
            )
        else:
            RNS.log("RRC welcomed but no rooms configured to join", RNS.LOG_WARNING)
        for room in rooms_to_join:
            try:
                self.join(room)
            except Exception:
                logger.exception("Auto-join failed for room %s", room)
                RNS.log(f"RRC auto-join failed for #{room}", RNS.LOG_ERROR)

    def _handle_joined(self, env: dict[int, Any]) -> None:
        room = envelope_room(env)
        if not room:
            return
        body = envelope_body(env)
        joiner_nick = envelope_nick(env)
        own_hash = self._src_hash()
        body_hashes = []
        if isinstance(body, list):
            body_hashes = [
                bytes(entry) for entry in body if isinstance(entry, (bytes, bytearray))
            ]

        with self._lock:
            self_join = room in self._pending_joins
            if self_join:
                self._pending_joins.discard(room)
            self.rooms.add(room)
            self._rejoin_rooms.add(room)
            for hash_bytes in body_hashes:
                self._track_member(room, hash_bytes)
            self._track_member(room, own_hash)
            if (
                not self_join
                and isinstance(joiner_nick, str)
                and joiner_nick
                and len(body_hashes) == 1
            ):
                joiner = body_hashes[0]
                if joiner != own_hash:
                    self._track_nick(joiner, joiner_nick)

        if self_join:
            RNS.log(f"RRC joined #{room}", RNS.LOG_INFO)

        msg = RRCMessage(
            kind="joined",
            room=room,
            src=body_hashes[0] if len(body_hashes) == 1 else None,
            nick=joiner_nick,
            text=room,
            ts=now_ms(),
            hub_hash=self.hub_hash,
            raw=env,
        )
        self._emit("joined", msg)

    def _handle_parted(self, env: dict[int, Any]) -> None:
        room = envelope_room(env)
        if not room:
            return
        body = envelope_body(env)
        parter_nick = envelope_nick(env)
        own_hash = self._src_hash()
        body_hashes = []
        if isinstance(body, list):
            body_hashes = [
                bytes(entry) for entry in body if isinstance(entry, (bytes, bytearray))
            ]

        with self._lock:
            self_part = room in self._pending_parts
            if self_part:
                self._pending_parts.discard(room)
            if (
                not self_part
                and isinstance(parter_nick, str)
                and parter_nick
                and len(body_hashes) == 1
            ):
                parter = body_hashes[0]
                if parter != own_hash:
                    self.nicks[parter] = parter_nick
            members = self.members.get(room)
            if members is not None:
                for hash_bytes in body_hashes:
                    members.discard(hash_bytes)
            if self_part:
                self.rooms.discard(room)
                self.members.pop(room, None)
                self._rejoin_rooms.discard(room)

        msg = RRCMessage(
            kind="parted",
            room=room,
            src=body_hashes[0] if len(body_hashes) == 1 else None,
            nick=parter_nick,
            text=room,
            ts=now_ms(),
            hub_hash=self.hub_hash,
            raw=env,
        )
        self._emit("parted", msg)

    def _handle_room_content(self, env: dict[int, Any], msg_type: int) -> None:
        body = envelope_body(env)
        room = envelope_room(env)
        src = envelope_src(env)
        nick = envelope_nick(env)
        mid = env.get(K_ID)
        own_hash = self._src_hash()

        if (
            src is not None
            and src == own_hash
            and isinstance(mid, (bytes, bytearray))
            and bytes(mid) in self._sent_ids
        ):
            return

        if src is not None and nick:
            with self._lock:
                self._track_nick(src, nick)
                if room:
                    self._track_member(room, src)

        kind = {T_MSG: "msg", T_NOTICE: "notice", T_ACTION: "action"}[msg_type]

        if (
            msg_type == T_NOTICE
            and room is None
            and isinstance(body, str)
            and body.strip()
        ):
            with self._lock:
                self.motd = body
            self._emit("motd", body)

        if not isinstance(body, str):
            return

        mention = False
        if src != own_hash and self.nick:
            pat = _mention_re(self.nick)
            if pat is not None and pat.search(body):
                mention = True

        msg = RRCMessage(
            kind=kind,
            room=room,
            src=src,
            nick=nick,
            text=body,
            ts=now_ms(),
            hub_hash=self.hub_hash,
            mention=mention,
            raw=env,
        )
        self._emit(kind, msg)

    def status_dict(self) -> dict[str, Any]:
        """Return a serializable status snapshot."""
        with self._lock:
            return {
                "hub_hash": self.hub_hash.hex(),
                "dest_name": self.dest_name,
                "status": STATUS_NAMES.get(self.status, str(self.status)),
                "status_text": self.status_text,
                "welcomed": self.welcomed,
                "hub_name": self.hub_name,
                "hub_version": self.hub_version,
                "nick": self.nick,
                "rooms": sorted(self.rooms),
                "rejoin_rooms": sorted(self._rejoin_rooms),
                "motd": self.motd,
                "max_msg_body_bytes": self.max_msg_body_bytes,
                "max_rooms_per_session": self.max_rooms_per_session,
                "rate_limit_msgs_per_minute": self.rate_limit_msgs_per_minute,
            }

connected property

True when the session is welcomed and the link is active.

connect()

Begin connecting to the hub in a background thread.

Source code in lxmfy/rrc/client.py
def connect(self) -> None:
    """Begin connecting to the hub in a background thread."""
    with self._lock:
        if self.status in (STATUS_CONNECTING, STATUS_CONNECTED):
            return
        self._manual_disconnect = False
        if self._reconnect_timer is not None:
            self._reconnect_timer.cancel()
            self._reconnect_timer = None
        text = (
            f"Reconnecting (attempt {self._reconnect_attempts})"
            if self._reconnect_attempts > 0
            else "Connecting"
        )
        self._set_status(STATUS_CONNECTING, text)

    threading.Thread(target=self._connect_worker, daemon=True).start()

disconnect()

Tear down the session and cancel reconnect.

Source code in lxmfy/rrc/client.py
def disconnect(self) -> None:
    """Tear down the session and cancel reconnect."""
    self._stop_hello.set()
    with self._lock:
        self._manual_disconnect = True
        self._reconnect_attempts = 0
        if self._reconnect_timer is not None:
            self._reconnect_timer.cancel()
            self._reconnect_timer = None
        link = self.link
        self.link = None
    if link is not None:
        with contextlib.suppress(Exception):
            link.teardown()
    self._reset_session_state(preserve_rejoin=False)
    self._deregister_announce_handler()
    self._set_status(STATUS_DISCONNECTED, "Disconnected")

join(room, key=None)

Request membership in a room. Returns the normalized room name.

Source code in lxmfy/rrc/client.py
def join(self, room: str, key: str | None = None) -> str:
    """Request membership in a room. Returns the normalized room name."""
    room_n = normalize_room(room)
    if len(room_n.encode("utf-8")) > self.max_room_name_bytes:
        raise ValueError("room name too long for hub limit")
    with self._lock:
        projected = len(self.rooms | self._pending_joins | {room_n})
        if projected > self.max_rooms_per_session:
            raise RuntimeError("max rooms per session exceeded")
        self._pending_joins.add(room_n)
        self._rejoin_rooms.add(room_n)
    body = key if isinstance(key, str) and key else None
    env = make_envelope(
        T_JOIN,
        src=self._src_hash(),
        room=room_n,
        body=body,
        nick=self.nick,
    )
    self._send_env(env)
    return room_n

part(room)

Leave a room. Returns the normalized room name.

Source code in lxmfy/rrc/client.py
def part(self, room: str) -> str:
    """Leave a room. Returns the normalized room name."""
    room_n = normalize_room(room)
    env = make_envelope(
        T_PART,
        src=self._src_hash(),
        room=room_n,
        nick=self.nick,
    )
    with self._lock:
        self._pending_parts.add(room_n)
        self._rejoin_rooms.discard(room_n)
    try:
        self._send_env(env)
    except Exception as exc:
        logger.warning("RRC PART send failed for #%s: %s", room_n, exc)
        with self._lock:
            self._pending_parts.discard(room_n)
            self._rejoin_rooms.add(room_n)
        raise
    with self._lock:
        self.rooms.discard(room_n)
        self.members.pop(room_n, None)
    return room_n

ping()

Send a PING and return the correlation body.

Source code in lxmfy/rrc/client.py
def ping(self) -> bytes:
    """Send a PING and return the correlation body."""
    body = os.urandom(8)
    env = make_envelope(T_PING, src=self._src_hash(), body=body)
    with self._lock:
        now = now_ms()
        self._pending_pings[body] = now
        expired = [k for k, v in self._pending_pings.items() if now - v > 15000]
        for key in expired:
            self._pending_pings.pop(key, None)
        while len(self._pending_pings) > MAX_PENDING_PINGS:
            oldest = next(iter(self._pending_pings))
            self._pending_pings.pop(oldest, None)
    self._send_env(env)
    return body

send_action(room, text)

Send an ACTION to a room. Returns the message id.

Source code in lxmfy/rrc/client.py
def send_action(self, room: str, text: str) -> bytes:
    """Send an ACTION to a room. Returns the message id."""
    return self._send_room_text(T_ACTION, room, text, "action")

send_command(text, room=None)

Send a slash command as a MSG body.

Source code in lxmfy/rrc/client.py
def send_command(self, text: str, room: str | None = None) -> None:
    """Send a slash command as a MSG body."""
    if not isinstance(text, str) or not text.startswith("/"):
        raise ValueError("command must start with /")
    room_n = normalize_room(room) if room else None
    self._check_rate_limit()
    env = make_envelope(
        T_MSG,
        src=self._src_hash(),
        room=room_n,
        body=text,
        nick=self.nick,
    )
    self._send_env(env)

send_message(room, text)

Send a MSG to a room. Returns the message id.

Source code in lxmfy/rrc/client.py
def send_message(self, room: str, text: str) -> bytes:
    """Send a MSG to a room. Returns the message id."""
    return self._send_room_text(T_MSG, room, text, "msg")

send_notice(room, text)

Send a NOTICE to a room. Returns the message id.

Source code in lxmfy/rrc/client.py
def send_notice(self, room: str, text: str) -> bytes:
    """Send a NOTICE to a room. Returns the message id."""
    return self._send_room_text(T_NOTICE, room, text, "notice")

set_auto_join(rooms)

Configure rooms to join automatically after WELCOME.

Source code in lxmfy/rrc/client.py
def set_auto_join(self, rooms: list[str] | None) -> None:
    """Configure rooms to join automatically after WELCOME."""
    with self._lock:
        self._auto_join_rooms = [
            normalize_room(r) for r in (rooms or []) if isinstance(r, str)
        ]

set_nick(nick)

Set the advisory nickname used on outgoing envelopes.

Source code in lxmfy/rrc/client.py
def set_nick(self, nick: str | None) -> None:
    """Set the advisory nickname used on outgoing envelopes."""
    if nick is not None and nick != "":
        if not isinstance(nick, str):
            raise ValueError("nick must be a string")
        if len(nick.encode("utf-8")) > self.max_nick_bytes:
            raise ValueError("nick too long for hub limit")
    with self._lock:
        self.nick = nick if isinstance(nick, str) and nick else None

status_dict()

Return a serializable status snapshot.

Source code in lxmfy/rrc/client.py
def status_dict(self) -> dict[str, Any]:
    """Return a serializable status snapshot."""
    with self._lock:
        return {
            "hub_hash": self.hub_hash.hex(),
            "dest_name": self.dest_name,
            "status": STATUS_NAMES.get(self.status, str(self.status)),
            "status_text": self.status_text,
            "welcomed": self.welcomed,
            "hub_name": self.hub_name,
            "hub_version": self.hub_version,
            "nick": self.nick,
            "rooms": sorted(self.rooms),
            "rejoin_rooms": sorted(self._rejoin_rooms),
            "motd": self.motd,
            "max_msg_body_bytes": self.max_msg_body_bytes,
            "max_rooms_per_session": self.max_rooms_per_session,
            "rate_limit_msgs_per_minute": self.rate_limit_msgs_per_minute,
        }

Owns RRCClient sessions and fans events out to bot handlers.

Source code in lxmfy/rrc/manager.py
class RRCManager:
    """Owns RRCClient sessions and fans events out to bot handlers."""

    STORAGE_KEY = "rrc_sessions"

    def __init__(
        self,
        identity: RNS.Identity | None = None,
        nick: str | None = None,
        dest_name: str = DEFAULT_DEST_NAME,
        auto_reconnect: bool = True,
        storage=None,
        persist_sessions: bool = False,
    ):
        self.identity = identity
        self.nick = nick
        self.dest_name = dest_name or DEFAULT_DEST_NAME
        self.auto_reconnect = auto_reconnect
        self.storage = storage
        self.persist_sessions = bool(persist_sessions)
        self.clients: dict[bytes, RRCClient] = {}
        self._handlers: list[EventCallback] = []

    def on_event(self, callback: EventCallback) -> EventCallback:
        """Register an event callback. Returns the callback for decorator use."""
        self._handlers.append(callback)
        return callback

    def _dispatch(self, event: str, client: RRCClient, payload: Any) -> None:
        for handler in list(self._handlers):
            try:
                run_sync(handler, event, client, payload)
            except Exception:
                logger.exception("RRC manager handler failed for %s", event)
        if event in ("joined", "parted"):
            self.save_sessions()

    def connect(
        self,
        hub_hash: bytes | str,
        rooms: list[str] | None = None,
        nick: str | None = None,
        dest_name: str | None = None,
        auto_reconnect: bool | None = None,
    ) -> RRCClient:
        """Connect to an RRC hub. Returns the client session."""
        if self.identity is None:
            raise RuntimeError("RRC manager has no identity")

        if isinstance(hub_hash, str):
            hub_bytes = bytes.fromhex(hub_hash)
        else:
            hub_bytes = bytes(hub_hash)

        existing = self.clients.get(hub_bytes)
        if existing is not None:
            if rooms:
                existing.set_auto_join(rooms)
                with existing._lock:
                    for room in rooms:
                        if isinstance(room, str):
                            existing._rejoin_rooms.add(normalize_room(room))
            if nick is not None:
                existing.set_nick(nick)
            if not existing.connected:
                existing.connect()
            elif rooms:
                for room in rooms:
                    try:
                        existing.join(room)
                    except Exception:
                        logger.exception("Failed joining room %s", room)
            self.save_sessions()
            return existing

        client = RRCClient(
            identity=self.identity,
            hub_hash=hub_bytes,
            dest_name=dest_name or self.dest_name,
            nick=nick if nick is not None else self.nick,
            auto_reconnect=(
                self.auto_reconnect if auto_reconnect is None else auto_reconnect
            ),
            on_event=self._dispatch,
        )
        if rooms:
            client.set_auto_join(rooms)
            with client._lock:
                for room in rooms:
                    if isinstance(room, str):
                        client._rejoin_rooms.add(normalize_room(room))
        self.clients[hub_bytes] = client
        client.connect()
        self.save_sessions()
        return client

    def disconnect(self, hub_hash: bytes | str | None = None) -> None:
        """Disconnect one hub, or all hubs when hub_hash is None."""
        if hub_hash is None:
            for client in list(self.clients.values()):
                client.disconnect()
            self.clients.clear()
            self.save_sessions()
            return

        hub_bytes = (
            bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
        )
        client = self.clients.pop(hub_bytes, None)
        if client is not None:
            client.disconnect()
        self.save_sessions()

    def get(self, hub_hash: bytes | str) -> RRCClient | None:
        """Return a connected client for a hub hash, if present."""
        hub_bytes = (
            bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
        )
        return self.clients.get(hub_bytes)

    def send_message(
        self,
        room: str,
        text: str,
        hub_hash: bytes | str | None = None,
    ) -> bytes:
        """Send a MSG on the first connected hub, or a specific hub."""
        client = self._require_client(hub_hash)
        return client.send_message(room, text)

    def send_notice(
        self,
        room: str,
        text: str,
        hub_hash: bytes | str | None = None,
    ) -> bytes:
        """Send a NOTICE on the first connected hub, or a specific hub."""
        client = self._require_client(hub_hash)
        return client.send_notice(room, text)

    def send_action(
        self,
        room: str,
        text: str,
        hub_hash: bytes | str | None = None,
    ) -> bytes:
        """Send an ACTION on the first connected hub, or a specific hub."""
        client = self._require_client(hub_hash)
        return client.send_action(room, text)

    def join(
        self,
        room: str,
        hub_hash: bytes | str | None = None,
    ) -> str:
        """Join a room on the first connected hub, or a specific hub."""
        client = self._require_client(hub_hash)
        room_n = client.join(room)
        self.save_sessions()
        return room_n

    def part(
        self,
        room: str,
        hub_hash: bytes | str | None = None,
    ) -> str:
        """Part a room on the first connected hub, or a specific hub."""
        client = self._require_client(hub_hash)
        room_n = client.part(room)
        self.save_sessions()
        return room_n

    def _require_client(self, hub_hash: bytes | str | None) -> RRCClient:
        if hub_hash is not None:
            client = self.get(hub_hash)
            if client is None:
                raise RuntimeError("RRC hub not connected")
            return client
        for client in self.clients.values():
            if client.connected:
                return client
        raise RuntimeError("No connected RRC hub")

    def status(self) -> list[dict[str, Any]]:
        """Return status snapshots for all hub sessions."""
        return [client.status_dict() for client in self.clients.values()]

    def save_sessions(self) -> None:
        """Persist hub hashes, rooms, and nick for crash recovery."""
        if not self.persist_sessions or self.storage is None:
            return
        entries = []
        for client in self.clients.values():
            with client._lock:
                rooms = sorted(
                    set(client.rooms)
                    | set(client._rejoin_rooms)
                    | set(client._auto_join_rooms),
                )
            entries.append(
                {
                    "hub_hash": client.hub_hash.hex(),
                    "dest_name": client.dest_name,
                    "nick": client.nick,
                    "rooms": rooms,
                    "auto_reconnect": client.auto_reconnect,
                },
            )
        try:
            self.storage.set(self.STORAGE_KEY, entries)
        except Exception:
            logger.exception("Failed to persist RRC sessions")

    def restore_sessions(self) -> int:
        """Restore persisted hub sessions. Returns number of hubs reconnected."""
        if not self.persist_sessions or self.storage is None:
            return 0
        entries = self.storage.get(self.STORAGE_KEY, [])
        if not isinstance(entries, list):
            return 0
        restored = 0
        for entry in entries:
            if not isinstance(entry, dict):
                continue
            hub_hash = entry.get("hub_hash")
            if not isinstance(hub_hash, str):
                continue
            try:
                self.connect(
                    hub_hash,
                    rooms=entry.get("rooms")
                    if isinstance(entry.get("rooms"), list)
                    else None,
                    nick=entry.get("nick")
                    if isinstance(entry.get("nick"), str)
                    else None,
                    dest_name=entry.get("dest_name")
                    if isinstance(entry.get("dest_name"), str)
                    else None,
                    auto_reconnect=entry.get("auto_reconnect")
                    if isinstance(entry.get("auto_reconnect"), bool)
                    else None,
                )
                restored += 1
            except Exception:
                logger.exception("Failed to restore RRC hub %s", hub_hash)
        return restored

    def shutdown(self) -> None:
        """Persist session state and tear down links without forgetting hubs."""
        self.save_sessions()
        for client in list(self.clients.values()):
            try:
                client.disconnect()
            except Exception:
                logger.exception("RRC client disconnect failed during shutdown")

connect(hub_hash, rooms=None, nick=None, dest_name=None, auto_reconnect=None)

Connect to an RRC hub. Returns the client session.

Source code in lxmfy/rrc/manager.py
def connect(
    self,
    hub_hash: bytes | str,
    rooms: list[str] | None = None,
    nick: str | None = None,
    dest_name: str | None = None,
    auto_reconnect: bool | None = None,
) -> RRCClient:
    """Connect to an RRC hub. Returns the client session."""
    if self.identity is None:
        raise RuntimeError("RRC manager has no identity")

    if isinstance(hub_hash, str):
        hub_bytes = bytes.fromhex(hub_hash)
    else:
        hub_bytes = bytes(hub_hash)

    existing = self.clients.get(hub_bytes)
    if existing is not None:
        if rooms:
            existing.set_auto_join(rooms)
            with existing._lock:
                for room in rooms:
                    if isinstance(room, str):
                        existing._rejoin_rooms.add(normalize_room(room))
        if nick is not None:
            existing.set_nick(nick)
        if not existing.connected:
            existing.connect()
        elif rooms:
            for room in rooms:
                try:
                    existing.join(room)
                except Exception:
                    logger.exception("Failed joining room %s", room)
        self.save_sessions()
        return existing

    client = RRCClient(
        identity=self.identity,
        hub_hash=hub_bytes,
        dest_name=dest_name or self.dest_name,
        nick=nick if nick is not None else self.nick,
        auto_reconnect=(
            self.auto_reconnect if auto_reconnect is None else auto_reconnect
        ),
        on_event=self._dispatch,
    )
    if rooms:
        client.set_auto_join(rooms)
        with client._lock:
            for room in rooms:
                if isinstance(room, str):
                    client._rejoin_rooms.add(normalize_room(room))
    self.clients[hub_bytes] = client
    client.connect()
    self.save_sessions()
    return client

disconnect(hub_hash=None)

Disconnect one hub, or all hubs when hub_hash is None.

Source code in lxmfy/rrc/manager.py
def disconnect(self, hub_hash: bytes | str | None = None) -> None:
    """Disconnect one hub, or all hubs when hub_hash is None."""
    if hub_hash is None:
        for client in list(self.clients.values()):
            client.disconnect()
        self.clients.clear()
        self.save_sessions()
        return

    hub_bytes = (
        bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
    )
    client = self.clients.pop(hub_bytes, None)
    if client is not None:
        client.disconnect()
    self.save_sessions()

get(hub_hash)

Return a connected client for a hub hash, if present.

Source code in lxmfy/rrc/manager.py
def get(self, hub_hash: bytes | str) -> RRCClient | None:
    """Return a connected client for a hub hash, if present."""
    hub_bytes = (
        bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
    )
    return self.clients.get(hub_bytes)

join(room, hub_hash=None)

Join a room on the first connected hub, or a specific hub.

Source code in lxmfy/rrc/manager.py
def join(
    self,
    room: str,
    hub_hash: bytes | str | None = None,
) -> str:
    """Join a room on the first connected hub, or a specific hub."""
    client = self._require_client(hub_hash)
    room_n = client.join(room)
    self.save_sessions()
    return room_n

on_event(callback)

Register an event callback. Returns the callback for decorator use.

Source code in lxmfy/rrc/manager.py
def on_event(self, callback: EventCallback) -> EventCallback:
    """Register an event callback. Returns the callback for decorator use."""
    self._handlers.append(callback)
    return callback

part(room, hub_hash=None)

Part a room on the first connected hub, or a specific hub.

Source code in lxmfy/rrc/manager.py
def part(
    self,
    room: str,
    hub_hash: bytes | str | None = None,
) -> str:
    """Part a room on the first connected hub, or a specific hub."""
    client = self._require_client(hub_hash)
    room_n = client.part(room)
    self.save_sessions()
    return room_n

restore_sessions()

Restore persisted hub sessions. Returns number of hubs reconnected.

Source code in lxmfy/rrc/manager.py
def restore_sessions(self) -> int:
    """Restore persisted hub sessions. Returns number of hubs reconnected."""
    if not self.persist_sessions or self.storage is None:
        return 0
    entries = self.storage.get(self.STORAGE_KEY, [])
    if not isinstance(entries, list):
        return 0
    restored = 0
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        hub_hash = entry.get("hub_hash")
        if not isinstance(hub_hash, str):
            continue
        try:
            self.connect(
                hub_hash,
                rooms=entry.get("rooms")
                if isinstance(entry.get("rooms"), list)
                else None,
                nick=entry.get("nick")
                if isinstance(entry.get("nick"), str)
                else None,
                dest_name=entry.get("dest_name")
                if isinstance(entry.get("dest_name"), str)
                else None,
                auto_reconnect=entry.get("auto_reconnect")
                if isinstance(entry.get("auto_reconnect"), bool)
                else None,
            )
            restored += 1
        except Exception:
            logger.exception("Failed to restore RRC hub %s", hub_hash)
    return restored

save_sessions()

Persist hub hashes, rooms, and nick for crash recovery.

Source code in lxmfy/rrc/manager.py
def save_sessions(self) -> None:
    """Persist hub hashes, rooms, and nick for crash recovery."""
    if not self.persist_sessions or self.storage is None:
        return
    entries = []
    for client in self.clients.values():
        with client._lock:
            rooms = sorted(
                set(client.rooms)
                | set(client._rejoin_rooms)
                | set(client._auto_join_rooms),
            )
        entries.append(
            {
                "hub_hash": client.hub_hash.hex(),
                "dest_name": client.dest_name,
                "nick": client.nick,
                "rooms": rooms,
                "auto_reconnect": client.auto_reconnect,
            },
        )
    try:
        self.storage.set(self.STORAGE_KEY, entries)
    except Exception:
        logger.exception("Failed to persist RRC sessions")

send_action(room, text, hub_hash=None)

Send an ACTION on the first connected hub, or a specific hub.

Source code in lxmfy/rrc/manager.py
def send_action(
    self,
    room: str,
    text: str,
    hub_hash: bytes | str | None = None,
) -> bytes:
    """Send an ACTION on the first connected hub, or a specific hub."""
    client = self._require_client(hub_hash)
    return client.send_action(room, text)

send_message(room, text, hub_hash=None)

Send a MSG on the first connected hub, or a specific hub.

Source code in lxmfy/rrc/manager.py
def send_message(
    self,
    room: str,
    text: str,
    hub_hash: bytes | str | None = None,
) -> bytes:
    """Send a MSG on the first connected hub, or a specific hub."""
    client = self._require_client(hub_hash)
    return client.send_message(room, text)

send_notice(room, text, hub_hash=None)

Send a NOTICE on the first connected hub, or a specific hub.

Source code in lxmfy/rrc/manager.py
def send_notice(
    self,
    room: str,
    text: str,
    hub_hash: bytes | str | None = None,
) -> bytes:
    """Send a NOTICE on the first connected hub, or a specific hub."""
    client = self._require_client(hub_hash)
    return client.send_notice(room, text)

shutdown()

Persist session state and tear down links without forgetting hubs.

Source code in lxmfy/rrc/manager.py
def shutdown(self) -> None:
    """Persist session state and tear down links without forgetting hubs."""
    self.save_sessions()
    for client in list(self.clients.values()):
        try:
            client.disconnect()
        except Exception:
            logger.exception("RRC client disconnect failed during shutdown")

status()

Return status snapshots for all hub sessions.

Source code in lxmfy/rrc/manager.py
def status(self) -> list[dict[str, Any]]:
    """Return status snapshots for all hub sessions."""
    return [client.status_dict() for client in self.clients.values()]

A decoded room or session event from an RRC hub.

Source code in lxmfy/rrc/client.py
@dataclass
class RRCMessage:
    """A decoded room or session event from an RRC hub."""

    kind: str
    room: str | None
    src: bytes | None
    nick: str | None
    text: str
    ts: int
    hub_hash: bytes | None = None
    mention: bool = False
    raw: dict[int, Any] = field(default_factory=dict, repr=False)