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,43 @@
|
|
|
1
|
+
"""Text-to-speech via an OpenAI-compatible ``/audio/speech`` endpoint.
|
|
2
|
+
|
|
3
|
+
Used to synthesise the assistant reply into an audio file that is uploaded to
|
|
4
|
+
Nextcloud and shared into the conversation (toggled per-conversation by /tts).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TTSError(Exception):
|
|
13
|
+
"""Synthesis failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TTSClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
url: str,
|
|
20
|
+
*,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
model: str = "gpt-4o-mini-tts",
|
|
23
|
+
voice: str = "alloy",
|
|
24
|
+
timeout: float = 120.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._url = url.rstrip("/") + "/audio/speech"
|
|
27
|
+
self._model = model
|
|
28
|
+
self._voice = voice
|
|
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 synthesize(self, text: str) -> bytes:
|
|
36
|
+
body = {"model": self._model, "input": text, "voice": self._voice}
|
|
37
|
+
try:
|
|
38
|
+
resp = self._client.post(self._url, json=body)
|
|
39
|
+
except httpx.HTTPError as exc:
|
|
40
|
+
raise TTSError(f"synthesis request failed: {exc}") from exc
|
|
41
|
+
if resp.status_code >= 400:
|
|
42
|
+
raise TTSError(f"synthesis -> HTTP {resp.status_code}: {resp.text[:200]}")
|
|
43
|
+
return resp.content
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Minimal WebDAV client for uploading attachments to Nextcloud.
|
|
2
|
+
|
|
3
|
+
``nextcloud-talk-core`` only speaks the OCS API; sharing a file into a Talk
|
|
4
|
+
conversation (``TalkClient.share_file``) requires the file to already exist on
|
|
5
|
+
the server. This client uploads the file first via WebDAV (``PUT``), creating
|
|
6
|
+
the target collection if needed (``MKCOL``), so the bridge does not depend on a
|
|
7
|
+
desktop sync client having uploaded it.
|
|
8
|
+
|
|
9
|
+
It reuses the same Basic-Auth app-password credentials as the OCS client.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from nextcloud_talk_core import Settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebDavError(Exception):
|
|
21
|
+
"""An upload or collection-creation failed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WebDavClient:
|
|
25
|
+
def __init__(self, settings: Settings, *, timeout: float = 60.0) -> None:
|
|
26
|
+
self._user = settings.nc_user
|
|
27
|
+
# Files live under the user's principal collection.
|
|
28
|
+
self._base = f"{settings.nc_url}/remote.php/dav/files/{quote(settings.nc_user)}"
|
|
29
|
+
self._client = httpx.Client(
|
|
30
|
+
auth=httpx.BasicAuth(settings.nc_user, settings.nc_app_password),
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
self._client.close()
|
|
36
|
+
|
|
37
|
+
def upload(self, remote_path: str, content: bytes, *, content_type: str = "text/markdown") -> str:
|
|
38
|
+
"""Upload ``content`` to ``remote_path`` (relative to the user root).
|
|
39
|
+
|
|
40
|
+
Creates parent collections on demand. Returns the normalised path
|
|
41
|
+
(leading slash, suitable for ``TalkClient.share_file``).
|
|
42
|
+
"""
|
|
43
|
+
path = "/" + remote_path.strip("/")
|
|
44
|
+
url = self._base + _encode_path(path)
|
|
45
|
+
resp = self._put(url, content, content_type)
|
|
46
|
+
if resp.status_code == 409: # parent collection missing
|
|
47
|
+
self._ensure_dir(_parent(path))
|
|
48
|
+
resp = self._put(url, content, content_type)
|
|
49
|
+
if resp.status_code >= 400:
|
|
50
|
+
raise WebDavError(f"PUT {path} -> HTTP {resp.status_code}: {resp.text[:200]}")
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
def download(self, remote_path: str) -> bytes:
|
|
54
|
+
"""Download a file by its path relative to the user root."""
|
|
55
|
+
path = "/" + remote_path.strip("/")
|
|
56
|
+
url = self._base + _encode_path(path)
|
|
57
|
+
try:
|
|
58
|
+
resp = self._client.get(url)
|
|
59
|
+
except httpx.HTTPError as exc:
|
|
60
|
+
raise WebDavError(f"download {path} failed: {exc}") from exc
|
|
61
|
+
if resp.status_code >= 400:
|
|
62
|
+
raise WebDavError(f"GET {path} -> HTTP {resp.status_code}")
|
|
63
|
+
return resp.content
|
|
64
|
+
|
|
65
|
+
def _put(self, url: str, content: bytes, content_type: str) -> httpx.Response:
|
|
66
|
+
try:
|
|
67
|
+
return self._client.request("PUT", url, content=content, headers={"Content-Type": content_type})
|
|
68
|
+
except httpx.HTTPError as exc:
|
|
69
|
+
raise WebDavError(f"upload failed: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
def _ensure_dir(self, dir_path: str) -> None:
|
|
72
|
+
"""MKCOL each segment of ``dir_path`` (idempotent)."""
|
|
73
|
+
parts = [p for p in dir_path.strip("/").split("/") if p]
|
|
74
|
+
cumulative = ""
|
|
75
|
+
for part in parts:
|
|
76
|
+
cumulative += "/" + part
|
|
77
|
+
url = self._base + _encode_path(cumulative)
|
|
78
|
+
try:
|
|
79
|
+
resp = self._client.request("MKCOL", url)
|
|
80
|
+
except httpx.HTTPError as exc:
|
|
81
|
+
raise WebDavError(f"MKCOL {cumulative} failed: {exc}") from exc
|
|
82
|
+
# 201 Created, or 405 Method Not Allowed (already exists) are both fine.
|
|
83
|
+
if resp.status_code not in (201, 405):
|
|
84
|
+
raise WebDavError(f"MKCOL {cumulative} -> HTTP {resp.status_code}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _encode_path(path: str) -> str:
|
|
88
|
+
"""Percent-encode each path segment, preserving slashes."""
|
|
89
|
+
return "/" + "/".join(quote(seg) for seg in path.strip("/").split("/") if seg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parent(path: str) -> str:
|
|
93
|
+
return path.rsplit("/", 1)[0] or "/"
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencode-talk-bridge
|
|
3
|
+
Version: 0.2.7
|
|
4
|
+
Summary: Drive a local OpenCode instance from Nextcloud Talk via polling — a self-hosted chat bridge for a coding agent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/leiverkus/opencode-talk-bridge
|
|
6
|
+
Project-URL: Repository, https://github.com/leiverkus/opencode-talk-bridge
|
|
7
|
+
Author: Patrick Leiverkus
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,bridge,coding-agent,nextcloud,opencode,talk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Chat
|
|
22
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: nextcloud-talk-core<2,>=1.0.2
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# opencode-talk-bridge
|
|
33
|
+
|
|
34
|
+
[](https://github.com/leiverkus/opencode-talk-bridge/actions/workflows/ci.yml)
|
|
35
|
+
[](https://github.com/leiverkus/opencode-talk-bridge/releases/latest)
|
|
36
|
+
[](https://github.com/leiverkus/opencode-talk-bridge/blob/main/pyproject.toml)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://github.com/astral-sh/ruff)
|
|
39
|
+
|
|
40
|
+
Drive a local [OpenCode](https://opencode.ai) coding agent from
|
|
41
|
+
[Nextcloud Talk](https://nextcloud.com/talk/) — a self-hosted chat bridge.
|
|
42
|
+
|
|
43
|
+
An incoming Talk message is forwarded to a locally-running `opencode serve`
|
|
44
|
+
HTTP API; the agent's reply (long output / code as a file attachment) is posted
|
|
45
|
+
back into the conversation. The bridge uses **polling** (no webhooks), so it
|
|
46
|
+
works on institutional Talk instances where you have no admin access and cannot
|
|
47
|
+
register a webhook bot.
|
|
48
|
+
|
|
49
|
+
> ⚠️ **This bridge runs AI coding-agent actions on your machine, triggered from
|
|
50
|
+
> chat in infrastructure you may not control.** Read the
|
|
51
|
+
> [Threat model](#threat-model) first. A non-empty user allowlist is mandatory —
|
|
52
|
+
> the bridge refuses to start without one.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Nextcloud Talk ──long-poll──▶ bridge ──HTTP──▶ opencode serve (localhost)
|
|
60
|
+
(you type) ◀──post──────── bridge ◀──SSE──── (agent runs locally)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Polling**, not webhooks: the bridge long-polls the Talk chat endpoint
|
|
64
|
+
(`lookIntoFuture=1` + `lastKnownMessageId`) using only your app password.
|
|
65
|
+
- **OpenCode stays local**: the bridge talks to `opencode serve` over
|
|
66
|
+
`127.0.0.1`. It does **not** start OpenCode — run it yourself, via launchd, or
|
|
67
|
+
via the companion menubar app. The bridge health-checks it and reports when
|
|
68
|
+
it is down.
|
|
69
|
+
- **Prompts block; permissions are concurrent**: `POST /session/{id}/message`
|
|
70
|
+
blocks until the turn finishes, so each prompt runs in a worker thread while a
|
|
71
|
+
shared SSE consumer (`/global/event`) routes permission requests back into the
|
|
72
|
+
conversation in real time.
|
|
73
|
+
|
|
74
|
+
## Requirements
|
|
75
|
+
|
|
76
|
+
- Python ≥ 3.10, macOS or Linux.
|
|
77
|
+
- A Nextcloud account with **Talk** and an **app password**
|
|
78
|
+
(Settings → Security → App passwords) — not your login password.
|
|
79
|
+
- A running `opencode serve` (OpenCode ≥ 1.15). Default endpoint
|
|
80
|
+
`http://127.0.0.1:4096`.
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
**One command** — installs the `opencode-talk-bridge` CLI into an isolated
|
|
85
|
+
environment and onto your `PATH` ([uv](https://docs.astral.sh/uv/) or
|
|
86
|
+
[pipx](https://pipx.pypa.io/)):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv tool install git+https://github.com/leiverkus/opencode-talk-bridge.git
|
|
90
|
+
# or: pipx install git+https://github.com/leiverkus/opencode-talk-bridge.git
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
> Once the first PyPI release is published this becomes
|
|
94
|
+
> `uv tool install opencode-talk-bridge` (no git URL) — see
|
|
95
|
+
> [docs/publishing.md](docs/publishing.md).
|
|
96
|
+
|
|
97
|
+
Then create your config interactively and run:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
opencode-talk-bridge --init # interactive .env wizard (chmod 600)
|
|
101
|
+
opencode-talk-bridge --check # validate config + OpenCode health
|
|
102
|
+
opencode-talk-bridge # run (reads ./.env, or pass --env-file PATH)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--init` asks only for the essentials; every other option keeps a safe default
|
|
106
|
+
and can be added later (see [`.env.example`](.env.example) for the full list).
|
|
107
|
+
|
|
108
|
+
First run against a real Talk server? Follow the
|
|
109
|
+
[end-to-end smoke test](docs/smoke-test.md) — it tests the riskiest paths first
|
|
110
|
+
and covers the two-accounts gotcha.
|
|
111
|
+
|
|
112
|
+
Upgrade with `uv tool upgrade opencode-talk-bridge` (or `pipx upgrade …`).
|
|
113
|
+
|
|
114
|
+
<details>
|
|
115
|
+
<summary>From source (for development)</summary>
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git clone https://github.com/leiverkus/opencode-talk-bridge.git
|
|
119
|
+
cd opencode-talk-bridge
|
|
120
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
121
|
+
pip install -e ".[dev]" # editable + test/lint tools
|
|
122
|
+
cp .env.example .env # then edit .env
|
|
123
|
+
```
|
|
124
|
+
</details>
|
|
125
|
+
|
|
126
|
+
The OCS client is the separate [`nextcloud-talk-core`](https://pypi.org/project/nextcloud-talk-core/)
|
|
127
|
+
package from PyPI (tracking the 1.x line) — never reimplemented or vendored here.
|
|
128
|
+
|
|
129
|
+
## Configure
|
|
130
|
+
|
|
131
|
+
Edit `.env` (see [`.env.example`](.env.example) for the full list):
|
|
132
|
+
|
|
133
|
+
| Variable | Required | Purpose |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `NC_URL`, `NC_USER`, `NC_APP_PASSWORD` | ✅ | Nextcloud Talk credentials (app password). |
|
|
136
|
+
| `TALK_CONVERSATIONS` | ✅ | Conversation token(s) to watch, or `all`. |
|
|
137
|
+
| `ALLOWED_USERS` | ✅ | Comma-separated **user IDs** allowed to issue commands. Empty ⇒ refuses to start. |
|
|
138
|
+
| `OPENCODE_URL` | – | OpenCode base URL (default `http://127.0.0.1:4096`). |
|
|
139
|
+
| `OPENCODE_USERNAME` / `OPENCODE_PASSWORD` | – | Basic-Auth if your OpenCode server is secured. |
|
|
140
|
+
| `OPENCODE_DIRECTORY` | – | Workspace directory for OpenCode sessions. |
|
|
141
|
+
| `OPENCODE_MODEL` | – | Default model `providerID/modelID`. |
|
|
142
|
+
| `SHARE_WEBDAV_DIR` | – | WebDAV folder (relative to user root) for code/long-output **and TTS** attachments. Created on demand; blank disables attachments. |
|
|
143
|
+
| `RESPONSE_STREAMING`, `STREAM_THROTTLE_MS` | – | Live-stream replies via message editing (default on, 1500 ms throttle). |
|
|
144
|
+
| `HIDE_TOOL_MESSAGES`, `HIDE_THINKING` | – | Suppress `💻 tool` / `💭 thinking` notices. |
|
|
145
|
+
| `BOT_LOCALE` | – | UI language: `de` (default) or `en`. |
|
|
146
|
+
| `STT_API_URL` / `STT_API_KEY` / `STT_MODEL` / `STT_LANGUAGE` | – | Whisper-compatible speech-to-text for voice notes. |
|
|
147
|
+
| `TTS_API_URL` / `TTS_API_KEY` / `TTS_MODEL` / `TTS_VOICE` | – | OpenAI-compatible text-to-speech for `/tts` replies. |
|
|
148
|
+
| `TASK_LIMIT`, `LIST_LIMIT`, `TRACK_BACKGROUND_SESSIONS` | – | Scheduler limit, picker size, background notices. |
|
|
149
|
+
| `DB_PATH`, `STATUS_FILE`, `LOG_LEVEL` | – | Storage + logging. |
|
|
150
|
+
|
|
151
|
+
> **`ALLOWED_USERS` must be the stable user ID** (the login, e.g. `jdoe`), **not
|
|
152
|
+
> the display name.** The bridge matches the OCS `actorId` with
|
|
153
|
+
> `actorType == "users"`, so guests and bots can never impersonate an allowed
|
|
154
|
+
> user even if display names collide.
|
|
155
|
+
|
|
156
|
+
## Run
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Validate config + check OpenCode health, then exit:
|
|
160
|
+
opencode-talk-bridge --check
|
|
161
|
+
|
|
162
|
+
# Run the bridge (foreground):
|
|
163
|
+
opencode-talk-bridge # or: python -m opencode_talk_bridge
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Commands (in Talk)
|
|
167
|
+
|
|
168
|
+
Send any message (or a **voice note** / **file**) to prompt OpenCode. The reply
|
|
169
|
+
**streams live** into one message as it is generated. Slash-commands:
|
|
170
|
+
|
|
171
|
+
| Command | Effect |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `/new` | Start a fresh OpenCode session for this conversation. |
|
|
174
|
+
| `/session` | Show the current session id. |
|
|
175
|
+
| `/sessions` | List & switch recent sessions. |
|
|
176
|
+
| `/rename <title>` | Rename the current session. |
|
|
177
|
+
| `/detach` | Detach from the current session. |
|
|
178
|
+
| `/messages` | Browse messages, then **revert** or **fork**. |
|
|
179
|
+
| `/model [providerID/modelID]` | Show, pick, or set the model. |
|
|
180
|
+
| `/agent [name]` | Show, pick, or set the agent (e.g. plan/build). |
|
|
181
|
+
| `/projects` | Switch the OpenCode project. |
|
|
182
|
+
| `/worktree` | Switch the git worktree. |
|
|
183
|
+
| `/commands` | Browse & run custom OpenCode commands. |
|
|
184
|
+
| `/skills` | Browse & run OpenCode skills. |
|
|
185
|
+
| `/mcps` | Enable/disable MCP servers. |
|
|
186
|
+
| `/tts` | Toggle spoken (audio) replies (needs `TTS_*` + `SHARE_WEBDAV_DIR`). |
|
|
187
|
+
| `/task <min> <prompt>` | Schedule a task (`/task every <min> …` to repeat). |
|
|
188
|
+
| `/tasklist` | List & delete scheduled tasks. |
|
|
189
|
+
| `/stop` | Abort the current run. |
|
|
190
|
+
| `/status` | Show bridge & OpenCode health. |
|
|
191
|
+
| `/help` | List commands. |
|
|
192
|
+
|
|
193
|
+
`/projects` lists every project the OpenCode backend knows (same as the desktop
|
|
194
|
+
app). `/sessions`, `/commands`, and `/skills` are **project-scoped**: they show
|
|
195
|
+
what the conversation's current project shows — pick a project with `/projects`
|
|
196
|
+
(or set a default `OPENCODE_DIRECTORY`). With no project bound, `/sessions` falls
|
|
197
|
+
back to a global list across all projects.
|
|
198
|
+
|
|
199
|
+
**No inline buttons** on Talk → every picker is a **numbered list**: reply with
|
|
200
|
+
the number. When OpenCode asks **permission** for a dangerous action, reply
|
|
201
|
+
**`ja`** (allow once), **`immer`** (allow for this session), or **`nein`** (deny);
|
|
202
|
+
agent **questions** are answered by their option number or free text.
|
|
203
|
+
|
|
204
|
+
Set `BOT_LOCALE=en` for English UI strings (default `de`).
|
|
205
|
+
|
|
206
|
+
## Run under launchd (macOS)
|
|
207
|
+
|
|
208
|
+
A user-agent example is in [`deploy/`](deploy/). Edit the paths, then:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
cp deploy/com.leiverkus.opencode-talk-bridge.plist ~/Library/LaunchAgents/
|
|
212
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.leiverkus.opencode-talk-bridge.plist
|
|
213
|
+
# stop / uninstall:
|
|
214
|
+
launchctl bootout gui/$(id -u)/com.leiverkus.opencode-talk-bridge
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Secrets stay in `.env` (read from `WorkingDirectory`), never in the plist.
|
|
218
|
+
|
|
219
|
+
## Status file (menubar-app contract)
|
|
220
|
+
|
|
221
|
+
The bridge atomically writes `STATUS_FILE` (JSON) on every state change. A
|
|
222
|
+
supervising app (e.g. the Swift menubar app) can poll it. The file is always
|
|
223
|
+
valid JSON (temp-write + rename). Schema:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"state": "polling",
|
|
228
|
+
"since": 1748600000,
|
|
229
|
+
"opencode_healthy": true,
|
|
230
|
+
"conversations": ["abcdef12"],
|
|
231
|
+
"last_error": null,
|
|
232
|
+
"version": "0.1.0"
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`state` ∈ `starting` · `polling` · `working` · `opencode_down` · `error` ·
|
|
237
|
+
`stopped`. `since` is a Unix timestamp; `conversations` lists watched tokens;
|
|
238
|
+
`last_error` is a human-readable string or `null`.
|
|
239
|
+
|
|
240
|
+
## Threat model
|
|
241
|
+
|
|
242
|
+
This bridge **executes coding-agent actions on your machine** (file writes,
|
|
243
|
+
shell commands via OpenCode), **triggered by chat messages in a Nextcloud Talk
|
|
244
|
+
instance you may not administer** (e.g. a university server). Treat that Talk
|
|
245
|
+
instance as semi-trusted infrastructure. The trust boundary and mitigations:
|
|
246
|
+
|
|
247
|
+
- **Access control is the allowlist.** Only `actorType == "users"` IDs in
|
|
248
|
+
`ALLOWED_USERS` can issue commands; every other message is ignored. An empty
|
|
249
|
+
allowlist aborts startup. Anyone who can post as an allowlisted user can run
|
|
250
|
+
code as you — keep the list minimal and the control conversation private.
|
|
251
|
+
- **Dangerous actions require confirmation.** OpenCode's permission prompts
|
|
252
|
+
(shell, file writes) are surfaced into Talk and must be answered; nothing
|
|
253
|
+
runs without a `ja`/`immer`. Configure OpenCode's own permission policy
|
|
254
|
+
conservatively as defence in depth.
|
|
255
|
+
- **Secrets are never echoed.** The bridge does not post your app password or
|
|
256
|
+
tokens, and permission prompts show only the action kind and a length-capped
|
|
257
|
+
pattern — not raw command arguments that might contain secrets. Be aware that
|
|
258
|
+
OpenCode's **answer text itself** could contain sensitive repo content; it is
|
|
259
|
+
posted into Talk, whose admins can read it.
|
|
260
|
+
- **A compromised Talk server** could inject messages appearing to come from an
|
|
261
|
+
allowlisted user. The allowlist mitigates casual misuse, not a fully
|
|
262
|
+
compromised IdP/server. Do not point this at sensitive repos if that is your
|
|
263
|
+
threat model.
|
|
264
|
+
- **Local-only OpenCode.** Keep `opencode serve` bound to `127.0.0.1`. If you
|
|
265
|
+
expose it, secure it with `OPENCODE_USERNAME`/`OPENCODE_PASSWORD`.
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
ruff check src tests
|
|
271
|
+
ruff format --check src tests
|
|
272
|
+
pytest
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
All tests use mocked HTTP for both Talk and OpenCode — no live calls. CI runs
|
|
276
|
+
the matrix on Python 3.10–3.13.
|
|
277
|
+
|
|
278
|
+
## Changelog
|
|
279
|
+
|
|
280
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
281
|
+
|
|
282
|
+
## License
|
|
283
|
+
|
|
284
|
+
MIT © Patrick Leiverkus
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
opencode_talk_bridge/__init__.py,sha256=exajW8FF_KXe_QUGznQFmcUv5s4MHRySAITH1xz-lOk,337
|
|
2
|
+
opencode_talk_bridge/__main__.py,sha256=MZIwJSJ9Wn5BtPJjGUKjtFuMgpcJ7ZRScXG9Bqo-MAQ,3532
|
|
3
|
+
opencode_talk_bridge/allowlist.py,sha256=BpFevdSMMW6aYHLMHeJXIQ7IcPj--F0YmiWFaXl-Xz8,1421
|
|
4
|
+
opencode_talk_bridge/bridge.py,sha256=TbXCMhJlu-PBMeMpUF5BUXXn_75OJFv1GgAXlho5N0M,41388
|
|
5
|
+
opencode_talk_bridge/commands.py,sha256=xQ3ivjglQqeJCfW0lmy00PiB5THG9KpWhq8VAx6NQpk,1336
|
|
6
|
+
opencode_talk_bridge/config.py,sha256=AtQsLYH7N3tS7eG6z6evLAnsiXobw8d8R4Fv3IFwmKU,6148
|
|
7
|
+
opencode_talk_bridge/events.py,sha256=-5y0apug1FSfpZHK0uPvZC69wd_Vw_9eRVMx29MSdSQ,2209
|
|
8
|
+
opencode_talk_bridge/init.py,sha256=rZJnE-oiNp0PDf_vXoL-6orfqpbuuNi8q-JURPWzDUc,4439
|
|
9
|
+
opencode_talk_bridge/messages.py,sha256=67X0VJIwk43ymxTaJAOop5NJaG85Byzucfhfi3wmUT8,10813
|
|
10
|
+
opencode_talk_bridge/opencode.py,sha256=MyE1pdhS0ljr6gXcnIexpq0R5LwrZoNUXzt--g126fk,16019
|
|
11
|
+
opencode_talk_bridge/pending.py,sha256=u2zci56Ff0x_7HoHuLu7tVr0RKLzStmYCgOAsLBeSaw,3491
|
|
12
|
+
opencode_talk_bridge/permissions.py,sha256=rC8MiyGYW26yMrAm2WCzU_C0a_XUsbm0qSVkcZtzhn4,2683
|
|
13
|
+
opencode_talk_bridge/scheduler.py,sha256=1xXweIiBeLPAO8v02DzLebvA-13mOtw22ezFY089ElQ,4606
|
|
14
|
+
opencode_talk_bridge/sessions.py,sha256=bM-9J1x8v4Oc9qyf6OphFuPINTsdWh-YVNO5ISV5oQY,5375
|
|
15
|
+
opencode_talk_bridge/status.py,sha256=J6hUgnPOPjapqjyNU0v2TC7pAzaqcy0-aVviXdfPht0,2773
|
|
16
|
+
opencode_talk_bridge/streaming.py,sha256=rL94ckr1hTLAEDARCsqToCHyvH2kpFlRE5ATrrC7Pr8,2873
|
|
17
|
+
opencode_talk_bridge/stt.py,sha256=NRUuTblfKk7aKpembg6xj_8Uco-P2_U7UxeRWXQJFRk,1642
|
|
18
|
+
opencode_talk_bridge/talk.py,sha256=LY5OJoZ7s8gfZq2yDNJm4yF57gIjM3D4ntJE-B9eNHQ,5603
|
|
19
|
+
opencode_talk_bridge/tts.py,sha256=P-ijB8ZwHJaTbr8q1UorrHyhJMKJwjcTzzPBngkh_ng,1344
|
|
20
|
+
opencode_talk_bridge/webdav.py,sha256=M6H6B9qICQ4M6OqRYj-QQfK1n9ZP-GZWFESdhA-M9T0,3816
|
|
21
|
+
opencode_talk_bridge-0.2.7.dist-info/METADATA,sha256=AZced9Bx0AGRrvZnz1fLOEmd590V6dzMNXs_a76IsGA,12797
|
|
22
|
+
opencode_talk_bridge-0.2.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
23
|
+
opencode_talk_bridge-0.2.7.dist-info/entry_points.txt,sha256=H82IwEFIW6PleyEGzEeH8M2u2iKYohA-IBGUwevnPNI,76
|
|
24
|
+
opencode_talk_bridge-0.2.7.dist-info/licenses/LICENSE,sha256=fseWi5ej02eIb3nyWOsK_MIuBfK5uOFipv5WCGkrwCQ,1074
|
|
25
|
+
opencode_talk_bridge-0.2.7.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Leiverkus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|