Skip to content

Commit e43b5e0

Browse files
committed
feat: pluggable driver system with lifecycle management, middleware, and event bus
- BaseDriver accepts DriverContext (DI facade) with backward compat for Bridge - DriverManager handles auto-restart with backoff, health monitoring, hot-reload - EventBus for lifecycle + message events (bridge.message, driver.status) - Middleware chains (pre-receive, pre-send) hooked into Bridge routing - Plugin loader: built-in, pip entry points (nextbridge.drivers), local dirs - CLI hook registration so drivers can add subcommands without touching main.py - Bridge exposes senders_snapshot(), rules_snapshot() for control-plane drivers - DB aggregate helpers (stats, recent_mappings, list_user_bindings, session) - Opt-in password-protected admin API (/_nextbridge/drivers, /admin/reload)
1 parent e9cc670 commit e43b5e0

15 files changed

Lines changed: 1236 additions & 90 deletions

drivers/__init__.py

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from __future__ import annotations
2+
13
from abc import ABC, abstractmethod
2-
from typing import TYPE_CHECKING, Generic, TypeVar
4+
from dataclasses import dataclass, field
5+
from enum import Enum, auto
6+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
37

48
from pydantic import BaseModel
59

@@ -8,41 +12,135 @@
812

913
if TYPE_CHECKING:
1014
from services.bridge import Bridge
15+
from services.driver_context import DriverContext
1116

1217
T = TypeVar("T", bound=BaseModel)
1318

19+
DRIVER_API_VERSION = 2
20+
21+
22+
# ------------------------------------------------------------------
23+
# Capability & metadata declarations
24+
# ------------------------------------------------------------------
25+
26+
27+
class DriverCapability(Enum):
28+
SEND = auto()
29+
RECEIVE = auto()
30+
WEBHOOK = auto()
31+
ATTACHMENTS = auto()
32+
MENTIONS = auto()
33+
REPLIES = auto()
34+
35+
36+
@dataclass
37+
class DriverMeta:
38+
platform: str = ""
39+
display_name: str = ""
40+
version: str = "0.0.0"
41+
api_version: int = DRIVER_API_VERSION
42+
capabilities: set[DriverCapability] = field(
43+
default_factory=lambda: {DriverCapability.SEND, DriverCapability.RECEIVE}
44+
)
45+
author: str = ""
46+
url: str = ""
47+
48+
49+
class DriverHealth(Enum):
50+
UNKNOWN = "unknown"
51+
STARTING = "starting"
52+
HEALTHY = "healthy"
53+
DEGRADED = "degraded"
54+
UNHEALTHY = "unhealthy"
55+
STOPPED = "stopped"
56+
57+
58+
# ------------------------------------------------------------------
59+
# Base driver
60+
# ------------------------------------------------------------------
61+
1462

1563
class BaseDriver(ABC, Generic[T]):
16-
"""Abstract base class for all platform drivers."""
64+
"""Abstract base class for all platform drivers.
65+
66+
The third constructor parameter accepts either a legacy ``Bridge``
67+
instance (API v1) or a ``DriverContext`` (API v2). Existing drivers
68+
that pass ``bridge`` continue to work unchanged.
69+
"""
1770

18-
def __init__(self, instance_id: str, config: T, bridge: "Bridge"):
71+
meta: DriverMeta = DriverMeta()
72+
73+
def __init__(
74+
self,
75+
instance_id: str,
76+
config: T,
77+
ctx_or_bridge: DriverContext | Bridge | Any,
78+
) -> None:
1979
self.instance_id = instance_id
2080
self.config: T = config
21-
self.bridge = bridge
22-
self.http_server = None
2381

82+
from services.driver_context import DriverContext
83+
84+
if isinstance(ctx_or_bridge, DriverContext):
85+
self._ctx = ctx_or_bridge
86+
self.bridge = ctx_or_bridge.bridge
87+
else:
88+
self._ctx = DriverContext(bridge=ctx_or_bridge)
89+
self.bridge = ctx_or_bridge
90+
91+
self.http_server = None
2492
self.logger = log.get_logger(f"[{instance_id}]", instance=True)
2593

26-
# Media download proxy used by downstream attachment fetching.
27-
# Default follows driver-level proxy, but can be overridden by
28-
# per-driver media_proxy.
2994
base_proxy = get_proxy(getattr(config, "proxy", UNSET))
3095
self._media_proxy = get_proxy(getattr(config, "media_proxy", UNSET), base_proxy)
3196

97+
self._health = DriverHealth.UNKNOWN
98+
99+
# ------------------------------------------------------------------
100+
# Proxy helpers
101+
# ------------------------------------------------------------------
102+
32103
def _source_proxy_from_kwargs(self, kwargs: dict) -> str | None:
33-
# Keep explicit None from kwargs (disable proxy for this send call).
34104
if "source_proxy" in kwargs:
35105
return kwargs.get("source_proxy")
36106
return self._media_proxy
37107

38108
def attach_http_server(self, http_server) -> None:
39109
self.http_server = http_server
40110

111+
# ------------------------------------------------------------------
112+
# Lifecycle
113+
# ------------------------------------------------------------------
114+
41115
@abstractmethod
42116
async def start(self):
43117
"""Start the driver (connect, authenticate, begin listening).
44118
Long-running drivers should loop indefinitely here."""
45119

