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/events.py ADDED
@@ -0,0 +1,51 @@
1
+ """Tiny async signal bus (AD-4).
2
+
3
+ Explicit, in-process coupling between the bounded domains: the reconciler emits signals (e.g. a
4
+ user was synced) and onboarding (Phase 4) subscribes. Kept deliberately minimal — no broker, no
5
+ persistence — but designed so a domain could later move to its own process behind the same API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections import defaultdict
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from typing import Any
16
+
17
+ from onbot.logging import get_logger
18
+
19
+ log = get_logger(__name__)
20
+
21
+
22
+ class Signal(StrEnum):
23
+ user_synced = "user_synced"
24
+ drift_detected = "drift_detected"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Event:
29
+ signal: Signal
30
+ payload: dict[str, Any]
31
+
32
+
33
+ Handler = Callable[[Event], Awaitable[None]]
34
+
35
+
36
+ class EventBus:
37
+ def __init__(self) -> None:
38
+ self._handlers: dict[Signal, list[Handler]] = defaultdict(list)
39
+
40
+ def subscribe(self, signal: Signal, handler: Handler) -> None:
41
+ self._handlers[signal].append(handler)
42
+
43
+ async def emit(self, signal: Signal, **payload: Any) -> None:
44
+ handlers = self._handlers.get(signal, [])
45
+ if not handlers:
46
+ return
47
+ event = Event(signal=signal, payload=payload)
48
+ results = await asyncio.gather(*(h(event) for h in handlers), return_exceptions=True)
49
+ for result in results:
50
+ if isinstance(result, Exception):
51
+ log.exception("event handler for %s failed", signal, exc_info=result)
onbot/healthcheck.py ADDED
@@ -0,0 +1,134 @@
1
+ """Dependency health probe for ``onbot healthcheck`` (BATTLE_PLAN §5 Phase 8).
2
+
3
+ Probes the services the bot actually talks to — the Matrix CS API, the Synapse admin API, the
4
+ Authentik API, and (when configured) the MAS admin API — using the *real* configured credentials, so
5
+ the check verifies connectivity **and** authorization, not just that a port is open. Each probe is a
6
+ single lightweight authenticated request.
7
+
8
+ Exit code contract (suitable for a container ``HEALTHCHECK`` / orchestrator readiness probe):
9
+
10
+ * ``0`` — every required dependency answered successfully.
11
+ * ``1`` — at least one probe failed (unreachable, auth rejected, or unexpected response).
12
+
13
+ The bot user id from ``/whoami`` is compared against ``synapse_server.bot_user_id``; a mismatch is a
14
+ warning (the token authenticates as a different user than configured) but not a hard failure, since
15
+ the bot can still operate as whoever the token belongs to.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ from dataclasses import dataclass
22
+
23
+ from onbot.app import build_matrix_token_provider
24
+ from onbot.auth.token_provider import OAuth2ClientCredentialsTokenProvider
25
+ from onbot.clients.authentik import ApiClientAuthentik
26
+ from onbot.clients.mas_admin import ApiClientMasAdmin, mxid_localpart
27
+ from onbot.clients.matrix import ApiClientMatrix
28
+ from onbot.clients.synapse_admin import ApiClientSynapseAdmin
29
+ from onbot.config import OnbotConfig
30
+ from onbot.logging import get_logger
31
+
32
+ log = get_logger(__name__)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ProbeResult:
37
+ """Outcome of a single dependency probe."""
38
+
39
+ name: str
40
+ ok: bool
41
+ detail: str
42
+
43
+
44
+ async def _probe_matrix(matrix: ApiClientMatrix, expected_user_id: str) -> ProbeResult:
45
+ """Reach the CS API and confirm the bot token authenticates (``/whoami``)."""
46
+ whoami = await matrix.get_json("v3/account/whoami")
47
+ user_id = whoami.get("user_id", "")
48
+ if user_id != expected_user_id:
49
+ return ProbeResult(
50
+ "matrix-cs",
51
+ True,
52
+ f"reachable, but token is @{user_id} (config expects {expected_user_id})",
53
+ )
54
+ return ProbeResult("matrix-cs", True, f"authenticated as {user_id}")
55
+
56
+
57
+ async def _probe_synapse_admin(admin: ApiClientSynapseAdmin) -> ProbeResult:
58
+ """Confirm the admin token is authorized against the Synapse admin API."""
59
+ # A minimal authenticated read; ``guests=false`` is required under MSC3861/MAS (synapse_admin.py).
60
+ await admin.get_json("v2/users", params={"limit": 1, "guests": "false"})
61
+ return ProbeResult("synapse-admin", True, "admin API authorized")
62
+
63
+
64
+ async def _probe_authentik(authentik: ApiClientAuthentik) -> ProbeResult:
65
+ """Confirm the Authentik API token works."""
66
+ await authentik.get_json("core/users/", params={"page_size": 1})
67
+ return ProbeResult("authentik", True, "API token accepted")
68
+
69
+
70
+ async def _probe_mas_admin(mas: ApiClientMasAdmin, bot_user_id: str) -> ProbeResult:
71
+ """Confirm the MAS admin client-credentials token works (lifecycle enforcement path, §7 Q1)."""
72
+ # ``by-username`` returns the user or 404 (both prove auth); the lookup itself never mutates.
73
+ await mas.get_user_id_by_username(mxid_localpart(bot_user_id))
74
+ return ProbeResult("mas-admin", True, "admin API authorized")
75
+
76
+
77
+ async def run_healthcheck(config: OnbotConfig) -> int:
78
+ """Probe every configured dependency, log a line per result, and return an exit code."""
79
+ token_provider = build_matrix_token_provider(config.synapse_server)
80
+ authentik = ApiClientAuthentik(url=config.authentik_server.url, api_key=config.authentik_server.api_key)
81
+ admin = ApiClientSynapseAdmin(
82
+ server_url=config.synapse_server.server_url,
83
+ token_provider=token_provider,
84
+ admin_api_path=config.synapse_server.admin_api_path,
85
+ )
86
+ matrix = ApiClientMatrix(
87
+ server_url=config.synapse_server.server_url,
88
+ token_provider=token_provider,
89
+ server_name=config.synapse_server.server_name,
90
+ )
91
+ mas: ApiClientMasAdmin | None = None
92
+ if config.mas_admin is not None:
93
+ mas = ApiClientMasAdmin(
94
+ mas_url=config.mas_admin.url,
95
+ token_provider=OAuth2ClientCredentialsTokenProvider(
96
+ token_endpoint=f"{config.mas_admin.url.rstrip('/')}/oauth2/token",
97
+ client_id=config.mas_admin.client_id,
98
+ client_secret=config.mas_admin.client_secret,
99
+ scope="urn:mas:admin",
100
+ ),
101
+ )
102
+
103
+ probes = [
104
+ _probe_matrix(matrix, config.synapse_server.bot_user_id),
105
+ _probe_synapse_admin(admin),
106
+ _probe_authentik(authentik),
107
+ ]
108
+ names = ["matrix-cs", "synapse-admin", "authentik"]
109
+ if mas is not None:
110
+ probes.append(_probe_mas_admin(mas, config.synapse_server.bot_user_id))
111
+ names.append("mas-admin")
112
+
113
+ try:
114
+ results = await asyncio.gather(*probes, return_exceptions=True)
115
+ finally:
116
+ await authentik.aclose()
117
+ await admin.aclose()
118
+ await matrix.aclose()
119
+ if mas is not None:
120
+ await mas.aclose()
121
+
122
+ failed = False
123
+ for name, result in zip(names, results, strict=True):
124
+ if isinstance(result, ProbeResult):
125
+ log.info("healthcheck %-14s OK — %s", result.name, result.detail)
126
+ else:
127
+ failed = True
128
+ log.error("healthcheck %-14s FAIL — %r", name, result)
129
+
130
+ if failed:
131
+ log.error("healthcheck: one or more dependencies are unhealthy")
132
+ return 1
133
+ log.info("healthcheck: all dependencies healthy")
134
+ return 0
onbot/identity.py ADDED
@@ -0,0 +1,44 @@
1
+ """MXID / localpart mapping (AD-6 — the critical MAS integration contract).
2
+
3
+ Under MAS, accounts are auto-provisioned on first login and the MXID localpart is derived by MAS
4
+ from an upstream Authentik claim. The bot does **not** create accounts, but it must compute the
5
+ *same* MXID to match users to their Matrix accounts. The localpart source is configurable
6
+ (``sync_authentik_users_with_matrix_rooms.authentik_username_mapping_attribute``) and MUST agree
7
+ with the template MAS uses, otherwise users won't match (BATTLE_PLAN §7 Q2).
8
+
9
+ Shared by the reconciler (membership, power levels) and, later, onboarding (Phase 4).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Literal
15
+
16
+ from onbot.utils import get_nested_dict_val_by_path
17
+
18
+ SigilT = Literal["@", "#", "!"]
19
+
20
+
21
+ def build_canonical(local_part: str, server_name: str, sigil: SigilT = "@") -> str:
22
+ """Build a fully-qualified Matrix identifier: ``<sigil><local_part>:<server_name>``.
23
+
24
+ ``@`` → user ID, ``#`` → room alias, ``!`` → room/space ID.
25
+ """
26
+ return f"{sigil}{local_part}:{server_name}"
27
+
28
+
29
+ def compute_mxid(
30
+ authentik_user: dict[str, Any],
31
+ *,
32
+ username_attribute: str,
33
+ server_name: str,
34
+ ) -> str:
35
+ """Compute the deterministic MXID for an Authentik user (G1.2, AD-6).
36
+
37
+ ``username_attribute`` is a dotted path into the Authentik user object (e.g. ``username`` or
38
+ ``attributes.matrix_name``). Raises ``KeyError`` if the attribute is missing — a user we cannot
39
+ map deterministically must surface loudly, never be silently skipped.
40
+ """
41
+ local_part = get_nested_dict_val_by_path(authentik_user, username_attribute.split("."))
42
+ if local_part is None or local_part == "":
43
+ raise KeyError(username_attribute)
44
+ return build_canonical(str(local_part), server_name, "@")
@@ -0,0 +1,2 @@
1
+ """Quarantined account lifecycle (AD-5): deactivate/delete with cooldowns, dry-run + audit
2
+ defaults, highest test rigor. Implemented in Phase 5."""
@@ -0,0 +1,309 @@
1
+ """Quarantined account lifecycle (AD-5, G9.*): the scariest code in the repo.
2
+
3
+ When an Authentik account is disabled (or removed), the matching Matrix account must eventually be
4
+ locked out and, optionally, erased. This is destructive and irreversible past a point, so the module
5
+ is built around three safety principles:
6
+
7
+ * **Cooldowns absorb accidents (G12.3).** Detection only *marks* a user; nothing destructive happens
8
+ until ``deactivate_after_n_sec`` has elapsed, and erasure waits a further ``delete_after_n_sec``.
9
+ A user who reappears in Authentik before erasure is silently re-enabled (G9.6).
10
+ * **Dry-run + audit by default (Q6, AD-5).** Destructive effector calls are gated on an explicit
11
+ ``dry_run=False``; until an operator opts in, the manager only records bookkeeping timestamps and
12
+ logs an audit line describing what *would* happen.
13
+ * **Pure decision, isolated effects.** :func:`decide_account_action` is a side-effect-free state
14
+ machine (exhaustively unit-tested); all I/O lives behind the :class:`LifecycleEffectors` and
15
+ :class:`LifecycleLedgerStore` seams.
16
+
17
+ **Two-stage semantics (legacy-faithful, GOALS-aligned).** Stage one (``deactivate_after_n_sec``)
18
+ *logs the user out* — revoking every device/session (G9.2). Under the MAS topology (AD-6) this is the
19
+ effective lock-out: re-login flows through MAS→Authentik, which already blocks the disabled upstream
20
+ user. It is also reversible, which keeps the G9.6 re-enable window open. Stage two
21
+ (``delete_after_n_sec``) *erases* the account (G9.4) and optionally its uploaded media (G9.5).
22
+
23
+ Whether MAS *itself* propagates the upstream disable (revoking sessions / locking the account) is an
24
+ open question for the maintainer (BATTLE_PLAN §7 Q1); this module is the enforcement backstop either
25
+ way.
26
+
27
+ There is no database (AD-1): the per-user bookkeeping (mark/disable timestamps) is persisted as a
28
+ single blob in the bot's Matrix *account data*, keyed by MXID — decoupled from the onboarding DM.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from collections.abc import Callable
35
+ from enum import StrEnum
36
+ from typing import Any, Protocol, runtime_checkable
37
+
38
+ from pydantic import BaseModel, Field
39
+
40
+ from onbot.config import OnbotConfig
41
+ from onbot.logging import get_logger
42
+ from onbot.reconciler.state import SCHEMA_VERSION, event_type_name
43
+
44
+ log = get_logger(__name__)
45
+ # Dedicated audit channel so operators can route the irreversible-action trail separately.
46
+ audit = get_logger("onbot.lifecycle.audit")
47
+
48
+
49
+ def lifecycle_account_data_type(server_name: str) -> str:
50
+ """Account-data type holding the lifecycle ledger, e.g. ``org.company.onbot.lifecycle``."""
51
+ return event_type_name(server_name, "lifecycle")
52
+
53
+
54
+ class LifecycleAction(StrEnum):
55
+ none = "none" # active user, no bookkeeping to do
56
+ reenable = "reenable" # previously marked/disabled but active again → clear (G9.6)
57
+ mark = "mark" # newly detected orphan → start the cooldown clock (no destructive action)
58
+ logout = "logout" # cooldown elapsed → revoke sessions (G9.2)
59
+ erase = "erase" # further cooldown elapsed → deactivate/erase account (G9.4/G9.5)
60
+ wait = "wait" # in a cooldown window → do nothing yet
61
+
62
+
63
+ def decide_account_action(
64
+ *,
65
+ is_orphaned: bool,
66
+ marked_ts: float | None,
67
+ disabled_ts: float | None,
68
+ now: float,
69
+ deactivate_after_n_sec: float,
70
+ delete_after_n_sec: float | None,
71
+ ) -> LifecycleAction:
72
+ """Pure state machine: given a user's orphan status and timestamps, decide the next action.
73
+
74
+ ``marked_ts`` is when the user was first seen orphaned; ``disabled_ts`` is when they were logged
75
+ out. ``delete_after_n_sec=None`` disables erasure entirely (a logged-out user simply stays so).
76
+ """
77
+ has_state = marked_ts is not None or disabled_ts is not None
78
+ if not is_orphaned:
79
+ return LifecycleAction.reenable if has_state else LifecycleAction.none
80
+ if marked_ts is None:
81
+ return LifecycleAction.mark
82
+ if disabled_ts is None:
83
+ if now - marked_ts >= deactivate_after_n_sec:
84
+ return LifecycleAction.logout
85
+ return LifecycleAction.wait
86
+ if delete_after_n_sec is not None and now - disabled_ts >= delete_after_n_sec:
87
+ return LifecycleAction.erase
88
+ return LifecycleAction.wait
89
+
90
+
91
+ class LifecycleEntry(BaseModel):
92
+ """Per-user bookkeeping for the lifecycle state machine."""
93
+
94
+ marked_for_disabling_timestamp: float | None = None
95
+ disabled_user_timestamp: float | None = None
96
+
97
+
98
+ class LifecycleLedger(BaseModel):
99
+ """Versioned blob of every tracked user's lifecycle state (stored in bot account data)."""
100
+
101
+ schema_version: int = SCHEMA_VERSION
102
+ entries: dict[str, LifecycleEntry] = Field(default_factory=dict)
103
+
104
+
105
+ @runtime_checkable
106
+ class LifecycleEffectors(Protocol):
107
+ """The destructive Matrix/Synapse operations, isolated behind a seam (separate boundary, AD-5)."""
108
+
109
+ async def logout(self, mxid: str) -> None:
110
+ """Revoke every session for ``mxid`` (G9.2). Reversible (see :meth:`reenable`)."""
111
+
112
+ async def reenable(self, mxid: str) -> None:
113
+ """Reverse a non-destructive logout when the user returns (G9.6). May be a no-op."""
114
+
115
+ async def erase(self, mxid: str, *, delete_media: bool) -> None:
116
+ """Deactivate/erase the account (G9.4) and optionally its uploaded media (G9.5)."""
117
+
118
+
119
+ class AdminApiLifecycleEffectors:
120
+ """Concrete effectors over the Synapse Admin API.
121
+
122
+ NOTE (BATTLE_PLAN §7 Q1, verified by the Phase 7b harness): under MAS these do **not** revoke a
123
+ live MAS-issued session — deleting devices/deactivating in Synapse leaves the MAS token valid.
124
+ Use :class:`MasLifecycleEffectors` for real enforcement under the MAS topology (ADR-0006). These
125
+ remain correct for non-MAS Synapse deployments.
126
+ """
127
+
128
+ def __init__(self, admin: Any) -> None:
129
+ self.admin = admin
130
+
131
+ async def logout(self, mxid: str) -> None:
132
+ await self.admin.logout_account(mxid)
133
+
134
+ async def reenable(self, mxid: str) -> None:
135
+ # Synapse logout deletes devices; there is nothing to restore — the user logs in afresh.
136
+ return None
137
+
138
+ async def erase(self, mxid: str, *, delete_media: bool) -> None:
139
+ if delete_media:
140
+ await self.admin.delete_user_media(mxid)
141
+ await self.admin.deactivate_account(mxid, erase=delete_media)
142
+
143
+
144
+ class MasLifecycleEffectors:
145
+ """Effectors that enforce lockout through the **MAS admin API** (ADR-0006, §7 Q1).
146
+
147
+ This is the path that actually works under MAS: ``logout`` locks the MAS user (revoking live
148
+ sessions, reversibly), ``reenable`` unlocks them when their upstream account returns (G9.6), and
149
+ ``erase`` deactivates the account irreversibly. Media deletion still goes through the Synapse
150
+ admin API (MAS does not own media).
151
+ """
152
+
153
+ def __init__(self, mas_admin: Any, synapse_admin: Any | None = None) -> None:
154
+ self.mas = mas_admin
155
+ self.synapse_admin = synapse_admin
156
+
157
+ async def _resolve(self, mxid: str) -> str | None:
158
+ from onbot.clients.mas_admin import mxid_localpart
159
+
160
+ uid: str | None = await self.mas.get_user_id_by_username(mxid_localpart(mxid))
161
+ if uid is None:
162
+ log.warning("no MAS user for %s; cannot enforce lifecycle action", mxid)
163
+ return uid
164
+
165
+ async def logout(self, mxid: str) -> None:
166
+ uid = await self._resolve(mxid)
167
+ if uid is not None:
168
+ await self.mas.lock_user(uid)
169
+
170
+ async def reenable(self, mxid: str) -> None:
171
+ uid = await self._resolve(mxid)
172
+ if uid is not None:
173
+ await self.mas.unlock_user(uid)
174
+
175
+ async def erase(self, mxid: str, *, delete_media: bool) -> None:
176
+ uid = await self._resolve(mxid)
177
+ if uid is not None:
178
+ await self.mas.deactivate_user(uid)
179
+ if delete_media and self.synapse_admin is not None:
180
+ await self.synapse_admin.delete_user_media(mxid)
181
+
182
+
183
+ @runtime_checkable
184
+ class LifecycleLedgerStore(Protocol):
185
+ async def load(self) -> LifecycleLedger: ...
186
+
187
+ async def save(self, ledger: LifecycleLedger) -> None: ...
188
+
189
+
190
+ class MatrixAccountDataLedgerStore:
191
+ """Persists the ledger as a single account-data blob on the bot user (no database, AD-1)."""
192
+
193
+ def __init__(self, client: Any, bot_id: str, server_name: str) -> None:
194
+ self.client = client
195
+ self.bot_id = bot_id
196
+ self.data_type = lifecycle_account_data_type(server_name)
197
+
198
+ async def load(self) -> LifecycleLedger:
199
+ raw = await self.client.get_account_data(self.bot_id, self.data_type)
200
+ if not raw:
201
+ return LifecycleLedger()
202
+ return LifecycleLedger.model_validate(raw)
203
+
204
+ async def save(self, ledger: LifecycleLedger) -> None:
205
+ await self.client.set_account_data(self.bot_id, self.data_type, ledger.model_dump(mode="json"))
206
+
207
+
208
+ class LifecycleOutcome(BaseModel):
209
+ """What the manager decided for one user this pass (returned for tests/observability)."""
210
+
211
+ mxid: str
212
+ action: LifecycleAction
213
+ dry_run: bool
214
+
215
+
216
+ class AccountLifecycleManager:
217
+ """Orchestrates the lifecycle pass: load ledger → decide per user → act → persist.
218
+
219
+ Invoked only by the reconciler with the set of *orphaned* MXIDs (Matrix accounts whose Authentik
220
+ user is disabled/gone). Any MXID already in the ledger that is no longer orphaned is re-enabled.
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ config: OnbotConfig,
226
+ store: LifecycleLedgerStore,
227
+ effectors: LifecycleEffectors,
228
+ *,
229
+ clock: Callable[[], float] = time.time,
230
+ ) -> None:
231
+ self.cfg = config.sync_authentik_users_with_matrix_rooms.deactivate_disabled_authentik_users_in_matrix
232
+ self.store = store
233
+ self.effectors = effectors
234
+ self.clock = clock
235
+
236
+ async def reconcile_accounts(self, orphaned_mxids: set[str]) -> list[LifecycleOutcome]:
237
+ if not self.cfg.enabled:
238
+ return []
239
+ ledger = await self.store.load()
240
+ now = self.clock()
241
+ outcomes: list[LifecycleOutcome] = []
242
+ changed = False
243
+
244
+ # Decide for every orphan and for anyone we are already tracking (so they can be re-enabled).
245
+ for mxid in sorted(orphaned_mxids | set(ledger.entries)):
246
+ entry = ledger.entries.get(mxid)
247
+ action = decide_account_action(
248
+ is_orphaned=mxid in orphaned_mxids,
249
+ marked_ts=entry.marked_for_disabling_timestamp if entry else None,
250
+ disabled_ts=entry.disabled_user_timestamp if entry else None,
251
+ now=now,
252
+ deactivate_after_n_sec=self.cfg.deactivate_after_n_sec,
253
+ delete_after_n_sec=self.cfg.delete_after_n_sec,
254
+ )
255
+ if action is LifecycleAction.none or action is LifecycleAction.wait:
256
+ continue
257
+ changed |= await self._apply(ledger, mxid, action, now)
258
+ outcomes.append(LifecycleOutcome(mxid=mxid, action=action, dry_run=self.cfg.dry_run))
259
+
260
+ if changed:
261
+ await self.store.save(ledger)
262
+ return outcomes
263
+
264
+ async def _apply(self, ledger: LifecycleLedger, mxid: str, action: LifecycleAction, now: float) -> bool:
265
+ """Carry out one decision; return whether the ledger changed. Destructive ops respect dry-run."""
266
+ if action is LifecycleAction.reenable:
267
+ # Restorative (only ever grants access back), so it runs even under dry-run — it undoes a
268
+ # prior real logout/lock. A no-op when nothing was locked.
269
+ await self.effectors.reenable(mxid)
270
+ ledger.entries.pop(mxid, None)
271
+ audit.info("re-enabled %s (Authentik account active again); cleared lifecycle state", mxid)
272
+ return True
273
+
274
+ if action is LifecycleAction.mark:
275
+ # Bookkeeping only — harmless to persist even in dry-run, and it starts the cooldown clock.
276
+ ledger.entries[mxid] = LifecycleEntry(marked_for_disabling_timestamp=now)
277
+ audit.info(
278
+ "marked %s for lifecycle action (Authentik account disabled); logout in %ss",
279
+ mxid,
280
+ self.cfg.deactivate_after_n_sec,
281
+ )
282
+ return True
283
+
284
+ if action is LifecycleAction.logout:
285
+ if self.cfg.dry_run:
286
+ audit.warning("DRY-RUN: would log out %s (revoke all sessions)", mxid)
287
+ return False
288
+ await self.effectors.logout(mxid)
289
+ entry = ledger.entries.setdefault(mxid, LifecycleEntry())
290
+ entry.disabled_user_timestamp = now
291
+ audit.warning(
292
+ "logged out %s (revoked all sessions); erase in %ss", mxid, self.cfg.delete_after_n_sec
293
+ )
294
+ return True
295
+
296
+ # action is LifecycleAction.erase
297
+ if self.cfg.dry_run:
298
+ audit.warning(
299
+ "DRY-RUN: would erase %s (deactivate account, delete_media=%s)",
300
+ mxid,
301
+ self.cfg.include_user_media_on_delete,
302
+ )
303
+ return False
304
+ await self.effectors.erase(mxid, delete_media=self.cfg.include_user_media_on_delete)
305
+ ledger.entries.pop(mxid, None)
306
+ audit.warning(
307
+ "erased %s (account deactivated, media=%s)", mxid, self.cfg.include_user_media_on_delete
308
+ )
309
+ return True
onbot/logging.py ADDED
@@ -0,0 +1,26 @@
1
+ """Structured logging setup.
2
+
3
+ A thin, dependency-light wrapper over the stdlib ``logging`` module. Phase 2 keeps this
4
+ minimal; a structured-logging backend can be slotted in later without changing call sites.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+
12
+ _DEFAULT_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
13
+
14
+
15
+ def configure_logging(level: str | int | None = None) -> None:
16
+ """Configure root logging once, honouring ``ONBOT_LOG_LEVEL`` when ``level`` is unset."""
17
+ if level is None:
18
+ level = os.environ.get("ONBOT_LOG_LEVEL", "INFO")
19
+ if isinstance(level, str):
20
+ level = logging.getLevelName(level.upper())
21
+ logging.basicConfig(level=level, format=_DEFAULT_FORMAT)
22
+
23
+
24
+ def get_logger(name: str) -> logging.Logger:
25
+ """Return a module-scoped logger; prefer ``get_logger(__name__)`` at call sites."""
26
+ return logging.getLogger(name)
onbot/media.py ADDED
@@ -0,0 +1,46 @@
1
+ """Remote-URL → Matrix media upload with deduplication (G10.1, G10.2, Phase 6).
2
+
3
+ Avatars (bot, rooms, the space) are configured as plain HTTP(S) URLs. To use them in Matrix they
4
+ must be fetched and uploaded to the homeserver's media repo, which returns an ``mxc://`` URI. This
5
+ helper does that and **deduplicates by source URL within a run**, so the same avatar URL is fetched
6
+ and uploaded at most once (the legacy bot re-uploaded on every tick). Uploads go through the
7
+ authenticated media endpoint on :class:`~onbot.clients.matrix.ApiClientMatrix` (MSC3916).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import httpx
13
+
14
+ from onbot.clients.matrix import ApiClientMatrix
15
+ from onbot.logging import get_logger
16
+
17
+ log = get_logger(__name__)
18
+
19
+ _DEFAULT_CONTENT_TYPE = "application/octet-stream"
20
+
21
+
22
+ class MediaUploader:
23
+ def __init__(self, client: ApiClientMatrix, *, http_client: httpx.AsyncClient | None = None) -> None:
24
+ self.client = client
25
+ self._http = http_client or httpx.AsyncClient(timeout=30.0, follow_redirects=True)
26
+ self._owns_http = http_client is None
27
+ # Source URL -> mxc URI, so a repeated URL uploads only once (G10.2).
28
+ self._cache: dict[str, str] = {}
29
+
30
+ async def upload_from_url(self, url: str) -> str:
31
+ """Fetch ``url`` and upload it, returning the ``mxc://`` URI (cached per source URL)."""
32
+ cached = self._cache.get(url)
33
+ if cached is not None:
34
+ return cached
35
+ response = await self._http.get(url)
36
+ response.raise_for_status()
37
+ raw_type = response.headers.get("content-type", _DEFAULT_CONTENT_TYPE).split(";")[0].strip()
38
+ content_type = raw_type or _DEFAULT_CONTENT_TYPE
39
+ mxc = await self.client.upload_media(response.content, content_type=content_type)
40
+ self._cache[url] = mxc
41
+ log.info("uploaded avatar %s -> %s", url, mxc)
42
+ return mxc
43
+
44
+ async def aclose(self) -> None:
45
+ if self._owns_http:
46
+ await self._http.aclose()