Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

171
core/registry.py Normal file
View 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