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.
@@ -0,0 +1,348 @@
1
+ """Reconciler engine (AD-2): level-triggered, idempotent convergence.
2
+
3
+ Computes *desired* state (from Authentik) vs *actual* state (Synapse) and applies the diff. Runs on
4
+ a schedule **and** on demand (replacing the legacy ``while True: sleep`` tick loop), and shuts down
5
+ gracefully on SIGINT/SIGTERM. A single ``reconcile_once`` pass is fully re-runnable.
6
+
7
+ The pure decision logic lives in the sibling modules (``rooms``, ``membership``, ``power_levels``);
8
+ reads go through the Authentik + Synapse-admin clients; writes go through the Synapse-admin client
9
+ (membership/block) and the :class:`MatrixEffectors` seam (CS-API operations, Phase 4).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import signal
17
+ from typing import Any
18
+
19
+ from onbot.clients.authentik import ApiClientAuthentik
20
+ from onbot.clients.synapse_admin import ApiClientSynapseAdmin
21
+ from onbot.config import OnbotConfig, SyncMatrixRoomsBasedOnAuthentikGroups
22
+ from onbot.events import EventBus, Signal
23
+ from onbot.identity import build_canonical, compute_mxid
24
+ from onbot.lifecycle.accounts import AccountLifecycleManager
25
+ from onbot.logging import get_logger
26
+ from onbot.models import GroupRoomMap, MappedUser, MatrixRoom
27
+ from onbot.reconciler.effectors import DryRunEffectors, MatrixEffectors
28
+ from onbot.reconciler.membership import (
29
+ desired_room_members,
30
+ diff_room_membership,
31
+ diff_space_membership,
32
+ )
33
+ from onbot.reconciler.power_levels import (
34
+ PowerLevelGroup,
35
+ compute_desired_user_levels,
36
+ extract_power_level_groups,
37
+ merge_power_levels,
38
+ )
39
+ from onbot.reconciler.rooms import build_group_room_maps
40
+ from onbot.reconciler.state import (
41
+ GroupRoomState,
42
+ OnbotRoomType,
43
+ SpaceRoomState,
44
+ dump_room_state,
45
+ event_type_name,
46
+ )
47
+
48
+ log = get_logger(__name__)
49
+
50
+
51
+ class ConfigurationError(RuntimeError):
52
+ """Raised when the configuration cannot be satisfied (e.g. required space missing)."""
53
+
54
+
55
+ class ReconcilerEngine:
56
+ def __init__(
57
+ self,
58
+ config: OnbotConfig,
59
+ authentik: ApiClientAuthentik,
60
+ admin: ApiClientSynapseAdmin,
61
+ effectors: MatrixEffectors | None = None,
62
+ events: EventBus | None = None,
63
+ lifecycle: AccountLifecycleManager | None = None,
64
+ ) -> None:
65
+ self.config = config
66
+ self.authentik = authentik
67
+ self.admin = admin
68
+ self.effectors: MatrixEffectors = effectors or DryRunEffectors()
69
+ self.events = events or EventBus()
70
+ self.lifecycle = lifecycle
71
+ self.server_name = config.synapse_server.server_name
72
+ self._stop = asyncio.Event()
73
+ self._trigger = asyncio.Event()
74
+
75
+ # --- runtime loop --------------------------------------------------------
76
+
77
+ def trigger(self) -> None:
78
+ """Request an out-of-band reconcile (on-demand) before the next scheduled tick."""
79
+ self._trigger.set()
80
+
81
+ def request_stop(self) -> None:
82
+ self._stop.set()
83
+ self._trigger.set() # unblock the wait so we exit promptly
84
+
85
+ async def run(self) -> None:
86
+ """Run scheduled + on-demand reconciles until stopped (SIGINT/SIGTERM)."""
87
+ self._install_signal_handlers()
88
+ log.info("reconciler started; tick=%ss", self.config.server_tick_rate_sec)
89
+ while not self._stop.is_set():
90
+ # Clear before the pass so a trigger raised *during* it is preserved for the next wait.
91
+ self._trigger.clear()
92
+ try:
93
+ await self.reconcile_once()
94
+ except Exception:
95
+ log.exception("reconcile pass failed; will retry next tick")
96
+ if self._stop.is_set():
97
+ break
98
+ await self._wait_for_next_tick()
99
+ log.info("reconciler stopped")
100
+
101
+ async def _wait_for_next_tick(self) -> None:
102
+ # Wake on an on-demand trigger, or fall through on the scheduled timeout.
103
+ with contextlib.suppress(TimeoutError):
104
+ await asyncio.wait_for(self._trigger.wait(), timeout=self.config.server_tick_rate_sec)
105
+
106
+ def _install_signal_handlers(self) -> None:
107
+ try:
108
+ loop = asyncio.get_running_loop()
109
+ for sig in (signal.SIGINT, signal.SIGTERM):
110
+ loop.add_signal_handler(sig, self.request_stop)
111
+ except NotImplementedError, RuntimeError: # pragma: no cover - non-main thread / Windows
112
+ log.debug("signal handlers unavailable in this environment")
113
+
114
+ # --- one convergence pass ------------------------------------------------
115
+
116
+ async def reconcile_once(self) -> None:
117
+ log.info("reconcile: gathering desired (Authentik) and actual (Synapse) state")
118
+ matrix_users = await self.admin.list_users()
119
+ users = await self._gather_mapped_users(matrix_users)
120
+ space = await self._resolve_space()
121
+ group_maps = await self._gather_group_room_maps()
122
+
123
+ await self._converge_rooms(group_maps, space)
124
+ if space is not None:
125
+ await self._converge_space_membership(space, users)
126
+ await self._converge_room_membership_and_levels(group_maps, users)
127
+ await self._converge_lifecycle(matrix_users, {u.mxid for u in users})
128
+ log.info("reconcile: done (%d users, %d group rooms)", len(users), len(group_maps))
129
+
130
+ async def _gather_mapped_users(self, matrix_users: list[dict[str, Any]]) -> list[MappedUser]:
131
+ cfg = self.config.sync_authentik_users_with_matrix_rooms
132
+ if not cfg.enabled:
133
+ return []
134
+ configured_paths = cfg.sync_only_users_in_authentik_pathes
135
+ paths: list[str | None] = [*configured_paths] if configured_paths else [None]
136
+ by_mxid: dict[str, dict[str, Any]] = {}
137
+ seen_pks: set[str] = set()
138
+ for path in paths:
139
+ for user in await self.authentik.list_users(
140
+ filter_by_path=path,
141
+ filter_by_attribute=cfg.sync_only_users_with_authentik_attributes,
142
+ filter_groups_by_pk=cfg.sync_only_users_of_groups_with_id,
143
+ filter_is_active=True,
144
+ ):
145
+ if user["username"] in self.config.authentik_user_ignore_list:
146
+ continue
147
+ if user["pk"] in seen_pks:
148
+ continue
149
+ seen_pks.add(user["pk"])
150
+ try:
151
+ mxid = compute_mxid(
152
+ user,
153
+ username_attribute=cfg.authentik_username_mapping_attribute,
154
+ server_name=self.server_name,
155
+ )
156
+ except KeyError:
157
+ log.warning("cannot map MXID for Authentik user %r; skipping", user.get("pk"))
158
+ continue
159
+ by_mxid[mxid] = user
160
+
161
+ mapped: list[MappedUser] = []
162
+ for matrix_user in matrix_users:
163
+ mxid = matrix_user["name"]
164
+ if mxid in self.config.matrix_user_ignore_list:
165
+ continue
166
+ authentik_user = by_mxid.get(mxid)
167
+ if authentik_user is not None:
168
+ mapped.append(MappedUser(authentik_obj=authentik_user, mxid=mxid, matrix_obj=matrix_user))
169
+ await self.events.emit(Signal.user_synced, mxid=mxid)
170
+ return mapped
171
+
172
+ async def _gather_group_room_maps(self) -> list[GroupRoomMap]:
173
+ settings = self.config.sync_matrix_rooms_based_on_authentik_groups
174
+ if not settings.enabled:
175
+ return []
176
+ groups = await self.authentik.list_groups(filter_by_attribute=settings.only_groups_with_attributes)
177
+ rooms = [MatrixRoom.from_admin_api(r) for r in await self.admin.list_non_space_rooms()]
178
+ return build_group_room_maps(groups, rooms, self.config, self.server_name)
179
+
180
+ async def _resolve_space(self) -> MatrixRoom | None:
181
+ cfg = self.config.create_matrix_rooms_in_a_matrix_space
182
+ if not cfg.enabled:
183
+ return None
184
+ target_alias = build_canonical(cfg.alias, self.server_name, "#")
185
+ for sp in await self.admin.list_spaces():
186
+ if sp.get("canonical_alias") == target_alias:
187
+ return MatrixRoom.from_admin_api(sp, is_space=True)
188
+ if not cfg.create_matrix_space_if_not_exists.enabled:
189
+ raise ConfigurationError(
190
+ f"space {target_alias!r} not found and auto-creation is disabled "
191
+ "(create_matrix_rooms_in_a_matrix_space.create_matrix_space_if_not_exists.enabled)"
192
+ )
193
+ create = cfg.create_matrix_space_if_not_exists
194
+ room_id = await self.effectors.create_space(
195
+ alias=cfg.alias, name=create.name, topic=create.topic, params=create.space_params
196
+ )
197
+ await self.effectors.put_room_state(
198
+ room_id,
199
+ event_type_name(self.server_name, OnbotRoomType.space),
200
+ dump_room_state(SpaceRoomState(authentik_server=self.config.authentik_server.url)),
201
+ )
202
+ return MatrixRoom(room_id=room_id, canonical_alias=target_alias, is_space=True)
203
+
204
+ async def _converge_rooms(self, group_maps: list[GroupRoomMap], space: MatrixRoom | None) -> None:
205
+ parent_space_id = space.room_id if space else None
206
+ for gm in group_maps:
207
+ if gm.room is None:
208
+ room_id = await self.effectors.create_group_room(gm.desired, parent_space_id)
209
+ await self.effectors.put_room_state(
210
+ room_id,
211
+ event_type_name(self.server_name, OnbotRoomType.group_room),
212
+ dump_room_state(
213
+ GroupRoomState(
214
+ group_id=gm.group_pk,
215
+ authentik_server=self.config.authentik_server.url,
216
+ )
217
+ ),
218
+ )
219
+ gm.room = MatrixRoom(room_id=room_id, canonical_alias=gm.desired.canonical_alias)
220
+ elif await self.admin.room_is_blocked(gm.room.room_id):
221
+ # G2.3: a previously blocked room whose group reappeared gets unblocked.
222
+ log.info("unblocking room %s (group reappeared)", gm.room.room_id)
223
+ await self.admin.room_set_blocked(gm.room.room_id, blocked=False)
224
+
225
+ async def _converge_space_membership(self, space: MatrixRoom, users: list[MappedUser]) -> None:
226
+ members = await self.admin.list_room_members(space.room_id)
227
+ diff = diff_space_membership(users, members)
228
+ for mxid in diff.to_add:
229
+ await self.admin.add_user_to_room(space.room_id, mxid)
230
+
231
+ async def _converge_room_membership_and_levels(
232
+ self, group_maps: list[GroupRoomMap], users: list[MappedUser]
233
+ ) -> None:
234
+ sync_cfg = self.config.sync_authentik_users_with_matrix_rooms
235
+ room_cfg = self.config.sync_matrix_rooms_based_on_authentik_groups
236
+ bot_id = self.config.synapse_server.bot_user_id
237
+
238
+ pl_groups = extract_power_level_groups(
239
+ await self.authentik.list_groups(
240
+ filter_has_non_empty_attributes=[room_cfg.authentik_group_attr_for_matrix_power_level]
241
+ ),
242
+ room_cfg.authentik_group_attr_for_matrix_power_level,
243
+ )
244
+
245
+ for gm in group_maps:
246
+ if gm.room is None: # freshly created in dry-run with synthetic id is still set; guard anyway
247
+ continue
248
+ room_id = gm.room.room_id
249
+ actual_members = await self.admin.list_room_members(room_id)
250
+ desired_mxids = desired_room_members(gm.group_pk, users)
251
+
252
+ mdiff = diff_room_membership(
253
+ desired_mxids,
254
+ actual_members,
255
+ kick_enabled=sync_cfg.kick_matrix_room_members_not_in_mapped_authentik_group_anymore,
256
+ protected_ids=[bot_id],
257
+ )
258
+ for mxid in mdiff.to_add:
259
+ await self.admin.add_user_to_room(room_id, mxid)
260
+ for mxid in mdiff.to_kick:
261
+ await self.effectors.kick_user(
262
+ room_id,
263
+ mxid,
264
+ "Removed: missing/revoked group membership in the central user directory.",
265
+ )
266
+
267
+ await self._converge_power_levels(room_id, gm.group_pk, users, pl_groups, room_cfg)
268
+ await self._converge_room_attributes(gm)
269
+
270
+ async def _converge_power_levels(
271
+ self,
272
+ room_id: str,
273
+ group_pk: str,
274
+ users: list[MappedUser],
275
+ pl_groups: list[PowerLevelGroup],
276
+ room_cfg: SyncMatrixRoomsBasedOnAuthentikGroups,
277
+ ) -> None:
278
+ members_in_room = [u for u in users if group_pk in u.group_pks]
279
+ managed = {u.mxid for u in members_in_room}
280
+ if not managed:
281
+ return
282
+ desired = compute_desired_user_levels(
283
+ members_in_room,
284
+ pl_groups,
285
+ make_superusers_admin=room_cfg.make_authentik_superusers_matrix_room_admin,
286
+ )
287
+ current = await self.effectors.get_room_power_levels(room_id)
288
+ current_users = dict(current.get("users", {}))
289
+ merged = merge_power_levels(current_users, desired, managed)
290
+ if merged != current_users:
291
+ new_levels = {**current, "users": merged}
292
+ await self.effectors.set_room_power_levels(room_id, new_levels)
293
+
294
+ async def _converge_room_attributes(self, gm: GroupRoomMap) -> None:
295
+ if not self.config.matrix_room_default_settings.keep_updating_matrix_attributes_from_authentik:
296
+ return
297
+ assert gm.room is not None
298
+ if gm.desired.name is not None and gm.room.name != gm.desired.name:
299
+ await self.effectors.set_room_name(gm.room.room_id, gm.desired.name)
300
+ if gm.desired.topic is not None and gm.room.topic != gm.desired.topic:
301
+ await self.effectors.set_room_topic(gm.room.room_id, gm.desired.topic)
302
+
303
+ # --- lifecycle (AD-5, G9.*): quarantined, invoked only from the reconcile result ---
304
+
305
+ async def _converge_lifecycle(self, matrix_users: list[dict[str, Any]], active_mxids: set[str]) -> None:
306
+ if self.lifecycle is None:
307
+ return
308
+ sync_cfg = self.config.sync_authentik_users_with_matrix_rooms
309
+ if not sync_cfg.deactivate_disabled_authentik_users_in_matrix.enabled:
310
+ return
311
+ orphaned = await self._gather_orphaned_mxids(matrix_users, active_mxids)
312
+ await self.lifecycle.reconcile_accounts(orphaned)
313
+
314
+ async def _gather_orphaned_mxids(
315
+ self, matrix_users: list[dict[str, Any]], active_mxids: set[str]
316
+ ) -> set[str]:
317
+ """MXIDs whose Authentik account is disabled but a Matrix account still exists (G9.1).
318
+
319
+ Scoped to *disabled* Authentik users we can positively map — never sweeping arbitrary Matrix
320
+ accounts that simply lack an Authentik counterpart (e.g. admin/service users), so the
321
+ destructive path can only ever touch accounts we provisioned from a now-disabled directory
322
+ entry. The bot user and the ignore lists (G12.1) are always excluded.
323
+ """
324
+ cfg = self.config.sync_authentik_users_with_matrix_rooms
325
+ disabled_users = await self.authentik.list_users(
326
+ filter_by_attribute=cfg.sync_only_users_with_authentik_attributes,
327
+ filter_is_active=False,
328
+ )
329
+ matrix_mxids = {u["name"] for u in matrix_users}
330
+ bot_id = self.config.synapse_server.bot_user_id
331
+ orphaned: set[str] = set()
332
+ for user in disabled_users:
333
+ if user.get("username") in self.config.authentik_user_ignore_list:
334
+ continue
335
+ try:
336
+ mxid = compute_mxid(
337
+ user,
338
+ username_attribute=cfg.authentik_username_mapping_attribute,
339
+ server_name=self.server_name,
340
+ )
341
+ except KeyError:
342
+ continue
343
+ if mxid == bot_id or mxid in self.config.matrix_user_ignore_list:
344
+ continue
345
+ if mxid in active_mxids or mxid not in matrix_mxids:
346
+ continue
347
+ orphaned.add(mxid)
348
+ return orphaned
@@ -0,0 +1,54 @@
1
+ """Group membership → room membership (pure diff logic).
2
+
3
+ Ported from legacy ``Bot.sync_users_and_rooms`` / ``sync_users_and_space``. Fixes the BATTLE_PLAN §3
4
+ bug where membership was tested with substring ``in`` (``grp["pk"] in room...["pk"]``) instead of
5
+ set membership — here it is exact set containment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass, field
12
+
13
+ from onbot.models import MappedUser
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class MembershipDiff:
18
+ to_add: list[str] = field(default_factory=list)
19
+ to_kick: list[str] = field(default_factory=list)
20
+
21
+
22
+ def desired_room_members(group_pk: str, mapped_users: Iterable[MappedUser]) -> set[str]:
23
+ """MXIDs that should be in the room mapped to ``group_pk`` (members of that Authentik group)."""
24
+ return {u.mxid for u in mapped_users if group_pk in u.group_pks}
25
+
26
+
27
+ def diff_room_membership(
28
+ desired_mxids: set[str],
29
+ actual_member_ids: Iterable[str],
30
+ *,
31
+ kick_enabled: bool,
32
+ protected_ids: Iterable[str] = (),
33
+ ) -> MembershipDiff:
34
+ """Compute adds/kicks to converge actual room membership onto ``desired_mxids``.
35
+
36
+ ``kick_enabled`` gates removals (G3.2 is toggleable). ``protected_ids`` (e.g. the bot itself) are
37
+ never kicked.
38
+ """
39
+ actual = set(actual_member_ids)
40
+ protected = set(protected_ids)
41
+ to_add = sorted(desired_mxids - actual)
42
+ to_kick: list[str] = []
43
+ if kick_enabled:
44
+ to_kick = sorted((actual - desired_mxids) - protected)
45
+ return MembershipDiff(to_add=to_add, to_kick=to_kick)
46
+
47
+
48
+ def diff_space_membership(
49
+ mapped_users: Iterable[MappedUser], actual_member_ids: Iterable[str]
50
+ ) -> MembershipDiff:
51
+ """All synced users should be in the parent space (G1.3); we only ever add, never kick."""
52
+ desired = {u.mxid for u in mapped_users}
53
+ to_add = sorted(desired - set(actual_member_ids))
54
+ return MembershipDiff(to_add=to_add)
@@ -0,0 +1,96 @@
1
+ """Per-room power-level computation (pure logic).
2
+
3
+ Ported from legacy ``power_level_manager.py`` and made side-effect-free. Two improvements over
4
+ legacy:
5
+
6
+ * **Withdrawal (G8.4):** legacy left elevated power levels sticky forever. Here we compute the full
7
+ *desired* level for every managed room member and :func:`merge_power_levels` removes the explicit
8
+ entry for any managed user who no longer qualifies — converging both up and down (AD-2).
9
+ * **Highest wins (G8.3):** when a user is in several power-level groups we take the max; superuser
10
+ room-admin (G8.2) overrides group levels.
11
+
12
+ Non-managed users (e.g. the bot, manually-promoted accounts) are never touched.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from onbot.models import MappedUser
22
+ from onbot.utils import get_nested_dict_val_by_path
23
+
24
+ ROOM_ADMIN_LEVEL = 100
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class PowerLevelGroup:
29
+ """An Authentik group that confers a Matrix power level on its members."""
30
+
31
+ member_pks: set[str]
32
+ level: int
33
+
34
+
35
+ def extract_power_level_groups(
36
+ groups: Iterable[dict[str, Any]], attribute_path: str
37
+ ) -> list[PowerLevelGroup]:
38
+ """Build :class:`PowerLevelGroup` entries from Authentik groups carrying the power-level attr.
39
+
40
+ ``attribute_path`` is the attribute name/path *within* the group's ``attributes`` object (the
41
+ config value of ``authentik_group_attr_for_matrix_power_level``).
42
+ """
43
+ key_path = ("attributes." + attribute_path).split(".")
44
+ result: list[PowerLevelGroup] = []
45
+ for group in groups:
46
+ level = get_nested_dict_val_by_path(group, key_path, fallback_val=None)
47
+ if not isinstance(level, int) or isinstance(level, bool):
48
+ continue
49
+ result.append(PowerLevelGroup(member_pks=set(group.get("users", [])), level=level))
50
+ return result
51
+
52
+
53
+ def compute_desired_user_levels(
54
+ members: Iterable[MappedUser],
55
+ power_level_groups: Iterable[PowerLevelGroup],
56
+ *,
57
+ make_superusers_admin: bool,
58
+ admin_level: int = ROOM_ADMIN_LEVEL,
59
+ ) -> dict[str, int]:
60
+ """Desired explicit power level per managed member who qualifies for one (> default).
61
+
62
+ Members with no group level and no superuser admin are omitted (they should sit at the room
63
+ default); :func:`merge_power_levels` turns that omission into a withdrawal.
64
+ """
65
+ groups = list(power_level_groups)
66
+ desired: dict[str, int] = {}
67
+ for member in members:
68
+ level: int | None = None
69
+ member_pk = member.authentik_obj.get("pk")
70
+ for group in groups:
71
+ if member_pk in group.member_pks:
72
+ level = group.level if level is None else max(level, group.level)
73
+ if make_superusers_admin and member.is_superuser:
74
+ level = admin_level
75
+ if level is not None:
76
+ desired[member.mxid] = level
77
+ return desired
78
+
79
+
80
+ def merge_power_levels(
81
+ current_users: dict[str, int],
82
+ desired: dict[str, int],
83
+ managed_mxids: set[str],
84
+ ) -> dict[str, int]:
85
+ """Merge desired levels into the current ``users`` map, withdrawing stale managed entries.
86
+
87
+ Only ``managed_mxids`` are added/updated/removed; everyone else is left untouched. Returns a new
88
+ dict (the caller decides whether it changed before writing).
89
+ """
90
+ merged = dict(current_users)
91
+ for mxid in managed_mxids:
92
+ if mxid in desired:
93
+ merged[mxid] = desired[mxid]
94
+ else:
95
+ merged.pop(mxid, None)
96
+ return merged
@@ -0,0 +1,127 @@
1
+ """Group → room projection (pure logic).
2
+
3
+ Ported from the legacy ``Bot._get_authentik_groups_to_synapse_room_mappings`` and
4
+ ``_get_matrix_room_attrs_from_authentik_group``, split into side-effect-free functions so the
5
+ mapping rules (the valuable business logic per BATTLE_PLAN §3) can be unit-tested without any API.
6
+
7
+ Bugs fixed vs legacy:
8
+
9
+ * ``matrix_room_create_params_from_authentik_attribute`` is now split into a key path before lookup
10
+ (legacy passed the raw dotted string to ``get_nested_dict_val_by_path``, so it never matched).
11
+ * the create-params attribute default is ``attributes.chatroom_params`` (legacy ``attribute.…`` typo).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from onbot.config import MatrixDynamicRoomSettings, OnbotConfig
20
+ from onbot.identity import build_canonical
21
+ from onbot.logging import get_logger
22
+ from onbot.models import GroupRoomMap, MatrixRoom, RoomCreateAttributes
23
+ from onbot.utils import get_nested_dict_val_by_path
24
+
25
+ log = get_logger(__name__)
26
+
27
+
28
+ def filter_synced_groups(groups: list[dict[str, Any]], config: OnbotConfig) -> list[dict[str, Any]]:
29
+ """Apply the selective group→room rules (G2.5): ignore list, name prefix, parentage.
30
+
31
+ The attribute filter (``only_groups_with_attributes``) is applied at the Authentik query level,
32
+ so it is not repeated here.
33
+ """
34
+ settings = config.sync_matrix_rooms_based_on_authentik_groups
35
+ result = groups
36
+ if config.authentik_group_id_ignore_list:
37
+ ignore = set(config.authentik_group_id_ignore_list)
38
+ result = [g for g in result if g["pk"] not in ignore]
39
+ if settings.only_for_groupnames_starting_with:
40
+ prefix = settings.only_for_groupnames_starting_with
41
+ result = [g for g in result if g["name"].startswith(prefix)]
42
+ if settings.only_for_children_of_groups_with_uid:
43
+ parents = set(settings.only_for_children_of_groups_with_uid)
44
+ result = [g for g in result if g.get("parent") in parents]
45
+ return result
46
+
47
+
48
+ def resolve_room_settings(group_pk: str, config: OnbotConfig) -> MatrixDynamicRoomSettings:
49
+ """Merge per-group overrides (G6.7) over the default room settings."""
50
+ defaults = config.matrix_room_default_settings
51
+ override = config.per_authentik_group_pk_matrix_room_settings.get(group_pk)
52
+ if override is None:
53
+ return defaults
54
+ return MatrixDynamicRoomSettings.model_validate(defaults.model_dump() | override.model_dump())
55
+
56
+
57
+ def compute_room_attributes(
58
+ group: dict[str, Any], config: OnbotConfig, server_name: str
59
+ ) -> RoomCreateAttributes:
60
+ """Compute the desired Matrix room attributes for an Authentik group (G6.1-G6.6)."""
61
+ settings = resolve_room_settings(group["pk"], config)
62
+
63
+ alias_base = get_nested_dict_val_by_path(
64
+ group, settings.matrix_alias_from_authentik_attribute.split("."), fallback_val=None
65
+ )
66
+ if not alias_base:
67
+ alias_base = group["pk"]
68
+ alias = f"{settings.alias_prefix or ''}{alias_base}"
69
+ # Matrix room aliases keep things simple: drop dashes (legacy behaviour; preserves alias matching).
70
+ alias = alias.replace("-", "")
71
+
72
+ name = get_nested_dict_val_by_path(
73
+ group, settings.matrix_name_from_authentik_attribute.split("."), fallback_val=None
74
+ )
75
+ if settings.name_prefix and name and not name.startswith(settings.name_prefix):
76
+ name = f"{settings.name_prefix}{name}"
77
+
78
+ topic: str | None = None
79
+ if settings.matrix_topic_from_authentik_attribute:
80
+ topic = get_nested_dict_val_by_path(
81
+ group, settings.matrix_topic_from_authentik_attribute.split("."), fallback_val=None
82
+ )
83
+ if settings.topic_prefix:
84
+ topic = f"{settings.topic_prefix}{topic or ''}"
85
+
86
+ room_params = dict(settings.default_room_create_params or {})
87
+ if settings.matrix_room_create_params_from_authentik_attribute:
88
+ raw = get_nested_dict_val_by_path(
89
+ group,
90
+ settings.matrix_room_create_params_from_authentik_attribute.split("."),
91
+ fallback_val=None,
92
+ )
93
+ if raw:
94
+ try:
95
+ room_params = room_params | json.loads(raw)
96
+ except json.JSONDecodeError, TypeError:
97
+ log.warning("Group %s has invalid room-create-params JSON: %r", group["pk"], raw)
98
+
99
+ return RoomCreateAttributes(
100
+ alias=alias,
101
+ canonical_alias=build_canonical(alias, server_name, "#"),
102
+ name=name,
103
+ topic=topic,
104
+ room_params=room_params,
105
+ encrypted=settings.end2end_encryption_enabled,
106
+ )
107
+
108
+
109
+ def build_group_room_maps(
110
+ groups: list[dict[str, Any]],
111
+ actual_rooms: list[MatrixRoom],
112
+ config: OnbotConfig,
113
+ server_name: str,
114
+ ) -> list[GroupRoomMap]:
115
+ """Project filtered groups to desired rooms, pairing each with its actual room if present."""
116
+ rooms_by_alias = {r.canonical_alias: r for r in actual_rooms if r.canonical_alias}
117
+ maps: list[GroupRoomMap] = []
118
+ for group in filter_synced_groups(groups, config):
119
+ desired = compute_room_attributes(group, config, server_name)
120
+ maps.append(
121
+ GroupRoomMap(
122
+ authentik_group=group,
123
+ desired=desired,
124
+ room=rooms_by_alias.get(desired.canonical_alias),
125
+ )
126
+ )
127
+ return maps