nextcloud-talk-capture 0.1.0__tar.gz

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,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: nextcloud-talk-capture
3
+ Version: 0.1.0
4
+ Summary: Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring
5
+ Author: Done
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/devsoftmus/done-transcription-app
8
+ Project-URL: Issues, https://github.com/devsoftmus/done-transcription-app/issues
9
+ Keywords: nextcloud,nextcloud-talk,webrtc,transcription,audio-capture
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Conferencing
17
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: aiohttp>=3.9.0
21
+ Requires-Dist: websockets>=12.0
22
+ Requires-Dist: aiortc>=1.9.0
23
+ Requires-Dist: pymysql>=1.1.0
24
+ Requires-Dist: sshtunnel>=0.4.0
25
+ Requires-Dist: paramiko<4.0,>=3.0
26
+
27
+ # nextcloud-talk-capture
28
+
29
+ Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
30
+ stream per speaker, and hand over the finished call as plain data.
31
+
32
+ Import name is `talk_capture`.
33
+
34
+ ```python
35
+ from talk_capture import CaptureConfig
36
+ from talk_capture.monitor import CallMonitor
37
+ from talk_capture.spreed_client import SpreedClient
38
+ ```
39
+
40
+ ## What it does — and what it deliberately does not
41
+
42
+ This is the capture half of a transcription pipeline: it gets the audio and
43
+ tells you who was speaking. What happens afterwards — speech recognition,
44
+ summaries, publishing, storage — is not its business, and it holds no dependency
45
+ on any of it.
46
+
47
+ That separation is the whole point. The same package runs inside a Nextcloud
48
+ ExApp, inside a standalone capture service, or inside an application that does
49
+ everything, without dragging a product's worth of code along.
50
+
51
+ | Module | |
52
+ |---|---|
53
+ | `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
54
+ | `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
55
+ | `presence` | join/leave annotations (pure functions, no I/O) |
56
+ | `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
57
+ | `resolve` | assembles a `FinishedCall` when the call ends |
58
+ | `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
59
+ | `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
60
+ | `config` | `CaptureConfig` — what capture needs, nothing more |
61
+ | `aioice_patch` | works around an aioice race; import it before aiortc |
62
+
63
+ ## Requirements
64
+
65
+ - Nextcloud Talk with a **High Performance Backend**. Call media lives in the
66
+ signalling server, not in the PHP layer, so an HPB is required.
67
+ - Python 3.11+
68
+
69
+ ## Configuration
70
+
71
+ `CaptureConfig` carries what capture needs: Nextcloud credentials, the
72
+ signalling server and its secret, call-detection database access, the room
73
+ filter, and where to send the audio. Build it from the environment with
74
+ `CaptureConfig.from_env()`, or pass any object exposing the same attributes —
75
+ config is taken duck-typed, so a larger application config works unchanged.
76
+
77
+ ## Contributing
78
+
79
+ Two rules keep the package shippable on its own, both enforced by tests in the
80
+ consuming repository:
81
+
82
+ - nothing here may import the downstream half (publishing, analysis, storage);
83
+ - a config field must keep its name, type and default in sync with any larger
84
+ config that is passed in — a mismatch fails mid-call, not at import.
85
+
86
+ ## Licence
87
+
88
+ MIT.
@@ -0,0 +1,62 @@
1
+ # nextcloud-talk-capture
2
+
3
+ Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
4
+ stream per speaker, and hand over the finished call as plain data.
5
+
6
+ Import name is `talk_capture`.
7
+
8
+ ```python
9
+ from talk_capture import CaptureConfig
10
+ from talk_capture.monitor import CallMonitor
11
+ from talk_capture.spreed_client import SpreedClient
12
+ ```
13
+
14
+ ## What it does — and what it deliberately does not
15
+
16
+ This is the capture half of a transcription pipeline: it gets the audio and
17
+ tells you who was speaking. What happens afterwards — speech recognition,
18
+ summaries, publishing, storage — is not its business, and it holds no dependency
19
+ on any of it.
20
+
21
+ That separation is the whole point. The same package runs inside a Nextcloud
22
+ ExApp, inside a standalone capture service, or inside an application that does
23
+ everything, without dragging a product's worth of code along.
24
+
25
+ | Module | |
26
+ |---|---|
27
+ | `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
28
+ | `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
29
+ | `presence` | join/leave annotations (pure functions, no I/O) |
30
+ | `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
31
+ | `resolve` | assembles a `FinishedCall` when the call ends |
32
+ | `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
33
+ | `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
34
+ | `config` | `CaptureConfig` — what capture needs, nothing more |
35
+ | `aioice_patch` | works around an aioice race; import it before aiortc |
36
+
37
+ ## Requirements
38
+
39
+ - Nextcloud Talk with a **High Performance Backend**. Call media lives in the
40
+ signalling server, not in the PHP layer, so an HPB is required.
41
+ - Python 3.11+
42
+
43
+ ## Configuration
44
+
45
+ `CaptureConfig` carries what capture needs: Nextcloud credentials, the
46
+ signalling server and its secret, call-detection database access, the room
47
+ filter, and where to send the audio. Build it from the environment with
48
+ `CaptureConfig.from_env()`, or pass any object exposing the same attributes —
49
+ config is taken duck-typed, so a larger application config works unchanged.
50
+
51
+ ## Contributing
52
+
53
+ Two rules keep the package shippable on its own, both enforced by tests in the
54
+ consuming repository:
55
+
56
+ - nothing here may import the downstream half (publishing, analysis, storage);
57
+ - a config field must keep its name, type and default in sync with any larger
58
+ config that is passed in — a mismatch fails mid-call, not at import.
59
+
60
+ ## Licence
61
+
62
+ MIT.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Distribution name is explicit about the domain (PyPI is a global namespace);
7
+ # the import name stays `talk_capture`.
8
+ name = "nextcloud-talk-capture"
9
+ version = "0.1.0"
10
+ description = "Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring"
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+ # MIT on purpose, not by default: this package is consumed both by an AGPL
14
+ # Nextcloud app and by a proprietary backend. Copyleft here would reach through
15
+ # into the latter; MIT is compatible in both directions.
16
+ license = { text = "MIT" }
17
+ authors = [{ name = "Done" }]
18
+ keywords = ["nextcloud", "nextcloud-talk", "webrtc", "transcription", "audio-capture"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Communications :: Conferencing",
27
+ "Topic :: Multimedia :: Sound/Audio :: Capture/Recording",
28
+ ]
29
+ dependencies = [
30
+ "aiohttp>=3.9.0",
31
+ "websockets>=12.0",
32
+ "aiortc>=1.9.0",
33
+ "pymysql>=1.1.0",
34
+ "sshtunnel>=0.4.0",
35
+ "paramiko>=3.0,<4.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/devsoftmus/done-transcription-app"
40
+ Issues = "https://github.com/devsoftmus/done-transcription-app/issues"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: nextcloud-talk-capture
3
+ Version: 0.1.0
4
+ Summary: Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring
5
+ Author: Done
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/devsoftmus/done-transcription-app
8
+ Project-URL: Issues, https://github.com/devsoftmus/done-transcription-app/issues
9
+ Keywords: nextcloud,nextcloud-talk,webrtc,transcription,audio-capture
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Conferencing
17
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: aiohttp>=3.9.0
21
+ Requires-Dist: websockets>=12.0
22
+ Requires-Dist: aiortc>=1.9.0
23
+ Requires-Dist: pymysql>=1.1.0
24
+ Requires-Dist: sshtunnel>=0.4.0
25
+ Requires-Dist: paramiko<4.0,>=3.0
26
+
27
+ # nextcloud-talk-capture
28
+
29
+ Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
30
+ stream per speaker, and hand over the finished call as plain data.
31
+
32
+ Import name is `talk_capture`.
33
+
34
+ ```python
35
+ from talk_capture import CaptureConfig
36
+ from talk_capture.monitor import CallMonitor
37
+ from talk_capture.spreed_client import SpreedClient
38
+ ```
39
+
40
+ ## What it does — and what it deliberately does not
41
+
42
+ This is the capture half of a transcription pipeline: it gets the audio and
43
+ tells you who was speaking. What happens afterwards — speech recognition,
44
+ summaries, publishing, storage — is not its business, and it holds no dependency
45
+ on any of it.
46
+
47
+ That separation is the whole point. The same package runs inside a Nextcloud
48
+ ExApp, inside a standalone capture service, or inside an application that does
49
+ everything, without dragging a product's worth of code along.
50
+
51
+ | Module | |
52
+ |---|---|
53
+ | `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
54
+ | `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
55
+ | `presence` | join/leave annotations (pure functions, no I/O) |
56
+ | `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
57
+ | `resolve` | assembles a `FinishedCall` when the call ends |
58
+ | `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
59
+ | `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
60
+ | `config` | `CaptureConfig` — what capture needs, nothing more |
61
+ | `aioice_patch` | works around an aioice race; import it before aiortc |
62
+
63
+ ## Requirements
64
+
65
+ - Nextcloud Talk with a **High Performance Backend**. Call media lives in the
66
+ signalling server, not in the PHP layer, so an HPB is required.
67
+ - Python 3.11+
68
+
69
+ ## Configuration
70
+
71
+ `CaptureConfig` carries what capture needs: Nextcloud credentials, the
72
+ signalling server and its secret, call-detection database access, the room
73
+ filter, and where to send the audio. Build it from the environment with
74
+ `CaptureConfig.from_env()`, or pass any object exposing the same attributes —
75
+ config is taken duck-typed, so a larger application config works unchanged.
76
+
77
+ ## Contributing
78
+
79
+ Two rules keep the package shippable on its own, both enforced by tests in the
80
+ consuming repository:
81
+
82
+ - nothing here may import the downstream half (publishing, analysis, storage);
83
+ - a config field must keep its name, type and default in sync with any larger
84
+ config that is passed in — a mismatch fails mid-call, not at import.
85
+
86
+ ## Licence
87
+
88
+ MIT.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/nextcloud_talk_capture.egg-info/PKG-INFO
4
+ src/nextcloud_talk_capture.egg-info/SOURCES.txt
5
+ src/nextcloud_talk_capture.egg-info/dependency_links.txt
6
+ src/nextcloud_talk_capture.egg-info/requires.txt
7
+ src/nextcloud_talk_capture.egg-info/top_level.txt
8
+ src/talk_capture/__init__.py
9
+ src/talk_capture/aioice_patch.py
10
+ src/talk_capture/chat_commands.py
11
+ src/talk_capture/config.py
12
+ src/talk_capture/contracts.py
13
+ src/talk_capture/monitor.py
14
+ src/talk_capture/presence.py
15
+ src/talk_capture/resolve.py
16
+ src/talk_capture/spreed_client.py
17
+ src/talk_capture/transcriber_backend.py
@@ -0,0 +1,6 @@
1
+ aiohttp>=3.9.0
2
+ websockets>=12.0
3
+ aiortc>=1.9.0
4
+ pymysql>=1.1.0
5
+ sshtunnel>=0.4.0
6
+ paramiko<4.0,>=3.0
@@ -0,0 +1,12 @@
1
+ """Nextcloud Talk call capture — signalling, monitoring, and the brain contract.
2
+
3
+ Everything needed to join a call, receive per-speaker audio, and hand over a
4
+ FinishedCall. Deliberately knows nothing about what happens next: publishing,
5
+ analysis and storage belong to whatever consumes this package, and nothing
6
+ here may import them.
7
+ """
8
+
9
+ from .config import CaptureConfig
10
+ from .contracts import FinishedCall, SpeakerStatInput
11
+
12
+ __all__ = ["CaptureConfig", "FinishedCall", "SpeakerStatInput"]
@@ -0,0 +1,39 @@
1
+ """Monkey-patch for aioice Transaction.__retry race condition.
2
+
3
+ Bug: aioice/stun.py Transaction.__retry() calls
4
+ self.__future.set_exception(TransactionTimeout())
5
+ without checking if the future is already done.
6
+
7
+ If a response arrives at the same moment as the retry timeout,
8
+ response_received() sets the future first (it HAS the done() check),
9
+ then __retry() tries to set_exception on an already-done future
10
+ → asyncio.InvalidStateError → process crash.
11
+
12
+ Fix: Add `if not self.__future.done()` guard, same as response_received().
13
+
14
+ See: https://github.com/aiortc/aioice/blob/main/src/aioice/stun.py
15
+ """
16
+
17
+ import asyncio
18
+ from aioice import stun
19
+
20
+
21
+ def _patched_retry(self):
22
+ if self._Transaction__tries >= self._Transaction__tries_max:
23
+ if not self._Transaction__future.done():
24
+ self._Transaction__future.set_exception(stun.TransactionTimeout())
25
+ return
26
+
27
+ self._Transaction__protocol.send_stun(
28
+ self._Transaction__request, self._Transaction__addr
29
+ )
30
+
31
+ loop = asyncio.get_event_loop()
32
+ self._Transaction__timeout_handle = loop.call_later(
33
+ self._Transaction__timeout_delay, self._Transaction__retry
34
+ )
35
+ self._Transaction__timeout_delay *= 2
36
+ self._Transaction__tries += 1
37
+
38
+
39
+ stun.Transaction._Transaction__retry = _patched_retry
@@ -0,0 +1,170 @@
1
+ """Opt-out chat commands and the one-to-one join gate.
2
+
3
+ Three behaviours that decide whether a call is recorded at all:
4
+
5
+ * the opt-out command posted before a call starts (checked once, at join),
6
+ * the same command posted mid-call, which pauses and resumes recording,
7
+ * one-to-one calls, where capture waits for the second participant so a
8
+ ringing call is never recorded.
9
+
10
+ Consent lives here rather than in the signalling client on purpose: it must hold
11
+ regardless of how the audio is obtained.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from typing import Awaitable, Callable
19
+
20
+ import aiohttp
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ CMD_STOP = frozenset(("/без-записи", "/no-record", "/no-transcribe"))
25
+ CMD_START = frozenset(("/запись", "/record", "/transcribe"))
26
+ CHAT_POLL_INTERVAL = 5 # seconds between chat polls
27
+
28
+
29
+ def _ocs_auth(config):
30
+ return aiohttp.BasicAuth(config.nc_user, config.nc_password)
31
+
32
+
33
+ _OCS_HEADERS = {"OCS-APIRequest": "true", "Accept": "application/json"}
34
+
35
+
36
+ async def check_no_transcribe(config, room_token: str) -> bool:
37
+ """True if someone posted /без-записи in the room in the last 5 minutes —
38
+ transcription should be skipped for this call."""
39
+ url = (
40
+ f"{config.nc_url}/ocs/v2.php/apps/spreed/api/v1"
41
+ f"/chat/{room_token}?lookIntoFuture=0&limit=10"
42
+ )
43
+ try:
44
+ async with aiohttp.ClientSession(
45
+ auth=_ocs_auth(config), headers=_OCS_HEADERS,
46
+ ) as session:
47
+ async with session.get(url) as resp:
48
+ if resp.status != 200:
49
+ return False
50
+ data = await resp.json()
51
+ messages = data.get("ocs", {}).get("data", [])
52
+ now = time.time()
53
+ for msg in messages:
54
+ text = (msg.get("message") or "").strip().lower()
55
+ if now - msg.get("timestamp", 0) > 300:
56
+ continue
57
+ if text in CMD_STOP:
58
+ logger.info(
59
+ "Transcription disabled for %s by %s",
60
+ room_token, msg.get("actorDisplayName", "?"),
61
+ )
62
+ return True
63
+ except Exception:
64
+ logger.exception("Failed to check no-transcribe for %s", room_token)
65
+ return False
66
+
67
+
68
+ async def wait_for_second_participant(
69
+ monitor, room_token: str, is_active: Callable[[], bool],
70
+ *, max_wait: int = 60, poll: int = 3,
71
+ ) -> bool:
72
+ """Wait until 2+ participants are in a 1:1 call before connecting (the bot
73
+ must NOT join while ringing — internal clients auto-set inCall, which stops
74
+ the ring). Returns True once 2+ present, False on timeout / call-ended.
75
+ `is_active()` reports whether the call is still pending (e.g. token still in
76
+ the session map)."""
77
+ loop = asyncio.get_running_loop()
78
+ logger.info("1:1 call %s — waiting for second participant", room_token)
79
+ for _ in range(0, max_wait, poll):
80
+ if not is_active():
81
+ logger.info("1:1 call %s — ended while waiting", room_token)
82
+ return False
83
+ try:
84
+ count = await loop.run_in_executor(
85
+ None, monitor._count_incall_participants, room_token,
86
+ )
87
+ if count >= 2:
88
+ logger.info("1:1 call %s — %d in call, joining", room_token, count)
89
+ return True
90
+ except Exception:
91
+ logger.debug("1:1 participant poll error for %s", room_token,
92
+ exc_info=True)
93
+ await asyncio.sleep(poll)
94
+ logger.info("1:1 call %s — second participant never joined", room_token)
95
+ return False
96
+
97
+
98
+ class ChatCommandMonitor:
99
+ """Polls a room's chat for /без-записи (pause) and /запись (resume) and
100
+ flips `self.recording`. The capture session checks `recording` before
101
+ feeding frames. start() launches the loop; stop() cancels it."""
102
+
103
+ def __init__(self, config, room_token: str):
104
+ self._config = config
105
+ self._token = room_token
106
+ self.recording = True
107
+ self._last_id = 0
108
+ self._task: asyncio.Task | None = None
109
+
110
+ def start(self) -> None:
111
+ self._task = asyncio.create_task(self._loop())
112
+
113
+ async def stop(self) -> None:
114
+ if self._task:
115
+ self._task.cancel()
116
+ try:
117
+ await self._task
118
+ except asyncio.CancelledError:
119
+ pass
120
+
121
+ def apply(self, text: str, author: str = "?") -> None:
122
+ """Apply one chat message's command (pure; used by the loop + tests)."""
123
+ text = (text or "").strip().lower()
124
+ if text in CMD_STOP and self.recording:
125
+ self.recording = False
126
+ logger.info("Recording PAUSED for %s by %s", self._token, author)
127
+ elif text in CMD_START and not self.recording:
128
+ self.recording = True
129
+ logger.info("Recording RESUMED for %s by %s", self._token, author)
130
+
131
+ async def _loop(self) -> None:
132
+ base = (
133
+ f"{self._config.nc_url}/ocs/v2.php/apps/spreed/api/v1"
134
+ f"/chat/{self._token}"
135
+ )
136
+ try:
137
+ async with aiohttp.ClientSession(
138
+ auth=_ocs_auth(self._config), headers=_OCS_HEADERS,
139
+ ) as session:
140
+ async with session.get(f"{base}?lookIntoFuture=0&limit=1") as resp:
141
+ if resp.status == 200:
142
+ msgs = (await resp.json()).get("ocs", {}).get("data", [])
143
+ if msgs:
144
+ self._last_id = msgs[0].get("id", 0)
145
+ while True:
146
+ await asyncio.sleep(CHAT_POLL_INTERVAL)
147
+ try:
148
+ async with session.get(
149
+ f"{base}?lookIntoFuture=1"
150
+ f"&lastKnownMessageId={self._last_id}"
151
+ f"&limit=20&timeout=0"
152
+ ) as resp:
153
+ if resp.status != 200:
154
+ continue
155
+ for msg in (await resp.json()).get(
156
+ "ocs", {}).get("data", []):
157
+ mid = msg.get("id", 0)
158
+ if mid > self._last_id:
159
+ self._last_id = mid
160
+ self.apply(msg.get("message", ""),
161
+ msg.get("actorDisplayName", "?"))
162
+ except asyncio.CancelledError:
163
+ raise
164
+ except Exception:
165
+ logger.debug("Chat poll error for %s", self._token,
166
+ exc_info=True)
167
+ except asyncio.CancelledError:
168
+ pass
169
+ except Exception:
170
+ logger.exception("Chat monitor failed for %s", self._token)
@@ -0,0 +1,124 @@
1
+ """Configuration the capture path needs — and nothing else.
2
+
3
+ An application that also transcribes, analyses and stores calls has a much
4
+ larger configuration; capture needs about thirty values out of it.
5
+
6
+ CaptureConfig declares exactly those. Capture modules take config duck-typed, so
7
+ a larger application config with the same field names works unchanged — and a
8
+ capture-only deployment can build one from the environment instead.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+
15
+
16
+ def _csv(name: str) -> tuple:
17
+ return tuple(t.strip() for t in os.environ.get(name, "").split(",") if t.strip())
18
+
19
+
20
+ @dataclass
21
+ class CaptureConfig:
22
+ """What it takes to detect a call, join it, and receive per-speaker audio."""
23
+
24
+ # ── Nextcloud (OCS API: room metadata, chat commands, participants) ──
25
+ nc_url: str = ""
26
+ nc_user: str = ""
27
+ nc_password: str = ""
28
+
29
+ # ── Signalling (HPB) — the media path ──
30
+ hpb_url: str = ""
31
+ hpb_secret: str = ""
32
+
33
+ # ── Call detection: Nextcloud MySQL, optionally over an SSH tunnel ──
34
+ db_host: str = "127.0.0.1"
35
+ db_port: int = 3306
36
+ db_user: str = ""
37
+ db_password: str = ""
38
+ db_name: str = "nextcloud"
39
+ ssh_host: str = "" # empty = direct MySQL, no tunnel
40
+ ssh_port: int = 22
41
+ ssh_user: str = "root"
42
+ ssh_key_path: str = "/root/.ssh/id_rsa"
43
+ poll_interval: int = 5
44
+
45
+ # ── Which rooms to capture ──
46
+ # allowlist wins when set (pilot rollout / a single test room); the
47
+ # include/exclude pair is the older per-room filter.
48
+ room_allowlist: tuple = ()
49
+ included_rooms: tuple = ()
50
+ excluded_rooms: tuple = ()
51
+
52
+ # ── Where the audio goes ──
53
+ transcriber_backend: str = "in_process" # in_process | grpc
54
+ transcriber_grpc_target: str = "transcriber:7100"
55
+ transcriber_api_token: str = ""
56
+
57
+ # ── Result delivery (capture-only services POST S2 to the brain) ──
58
+ # Where a capture-only deployment POSTs the finished call. No default: it
59
+ # is deployment-specific, and silently posting nowhere is worse than failing.
60
+ nc_capture_s2_url: str = ""
61
+ s2_intake_token: str = ""
62
+ nc_capture_outbox_db: str = "/var/nc_capture_outbox/outbox.db"
63
+ nc_capture_replay_interval: float = 60.0
64
+
65
+ # ── Transcript presentation (travels with the finished call) ──
66
+ transcript_language: str = "ru"
67
+ daily_report_timezone: str = "Europe/Moscow"
68
+
69
+ # ── Voice-sample collection (optional; off unless enabled) ──
70
+ enrollment_collection_enabled: bool = False
71
+ enrollment_store_path: str = "/var/enrollment_store"
72
+ enrollment_min_rms: float = 0.003
73
+ enrollment_chunk_seconds: int = 30
74
+ enrollment_max_chunks_per_call: int = 10
75
+
76
+ diagnostic_logging: bool = False
77
+
78
+ @classmethod
79
+ def from_env(cls) -> "CaptureConfig":
80
+ """Build from the environment — for services that capture only."""
81
+ return cls(
82
+ nc_url=os.environ.get("NEXTCLOUD_URL", "").rstrip("/"),
83
+ nc_user=os.environ.get("NC_USER", ""),
84
+ nc_password=os.environ.get("NC_PASSWORD", ""),
85
+ hpb_url=os.environ.get("HPB_URL", ""),
86
+ hpb_secret=os.environ.get("HPB_SECRET", ""),
87
+ db_host=os.environ.get("DB_HOST", "127.0.0.1"),
88
+ db_port=int(os.environ.get("DB_PORT", "3306")),
89
+ db_user=os.environ.get("DB_USER", ""),
90
+ db_password=os.environ.get("DB_PASSWORD", ""),
91
+ db_name=os.environ.get("DB_NAME", "nextcloud"),
92
+ ssh_host=os.environ.get("SSH_HOST", ""),
93
+ ssh_port=int(os.environ.get("SSH_PORT", "22")),
94
+ ssh_user=os.environ.get("SSH_USER", "root"),
95
+ ssh_key_path=os.environ.get("SSH_KEY_PATH", "/root/.ssh/id_rsa"),
96
+ poll_interval=int(os.environ.get("POLL_INTERVAL", "5")),
97
+ room_allowlist=_csv("ROOM_ALLOWLIST"),
98
+ included_rooms=_csv("INCLUDED_ROOMS"),
99
+ excluded_rooms=_csv("EXCLUDED_ROOMS"),
100
+ transcriber_backend=os.environ.get("TRANSCRIBER_BACKEND", "in_process"),
101
+ transcriber_grpc_target=os.environ.get(
102
+ "TRANSCRIBER_GRPC_TARGET", "transcriber:7100"),
103
+ transcriber_api_token=os.environ.get("TRANSCRIBER_API_TOKEN", ""),
104
+ nc_capture_s2_url=os.environ.get("NC_CAPTURE_S2_URL", ""),
105
+ s2_intake_token=os.environ.get("S2_INTAKE_TOKEN", ""),
106
+ nc_capture_outbox_db=os.environ.get(
107
+ "NC_CAPTURE_OUTBOX_DB", "/var/nc_capture_outbox/outbox.db"),
108
+ nc_capture_replay_interval=float(
109
+ os.environ.get("NC_CAPTURE_REPLAY_INTERVAL", "60")),
110
+ transcript_language=os.environ.get("TRANSCRIPT_LANGUAGE", "ru"),
111
+ daily_report_timezone=os.environ.get(
112
+ "DAILY_REPORT_TIMEZONE", "Europe/Moscow"),
113
+ enrollment_collection_enabled=os.environ.get(
114
+ "ENROLLMENT_COLLECTION_ENABLED", "false").lower() == "true",
115
+ enrollment_store_path=os.environ.get(
116
+ "ENROLLMENT_STORE_PATH", "/var/enrollment_store"),
117
+ enrollment_min_rms=float(os.environ.get("ENROLLMENT_MIN_RMS", "0.003")),
118
+ enrollment_chunk_seconds=int(
119
+ os.environ.get("ENROLLMENT_CHUNK_SECONDS", "30")),
120
+ enrollment_max_chunks_per_call=int(
121
+ os.environ.get("ENROLLMENT_MAX_CHUNKS_PER_CALL", "10")),
122
+ diagnostic_logging=os.environ.get(
123
+ "DIAGNOSTIC_LOGGING", "false").lower() == "true",
124
+ )