envsbot 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {envsbot-2.2.0 → envsbot-2.3.0}/PKG-INFO +7 -6
- {envsbot-2.2.0 → envsbot-2.3.0}/README.md +4 -3
- {envsbot-2.2.0 → envsbot-2.3.0}/config_sample.py +21 -5
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_admin.py +12 -1
- envsbot-2.2.0/core_plugins/help.py → envsbot-2.3.0/core_plugins/help/__init__.py +32 -398
- envsbot-2.3.0/core_plugins/help/formatting.py +413 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/message_cache.py +8 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/PKG-INFO +7 -6
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/SOURCES.txt +8 -2
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/requires.txt +2 -2
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.py +2 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/translate.py +305 -4
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/weather.py +99 -77
- {envsbot-2.2.0 → envsbot-2.3.0}/pyproject.toml +37 -9
- {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_architecture.py +34 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_deploy_bootstrap.py +4 -4
- {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_mutmut_tooling.py +79 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_docs.py +6 -3
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/runtime.py +3 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/spec.py +5 -2
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/message_cache.py +180 -52
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager.py +15 -487
- envsbot-2.3.0/utils/plugin_manager_inspection.py +110 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_lifecycle.py +16 -0
- envsbot-2.3.0/utils/plugin_manager_runtime.py +398 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/translation_providers.py +226 -6
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/version.py +1 -1
- {envsbot-2.2.0 → envsbot-2.3.0}/LICENSE +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/audit.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/connection.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/context.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/dispatch.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/lifecycle.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/messages.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/permissions.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/room_state.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/bot/routing.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_core.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_reg_profile.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/audit.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/backups.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/config_cmd.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/doctor.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/outbox.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/plugins.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/presence.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/reports.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/defaults.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/invites.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/lifecycle.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/permissions.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/presence.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/settings.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/state.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/tasks.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/usage.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/lookup.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/permission_commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/permissions.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/roles.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/tracking.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/audit.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/command_usage.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/idlerpg.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/locking.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/manager.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/migrations/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/outbox.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/rooms.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/database/users.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/dependency_links.txt +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/entry_points.txt +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/top_level.txt +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/birthday_notify.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/dice.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/ducks.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/config.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/constants.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/events.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/export.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/handlers.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/items.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/leveling.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/map.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/quests.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/seasons.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/state.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/tasks.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/info.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/karma.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/pin.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/poll.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/config.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/events.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/lifecycle.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/parsing.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/runtime.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/store.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/tasks.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/command_support.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/config.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/fetch.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/lifecycle.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/listing.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/store.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/subscriptions.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/tasks.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/templates.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/sed.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/tell.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/tools.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/urlcheck.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/commands.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/config.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/fetch.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/fields.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/store.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/timezone.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/xkcd.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/plugins/xmpp.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/setup.cfg +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_deploy_script.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_packaging.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_alerts.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_notify.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_reports.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/audit.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/backups.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled/avatar.jpg +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled/init_chat_slang.csv +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled_assets.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_context.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_execution.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_help.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_metadata.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_registry.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/__init__.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/defaults.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/display.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/errors.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/loader.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/logging_setup.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/validation.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/database_cli.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/deploy_systemd_values.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/file_security.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/formatting.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/health.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/http_fetch.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/http_user_agent.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/logging_helpers.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/outbox.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/performance.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/permissions.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_dependencies.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_diagnostics.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_discovery.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_metadata.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/preflight.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/presence_manager.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/python_source.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/rate_limiter.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/redaction.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/release_state.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/room_features.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/runtime_paths.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/runtime_watchdog.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/systemd_deploy.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/task_supervisor.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/time_utils.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/tls_certificate.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/updatecheck.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/url_safety.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/urlcheck_extraction.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/utils/xmpp_notify.py +0 -0
- {envsbot-2.2.0 → envsbot-2.3.0}/vcard_sample.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: envsbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: A modular plugin-driven XMPP bot framework
|
|
5
5
|
Author-email: ~dan <fab@redterminal.org>, ~creme <creme@envs.net>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -22,7 +22,7 @@ Classifier: Operating System :: OS Independent
|
|
|
22
22
|
Requires-Python: >=3.12
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: envs-xmpp<2.0,>=1.
|
|
25
|
+
Requires-Dist: envs-xmpp<2.0,>=1.5.0
|
|
26
26
|
Requires-Dist: aiosqlite<1,>=0.20
|
|
27
27
|
Requires-Dist: aiohttp<4,>=3.14.3
|
|
28
28
|
Requires-Dist: beautifulsoup4<5,>=4.12.3
|
|
@@ -33,7 +33,7 @@ Requires-Dist: psutil<8,>=5.9
|
|
|
33
33
|
Requires-Dist: slixmpp<2,>=1.8
|
|
34
34
|
Provides-Extra: dev
|
|
35
35
|
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
36
|
-
Requires-Dist: mutmut
|
|
36
|
+
Requires-Dist: mutmut==3.6.0; extra == "dev"
|
|
37
37
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
38
38
|
Requires-Dist: pytest<10,>=8.0; extra == "dev"
|
|
39
39
|
Requires-Dist: pytest-asyncio>=1.3.0; extra == "dev"
|
|
@@ -75,7 +75,7 @@ The bot was originally developed for the **envs pubnix/tilde** community and fol
|
|
|
75
75
|
* vCard and avatar support via XEP-0054, XEP-0084 and XEP-0153
|
|
76
76
|
* RSS/Atom feed watcher for room and direct subscriptions with stable feed numbers, scoped listing/search, pause/retry controls and per-destination templates
|
|
77
77
|
* URL metadata checks for links, files and YouTube videos
|
|
78
|
-
* Shared
|
|
78
|
+
* Shared privacy-aware recent-message cache for reply-aware plugins
|
|
79
79
|
* Weather, translation, vCard lookup, XMPP diagnostics, reminders, polls, pins, tell messages and utility commands
|
|
80
80
|
* Community/fun plugins such as IdleRPG, ducks, dice, karma, sed corrections and XKCD
|
|
81
81
|
* Pytest-based test suite with Drone CI and GitHub Actions support
|
|
@@ -150,7 +150,7 @@ Verify the installed application and shared core without starting XMPP:
|
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
envsbot --version
|
|
153
|
-
# envsbot 2.
|
|
153
|
+
# envsbot 2.3.0 (envs-xmpp 1.5.0)
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
For a structured 72-hour post-release observation checklist, see
|
|
@@ -611,10 +611,11 @@ Run mutation tests with mutmut:
|
|
|
611
611
|
```bash
|
|
612
612
|
./scripts/mutmut.sh run
|
|
613
613
|
./scripts/mutmut.sh results
|
|
614
|
+
./scripts/mutmut.sh check
|
|
614
615
|
./scripts/mutmut.sh browse
|
|
615
616
|
```
|
|
616
617
|
|
|
617
|
-
The
|
|
618
|
+
The release mutation gate uses a curated deterministic scope in `pyproject.toml` rather than the entire bot. A full-repository audit currently creates more than 70,000 mutants and thousands of timeout-heavy async/background-task mutations, which is useful for occasional deep analysis but too noisy for a reproducible release gate. The curated scope covers routing, persistent message-cache storage, translate/weather behavior, help formatting, and plugin-manager internals and may be expanded only when added paths run without mutation timeouts. `scripts/mutmut.sh` deliberately unsets `PYTHONPATH` so the generated `./mutants` checkout cannot be shadowed by the original sources. Use `./scripts/mutmut.sh fresh` for a clean release-gate run, then `./scripts/mutmut.sh check` to compare it with the accepted survivor baseline. After reviewing an intentional survivor delta, `./scripts/mutmut.sh accept` updates that baseline. New `no tests`, timeout, suspicious, or unchecked results can never be accepted.
|
|
618
619
|
|
|
619
620
|
Drone CI is configured in `.drone.yml`.
|
|
620
621
|
|
|
@@ -31,7 +31,7 @@ The bot was originally developed for the **envs pubnix/tilde** community and fol
|
|
|
31
31
|
* vCard and avatar support via XEP-0054, XEP-0084 and XEP-0153
|
|
32
32
|
* RSS/Atom feed watcher for room and direct subscriptions with stable feed numbers, scoped listing/search, pause/retry controls and per-destination templates
|
|
33
33
|
* URL metadata checks for links, files and YouTube videos
|
|
34
|
-
* Shared
|
|
34
|
+
* Shared privacy-aware recent-message cache for reply-aware plugins
|
|
35
35
|
* Weather, translation, vCard lookup, XMPP diagnostics, reminders, polls, pins, tell messages and utility commands
|
|
36
36
|
* Community/fun plugins such as IdleRPG, ducks, dice, karma, sed corrections and XKCD
|
|
37
37
|
* Pytest-based test suite with Drone CI and GitHub Actions support
|
|
@@ -106,7 +106,7 @@ Verify the installed application and shared core without starting XMPP:
|
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
108
|
envsbot --version
|
|
109
|
-
# envsbot 2.
|
|
109
|
+
# envsbot 2.3.0 (envs-xmpp 1.5.0)
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
For a structured 72-hour post-release observation checklist, see
|
|
@@ -567,10 +567,11 @@ Run mutation tests with mutmut:
|
|
|
567
567
|
```bash
|
|
568
568
|
./scripts/mutmut.sh run
|
|
569
569
|
./scripts/mutmut.sh results
|
|
570
|
+
./scripts/mutmut.sh check
|
|
570
571
|
./scripts/mutmut.sh browse
|
|
571
572
|
```
|
|
572
573
|
|
|
573
|
-
The
|
|
574
|
+
The release mutation gate uses a curated deterministic scope in `pyproject.toml` rather than the entire bot. A full-repository audit currently creates more than 70,000 mutants and thousands of timeout-heavy async/background-task mutations, which is useful for occasional deep analysis but too noisy for a reproducible release gate. The curated scope covers routing, persistent message-cache storage, translate/weather behavior, help formatting, and plugin-manager internals and may be expanded only when added paths run without mutation timeouts. `scripts/mutmut.sh` deliberately unsets `PYTHONPATH` so the generated `./mutants` checkout cannot be shadowed by the original sources. Use `./scripts/mutmut.sh fresh` for a clean release-gate run, then `./scripts/mutmut.sh check` to compare it with the accepted survivor baseline. After reviewing an intentional survivor delta, `./scripts/mutmut.sh accept` updates that baseline. New `no tests`, timeout, suspicious, or unchecked results can never be accepted.
|
|
574
575
|
|
|
575
576
|
Drone CI is configured in `.drone.yml`.
|
|
576
577
|
|
|
@@ -314,17 +314,28 @@ ADMIN_REPORT_BACKUP_SMOKE_TEST = False
|
|
|
314
314
|
|
|
315
315
|
# ================= MESSAGE CACHE =================
|
|
316
316
|
|
|
317
|
-
# Number of recent messages retained per room or private conversation. The
|
|
318
|
-
#
|
|
319
|
-
# therefore persisted in the bot database and included in normal database backups.
|
|
320
|
-
# Lower this value if less retained history is wanted.
|
|
317
|
+
# Number of recent messages retained in RAM per room or private conversation. The same
|
|
318
|
+
# bound applies to SQLite when MESSAGE_CACHE_PERSIST is enabled.
|
|
321
319
|
# Startup-only: restart envsbot after changing this value.
|
|
322
320
|
MESSAGE_CACHE_SIZE = 100
|
|
323
321
|
|
|
324
|
-
# Remove cached messages older than this many days. Set 0 to
|
|
322
|
+
# Remove cached messages older than this many days from RAM and SQLite. Set 0 to
|
|
323
|
+
# disable age pruning.
|
|
325
324
|
# Startup-only: restart envsbot after changing this value.
|
|
326
325
|
MESSAGE_CACHE_MAX_AGE_DAYS = 30
|
|
327
326
|
|
|
327
|
+
# Persist recent message bodies in SQLite so history survives restarts. When disabled,
|
|
328
|
+
# existing message-cache rows are purged at startup and new history remains in RAM
|
|
329
|
+
# only.
|
|
330
|
+
# Startup-only: restart envsbot after changing this value.
|
|
331
|
+
MESSAGE_CACHE_PERSIST = True
|
|
332
|
+
|
|
333
|
+
# Respect XEP-0334 storage hints. no-store messages are excluded from history; no-
|
|
334
|
+
# permanent-store messages remain available only in RAM and are never written to
|
|
335
|
+
# SQLite.
|
|
336
|
+
# Startup-only: restart envsbot after changing this value.
|
|
337
|
+
MESSAGE_CACHE_RESPECT_NO_STORE = True
|
|
338
|
+
|
|
328
339
|
|
|
329
340
|
# ================= USER TRACKING =================
|
|
330
341
|
|
|
@@ -668,6 +679,11 @@ TRANSLATE_TO = None
|
|
|
668
679
|
# LibreTranslate fallback.
|
|
669
680
|
TRANSLATE_LIBRETRANSLATE_URL = 'https://translate.envs.net/'
|
|
670
681
|
|
|
682
|
+
# Optional explicit LibreTranslate /languages endpoint for capability discovery. Leave
|
|
683
|
+
# None/empty to derive it from TRANSLATE_LIBRETRANSLATE_URL. Useful when translation
|
|
684
|
+
# requests use a custom reverse-proxy path.
|
|
685
|
+
TRANSLATE_LIBRETRANSLATE_LANGUAGES_URL = None
|
|
686
|
+
|
|
671
687
|
# Optional LibreTranslate API key. Configured API-key providers are tried before the
|
|
672
688
|
# unauthenticated LibreTranslate fallback.
|
|
673
689
|
TRANSLATE_LIBRETRANSLATE_API_KEY = None
|
|
@@ -463,12 +463,23 @@ def _cache_detail_lines(bot, health: HealthSnapshot | None = None) -> list[str]:
|
|
|
463
463
|
if (cache_check.needs_attention if cache_check is not None else cache.get("degraded"))
|
|
464
464
|
else "healthy"
|
|
465
465
|
)
|
|
466
|
+
privacy = ""
|
|
467
|
+
if "respect_no_store" in cache:
|
|
468
|
+
if cache.get("respect_no_store"):
|
|
469
|
+
privacy = (
|
|
470
|
+
" · hints=on "
|
|
471
|
+
f"(skipped={int(cache.get('no_store_skips', 0) or 0)}, "
|
|
472
|
+
"memory-only="
|
|
473
|
+
f"{int(cache.get('no_permanent_store_messages', 0) or 0)})"
|
|
474
|
+
)
|
|
475
|
+
else:
|
|
476
|
+
privacy = " · hints=off"
|
|
466
477
|
lines.append(
|
|
467
478
|
f"Message cache: {int(cache.get('messages', 0) or 0)} messages · "
|
|
468
479
|
f"pending={int(cache.get('pending_writes', 0) or 0)} · "
|
|
469
480
|
f"retry={int(cache.get('retry_backlog', 0) or 0)} · "
|
|
470
481
|
f"dropped={int(cache.get('dropped_persistence_entries', 0) or 0)} · "
|
|
471
|
-
f"{persistence} · {cache_health}"
|
|
482
|
+
f"{persistence}{privacy} · {cache_health}"
|
|
472
483
|
)
|
|
473
484
|
else:
|
|
474
485
|
lines.append("Message cache: unavailable")
|
|
@@ -31,22 +31,50 @@ Examples
|
|
|
31
31
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
|
-
import inspect
|
|
35
34
|
import logging
|
|
36
|
-
import re
|
|
37
35
|
|
|
38
36
|
import slixmpp
|
|
39
37
|
|
|
40
38
|
from core_plugins._core import _get_enabled_rooms, handle_room_toggle_command
|
|
39
|
+
from core_plugins.help.formatting import (
|
|
40
|
+
_access_label,
|
|
41
|
+
_access_summary,
|
|
42
|
+
_clean_doc,
|
|
43
|
+
_command_access_profiles,
|
|
44
|
+
_command_context_plugin,
|
|
45
|
+
_command_example_entries,
|
|
46
|
+
_command_examples,
|
|
47
|
+
_command_query_tokens,
|
|
48
|
+
_command_short,
|
|
49
|
+
_command_subcommand_entries,
|
|
50
|
+
_command_usage,
|
|
51
|
+
_common_plugin_access,
|
|
52
|
+
_context_label,
|
|
53
|
+
_effective_subcommand_context,
|
|
54
|
+
_effective_subcommand_role,
|
|
55
|
+
_example_description_for_command,
|
|
56
|
+
_examples_for_plugin_command,
|
|
57
|
+
_first_line,
|
|
58
|
+
_format_command_detail,
|
|
59
|
+
_format_command_line,
|
|
60
|
+
_format_example_lines,
|
|
61
|
+
_format_one_subcommand_lines,
|
|
62
|
+
_format_plugin_command_lines,
|
|
63
|
+
_plugin_description,
|
|
64
|
+
_plugin_meta,
|
|
65
|
+
_role_label,
|
|
66
|
+
_section_lines,
|
|
67
|
+
_sectioned_subcommands,
|
|
68
|
+
_subcommand_aliases,
|
|
69
|
+
_visible_subcommands,
|
|
70
|
+
)
|
|
41
71
|
from utils.command import (
|
|
42
72
|
COMMANDS,
|
|
43
73
|
Command,
|
|
44
|
-
CommandExample,
|
|
45
74
|
CommandSubcommand,
|
|
46
75
|
Role,
|
|
47
76
|
check_permission,
|
|
48
77
|
command,
|
|
49
|
-
command_examples,
|
|
50
78
|
command_subcommands,
|
|
51
79
|
resolve_command,
|
|
52
80
|
)
|
|
@@ -133,146 +161,6 @@ async def get_help_store(bot):
|
|
|
133
161
|
# DOCSTRING / METADATA HELPERS
|
|
134
162
|
# --------------------------------------------------
|
|
135
163
|
|
|
136
|
-
def _clean_doc(doc: str | None, prefix: str) -> str:
|
|
137
|
-
"""Return a readable docstring with prefix placeholders resolved."""
|
|
138
|
-
if not doc:
|
|
139
|
-
return ""
|
|
140
|
-
return inspect.cleandoc(doc).replace("{prefix}", prefix).strip()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _first_line(doc: str | None) -> str:
|
|
144
|
-
"""Return the first non-empty line from a docstring."""
|
|
145
|
-
doc = _clean_doc(doc, "")
|
|
146
|
-
if not doc:
|
|
147
|
-
return ""
|
|
148
|
-
for line in doc.splitlines():
|
|
149
|
-
line = line.strip()
|
|
150
|
-
if line:
|
|
151
|
-
return line
|
|
152
|
-
return ""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _section_lines(doc: str, title: str) -> list[str]:
|
|
156
|
-
"""
|
|
157
|
-
Extract simple NumPy-style docstring sections such as Usage or Examples.
|
|
158
|
-
"""
|
|
159
|
-
if not doc:
|
|
160
|
-
return []
|
|
161
|
-
|
|
162
|
-
lines = doc.splitlines()
|
|
163
|
-
start = None
|
|
164
|
-
for i, line in enumerate(lines):
|
|
165
|
-
if line.strip().lower() == title.lower():
|
|
166
|
-
start = i + 1
|
|
167
|
-
# Skip underline made of dashes if present.
|
|
168
|
-
if start < len(lines) and set(lines[start].strip()) <= {"-"}:
|
|
169
|
-
start += 1
|
|
170
|
-
break
|
|
171
|
-
if start is None:
|
|
172
|
-
return []
|
|
173
|
-
|
|
174
|
-
out = []
|
|
175
|
-
for line in lines[start:]:
|
|
176
|
-
stripped = line.strip()
|
|
177
|
-
if (
|
|
178
|
-
stripped
|
|
179
|
-
and re.fullmatch(r"[A-Za-z][A-Za-z /_-]+", stripped)
|
|
180
|
-
and len(stripped.split()) <= 4
|
|
181
|
-
):
|
|
182
|
-
break
|
|
183
|
-
out.append(line.rstrip())
|
|
184
|
-
|
|
185
|
-
return [line for line in out if line.strip()]
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _command_short(cmd_obj, prefix: str) -> str:
|
|
189
|
-
"""Return the command summary from decorator metadata only."""
|
|
190
|
-
short = str(getattr(cmd_obj, "short", "") or "")
|
|
191
|
-
return short.format(prefix=prefix) if short else "No description available."
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _command_usage(cmd_obj, prefix: str) -> list[str]:
|
|
195
|
-
"""Return usage lines from decorator metadata only."""
|
|
196
|
-
usage = str(getattr(cmd_obj, "usage", "") or "")
|
|
197
|
-
return [usage.format(prefix=prefix)] if usage else []
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _command_example_entries(cmd_obj, prefix: str) -> list[CommandExample]:
|
|
201
|
-
"""Return normalized, prefix-resolved command examples."""
|
|
202
|
-
return [
|
|
203
|
-
CommandExample(
|
|
204
|
-
example.command.format(prefix=prefix),
|
|
205
|
-
example.description.format(prefix=prefix),
|
|
206
|
-
)
|
|
207
|
-
for example in command_examples(cmd_obj)
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _command_examples(cmd_obj, prefix: str) -> list[str]:
|
|
212
|
-
"""Return example commands for compatibility with older callers."""
|
|
213
|
-
return [example.command for example in _command_example_entries(cmd_obj, prefix)]
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def _command_subcommand_entries(
|
|
217
|
-
cmd_obj,
|
|
218
|
-
prefix: str,
|
|
219
|
-
) -> list[CommandSubcommand]:
|
|
220
|
-
"""Return normalized, prefix-resolved structured subcommands."""
|
|
221
|
-
result = []
|
|
222
|
-
for subcommand in command_subcommands(cmd_obj):
|
|
223
|
-
result.append(
|
|
224
|
-
CommandSubcommand(
|
|
225
|
-
name=subcommand.name,
|
|
226
|
-
usage=subcommand.usage.format(prefix=prefix),
|
|
227
|
-
short=subcommand.short.format(prefix=prefix),
|
|
228
|
-
aliases=tuple(subcommand.aliases),
|
|
229
|
-
examples=tuple(
|
|
230
|
-
CommandExample(
|
|
231
|
-
example.command.format(prefix=prefix),
|
|
232
|
-
example.description.format(prefix=prefix),
|
|
233
|
-
)
|
|
234
|
-
for example in subcommand.examples
|
|
235
|
-
),
|
|
236
|
-
role=subcommand.role,
|
|
237
|
-
context=subcommand.context,
|
|
238
|
-
section=subcommand.section,
|
|
239
|
-
)
|
|
240
|
-
)
|
|
241
|
-
return result
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _role_label(role: Role) -> str:
|
|
245
|
-
return str(role)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _context_label(cmd_obj) -> str:
|
|
249
|
-
context = getattr(cmd_obj, "context", "any") or "any"
|
|
250
|
-
if context != "any":
|
|
251
|
-
return context
|
|
252
|
-
|
|
253
|
-
# envsbot blocks privileged commands in normal room messages.
|
|
254
|
-
if getattr(cmd_obj, "role", Role.NONE) <= Role.MODERATOR:
|
|
255
|
-
return "private chat / MUC PM"
|
|
256
|
-
return "room, MUC PM or private chat"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def _plugin_meta(bot, name: str) -> dict:
|
|
260
|
-
meta = getattr(bot.bot_plugins, "meta", {}).get(name, {}) or {}
|
|
261
|
-
if meta:
|
|
262
|
-
return meta
|
|
263
|
-
|
|
264
|
-
module = bot.bot_plugins.plugins.get(name)
|
|
265
|
-
if module is None:
|
|
266
|
-
return {}
|
|
267
|
-
return getattr(module, "PLUGIN_META", {}) or {}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _plugin_description(bot, name: str, module) -> str:
|
|
271
|
-
meta = _plugin_meta(bot, name)
|
|
272
|
-
desc = meta.get("description") or _first_line(module.__doc__)
|
|
273
|
-
return desc or "No description available."
|
|
274
|
-
|
|
275
|
-
|
|
276
164
|
def _room_feature_entry(plugin: str) -> dict | None:
|
|
277
165
|
"""Return room-toggle metadata for a plugin, if it has room settings."""
|
|
278
166
|
return ROOM_FEATURE_HELP.get(str(plugin).strip().lower())
|
|
@@ -358,252 +246,6 @@ def _all_visible_commands(bot, role: Role) -> list[tuple[str, Command]]:
|
|
|
358
246
|
return commands
|
|
359
247
|
|
|
360
248
|
|
|
361
|
-
def _format_command_line(cmd_obj, prefix: str) -> str:
|
|
362
|
-
aliases = sorted(set(a for a in (cmd_obj.aliases or []) if a != cmd_obj.name))
|
|
363
|
-
alias_text = ""
|
|
364
|
-
if aliases:
|
|
365
|
-
alias_text = f" / aliases: {', '.join(prefix + a for a in aliases)}"
|
|
366
|
-
return (
|
|
367
|
-
f"• {prefix}{cmd_obj.name} [{_role_label(cmd_obj.role)}] — "
|
|
368
|
-
f"{_command_short(cmd_obj, prefix)}{alias_text}"
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def _effective_subcommand_role(cmd_obj, subcommand: CommandSubcommand) -> Role:
|
|
373
|
-
return subcommand.role if subcommand.role is not None else cmd_obj.role
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def _effective_subcommand_context(cmd_obj, subcommand: CommandSubcommand) -> str:
|
|
377
|
-
return subcommand.context or _context_label(cmd_obj)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _visible_subcommands(cmd_obj, role: Role, prefix: str) -> list[CommandSubcommand]:
|
|
381
|
-
"""Return structured subcommands visible to one user role."""
|
|
382
|
-
return [
|
|
383
|
-
subcommand
|
|
384
|
-
for subcommand in _command_subcommand_entries(cmd_obj, prefix)
|
|
385
|
-
if role <= _effective_subcommand_role(cmd_obj, subcommand)
|
|
386
|
-
]
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def _subcommand_aliases(cmd_obj, subcommand: CommandSubcommand, prefix: str) -> list[str]:
|
|
390
|
-
"""Return full command aliases for one structured subcommand."""
|
|
391
|
-
root = str(cmd_obj.name)
|
|
392
|
-
return [f"{prefix}{root} {alias}" for alias in subcommand.aliases]
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def _access_summary(role: Role, context: str) -> str:
|
|
396
|
-
"""Return one compact role/context line for focused help output."""
|
|
397
|
-
return f"Role: {_role_label(role)} · Context: {context}"
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def _access_label(role: Role, context: str) -> str:
|
|
401
|
-
"""Return a short access label suitable for one command line."""
|
|
402
|
-
return f"{_role_label(role)} · {context}"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _command_access_profiles(
|
|
406
|
-
cmd_obj,
|
|
407
|
-
prefix: str,
|
|
408
|
-
role: Role,
|
|
409
|
-
) -> list[tuple[Role, str]]:
|
|
410
|
-
"""Return visible role/context pairs represented by one command family."""
|
|
411
|
-
subcommands = _visible_subcommands(cmd_obj, role, prefix)
|
|
412
|
-
if subcommands:
|
|
413
|
-
return [
|
|
414
|
-
(
|
|
415
|
-
_effective_subcommand_role(cmd_obj, subcommand),
|
|
416
|
-
_effective_subcommand_context(cmd_obj, subcommand),
|
|
417
|
-
)
|
|
418
|
-
for subcommand in subcommands
|
|
419
|
-
]
|
|
420
|
-
return [(cmd_obj.role, _context_label(cmd_obj))]
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def _common_plugin_access(
|
|
424
|
-
commands: list,
|
|
425
|
-
prefix: str,
|
|
426
|
-
role: Role,
|
|
427
|
-
) -> tuple[Role, str] | None:
|
|
428
|
-
"""Return a shared access profile when every visible command uses it."""
|
|
429
|
-
profiles = {
|
|
430
|
-
profile
|
|
431
|
-
for cmd_obj in commands
|
|
432
|
-
for profile in _command_access_profiles(cmd_obj, prefix, role)
|
|
433
|
-
}
|
|
434
|
-
return next(iter(profiles)) if len(profiles) == 1 else None
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def _format_one_subcommand_lines(
|
|
438
|
-
cmd_obj,
|
|
439
|
-
subcommand: CommandSubcommand,
|
|
440
|
-
prefix: str,
|
|
441
|
-
*,
|
|
442
|
-
common_access: tuple[Role, str] | None = None,
|
|
443
|
-
) -> list[str]:
|
|
444
|
-
"""Return compact lines for one structured subcommand."""
|
|
445
|
-
effective_role = _effective_subcommand_role(cmd_obj, subcommand)
|
|
446
|
-
context = _effective_subcommand_context(cmd_obj, subcommand)
|
|
447
|
-
access = (effective_role, context)
|
|
448
|
-
access_suffix = (
|
|
449
|
-
""
|
|
450
|
-
if common_access == access
|
|
451
|
-
else f" [{_access_label(effective_role, context)}]"
|
|
452
|
-
)
|
|
453
|
-
lines = [f"• {subcommand.usage} — {subcommand.short}{access_suffix}"]
|
|
454
|
-
aliases = _subcommand_aliases(cmd_obj, subcommand, prefix)
|
|
455
|
-
if aliases:
|
|
456
|
-
lines.append(" Aliases: " + ", ".join(aliases))
|
|
457
|
-
return lines
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def _sectioned_subcommands(
|
|
461
|
-
subcommands: list[CommandSubcommand],
|
|
462
|
-
) -> list[tuple[str, list[CommandSubcommand]]]:
|
|
463
|
-
"""Group subcommands by their optional help section, preserving order."""
|
|
464
|
-
sections: list[tuple[str, list[CommandSubcommand]]] = []
|
|
465
|
-
indexes: dict[str, int] = {}
|
|
466
|
-
for subcommand in subcommands:
|
|
467
|
-
section = subcommand.section.strip()
|
|
468
|
-
if section not in indexes:
|
|
469
|
-
indexes[section] = len(sections)
|
|
470
|
-
sections.append((section, []))
|
|
471
|
-
sections[indexes[section]][1].append(subcommand)
|
|
472
|
-
return sections
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def _format_plugin_command_lines(
|
|
476
|
-
cmd_obj,
|
|
477
|
-
prefix: str,
|
|
478
|
-
role: Role,
|
|
479
|
-
*,
|
|
480
|
-
common_access: tuple[Role, str] | None = None,
|
|
481
|
-
) -> list[str]:
|
|
482
|
-
"""Return compact, consistently spaced plugin-help command lines."""
|
|
483
|
-
subcommands = _visible_subcommands(cmd_obj, role, prefix)
|
|
484
|
-
if subcommands:
|
|
485
|
-
sections = _sectioned_subcommands(subcommands)
|
|
486
|
-
has_named_sections = any(section for section, _entries in sections)
|
|
487
|
-
lines: list[str] = []
|
|
488
|
-
for section, entries in sections:
|
|
489
|
-
if has_named_sections:
|
|
490
|
-
if lines:
|
|
491
|
-
lines.append("")
|
|
492
|
-
lines.append(f"{section or 'Other commands'}:")
|
|
493
|
-
for subcommand in entries:
|
|
494
|
-
lines.extend(
|
|
495
|
-
_format_one_subcommand_lines(
|
|
496
|
-
cmd_obj,
|
|
497
|
-
subcommand,
|
|
498
|
-
prefix,
|
|
499
|
-
common_access=common_access,
|
|
500
|
-
)
|
|
501
|
-
)
|
|
502
|
-
return lines
|
|
503
|
-
|
|
504
|
-
aliases = sorted(set(a for a in (cmd_obj.aliases or []) if a != cmd_obj.name))
|
|
505
|
-
access = (cmd_obj.role, _context_label(cmd_obj))
|
|
506
|
-
access_suffix = (
|
|
507
|
-
""
|
|
508
|
-
if common_access == access
|
|
509
|
-
else f" [{_access_label(*access)}]"
|
|
510
|
-
)
|
|
511
|
-
lines = [
|
|
512
|
-
f"• {_command_usage(cmd_obj, prefix)[0]} — "
|
|
513
|
-
f"{_command_short(cmd_obj, prefix)}{access_suffix}",
|
|
514
|
-
]
|
|
515
|
-
if aliases:
|
|
516
|
-
lines.append(" Aliases: " + ", ".join(prefix + alias for alias in aliases))
|
|
517
|
-
return lines
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
def _example_description_for_command(
|
|
521
|
-
cmd_obj,
|
|
522
|
-
example: CommandExample,
|
|
523
|
-
prefix: str,
|
|
524
|
-
) -> str:
|
|
525
|
-
"""Return an explicit or subcommand-derived example description."""
|
|
526
|
-
if example.description:
|
|
527
|
-
return example.description
|
|
528
|
-
|
|
529
|
-
example_tokens = _command_query_tokens(example.command, prefix)
|
|
530
|
-
for subcommand in _command_subcommand_entries(cmd_obj, prefix):
|
|
531
|
-
names = [subcommand.name, *subcommand.aliases]
|
|
532
|
-
for name in names:
|
|
533
|
-
if not name or name.startswith("<"):
|
|
534
|
-
continue
|
|
535
|
-
name_tokens = tuple(name.lower().split())
|
|
536
|
-
primary_tokens = tuple(str(cmd_obj.name).lower().split())
|
|
537
|
-
if example_tokens[: len(primary_tokens)] != primary_tokens:
|
|
538
|
-
continue
|
|
539
|
-
remaining = example_tokens[len(primary_tokens):]
|
|
540
|
-
if remaining[: len(name_tokens)] == name_tokens:
|
|
541
|
-
return subcommand.short
|
|
542
|
-
return _command_short(cmd_obj, prefix)
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def _format_example_lines(
|
|
546
|
-
cmd_obj,
|
|
547
|
-
examples: list[CommandExample] | tuple[CommandExample, ...],
|
|
548
|
-
prefix: str,
|
|
549
|
-
) -> list[str]:
|
|
550
|
-
"""Render every example and its description on one compact line."""
|
|
551
|
-
lines = []
|
|
552
|
-
for example in examples:
|
|
553
|
-
description = _example_description_for_command(cmd_obj, example, prefix)
|
|
554
|
-
suffix = f" — {description}" if description else ""
|
|
555
|
-
lines.append(f"• {example.command}{suffix}")
|
|
556
|
-
return lines
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def _examples_for_plugin_command(
|
|
560
|
-
cmd_obj,
|
|
561
|
-
prefix: str,
|
|
562
|
-
role: Role,
|
|
563
|
-
) -> list[CommandExample]:
|
|
564
|
-
"""Prefer concise structured examples for aggregate commands."""
|
|
565
|
-
subcommands = _visible_subcommands(cmd_obj, role, prefix)
|
|
566
|
-
if subcommands:
|
|
567
|
-
return [example for subcommand in subcommands for example in subcommand.examples]
|
|
568
|
-
return _command_example_entries(cmd_obj, prefix)
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def _format_command_detail(cmd_obj, prefix: str, role: Role | None = None) -> list[str]:
|
|
572
|
-
lines = [
|
|
573
|
-
f"📖 Command: {prefix}{cmd_obj.name}",
|
|
574
|
-
_access_summary(cmd_obj.role, _context_label(cmd_obj)),
|
|
575
|
-
]
|
|
576
|
-
|
|
577
|
-
aliases = sorted(set(a for a in (cmd_obj.aliases or []) if a != cmd_obj.name))
|
|
578
|
-
if aliases:
|
|
579
|
-
lines.append("Aliases: " + ", ".join(prefix + a for a in aliases))
|
|
580
|
-
|
|
581
|
-
lines += ["", _command_short(cmd_obj, prefix), "", "Usage:"]
|
|
582
|
-
for usage in _command_usage(cmd_obj, prefix):
|
|
583
|
-
lines.append(f" {usage}")
|
|
584
|
-
|
|
585
|
-
visible_role = role if role is not None else Role.OWNER
|
|
586
|
-
subcommands = _visible_subcommands(cmd_obj, visible_role, prefix)
|
|
587
|
-
if subcommands:
|
|
588
|
-
lines += ["", "Subcommands:"]
|
|
589
|
-
lines.extend(_format_plugin_command_lines(cmd_obj, prefix, visible_role))
|
|
590
|
-
|
|
591
|
-
examples = _examples_for_plugin_command(cmd_obj, prefix, visible_role)
|
|
592
|
-
if examples:
|
|
593
|
-
lines += ["", "Examples:"]
|
|
594
|
-
lines.extend(_format_example_lines(cmd_obj, examples, prefix))
|
|
595
|
-
|
|
596
|
-
return lines
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
def _command_context_plugin(bot, cmd_obj) -> str | None:
|
|
600
|
-
"""Return the plugin name matching a focused command, if any."""
|
|
601
|
-
first_token = str(cmd_obj.name).split(maxsplit=1)[0].lower()
|
|
602
|
-
if first_token in getattr(bot.bot_plugins, "plugins", {}):
|
|
603
|
-
return first_token
|
|
604
|
-
return None
|
|
605
|
-
|
|
606
|
-
|
|
607
249
|
def _format_plugin_context_lines(bot, plugin: str) -> list[str]:
|
|
608
250
|
"""Return compact plugin context for focused command help."""
|
|
609
251
|
module = bot.bot_plugins.plugins[plugin]
|
|
@@ -624,14 +266,6 @@ def _format_plugin_context_lines(bot, plugin: str) -> list[str]:
|
|
|
624
266
|
return lines
|
|
625
267
|
|
|
626
268
|
|
|
627
|
-
def _command_query_tokens(query: str, prefix: str) -> tuple[str, ...]:
|
|
628
|
-
"""Return normalized command tokens for a help query."""
|
|
629
|
-
query = query.strip().lower()
|
|
630
|
-
if prefix and query.startswith(prefix):
|
|
631
|
-
query = query[len(prefix):].strip()
|
|
632
|
-
return tuple(part for part in query.split() if part)
|
|
633
|
-
|
|
634
|
-
|
|
635
269
|
def _commands_matching_prefix(
|
|
636
270
|
tokens: tuple[str, ...],
|
|
637
271
|
role: Role,
|