audex 1.0.7a3__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.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+
6
+ class SessionDict(t.TypedDict):
7
+ """Session data structure."""
8
+
9
+ id: str
10
+ doctor_id: str
11
+ patient_name: str | None
12
+ clinic_number: str | None
13
+ medical_record_number: str | None
14
+ diagnosis: str | None
15
+ status: str
16
+ started_at: str | None
17
+ ended_at: str | None
18
+ created_at: str
19
+
20
+
21
+ class UtteranceDict(t.TypedDict):
22
+ """Utterance data structure."""
23
+
24
+ id: str
25
+ sequence: int
26
+ speaker: str
27
+ text: str
28
+ confidence: float | None
29
+ start_time_ms: int
30
+ end_time_ms: int
31
+ duration_ms: int
32
+ timestamp: str
33
+
34
+
35
+ class SegmentDict(t.TypedDict):
36
+ """Segment data structure."""
37
+
38
+ id: str
39
+ sequence: int
40
+ audio_key: str
41
+ started_at: str
42
+ ended_at: str | None
43
+ duration_ms: int | None
44
+
45
+
46
+ class SessionExportData(t.TypedDict):
47
+ """Complete session export data."""
48
+
49
+ session: SessionDict
50
+ utterances: list[UtteranceDict]
51
+ segments: list[SegmentDict]
52
+
53
+
54
+ class ConversationJSON(t.TypedDict):
55
+ """conversation.json format."""
56
+
57
+ session: SessionDict
58
+ utterances: list[UtteranceDict]
59
+ total_utterances: int
60
+ total_segments: int
61
+
62
+
63
+ class AudioMetadataItem(t.TypedDict):
64
+ """Audio file metadata item."""
65
+
66
+ filename: str
67
+ sequence: int
68
+ duration_ms: int | None
69
+ started_at: str
70
+ ended_at: str | None
71
+
72
+
73
+ class AudioMetadataJSON(t.TypedDict):
74
+ """audio/metadata.json format."""
75
+
76
+ session_id: str
77
+ total_segments: int
78
+ segments: list[AudioMetadataItem]
79
+
80
+
81
+ class SessionListResponse(t.TypedDict):
82
+ """API response for session list."""
83
+
84
+ sessions: list[SessionDict]
85
+ total: int
86
+ page: int
87
+ page_size: int
88
+
89
+
90
+ class ErrorResponse(t.TypedDict):
91
+ """API error response."""
92
+
93
+ error: str
94
+ details: str | None
95
+
96
+
97
+ class ExportMultipleRequest(t.TypedDict):
98
+ """Request body for exporting multiple sessions."""
99
+
100
+ session_ids: list[str]
101
+
102
+
103
+ class LoginRequest(t.TypedDict):
104
+ """Login request body."""
105
+
106
+ eid: str
107
+ password: str
108
+
109
+
110
+ class LoginResponse(t.TypedDict):
111
+ """Login response."""
112
+
113
+ success: bool
114
+ doctor_id: str | None
115
+ doctor_name: str | None
116
+
117
+
118
+ class DoctorSessionData(t.TypedDict):
119
+ """Doctor session data stored in cookie."""
120
+
121
+ doctor_id: str
122
+ eid: str
123
+ doctor_name: str
audex/lib/session.py ADDED
@@ -0,0 +1,503 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import datetime
6
+ import hashlib
7
+ import json
8
+ import pathlib
9
+ import tempfile
10
+ import typing as t
11
+
12
+ from audex import __title__
13
+ from audex import utils
14
+ from audex.exceptions import AudexError
15
+ from audex.helper.mixin import AsyncContextMixin
16
+ from audex.helper.mixin import LoggingMixin
17
+
18
+
19
+ class SessionData(t.NamedTuple):
20
+ """Session data structure.
21
+
22
+ Attributes:
23
+ doctor_id: Doctor's unique identifier.
24
+ doctor_name: Doctor's display name.
25
+ eid: Doctor's eid.
26
+ created_at: Session creation timestamp (ISO format).
27
+ expires_at: Session expiration timestamp (ISO format).
28
+ """
29
+
30
+ doctor_id: str
31
+ doctor_name: str | None
32
+ eid: str
33
+ created_at: str
34
+ expires_at: str
35
+
36
+ def to_dict(self) -> dict[str, str | None]:
37
+ """Convert to dictionary for JSON serialization."""
38
+ return {
39
+ "doctor_id": self.doctor_id,
40
+ "doctor_name": self.doctor_name,
41
+ "eid": self.eid,
42
+ "created_at": self.created_at,
43
+ "expires_at": self.expires_at,
44
+ }
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: dict[str, str]) -> t.Self:
48
+ """Create from dictionary."""
49
+ return cls(
50
+ doctor_id=data["doctor_id"],
51
+ doctor_name=data["doctor_name"],
52
+ eid=data["eid"],
53
+ created_at=data["created_at"],
54
+ expires_at=data["expires_at"],
55
+ )
56
+
57
+
58
+ class SessionManager(LoggingMixin, AsyncContextMixin):
59
+ """Secure local session manager with automatic state management.
60
+
61
+ This manager maintains session state automatically - no need to manually
62
+ manage tokens. Session persists across application restarts until it
63
+ expires. Uses system temp directory with encryption for security.
64
+
65
+ Security features:
66
+ 1. Uses system temp directory (auto-cleaned on reboot)
67
+ 2. File permissions restricted to current user (0o600)
68
+ 3. Session data encrypted with machine-specific key
69
+ 4. Automatic expiration and cleanup
70
+ 5. Process-specific session binding
71
+
72
+ Attributes:
73
+ session_dir: Directory for session files (in system temp).
74
+ session_file: Path to the encrypted session file.
75
+ ttl: Default time-to-live for sessions.
76
+
77
+ Example:
78
+ ```python
79
+ # Initialize manager
80
+ manager = SessionManager(
81
+ app_name="audex",
82
+ default_ttl=timedelta(hours=8),
83
+ )
84
+
85
+ await manager.init()
86
+
87
+ # Login
88
+ await manager.login(
89
+ doctor_id="doctor-abc123",
90
+ doctor_name="张医生",
91
+ eid="dr_zhang",
92
+ )
93
+
94
+ # Check if logged in (no token needed!)
95
+ if await manager.is_logged_in():
96
+ session = await manager.get_session()
97
+ print(f"Logged in as: {session.doctor_name}")
98
+
99
+ # Works across app restarts (if not expired)
100
+ # ... restart app ...
101
+ if await manager.is_logged_in():
102
+ print("Auto-logged in!")
103
+
104
+ # Logout
105
+ await manager.logout()
106
+
107
+ # Cleanup
108
+ await manager.close()
109
+ ```
110
+ """
111
+
112
+ __logtag__ = "audex.lib.session"
113
+
114
+ def __init__(
115
+ self,
116
+ app_name: str = __title__,
117
+ *,
118
+ ttl: datetime.timedelta = datetime.timedelta(hours=8),
119
+ ) -> None:
120
+ super().__init__()
121
+ self.app_name = app_name
122
+ self.ttl = ttl
123
+
124
+ # Use system temp directory
125
+ self.session_dir = pathlib.Path(tempfile.gettempdir()) / f".{app_name}_session"
126
+ self.session_file = self.session_dir / "session.enc"
127
+
128
+ # Machine-specific encryption key (derived from machine ID)
129
+ self._encryption_key = self._generate_machine_key()
130
+
131
+ self._cleanup_task: asyncio.Task[None] | None = None
132
+ self._lock = asyncio.Lock()
133
+
134
+ async def init(self) -> None:
135
+ """Initialize the session manager.
136
+
137
+ Creates session directory with restricted permissions and starts
138
+ automatic cleanup task.
139
+ """
140
+ # Create session directory with restricted permissions
141
+ self.session_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
142
+
143
+ # Start automatic cleanup task
144
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
145
+
146
+ self.logger.info(
147
+ "Session manager initialized",
148
+ session_dir=str(self.session_dir),
149
+ ttl_hours=self.ttl.total_seconds() / 3600,
150
+ )
151
+
152
+ async def close(self) -> None:
153
+ """Close the session manager.
154
+
155
+ Stops the cleanup task. Session file is preserved for next
156
+ startup.
157
+ """
158
+ if self._cleanup_task:
159
+ self._cleanup_task.cancel()
160
+ with contextlib.suppress(asyncio.CancelledError):
161
+ await self._cleanup_task
162
+ self._cleanup_task = None
163
+
164
+ self.logger.info("Session manager closed")
165
+
166
+ async def login(
167
+ self,
168
+ doctor_id: str,
169
+ eid: str,
170
+ *,
171
+ doctor_name: str | None = None,
172
+ ttl: datetime.timedelta | None = None,
173
+ ) -> SessionData:
174
+ """Create a login session.
175
+
176
+ If a session already exists, it will be replaced with the new one.
177
+ Session persists across application restarts until expiration.
178
+
179
+ Args:
180
+ doctor_id: Doctor's unique identifier.
181
+ doctor_name: Doctor's display name.
182
+ eid: Doctor's eid.
183
+ ttl: Time-to-live for the session. If None, uses default.
184
+
185
+ Returns:
186
+ Created SessionData.
187
+
188
+ Example:
189
+ ```python
190
+ session = await manager.login(
191
+ doctor_id="doctor-abc123",
192
+ doctor_name="张医生",
193
+ eid="dr_zhang",
194
+ )
195
+ print(f"Logged in as: {session.doctor_name}")
196
+ ```
197
+ """
198
+ async with self._lock:
199
+ ttl = ttl or self.ttl
200
+ now = utils.utcnow()
201
+ expires_at = now + ttl
202
+
203
+ session_data = SessionData(
204
+ doctor_id=doctor_id,
205
+ doctor_name=doctor_name,
206
+ eid=eid,
207
+ created_at=now.isoformat(),
208
+ expires_at=expires_at.isoformat(),
209
+ )
210
+
211
+ # Encrypt and write session data
212
+ await self._write_session(session_data)
213
+
214
+ self.logger.bind(
215
+ doctor_id=doctor_id,
216
+ eid=eid,
217
+ expires_at=expires_at.isoformat(),
218
+ ).info(f"Session created for {eid}")
219
+
220
+ return session_data
221
+
222
+ async def logout(self) -> bool:
223
+ """Logout by deleting the session.
224
+
225
+ Returns:
226
+ True if session was deleted, False if no session exists.
227
+
228
+ Example:
229
+ ```python
230
+ logged_out = await manager.logout()
231
+ if logged_out:
232
+ print("Successfully logged out")
233
+ ```
234
+ """
235
+ async with self._lock:
236
+ if not self.session_file.exists():
237
+ return False
238
+
239
+ try:
240
+ self.session_file.unlink()
241
+ self.logger.info("Session deleted (logout)")
242
+ return True
243
+ except Exception as e:
244
+ self.logger.error(f"Failed to delete session: {e}", exc_info=True)
245
+ return False
246
+
247
+ async def is_logged_in(self) -> bool:
248
+ """Check if there's an active (non-expired) session.
249
+
250
+ Returns:
251
+ True if there's an active session, False otherwise.
252
+
253
+ Example:
254
+ ```python
255
+ if await manager.is_logged_in():
256
+ print("User is logged in")
257
+ else:
258
+ print("Please login")
259
+ ```
260
+ """
261
+ session = await self.get_session()
262
+ return session is not None
263
+
264
+ async def get_session(self) -> SessionData | None:
265
+ """Get current session if exists and not expired.
266
+
267
+ Returns:
268
+ SessionData if session exists and is valid, None otherwise.
269
+
270
+ Example:
271
+ ```python
272
+ session = await manager.get_session()
273
+ if session:
274
+ print(f"Logged in as: {session.doctor_name}")
275
+ print(f"Doctor ID: {session.doctor_id}")
276
+ else:
277
+ print("No active session")
278
+ ```
279
+ """
280
+ async with self._lock:
281
+ if not self.session_file.exists():
282
+ return None
283
+
284
+ try:
285
+ # Read and decrypt session data
286
+ session_data = await self._read_session()
287
+ if session_data is None:
288
+ return None
289
+
290
+ # Check if expired
291
+ expires_at = datetime.datetime.fromisoformat(session_data.expires_at)
292
+ if utils.utcnow() >= expires_at:
293
+ self.logger.debug("Session expired, removing file")
294
+ self.session_file.unlink()
295
+ return None
296
+
297
+ return session_data
298
+
299
+ except Exception as e:
300
+ self.logger.warning(f"Failed to read session: {e}", exc_info=True)
301
+ # If data is corrupted, remove file
302
+ if self.session_file.exists():
303
+ self.session_file.unlink()
304
+ return None
305
+
306
+ async def get_doctor_id(self) -> str | None:
307
+ """Get current logged-in doctor's ID.
308
+
309
+ Returns:
310
+ Doctor ID if logged in, None otherwise.
311
+
312
+ Example:
313
+ ```python
314
+ doctor_id = await manager.get_doctor_id()
315
+ if doctor_id:
316
+ print(f"Current doctor: {doctor_id}")
317
+ ```
318
+ """
319
+ session = await self.get_session()
320
+ return session.doctor_id if session else None
321
+
322
+ async def extend_session(self, extra_ttl: datetime.timedelta) -> bool:
323
+ """Extend current session expiration time.
324
+
325
+ Args:
326
+ extra_ttl: Additional time to add to expiration.
327
+
328
+ Returns:
329
+ True if session was extended, False if no session exists.
330
+
331
+ Example:
332
+ ```python
333
+ # Extend by 2 hours
334
+ extended = await manager.extend_session(timedelta(hours=2))
335
+ if extended:
336
+ print("Session extended")
337
+ ```
338
+ """
339
+ async with self._lock:
340
+ session = await self.get_session()
341
+ if session is None:
342
+ return False
343
+
344
+ # Calculate new expiration
345
+ current_expires = datetime.datetime.fromisoformat(session.expires_at)
346
+ new_expires = current_expires + extra_ttl
347
+
348
+ # Create updated session
349
+ updated_session = SessionData(
350
+ doctor_id=session.doctor_id,
351
+ doctor_name=session.doctor_name,
352
+ eid=session.eid,
353
+ created_at=session.created_at,
354
+ expires_at=new_expires.isoformat(),
355
+ )
356
+
357
+ await self._write_session(updated_session)
358
+ self.logger.info(
359
+ "Session extended",
360
+ new_expires_at=new_expires.isoformat(),
361
+ )
362
+
363
+ return True
364
+
365
+ async def _read_session(self) -> SessionData | None:
366
+ """Read and decrypt session from file.
367
+
368
+ Returns:
369
+ SessionData if successful, None otherwise.
370
+ """
371
+ try:
372
+ encrypted_data = self.session_file.read_bytes()
373
+ decrypted_json = self._decrypt_data(encrypted_data)
374
+ data = json.loads(decrypted_json)
375
+ return SessionData.from_dict(data)
376
+ except Exception as e:
377
+ self.logger.warning(f"Failed to decrypt session: {e}")
378
+ return None
379
+
380
+ async def _write_session(self, session_data: SessionData) -> None:
381
+ """Encrypt and write session to file.
382
+
383
+ Args:
384
+ session_data: Session data to write.
385
+ """
386
+ json_data = json.dumps(session_data.to_dict())
387
+ encrypted_data = self._encrypt_data(json_data)
388
+
389
+ self.session_file.write_bytes(encrypted_data)
390
+ self.session_file.chmod(0o600) # Restrict to owner only
391
+
392
+ async def _cleanup_loop(self) -> None:
393
+ """Automatic cleanup loop that runs every 5 minutes.
394
+
395
+ Checks for expired sessions and removes them.
396
+ """
397
+ while True:
398
+ try:
399
+ await asyncio.sleep(300) # Check every 5 minutes
400
+
401
+ if self.session_file.exists():
402
+ session = await self.get_session()
403
+ if session is None:
404
+ # File exists but session is invalid/expired
405
+ self.logger.debug("Auto-cleanup: removed expired/invalid session")
406
+
407
+ except asyncio.CancelledError:
408
+ break
409
+ except Exception as e:
410
+ self.logger.error(f"Error in cleanup loop: {e}", exc_info=True)
411
+
412
+ def _generate_machine_key(self) -> bytes:
413
+ """Generate machine-specific encryption key.
414
+
415
+ Uses machine ID and app name to create a deterministic key that's
416
+ unique to this machine and application.
417
+
418
+ Returns:
419
+ 32-byte encryption key.
420
+ """
421
+ # Try to get machine ID from various sources
422
+ machine_id = None
423
+
424
+ # Try /etc/machine-id (Linux)
425
+ machine_id_file = pathlib.Path("/etc/machine-id")
426
+ if machine_id_file.exists():
427
+ with contextlib.suppress(Exception):
428
+ machine_id = machine_id_file.read_text().strip()
429
+
430
+ # Try /var/lib/dbus/machine-id (Linux alternative)
431
+ if machine_id is None:
432
+ dbus_id_file = pathlib.Path("/var/lib/dbus/machine-id")
433
+ if dbus_id_file.exists():
434
+ with contextlib.suppress(Exception):
435
+ machine_id = dbus_id_file.read_text().strip()
436
+
437
+ # Fallback: use hostname + eid
438
+ if machine_id is None:
439
+ import getpass
440
+ import socket
441
+
442
+ machine_id = f"{socket.gethostname()}-{getpass.getuser()}"
443
+
444
+ # Derive key from machine ID and app name
445
+ key_material = f"{machine_id}:{self.app_name}".encode()
446
+ return hashlib.sha256(key_material).digest()
447
+
448
+ def _encrypt_data(self, data: str) -> bytes:
449
+ """Encrypt session data using machine-specific key.
450
+
451
+ Uses XOR encryption with the machine key. This provides basic
452
+ protection against casual inspection and prevents session files
453
+ from being copied to other machines.
454
+
455
+ Args:
456
+ data: Plain text data to encrypt.
457
+
458
+ Returns:
459
+ Encrypted bytes.
460
+ """
461
+ data_bytes = data.encode()
462
+
463
+ encrypted = bytearray()
464
+ for i, byte in enumerate(data_bytes):
465
+ encrypted.append(byte ^ self._encryption_key[i % len(self._encryption_key)])
466
+
467
+ return bytes(encrypted)
468
+
469
+ def _decrypt_data(self, data: bytes) -> str:
470
+ """Decrypt session data.
471
+
472
+ Args:
473
+ data: Encrypted bytes.
474
+
475
+ Returns:
476
+ Decrypted string.
477
+
478
+ Raises:
479
+ Exception: If decryption fails (wrong machine or corrupted data).
480
+ """
481
+ decrypted = bytearray()
482
+ for i, byte in enumerate(data):
483
+ decrypted.append(byte ^ self._encryption_key[i % len(self._encryption_key)])
484
+
485
+ return decrypted.decode()
486
+
487
+
488
+ class SessionError(AudexError):
489
+ """Base exception for session errors."""
490
+
491
+ default_message = "Session error occurred"
492
+
493
+
494
+ class SessionExpiredError(SessionError):
495
+ """Raised when session has expired."""
496
+
497
+ default_message = "Session has expired"
498
+
499
+
500
+ class SessionNotFoundError(SessionError):
501
+ """Raised when session is not found."""
502
+
503
+ default_message = "No active session found"