onbot 0.1.0.dev0__py3-none-any.whl
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.
- onbot/__init__.py +7 -0
- onbot/__main__.py +6 -0
- onbot/app.py +171 -0
- onbot/auth/__init__.py +19 -0
- onbot/auth/token_provider.py +153 -0
- onbot/cli.py +80 -0
- onbot/clients/__init__.py +5 -0
- onbot/clients/authentik.py +123 -0
- onbot/clients/base.py +243 -0
- onbot/clients/mas_admin.py +65 -0
- onbot/clients/matrix.py +457 -0
- onbot/clients/synapse_admin.py +159 -0
- onbot/clients/versions.py +61 -0
- onbot/config.py +653 -0
- onbot/events.py +51 -0
- onbot/healthcheck.py +134 -0
- onbot/identity.py +44 -0
- onbot/lifecycle/__init__.py +2 -0
- onbot/lifecycle/accounts.py +309 -0
- onbot/logging.py +26 -0
- onbot/media.py +46 -0
- onbot/models.py +74 -0
- onbot/onboarding/__init__.py +5 -0
- onbot/onboarding/listener.py +103 -0
- onbot/onboarding/welcome.py +116 -0
- onbot/reconciler/__init__.py +2 -0
- onbot/reconciler/effectors.py +78 -0
- onbot/reconciler/engine.py +348 -0
- onbot/reconciler/membership.py +54 -0
- onbot/reconciler/power_levels.py +96 -0
- onbot/reconciler/rooms.py +127 -0
- onbot/reconciler/state.py +77 -0
- onbot/utils.py +61 -0
- onbot-0.1.0.dev0.dist-info/METADATA +241 -0
- onbot-0.1.0.dev0.dist-info/RECORD +38 -0
- onbot-0.1.0.dev0.dist-info/WHEEL +4 -0
- onbot-0.1.0.dev0.dist-info/entry_points.txt +5 -0
- onbot-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
onbot/models.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Domain models shared across the reconciler.
|
|
2
|
+
|
|
3
|
+
These are lightweight typed views over the raw Authentik / Synapse-admin API dicts (which stay as
|
|
4
|
+
``dict`` since their shapes are owned upstream). Replaces the legacy ``UserMap`` / ``Group2RoomMap``
|
|
5
|
+
god-objects with plain, side-effect-free dataclasses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class RoomCreateAttributes:
|
|
16
|
+
"""Desired Matrix room attributes computed from an Authentik group."""
|
|
17
|
+
|
|
18
|
+
alias: str
|
|
19
|
+
canonical_alias: str
|
|
20
|
+
name: str | None = None
|
|
21
|
+
topic: str | None = None
|
|
22
|
+
room_params: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
encrypted: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class MatrixRoom:
|
|
28
|
+
"""Actual room as seen via the Synapse admin API."""
|
|
29
|
+
|
|
30
|
+
room_id: str
|
|
31
|
+
canonical_alias: str | None = None
|
|
32
|
+
name: str | None = None
|
|
33
|
+
topic: str | None = None
|
|
34
|
+
is_space: bool = False
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_admin_api(cls, obj: dict[str, Any], *, is_space: bool = False) -> MatrixRoom:
|
|
38
|
+
return cls(
|
|
39
|
+
room_id=obj["room_id"],
|
|
40
|
+
canonical_alias=obj.get("canonical_alias"),
|
|
41
|
+
name=obj.get("name"),
|
|
42
|
+
topic=obj.get("topic"),
|
|
43
|
+
is_space=is_space,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class MappedUser:
|
|
49
|
+
"""An Authentik user resolved to its (MAS-provisioned) Matrix account."""
|
|
50
|
+
|
|
51
|
+
authentik_obj: dict[str, Any]
|
|
52
|
+
mxid: str
|
|
53
|
+
matrix_obj: dict[str, Any] | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_superuser(self) -> bool:
|
|
57
|
+
return bool(self.authentik_obj.get("is_superuser"))
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def group_pks(self) -> set[str]:
|
|
61
|
+
return {g["pk"] for g in self.authentik_obj.get("groups_obj", [])}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class GroupRoomMap:
|
|
66
|
+
"""A desired group→room projection, paired with the actual room if one exists."""
|
|
67
|
+
|
|
68
|
+
authentik_group: dict[str, Any]
|
|
69
|
+
desired: RoomCreateAttributes
|
|
70
|
+
room: MatrixRoom | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def group_pk(self) -> str:
|
|
74
|
+
return str(self.authentik_group["pk"])
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Event-driven welcome/onboarding via the sync stream (AD-3): listener + idempotent welcome flow.
|
|
2
|
+
|
|
3
|
+
Phase 4. MXID identity mapping consistent with the MAS localpart template lives in the shared
|
|
4
|
+
``onbot.identity`` module (AD-6), reused here and by the reconciler.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Onboarding listener (AD-3): event-driven welcome via the sync stream + the reconciler signal.
|
|
2
|
+
|
|
3
|
+
Two trigger paths converge here (AD-4 explicit coupling):
|
|
4
|
+
|
|
5
|
+
* **Reconciler signal** — the reconciler emits :attr:`Signal.user_synced` for every provisioned user
|
|
6
|
+
it sees; the listener subscribes and welcomes them. This is the dependable path and works without
|
|
7
|
+
any sync support.
|
|
8
|
+
* **Sync stream** — :meth:`run` consumes Simplified Sliding Sync and welcomes users on ``join``
|
|
9
|
+
membership events, for instant onboarding with no tick latency.
|
|
10
|
+
|
|
11
|
+
Both funnel through :meth:`_maybe_welcome`, which filters out the bot and ignored users and defers to
|
|
12
|
+
the idempotent :class:`~onbot.onboarding.welcome.WelcomeService` (so duplicate triggers are safe).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
|
|
20
|
+
from onbot.clients.matrix import ApiClientMatrix, SyncNotSupportedError, SyncResult
|
|
21
|
+
from onbot.config import OnbotConfig
|
|
22
|
+
from onbot.events import Event, EventBus, Signal
|
|
23
|
+
from onbot.logging import get_logger
|
|
24
|
+
from onbot.onboarding.welcome import WelcomeService
|
|
25
|
+
|
|
26
|
+
log = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
_ERROR_BACKOFF_SEC = 5.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_joined_users(result: SyncResult) -> set[str]:
|
|
32
|
+
"""MXIDs with a ``join`` membership event in this sync slice."""
|
|
33
|
+
joined: set[str] = set()
|
|
34
|
+
for room in result.rooms:
|
|
35
|
+
for ev in room.member_events():
|
|
36
|
+
if (ev.get("content") or {}).get("membership") != "join":
|
|
37
|
+
continue
|
|
38
|
+
mxid = ev.get("state_key") or ev.get("sender")
|
|
39
|
+
if mxid:
|
|
40
|
+
joined.add(mxid)
|
|
41
|
+
return joined
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OnboardingListener:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
client: ApiClientMatrix,
|
|
48
|
+
welcome: WelcomeService,
|
|
49
|
+
config: OnbotConfig,
|
|
50
|
+
events: EventBus,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.client = client
|
|
53
|
+
self.welcome = welcome
|
|
54
|
+
self.config = config
|
|
55
|
+
self.events = events
|
|
56
|
+
self.bot_id = config.synapse_server.bot_user_id
|
|
57
|
+
self._pos: str | None = None
|
|
58
|
+
self._stop = asyncio.Event()
|
|
59
|
+
|
|
60
|
+
def start(self) -> None:
|
|
61
|
+
"""Subscribe to the reconciler's user-provisioned signal (call once, before running)."""
|
|
62
|
+
self.events.subscribe(Signal.user_synced, self._on_user_synced)
|
|
63
|
+
|
|
64
|
+
def request_stop(self) -> None:
|
|
65
|
+
self._stop.set()
|
|
66
|
+
|
|
67
|
+
async def _on_user_synced(self, event: Event) -> None:
|
|
68
|
+
await self._maybe_welcome(event.payload["mxid"])
|
|
69
|
+
|
|
70
|
+
async def _maybe_welcome(self, mxid: str) -> None:
|
|
71
|
+
if mxid == self.bot_id or mxid in self.config.matrix_user_ignore_list:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
await self.welcome.welcome_user(mxid)
|
|
75
|
+
except Exception:
|
|
76
|
+
log.exception("welcome flow failed for %s", mxid)
|
|
77
|
+
|
|
78
|
+
async def run(self) -> None:
|
|
79
|
+
"""Consume the sync stream and welcome joining users until stopped."""
|
|
80
|
+
log.info("onboarding listener started (sliding sync)")
|
|
81
|
+
while not self._stop.is_set():
|
|
82
|
+
try:
|
|
83
|
+
result = await self.client.sliding_sync(self._pos)
|
|
84
|
+
except SyncNotSupportedError:
|
|
85
|
+
# The homeserver does not support Simplified Sliding Sync; rely on the reconciler
|
|
86
|
+
# signal path for onboarding (the listener stays subscribed via start()).
|
|
87
|
+
log.warning("sliding sync unsupported; onboarding via reconciler signal only")
|
|
88
|
+
break
|
|
89
|
+
except Exception:
|
|
90
|
+
log.exception("sync failed; backing off %.0fs", _ERROR_BACKOFF_SEC)
|
|
91
|
+
await self._sleep(_ERROR_BACKOFF_SEC)
|
|
92
|
+
continue
|
|
93
|
+
self._pos = result.pos
|
|
94
|
+
for mxid in extract_joined_users(result):
|
|
95
|
+
if self._stop.is_set():
|
|
96
|
+
break
|
|
97
|
+
await self._maybe_welcome(mxid)
|
|
98
|
+
log.info("onboarding listener stopped")
|
|
99
|
+
|
|
100
|
+
async def _sleep(self, seconds: float) -> None:
|
|
101
|
+
# Sleep, but wake immediately on stop.
|
|
102
|
+
with contextlib.suppress(TimeoutError):
|
|
103
|
+
await asyncio.wait_for(self._stop.wait(), timeout=seconds)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Welcome / onboarding flow (AD-3, G4.*).
|
|
2
|
+
|
|
3
|
+
When a user is provisioned/seen, the bot opens a 1:1 direct room with them and sends the configured
|
|
4
|
+
welcome messages. Two layers of idempotency keep it safe to call repeatedly (the listener may fire
|
|
5
|
+
on both the reconciler signal *and* a join event):
|
|
6
|
+
|
|
7
|
+
* **One DM per user** — the bot's ``m.direct`` account data maps each user to their DM room; an
|
|
8
|
+
existing room is reused rather than re-created.
|
|
9
|
+
* **Each message once** — the DM's onbot ``direct_room`` state event records a content hash per sent
|
|
10
|
+
message (G4.3). Already-sent messages are skipped; only new/changed ones go out.
|
|
11
|
+
|
|
12
|
+
No database: all bookkeeping lives in Matrix account data + room state (AD-1).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
|
|
20
|
+
from onbot.clients.matrix import ApiClientMatrix
|
|
21
|
+
from onbot.config import OnbotConfig
|
|
22
|
+
from onbot.logging import get_logger
|
|
23
|
+
from onbot.reconciler.state import (
|
|
24
|
+
DirectRoomState,
|
|
25
|
+
OnbotRoomType,
|
|
26
|
+
dump_room_state,
|
|
27
|
+
event_type_name,
|
|
28
|
+
parse_room_state,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
M_DIRECT = "m.direct"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _message_key(message: str) -> str:
|
|
37
|
+
"""Stable per-message idempotency key (content hash). Changing the text re-sends only that one."""
|
|
38
|
+
return hashlib.sha256(message.encode("utf-8")).hexdigest()[:16]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WelcomeService:
|
|
42
|
+
def __init__(self, client: ApiClientMatrix, config: OnbotConfig) -> None:
|
|
43
|
+
self.client = client
|
|
44
|
+
self.config = config
|
|
45
|
+
self.bot_id = config.synapse_server.bot_user_id
|
|
46
|
+
self.server_name = config.synapse_server.server_name
|
|
47
|
+
self._direct_event_type = event_type_name(self.server_name, OnbotRoomType.direct_room)
|
|
48
|
+
# G4.5: optionally gather onboarding DMs under the managed space (opt-in — 1:1 rooms in a
|
|
49
|
+
# space is a matter of taste). Resolved lazily from the configured space alias and cached.
|
|
50
|
+
space_cfg = config.create_matrix_rooms_in_a_matrix_space
|
|
51
|
+
self._place_in_space = config.place_onboarding_rooms_in_space and space_cfg.enabled
|
|
52
|
+
self._space_alias = f"#{space_cfg.alias}:{self.server_name}"
|
|
53
|
+
self._space_id: str | None = None
|
|
54
|
+
|
|
55
|
+
async def welcome_user(self, mxid: str) -> None:
|
|
56
|
+
"""Ensure ``mxid`` has a DM with all configured welcome messages delivered (idempotent)."""
|
|
57
|
+
messages = self.config.welcome_new_users_messages or []
|
|
58
|
+
if not messages:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
room_id, created = await self._ensure_direct_room(mxid)
|
|
62
|
+
state = (
|
|
63
|
+
await self._load_direct_state(room_id, mxid)
|
|
64
|
+
if not created
|
|
65
|
+
else DirectRoomState(user_id=mxid, authentik_server=self.config.authentik_server.url)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
changed = created
|
|
69
|
+
for message in messages:
|
|
70
|
+
key = _message_key(message)
|
|
71
|
+
if key in state.welcome_messages_sent:
|
|
72
|
+
continue
|
|
73
|
+
await self.client.send_text_message(room_id, message)
|
|
74
|
+
state.welcome_messages_sent[key] = datetime.now(UTC).isoformat()
|
|
75
|
+
changed = True
|
|
76
|
+
|
|
77
|
+
if changed:
|
|
78
|
+
await self.client.put_room_state_event(room_id, self._direct_event_type, dump_room_state(state))
|
|
79
|
+
log.info(
|
|
80
|
+
"welcomed %s in %s (%d messages tracked)",
|
|
81
|
+
mxid,
|
|
82
|
+
room_id,
|
|
83
|
+
len(state.welcome_messages_sent),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def _ensure_direct_room(self, mxid: str) -> tuple[str, bool]:
|
|
87
|
+
"""Return ``(room_id, created)`` — reusing the bot's existing DM with ``mxid`` if any."""
|
|
88
|
+
direct = await self.client.get_account_data(self.bot_id, M_DIRECT)
|
|
89
|
+
existing = direct.get(mxid) or []
|
|
90
|
+
if existing:
|
|
91
|
+
return str(existing[0]), False
|
|
92
|
+
|
|
93
|
+
room_id = await self.client.create_direct_message_room(mxid)
|
|
94
|
+
direct.setdefault(mxid, []).append(room_id)
|
|
95
|
+
await self.client.set_account_data(self.bot_id, M_DIRECT, direct)
|
|
96
|
+
await self._maybe_place_in_space(room_id)
|
|
97
|
+
return room_id, True
|
|
98
|
+
|
|
99
|
+
async def _maybe_place_in_space(self, room_id: str) -> None:
|
|
100
|
+
"""Add a freshly created DM to the managed space, if configured (G4.5)."""
|
|
101
|
+
if not self._place_in_space:
|
|
102
|
+
return
|
|
103
|
+
if self._space_id is None:
|
|
104
|
+
self._space_id = await self.client.resolve_room_alias(self._space_alias)
|
|
105
|
+
if self._space_id is None:
|
|
106
|
+
log.warning("cannot place DM in space: alias %s does not resolve yet", self._space_alias)
|
|
107
|
+
return
|
|
108
|
+
await self.client.link_room_to_space(self._space_id, room_id)
|
|
109
|
+
|
|
110
|
+
async def _load_direct_state(self, room_id: str, mxid: str) -> DirectRoomState:
|
|
111
|
+
content = await self.client.get_room_state_event(room_id, self._direct_event_type)
|
|
112
|
+
if content is None:
|
|
113
|
+
return DirectRoomState(user_id=mxid, authentik_server=self.config.authentik_server.url)
|
|
114
|
+
parsed = parse_room_state(OnbotRoomType.direct_room, content)
|
|
115
|
+
assert isinstance(parsed, DirectRoomState)
|
|
116
|
+
return parsed
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Matrix-side effectors — the write seam between the reconciler and the Matrix CS API.
|
|
2
|
+
|
|
3
|
+
The reconciler decides *what* should change (pure logic in the sibling modules); these effectors
|
|
4
|
+
perform the Matrix Client-Server operations that the Synapse admin API cannot do (room/space
|
|
5
|
+
creation, kicks, power levels, room name/topic, custom state events).
|
|
6
|
+
|
|
7
|
+
The concrete CS-API implementation lands in Phase 4 (clients/matrix.py) once the Matrix-library
|
|
8
|
+
decision is made (AD / Phase 6). For Phase 3 we ship the :class:`MatrixEffectors` protocol plus a
|
|
9
|
+
:class:`DryRunEffectors` that logs intended actions — so ``reconcile-once`` runs end-to-end against
|
|
10
|
+
real read APIs without mutating anything (the dry-run-by-default safety principle, Q6).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from onbot.logging import get_logger
|
|
19
|
+
from onbot.models import RoomCreateAttributes
|
|
20
|
+
|
|
21
|
+
log = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class MatrixEffectors(Protocol):
|
|
26
|
+
async def create_group_room(self, attrs: RoomCreateAttributes, parent_space_id: str | None) -> str: ...
|
|
27
|
+
|
|
28
|
+
async def create_space(self, *, alias: str, name: str, topic: str, params: dict[str, Any]) -> str: ...
|
|
29
|
+
|
|
30
|
+
async def kick_user(self, room_id: str, user_id: str, reason: str | None = None) -> None: ...
|
|
31
|
+
|
|
32
|
+
async def get_room_power_levels(self, room_id: str) -> dict[str, Any]: ...
|
|
33
|
+
|
|
34
|
+
async def set_room_power_levels(self, room_id: str, power_levels: dict[str, Any]) -> None: ...
|
|
35
|
+
|
|
36
|
+
async def set_room_name(self, room_id: str, name: str) -> None: ...
|
|
37
|
+
|
|
38
|
+
async def set_room_topic(self, room_id: str, topic: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
async def put_room_state(self, room_id: str, event_type: str, content: dict[str, Any]) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DryRunEffectors:
|
|
44
|
+
"""Logs every intended write and mutates nothing. Default until Phase 4 wires the CS client."""
|
|
45
|
+
|
|
46
|
+
def _synthetic_room_id(self, server_name: str = "dry-run") -> str:
|
|
47
|
+
return f"!dryrun-{uuid.uuid4().hex[:12]}:{server_name}"
|
|
48
|
+
|
|
49
|
+
async def create_group_room(self, attrs: RoomCreateAttributes, parent_space_id: str | None) -> str:
|
|
50
|
+
log.info(
|
|
51
|
+
"[dry-run] would create group room alias=%s name=%r in space=%s",
|
|
52
|
+
attrs.canonical_alias,
|
|
53
|
+
attrs.name,
|
|
54
|
+
parent_space_id,
|
|
55
|
+
)
|
|
56
|
+
return self._synthetic_room_id()
|
|
57
|
+
|
|
58
|
+
async def create_space(self, *, alias: str, name: str, topic: str, params: dict[str, Any]) -> str:
|
|
59
|
+
log.info("[dry-run] would create space alias=%s name=%r", alias, name)
|
|
60
|
+
return self._synthetic_room_id()
|
|
61
|
+
|
|
62
|
+
async def kick_user(self, room_id: str, user_id: str, reason: str | None = None) -> None:
|
|
63
|
+
log.info("[dry-run] would kick %s from %s (%s)", user_id, room_id, reason)
|
|
64
|
+
|
|
65
|
+
async def get_room_power_levels(self, room_id: str) -> dict[str, Any]:
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
async def set_room_power_levels(self, room_id: str, power_levels: dict[str, Any]) -> None:
|
|
69
|
+
log.info("[dry-run] would set power levels in %s -> %s", room_id, power_levels)
|
|
70
|
+
|
|
71
|
+
async def set_room_name(self, room_id: str, name: str) -> None:
|
|
72
|
+
log.info("[dry-run] would set name of %s -> %r", room_id, name)
|
|
73
|
+
|
|
74
|
+
async def set_room_topic(self, room_id: str, topic: str) -> None:
|
|
75
|
+
log.info("[dry-run] would set topic of %s -> %r", room_id, topic)
|
|
76
|
+
|
|
77
|
+
async def put_room_state(self, room_id: str, event_type: str, content: dict[str, Any]) -> None:
|
|
78
|
+
log.info("[dry-run] would set state %s on %s", event_type, room_id)
|