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,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())