oc_connect
Loading...
Searching...
No Matches
destinations.py
Go to the documentation of this file.
1"""Named Telegram destination metadata store.
2
3Dependencies
4------------
5- oc_connect.audit (timestamp, utc_now - the created_at_utc stamp of a destination)
6- oc_connect.workspace (state_dir - the workspace-anchored directory of the destination store)
7- pydantic (BaseModel, ConfigDict - the frozen strict TelegramDestination model)
8"""
9from __future__ import annotations
10
11import json
12import os
13import secrets as _secrets
14from collections.abc import Callable
15from datetime import datetime
16from pathlib import Path
17
18from pydantic import BaseModel, ConfigDict
19
20from ...audit import timestamp, utc_now
21from ...workspace import state_dir
22
23DESTINATION_FILENAME = "destinations.json"
24
25
26def default_destination_path(root: str | Path | None = None) -> Path:
27 """Return the workspace-anchored destination store path (CONNECT-02 ยง7.1)."""
28 return state_dir(root) / DESTINATION_FILENAME
29
30
31class TelegramDestination(BaseModel):
32 """Pinned operator identity and chat for one named Telegram target."""
33
34 model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
35
36 name: str
37 connector: str
38 chat_id: str
39 operator_account_id: str
40 credential_name: str
41 created_at_utc: str
42
43
44class DestinationFileError(ValueError):
45 """The destination store is unreadable or malformed."""
46
47
49 """Atomic JSON store for non-secret destination metadata."""
50
52 self,
53 path: str | Path | None = None,
54 *,
55 now: Callable[[], datetime] = utc_now,
56 root: str | Path | None = None,
57 ) -> None:
58 self.path = Path(path) if path is not None else default_destination_path(root)
59 self._now = now
60
61 def _load(self) -> dict[str, TelegramDestination]:
62 if not self.path.is_file():
63 return {}
64 try:
65 payload = json.loads(self.path.read_text(encoding="utf-8"))
66 return {
67 destination.name: destination
68 for destination in (
69 TelegramDestination.model_validate(raw)
70 for raw in payload.get("destinations", [])
71 )
72 }
73 except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
75 f"could not parse Telegram destinations at {self.path} "
76 f"({type(exc).__name__})"
77 ) from None
78
79 def _write(self, destinations: dict[str, TelegramDestination]) -> None:
80 self.path.parent.mkdir(parents=True, exist_ok=True)
81 temporary = self.path.with_name(
82 f".tmp-{self.path.name}-{_secrets.token_hex(8)}"
83 )
84 payload = {
85 "version": 1,
86 "destinations": [
87 destinations[key].model_dump(mode="json")
88 for key in sorted(destinations)
89 ],
90 }
91 try:
92 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
93 json.dump(payload, handle, indent=2, sort_keys=True)
94 handle.write("\n")
95 handle.flush()
96 os.fsync(handle.fileno())
97 os.replace(temporary, self.path)
98 finally:
99 if temporary.exists():
100 try:
101 temporary.unlink()
102 except OSError:
103 pass
104
106 self,
107 *,
108 name: str,
109 chat_id: str,
110 operator_account_id: str,
111 credential_name: str,
112 ) -> TelegramDestination:
113 """Create or replace one named destination."""
114 fields = (name, chat_id, operator_account_id, credential_name)
115 if not all(value.strip() for value in fields):
116 raise ValueError("destination fields must not be blank")
117 destination = TelegramDestination(
118 name=name,
119 connector="telegram",
120 chat_id=chat_id,
121 operator_account_id=operator_account_id,
122 credential_name=credential_name,
123 created_at_utc=timestamp(self._now()),
124 )
125 destinations = self._load()
126 destinations[name] = destination
127 self._write(destinations)
128 return destination
129
130 def get(self, name: str) -> TelegramDestination:
131 """Return a named destination or fail with its public name."""
132 try:
133 return self._load()[name]
134 except KeyError:
135 raise KeyError(f"Telegram destination {name!r} is not configured") from None
136
137 def list_destinations(self) -> tuple[TelegramDestination, ...]:
138 """Return destinations in stable name order."""
139 destinations = self._load()
140 return tuple(destinations[key] for key in sorted(destinations))
141
142
143__all__ = [
144 "DESTINATION_FILENAME",
145 "DestinationFileError",
146 "DestinationStore",
147 "TelegramDestination",
148 "default_destination_path",
149]
TelegramDestination record(self, *, str name, str chat_id, str operator_account_id, str credential_name)
None __init__(self, str|Path|None path=None, *, Callable[[], datetime] now=utc_now, str|Path|None root=None)
None _write(self, dict[str, TelegramDestination] destinations)
tuple[TelegramDestination,...] list_destinations(self)
Path default_destination_path(str|Path|None root=None)