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,60 @@
1
+ """Parse incoming Talk messages into commands or plain prompts.
2
+
3
+ Pure functions β€” no I/O β€” so the parser is trivially unit-testable. A message
4
+ whose first token is a known ``/word`` becomes a :class:`Command`; everything
5
+ else is a :class:`Prompt`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ # Known slash-commands. Keep in sync with the help text and the README.
13
+ COMMANDS = (
14
+ "new",
15
+ "session",
16
+ "sessions",
17
+ "model",
18
+ "agent",
19
+ "projects",
20
+ "worktree",
21
+ "messages",
22
+ "commands",
23
+ "skills",
24
+ "mcps",
25
+ "rename",
26
+ "detach",
27
+ "tts",
28
+ "task",
29
+ "tasklist",
30
+ "stop",
31
+ "status",
32
+ "help",
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Command:
38
+ name: str
39
+ arg: str # remainder of the line after the command, stripped (may be empty)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Prompt:
44
+ text: str
45
+
46
+
47
+ def parse(message: str) -> Command | Prompt:
48
+ text = message.strip()
49
+ if not text.startswith("/"):
50
+ return Prompt(text=text)
51
+
52
+ first, _, rest = text.partition(" ")
53
+ name = first[1:].lower()
54
+ if name in COMMANDS:
55
+ return Command(name=name, arg=rest.strip())
56
+ # Unknown slash-word: treat as a plain prompt rather than silently dropping.
57
+ return Prompt(text=text)
58
+
59
+
60
+ # The help text is localised in messages.py (key "help"), not here.
@@ -0,0 +1,169 @@
1
+ """Runtime configuration for the bridge.
2
+
3
+ Everything is loaded from the environment (optionally seeded from a `.env`
4
+ file). The Nextcloud Talk credentials are delegated to
5
+ ``nextcloud_talk_core.Settings``; the bridge owns the rest.
6
+
7
+ The single hard security invariant lives here: an empty allowlist raises, so
8
+ the bridge cannot start without one (see README threat model).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from nextcloud_talk_core import Settings
18
+
19
+
20
+ class ConfigError(Exception):
21
+ """Raised when the bridge configuration is missing or invalid."""
22
+
23
+
24
+ def load_dotenv(path: str | os.PathLike[str] = ".env") -> None:
25
+ """Seed os.environ from a simple `.env` file if present.
26
+
27
+ Intentionally minimal (no external dependency): ``KEY=VALUE`` per line,
28
+ ``#`` comments, optional surrounding quotes. Existing environment variables
29
+ are never overwritten, so launchd/shell env always wins over the file.
30
+ """
31
+ p = Path(path)
32
+ if not p.is_file():
33
+ return
34
+ for raw in p.read_text(encoding="utf-8").splitlines():
35
+ line = raw.strip()
36
+ if not line or line.startswith("#") or "=" not in line:
37
+ continue
38
+ key, _, value = line.partition("=")
39
+ key = key.strip()
40
+ value = value.strip().strip('"').strip("'")
41
+ if key and key not in os.environ:
42
+ os.environ[key] = value
43
+
44
+
45
+ def _split_csv(value: str) -> list[str]:
46
+ return [item.strip() for item in value.split(",") if item.strip()]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Config:
51
+ """Validated bridge configuration."""
52
+
53
+ talk: Settings
54
+ conversations: tuple[str, ...]
55
+ watch_all: bool
56
+ allowed_users: frozenset[str]
57
+
58
+ opencode_url: str
59
+ opencode_username: str | None
60
+ opencode_password: str | None
61
+ opencode_directory: str | None
62
+ opencode_model: str | None
63
+
64
+ db_path: str
65
+ status_file: str
66
+ # WebDAV folder (relative to the Nextcloud user root) the bridge uploads
67
+ # code/long-output attachments into before sharing them. None disables
68
+ # attachments (long output is posted as text instead).
69
+ share_webdav_dir: str | None
70
+ log_level: str
71
+
72
+ # Interaction / streaming
73
+ response_streaming: bool
74
+ stream_throttle_ms: int
75
+ hide_tool_messages: bool
76
+ hide_thinking: bool
77
+ track_background_sessions: bool
78
+ list_limit: int
79
+ bot_locale: str
80
+
81
+ # Phase 3: voice
82
+ stt_url: str | None
83
+ stt_key: str | None
84
+ stt_model: str
85
+ stt_language: str | None
86
+ tts_url: str | None
87
+ tts_key: str | None
88
+ tts_model: str
89
+ tts_voice: str
90
+ task_limit: int
91
+
92
+ poll_timeout: int = 30
93
+ attachment_threshold: int = field(default=1500)
94
+
95
+ @classmethod
96
+ def from_env(cls) -> Config:
97
+ talk = Settings.from_env()
98
+
99
+ allowed = frozenset(_split_csv(os.environ.get("ALLOWED_USERS", "")))
100
+ if not allowed:
101
+ raise ConfigError(
102
+ "ALLOWED_USERS is empty. Refusing to start: set it to a "
103
+ "comma-separated list of Talk user IDs allowed to issue commands."
104
+ )
105
+
106
+ raw_convos = os.environ.get("TALK_CONVERSATIONS", "").strip()
107
+ watch_all = raw_convos.lower() == "all"
108
+ conversations = tuple(_split_csv(raw_convos)) if not watch_all else ()
109
+ if not watch_all and not conversations:
110
+ raise ConfigError(
111
+ "TALK_CONVERSATIONS is empty. Set it to one or more conversation "
112
+ 'tokens, or "all" to watch every conversation.'
113
+ )
114
+
115
+ opencode_url = os.environ.get("OPENCODE_URL", "http://127.0.0.1:4096").strip().rstrip("/")
116
+
117
+ return cls(
118
+ talk=talk,
119
+ conversations=conversations,
120
+ watch_all=watch_all,
121
+ allowed_users=allowed,
122
+ opencode_url=opencode_url,
123
+ opencode_username=_or_none(os.environ.get("OPENCODE_USERNAME")),
124
+ opencode_password=_or_none(os.environ.get("OPENCODE_PASSWORD")),
125
+ opencode_directory=_or_none(os.environ.get("OPENCODE_DIRECTORY")),
126
+ opencode_model=_or_none(os.environ.get("OPENCODE_MODEL")),
127
+ db_path=os.environ.get("DB_PATH", "bridge.sqlite3").strip(),
128
+ status_file=os.environ.get("STATUS_FILE", "status.json").strip(),
129
+ share_webdav_dir=_or_none(os.environ.get("SHARE_WEBDAV_DIR")),
130
+ log_level=os.environ.get("LOG_LEVEL", "INFO").strip().upper(),
131
+ response_streaming=_bool(os.environ.get("RESPONSE_STREAMING"), True),
132
+ stream_throttle_ms=_int(os.environ.get("STREAM_THROTTLE_MS"), 1500),
133
+ hide_tool_messages=_bool(os.environ.get("HIDE_TOOL_MESSAGES"), False),
134
+ hide_thinking=_bool(os.environ.get("HIDE_THINKING"), True),
135
+ track_background_sessions=_bool(os.environ.get("TRACK_BACKGROUND_SESSIONS"), True),
136
+ list_limit=_int(os.environ.get("LIST_LIMIT"), 10),
137
+ bot_locale=os.environ.get("BOT_LOCALE", "de").strip().lower() or "de",
138
+ stt_url=_or_none(os.environ.get("STT_API_URL")),
139
+ stt_key=_or_none(os.environ.get("STT_API_KEY")),
140
+ stt_model=os.environ.get("STT_MODEL", "whisper-large-v3-turbo").strip(),
141
+ stt_language=_or_none(os.environ.get("STT_LANGUAGE")),
142
+ tts_url=_or_none(os.environ.get("TTS_API_URL")),
143
+ tts_key=_or_none(os.environ.get("TTS_API_KEY")),
144
+ tts_model=os.environ.get("TTS_MODEL", "gpt-4o-mini-tts").strip(),
145
+ tts_voice=os.environ.get("TTS_VOICE", "alloy").strip(),
146
+ task_limit=_int(os.environ.get("TASK_LIMIT"), 10),
147
+ )
148
+
149
+
150
+ def _or_none(value: str | None) -> str | None:
151
+ if value is None:
152
+ return None
153
+ value = value.strip()
154
+ return value or None
155
+
156
+
157
+ def _bool(value: str | None, default: bool) -> bool:
158
+ if value is None or not value.strip():
159
+ return default
160
+ return value.strip().lower() in ("1", "true", "yes", "on")
161
+
162
+
163
+ def _int(value: str | None, default: int) -> int:
164
+ if value is None or not value.strip():
165
+ return default
166
+ try:
167
+ return int(value.strip())
168
+ except ValueError:
169
+ return default
@@ -0,0 +1,85 @@
1
+ """Classify OpenCode SSE event payloads into typed bridge events.
2
+
3
+ Keeps the parsing of OpenCode's large ``GlobalEvent`` union out of the bridge:
4
+ ``classify`` turns a raw payload (``{"type", "properties"}``) into one of a few
5
+ simple dataclasses the bridge acts on, or ``None`` for events we ignore. This is
6
+ pure and unit-testable; the bridge owns the side effects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TextDelta:
17
+ session_id: str
18
+ message_id: str
19
+ part_id: str
20
+ text: str
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ToolEvent:
25
+ session_id: str
26
+ tool: str
27
+ status: str
28
+ call_id: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ReasoningDelta:
33
+ session_id: str
34
+ text: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PermissionEvent:
39
+ request: dict[str, Any]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class QuestionEvent:
44
+ request: dict[str, Any]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SessionIdle:
49
+ session_id: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SessionError:
54
+ session_id: str
55
+
56
+
57
+ Event = TextDelta | ToolEvent | ReasoningDelta | PermissionEvent | QuestionEvent | SessionIdle | SessionError
58
+
59
+
60
+ def classify(payload: dict[str, Any]) -> Event | None:
61
+ etype = payload.get("type")
62
+ props = payload.get("properties") or {}
63
+
64
+ if etype == "message.part.updated":
65
+ part = props.get("part") or {}
66
+ ptype = part.get("type")
67
+ sid = part.get("sessionID", "")
68
+ if ptype == "text":
69
+ return TextDelta(sid, part.get("messageID", ""), part.get("id", ""), part.get("text", ""))
70
+ if ptype == "tool":
71
+ state = part.get("state") or {}
72
+ return ToolEvent(sid, part.get("tool", ""), state.get("status", ""), part.get("callID", ""))
73
+ if ptype == "reasoning":
74
+ return ReasoningDelta(sid, part.get("text", ""))
75
+ return None
76
+
77
+ if etype == "permission.asked":
78
+ return PermissionEvent(props)
79
+ if etype == "question.asked":
80
+ return QuestionEvent(props)
81
+ if etype == "session.idle":
82
+ return SessionIdle(props.get("sessionID", ""))
83
+ if etype == "session.error":
84
+ return SessionError(props.get("sessionID", ""))
85
+ return None
@@ -0,0 +1,118 @@
1
+ """Interactive ``.env`` generator (``opencode-talk-bridge --init``).
2
+
3
+ Prompts for the essential settings and writes a ``.env`` (chmod 600, since it
4
+ holds the app password). Advanced options (streaming, STT/TTS, scheduler) keep
5
+ their defaults and can be added later from ``.env.example``. Input functions are
6
+ injectable so the flow is unit-testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import os
13
+ import sys
14
+ from collections.abc import Callable
15
+
16
+ # (key, label, default, required, secret) β€” order preserved in the written file.
17
+ _FIELDS: list[tuple[str, str, str, bool, bool]] = [
18
+ ("NC_URL", "Nextcloud base URL (https://…)", "", True, False),
19
+ ("NC_USER", "Nextcloud username", "", True, False),
20
+ ("NC_APP_PASSWORD", "Nextcloud app password (hidden)", "", True, True),
21
+ (
22
+ "TALK_CONVERSATIONS",
23
+ "Talk conversation token(s) to watch (comma-separated, or 'all')",
24
+ "",
25
+ True,
26
+ False,
27
+ ),
28
+ ("ALLOWED_USERS", "Allowed Talk user IDs (comma-separated logins)", "", True, False),
29
+ ("OPENCODE_URL", "OpenCode server URL", "http://127.0.0.1:4096", False, False),
30
+ ("OPENCODE_USERNAME", "OpenCode Basic-Auth user (blank if unsecured)", "", False, False),
31
+ ("OPENCODE_PASSWORD", "OpenCode Basic-Auth password (blank if unsecured)", "", False, True),
32
+ ("SHARE_WEBDAV_DIR", "WebDAV folder for code/TTS attachments (blank to disable)", "", False, False),
33
+ ("BOT_LOCALE", "UI language (de/en)", "de", False, False),
34
+ ]
35
+
36
+
37
+ def _is_url(value: str) -> bool:
38
+ return value.startswith("http://") or value.startswith("https://")
39
+
40
+
41
+ def run_init(
42
+ env_path: str = ".env",
43
+ *,
44
+ ask: Callable[[str], str] | None = None,
45
+ ask_secret: Callable[[str], str] | None = None,
46
+ out: Callable[[str], None] | None = None,
47
+ confirm: Callable[[str], bool] | None = None,
48
+ ) -> int:
49
+ ask = ask or input
50
+ ask_secret = ask_secret or getpass.getpass
51
+ out = out or print
52
+ confirm = confirm or _default_confirm
53
+
54
+ if not _interactive(ask):
55
+ out("--init requires an interactive terminal.")
56
+ return 2
57
+
58
+ if os.path.exists(env_path) and not confirm(f"{env_path} exists β€” overwrite?"):
59
+ out("Aborted; existing file kept.")
60
+ return 1
61
+
62
+ out("Setting up opencode-talk-bridge. Press Enter to accept a [default].\n")
63
+ values: dict[str, str] = {}
64
+ for key, label, default, required, secret in _FIELDS:
65
+ validate = _is_url if key in ("NC_URL", "OPENCODE_URL") else None
66
+ values[key] = _prompt(ask, ask_secret, out, label, default, required, secret, validate)
67
+
68
+ _write_env(env_path, values)
69
+ out(f"\nβœ… Wrote {env_path} (chmod 600).")
70
+ out("Next: `opencode-talk-bridge --check`, then `opencode-talk-bridge`.")
71
+ out("More options (streaming, STT/TTS, scheduler) are documented in .env.example.")
72
+ return 0
73
+
74
+
75
+ def _prompt(
76
+ ask: Callable[[str], str],
77
+ ask_secret: Callable[[str], str],
78
+ out: Callable[[str], None],
79
+ label: str,
80
+ default: str,
81
+ required: bool,
82
+ secret: bool,
83
+ validate: Callable[[str], bool] | None,
84
+ ) -> str:
85
+ suffix = f" [{default}]" if default else ""
86
+ while True:
87
+ raw = ask_secret(f"{label}: ") if secret else ask(f"{label}{suffix}: ")
88
+ value = raw.strip() or default
89
+ if not value:
90
+ if required:
91
+ out(" β†’ required, please enter a value.")
92
+ continue
93
+ return ""
94
+ if validate and not validate(value):
95
+ out(" β†’ must start with http:// or https://")
96
+ continue
97
+ return value
98
+
99
+
100
+ def _write_env(env_path: str, values: dict[str, str]) -> None:
101
+ lines = ["# opencode-talk-bridge configuration (generated by --init).", ""]
102
+ for key, _label, _default, _required, _secret in _FIELDS:
103
+ lines.append(f"{key}={values.get(key, '')}")
104
+ content = "\n".join(lines) + "\n"
105
+ # Write 0600 from the start so the app password is never world-readable.
106
+ fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
107
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
108
+ fh.write(content)
109
+ os.chmod(env_path, 0o600)
110
+
111
+
112
+ def _interactive(ask: Callable[[str], str]) -> bool:
113
+ # Only enforce a real TTY for the built-in input(); injected asks pass.
114
+ return ask is not input or sys.stdin.isatty()
115
+
116
+
117
+ def _default_confirm(question: str) -> bool:
118
+ return input(f"{question} [y/N] ").strip().lower() in ("y", "yes", "j", "ja")
@@ -0,0 +1,226 @@
1
+ """Localised user-facing strings (i18n).
2
+
3
+ A tiny catalog keyed by message id, with German (default) and English. The
4
+ bridge builds a translator via ``translator(BOT_LOCALE)`` and calls
5
+ ``self._t("key", **fields)``. Unknown locales fall back to German; unknown keys
6
+ fall back to the key itself.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ DE: dict[str, str] = {
14
+ "working": "πŸ”§ OpenCode arbeitet …",
15
+ "busy": "⏳ Ich arbeite noch an der vorherigen Anfrage – bitte warten oder /stop.",
16
+ "down": "⚠️ OpenCode ist nicht erreichbar. Bitte den Server prüfen.",
17
+ "error": "⚠️ Fehler: {error}",
18
+ "oc_error": "⚠️ OpenCode-Fehler – bitte erneut versuchen.",
19
+ "unexpected": "⚠️ Unerwarteter Fehler – die Anfrage wurde ΓΌbersprungen.",
20
+ "no_answer": "(keine Antwort)",
21
+ "aborted": "πŸ›‘ Abgebrochen.",
22
+ "attached_as_file": "πŸ“Ž Antwort als Datei angehΓ€ngt.",
23
+ "session_error": "⚠️ OpenCode meldet einen Fehler.",
24
+ "thinking": "πŸ’­ denkt nach …",
25
+ "bg_done": "βœ… Hintergrund-Session fertig.",
26
+ # sessions
27
+ "new_session": "πŸ†• Neue OpenCode-Session beim nΓ€chsten Prompt.",
28
+ "session_is": "Session: {sid}",
29
+ "no_session_yet": "Noch keine Session.",
30
+ "no_session": "Keine Session.",
31
+ "no_running_session": "Keine laufende Session.",
32
+ "session_switched": "βœ… Session gewechselt: {sid}",
33
+ "detached": "πŸ”Œ Von der Session getrennt. Der nΓ€chste Prompt startet eine neue.",
34
+ "no_sessions": "Keine Sessions vorhanden. Schreib einfach einen Prompt.",
35
+ "rename_usage": "Nutzung: /rename <neuer Titel>",
36
+ "no_session_rename": "Keine Session zum Umbenennen.",
37
+ "renamed": "βœ… Umbenannt: {title}",
38
+ # model / agent
39
+ "model_format": "Format: /model providerID/modelID",
40
+ "model_set": "βœ… Modell gesetzt: {model}",
41
+ "model_current": "Aktuelles Modell: {model}\nSetzen: /model providerID/modelID",
42
+ "agent_set": "βœ… Agent gesetzt: {agent}",
43
+ "no_agents": "Keine Agenten verfΓΌgbar. Setzen: /agent <name>",
44
+ # pickers
45
+ "pick_number": "_Antworte mit der Nummer._",
46
+ "nothing_to_pick": "Nichts zur Auswahl.",
47
+ "title_sessions": "πŸ—‚ Sessions:",
48
+ "title_models": "🧠 Modelle:",
49
+ "title_agents": "🎭 Agenten:",
50
+ "title_projects": "πŸ“ Projekte:",
51
+ "title_worktrees": "🌿 Worktrees:",
52
+ "title_commands": "⚑ Commands:",
53
+ "title_skills": "🧩 Skills:",
54
+ "title_mcps": "πŸ”Œ MCP-Server:",
55
+ "title_messages": "πŸ’¬ Nachrichten:",
56
+ "title_action": "Aktion:",
57
+ "no_projects": "Keine Projekte gefunden.",
58
+ "no_worktrees": "Keine Worktrees gefunden.",
59
+ "no_commands": "Keine Commands verfΓΌgbar.",
60
+ "no_skills": "Keine Skills verfΓΌgbar.",
61
+ "no_mcps": "Keine MCP-Server konfiguriert.",
62
+ "no_messages": "Keine Nachrichten.",
63
+ "dir_switched": "βœ… {kind} gewechselt: {directory}\nNeue Session beim nΓ€chsten Prompt.",
64
+ "reverted": "↩️ ZurΓΌckgesetzt.",
65
+ "mcp_toggled": "πŸ”Œ {name} {state}.",
66
+ "mcp_on": "aktiviert",
67
+ "mcp_off": "deaktiviert",
68
+ "action_revert": "↩️ Revert (hierhin zurΓΌck)",
69
+ "action_fork": "🍴 Fork (neue Session ab hier)",
70
+ # permission / question
71
+ "perm_once": "πŸ” erlaubt (einmal).",
72
+ "perm_always": "πŸ” erlaubt (immer).",
73
+ "perm_reject": "πŸ” abgelehnt.",
74
+ "answered": "βœ… {answer}",
75
+ # tts
76
+ "tts_unconfigured": "TTS ist nicht konfiguriert (TTS_API_URL / TTS_API_KEY).",
77
+ "tts_on": "πŸ”Š Sprachausgabe aktiviert.",
78
+ "tts_off": "πŸ”Š Sprachausgabe deaktiviert.",
79
+ # scheduled tasks
80
+ "no_scheduler": "Geplante Aufgaben sind nicht aktiv.",
81
+ "task_usage": "Nutzung: /task <Minuten> <Prompt> oder /task every <Minuten> <Prompt>",
82
+ "task_limit": "Limit erreicht ({limit} Aufgaben). Erst eine lΓΆschen (/tasklist).",
83
+ "task_created": "⏰ Aufgabe geplant (in {minutes} min).",
84
+ "task_none": "Keine geplanten Aufgaben.",
85
+ "task_deleted": "πŸ—‘ Aufgabe gelΓΆscht.",
86
+ "title_tasks": "⏰ Geplante Aufgaben:",
87
+ # status
88
+ "status": "πŸ“Š OpenCode: {health}\nSession: {sid}\nModell: {model}\nAgent: {agent}",
89
+ "reachable": "βœ… erreichbar",
90
+ "unreachable": "⚠️ nicht erreichbar",
91
+ "help": (
92
+ "πŸ€– *opencode-talk-bridge*\n"
93
+ "Schreib eine Nachricht, um OpenCode zu prompten. Befehle:\n"
94
+ "β€’ /new β€” neue OpenCode-Session\n"
95
+ "β€’ /session β€” aktuelle Session-ID anzeigen\n"
96
+ "β€’ /sessions β€” Sessions auflisten & wechseln\n"
97
+ "β€’ /rename <Titel> β€” aktuelle Session umbenennen\n"
98
+ "β€’ /detach β€” von der Session trennen\n"
99
+ "β€’ /messages β€” Nachrichten durchsuchen, dann Revert/Fork\n"
100
+ "β€’ /model [providerID/modelID] β€” Modell anzeigen/wΓ€hlen/setzen\n"
101
+ "β€’ /agent [name] β€” Agent anzeigen/wΓ€hlen/setzen (plan/build)\n"
102
+ "β€’ /projects β€” OpenCode-Projekt wechseln\n"
103
+ "β€’ /worktree β€” Git-Worktree wechseln\n"
104
+ "β€’ /commands β€” eigene OpenCode-Commands ausfΓΌhren\n"
105
+ "β€’ /skills β€” OpenCode-Skills ausfΓΌhren\n"
106
+ "β€’ /mcps β€” MCP-Server aktivieren/deaktivieren\n"
107
+ "β€’ /tts β€” Sprachausgabe umschalten (falls konfiguriert)\n"
108
+ "β€’ /task <Min> <Prompt> β€” Aufgabe planen (/task every <Min> … fΓΌr wiederkehrend)\n"
109
+ "β€’ /tasklist β€” geplante Aufgaben anzeigen/lΓΆschen\n"
110
+ "β€’ /stop β€” aktuellen Lauf abbrechen\n"
111
+ "β€’ /status β€” Bridge- & OpenCode-Status\n"
112
+ "β€’ /help β€” diese Nachricht\n"
113
+ "Picker: mit der Nummer antworten. Permission: `ja` (einmal), "
114
+ "`immer` (immer), `nein` (ablehnen)."
115
+ ),
116
+ }
117
+
118
+ EN: dict[str, str] = {
119
+ "working": "πŸ”§ OpenCode is working …",
120
+ "busy": "⏳ Still working on the previous request – please wait or /stop.",
121
+ "down": "⚠️ OpenCode is unreachable. Please check the server.",
122
+ "error": "⚠️ Error: {error}",
123
+ "oc_error": "⚠️ OpenCode error – please try again.",
124
+ "unexpected": "⚠️ Unexpected error – the request was skipped.",
125
+ "no_answer": "(no answer)",
126
+ "aborted": "πŸ›‘ Aborted.",
127
+ "attached_as_file": "πŸ“Ž Answer attached as a file.",
128
+ "session_error": "⚠️ OpenCode reported an error.",
129
+ "thinking": "πŸ’­ thinking …",
130
+ "bg_done": "βœ… Background session finished.",
131
+ "new_session": "πŸ†• New OpenCode session on the next prompt.",
132
+ "session_is": "Session: {sid}",
133
+ "no_session_yet": "No session yet.",
134
+ "no_session": "No session.",
135
+ "no_running_session": "No running session.",
136
+ "session_switched": "βœ… Session switched: {sid}",
137
+ "detached": "πŸ”Œ Detached from the session. The next prompt starts a new one.",
138
+ "no_sessions": "No sessions yet. Just send a prompt.",
139
+ "rename_usage": "Usage: /rename <new title>",
140
+ "no_session_rename": "No session to rename.",
141
+ "renamed": "βœ… Renamed: {title}",
142
+ "model_format": "Format: /model providerID/modelID",
143
+ "model_set": "βœ… Model set: {model}",
144
+ "model_current": "Current model: {model}\nSet: /model providerID/modelID",
145
+ "agent_set": "βœ… Agent set: {agent}",
146
+ "no_agents": "No agents available. Set: /agent <name>",
147
+ "pick_number": "_Reply with the number._",
148
+ "nothing_to_pick": "Nothing to choose.",
149
+ "title_sessions": "πŸ—‚ Sessions:",
150
+ "title_models": "🧠 Models:",
151
+ "title_agents": "🎭 Agents:",
152
+ "title_projects": "πŸ“ Projects:",
153
+ "title_worktrees": "🌿 Worktrees:",
154
+ "title_commands": "⚑ Commands:",
155
+ "title_skills": "🧩 Skills:",
156
+ "title_mcps": "πŸ”Œ MCP servers:",
157
+ "title_messages": "πŸ’¬ Messages:",
158
+ "title_action": "Action:",
159
+ "no_projects": "No projects found.",
160
+ "no_worktrees": "No worktrees found.",
161
+ "no_commands": "No commands available.",
162
+ "no_skills": "No skills available.",
163
+ "no_mcps": "No MCP servers configured.",
164
+ "no_messages": "No messages.",
165
+ "dir_switched": "βœ… {kind} switched: {directory}\nNew session on the next prompt.",
166
+ "reverted": "↩️ Reverted.",
167
+ "mcp_toggled": "πŸ”Œ {name} {state}.",
168
+ "mcp_on": "enabled",
169
+ "mcp_off": "disabled",
170
+ "action_revert": "↩️ Revert (back to here)",
171
+ "action_fork": "🍴 Fork (new session from here)",
172
+ "perm_once": "πŸ” allowed (once).",
173
+ "perm_always": "πŸ” allowed (always).",
174
+ "perm_reject": "πŸ” rejected.",
175
+ "answered": "βœ… {answer}",
176
+ "tts_unconfigured": "TTS is not configured (TTS_API_URL / TTS_API_KEY).",
177
+ "tts_on": "πŸ”Š Spoken replies enabled.",
178
+ "tts_off": "πŸ”Š Spoken replies disabled.",
179
+ "no_scheduler": "Scheduled tasks are not enabled.",
180
+ "task_usage": "Usage: /task <minutes> <prompt> or /task every <minutes> <prompt>",
181
+ "task_limit": "Limit reached ({limit} tasks). Delete one first (/tasklist).",
182
+ "task_created": "⏰ Task scheduled (in {minutes} min).",
183
+ "task_none": "No scheduled tasks.",
184
+ "task_deleted": "πŸ—‘ Task deleted.",
185
+ "title_tasks": "⏰ Scheduled tasks:",
186
+ "status": "πŸ“Š OpenCode: {health}\nSession: {sid}\nModel: {model}\nAgent: {agent}",
187
+ "reachable": "βœ… reachable",
188
+ "unreachable": "⚠️ unreachable",
189
+ "help": (
190
+ "πŸ€– *opencode-talk-bridge*\n"
191
+ "Send a message to prompt OpenCode. Commands:\n"
192
+ "β€’ /new β€” start a fresh OpenCode session\n"
193
+ "β€’ /session β€” show the current session id\n"
194
+ "β€’ /sessions β€” list & switch recent sessions\n"
195
+ "β€’ /rename <title> β€” rename the current session\n"
196
+ "β€’ /detach β€” detach from the current session\n"
197
+ "β€’ /messages β€” browse messages, then revert or fork\n"
198
+ "β€’ /model [providerID/modelID] β€” show, pick, or set the model\n"
199
+ "β€’ /agent [name] β€” show, pick, or set the agent (plan/build)\n"
200
+ "β€’ /projects β€” switch the OpenCode project\n"
201
+ "β€’ /worktree β€” switch the git worktree\n"
202
+ "β€’ /commands β€” browse & run custom OpenCode commands\n"
203
+ "β€’ /skills β€” browse & run OpenCode skills\n"
204
+ "β€’ /mcps β€” enable/disable MCP servers\n"
205
+ "β€’ /tts β€” toggle spoken replies (if configured)\n"
206
+ "β€’ /task <min> <prompt> β€” schedule a task (/task every <min> … to repeat)\n"
207
+ "β€’ /tasklist β€” list/delete scheduled tasks\n"
208
+ "β€’ /stop β€” abort the current run\n"
209
+ "β€’ /status β€” bridge & OpenCode health\n"
210
+ "β€’ /help β€” this message\n"
211
+ "Pickers: reply with the number. Permission: `ja` (once), "
212
+ "`immer` (always), `nein` (deny)."
213
+ ),
214
+ }
215
+
216
+ CATALOG: dict[str, dict[str, str]] = {"de": DE, "en": EN}
217
+
218
+
219
+ def translator(locale: str) -> Callable[..., str]:
220
+ table = CATALOG.get(locale, DE)
221
+
222
+ def t(key: str, **fields: object) -> str:
223
+ template = table.get(key) or DE.get(key, key)
224
+ return template.format(**fields) if fields else template
225
+
226
+ return t