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/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,2 @@
1
+ """Level-triggered, idempotent Authentik→Matrix reconciler (AD-2): group→room projection,
2
+ membership, power levels, parent space, onbot room-state. Implemented in Phase 3."""
@@ -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)