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