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