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.
- solstone_linux/__init__.py +6 -0
- solstone_linux/activity.py +384 -0
- solstone_linux/audio_detect.py +79 -0
- solstone_linux/audio_mute.py +47 -0
- solstone_linux/audio_recorder.py +186 -0
- solstone_linux/chat_bridge.py +493 -0
- solstone_linux/cli.py +489 -0
- solstone_linux/config.py +130 -0
- solstone_linux/dbus_service.py +149 -0
- solstone_linux/dbusmenu.py +242 -0
- solstone_linux/doctor.py +277 -0
- solstone_linux/icons/hicolor/index.theme +12 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +17 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +17 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +7 -0
- solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +16 -0
- solstone_linux/install_guard.py +210 -0
- solstone_linux/monitor_positions.py +110 -0
- solstone_linux/observer.py +757 -0
- solstone_linux/recovery.py +175 -0
- solstone_linux/screencast.py +572 -0
- solstone_linux/session_env.py +92 -0
- solstone_linux/sni.py +250 -0
- solstone_linux/solstone-linux.service.in +17 -0
- solstone_linux/streams.py +87 -0
- solstone_linux/sync.py +497 -0
- solstone_linux/tray.py +577 -0
- solstone_linux/upload.py +290 -0
- solstone_linux-0.1.0.dist-info/METADATA +73 -0
- solstone_linux-0.1.0.dist-info/RECORD +33 -0
- solstone_linux-0.1.0.dist-info/WHEEL +4 -0
- solstone_linux-0.1.0.dist-info/entry_points.txt +2 -0
- solstone_linux-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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)
|