solstone-linux 0.1.0__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,186 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Audio recording for Linux desktop observer.
5
+
6
+ Extracted from solstone's observe/hear.py — AudioRecorder class only.
7
+ load_transcript() and format_audio() remain in solstone core (used by 15+ files).
8
+
9
+ Changes from monorepo version:
10
+ - Replaces `from observe.detect import input_detect` with local audio_detect
11
+ - Replaces conditional `think.callosum` import with local logging
12
+ - Defines SAMPLE_RATE locally (was from observe.utils)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import gc
18
+ import io
19
+ import logging
20
+ import os
21
+ import signal
22
+ import threading
23
+ import time
24
+ from queue import Queue
25
+
26
+ import numpy as np
27
+ import soundfile as sf
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Standard sample rate for audio processing
32
+ SAMPLE_RATE = 16000
33
+ BLOCK_SIZE = 1024
34
+
35
+
36
+ class AudioRecorder:
37
+ """Records stereo audio from microphone and system audio."""
38
+
39
+ def __init__(self):
40
+ # Queue holds stereo chunks (mic=left, sys=right)
41
+ self.audio_queue = Queue()
42
+ self._running = True
43
+ self.recording_thread = None
44
+
45
+ def detect(self):
46
+ """Detect microphone and system audio devices."""
47
+ from .audio_detect import input_detect
48
+
49
+ mic, loopback = input_detect()
50
+ if mic is None or loopback is None:
51
+ logger.error(f"Detection failed: mic {mic} sys {loopback}")
52
+ return False
53
+ logger.info(f"Detected microphone: {mic.name}")
54
+ logger.info(f"Detected system audio: {loopback.name}")
55
+ self.mic_device = mic
56
+ self.sys_device = loopback
57
+ return True
58
+
59
+ def record_both(self):
60
+ """Record from both mic and system audio in a loop."""
61
+ while self._running:
62
+ try:
63
+ with (
64
+ self.mic_device.recorder(
65
+ samplerate=SAMPLE_RATE, channels=[-1], blocksize=BLOCK_SIZE
66
+ ) as mic_rec,
67
+ self.sys_device.recorder(
68
+ samplerate=SAMPLE_RATE, channels=[-1], blocksize=BLOCK_SIZE
69
+ ) as sys_rec,
70
+ ):
71
+ block_count = 0
72
+ while self._running and block_count < 1000:
73
+ try:
74
+ mic_chunk = mic_rec.record(numframes=BLOCK_SIZE)
75
+ sys_chunk = sys_rec.record(numframes=BLOCK_SIZE)
76
+
77
+ # Basic validation
78
+ if mic_chunk is None or mic_chunk.size == 0:
79
+ logger.warning("Empty microphone buffer")
80
+ continue
81
+ if sys_chunk is None or sys_chunk.size == 0:
82
+ logger.warning("Empty system buffer")
83
+ continue
84
+
85
+ try:
86
+ stereo_chunk = np.column_stack((mic_chunk, sys_chunk))
87
+ self.audio_queue.put(stereo_chunk)
88
+ block_count += 1
89
+ except (TypeError, ValueError, AttributeError) as e:
90
+ error_msg = f"Fatal audio format error: {e}"
91
+ logger.error(
92
+ f"{error_msg} - triggering clean shutdown\n"
93
+ f" mic_chunk type={type(mic_chunk)}, "
94
+ f"shape={getattr(mic_chunk, 'shape', 'N/A')}, "
95
+ f"dtype={getattr(mic_chunk, 'dtype', 'N/A')}\n"
96
+ f" sys_chunk type={type(sys_chunk)}, "
97
+ f"shape={getattr(sys_chunk, 'shape', 'N/A')}, "
98
+ f"dtype={getattr(sys_chunk, 'dtype', 'N/A')}"
99
+ )
100
+ # Stop recording thread and trigger shutdown
101
+ self._running = False
102
+ os.kill(os.getpid(), signal.SIGTERM)
103
+ return
104
+ except Exception as e:
105
+ logger.error(f"Error recording audio: {e}")
106
+ if not self._running:
107
+ break
108
+ time.sleep(0.5)
109
+ del mic_rec, sys_rec
110
+ gc.collect()
111
+ except Exception as e:
112
+ logger.error(f"Error setting up recorders: {e}")
113
+ if self._running:
114
+ time.sleep(1)
115
+
116
+ def get_buffers(self) -> np.ndarray:
117
+ """Return concatenated stereo audio data from the queue."""
118
+ stereo_buffer = np.array([], dtype=np.float32).reshape(0, 2)
119
+
120
+ while not self.audio_queue.empty():
121
+ stereo_chunk = self.audio_queue.get()
122
+
123
+ if stereo_chunk is None or stereo_chunk.size == 0:
124
+ logger.warning("Queue contained empty chunk")
125
+ continue
126
+
127
+ # Clean the data
128
+ stereo_chunk = np.nan_to_num(
129
+ stereo_chunk, nan=0.0, posinf=1e10, neginf=-1e10
130
+ )
131
+ stereo_buffer = np.vstack((stereo_buffer, stereo_chunk))
132
+
133
+ if stereo_buffer.size == 0:
134
+ logger.warning("No valid audio data retrieved from queue")
135
+
136
+ return stereo_buffer
137
+
138
+ def create_flac_bytes(self, stereo_data: np.ndarray) -> bytes:
139
+ """Create FLAC bytes from stereo audio data."""
140
+ if stereo_data is None or stereo_data.size == 0:
141
+ logger.warning("Audio data is empty. Returning empty bytes.")
142
+ return b""
143
+
144
+ audio_data = (np.clip(stereo_data, -1.0, 1.0) * 32767).astype(np.int16)
145
+
146
+ buf = io.BytesIO()
147
+ try:
148
+ sf.write(buf, audio_data, SAMPLE_RATE, format="FLAC")
149
+ except Exception as e:
150
+ logger.error(
151
+ f"Error creating FLAC: {e}. Audio data shape: {audio_data.shape}, dtype: {audio_data.dtype}"
152
+ )
153
+ return b""
154
+
155
+ return buf.getvalue()
156
+
157
+ def create_mono_flac_bytes(self, mono_data: np.ndarray) -> bytes:
158
+ """Create FLAC bytes from mono audio data."""
159
+ if mono_data is None or mono_data.size == 0:
160
+ logger.warning("Mono audio data is empty. Returning empty bytes.")
161
+ return b""
162
+
163
+ audio_data = (np.clip(mono_data, -1.0, 1.0) * 32767).astype(np.int16)
164
+
165
+ buf = io.BytesIO()
166
+ try:
167
+ sf.write(buf, audio_data, SAMPLE_RATE, format="FLAC")
168
+ except Exception as e:
169
+ logger.error(
170
+ f"Error creating mono FLAC: {e}. Audio shape: {audio_data.shape}"
171
+ )
172
+ return b""
173
+
174
+ return buf.getvalue()
175
+
176
+ def start_recording(self):
177
+ """Start the recording thread."""
178
+ self._running = True
179
+ self.recording_thread = threading.Thread(target=self.record_both, daemon=True)
180
+ self.recording_thread.start()
181
+
182
+ def stop_recording(self):
183
+ """Stop the recording thread."""
184
+ self._running = False
185
+ if self.recording_thread:
186
+ self.recording_thread.join(timeout=2.0)
@@ -0,0 +1,493 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (c) 2026 sol pbc
3
+
4
+ """Bridge server-initiated chat events into local notification surfaces.
5
+
6
+ The bridge consumes callosum SSE frames, mirrors requests into an optional FIFO,
7
+ and fires click-capturing desktop notifications when the server opt-in allows it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import errno
14
+ import json
15
+ import logging
16
+ import os
17
+ import stat
18
+ import subprocess
19
+ import threading
20
+ import time
21
+ from collections import OrderedDict
22
+ from dataclasses import dataclass
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import requests
28
+
29
+ from .config import Config
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Keep these event names and owner-facing copy hand-synced with
34
+ # solstone/convey/sol_initiated/copy.py; this repo does not vendor that canon.
35
+ EVENT_SOL_CHAT_REQUEST = "sol_chat_request"
36
+ EVENT_SOL_CHAT_REQUEST_SUPERSEDED = "sol_chat_request_superseded"
37
+ EVENT_OWNER_CHAT_OPEN = "owner_chat_open"
38
+ EVENT_OWNER_CHAT_DISMISSED = "owner_chat_dismissed"
39
+
40
+ NOTIFY_TITLE = "sol"
41
+ SURFACE = "linux"
42
+ FIFO_PATH = Path.home() / ".solstone" / "notify"
43
+ _HANDLED_EVENTS = frozenset(
44
+ {
45
+ EVENT_SOL_CHAT_REQUEST,
46
+ EVENT_SOL_CHAT_REQUEST_SUPERSEDED,
47
+ EVENT_OWNER_CHAT_OPEN,
48
+ EVENT_OWNER_CHAT_DISMISSED,
49
+ }
50
+ )
51
+ RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30]
52
+ HEARTBEAT_STALE_SECONDS = 60
53
+ OPT_IN_POLL_SECONDS = 300
54
+ PENDING_CAP = 32
55
+
56
+
57
+ @dataclass
58
+ class PendingRequest:
59
+ request_id: str
60
+ summary: str
61
+ chat_url: str
62
+ notify_task: asyncio.Task | None = None
63
+
64
+
65
+ class _SseParser:
66
+ def __init__(self) -> None:
67
+ self._event: str | None = None
68
+ self._data: list[str] = []
69
+ self._id: str | None = None
70
+
71
+ def feed_line(self, line: str) -> dict[str, str | None] | None:
72
+ line = line.rstrip("\r\n")
73
+ if line == "":
74
+ if not self._data:
75
+ self._event = None
76
+ self._id = None
77
+ return None
78
+ frame = {
79
+ "event": self._event,
80
+ "data": "\n".join(self._data),
81
+ "id": self._id,
82
+ }
83
+ self._event = None
84
+ self._data = []
85
+ self._id = None
86
+ return frame
87
+
88
+ if line.startswith(":"):
89
+ return None
90
+
91
+ field, sep, value = line.partition(":")
92
+ if sep and value.startswith(" "):
93
+ value = value[1:]
94
+
95
+ if field == "data":
96
+ self._data.append(value)
97
+ elif field == "event":
98
+ self._event = value
99
+ elif field == "id":
100
+ self._id = value
101
+
102
+ return None
103
+
104
+
105
+ def _auth_headers(key: str) -> dict[str, str]:
106
+ return {"Authorization": f"Bearer {key}"}
107
+
108
+
109
+ def _write_fifo(line: str, path: Path = FIFO_PATH) -> None:
110
+ try:
111
+ if not path.exists():
112
+ logger.debug("Chat bridge FIFO missing: %s", path)
113
+ return
114
+ if not stat.S_ISFIFO(path.stat().st_mode):
115
+ logger.debug("Chat bridge path is not a FIFO: %s", path)
116
+ return
117
+
118
+ fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
119
+ try:
120
+ os.write(fd, line.encode("utf-8"))
121
+ finally:
122
+ os.close(fd)
123
+ except FileNotFoundError:
124
+ logger.debug("Chat bridge FIFO missing: %s", path)
125
+ except BlockingIOError:
126
+ logger.debug("Chat bridge FIFO has no reader: %s", path)
127
+ except OSError as e:
128
+ if e.errno in (errno.ENXIO, errno.EAGAIN, errno.EWOULDBLOCK):
129
+ logger.debug("Chat bridge FIFO unavailable: %s", e)
130
+ return
131
+ logger.warning("Chat bridge FIFO write failed: %s", e)
132
+
133
+
134
+ def _push_frame(
135
+ queue: asyncio.Queue,
136
+ loop: asyncio.AbstractEventLoop,
137
+ frame: dict[str, Any],
138
+ ) -> None:
139
+ loop.call_soon_threadsafe(queue.put_nowait, frame)
140
+
141
+
142
+ def _sse_worker(
143
+ url: str,
144
+ key: str,
145
+ queue: asyncio.Queue,
146
+ loop: asyncio.AbstractEventLoop,
147
+ stop_event: threading.Event,
148
+ ) -> None:
149
+ parser = _SseParser()
150
+ try:
151
+ response = requests.get(
152
+ url,
153
+ stream=True,
154
+ headers=_auth_headers(key),
155
+ timeout=(10, None),
156
+ )
157
+ if response.status_code in (401, 403):
158
+ _push_frame(
159
+ queue, loop, {"_terminal": True, "status": response.status_code}
160
+ )
161
+ return
162
+ if response.status_code != 200:
163
+ _push_frame(
164
+ queue,
165
+ loop,
166
+ {
167
+ "_transport_error": True,
168
+ "error": f"status {response.status_code}",
169
+ },
170
+ )
171
+ return
172
+
173
+ for raw_line in response.iter_lines(decode_unicode=True):
174
+ if stop_event.is_set():
175
+ return
176
+ if raw_line is None:
177
+ continue
178
+ line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
179
+ if line.startswith(":"):
180
+ _push_frame(queue, loop, {"_heartbeat": True})
181
+ frame = parser.feed_line(line)
182
+ if frame is not None:
183
+ _push_frame(queue, loop, frame)
184
+ except requests.RequestException as e:
185
+ _push_frame(queue, loop, {"_transport_error": True, "error": str(e)})
186
+
187
+
188
+ async def _poll_opt_in(server_url: str, key: str) -> bool:
189
+ url = f"{server_url.rstrip('/')}/api/sol_voice"
190
+
191
+ try:
192
+ response = await asyncio.to_thread(
193
+ requests.get,
194
+ url,
195
+ headers=_auth_headers(key),
196
+ timeout=10,
197
+ )
198
+ if response.status_code != 200:
199
+ return False
200
+ data = response.json()
201
+ except (requests.RequestException, ValueError, TypeError):
202
+ return False
203
+
204
+ return bool(data.get("linux_notify_send", False))
205
+
206
+
207
+ def _chat_url(server_url: str, day: str | None, event_index: int | None) -> str:
208
+ base = server_url.rstrip("/")
209
+ if day and event_index is not None:
210
+ return f"{base}/app/chat/{day}#event-{event_index}"
211
+ today = datetime.now().strftime("%Y%m%d")
212
+ return f"{base}/app/chat/{today}"
213
+
214
+
215
+ async def _handle_one_notification(
216
+ req: PendingRequest, server_url: str, key: str
217
+ ) -> None:
218
+ proc = await asyncio.create_subprocess_exec(
219
+ "notify-send",
220
+ "--wait",
221
+ "--app-name",
222
+ "solstone",
223
+ "--action=open=Open",
224
+ NOTIFY_TITLE,
225
+ req.summary,
226
+ stdout=asyncio.subprocess.PIPE,
227
+ stderr=asyncio.subprocess.DEVNULL,
228
+ )
229
+ try:
230
+ await proc.communicate()
231
+ except asyncio.CancelledError:
232
+ proc.terminate()
233
+ try:
234
+ await asyncio.wait_for(proc.wait(), timeout=1)
235
+ except asyncio.TimeoutError:
236
+ proc.kill()
237
+ await proc.wait()
238
+ raise
239
+
240
+ if proc.returncode != 0:
241
+ logger.debug("notify-send exited with status %s", proc.returncode)
242
+ return
243
+
244
+ logger.info("Opening chat request: %s", req.request_id)
245
+ url = f"{server_url.rstrip('/')}/api/chat/{EVENT_SOL_CHAT_REQUEST}/open"
246
+ try:
247
+ response = await asyncio.to_thread(
248
+ requests.post,
249
+ url,
250
+ json={"request_id": req.request_id},
251
+ headers=_auth_headers(key),
252
+ timeout=10,
253
+ )
254
+ if response.status_code >= 400:
255
+ logger.debug("Chat open ack failed: status %s", response.status_code)
256
+ except requests.RequestException as e:
257
+ logger.debug("Chat open ack failed: %s", e)
258
+
259
+ try:
260
+ subprocess.Popen(
261
+ ["xdg-open", req.chat_url],
262
+ stdout=subprocess.DEVNULL,
263
+ stderr=subprocess.DEVNULL,
264
+ )
265
+ except OSError as e:
266
+ logger.debug("xdg-open failed: %s", e)
267
+
268
+
269
+ async def _opt_in_poll_loop(server_url: str, key: str, state: dict[str, bool]) -> None:
270
+ while True:
271
+ state["value"] = await _poll_opt_in(server_url, key)
272
+ await asyncio.sleep(OPT_IN_POLL_SECONDS)
273
+
274
+
275
+ def _cancel_pending_task(task: asyncio.Task | None) -> None:
276
+ if task is not None and not task.done():
277
+ task.cancel()
278
+
279
+
280
+ def _enforce_pending_cap(pending: OrderedDict[str, PendingRequest]) -> None:
281
+ while len(pending) > PENDING_CAP:
282
+ request_id, old_req = pending.popitem(last=False)
283
+ _cancel_pending_task(old_req.notify_task)
284
+ logger.debug("Evicted pending chat request: %s", request_id)
285
+
286
+
287
+ def _mark_stale_if_needed(
288
+ last_frame_at: float, is_stale: bool, stale_logged: bool
289
+ ) -> tuple[bool, bool]:
290
+ if time.monotonic() - last_frame_at > HEARTBEAT_STALE_SECONDS and not is_stale:
291
+ logger.warning("Chat bridge heartbeat stale")
292
+ return True, True
293
+ return is_stale, stale_logged
294
+
295
+
296
+ def _mark_live_frame(is_stale: bool, stale_logged: bool) -> tuple[bool, bool]:
297
+ if is_stale:
298
+ if stale_logged:
299
+ logger.info("Chat bridge heartbeat recovered")
300
+ return False, False
301
+ return is_stale, stale_logged
302
+
303
+
304
+ async def _dispatch_event(
305
+ payload: dict[str, Any],
306
+ pending: OrderedDict[str, PendingRequest],
307
+ opt_in: bool,
308
+ is_stale: bool,
309
+ config: Config,
310
+ ) -> None:
311
+ if payload.get("tract") != "chat":
312
+ return
313
+
314
+ event = payload.get("event")
315
+ if event not in _HANDLED_EVENTS:
316
+ return
317
+
318
+ request_id = payload.get("request_id")
319
+ if not request_id:
320
+ logger.debug("Chat event missing request_id: %s", event)
321
+ return
322
+ request_id = str(request_id)
323
+
324
+ if event == EVENT_SOL_CHAT_REQUEST:
325
+ summary = str(payload.get("summary") or "")
326
+ _write_fifo(f"sol-ping {request_id} {summary}\n")
327
+
328
+ old_req = pending.pop(request_id, None)
329
+ if old_req is not None:
330
+ _cancel_pending_task(old_req.notify_task)
331
+
332
+ if opt_in and not is_stale:
333
+ event_index = payload.get("event_index")
334
+ if not isinstance(event_index, int):
335
+ event_index = None
336
+ req = PendingRequest(
337
+ request_id=request_id,
338
+ summary=summary,
339
+ chat_url=_chat_url(config.server_url, payload.get("day"), event_index),
340
+ )
341
+ req.notify_task = asyncio.create_task(
342
+ _handle_one_notification(req, config.server_url, config.key)
343
+ )
344
+ pending[request_id] = req
345
+ _enforce_pending_cap(pending)
346
+ return
347
+
348
+ if event in (
349
+ EVENT_SOL_CHAT_REQUEST_SUPERSEDED,
350
+ EVENT_OWNER_CHAT_OPEN,
351
+ EVENT_OWNER_CHAT_DISMISSED,
352
+ ):
353
+ old_req = pending.pop(request_id, None)
354
+ if old_req is not None:
355
+ _cancel_pending_task(old_req.notify_task)
356
+ _write_fifo(f"clear {request_id}\n")
357
+
358
+
359
+ async def _cancel_pending_notifications(
360
+ pending: OrderedDict[str, PendingRequest],
361
+ ) -> None:
362
+ tasks = [req.notify_task for req in pending.values() if req.notify_task is not None]
363
+ for task in tasks:
364
+ _cancel_pending_task(task)
365
+ if tasks:
366
+ await asyncio.gather(*tasks, return_exceptions=True)
367
+ pending.clear()
368
+
369
+
370
+ async def _await_worker(worker_task: asyncio.Task | None) -> None:
371
+ if worker_task is None:
372
+ return
373
+ try:
374
+ await asyncio.wait_for(worker_task, timeout=1)
375
+ except asyncio.TimeoutError:
376
+ worker_task.cancel()
377
+ await asyncio.gather(worker_task, return_exceptions=True)
378
+
379
+
380
+ async def _sleep_reconnect(delay: int, stop_event: asyncio.Event) -> None:
381
+ if not stop_event.is_set():
382
+ await asyncio.sleep(delay)
383
+
384
+
385
+ async def run_chat_bridge(config: Config, stop_event: asyncio.Event) -> None:
386
+ try:
387
+ if not config.chat_bridge_enabled:
388
+ return
389
+ if not config.server_url or not config.key:
390
+ logger.debug("Chat bridge disabled: server_url or key missing")
391
+ return
392
+
393
+ server_url = config.server_url.rstrip("/")
394
+ key = config.key
395
+ sse_url = f"{server_url}/app/observer/{key}/callosum"
396
+ pending: OrderedDict[str, PendingRequest] = OrderedDict()
397
+ opt_in_state = {"value": False}
398
+ opt_in_task = asyncio.create_task(
399
+ _opt_in_poll_loop(server_url, key, opt_in_state)
400
+ )
401
+ reconnect_index = 0
402
+ is_stale = False
403
+ stale_logged = False
404
+ worker_task: asyncio.Task | None = None
405
+ thread_stop: threading.Event | None = None
406
+
407
+ try:
408
+ while not stop_event.is_set():
409
+ queue: asyncio.Queue = asyncio.Queue()
410
+ thread_stop = threading.Event()
411
+ loop = asyncio.get_running_loop()
412
+ worker_task = asyncio.create_task(
413
+ asyncio.to_thread(
414
+ _sse_worker, sse_url, key, queue, loop, thread_stop
415
+ )
416
+ )
417
+ last_frame_at = time.monotonic()
418
+ reconnect = False
419
+
420
+ while not stop_event.is_set():
421
+ try:
422
+ frame = await asyncio.wait_for(queue.get(), timeout=5)
423
+ except asyncio.TimeoutError:
424
+ is_stale, stale_logged = _mark_stale_if_needed(
425
+ last_frame_at, is_stale, stale_logged
426
+ )
427
+ if worker_task.done():
428
+ reconnect = True
429
+ break
430
+ continue
431
+
432
+ if frame.get("_terminal"):
433
+ logger.error(
434
+ "Chat bridge SSE authorization failed: status %s",
435
+ frame.get("status"),
436
+ )
437
+ thread_stop.set()
438
+ return
439
+
440
+ if frame.get("_transport_error"):
441
+ logger.debug(
442
+ "Chat bridge transport error: %s", frame.get("error")
443
+ )
444
+ reconnect = True
445
+ break
446
+
447
+ last_frame_at = time.monotonic()
448
+ reconnect_index = 0
449
+ is_stale, stale_logged = _mark_live_frame(is_stale, stale_logged)
450
+
451
+ if frame.get("_heartbeat"):
452
+ continue
453
+
454
+ data = frame.get("data")
455
+ if not isinstance(data, str):
456
+ continue
457
+ try:
458
+ payload = json.loads(data)
459
+ except json.JSONDecodeError as e:
460
+ logger.debug("Chat bridge frame JSON decode failed: %s", e)
461
+ continue
462
+ if not isinstance(payload, dict):
463
+ continue
464
+ await _dispatch_event(
465
+ payload,
466
+ pending,
467
+ opt_in_state["value"],
468
+ is_stale,
469
+ config,
470
+ )
471
+
472
+ if thread_stop:
473
+ thread_stop.set()
474
+ await _await_worker(worker_task)
475
+ worker_task = None
476
+ if stop_event.is_set():
477
+ break
478
+ if reconnect:
479
+ delay = RECONNECT_DELAYS[
480
+ min(reconnect_index, len(RECONNECT_DELAYS) - 1)
481
+ ]
482
+ reconnect_index += 1
483
+ logger.info("Chat bridge reconnecting in %ss", delay)
484
+ await _sleep_reconnect(delay, stop_event)
485
+ finally:
486
+ if thread_stop:
487
+ thread_stop.set()
488
+ opt_in_task.cancel()
489
+ await asyncio.gather(opt_in_task, return_exceptions=True)
490
+ await _cancel_pending_notifications(pending)
491
+ await _await_worker(worker_task)
492
+ except Exception as e:
493
+ logger.error("Chat bridge crashed: %s", e, exc_info=True)