opencode-talk-bridge 0.2.7__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.
- opencode_talk_bridge/__init__.py +8 -0
- opencode_talk_bridge/__main__.py +116 -0
- opencode_talk_bridge/allowlist.py +33 -0
- opencode_talk_bridge/bridge.py +1014 -0
- opencode_talk_bridge/commands.py +60 -0
- opencode_talk_bridge/config.py +169 -0
- opencode_talk_bridge/events.py +85 -0
- opencode_talk_bridge/init.py +118 -0
- opencode_talk_bridge/messages.py +226 -0
- opencode_talk_bridge/opencode.py +418 -0
- opencode_talk_bridge/pending.py +115 -0
- opencode_talk_bridge/permissions.py +79 -0
- opencode_talk_bridge/scheduler.py +144 -0
- opencode_talk_bridge/sessions.py +141 -0
- opencode_talk_bridge/status.py +88 -0
- opencode_talk_bridge/streaming.py +78 -0
- opencode_talk_bridge/stt.py +48 -0
- opencode_talk_bridge/talk.py +171 -0
- opencode_talk_bridge/tts.py +43 -0
- opencode_talk_bridge/webdav.py +93 -0
- opencode_talk_bridge-0.2.7.dist-info/METADATA +284 -0
- opencode_talk_bridge-0.2.7.dist-info/RECORD +25 -0
- opencode_talk_bridge-0.2.7.dist-info/WHEEL +4 -0
- opencode_talk_bridge-0.2.7.dist-info/entry_points.txt +2 -0
- opencode_talk_bridge-0.2.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Scheduled tasks: run a prompt in a conversation later or on an interval.
|
|
2
|
+
|
|
3
|
+
A SQLite-backed task store plus a daemon thread that fires due tasks via a
|
|
4
|
+
runner callback (the bridge wires this to start a normal prompt turn). One-shot
|
|
5
|
+
tasks are deleted after firing; recurring tasks are rescheduled by their
|
|
6
|
+
interval. Times are Unix timestamps so the schedule survives restarts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
token TEXT NOT NULL,
|
|
24
|
+
prompt TEXT NOT NULL,
|
|
25
|
+
run_at INTEGER NOT NULL,
|
|
26
|
+
interval_s INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
created_at INTEGER NOT NULL DEFAULT 0
|
|
28
|
+
);
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Task:
|
|
34
|
+
id: int
|
|
35
|
+
token: str
|
|
36
|
+
prompt: str
|
|
37
|
+
run_at: int
|
|
38
|
+
interval_s: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskStore:
|
|
42
|
+
def __init__(self, db_path: str) -> None:
|
|
43
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
44
|
+
self._conn.row_factory = sqlite3.Row
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._conn.executescript(_SCHEMA)
|
|
48
|
+
self._conn.commit()
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._conn.close()
|
|
53
|
+
|
|
54
|
+
def add(self, token: str, prompt: str, run_at: int, interval_s: int = 0, *, now: int = 0) -> int:
|
|
55
|
+
with self._lock:
|
|
56
|
+
cur = self._conn.execute(
|
|
57
|
+
"INSERT INTO scheduled_tasks (token, prompt, run_at, interval_s, created_at)"
|
|
58
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
59
|
+
(token, prompt, run_at, interval_s, now),
|
|
60
|
+
)
|
|
61
|
+
self._conn.commit()
|
|
62
|
+
return int(cur.lastrowid)
|
|
63
|
+
|
|
64
|
+
def list(self, token: str) -> list[Task]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
rows = self._conn.execute(
|
|
67
|
+
"SELECT * FROM scheduled_tasks WHERE token = ? ORDER BY run_at", (token,)
|
|
68
|
+
).fetchall()
|
|
69
|
+
return [_row_to_task(r) for r in rows]
|
|
70
|
+
|
|
71
|
+
def count(self) -> int:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return int(self._conn.execute("SELECT COUNT(*) FROM scheduled_tasks").fetchone()[0])
|
|
74
|
+
|
|
75
|
+
def delete(self, task_id: int) -> None:
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._conn.execute("DELETE FROM scheduled_tasks WHERE id = ?", (task_id,))
|
|
78
|
+
self._conn.commit()
|
|
79
|
+
|
|
80
|
+
def due(self, now: int) -> list[Task]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
rows = self._conn.execute(
|
|
83
|
+
"SELECT * FROM scheduled_tasks WHERE run_at <= ? ORDER BY run_at", (now,)
|
|
84
|
+
).fetchall()
|
|
85
|
+
return [_row_to_task(r) for r in rows]
|
|
86
|
+
|
|
87
|
+
def reschedule(self, task_id: int, new_run_at: int) -> None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._conn.execute("UPDATE scheduled_tasks SET run_at = ? WHERE id = ?", (new_run_at, task_id))
|
|
90
|
+
self._conn.commit()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _row_to_task(row: sqlite3.Row) -> Task:
|
|
94
|
+
return Task(
|
|
95
|
+
id=row["id"],
|
|
96
|
+
token=row["token"],
|
|
97
|
+
prompt=row["prompt"],
|
|
98
|
+
run_at=row["run_at"],
|
|
99
|
+
interval_s=row["interval_s"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Scheduler:
|
|
104
|
+
"""Polls the task store and fires due tasks via ``runner(token, prompt)``."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
store: TaskStore,
|
|
109
|
+
runner: Callable[[str, str], None],
|
|
110
|
+
*,
|
|
111
|
+
tick: float = 30.0,
|
|
112
|
+
clock: Callable[[], int] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._store = store
|
|
115
|
+
self._runner = runner
|
|
116
|
+
self._tick = tick
|
|
117
|
+
self._clock = clock or (lambda: int(time.time()))
|
|
118
|
+
self._stop = threading.Event()
|
|
119
|
+
self._thread: threading.Thread | None = None
|
|
120
|
+
|
|
121
|
+
def start(self) -> None:
|
|
122
|
+
self._thread = threading.Thread(target=self._loop, name="scheduler", daemon=True)
|
|
123
|
+
self._thread.start()
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
self._stop.set()
|
|
127
|
+
|
|
128
|
+
def _loop(self) -> None:
|
|
129
|
+
while not self._stop.is_set():
|
|
130
|
+
self.run_due()
|
|
131
|
+
self._stop.wait(self._tick)
|
|
132
|
+
|
|
133
|
+
def run_due(self) -> None:
|
|
134
|
+
"""Fire all due tasks once (also usable directly in tests)."""
|
|
135
|
+
now = self._clock()
|
|
136
|
+
for task in self._store.due(now):
|
|
137
|
+
try:
|
|
138
|
+
self._runner(task.token, task.prompt)
|
|
139
|
+
except Exception: # noqa: BLE001 - a bad task must not kill the loop
|
|
140
|
+
log.exception("scheduled task %s failed", task.id)
|
|
141
|
+
if task.interval_s > 0:
|
|
142
|
+
self._store.reschedule(task.id, now + task.interval_s)
|
|
143
|
+
else:
|
|
144
|
+
self._store.delete(task.id)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""SQLite-backed mapping of Talk conversations to OpenCode sessions.
|
|
2
|
+
|
|
3
|
+
One row per watched conversation. Persists across bridge restarts so that
|
|
4
|
+
``last_known_message_id`` survives (no replay of chat history) and a
|
|
5
|
+
conversation stays bound to its OpenCode session and chosen model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
16
|
+
token TEXT PRIMARY KEY,
|
|
17
|
+
opencode_session_id TEXT,
|
|
18
|
+
model TEXT,
|
|
19
|
+
agent TEXT,
|
|
20
|
+
directory TEXT,
|
|
21
|
+
tts_enabled INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
last_known_message_id INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
24
|
+
);
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Columns added after 0.1.x; applied idempotently for existing databases.
|
|
28
|
+
_MIGRATIONS = (
|
|
29
|
+
"ALTER TABLE conversations ADD COLUMN agent TEXT",
|
|
30
|
+
"ALTER TABLE conversations ADD COLUMN directory TEXT",
|
|
31
|
+
"ALTER TABLE conversations ADD COLUMN tts_enabled INTEGER NOT NULL DEFAULT 0",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ConversationState:
|
|
37
|
+
token: str
|
|
38
|
+
opencode_session_id: str | None
|
|
39
|
+
model: str | None
|
|
40
|
+
last_known_message_id: int
|
|
41
|
+
agent: str | None = None
|
|
42
|
+
directory: str | None = None
|
|
43
|
+
tts_enabled: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionStore:
|
|
47
|
+
"""Thread-safe SQLite store. A single connection guarded by a lock."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, db_path: str) -> None:
|
|
50
|
+
# check_same_thread=False: the SSE thread and poll loop may both touch it,
|
|
51
|
+
# serialised by self._lock.
|
|
52
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
53
|
+
self._conn.row_factory = sqlite3.Row
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._conn.executescript(_SCHEMA)
|
|
57
|
+
for migration in _MIGRATIONS:
|
|
58
|
+
try:
|
|
59
|
+
self._conn.execute(migration)
|
|
60
|
+
except sqlite3.OperationalError:
|
|
61
|
+
pass # column already exists
|
|
62
|
+
self._conn.commit()
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._conn.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> SessionStore:
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *_exc: object) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
# --- reads -------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def get(self, token: str) -> ConversationState | None:
|
|
77
|
+
with self._lock:
|
|
78
|
+
row = self._conn.execute("SELECT * FROM conversations WHERE token = ?", (token,)).fetchone()
|
|
79
|
+
return _row_to_state(row) if row else None
|
|
80
|
+
|
|
81
|
+
def get_or_create(self, token: str) -> ConversationState:
|
|
82
|
+
existing = self.get(token)
|
|
83
|
+
if existing is not None:
|
|
84
|
+
return existing
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._conn.execute("INSERT OR IGNORE INTO conversations (token) VALUES (?)", (token,))
|
|
87
|
+
self._conn.commit()
|
|
88
|
+
return ConversationState(token=token, opencode_session_id=None, model=None, last_known_message_id=0)
|
|
89
|
+
|
|
90
|
+
def session_id_for(self, token: str) -> str | None:
|
|
91
|
+
state = self.get(token)
|
|
92
|
+
return state.opencode_session_id if state else None
|
|
93
|
+
|
|
94
|
+
# --- writes ------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def set_session(self, token: str, session_id: str | None, *, now: int = 0) -> None:
|
|
97
|
+
self._upsert(token, "opencode_session_id", session_id, now)
|
|
98
|
+
|
|
99
|
+
def set_model(self, token: str, model: str | None, *, now: int = 0) -> None:
|
|
100
|
+
self._upsert(token, "model", model, now)
|
|
101
|
+
|
|
102
|
+
def set_agent(self, token: str, agent: str | None, *, now: int = 0) -> None:
|
|
103
|
+
self._upsert(token, "agent", agent, now)
|
|
104
|
+
|
|
105
|
+
def set_directory(self, token: str, directory: str | None, *, now: int = 0) -> None:
|
|
106
|
+
self._upsert(token, "directory", directory, now)
|
|
107
|
+
|
|
108
|
+
def set_tts(self, token: str, enabled: bool, *, now: int = 0) -> None:
|
|
109
|
+
self._upsert(token, "tts_enabled", 1 if enabled else 0, now)
|
|
110
|
+
|
|
111
|
+
def update_last_message_id(self, token: str, message_id: int, *, now: int = 0) -> None:
|
|
112
|
+
self._upsert(token, "last_known_message_id", message_id, now)
|
|
113
|
+
|
|
114
|
+
def clear_session(self, token: str, *, now: int = 0) -> None:
|
|
115
|
+
"""Forget the OpenCode session binding (e.g. on /new) but keep last id."""
|
|
116
|
+
self.set_session(token, None, now=now)
|
|
117
|
+
|
|
118
|
+
def _upsert(self, token: str, column: str, value: object, now: int) -> None:
|
|
119
|
+
# column is from a fixed internal set — never user input.
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._conn.execute(
|
|
122
|
+
f"""INSERT INTO conversations (token, {column}, updated_at)
|
|
123
|
+
VALUES (?, ?, ?)
|
|
124
|
+
ON CONFLICT(token) DO UPDATE SET {column} = excluded.{column},
|
|
125
|
+
updated_at = excluded.updated_at""",
|
|
126
|
+
(token, value, now),
|
|
127
|
+
)
|
|
128
|
+
self._conn.commit()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _row_to_state(row: sqlite3.Row) -> ConversationState:
|
|
132
|
+
keys = row.keys()
|
|
133
|
+
return ConversationState(
|
|
134
|
+
token=row["token"],
|
|
135
|
+
opencode_session_id=row["opencode_session_id"],
|
|
136
|
+
model=row["model"],
|
|
137
|
+
last_known_message_id=row["last_known_message_id"],
|
|
138
|
+
agent=row["agent"] if "agent" in keys else None,
|
|
139
|
+
directory=row["directory"] if "directory" in keys else None,
|
|
140
|
+
tts_enabled=bool(row["tts_enabled"]) if "tts_enabled" in keys else False,
|
|
141
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Atomic JSON status file — the interface for the Swift menubar app.
|
|
2
|
+
|
|
3
|
+
The menubar app polls this file; it must always be valid JSON, so writes go to
|
|
4
|
+
a temp file and are atomically renamed into place. The schema is a stable
|
|
5
|
+
contract documented in the README.
|
|
6
|
+
|
|
7
|
+
State machine::
|
|
8
|
+
|
|
9
|
+
starting -> polling <-> working
|
|
10
|
+
| \\-> opencode_down
|
|
11
|
+
\\-> error
|
|
12
|
+
-> stopped (clean shutdown)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
# Allowed top-level states (documented in the README contract).
|
|
24
|
+
STATES = ("starting", "polling", "working", "opencode_down", "error", "stopped")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StatusWriter:
|
|
28
|
+
def __init__(self, path: str) -> None:
|
|
29
|
+
self._path = path
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._state: dict[str, Any] = {
|
|
32
|
+
"state": "starting",
|
|
33
|
+
"since": 0,
|
|
34
|
+
"opencode_healthy": False,
|
|
35
|
+
"conversations": [],
|
|
36
|
+
"last_error": None,
|
|
37
|
+
"version": _version(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def update(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
state: str | None = None,
|
|
44
|
+
since: int | None = None,
|
|
45
|
+
opencode_healthy: bool | None = None,
|
|
46
|
+
conversations: list[str] | None = None,
|
|
47
|
+
last_error: str | None = ..., # sentinel: ... means "leave unchanged"
|
|
48
|
+
) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
if state is not None:
|
|
51
|
+
if state not in STATES:
|
|
52
|
+
raise ValueError(f"unknown state {state!r}")
|
|
53
|
+
self._state["state"] = state
|
|
54
|
+
if since is not None:
|
|
55
|
+
self._state["since"] = since
|
|
56
|
+
if opencode_healthy is not None:
|
|
57
|
+
self._state["opencode_healthy"] = opencode_healthy
|
|
58
|
+
if conversations is not None:
|
|
59
|
+
self._state["conversations"] = list(conversations)
|
|
60
|
+
if last_error is not ...:
|
|
61
|
+
self._state["last_error"] = last_error
|
|
62
|
+
self._flush()
|
|
63
|
+
|
|
64
|
+
def snapshot(self) -> dict[str, Any]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
return dict(self._state)
|
|
67
|
+
|
|
68
|
+
def _flush(self) -> None:
|
|
69
|
+
data = json.dumps(self._state, indent=2)
|
|
70
|
+
directory = os.path.dirname(os.path.abspath(self._path)) or "."
|
|
71
|
+
os.makedirs(directory, exist_ok=True)
|
|
72
|
+
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".status-", suffix=".tmp")
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
75
|
+
fh.write(data)
|
|
76
|
+
os.replace(tmp, self._path) # atomic on POSIX
|
|
77
|
+
except BaseException:
|
|
78
|
+
try:
|
|
79
|
+
os.unlink(tmp)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _version() -> str:
|
|
86
|
+
from . import __version__
|
|
87
|
+
|
|
88
|
+
return __version__
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Live response streaming via Talk message editing.
|
|
2
|
+
|
|
3
|
+
OpenCode emits ``message.part.updated`` events whose ``TextPart.text`` is the
|
|
4
|
+
*cumulative* text of that part. The SSE thread feeds those into a ``StreamState``
|
|
5
|
+
keyed by OpenCode ``sessionID``; the state edits a single Talk message at most
|
|
6
|
+
once per ``throttle`` seconds (each edit is a full REST call, so we throttle
|
|
7
|
+
harder than Telegram would). ``finalize`` forces a last edit with the
|
|
8
|
+
authoritative text once the blocking prompt returns.
|
|
9
|
+
|
|
10
|
+
A monotonic clock is injected so throttling is deterministic in tests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
# Talk rejects empty edits and has a max length; keep a safety cap.
|
|
19
|
+
_MAX_LEN = 30000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StreamState:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
token: str,
|
|
26
|
+
message_id: int,
|
|
27
|
+
editor: Callable[[str, int, str], None],
|
|
28
|
+
*,
|
|
29
|
+
throttle: float = 1.5,
|
|
30
|
+
clock: Callable[[], float] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.token = token
|
|
33
|
+
self.message_id = message_id
|
|
34
|
+
self._edit = editor
|
|
35
|
+
self._throttle = throttle
|
|
36
|
+
self._clock = clock or _monotonic
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
# Cumulative text per (messageID, partID), joined in insertion order.
|
|
39
|
+
self._parts: dict[tuple[str, str], str] = {}
|
|
40
|
+
self._order: list[tuple[str, str]] = []
|
|
41
|
+
self._last_edit = float("-inf")
|
|
42
|
+
self._last_rendered = ""
|
|
43
|
+
|
|
44
|
+
def update_part(self, message_id: str, part_id: str, text: str) -> None:
|
|
45
|
+
"""Record the latest cumulative text of a text part, then maybe edit."""
|
|
46
|
+
key = (message_id, part_id)
|
|
47
|
+
with self._lock:
|
|
48
|
+
if key not in self._parts:
|
|
49
|
+
self._order.append(key)
|
|
50
|
+
self._parts[key] = text
|
|
51
|
+
rendered = self._render()
|
|
52
|
+
now = self._clock()
|
|
53
|
+
if rendered and rendered != self._last_rendered and (now - self._last_edit) >= self._throttle:
|
|
54
|
+
self._flush(rendered, now)
|
|
55
|
+
|
|
56
|
+
def finalize(self, text: str | None = None) -> None:
|
|
57
|
+
"""Force a final edit. If ``text`` is given it replaces the buffer."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
rendered = text if text is not None else self._render()
|
|
60
|
+
if rendered and rendered != self._last_rendered:
|
|
61
|
+
self._flush(rendered, self._clock())
|
|
62
|
+
|
|
63
|
+
def _render(self) -> str:
|
|
64
|
+
return "\n".join(self._parts[k] for k in self._order if self._parts[k]).strip()[:_MAX_LEN]
|
|
65
|
+
|
|
66
|
+
def _flush(self, rendered: str, now: float) -> None:
|
|
67
|
+
try:
|
|
68
|
+
self._edit(self.token, self.message_id, rendered)
|
|
69
|
+
self._last_rendered = rendered
|
|
70
|
+
self._last_edit = now
|
|
71
|
+
except Exception: # noqa: BLE001 - a failed edit must not kill the SSE loop
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _monotonic() -> float:
|
|
76
|
+
import time
|
|
77
|
+
|
|
78
|
+
return time.monotonic()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Speech-to-text via an OpenAI-compatible ``/audio/transcriptions`` endpoint.
|
|
2
|
+
|
|
3
|
+
Used to transcribe incoming Talk voice notes into a prompt. Compatible with
|
|
4
|
+
OpenAI, Groq, Together, and self-hosted Whisper servers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class STTError(Exception):
|
|
13
|
+
"""Transcription failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class STTClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
url: str,
|
|
20
|
+
*,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
model: str = "whisper-large-v3-turbo",
|
|
23
|
+
language: str | None = None,
|
|
24
|
+
timeout: float = 120.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._url = url.rstrip("/") + "/audio/transcriptions"
|
|
27
|
+
self._model = model
|
|
28
|
+
self._language = language
|
|
29
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
30
|
+
self._client = httpx.Client(headers=headers, timeout=timeout)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
self._client.close()
|
|
34
|
+
|
|
35
|
+
def transcribe(self, audio: bytes, filename: str = "audio.ogg") -> str:
|
|
36
|
+
data: dict[str, str] = {"model": self._model}
|
|
37
|
+
if self._language:
|
|
38
|
+
data["language"] = self._language
|
|
39
|
+
try:
|
|
40
|
+
resp = self._client.post(self._url, files={"file": (filename, audio)}, data=data)
|
|
41
|
+
except httpx.HTTPError as exc:
|
|
42
|
+
raise STTError(f"transcription request failed: {exc}") from exc
|
|
43
|
+
if resp.status_code >= 400:
|
|
44
|
+
raise STTError(f"transcription -> HTTP {resp.status_code}: {resp.text[:200]}")
|
|
45
|
+
try:
|
|
46
|
+
return (resp.json().get("text") or "").strip()
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise STTError(f"non-JSON transcription response: {exc}") from exc
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Nextcloud Talk gateway used by the bridge.
|
|
2
|
+
|
|
3
|
+
Two concerns, two underlying clients from ``nextcloud-talk-core`` (never
|
|
4
|
+
reimplemented here):
|
|
5
|
+
|
|
6
|
+
- **Sending / sharing / listing**: the high-level ``TalkClient``.
|
|
7
|
+
- **Polling**: the low-level ``OCSClient``, called directly so we can read the
|
|
8
|
+
raw ``actorId`` / ``actorType`` per message. The core ``Message`` model
|
|
9
|
+
collapses the author to a display name (``actorDisplayName``), which is unfit
|
|
10
|
+
for the security allowlist — so polling parses the raw OCS dicts itself,
|
|
11
|
+
mirroring core's ``wait_for_messages`` request exactly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from nextcloud_talk_core import (
|
|
20
|
+
Conversation,
|
|
21
|
+
NextcloudTalkError,
|
|
22
|
+
OCSClient,
|
|
23
|
+
Settings,
|
|
24
|
+
TalkClient,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .webdav import WebDavClient, WebDavError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FileRef:
|
|
32
|
+
"""A file shared into the conversation (WebDAV ``path`` + mime)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
path: str
|
|
36
|
+
mimetype: str
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_audio(self) -> bool:
|
|
40
|
+
return self.mimetype.startswith("audio/")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IncomingMessage:
|
|
45
|
+
id: int
|
|
46
|
+
actor_id: str
|
|
47
|
+
actor_type: str
|
|
48
|
+
actor_display_name: str
|
|
49
|
+
text: str
|
|
50
|
+
timestamp: int
|
|
51
|
+
is_system: bool
|
|
52
|
+
files: tuple[FileRef, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_message(raw: dict[str, Any]) -> IncomingMessage:
|
|
56
|
+
files = tuple(
|
|
57
|
+
FileRef(
|
|
58
|
+
name=p.get("name", ""),
|
|
59
|
+
path=p.get("path", ""),
|
|
60
|
+
mimetype=p.get("mimetype", ""),
|
|
61
|
+
)
|
|
62
|
+
for p in (raw.get("messageParameters") or {}).values()
|
|
63
|
+
if isinstance(p, dict) and p.get("type") == "file" and p.get("path")
|
|
64
|
+
)
|
|
65
|
+
return IncomingMessage(
|
|
66
|
+
id=int(raw["id"]),
|
|
67
|
+
actor_id=raw.get("actorId", ""),
|
|
68
|
+
actor_type=raw.get("actorType", ""),
|
|
69
|
+
actor_display_name=raw.get("actorDisplayName", ""),
|
|
70
|
+
text=raw.get("message", ""),
|
|
71
|
+
timestamp=int(raw.get("timestamp", 0)),
|
|
72
|
+
is_system=raw.get("systemMessage", "") != "" or raw.get("messageType") == "system",
|
|
73
|
+
files=files,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TalkGateway:
|
|
78
|
+
"""Polling + posting against Nextcloud Talk."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, settings: Settings) -> None:
|
|
81
|
+
self._settings = settings
|
|
82
|
+
self._talk = TalkClient(settings)
|
|
83
|
+
self._ocs = OCSClient(settings)
|
|
84
|
+
self._webdav = WebDavClient(settings)
|
|
85
|
+
self.own_user = settings.nc_user
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._talk.close()
|
|
89
|
+
self._ocs.close()
|
|
90
|
+
self._webdav.close()
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> TalkGateway:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *_exc: object) -> None:
|
|
96
|
+
self.close()
|
|
97
|
+
|
|
98
|
+
# --- read --------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def list_conversations(self) -> list[Conversation]:
|
|
101
|
+
return self._talk.list_conversations()
|
|
102
|
+
|
|
103
|
+
def latest_message_id(self, token: str) -> int:
|
|
104
|
+
"""Most recent message id in a conversation, or 0 if empty.
|
|
105
|
+
|
|
106
|
+
Used to initialise the long-poll cursor so a freshly-watched
|
|
107
|
+
conversation does not replay its whole history.
|
|
108
|
+
"""
|
|
109
|
+
data = self._ocs.get(
|
|
110
|
+
f"/api/v1/chat/{token}",
|
|
111
|
+
params={"lookIntoFuture": 0, "limit": 1},
|
|
112
|
+
)
|
|
113
|
+
if not data:
|
|
114
|
+
return 0
|
|
115
|
+
return max(int(m["id"]) for m in data)
|
|
116
|
+
|
|
117
|
+
def poll(self, token: str, last_known_message_id: int, timeout: int = 30) -> list[IncomingMessage]:
|
|
118
|
+
"""Long-poll for new messages after ``last_known_message_id``.
|
|
119
|
+
|
|
120
|
+
Mirrors ``nextcloud_talk_core.TalkClient.wait_for_messages`` but returns
|
|
121
|
+
raw-parsed messages including the stable author id. Returns [] on
|
|
122
|
+
timeout with no new messages.
|
|
123
|
+
"""
|
|
124
|
+
timeout = min(timeout, 60)
|
|
125
|
+
data = self._ocs.get(
|
|
126
|
+
f"/api/v1/chat/{token}",
|
|
127
|
+
params={
|
|
128
|
+
"lookIntoFuture": 1,
|
|
129
|
+
"lastKnownMessageId": last_known_message_id,
|
|
130
|
+
"limit": 100,
|
|
131
|
+
"timeout": timeout,
|
|
132
|
+
},
|
|
133
|
+
# HTTP timeout must outlast the server-side long-poll.
|
|
134
|
+
timeout=timeout + 30,
|
|
135
|
+
)
|
|
136
|
+
if not data:
|
|
137
|
+
return []
|
|
138
|
+
return [_parse_message(m) for m in data]
|
|
139
|
+
|
|
140
|
+
# --- write -------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def send(self, token: str, text: str, reply_to: int | None = None) -> int:
|
|
143
|
+
"""Post a message; return its id (so it can be edited for streaming)."""
|
|
144
|
+
msg = self._talk.send_message(token, text, reply_to=reply_to)
|
|
145
|
+
return msg.id
|
|
146
|
+
|
|
147
|
+
def edit(self, token: str, message_id: int, text: str) -> None:
|
|
148
|
+
"""Edit a previously-sent message (own messages, ≤24 h)."""
|
|
149
|
+
self._talk.edit_message(token, message_id, text)
|
|
150
|
+
|
|
151
|
+
def download(self, webdav_path: str) -> bytes:
|
|
152
|
+
"""Download a file from Nextcloud by its WebDAV path (for attachments)."""
|
|
153
|
+
return self._webdav.download(webdav_path)
|
|
154
|
+
|
|
155
|
+
def upload_and_share(
|
|
156
|
+
self,
|
|
157
|
+
token: str,
|
|
158
|
+
remote_path: str,
|
|
159
|
+
content: bytes,
|
|
160
|
+
*,
|
|
161
|
+
caption: str | None = None,
|
|
162
|
+
content_type: str = "text/markdown",
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Upload ``content`` to the server via WebDAV, then share it into the
|
|
165
|
+
conversation. Uploading first removes the dependency on a desktop sync
|
|
166
|
+
client having already pushed the file."""
|
|
167
|
+
path = self._webdav.upload(remote_path, content, content_type=content_type)
|
|
168
|
+
self._talk.share_file(token, path, caption=caption)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["FileRef", "IncomingMessage", "TalkGateway", "NextcloudTalkError", "WebDavError"]
|