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.
@@ -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"]