Build reusable bot framework
This commit is contained in:
171
core/registry.py
Normal file
171
core/registry.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
registry.py - Discovery and registration for framework feature modules
|
||||
|
||||
Each package directly under ``modules`` may expose ``register(registry)``.
|
||||
The same registry is loaded by the API, Discord bot, and scheduler so a
|
||||
feature can keep its routes, commands, prompts, and jobs together.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
|
||||
class FrameworkRegistry:
|
||||
def __init__(self):
|
||||
self.modules = {}
|
||||
self.commands = {}
|
||||
self.route_registrars = []
|
||||
self.job_handlers = {}
|
||||
self._loading_module = None
|
||||
|
||||
def clear(self):
|
||||
self.modules.clear()
|
||||
self.commands.clear()
|
||||
self.route_registrars.clear()
|
||||
self.job_handlers.clear()
|
||||
self._loading_module = None
|
||||
|
||||
def begin_module(self, name, package):
|
||||
if name in self.modules:
|
||||
raise ValueError(f"Duplicate module name: {name}")
|
||||
self.modules[name] = {
|
||||
"name": name,
|
||||
"package": package,
|
||||
"description": "",
|
||||
}
|
||||
self._loading_module = name
|
||||
|
||||
def finish_module(self):
|
||||
self._loading_module = None
|
||||
|
||||
def describe(self, description):
|
||||
module_name = self._require_loading_module()
|
||||
self.modules[module_name]["description"] = description.strip()
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
interaction_type,
|
||||
handler,
|
||||
prompt,
|
||||
validator=None,
|
||||
help_text=None,
|
||||
description="",
|
||||
):
|
||||
module_name = self._require_loading_module()
|
||||
if interaction_type in self.commands:
|
||||
raise ValueError(f"Duplicate command type: {interaction_type}")
|
||||
if not callable(handler):
|
||||
raise TypeError(f"Handler for {interaction_type} must be callable")
|
||||
if validator is not None and not callable(validator):
|
||||
raise TypeError(f"Validator for {interaction_type} must be callable")
|
||||
if not isinstance(prompt, dict) or not prompt.get("system") or not prompt.get(
|
||||
"user_template"
|
||||
):
|
||||
raise ValueError(
|
||||
f"Command {interaction_type} must provide system and user_template prompts"
|
||||
)
|
||||
|
||||
self.commands[interaction_type] = {
|
||||
"module": module_name,
|
||||
"handler": handler,
|
||||
"prompt": prompt,
|
||||
"validator": validator,
|
||||
"help_text": list(help_text or []),
|
||||
"description": description.strip(),
|
||||
}
|
||||
|
||||
def register_routes(self, registrar):
|
||||
module_name = self._require_loading_module()
|
||||
if not callable(registrar):
|
||||
raise TypeError(f"Route registrar for {module_name} must be callable")
|
||||
self.route_registrars.append((module_name, registrar))
|
||||
|
||||
def register_job(self, job_type, handler):
|
||||
module_name = self._require_loading_module()
|
||||
if job_type in self.job_handlers:
|
||||
raise ValueError(f"Duplicate job type: {job_type}")
|
||||
if not callable(handler):
|
||||
raise TypeError(f"Job handler for {job_type} must be callable")
|
||||
self.job_handlers[job_type] = {
|
||||
"module": module_name,
|
||||
"handler": handler,
|
||||
}
|
||||
|
||||
def get_command(self, interaction_type):
|
||||
return self.commands.get(interaction_type)
|
||||
|
||||
def get_job_handler(self, job_type):
|
||||
registration = self.job_handlers.get(job_type)
|
||||
return registration["handler"] if registration else None
|
||||
|
||||
def list_commands(self):
|
||||
return list(self.commands.keys())
|
||||
|
||||
def router_context(self):
|
||||
lines = []
|
||||
for name, command in sorted(self.commands.items()):
|
||||
description = command["description"] or "No description provided"
|
||||
lines.append(f"- {name}: {description}")
|
||||
return "\n".join(lines) if lines else "No modules are available"
|
||||
|
||||
def help_lines(self):
|
||||
lines = []
|
||||
for name, command in sorted(self.commands.items()):
|
||||
if command["help_text"]:
|
||||
lines.extend(f"- {item}" for item in command["help_text"])
|
||||
else:
|
||||
lines.append(f"- {name}: {command['description']}")
|
||||
return lines
|
||||
|
||||
def _require_loading_module(self):
|
||||
if not self._loading_module:
|
||||
raise RuntimeError("Registration must happen inside a module register() call")
|
||||
return self._loading_module
|
||||
|
||||
|
||||
registry = FrameworkRegistry()
|
||||
_loaded = False
|
||||
|
||||
|
||||
def discover_modules(force=False):
|
||||
"""Discover and register feature packages exactly once per process."""
|
||||
global _loaded
|
||||
if _loaded and not force:
|
||||
return registry
|
||||
|
||||
_loaded = False
|
||||
|
||||
package = importlib.import_module("modules")
|
||||
discovered = sorted(
|
||||
item.name
|
||||
for item in pkgutil.iter_modules(package.__path__)
|
||||
if item.ispkg and not item.name.startswith("_")
|
||||
)
|
||||
|
||||
registry.clear()
|
||||
try:
|
||||
for module_name in discovered:
|
||||
qualified_name = f"modules.{module_name}"
|
||||
feature_module = importlib.import_module(qualified_name)
|
||||
register_fn = getattr(feature_module, "register", None)
|
||||
if not callable(register_fn):
|
||||
raise RuntimeError(f"{qualified_name} must expose register(registry)")
|
||||
|
||||
registry.begin_module(module_name, qualified_name)
|
||||
try:
|
||||
register_fn(registry)
|
||||
finally:
|
||||
registry.finish_module()
|
||||
except Exception:
|
||||
registry.clear()
|
||||
raise
|
||||
|
||||
_loaded = True
|
||||
return registry
|
||||
|
||||
|
||||
def reset_registry():
|
||||
"""Reset discovery state for tests."""
|
||||
global _loaded
|
||||
registry.clear()
|
||||
_loaded = False
|
||||
Reference in New Issue
Block a user