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,8 @@
|
|
|
1
|
+
"""opencode-talk-bridge: drive a local OpenCode instance from Nextcloud Talk.
|
|
2
|
+
|
|
3
|
+
A polling bridge (no webhooks) that forwards allowlisted Talk messages to a
|
|
4
|
+
local `opencode serve` HTTP API and posts the agent's replies back into the
|
|
5
|
+
conversation. See README.md for the threat model and the status-file contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.7"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""CLI entrypoint: load config, wire up clients, run the bridge.
|
|
2
|
+
|
|
3
|
+
Usable directly (``python -m opencode_talk_bridge``) or via the
|
|
4
|
+
``opencode-talk-bridge`` console script. Designed to run under launchd; see
|
|
5
|
+
``deploy/`` and the README.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .bridge import Bridge
|
|
16
|
+
from .config import Config, ConfigError, load_dotenv
|
|
17
|
+
from .opencode import OpenCodeClient, wait_for_healthy
|
|
18
|
+
from .scheduler import TaskStore
|
|
19
|
+
from .sessions import SessionStore
|
|
20
|
+
from .status import StatusWriter
|
|
21
|
+
from .stt import STTClient
|
|
22
|
+
from .talk import TalkGateway
|
|
23
|
+
from .tts import TTSClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
p = argparse.ArgumentParser(prog="opencode-talk-bridge", description=__doc__)
|
|
28
|
+
p.add_argument("--env-file", default=".env", help="path to a .env file (default: .env)")
|
|
29
|
+
p.add_argument("--init", action="store_true", help="interactively create the .env file, then exit")
|
|
30
|
+
p.add_argument("--check", action="store_true", help="validate config + check OpenCode health, then exit")
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> int:
|
|
35
|
+
args = _build_parser().parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if args.init:
|
|
38
|
+
from .init import run_init
|
|
39
|
+
|
|
40
|
+
return run_init(args.env_file)
|
|
41
|
+
|
|
42
|
+
load_dotenv(args.env_file)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
config = Config.from_env()
|
|
46
|
+
except ConfigError as exc:
|
|
47
|
+
print(f"configuration error: {exc}", file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
logging.basicConfig(
|
|
51
|
+
level=getattr(logging, config.log_level, logging.INFO),
|
|
52
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
53
|
+
)
|
|
54
|
+
log = logging.getLogger("opencode_talk_bridge")
|
|
55
|
+
|
|
56
|
+
opencode = OpenCodeClient(
|
|
57
|
+
config.opencode_url,
|
|
58
|
+
username=config.opencode_username,
|
|
59
|
+
password=config.opencode_password,
|
|
60
|
+
directory=config.opencode_directory,
|
|
61
|
+
default_model=config.opencode_model,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if args.check:
|
|
65
|
+
healthy = opencode.health()
|
|
66
|
+
print(f"config OK; OpenCode at {config.opencode_url}: {'healthy' if healthy else 'UNREACHABLE'}")
|
|
67
|
+
opencode.close()
|
|
68
|
+
return 0 if healthy else 1
|
|
69
|
+
|
|
70
|
+
status = StatusWriter(config.status_file)
|
|
71
|
+
if not wait_for_healthy(opencode, attempts=3, delay=2.0):
|
|
72
|
+
log.warning("OpenCode at %s is not reachable yet — starting anyway", config.opencode_url)
|
|
73
|
+
status.update(state="opencode_down", opencode_healthy=False)
|
|
74
|
+
|
|
75
|
+
gateway = TalkGateway(config.talk)
|
|
76
|
+
store = SessionStore(config.db_path)
|
|
77
|
+
|
|
78
|
+
stt = (
|
|
79
|
+
STTClient(
|
|
80
|
+
config.stt_url, api_key=config.stt_key, model=config.stt_model, language=config.stt_language
|
|
81
|
+
)
|
|
82
|
+
if config.stt_url
|
|
83
|
+
else None
|
|
84
|
+
)
|
|
85
|
+
tts = (
|
|
86
|
+
TTSClient(config.tts_url, api_key=config.tts_key, model=config.tts_model, voice=config.tts_voice)
|
|
87
|
+
if config.tts_url
|
|
88
|
+
else None
|
|
89
|
+
)
|
|
90
|
+
task_store = TaskStore(config.db_path)
|
|
91
|
+
|
|
92
|
+
bridge = Bridge(config, gateway, opencode, store, status, stt=stt, tts=tts, task_store=task_store)
|
|
93
|
+
|
|
94
|
+
def _handle_signal(signum, _frame):
|
|
95
|
+
log.info("received signal %s", signum)
|
|
96
|
+
bridge.stop()
|
|
97
|
+
|
|
98
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
99
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
bridge.run()
|
|
103
|
+
finally:
|
|
104
|
+
gateway.close()
|
|
105
|
+
store.close()
|
|
106
|
+
task_store.close()
|
|
107
|
+
opencode.close()
|
|
108
|
+
if stt:
|
|
109
|
+
stt.close()
|
|
110
|
+
if tts:
|
|
111
|
+
tts.close()
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Allowlist enforcement — the bridge's primary access control.
|
|
2
|
+
|
|
3
|
+
Messages are only acted on if their author is an allowlisted Talk user. The
|
|
4
|
+
author is matched on the STABLE user id (OCS ``actorId`` with
|
|
5
|
+
``actorType == "users"``), never the display name — display names are not
|
|
6
|
+
unique and can be changed. This is why the bridge polls raw OCS message dicts
|
|
7
|
+
(see ``talk.py``) instead of relying on ``nextcloud_talk_core.Message.actor``,
|
|
8
|
+
which collapses to the display name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Allowlist:
|
|
17
|
+
def __init__(self, user_ids: Iterable[str]) -> None:
|
|
18
|
+
self._users = frozenset(u.strip() for u in user_ids if u.strip())
|
|
19
|
+
if not self._users:
|
|
20
|
+
# Defensive: Config.from_env already guarantees this, but never
|
|
21
|
+
# allow an empty allowlist to be constructed silently.
|
|
22
|
+
raise ValueError("allowlist must not be empty")
|
|
23
|
+
|
|
24
|
+
def is_allowed(self, actor_id: str, actor_type: str = "users") -> bool:
|
|
25
|
+
"""True iff this is a real user on the allowlist.
|
|
26
|
+
|
|
27
|
+
Bots, guests, federated users, and system actors are always rejected
|
|
28
|
+
regardless of id collisions, because actor_type must be "users".
|
|
29
|
+
"""
|
|
30
|
+
return actor_type == "users" and actor_id in self._users
|
|
31
|
+
|
|
32
|
+
def __contains__(self, actor_id: object) -> bool:
|
|
33
|
+
return isinstance(actor_id, str) and actor_id in self._users
|