Skip to content

fix(discord): resolve plugin by package root when syncing slash commands - #10632

Open
yuexia5 wants to merge 1 commit into
AstrBotDevs:masterfrom
yuexia5:fix/10626-discord-star-map-keyerror
Open

yuexia5 wants to merge 1 commit into
AstrBotDevs:masterfrom
yuexia5:fix/10626-discord-star-map-keyerror

Conversation

@yuexia5

@yuexia5 yuexia5 commented Oct 19, 2026 •

Copy link
Copy Markdown

Fixes #10626

A plugin handler defined in a submodule (e.g. a mixin class in plugin/foo.py inherited by the plugin's main Star class) is registered with handler_module_path = handler.__module__, e.g. data.plugins.<plugin>.foo. But star_map is only keyed by the plugin's entry module (data.plugins.<plugin>.main).

DiscordPlatformAdapter._collect_and_register_commands dereferenced star_map[handler_md.handler_module_path] directly, so a single such handler raises KeyError inside on_ready, aborting the loop before sync_commands() is reached -- and no plugin's slash commands get registered, not just the offending plugin's.

Modifications / 改动点

  • astrbot/core/platform/sources/discord/discord_platform_adapter.py:
    _collect_and_register_commands now resolves the owning plugin through
    _plugin_for_handler_module(): exact star_map lookup first, then a package
    root match on data.plugins.<root_dir_name> /
    astrbot.builtin_stars.<root_dir_name> (respecting reserved), so handlers
    defined in an active plugin's submodule or __init__.py still sync their
    slash commands. Only handlers that no plugin owns are skipped, instead of
    raising KeyError; the activated / enabled checks are unchanged.

  • tests/test_discord_command_sync.py: regression coverage for:

    • an unowned handler no longer aborts the loop, and active plugins still register;
    • a submodule handler of an activated plugin registers;
    • a handler defined in the plugin __init__.py (module_path == package root) registers;
    • an orphan handler owned by no plugin is skipped;
    • a submodule handler of a deactivated plugin is skipped;
    • the tests no longer depend on sys.modules["discord"] import order.
  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

The new regression test fails on the old code (KeyError, nothing registered)
and passes with this fix. Discord suite, run together with
tests/test_discord_adapter.py (which imports the real discord module first,
matching how CI collects the whole tests/ directory):

tests/test_discord_adapter.py + tests/test_discord_command_sync.py
46 passed

The rest of the suite is unchanged: pytest tests reports 4693 passed,
12 skipped (verified on Python 3.12 with py-cord installed).

Ruff check and format --check are both clean.


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    Not applicable: this PR adds no new features, it only removes a crash path.
  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    Verification steps: PYTHONPATH=. python -m pytest tests/test_discord_adapter.py tests/test_discord_command_sync.py
  • 📚 I checked the affected WebUI instructions and screenshots in docs/zh and docs/en against the changed navigation, page structure, and labels, and updated them in this PR (or explained why no documentation update is needed).
    No documentation update needed: this touches only the Discord adapter's internal command collection, with no WebUI navigation, page structure, or terminology change.
  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 My changes do not introduce malicious code.

Note: the fix is the approach suggested in #10626 and in review. The resolver is kept local to the Discord adapter for now; once #9976 (plugin_for_handler_module in star_handler.py) lands, this can be rebased onto the shared resolver.


CI note: the Unit Tests job currently hangs on master at
tests/unit/test_core_lifecycle.py and gets cancelled at the 30-minute job
timeout. That is a pre-existing issue (a process-wide threading.Thread mock
plus asyncio.to_thread() in restart()), unrelated to this PR, and is
already being fixed in #10617. Until that lands, every Unit Tests run will
time out.

sourcery-ai[bot]
sourcery-ai Bot previously approved these changes Oct 19, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Approved.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@he-yufeng

Copy link
Copy Markdown
Contributor

The crash fix is needed and the regression test is real, but the skip end-state drops a live plugin's commands: a handler defined in a plugin submodule (mixin in plugin/foo.py) inherits the plugin's lifecycle, so when the plugin is activated its slash commands should still sync. With star_map.get(...) or skip, an activated plugin's submodule commands silently never reach Discord. Telegram already has this silent-drop shape at tg_adapter.py:359 (not in star_map or not ...activated skips them there too), so the two adapters would agree on the wrong behavior.

The resolution that keeps the commands working is to map the handler module to its owning plugin package rather than require an exact star_map key: star_map is keyed by entry module (data.plugins.<plugin>.main), so the owner of data.plugins.<plugin>.foo is found by matching the plugin's package root (data.plugins.<plugin>) as a prefix. My open #9976 adds exactly that resolver (plugin_for_handler_module in star_handler.py) for the hook-binding side of the same bug family; the same lookup fits here, e.g. resolve the plugin, then apply the existing activated/enabled checks, and only skip when no plugin owns the module at all.

Worth a test variant beyond the orphan case: a submodule handler whose owning plugin IS activated should have its commands registered, not skipped.

Sibling sites with the same unguarded subscript, in case you want to track them separately rather than fix them here: core_lifecycle.py:372, result_decorate/stage.py:166/178/186, waking_check/stage.py:189/209, platform/manager.py:253, and func_tool_manager.py:1053/1094 (the last two surface a user-facing "plugin disabled" message, so they need the resolver more than a skip).

@yuexia5

yuexia5 commented Oct 19, 2026

Copy link
Copy Markdown
Author

Thanks for the careful review — the submodule-drop concern is valid, fixed in dd0e938.

_collect_and_register_commands now resolves the owning plugin via a new _plugin_for_handler_module() helper: exact star_map lookup first, then a package-root prefix match on data.plugins.<root_dir_name> / astrbot.builtin_stars.<root_dir_name> (respecting reserved). Only handlers owned by no plugin at all are skipped. activated/enabled checks are applied to the resolved plugin as you described; the Telegram adapter is left untouched here per your note that the sibling sites deserve separate tracking.

Tests added per your suggestion:

  • submodule handler of an activated plugin -> command is registered and synced
  • submodule handler of an inactive plugin -> no command registered
  • the original orphan case still skips without KeyError

All four pass locally. Happy to rebase onto plugin_for_handler_module from #9976 once it lands, if you'd prefer the shared resolver.

@sourcery-ai
sourcery-ai Bot dismissed their stale review October 19, 2026 18:56

Sourcery withdrew this approval because the latest commits introduced blocking findings.

@yuexia5

yuexia5 commented Oct 19, 2026

Copy link
Copy Markdown
Author

Follow-up in 2251d5a: while re-checking I noticed the prefix-only match dropped handlers defined in a plugin's __init__.py (module path equals the package root exactly, no trailing dot). The resolver now accepts an exact package-root match as well, mirroring plugin_for_handler_module in #9976. Added tests for package-root handlers and for true orphan modules; all 6 pass locally.

A handler defined in a plugin submodule (e.g. a mixin in
`data.plugins.<plugin>.foo`) is registered with `handler_module_path`
pointing at that submodule, but `star_map` is keyed by the plugin entry
module. `_collect_and_register_commands` subscripted `star_map` directly,
raising `KeyError` inside `on_ready` and aborting the loop before
`sync_commands()` was reached, so no plugin's slash commands were
registered at all.

Resolve the owning plugin by package root instead: exact `star_map`
lookup first, then a match on `data.plugins.<root_dir_name>` /
`astrbot.builtin_stars.<root_dir_name>`. Only handlers that no plugin
owns are skipped, and activated plugins keep their slash commands even
when the handler lives in a submodule or in the plugin `__init__.py`.

Tests cover the unowned-handler regression, submodule handlers of active
and inactive plugins, package-root handlers, and true orphans. They also
no longer depend on `sys.modules["discord"]` import order, which made the
new cases fail when the whole suite imported the real py-cord first.

Fixes AstrBotDevs#10626
@yuexia5
yuexia5 force-pushed the fix/10626-discord-star-map-keyerror branch from 2251d5a to 91c8b27 Compare October 20, 2026 03:00
@yuexia5 yuexia5 changed the title fix: skip handlers whose plugin is missing from star_map fix(discord): resolve plugin by package root when syncing slash commands Oct 20, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sourcery assessment

Approved.

@yuexia5

yuexia5 commented Oct 20, 2026

Copy link
Copy Markdown
Author

Verified end-to-end against a real Discord bot

In addition to the unit tests, I ran both revisions against a real bot in a test guild. Guild-scoped command registration was used so the result is observable immediately instead of waiting for global command propagation.

Test setup

A throwaway plugin with one command in main.py and one command on a mixin in mixin.py:

# mixin.py
class SubmoduleMixin:
    @filter.command("submod_cmd")
    async def submod_cmd(self, event: AstrMessageEvent):
        ...

so that handler is registered with handler_module_path = data.plugins.<plugin>.mixin.

Before (master, old code)

[Discord] Collecting and registering slash commands...
[ERROR] [Discord] on_ready_once_callback err: 'data.plugins.<plugin>.mixin'
KeyError: 'data.plugins.<plugin>.mixin'

GET /applications/<app>/guilds/<guild>/commands returned 0 commands.

So the KeyError aborted the sync loop exactly as described in #10626: nothing was registered, not even the command defined in the plugin's main.py.

After (this PR)

[Discord] Collecting and registering slash commands...
[Discord] Ready to sync 13 commands: submod_cmd, main_cmd, help, ...
[Discord] Command synchronization completed.

GET /applications/<app>/guilds/<guild>/commands returned 13 commands, including /main_cmd and /submod_cmd.

The submodule handler's command now reaches Discord instead of both aborting the loop and losing its own command. The temporary guild commands were removed after the run and the throwaway instance was shut down.

@yuexia5

yuexia5 commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Thanks — implemented along the lines you suggested. _plugin_for_handler_module() tries the exact star_map key first, then matches the plugin package root (data.plugins.<root> / astrbot.builtin_stars.<root>, honouring reserved), including the case where the module path equals the package root itself (a handler in __init__.py). The existing activated (plugin) and enabled (handler) checks are unchanged, and only handlers no plugin owns are skipped. Test for the activated-submodule case: test_discord_command_sync_registers_submodule_handler_of_active_plugin.

Two things to settle before this lands:

  1. Your fix(star): bind and unload hooks registered from plugin submodules #9976 has been open since 8 September and adds plugin_for_handler_module() / plugin_package_root() to star_handler.py — same semantics. I didn't want to hold the [Bug] Discord slash command sync aborts with KeyError when a plugin handler is defined in a submodule #10626 crash fix behind it, so the resolver here is a local mirror; I'll rebase onto yours once it lands. Say the word if you'd rather I wait.

  2. tg_adapter.py:358 still has the not in star_map shape. No crash there — just a silent drop for submodule handlers, so Discord and Telegram would disagree. Same family, different failure mode, so I've kept this PR to the [Bug] Discord slash command sync aborts with KeyError when a plugin handler is defined in a submodule #10626 crash. Telegram and the subscripts you listed are out of scope here — they'd be cleaner to fix on top of fix(star): bind and unload hooks registered from plugin submodules #9976's resolver anyway.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Discord slash command sync aborts with KeyError when a plugin handler is defined in a submodule

2 participants