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,1014 @@
|
|
|
1
|
+
"""Bridge orchestration: glue Nextcloud Talk polling to the OpenCode server.
|
|
2
|
+
|
|
3
|
+
Threading model (sync throughout, to match ``nextcloud-talk-core``):
|
|
4
|
+
|
|
5
|
+
- One **poll thread per watched conversation** runs a Talk long-poll loop.
|
|
6
|
+
- One **SSE thread** consumes ``/global/event`` for the whole server, routes
|
|
7
|
+
permission/question asks to the bound conversation, and feeds streaming text
|
|
8
|
+
deltas + tool/thinking notices into the live answer message.
|
|
9
|
+
- Each prompt runs in an **ephemeral worker thread**, because
|
|
10
|
+
``POST /session/{id}/message`` blocks until the turn finishes — and that turn
|
|
11
|
+
may pause for a permission/question whose reply arrives on the conversation's
|
|
12
|
+
poll loop. Running the prompt inline would deadlock that handshake.
|
|
13
|
+
|
|
14
|
+
Talk has no inline buttons, so every interactive flow (permission, question,
|
|
15
|
+
list picker) is a **pending interaction** resolved by the next reply
|
|
16
|
+
(see ``pending.py``). Live streaming uses Talk message editing (``streaming.py``).
|
|
17
|
+
Shared in-memory maps are guarded by ``self._lock``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from . import commands
|
|
28
|
+
from .allowlist import Allowlist
|
|
29
|
+
from .config import Config
|
|
30
|
+
from .events import (
|
|
31
|
+
PermissionEvent,
|
|
32
|
+
QuestionEvent,
|
|
33
|
+
ReasoningDelta,
|
|
34
|
+
SessionError,
|
|
35
|
+
SessionIdle,
|
|
36
|
+
TextDelta,
|
|
37
|
+
ToolEvent,
|
|
38
|
+
classify,
|
|
39
|
+
)
|
|
40
|
+
from .messages import translator
|
|
41
|
+
from .opencode import (
|
|
42
|
+
OpenCodeClient,
|
|
43
|
+
OpenCodeDownError,
|
|
44
|
+
OpenCodeError,
|
|
45
|
+
PermissionAsk,
|
|
46
|
+
PromptResult,
|
|
47
|
+
QuestionAsk,
|
|
48
|
+
)
|
|
49
|
+
from .pending import (
|
|
50
|
+
PendingRegistry,
|
|
51
|
+
PermissionPending,
|
|
52
|
+
QuestionPending,
|
|
53
|
+
SelectionPending,
|
|
54
|
+
SelectItem,
|
|
55
|
+
format_question,
|
|
56
|
+
format_selection,
|
|
57
|
+
parse_choice,
|
|
58
|
+
)
|
|
59
|
+
from .permissions import format_prompt, interpret_reply
|
|
60
|
+
from .scheduler import Scheduler, TaskStore
|
|
61
|
+
from .sessions import SessionStore
|
|
62
|
+
from .status import StatusWriter
|
|
63
|
+
from .streaming import StreamState
|
|
64
|
+
from .stt import STTClient, STTError
|
|
65
|
+
from .talk import FileRef, IncomingMessage, NextcloudTalkError, TalkGateway, WebDavError
|
|
66
|
+
from .tts import TTSClient, TTSError
|
|
67
|
+
|
|
68
|
+
log = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
_TOOL_EMOJI = {
|
|
71
|
+
"bash": "💻",
|
|
72
|
+
"read": "📖",
|
|
73
|
+
"edit": "✏️",
|
|
74
|
+
"write": "✏️",
|
|
75
|
+
"grep": "🔎",
|
|
76
|
+
"glob": "🔎",
|
|
77
|
+
"list": "📂",
|
|
78
|
+
"webfetch": "🌐",
|
|
79
|
+
"todowrite": "📝",
|
|
80
|
+
"task": "🤖",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Bridge:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
config: Config,
|
|
88
|
+
gateway: TalkGateway,
|
|
89
|
+
opencode: OpenCodeClient,
|
|
90
|
+
store: SessionStore,
|
|
91
|
+
status: StatusWriter,
|
|
92
|
+
*,
|
|
93
|
+
stt: STTClient | None = None,
|
|
94
|
+
tts: TTSClient | None = None,
|
|
95
|
+
task_store: TaskStore | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._cfg = config
|
|
98
|
+
self._talk = gateway
|
|
99
|
+
self._oc = opencode
|
|
100
|
+
self._store = store
|
|
101
|
+
self._status = status
|
|
102
|
+
self._stt = stt
|
|
103
|
+
self._tts = tts
|
|
104
|
+
self._task_store = task_store
|
|
105
|
+
self._scheduler: Scheduler | None = None
|
|
106
|
+
self._allow = Allowlist(config.allowed_users)
|
|
107
|
+
self._pending = PendingRegistry()
|
|
108
|
+
self._t = translator(config.bot_locale)
|
|
109
|
+
|
|
110
|
+
self._stop = threading.Event()
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
self._session_to_token: dict[str, str] = {}
|
|
113
|
+
self._busy: dict[str, threading.Thread] = {}
|
|
114
|
+
self._streams: dict[str, StreamState] = {}
|
|
115
|
+
self._announced: dict[str, set[str]] = {} # session_id -> announced tool/thinking keys
|
|
116
|
+
self._threads: list[threading.Thread] = []
|
|
117
|
+
|
|
118
|
+
# --- lifecycle ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def run(self) -> None:
|
|
121
|
+
"""Start all threads and block until stopped."""
|
|
122
|
+
tokens = self._resolve_tokens()
|
|
123
|
+
if not tokens:
|
|
124
|
+
raise RuntimeError("no conversations to watch — check TALK_CONVERSATIONS")
|
|
125
|
+
self._load_session_map()
|
|
126
|
+
|
|
127
|
+
self._status.update(
|
|
128
|
+
state="polling", since=_now(), conversations=tokens, opencode_healthy=self._oc.health()
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if self._task_store is not None:
|
|
132
|
+
self._scheduler = Scheduler(self._task_store, self._run_scheduled)
|
|
133
|
+
self._scheduler.start()
|
|
134
|
+
|
|
135
|
+
sse = threading.Thread(target=self._sse_loop, name="sse", daemon=True)
|
|
136
|
+
sse.start()
|
|
137
|
+
self._threads.append(sse)
|
|
138
|
+
for token in tokens:
|
|
139
|
+
t = threading.Thread(target=self._poll_loop, args=(token,), name=f"poll:{token}", daemon=True)
|
|
140
|
+
t.start()
|
|
141
|
+
self._threads.append(t)
|
|
142
|
+
|
|
143
|
+
log.info("bridge running, watching %d conversation(s)", len(tokens))
|
|
144
|
+
try:
|
|
145
|
+
while not self._stop.is_set():
|
|
146
|
+
self._stop.wait(1.0)
|
|
147
|
+
finally:
|
|
148
|
+
self._status.update(state="stopped")
|
|
149
|
+
|
|
150
|
+
def stop(self) -> None:
|
|
151
|
+
log.info("stopping bridge")
|
|
152
|
+
self._stop.set()
|
|
153
|
+
if self._scheduler is not None:
|
|
154
|
+
self._scheduler.stop()
|
|
155
|
+
# Closing the OpenCode client breaks the blocking SSE stream.
|
|
156
|
+
self._oc.close()
|
|
157
|
+
|
|
158
|
+
def _run_scheduled(self, token: str, prompt: str) -> None:
|
|
159
|
+
self._start_prompt(token, prompt)
|
|
160
|
+
|
|
161
|
+
def _resolve_tokens(self) -> list[str]:
|
|
162
|
+
if not self._cfg.watch_all:
|
|
163
|
+
return list(self._cfg.conversations)
|
|
164
|
+
try:
|
|
165
|
+
return [c.token for c in self._talk.list_conversations()]
|
|
166
|
+
except NextcloudTalkError as exc:
|
|
167
|
+
log.error("could not list conversations: %s", exc)
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
def _load_session_map(self) -> None:
|
|
171
|
+
# Re-bind sessionID -> token for any conversations with a stored session.
|
|
172
|
+
for token in self._resolve_tokens():
|
|
173
|
+
sid = self._store.session_id_for(token)
|
|
174
|
+
if sid:
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._session_to_token[sid] = token
|
|
177
|
+
|
|
178
|
+
# --- Talk poll loop ----------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _poll_loop(self, token: str) -> None:
|
|
181
|
+
state = self._store.get_or_create(token)
|
|
182
|
+
last_id = state.last_known_message_id
|
|
183
|
+
if last_id == 0:
|
|
184
|
+
try:
|
|
185
|
+
last_id = self._talk.latest_message_id(token)
|
|
186
|
+
self._store.update_last_message_id(token, last_id, now=_now())
|
|
187
|
+
except NextcloudTalkError as exc:
|
|
188
|
+
log.warning("[%s] could not init cursor: %s", token, exc)
|
|
189
|
+
|
|
190
|
+
while not self._stop.is_set():
|
|
191
|
+
try:
|
|
192
|
+
messages = self._talk.poll(token, last_id, timeout=self._cfg.poll_timeout)
|
|
193
|
+
except NextcloudTalkError as exc:
|
|
194
|
+
log.warning("[%s] poll error: %s", token, exc)
|
|
195
|
+
self._stop.wait(2.0)
|
|
196
|
+
continue
|
|
197
|
+
for msg in messages:
|
|
198
|
+
last_id = max(last_id, msg.id)
|
|
199
|
+
self._store.update_last_message_id(token, last_id, now=_now())
|
|
200
|
+
if self._stop.is_set():
|
|
201
|
+
break
|
|
202
|
+
# Last-resort isolation: a single bad message (e.g. an OpenCode
|
|
203
|
+
# HTTP 4xx/5xx in a command handler) must never kill this
|
|
204
|
+
# conversation's poll thread. Log it, tell the user, keep polling.
|
|
205
|
+
try:
|
|
206
|
+
self._handle_message(token, msg)
|
|
207
|
+
except Exception: # noqa: BLE001
|
|
208
|
+
log.exception("[%s] error handling message %s", token, msg.id)
|
|
209
|
+
self._say(token, self._t("unexpected"))
|
|
210
|
+
|
|
211
|
+
def _handle_message(self, token: str, msg: IncomingMessage) -> None:
|
|
212
|
+
# Never react to system messages or our own posts (avoids loops).
|
|
213
|
+
if msg.is_system or msg.actor_id == self._talk.own_user:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
allowed = self._allow.is_allowed(msg.actor_id, msg.actor_type)
|
|
217
|
+
|
|
218
|
+
# A pending interaction (permission/question/selection) takes priority:
|
|
219
|
+
# an allowlisted user's reply may resolve it.
|
|
220
|
+
pending = self._pending.get(token)
|
|
221
|
+
if pending is not None and allowed and self._resolve_pending(token, pending, msg.text):
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if not allowed:
|
|
225
|
+
log.info("[%s] ignoring message from %s (%s)", token, msg.actor_id, msg.actor_type)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
parsed = commands.parse(msg.text)
|
|
229
|
+
if isinstance(parsed, commands.Command):
|
|
230
|
+
self._handle_command(token, parsed)
|
|
231
|
+
elif parsed.text or msg.files:
|
|
232
|
+
self._start_prompt(token, parsed.text, msg.files)
|
|
233
|
+
|
|
234
|
+
# --- pending interaction resolution -----------------------------------
|
|
235
|
+
|
|
236
|
+
def _resolve_pending(self, token: str, pending: object, text: str) -> bool:
|
|
237
|
+
"""Try to resolve the pending interaction. Returns True if consumed."""
|
|
238
|
+
if isinstance(pending, PermissionPending):
|
|
239
|
+
outcome = interpret_reply(text)
|
|
240
|
+
if outcome is None:
|
|
241
|
+
return False
|
|
242
|
+
self._pending.pop(token)
|
|
243
|
+
self._answer_permission(token, pending.ask, outcome)
|
|
244
|
+
return True
|
|
245
|
+
if isinstance(pending, QuestionPending):
|
|
246
|
+
return self._answer_question(token, pending.ask, text)
|
|
247
|
+
if isinstance(pending, SelectionPending):
|
|
248
|
+
idx = parse_choice(text, len(pending.items))
|
|
249
|
+
if idx is None:
|
|
250
|
+
return False
|
|
251
|
+
self._pending.pop(token)
|
|
252
|
+
pending.on_select(pending.items[idx].value)
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
def _answer_permission(self, token: str, ask: PermissionAsk, outcome: str) -> None:
|
|
257
|
+
try:
|
|
258
|
+
self._oc.reply_permission(ask.request_id, outcome)
|
|
259
|
+
self._say(
|
|
260
|
+
token,
|
|
261
|
+
self._t({"once": "perm_once", "always": "perm_always", "reject": "perm_reject"}[outcome]),
|
|
262
|
+
)
|
|
263
|
+
except OpenCodeDownError:
|
|
264
|
+
self._say(token, self._t("down"))
|
|
265
|
+
|
|
266
|
+
def _answer_question(self, token: str, ask: QuestionAsk, text: str) -> bool:
|
|
267
|
+
answer: str | None = None
|
|
268
|
+
if ask.options:
|
|
269
|
+
idx = parse_choice(text, len(ask.options))
|
|
270
|
+
if idx is not None:
|
|
271
|
+
answer = ask.options[idx].label
|
|
272
|
+
elif ask.custom and text.strip():
|
|
273
|
+
answer = text.strip()
|
|
274
|
+
else:
|
|
275
|
+
return False
|
|
276
|
+
else:
|
|
277
|
+
answer = text.strip()
|
|
278
|
+
if not answer:
|
|
279
|
+
return False
|
|
280
|
+
self._pending.pop(token)
|
|
281
|
+
try:
|
|
282
|
+
self._oc.reply_question(ask.request_id, answer)
|
|
283
|
+
self._say(token, self._t("answered", answer=answer))
|
|
284
|
+
except OpenCodeDownError:
|
|
285
|
+
self._say(token, self._t("down"))
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
# --- commands ----------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _handle_command(self, token: str, cmd: commands.Command) -> None:
|
|
291
|
+
handlers = {
|
|
292
|
+
"help": lambda: self._say(token, self._t("help")),
|
|
293
|
+
"new": lambda: self._cmd_new(token),
|
|
294
|
+
"session": lambda: self._cmd_session(token),
|
|
295
|
+
"sessions": lambda: self._cmd_sessions(token),
|
|
296
|
+
"model": lambda: self._handle_model(token, cmd.arg),
|
|
297
|
+
"agent": lambda: self._handle_agent(token, cmd.arg),
|
|
298
|
+
"projects": lambda: self._cmd_projects(token),
|
|
299
|
+
"worktree": lambda: self._cmd_worktree(token),
|
|
300
|
+
"messages": lambda: self._cmd_messages(token),
|
|
301
|
+
"commands": lambda: self._cmd_commands(token),
|
|
302
|
+
"skills": lambda: self._cmd_skills(token),
|
|
303
|
+
"mcps": lambda: self._cmd_mcps(token),
|
|
304
|
+
"rename": lambda: self._cmd_rename(token, cmd.arg),
|
|
305
|
+
"detach": lambda: self._cmd_detach(token),
|
|
306
|
+
"tts": lambda: self._cmd_tts(token),
|
|
307
|
+
"task": lambda: self._cmd_task(token, cmd.arg),
|
|
308
|
+
"tasklist": lambda: self._cmd_tasklist(token),
|
|
309
|
+
"stop": lambda: self._handle_stop(token),
|
|
310
|
+
"status": lambda: self._handle_status(token),
|
|
311
|
+
}
|
|
312
|
+
handler = handlers.get(cmd.name)
|
|
313
|
+
if not handler:
|
|
314
|
+
return
|
|
315
|
+
# Command handlers run inline on the poll thread and call OpenCode; a
|
|
316
|
+
# non-down HTTP error (4xx/5xx -> OpenCodeError) would otherwise escape.
|
|
317
|
+
try:
|
|
318
|
+
handler()
|
|
319
|
+
except OpenCodeDownError:
|
|
320
|
+
self._say(token, self._t("down"))
|
|
321
|
+
except OpenCodeError as exc:
|
|
322
|
+
log.warning("[%s] command /%s failed: %s", token, cmd.name, exc)
|
|
323
|
+
self._say(token, self._t("oc_error"))
|
|
324
|
+
|
|
325
|
+
def _cmd_new(self, token: str) -> None:
|
|
326
|
+
self._store.clear_session(token, now=_now())
|
|
327
|
+
self._say(token, self._t("new_session"))
|
|
328
|
+
|
|
329
|
+
def _cmd_session(self, token: str) -> None:
|
|
330
|
+
sid = self._store.session_id_for(token)
|
|
331
|
+
self._say(token, self._t("session_is", sid=sid) if sid else self._t("no_session_yet"))
|
|
332
|
+
|
|
333
|
+
def _conv_dir(self, token: str) -> str | None:
|
|
334
|
+
"""The project directory bound to this conversation (via /projects), or
|
|
335
|
+
None to fall back to the server's default (OPENCODE_DIRECTORY)."""
|
|
336
|
+
state = self._store.get(token)
|
|
337
|
+
return state.directory if state else None
|
|
338
|
+
|
|
339
|
+
def _cmd_sessions(self, token: str) -> None:
|
|
340
|
+
try:
|
|
341
|
+
sessions = self._oc.list_sessions(self._conv_dir(token))
|
|
342
|
+
except OpenCodeDownError:
|
|
343
|
+
self._say(token, self._t("down"))
|
|
344
|
+
return
|
|
345
|
+
sessions = sessions[: self._cfg.list_limit]
|
|
346
|
+
if not sessions:
|
|
347
|
+
self._say(token, self._t("no_sessions"))
|
|
348
|
+
return
|
|
349
|
+
items = [
|
|
350
|
+
SelectItem(
|
|
351
|
+
label=(s.get("title") or s.get("id", "?")), value=s["id"], description=s.get("id", "")[:12]
|
|
352
|
+
)
|
|
353
|
+
for s in sessions
|
|
354
|
+
if s.get("id")
|
|
355
|
+
]
|
|
356
|
+
self._offer_selection(
|
|
357
|
+
token, self._t("title_sessions"), items, lambda sid: self._switch_session(token, sid)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _switch_session(self, token: str, session_id: str) -> None:
|
|
361
|
+
self._store.set_session(token, session_id, now=_now())
|
|
362
|
+
with self._lock:
|
|
363
|
+
self._session_to_token[session_id] = token
|
|
364
|
+
self._say(token, self._t("session_switched", sid=session_id))
|
|
365
|
+
|
|
366
|
+
def _cmd_rename(self, token: str, arg: str) -> None:
|
|
367
|
+
sid = self._store.session_id_for(token)
|
|
368
|
+
if not sid:
|
|
369
|
+
self._say(token, self._t("no_session_rename"))
|
|
370
|
+
return
|
|
371
|
+
if not arg:
|
|
372
|
+
self._say(token, self._t("rename_usage"))
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
self._oc.rename_session(sid, arg.strip())
|
|
376
|
+
self._say(token, self._t("renamed", title=arg.strip()))
|
|
377
|
+
except OpenCodeDownError:
|
|
378
|
+
self._say(token, self._t("down"))
|
|
379
|
+
|
|
380
|
+
def _cmd_detach(self, token: str) -> None:
|
|
381
|
+
self._store.clear_session(token, now=_now())
|
|
382
|
+
self._say(token, self._t("detached"))
|
|
383
|
+
|
|
384
|
+
def _cmd_tts(self, token: str) -> None:
|
|
385
|
+
if not (self._cfg.tts_url and self._cfg.tts_key):
|
|
386
|
+
self._say(token, self._t("tts_unconfigured"))
|
|
387
|
+
return
|
|
388
|
+
state = self._store.get(token)
|
|
389
|
+
new_value = not (state.tts_enabled if state else False)
|
|
390
|
+
self._store.set_tts(token, new_value, now=_now())
|
|
391
|
+
self._say(token, self._t("tts_on" if new_value else "tts_off"))
|
|
392
|
+
|
|
393
|
+
def _cmd_task(self, token: str, arg: str) -> None:
|
|
394
|
+
if self._task_store is None:
|
|
395
|
+
self._say(token, self._t("no_scheduler"))
|
|
396
|
+
return
|
|
397
|
+
parsed = _parse_task_arg(arg)
|
|
398
|
+
if parsed is None:
|
|
399
|
+
self._say(token, self._t("task_usage"))
|
|
400
|
+
return
|
|
401
|
+
run_in, interval, prompt = parsed
|
|
402
|
+
if self._task_store.count() >= self._cfg.task_limit:
|
|
403
|
+
self._say(token, self._t("task_limit", limit=self._cfg.task_limit))
|
|
404
|
+
return
|
|
405
|
+
now = _now()
|
|
406
|
+
self._task_store.add(token, prompt, now + run_in, interval, now=now)
|
|
407
|
+
self._say(token, self._t("task_created", minutes=run_in // 60))
|
|
408
|
+
|
|
409
|
+
def _cmd_tasklist(self, token: str) -> None:
|
|
410
|
+
if self._task_store is None:
|
|
411
|
+
self._say(token, self._t("no_scheduler"))
|
|
412
|
+
return
|
|
413
|
+
tasks = self._task_store.list(token)
|
|
414
|
+
if not tasks:
|
|
415
|
+
self._say(token, self._t("task_none"))
|
|
416
|
+
return
|
|
417
|
+
items = [
|
|
418
|
+
SelectItem(
|
|
419
|
+
label=(t.prompt[:40] + ("…" if len(t.prompt) > 40 else "")),
|
|
420
|
+
value=str(t.id),
|
|
421
|
+
description=("⟳" if t.interval_s else ""),
|
|
422
|
+
)
|
|
423
|
+
for t in tasks
|
|
424
|
+
]
|
|
425
|
+
self._offer_selection(token, self._t("title_tasks"), items, lambda tid: self._delete_task(token, tid))
|
|
426
|
+
|
|
427
|
+
def _delete_task(self, token: str, task_id: str) -> None:
|
|
428
|
+
if self._task_store is not None:
|
|
429
|
+
self._task_store.delete(int(task_id))
|
|
430
|
+
self._say(token, self._t("task_deleted"))
|
|
431
|
+
|
|
432
|
+
def _cmd_projects(self, token: str) -> None:
|
|
433
|
+
try:
|
|
434
|
+
projects = self._oc.list_projects()
|
|
435
|
+
except OpenCodeDownError:
|
|
436
|
+
self._say(token, self._t("down"))
|
|
437
|
+
return
|
|
438
|
+
items = [
|
|
439
|
+
SelectItem(label=(p.get("name") or p.get("worktree", "?")), value=p.get("worktree", ""))
|
|
440
|
+
for p in projects[: self._cfg.list_limit]
|
|
441
|
+
if p.get("worktree")
|
|
442
|
+
]
|
|
443
|
+
if not items:
|
|
444
|
+
self._say(token, self._t("no_projects"))
|
|
445
|
+
return
|
|
446
|
+
self._offer_selection(
|
|
447
|
+
token, self._t("title_projects"), items, lambda d: self._switch_directory(token, d, "project")
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _cmd_worktree(self, token: str) -> None:
|
|
451
|
+
try:
|
|
452
|
+
worktrees = self._oc.list_worktrees()
|
|
453
|
+
except OpenCodeDownError:
|
|
454
|
+
self._say(token, self._t("down"))
|
|
455
|
+
return
|
|
456
|
+
items = [SelectItem(label=w, value=w) for w in worktrees[: self._cfg.list_limit]]
|
|
457
|
+
if not items:
|
|
458
|
+
self._say(token, self._t("no_worktrees"))
|
|
459
|
+
return
|
|
460
|
+
self._offer_selection(
|
|
461
|
+
token, self._t("title_worktrees"), items, lambda d: self._switch_directory(token, d, "worktree")
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _switch_directory(self, token: str, directory: str, kind: str) -> None:
|
|
465
|
+
# Switching project/worktree binds future sessions to a new directory;
|
|
466
|
+
# drop the current session so the next prompt creates one there.
|
|
467
|
+
self._store.set_directory(token, directory, now=_now())
|
|
468
|
+
self._store.clear_session(token, now=_now())
|
|
469
|
+
label = self._t("title_projects" if kind == "project" else "title_worktrees").strip(": 📁🌿")
|
|
470
|
+
self._say(token, self._t("dir_switched", kind=label, directory=directory))
|
|
471
|
+
|
|
472
|
+
def _cmd_commands(self, token: str) -> None:
|
|
473
|
+
try:
|
|
474
|
+
cmds = self._oc.list_commands(self._conv_dir(token))
|
|
475
|
+
except OpenCodeDownError:
|
|
476
|
+
self._say(token, self._t("down"))
|
|
477
|
+
return
|
|
478
|
+
# Skills also surface here with source "skill"; show them under /skills.
|
|
479
|
+
cmds = [c for c in cmds if c.get("source") != "skill"]
|
|
480
|
+
items = [
|
|
481
|
+
SelectItem(label=c["name"], value=c["name"], description=(c.get("description") or "")[:40])
|
|
482
|
+
for c in cmds[: self._cfg.list_limit]
|
|
483
|
+
if c.get("name")
|
|
484
|
+
]
|
|
485
|
+
if not items:
|
|
486
|
+
self._say(token, self._t("no_commands"))
|
|
487
|
+
return
|
|
488
|
+
self._offer_selection(
|
|
489
|
+
token, self._t("title_commands"), items, lambda name: self._run_command(token, name)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def _cmd_skills(self, token: str) -> None:
|
|
493
|
+
try:
|
|
494
|
+
skills = self._oc.list_skills(self._conv_dir(token))
|
|
495
|
+
except OpenCodeDownError:
|
|
496
|
+
self._say(token, self._t("down"))
|
|
497
|
+
return
|
|
498
|
+
items = [
|
|
499
|
+
SelectItem(label=s["name"], value=s["name"], description=(s.get("description") or "")[:40])
|
|
500
|
+
for s in skills[: self._cfg.list_limit]
|
|
501
|
+
if s.get("name")
|
|
502
|
+
]
|
|
503
|
+
if not items:
|
|
504
|
+
self._say(token, self._t("no_skills"))
|
|
505
|
+
return
|
|
506
|
+
self._offer_selection(
|
|
507
|
+
token, self._t("title_skills"), items, lambda name: self._run_command(token, name)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _run_command(self, token: str, name: str) -> None:
|
|
511
|
+
self._start_command(token, name)
|
|
512
|
+
|
|
513
|
+
def _cmd_mcps(self, token: str) -> None:
|
|
514
|
+
try:
|
|
515
|
+
mcps = self._oc.list_mcps()
|
|
516
|
+
except OpenCodeDownError:
|
|
517
|
+
self._say(token, self._t("down"))
|
|
518
|
+
return
|
|
519
|
+
names = list(mcps.keys())[: self._cfg.list_limit]
|
|
520
|
+
if not names:
|
|
521
|
+
self._say(token, self._t("no_mcps"))
|
|
522
|
+
return
|
|
523
|
+
items = [SelectItem(label=n, value=n) for n in names]
|
|
524
|
+
self._offer_selection(token, self._t("title_mcps"), items, lambda n: self._toggle_mcp(token, n, mcps))
|
|
525
|
+
|
|
526
|
+
def _toggle_mcp(self, token: str, name: str, mcps: dict) -> None:
|
|
527
|
+
cfg = mcps.get(name) or {}
|
|
528
|
+
currently = bool(cfg.get("enabled", True)) if isinstance(cfg, dict) else True
|
|
529
|
+
try:
|
|
530
|
+
self._oc.toggle_mcp(name, not currently)
|
|
531
|
+
state = self._t("mcp_off" if currently else "mcp_on")
|
|
532
|
+
self._say(token, self._t("mcp_toggled", name=name, state=state))
|
|
533
|
+
except OpenCodeDownError:
|
|
534
|
+
self._say(token, self._t("down"))
|
|
535
|
+
|
|
536
|
+
def _cmd_messages(self, token: str) -> None:
|
|
537
|
+
sid = self._store.session_id_for(token)
|
|
538
|
+
if not sid:
|
|
539
|
+
self._say(token, self._t("no_session"))
|
|
540
|
+
return
|
|
541
|
+
try:
|
|
542
|
+
messages = self._oc.session_messages(sid)
|
|
543
|
+
except OpenCodeDownError:
|
|
544
|
+
self._say(token, self._t("down"))
|
|
545
|
+
return
|
|
546
|
+
user_msgs = [
|
|
547
|
+
m
|
|
548
|
+
for m in messages
|
|
549
|
+
if (m.get("info") or {}).get("role") == "user" and (m.get("info") or {}).get("id")
|
|
550
|
+
]
|
|
551
|
+
user_msgs = user_msgs[-self._cfg.list_limit :]
|
|
552
|
+
if not user_msgs:
|
|
553
|
+
self._say(token, self._t("no_messages"))
|
|
554
|
+
return
|
|
555
|
+
items = [SelectItem(label=_message_label(m), value=(m["info"]["id"])) for m in user_msgs]
|
|
556
|
+
self._offer_selection(
|
|
557
|
+
token, self._t("title_messages"), items, lambda mid: self._offer_revert_fork(token, sid, mid)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _offer_revert_fork(self, token: str, session_id: str, message_id: str) -> None:
|
|
561
|
+
items = [
|
|
562
|
+
SelectItem(label=self._t("action_revert"), value="revert"),
|
|
563
|
+
SelectItem(label=self._t("action_fork"), value="fork"),
|
|
564
|
+
]
|
|
565
|
+
self._offer_selection(
|
|
566
|
+
token,
|
|
567
|
+
self._t("title_action"),
|
|
568
|
+
items,
|
|
569
|
+
lambda action: self._do_revert_fork(token, session_id, message_id, action),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def _do_revert_fork(self, token: str, session_id: str, message_id: str, action: str) -> None:
|
|
573
|
+
try:
|
|
574
|
+
if action == "revert":
|
|
575
|
+
self._oc.revert(session_id, message_id)
|
|
576
|
+
self._say(token, self._t("reverted"))
|
|
577
|
+
else:
|
|
578
|
+
new_id = self._oc.fork(session_id, message_id)
|
|
579
|
+
self._switch_session(token, new_id)
|
|
580
|
+
except OpenCodeDownError:
|
|
581
|
+
self._say(token, self._t("down"))
|
|
582
|
+
|
|
583
|
+
def _handle_model(self, token: str, arg: str) -> None:
|
|
584
|
+
if arg:
|
|
585
|
+
if "/" not in arg:
|
|
586
|
+
self._say(token, self._t("model_format"))
|
|
587
|
+
return
|
|
588
|
+
self._store.set_model(token, arg, now=_now())
|
|
589
|
+
self._say(token, self._t("model_set", model=arg))
|
|
590
|
+
return
|
|
591
|
+
try:
|
|
592
|
+
models = self._oc.list_models()
|
|
593
|
+
except OpenCodeDownError:
|
|
594
|
+
self._say(token, self._t("down"))
|
|
595
|
+
return
|
|
596
|
+
if not models:
|
|
597
|
+
current = (self._store.get(token).model if self._store.get(token) else None) or "(default)"
|
|
598
|
+
self._say(token, self._t("model_current", model=current))
|
|
599
|
+
return
|
|
600
|
+
items = [
|
|
601
|
+
SelectItem(label=f"{m['providerID']}/{m['id']}", value=f"{m['providerID']}/{m['id']}")
|
|
602
|
+
for m in models[: self._cfg.list_limit]
|
|
603
|
+
if m.get("providerID") and m.get("id")
|
|
604
|
+
]
|
|
605
|
+
self._offer_selection(token, self._t("title_models"), items, lambda v: self._set_model(token, v))
|
|
606
|
+
|
|
607
|
+
def _set_model(self, token: str, value: str) -> None:
|
|
608
|
+
self._store.set_model(token, value, now=_now())
|
|
609
|
+
self._say(token, self._t("model_set", model=value))
|
|
610
|
+
|
|
611
|
+
def _handle_agent(self, token: str, arg: str) -> None:
|
|
612
|
+
if arg:
|
|
613
|
+
self._store.set_agent(token, arg.strip(), now=_now())
|
|
614
|
+
self._say(token, self._t("agent_set", agent=arg.strip()))
|
|
615
|
+
return
|
|
616
|
+
try:
|
|
617
|
+
agents = self._oc.list_agents()
|
|
618
|
+
except OpenCodeDownError:
|
|
619
|
+
self._say(token, self._t("down"))
|
|
620
|
+
return
|
|
621
|
+
visible = [a for a in agents if not a.get("hidden") and a.get("name")]
|
|
622
|
+
if not visible:
|
|
623
|
+
self._say(token, self._t("no_agents"))
|
|
624
|
+
return
|
|
625
|
+
items = [
|
|
626
|
+
SelectItem(label=a["name"], value=a["name"], description=(a.get("description") or "")[:40])
|
|
627
|
+
for a in visible[: self._cfg.list_limit]
|
|
628
|
+
]
|
|
629
|
+
self._offer_selection(token, self._t("title_agents"), items, lambda v: self._set_agent(token, v))
|
|
630
|
+
|
|
631
|
+
def _set_agent(self, token: str, value: str) -> None:
|
|
632
|
+
self._store.set_agent(token, value, now=_now())
|
|
633
|
+
self._say(token, self._t("agent_set", agent=value))
|
|
634
|
+
|
|
635
|
+
def _offer_selection(self, token: str, title: str, items: list[SelectItem], on_select) -> None:
|
|
636
|
+
if not items:
|
|
637
|
+
self._say(token, self._t("nothing_to_pick"))
|
|
638
|
+
return
|
|
639
|
+
self._pending.set(token, SelectionPending(title=title, items=items, on_select=on_select))
|
|
640
|
+
self._say(token, format_selection(title, items, hint=self._t("pick_number")))
|
|
641
|
+
|
|
642
|
+
def _handle_stop(self, token: str) -> None:
|
|
643
|
+
sid = self._store.session_id_for(token)
|
|
644
|
+
if not sid:
|
|
645
|
+
self._say(token, self._t("no_running_session"))
|
|
646
|
+
return
|
|
647
|
+
try:
|
|
648
|
+
self._oc.abort(sid)
|
|
649
|
+
self._say(token, self._t("aborted"))
|
|
650
|
+
except OpenCodeDownError:
|
|
651
|
+
self._say(token, self._t("down"))
|
|
652
|
+
|
|
653
|
+
def _handle_status(self, token: str) -> None:
|
|
654
|
+
healthy = self._oc.health()
|
|
655
|
+
sid = self._store.session_id_for(token)
|
|
656
|
+
state = self._store.get(token)
|
|
657
|
+
model = (state.model if state else None) or self._cfg.opencode_model or "(default)"
|
|
658
|
+
agent = (state.agent if state else None) or "(default)"
|
|
659
|
+
health = self._t("reachable") if healthy else self._t("unreachable")
|
|
660
|
+
self._say(token, self._t("status", health=health, sid=sid or "—", model=model, agent=agent))
|
|
661
|
+
self._status.update(opencode_healthy=healthy)
|
|
662
|
+
|
|
663
|
+
# --- prompting ---------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def _start_prompt(self, token: str, text: str, files: tuple[FileRef, ...] = ()) -> None:
|
|
666
|
+
self._start_turn(token, lambda sid: self._prompt_runner(token, sid, text, files))
|
|
667
|
+
|
|
668
|
+
def _start_command(self, token: str, name: str) -> None:
|
|
669
|
+
self._start_turn(token, lambda sid: self._oc.run_command(sid, name))
|
|
670
|
+
|
|
671
|
+
def _prompt_runner(
|
|
672
|
+
self, token: str, session_id: str, text: str, files: tuple[FileRef, ...] = ()
|
|
673
|
+
) -> PromptResult:
|
|
674
|
+
prompt_text, extra_parts = self._process_files(text, files)
|
|
675
|
+
state = self._store.get(token)
|
|
676
|
+
model = (state.model if state else None) or self._cfg.opencode_model
|
|
677
|
+
agent = state.agent if state else None
|
|
678
|
+
return self._oc.prompt(
|
|
679
|
+
session_id, prompt_text, model=model, agent=agent, extra_parts=extra_parts or None
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
def _process_files(self, text: str, files: tuple[FileRef, ...]) -> tuple[str, list[dict]]:
|
|
683
|
+
"""Transcribe audio (STT) into the prompt and inline other files as
|
|
684
|
+
OpenCode file parts (base64 data URLs). Runs in the worker thread."""
|
|
685
|
+
prompt_text = text
|
|
686
|
+
extra_parts: list[dict] = []
|
|
687
|
+
for f in files:
|
|
688
|
+
try:
|
|
689
|
+
if f.is_audio and self._stt is not None:
|
|
690
|
+
audio = self._talk.download(f.path)
|
|
691
|
+
transcript = self._stt.transcribe(audio, f.name or "audio.ogg")
|
|
692
|
+
prompt_text = f"{prompt_text} {transcript}".strip() if prompt_text else transcript
|
|
693
|
+
elif not f.is_audio:
|
|
694
|
+
data = self._talk.download(f.path)
|
|
695
|
+
extra_parts.append(_file_part(f, data))
|
|
696
|
+
except (STTError, WebDavError, NextcloudTalkError) as exc:
|
|
697
|
+
log.warning("could not process attachment %s: %s", f.name, exc)
|
|
698
|
+
return prompt_text, extra_parts
|
|
699
|
+
|
|
700
|
+
def _start_turn(self, token: str, runner) -> None:
|
|
701
|
+
with self._lock:
|
|
702
|
+
worker = self._busy.get(token)
|
|
703
|
+
if worker is not None and worker.is_alive():
|
|
704
|
+
self._say(token, self._t("busy"))
|
|
705
|
+
return
|
|
706
|
+
t = threading.Thread(
|
|
707
|
+
target=self._run_turn, args=(token, runner), name=f"turn:{token}", daemon=True
|
|
708
|
+
)
|
|
709
|
+
self._busy[token] = t
|
|
710
|
+
t.start()
|
|
711
|
+
|
|
712
|
+
def _run_turn(self, token: str, runner) -> None:
|
|
713
|
+
try:
|
|
714
|
+
session_id = self._ensure_session(token)
|
|
715
|
+
except OpenCodeDownError:
|
|
716
|
+
self._say(token, self._t("down"))
|
|
717
|
+
self._status.update(state="opencode_down", opencode_healthy=False)
|
|
718
|
+
return
|
|
719
|
+
except OpenCodeError as exc:
|
|
720
|
+
# An OpenCode HTTP error carries server response text — log it, but
|
|
721
|
+
# post only a generic message so nothing leaks into the chat.
|
|
722
|
+
log.warning("[%s] session setup failed: %s", token, exc)
|
|
723
|
+
self._say(token, self._t("oc_error"))
|
|
724
|
+
return
|
|
725
|
+
except Exception as exc: # noqa: BLE001 - report any setup failure to the user
|
|
726
|
+
log.exception("session setup failed")
|
|
727
|
+
self._say(token, self._t("error", error=exc))
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
self._status.update(state="working")
|
|
731
|
+
msg_id = self._say(token, self._t("working"))
|
|
732
|
+
stream = self._begin_turn(session_id, token, msg_id)
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
result = runner(session_id)
|
|
736
|
+
except OpenCodeDownError:
|
|
737
|
+
self._end_turn(session_id)
|
|
738
|
+
self._say(token, self._t("down"))
|
|
739
|
+
self._status.update(state="opencode_down", opencode_healthy=False)
|
|
740
|
+
return
|
|
741
|
+
except OpenCodeError as exc:
|
|
742
|
+
log.warning("[%s] prompt failed: %s", token, exc) # details to log only
|
|
743
|
+
self._finalize_or_say(token, stream, self._t("oc_error"))
|
|
744
|
+
self._end_turn(session_id)
|
|
745
|
+
self._status.update(state="polling")
|
|
746
|
+
return
|
|
747
|
+
except Exception as exc: # noqa: BLE001
|
|
748
|
+
log.exception("prompt failed")
|
|
749
|
+
self._finalize_or_say(token, stream, self._t("error", error=exc))
|
|
750
|
+
self._end_turn(session_id)
|
|
751
|
+
self._status.update(state="polling")
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
self._pending.pop(token) # any unanswered ask is moot once the turn ends
|
|
755
|
+
self._deliver(token, result, stream)
|
|
756
|
+
self._end_turn(session_id)
|
|
757
|
+
self._status.update(state="polling")
|
|
758
|
+
|
|
759
|
+
def _ensure_session(self, token: str) -> str:
|
|
760
|
+
sid = self._store.session_id_for(token)
|
|
761
|
+
if sid:
|
|
762
|
+
return sid
|
|
763
|
+
state = self._store.get(token)
|
|
764
|
+
directory = state.directory if state else None
|
|
765
|
+
sid = self._oc.create_session(title=f"Talk {token}", directory=directory)
|
|
766
|
+
self._store.set_session(token, sid, now=_now())
|
|
767
|
+
with self._lock:
|
|
768
|
+
self._session_to_token[sid] = token
|
|
769
|
+
return sid
|
|
770
|
+
|
|
771
|
+
def _begin_turn(self, session_id: str, token: str, msg_id: int | None) -> StreamState | None:
|
|
772
|
+
"""Register a turn: a tool/thinking announce-set, plus a stream if
|
|
773
|
+
streaming is on and we have a message to edit."""
|
|
774
|
+
stream = None
|
|
775
|
+
with self._lock:
|
|
776
|
+
self._announced[session_id] = set()
|
|
777
|
+
if self._cfg.response_streaming and msg_id is not None:
|
|
778
|
+
stream = StreamState(
|
|
779
|
+
token, msg_id, self._talk.edit, throttle=self._cfg.stream_throttle_ms / 1000
|
|
780
|
+
)
|
|
781
|
+
self._streams[session_id] = stream
|
|
782
|
+
return stream
|
|
783
|
+
|
|
784
|
+
def _end_turn(self, session_id: str) -> None:
|
|
785
|
+
with self._lock:
|
|
786
|
+
self._streams.pop(session_id, None)
|
|
787
|
+
self._announced.pop(session_id, None)
|
|
788
|
+
|
|
789
|
+
def _deliver(self, token: str, result: PromptResult, stream: StreamState | None) -> None:
|
|
790
|
+
if result.aborted:
|
|
791
|
+
self._finalize_or_say(token, stream, self._t("aborted"))
|
|
792
|
+
return
|
|
793
|
+
if result.error:
|
|
794
|
+
self._finalize_or_say(token, stream, self._t("error", error=result.error))
|
|
795
|
+
return
|
|
796
|
+
text = result.text or self._t("no_answer")
|
|
797
|
+
if self._should_attach(text) and self._deliver_as_file(token, text):
|
|
798
|
+
self._finalize_or_say(token, stream, self._t("attached_as_file"))
|
|
799
|
+
else:
|
|
800
|
+
self._finalize_or_say(token, stream, text)
|
|
801
|
+
self._maybe_tts(token, result.text)
|
|
802
|
+
|
|
803
|
+
def _maybe_tts(self, token: str, text: str) -> None:
|
|
804
|
+
"""Synthesise the answer to audio and share it, if /tts is on for this
|
|
805
|
+
conversation and TTS + a share folder are configured."""
|
|
806
|
+
if self._tts is None or not text.strip():
|
|
807
|
+
return
|
|
808
|
+
state = self._store.get(token)
|
|
809
|
+
if not (state and state.tts_enabled and self._cfg.share_webdav_dir):
|
|
810
|
+
return
|
|
811
|
+
try:
|
|
812
|
+
audio = self._tts.synthesize(text[:4000])
|
|
813
|
+
name = f"opencode-tts-{_now()}.mp3"
|
|
814
|
+
remote = self._cfg.share_webdav_dir.rstrip("/") + "/" + name
|
|
815
|
+
self._talk.upload_and_share(token, remote, audio, content_type="audio/mpeg")
|
|
816
|
+
except (TTSError, WebDavError, NextcloudTalkError) as exc:
|
|
817
|
+
log.warning("[%s] TTS failed: %s", token, exc)
|
|
818
|
+
|
|
819
|
+
def _finalize_or_say(self, token: str, stream: StreamState | None, text: str) -> None:
|
|
820
|
+
if stream is not None:
|
|
821
|
+
stream.finalize(text)
|
|
822
|
+
else:
|
|
823
|
+
self._say(token, text)
|
|
824
|
+
|
|
825
|
+
# --- file attachment ---------------------------------------------------
|
|
826
|
+
|
|
827
|
+
def _should_attach(self, text: str) -> bool:
|
|
828
|
+
return len(text) > self._cfg.attachment_threshold or text.count("```") >= 2
|
|
829
|
+
|
|
830
|
+
def _deliver_as_file(self, token: str, text: str) -> bool:
|
|
831
|
+
"""Upload the answer to Nextcloud via WebDAV and share it into the
|
|
832
|
+
conversation. Returns True on success; False if attachments are not
|
|
833
|
+
configured or the upload/share failed (caller falls back to text)."""
|
|
834
|
+
if not self._cfg.share_webdav_dir:
|
|
835
|
+
return False
|
|
836
|
+
try:
|
|
837
|
+
name = f"opencode-{_now()}.md"
|
|
838
|
+
remote_path = self._cfg.share_webdav_dir.rstrip("/") + "/" + name
|
|
839
|
+
self._talk.upload_and_share(token, remote_path, text.encode("utf-8"), caption="OpenCode-Antwort")
|
|
840
|
+
return True
|
|
841
|
+
except (NextcloudTalkError, WebDavError) as exc:
|
|
842
|
+
log.warning("[%s] file attachment failed, posting text: %s", token, exc)
|
|
843
|
+
return False
|
|
844
|
+
|
|
845
|
+
# --- SSE consumer ------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
def _sse_loop(self) -> None:
|
|
848
|
+
backoff = 1.0
|
|
849
|
+
while not self._stop.is_set():
|
|
850
|
+
try:
|
|
851
|
+
for payload in self._oc.iter_events():
|
|
852
|
+
backoff = 1.0
|
|
853
|
+
if self._stop.is_set():
|
|
854
|
+
return
|
|
855
|
+
self._handle_event(payload)
|
|
856
|
+
except OpenCodeDownError:
|
|
857
|
+
if self._stop.is_set():
|
|
858
|
+
return
|
|
859
|
+
self._status.update(opencode_healthy=False)
|
|
860
|
+
log.warning("event stream down, retrying in %.0fs", backoff)
|
|
861
|
+
self._stop.wait(backoff)
|
|
862
|
+
backoff = min(backoff * 2, 30.0)
|
|
863
|
+
|
|
864
|
+
def _handle_event(self, payload: dict) -> None:
|
|
865
|
+
ev = classify(payload)
|
|
866
|
+
if ev is None:
|
|
867
|
+
return
|
|
868
|
+
if isinstance(ev, TextDelta):
|
|
869
|
+
self._on_text(ev)
|
|
870
|
+
elif isinstance(ev, ToolEvent):
|
|
871
|
+
self._on_tool(ev)
|
|
872
|
+
elif isinstance(ev, ReasoningDelta):
|
|
873
|
+
self._on_reasoning(ev)
|
|
874
|
+
elif isinstance(ev, PermissionEvent):
|
|
875
|
+
self._on_permission(ev.request)
|
|
876
|
+
elif isinstance(ev, QuestionEvent):
|
|
877
|
+
self._on_question(ev.request)
|
|
878
|
+
elif isinstance(ev, SessionError):
|
|
879
|
+
token = self._token_for_session(ev.session_id)
|
|
880
|
+
if token:
|
|
881
|
+
self._say(token, self._t("session_error"))
|
|
882
|
+
elif isinstance(ev, SessionIdle):
|
|
883
|
+
self._on_session_idle(ev.session_id)
|
|
884
|
+
|
|
885
|
+
def _on_session_idle(self, session_id: str) -> None:
|
|
886
|
+
# The foreground turn finishes via the blocking prompt return (it has an
|
|
887
|
+
# active announce-set). A mapped session going idle *without* an active
|
|
888
|
+
# turn is a background/detached completion worth a short notice.
|
|
889
|
+
if not self._cfg.track_background_sessions:
|
|
890
|
+
return
|
|
891
|
+
with self._lock:
|
|
892
|
+
is_foreground = session_id in self._announced
|
|
893
|
+
if is_foreground:
|
|
894
|
+
return
|
|
895
|
+
token = self._token_for_session(session_id)
|
|
896
|
+
if token:
|
|
897
|
+
self._say(token, self._t("bg_done"))
|
|
898
|
+
|
|
899
|
+
def _on_text(self, ev: TextDelta) -> None:
|
|
900
|
+
with self._lock:
|
|
901
|
+
stream = self._streams.get(ev.session_id)
|
|
902
|
+
if stream is not None:
|
|
903
|
+
stream.update_part(ev.message_id, ev.part_id, ev.text)
|
|
904
|
+
|
|
905
|
+
def _on_tool(self, ev: ToolEvent) -> None:
|
|
906
|
+
if self._cfg.hide_tool_messages or not ev.tool:
|
|
907
|
+
return
|
|
908
|
+
if not self._announce_once(ev.session_id, f"tool:{ev.call_id}"):
|
|
909
|
+
return
|
|
910
|
+
token = self._token_for_session(ev.session_id)
|
|
911
|
+
if token:
|
|
912
|
+
emoji = _TOOL_EMOJI.get(ev.tool, "🔧")
|
|
913
|
+
self._say(token, f"{emoji} `{ev.tool}`") # tool name is not localised
|
|
914
|
+
|
|
915
|
+
def _on_reasoning(self, ev: ReasoningDelta) -> None:
|
|
916
|
+
if self._cfg.hide_thinking:
|
|
917
|
+
return
|
|
918
|
+
if not self._announce_once(ev.session_id, "thinking"):
|
|
919
|
+
return # one thinking notice per turn
|
|
920
|
+
token = self._token_for_session(ev.session_id)
|
|
921
|
+
if token:
|
|
922
|
+
self._say(token, self._t("thinking"))
|
|
923
|
+
|
|
924
|
+
def _announce_once(self, session_id: str, key: str) -> bool:
|
|
925
|
+
"""Return True the first time ``key`` is seen this turn (per session)."""
|
|
926
|
+
with self._lock:
|
|
927
|
+
announced = self._announced.get(session_id)
|
|
928
|
+
if announced is None or key in announced:
|
|
929
|
+
return False
|
|
930
|
+
announced.add(key)
|
|
931
|
+
return True
|
|
932
|
+
|
|
933
|
+
def _on_permission(self, request: dict) -> None:
|
|
934
|
+
ask = PermissionAsk.from_request(request)
|
|
935
|
+
token = self._token_for_session(ask.session_id)
|
|
936
|
+
if token is None:
|
|
937
|
+
log.info("permission ask for unmapped session %s — ignoring", ask.session_id)
|
|
938
|
+
return
|
|
939
|
+
self._pending.set(token, PermissionPending(ask))
|
|
940
|
+
self._say(token, format_prompt(ask))
|
|
941
|
+
|
|
942
|
+
def _on_question(self, request: dict) -> None:
|
|
943
|
+
ask = QuestionAsk.from_request(request)
|
|
944
|
+
token = self._token_for_session(ask.session_id)
|
|
945
|
+
if token is None:
|
|
946
|
+
log.info("question for unmapped session %s — ignoring", ask.session_id)
|
|
947
|
+
return
|
|
948
|
+
self._pending.set(token, QuestionPending(ask))
|
|
949
|
+
self._say(token, format_question(ask))
|
|
950
|
+
|
|
951
|
+
def _token_for_session(self, session_id: str | None) -> str | None:
|
|
952
|
+
if not session_id:
|
|
953
|
+
return None
|
|
954
|
+
with self._lock:
|
|
955
|
+
return self._session_to_token.get(session_id)
|
|
956
|
+
|
|
957
|
+
# --- helpers -----------------------------------------------------------
|
|
958
|
+
|
|
959
|
+
def _say(self, token: str, text: str) -> int | None:
|
|
960
|
+
"""Post a message; return its id (for editing) or None on failure."""
|
|
961
|
+
try:
|
|
962
|
+
return self._talk.send(token, text)
|
|
963
|
+
except NextcloudTalkError as exc:
|
|
964
|
+
log.error("[%s] failed to post message: %s", token, exc)
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _file_part(f: FileRef, data: bytes) -> dict:
|
|
969
|
+
"""Build an OpenCode FilePartInput inlining the file as a base64 data URL."""
|
|
970
|
+
mime = f.mimetype or "application/octet-stream"
|
|
971
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
972
|
+
return {"type": "file", "mime": mime, "filename": f.name or "file", "url": f"data:{mime};base64,{b64}"}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _parse_task_arg(arg: str) -> tuple[int, int, str] | None:
|
|
976
|
+
"""Parse `/task` args into (run_in_seconds, interval_seconds, prompt).
|
|
977
|
+
|
|
978
|
+
Forms: "<minutes> <prompt>" (one-shot) or "every <minutes> <prompt>".
|
|
979
|
+
Requires minutes >= 1 and a non-empty prompt; returns None otherwise.
|
|
980
|
+
"""
|
|
981
|
+
parts = arg.split(maxsplit=1)
|
|
982
|
+
if len(parts) < 2:
|
|
983
|
+
return None
|
|
984
|
+
head, rest = parts
|
|
985
|
+
if head.lower() in ("every", "alle"):
|
|
986
|
+
sub = rest.split(maxsplit=1)
|
|
987
|
+
if len(sub) < 2:
|
|
988
|
+
return None
|
|
989
|
+
minutes_str, prompt = sub[0], sub[1].strip()
|
|
990
|
+
return _build_task(minutes_str, prompt, recurring=True)
|
|
991
|
+
return _build_task(head, rest.strip(), recurring=False)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _build_task(minutes_str: str, prompt: str, *, recurring: bool) -> tuple[int, int, str] | None:
|
|
995
|
+
if not minutes_str.isdigit() or not prompt:
|
|
996
|
+
return None
|
|
997
|
+
minutes = int(minutes_str)
|
|
998
|
+
if minutes < 1:
|
|
999
|
+
return None
|
|
1000
|
+
return minutes * 60, (minutes * 60 if recurring else 0), prompt
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _message_label(message: dict) -> str:
|
|
1004
|
+
"""Short label for a user message in the /messages picker."""
|
|
1005
|
+
parts = message.get("parts") or []
|
|
1006
|
+
for part in parts:
|
|
1007
|
+
if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
|
|
1008
|
+
text = part["text"].strip().replace("\n", " ")
|
|
1009
|
+
return text[:50] + ("…" if len(text) > 50 else "")
|
|
1010
|
+
return message.get("info", {}).get("id", "?")
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _now() -> int:
|
|
1014
|
+
return int(time.time())
|