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.
Files changed (198) hide show
  1. {envsbot-2.2.0 → envsbot-2.3.0}/PKG-INFO +7 -6
  2. {envsbot-2.2.0 → envsbot-2.3.0}/README.md +4 -3
  3. {envsbot-2.2.0 → envsbot-2.3.0}/config_sample.py +21 -5
  4. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_admin.py +12 -1
  5. envsbot-2.2.0/core_plugins/help.py → envsbot-2.3.0/core_plugins/help/__init__.py +32 -398
  6. envsbot-2.3.0/core_plugins/help/formatting.py +413 -0
  7. {envsbot-2.2.0 → envsbot-2.3.0}/database/message_cache.py +8 -0
  8. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/PKG-INFO +7 -6
  9. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/SOURCES.txt +8 -2
  10. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/requires.txt +2 -2
  11. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.py +2 -0
  12. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/translate.py +305 -4
  13. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/weather.py +99 -77
  14. {envsbot-2.2.0 → envsbot-2.3.0}/pyproject.toml +37 -9
  15. {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_architecture.py +34 -0
  16. {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_deploy_bootstrap.py +4 -4
  17. {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_mutmut_tooling.py +79 -0
  18. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_docs.py +6 -3
  19. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/runtime.py +3 -0
  20. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/spec.py +5 -2
  21. {envsbot-2.2.0 → envsbot-2.3.0}/utils/message_cache.py +180 -52
  22. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager.py +15 -487
  23. envsbot-2.3.0/utils/plugin_manager_inspection.py +110 -0
  24. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_lifecycle.py +16 -0
  25. envsbot-2.3.0/utils/plugin_manager_runtime.py +398 -0
  26. {envsbot-2.2.0 → envsbot-2.3.0}/utils/translation_providers.py +226 -6
  27. {envsbot-2.2.0 → envsbot-2.3.0}/utils/version.py +1 -1
  28. {envsbot-2.2.0 → envsbot-2.3.0}/LICENSE +0 -0
  29. {envsbot-2.2.0 → envsbot-2.3.0}/bot/__init__.py +0 -0
  30. {envsbot-2.2.0 → envsbot-2.3.0}/bot/audit.py +0 -0
  31. {envsbot-2.2.0 → envsbot-2.3.0}/bot/connection.py +0 -0
  32. {envsbot-2.2.0 → envsbot-2.3.0}/bot/context.py +0 -0
  33. {envsbot-2.2.0 → envsbot-2.3.0}/bot/dispatch.py +0 -0
  34. {envsbot-2.2.0 → envsbot-2.3.0}/bot/lifecycle.py +0 -0
  35. {envsbot-2.2.0 → envsbot-2.3.0}/bot/messages.py +0 -0
  36. {envsbot-2.2.0 → envsbot-2.3.0}/bot/permissions.py +0 -0
  37. {envsbot-2.2.0 → envsbot-2.3.0}/bot/room_state.py +0 -0
  38. {envsbot-2.2.0 → envsbot-2.3.0}/bot/routing.py +0 -0
  39. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/__init__.py +0 -0
  40. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_core.py +0 -0
  41. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/_reg_profile.py +0 -0
  42. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/audit.py +0 -0
  43. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/backups.py +0 -0
  44. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/config_cmd.py +0 -0
  45. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/doctor.py +0 -0
  46. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/outbox.py +0 -0
  47. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/plugins.py +0 -0
  48. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/presence.py +0 -0
  49. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/reports.py +0 -0
  50. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/__init__.py +0 -0
  51. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/commands.py +0 -0
  52. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/defaults.py +0 -0
  53. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/invites.py +0 -0
  54. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/lifecycle.py +0 -0
  55. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/permissions.py +0 -0
  56. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/presence.py +0 -0
  57. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/settings.py +0 -0
  58. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/rooms/state.py +0 -0
  59. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/tasks.py +0 -0
  60. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/usage.py +0 -0
  61. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/__init__.py +0 -0
  62. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/commands.py +0 -0
  63. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/formatting.py +0 -0
  64. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/lookup.py +0 -0
  65. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/permission_commands.py +0 -0
  66. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/permissions.py +0 -0
  67. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/roles.py +0 -0
  68. {envsbot-2.2.0 → envsbot-2.3.0}/core_plugins/users/tracking.py +0 -0
  69. {envsbot-2.2.0 → envsbot-2.3.0}/database/__init__.py +0 -0
  70. {envsbot-2.2.0 → envsbot-2.3.0}/database/audit.py +0 -0
  71. {envsbot-2.2.0 → envsbot-2.3.0}/database/command_usage.py +0 -0
  72. {envsbot-2.2.0 → envsbot-2.3.0}/database/idlerpg.py +0 -0
  73. {envsbot-2.2.0 → envsbot-2.3.0}/database/locking.py +0 -0
  74. {envsbot-2.2.0 → envsbot-2.3.0}/database/manager.py +0 -0
  75. {envsbot-2.2.0 → envsbot-2.3.0}/database/migrations/__init__.py +0 -0
  76. {envsbot-2.2.0 → envsbot-2.3.0}/database/outbox.py +0 -0
  77. {envsbot-2.2.0 → envsbot-2.3.0}/database/rooms.py +0 -0
  78. {envsbot-2.2.0 → envsbot-2.3.0}/database/users.py +0 -0
  79. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/dependency_links.txt +0 -0
  80. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/entry_points.txt +0 -0
  81. {envsbot-2.2.0 → envsbot-2.3.0}/envsbot.egg-info/top_level.txt +0 -0
  82. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/__init__.py +0 -0
  83. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/birthday_notify.py +0 -0
  84. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/dice.py +0 -0
  85. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/ducks.py +0 -0
  86. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/__init__.py +0 -0
  87. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/commands.py +0 -0
  88. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/config.py +0 -0
  89. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/constants.py +0 -0
  90. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/events.py +0 -0
  91. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/export.py +0 -0
  92. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/formatting.py +0 -0
  93. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/handlers.py +0 -0
  94. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/items.py +0 -0
  95. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/leveling.py +0 -0
  96. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/map.py +0 -0
  97. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/quests.py +0 -0
  98. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/seasons.py +0 -0
  99. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/state.py +0 -0
  100. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/idlerpg/tasks.py +0 -0
  101. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/info.py +0 -0
  102. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/karma.py +0 -0
  103. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/pin.py +0 -0
  104. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/poll.py +0 -0
  105. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/__init__.py +0 -0
  106. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/commands.py +0 -0
  107. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/config.py +0 -0
  108. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/events.py +0 -0
  109. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/formatting.py +0 -0
  110. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/lifecycle.py +0 -0
  111. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/parsing.py +0 -0
  112. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/runtime.py +0 -0
  113. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/store.py +0 -0
  114. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/reminder/tasks.py +0 -0
  115. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/__init__.py +0 -0
  116. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/command_support.py +0 -0
  117. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/commands.py +0 -0
  118. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/config.py +0 -0
  119. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/fetch.py +0 -0
  120. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/formatting.py +0 -0
  121. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/lifecycle.py +0 -0
  122. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/listing.py +0 -0
  123. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/store.py +0 -0
  124. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/subscriptions.py +0 -0
  125. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/tasks.py +0 -0
  126. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/rss/templates.py +0 -0
  127. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/sed.py +0 -0
  128. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/tell.py +0 -0
  129. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/tools.py +0 -0
  130. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/urlcheck.py +0 -0
  131. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/__init__.py +0 -0
  132. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/commands.py +0 -0
  133. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/config.py +0 -0
  134. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/fetch.py +0 -0
  135. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/fields.py +0 -0
  136. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/formatting.py +0 -0
  137. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/store.py +0 -0
  138. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/vcard/timezone.py +0 -0
  139. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/xkcd.py +0 -0
  140. {envsbot-2.2.0 → envsbot-2.3.0}/plugins/xmpp.py +0 -0
  141. {envsbot-2.2.0 → envsbot-2.3.0}/setup.cfg +0 -0
  142. {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_deploy_script.py +0 -0
  143. {envsbot-2.2.0 → envsbot-2.3.0}/tests/test_packaging.py +0 -0
  144. {envsbot-2.2.0 → envsbot-2.3.0}/utils/__init__.py +0 -0
  145. {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_alerts.py +0 -0
  146. {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_notify.py +0 -0
  147. {envsbot-2.2.0 → envsbot-2.3.0}/utils/admin_reports.py +0 -0
  148. {envsbot-2.2.0 → envsbot-2.3.0}/utils/audit.py +0 -0
  149. {envsbot-2.2.0 → envsbot-2.3.0}/utils/backups.py +0 -0
  150. {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled/avatar.jpg +0 -0
  151. {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled/init_chat_slang.csv +0 -0
  152. {envsbot-2.2.0 → envsbot-2.3.0}/utils/bundled_assets.py +0 -0
  153. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command.py +0 -0
  154. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_context.py +0 -0
  155. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_execution.py +0 -0
  156. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_help.py +0 -0
  157. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_metadata.py +0 -0
  158. {envsbot-2.2.0 → envsbot-2.3.0}/utils/command_registry.py +0 -0
  159. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/__init__.py +0 -0
  160. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/defaults.py +0 -0
  161. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/display.py +0 -0
  162. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/errors.py +0 -0
  163. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/loader.py +0 -0
  164. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/logging_setup.py +0 -0
  165. {envsbot-2.2.0 → envsbot-2.3.0}/utils/config/validation.py +0 -0
  166. {envsbot-2.2.0 → envsbot-2.3.0}/utils/database_cli.py +0 -0
  167. {envsbot-2.2.0 → envsbot-2.3.0}/utils/deploy_systemd_values.py +0 -0
  168. {envsbot-2.2.0 → envsbot-2.3.0}/utils/file_security.py +0 -0
  169. {envsbot-2.2.0 → envsbot-2.3.0}/utils/formatting.py +0 -0
  170. {envsbot-2.2.0 → envsbot-2.3.0}/utils/health.py +0 -0
  171. {envsbot-2.2.0 → envsbot-2.3.0}/utils/http_fetch.py +0 -0
  172. {envsbot-2.2.0 → envsbot-2.3.0}/utils/http_user_agent.py +0 -0
  173. {envsbot-2.2.0 → envsbot-2.3.0}/utils/logging_helpers.py +0 -0
  174. {envsbot-2.2.0 → envsbot-2.3.0}/utils/outbox.py +0 -0
  175. {envsbot-2.2.0 → envsbot-2.3.0}/utils/performance.py +0 -0
  176. {envsbot-2.2.0 → envsbot-2.3.0}/utils/permissions.py +0 -0
  177. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_dependencies.py +0 -0
  178. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_diagnostics.py +0 -0
  179. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_manager_discovery.py +0 -0
  180. {envsbot-2.2.0 → envsbot-2.3.0}/utils/plugin_metadata.py +0 -0
  181. {envsbot-2.2.0 → envsbot-2.3.0}/utils/preflight.py +0 -0
  182. {envsbot-2.2.0 → envsbot-2.3.0}/utils/presence_manager.py +0 -0
  183. {envsbot-2.2.0 → envsbot-2.3.0}/utils/python_source.py +0 -0
  184. {envsbot-2.2.0 → envsbot-2.3.0}/utils/rate_limiter.py +0 -0
  185. {envsbot-2.2.0 → envsbot-2.3.0}/utils/redaction.py +0 -0
  186. {envsbot-2.2.0 → envsbot-2.3.0}/utils/release_state.py +0 -0
  187. {envsbot-2.2.0 → envsbot-2.3.0}/utils/room_features.py +0 -0
  188. {envsbot-2.2.0 → envsbot-2.3.0}/utils/runtime_paths.py +0 -0
  189. {envsbot-2.2.0 → envsbot-2.3.0}/utils/runtime_watchdog.py +0 -0
  190. {envsbot-2.2.0 → envsbot-2.3.0}/utils/systemd_deploy.py +0 -0
  191. {envsbot-2.2.0 → envsbot-2.3.0}/utils/task_supervisor.py +0 -0
  192. {envsbot-2.2.0 → envsbot-2.3.0}/utils/time_utils.py +0 -0
  193. {envsbot-2.2.0 → envsbot-2.3.0}/utils/tls_certificate.py +0 -0
  194. {envsbot-2.2.0 → envsbot-2.3.0}/utils/updatecheck.py +0 -0
  195. {envsbot-2.2.0 → envsbot-2.3.0}/utils/url_safety.py +0 -0
  196. {envsbot-2.2.0 → envsbot-2.3.0}/utils/urlcheck_extraction.py +0 -0
  197. {envsbot-2.2.0 → envsbot-2.3.0}/utils/xmpp_notify.py +0 -0
  198. {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.2.0
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.3.0
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>=3.0; extra == "dev"
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 persistent recent-message cache for reply-aware plugins
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.2.0 (envs-xmpp 1.3.0)
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 mutmut configuration in `pyproject.toml` explicitly lists the flat-layout source paths and disables coverage during mutant test runs. `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 full run.
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 persistent recent-message cache for reply-aware plugins
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.2.0 (envs-xmpp 1.3.0)
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 mutmut configuration in `pyproject.toml` explicitly lists the flat-layout source paths and disables coverage during mutant test runs. `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 full run.
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 cache is
318
- # shared by all plugins, stored in SQLite and restored on restart. Message bodies are
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 disable age pruning.
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,