120+
async def stop(self):
121+
"""Graceful shutdown. Override in drivers that hold resources."""
122+
46123
@abstractmethod
47124
async def send(self, channel: dict, text: str, **kwargs) -> str | list[str] | None:
48125
"""Send *text* to the given *channel* on this platform."""
126+
127+
# ------------------------------------------------------------------
128+
# Health
129+
# ------------------------------------------------------------------
130+
131+
async def health_check(self) -> DriverHealth:
132+
return self._health
133+
134+
@property
135+
def health(self) -> DriverHealth:
136+
return self._health
137+
138+
@health.setter
139+
def health(self, value: DriverHealth) -> None:
140+
old = self._health
141+
self._health = value
142+
if old != value:
143+
try:
144+
self._ctx.emit("health_changed", driver=self, old=old, new=value)
145+
except Exception:
146+
pass

drivers/discord.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -596,28 +596,26 @@ async def _send_bot(
596596
pass
597597

598598
try:
599-
# Build kwargs to only include non-None/non-empty values
600-
send_kwargs = {}
601-
if text:
602-
send_kwargs["content"] = text
603-
if discord_files:
604-
send_kwargs["files"] = discord_files
605-
if reference is not None:
606-
send_kwargs["reference"] = reference
607-
608599
replied_user = True
609600
source_mentioned_self = kwargs.get("source_mentioned_self")
610601
if source_mentioned_self is not None:
611602
replied_user = bool(source_mentioned_self)
612603

613-
send_kwargs["allowed_mentions"] = discord.AllowedMentions(
604+
allowed = discord.AllowedMentions(
614605
everyone=self.config.allow_mentions_everyone,
615606
users=self.config.allow_mentions_users,
616607
roles=self.config.allow_mentions_roles,
617608
replied_user=replied_user,
618609
)
619610

620-
sent = await ch.send(**send_kwargs)
611+
send_kwargs: dict = {"allowed_mentions": allowed}
612+
if text:
613+
send_kwargs["content"] = text
614+
if discord_files:
615+
send_kwargs["files"] = discord_files
616+
if reference is not None:
617+
send_kwargs["reference"] = reference
618+
sent = await ch.send(**send_kwargs) # type: ignore[no-matching-overload]
621619
return str(sent.id)
622620
except Exception:
623621
self.logger.exception("send error")

drivers/qq.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,8 +2334,8 @@ async def _do_upload(d=data_bytes, fn=fname, gid=group_id):
23342334
}
23352335
)
23362336

2337-
main_segments = []
2338-
standalone_segments = []
2337+
main_segments: list[dict] = []
2338+
standalone_segments: list[dict | list[dict]] = []
23392339
for seg in segments:
23402340
if seg["type"] in ("video", "record"):
23412341
standalone_segments.append(seg)
@@ -2349,7 +2349,7 @@ async def _do_upload(d=data_bytes, fn=fname, gid=group_id):
23492349
and standalone_segments
23502350
):
23512351
# If only reply segment remains, attach it to the first standalone segment
2352-
standalone_segments[0] = [main_segments[0], standalone_segments[0]]
2352+
standalone_segments[0] = [main_segments[0], standalone_segments[0]] # ty: ignore[invalid-assignment]
23532353
main_segments = []
23542354
else:
23552355
msg_id = await self._api_send_group_msg(group_id, main_segments)

drivers/registry.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
"""
2-
Driver registry.
1+
"""Driver registry.
32
4-
Each driver module calls ``register()`` at import time. ``main.py`` then
5-
auto-discovers all driver modules via ``pkgutil.iter_modules`` so no central
6-
list needs to be maintained — drop a file into ``drivers/`` and it's live.
3+
Each driver module calls ``register()`` at import time. The plugin
4+
loader auto-discovers driver modules so no central list needs to be
5+
maintained — drop a file into ``drivers/`` and it's live.
76
"""
87

98
from __future__ import annotations
109

10+
from typing import Any, Callable
11+
1112
from pydantic import BaseModel
1213

1314
_REGISTRY: dict[str, tuple[type[BaseModel], type]] = {}
1415

16+
_CLI_HOOKS: list[Callable[..., Any]] = []
17+
1518

1619
def register(name: str, config_cls: type[BaseModel], driver_cls: type) -> None:
1720
"""Register a driver under *name*.
@@ -24,7 +27,41 @@ def register(name: str, config_cls: type[BaseModel], driver_cls: type) -> None:
2427
_REGISTRY[name] = (config_cls, driver_cls)
2528

2629

30+
def unregister(name: str) -> bool:
31+
"""Remove a driver from the registry. Returns ``True`` if it existed."""
32+
return _REGISTRY.pop(name, None) is not None
33+
34+
2735
def all_drivers() -> dict[str, tuple[type[BaseModel], type]]:
2836
"""Return a snapshot of ``{name: (config_cls, driver_cls)}`` for every
2937
registered driver."""
3038
return dict(_REGISTRY)
39+
40+
41+
def get_driver(name: str) -> tuple[type[BaseModel], type] | None:
42+
"""Look up a single driver by platform name."""
43+
return _REGISTRY.get(name)
44+
45+
46+
def register_cli(hook: Callable[..., Any]) -> None:
47+
"""Register a CLI subcommand hook.
48+
49+
The hook is called with an ``argparse._SubParsersAction`` and should
50+
add whatever subcommands the driver needs. Called at import time,
51+
same pattern as ``register()``.
52+
53+
Example::
54+
55+
def _setup_cli(subparsers):
56+
p = subparsers.add_parser("mydriver", help="...")
57+
sub = p.add_subparsers(dest="mydriver_command")
58+
sub.add_parser("pair", help="Pair with remote")
59+
60+
register_cli(_setup_cli)
61+
"""
62+
_CLI_HOOKS.append(hook)
63+
64+
65+
def all_cli_hooks() -> list[Callable[..., Any]]:
66+
"""Return all registered CLI hooks."""
67+
return list(_CLI_HOOKS)

0 commit comments

Comments
 (0)