stackchan-mcp 0.9.1__py3-none-win_amd64.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.
- stackchan_mcp/__init__.py +81 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/_libs/SOURCES.md +130 -0
- stackchan_mcp/_libs/opus.dll +0 -0
- stackchan_mcp/audio_input_hook.py +432 -0
- stackchan_mcp/audio_stream.py +162 -0
- stackchan_mcp/capture_server.py +469 -0
- stackchan_mcp/cli.py +958 -0
- stackchan_mcp/esp32_client.py +983 -0
- stackchan_mcp/event_log.py +189 -0
- stackchan_mcp/gateway.py +274 -0
- stackchan_mcp/handlers/__init__.py +7 -0
- stackchan_mcp/handlers/audio.py +21 -0
- stackchan_mcp/handlers/camera.py +25 -0
- stackchan_mcp/handlers/robot.py +52 -0
- stackchan_mcp/http_server.py +398 -0
- stackchan_mcp/mcp_router.py +126 -0
- stackchan_mcp/mdns_advertiser.py +347 -0
- stackchan_mcp/notify.example.yml +21 -0
- stackchan_mcp/notify_config.py +235 -0
- stackchan_mcp/ownership.py +270 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/queue.py +191 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +1365 -0
- stackchan_mcp/stt/__init__.py +62 -0
- stackchan_mcp/stt/audio_utils.py +102 -0
- stackchan_mcp/stt/base.py +94 -0
- stackchan_mcp/stt/faster_whisper.py +217 -0
- stackchan_mcp/stt/openai_whisper.py +177 -0
- stackchan_mcp/stt/orchestrator.py +568 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp/tts/__init__.py +62 -0
- stackchan_mcp/tts/audio_utils.py +177 -0
- stackchan_mcp/tts/base.py +86 -0
- stackchan_mcp/tts/orchestrator.py +688 -0
- stackchan_mcp/tts/voicevox.py +184 -0
- stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
- stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
- stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
- stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE-THIRD-PARTY +65 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""mDNS/DNS-SD advertisement for the StackChan WebSocket gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import logging
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SERVICE_TYPE = "_stackchan-mcp._tcp.local."
|
|
14
|
+
DEFAULT_INSTANCE = "stackchan-mcp"
|
|
15
|
+
SERVICE_NAME = f"{DEFAULT_INSTANCE}.{SERVICE_TYPE}"
|
|
16
|
+
FALLBACK_SERVICE_HOSTNAME = f"{DEFAULT_INSTANCE}.local."
|
|
17
|
+
TXT_VERSION = "1"
|
|
18
|
+
|
|
19
|
+
# Private (RFC1918) IPv4 ranges. Addresses inside these ranges are the most
|
|
20
|
+
# likely to be reachable from a same-LAN device, so they are advertised first.
|
|
21
|
+
# Anything outside is still advertised, just ordered after these.
|
|
22
|
+
_PRIVATE_NETWORKS = (
|
|
23
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
24
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
25
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Only prefixes shorter than /31 have a distinct network and broadcast address.
|
|
29
|
+
# A /31 (point-to-point) or /32 (host) address must never be treated as a
|
|
30
|
+
# network or broadcast address, since that would drop a legitimate host IP.
|
|
31
|
+
_MAX_NETWORK_BROADCAST_PREFIX = 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MdnsAdvertisement:
|
|
36
|
+
"""Resolved service advertisement parameters."""
|
|
37
|
+
|
|
38
|
+
service_type: str
|
|
39
|
+
service_name: str
|
|
40
|
+
server: str
|
|
41
|
+
port: int
|
|
42
|
+
path: str
|
|
43
|
+
properties: dict[str, str]
|
|
44
|
+
parsed_addresses: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_zeroconf_classes() -> tuple[type[Any], type[Any]]:
|
|
48
|
+
from zeroconf import ServiceInfo
|
|
49
|
+
from zeroconf.asyncio import AsyncZeroconf
|
|
50
|
+
|
|
51
|
+
return AsyncZeroconf, ServiceInfo
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_usable_ipv4(address: str) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
ip = ipaddress.ip_address(address)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
return (
|
|
60
|
+
ip.version == 4
|
|
61
|
+
and not ip.is_unspecified
|
|
62
|
+
and not ip.is_loopback
|
|
63
|
+
and not ip.is_multicast
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_network_or_broadcast_address(address: str, prefix: int | None) -> bool:
|
|
68
|
+
"""Return ``True`` when ``address`` is the subnet network or broadcast address.
|
|
69
|
+
|
|
70
|
+
Requires the subnet prefix: without it (e.g. the socket-based source) the
|
|
71
|
+
network/broadcast endpoints cannot be derived, so callers pass ``None`` and
|
|
72
|
+
the address is kept. Host prefixes (/31, /32) have no distinct network or
|
|
73
|
+
broadcast address and are never excluded here.
|
|
74
|
+
"""
|
|
75
|
+
if prefix is None or prefix > _MAX_NETWORK_BROADCAST_PREFIX:
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
interface = ipaddress.ip_interface(f"{address}/{prefix}")
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False
|
|
81
|
+
network = interface.network
|
|
82
|
+
return interface.ip in (network.network_address, network.broadcast_address)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_private_lan_address(address: str) -> bool:
|
|
86
|
+
try:
|
|
87
|
+
ip = ipaddress.ip_address(address)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return False
|
|
90
|
+
return any(ip in network for network in _PRIVATE_NETWORKS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _lan_reachability_tier(address: str) -> int:
|
|
94
|
+
"""Sort key: private (RFC1918) LAN addresses first, everything else after.
|
|
95
|
+
|
|
96
|
+
Returns a 2-value tier so a stable sort preserves the original enumeration
|
|
97
|
+
order within each tier.
|
|
98
|
+
"""
|
|
99
|
+
return 0 if _is_private_lan_address(address) else 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _select_advertised_addresses(
|
|
103
|
+
candidates: list[tuple[str, int | None]],
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
"""Filter, de-duplicate and order candidate addresses for advertisement.
|
|
106
|
+
|
|
107
|
+
Excludes only clearly-unusable addresses (loopback/multicast/unspecified via
|
|
108
|
+
:func:`_is_usable_ipv4`, plus network/broadcast addresses when a prefix is
|
|
109
|
+
known). All remaining addresses are kept and ordered by LAN-reachability
|
|
110
|
+
likelihood (RFC1918 private ranges first) using a stable sort, so addresses
|
|
111
|
+
a same-LAN device can reach are tried before overlay/global/edge-case ones.
|
|
112
|
+
"""
|
|
113
|
+
seen: set[str] = set()
|
|
114
|
+
usable: list[str] = []
|
|
115
|
+
for address, prefix in candidates:
|
|
116
|
+
if address in seen or not _is_usable_ipv4(address):
|
|
117
|
+
continue
|
|
118
|
+
if _is_network_or_broadcast_address(address, prefix):
|
|
119
|
+
continue
|
|
120
|
+
seen.add(address)
|
|
121
|
+
usable.append(address)
|
|
122
|
+
usable.sort(key=_lan_reachability_tier)
|
|
123
|
+
return usable
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_wildcard_host(host: str) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
ip = ipaddress.ip_address(host)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return host in {"", "*"}
|
|
131
|
+
return ip.is_unspecified
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _build_service_hostname() -> str:
|
|
135
|
+
"""Return a service-specific mDNS hostname for the SRV record.
|
|
136
|
+
|
|
137
|
+
Uses a fixed name to avoid advertising A records that overlap with
|
|
138
|
+
the system's own Bonjour hostname registration, which can trigger
|
|
139
|
+
macOS to change the user's LocalHostName.
|
|
140
|
+
"""
|
|
141
|
+
return FALLBACK_SERVICE_HOSTNAME
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _iter_ifaddr_ipv4_addresses() -> list[tuple[str, int | None]]:
|
|
145
|
+
"""Enumerate host IPv4 addresses with their subnet prefix length.
|
|
146
|
+
|
|
147
|
+
The prefix (``ip.network_prefix``) lets the caller drop network/broadcast
|
|
148
|
+
addresses; it is ``None`` only if ifaddr reports a non-integer prefix.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
import ifaddr
|
|
152
|
+
except ImportError:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
addresses: list[tuple[str, int | None]] = []
|
|
156
|
+
for adapter in ifaddr.get_adapters():
|
|
157
|
+
for ip in adapter.ips:
|
|
158
|
+
if not isinstance(ip.ip, str):
|
|
159
|
+
continue
|
|
160
|
+
prefix = ip.network_prefix if isinstance(ip.network_prefix, int) else None
|
|
161
|
+
addresses.append((ip.ip, prefix))
|
|
162
|
+
return addresses
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _iter_socket_ipv4_addresses() -> list[tuple[str, int | None]]:
|
|
166
|
+
"""Enumerate host IPv4 addresses via socket resolution.
|
|
167
|
+
|
|
168
|
+
This source carries no subnet prefix, so each entry pairs the address with
|
|
169
|
+
``None``; network/broadcast addresses therefore cannot be (and are not)
|
|
170
|
+
excluded from this source.
|
|
171
|
+
"""
|
|
172
|
+
addresses: set[str] = set()
|
|
173
|
+
hostnames = {socket.gethostname(), socket.getfqdn()}
|
|
174
|
+
|
|
175
|
+
for hostname in hostnames:
|
|
176
|
+
try:
|
|
177
|
+
infos = socket.getaddrinfo(hostname, None, socket.AF_INET)
|
|
178
|
+
except socket.gaierror:
|
|
179
|
+
continue
|
|
180
|
+
for _family, _socktype, _proto, _canonname, sockaddr in infos:
|
|
181
|
+
addresses.add(sockaddr[0])
|
|
182
|
+
|
|
183
|
+
# Add the primary outbound IPv4 as a best-effort fallback. UDP connect()
|
|
184
|
+
# selects a local address without sending packets.
|
|
185
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
186
|
+
try:
|
|
187
|
+
sock.connect(("8.8.8.8", 80))
|
|
188
|
+
addresses.add(sock.getsockname()[0])
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
finally:
|
|
192
|
+
sock.close()
|
|
193
|
+
|
|
194
|
+
return [(address, None) for address in sorted(addresses)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _enumerate_usable_ipv4_addresses() -> list[str]:
|
|
198
|
+
ifaddr_entries = _iter_ifaddr_ipv4_addresses()
|
|
199
|
+
socket_entries = _iter_socket_ipv4_addresses()
|
|
200
|
+
|
|
201
|
+
# The socket-based source carries no subnet prefix, so on its own it cannot
|
|
202
|
+
# exclude network/broadcast addresses. When the same address also appears
|
|
203
|
+
# in the ifaddr source (which does carry a prefix), adopt that prefix so
|
|
204
|
+
# ``_is_network_or_broadcast_address`` can recognise and drop the entry.
|
|
205
|
+
# Without this, a host whose ``getaddrinfo``-resolved set includes an
|
|
206
|
+
# interface's subnet network base (which can happen when an interface ends
|
|
207
|
+
# up with its own subnet's network address as its host IP) would be
|
|
208
|
+
# advertised and then crash the zeroconf socket with ``EADDRNOTAVAIL``.
|
|
209
|
+
prefix_by_address = {
|
|
210
|
+
address: prefix
|
|
211
|
+
for address, prefix in ifaddr_entries
|
|
212
|
+
if prefix is not None
|
|
213
|
+
}
|
|
214
|
+
enriched_socket_entries = [
|
|
215
|
+
(address, prefix_by_address.get(address, prefix))
|
|
216
|
+
for address, prefix in socket_entries
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
return _select_advertised_addresses(
|
|
220
|
+
[*ifaddr_entries, *enriched_socket_entries]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve_concrete_host_ipv4_addresses(host: str) -> list[str]:
|
|
225
|
+
try:
|
|
226
|
+
ip = ipaddress.ip_address(host)
|
|
227
|
+
except ValueError:
|
|
228
|
+
try:
|
|
229
|
+
infos = socket.getaddrinfo(host, None, socket.AF_INET)
|
|
230
|
+
except socket.gaierror:
|
|
231
|
+
return []
|
|
232
|
+
addresses = [sockaddr[0] for *_unused, sockaddr in infos]
|
|
233
|
+
else:
|
|
234
|
+
addresses = [str(ip)]
|
|
235
|
+
|
|
236
|
+
# A concrete HOST carries no subnet prefix, so network/broadcast addresses
|
|
237
|
+
# cannot be derived; pair each address with ``None`` to keep them.
|
|
238
|
+
return _select_advertised_addresses([(address, None) for address in addresses])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_advertisement(
|
|
242
|
+
*,
|
|
243
|
+
host: str,
|
|
244
|
+
port: int,
|
|
245
|
+
path: str = "/",
|
|
246
|
+
) -> MdnsAdvertisement | None:
|
|
247
|
+
"""Resolve advertisement parameters, or ``None`` if they would be unusable."""
|
|
248
|
+
if port <= 0 or port > 65535:
|
|
249
|
+
logger.warning(
|
|
250
|
+
"mDNS advertisement skipped: WebSocket port %s is not publishable",
|
|
251
|
+
port,
|
|
252
|
+
)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
256
|
+
addresses = (
|
|
257
|
+
_enumerate_usable_ipv4_addresses()
|
|
258
|
+
if _is_wildcard_host(host)
|
|
259
|
+
else _resolve_concrete_host_ipv4_addresses(host)
|
|
260
|
+
)
|
|
261
|
+
if not addresses:
|
|
262
|
+
logger.warning(
|
|
263
|
+
"mDNS advertisement skipped: no usable non-loopback IPv4 address "
|
|
264
|
+
"found for HOST=%s",
|
|
265
|
+
host,
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
return MdnsAdvertisement(
|
|
270
|
+
service_type=SERVICE_TYPE,
|
|
271
|
+
service_name=SERVICE_NAME,
|
|
272
|
+
server=_build_service_hostname(),
|
|
273
|
+
port=port,
|
|
274
|
+
path=normalized_path,
|
|
275
|
+
properties={"path": normalized_path, "version": TXT_VERSION},
|
|
276
|
+
parsed_addresses=addresses,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class MdnsAdvertiser:
|
|
281
|
+
"""Registers the gateway's WebSocket endpoint via mDNS/DNS-SD."""
|
|
282
|
+
|
|
283
|
+
def __init__(self) -> None:
|
|
284
|
+
self._zeroconf: Any | None = None
|
|
285
|
+
self._service_info: Any | None = None
|
|
286
|
+
|
|
287
|
+
async def start(self, *, host: str, port: int, path: str = "/") -> None:
|
|
288
|
+
advertisement = build_advertisement(host=host, port=port, path=path)
|
|
289
|
+
if advertisement is None:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
AsyncZeroconf, ServiceInfo = _load_zeroconf_classes()
|
|
293
|
+
# Constrain zeroconf to the IPv4 interfaces we actually advertise on.
|
|
294
|
+
# The default ``InterfaceChoice.All`` makes zeroconf bind a socket on
|
|
295
|
+
# every host IPv4 address it can find, which on a host with several
|
|
296
|
+
# interfaces can include addresses the kernel refuses ``sendto`` on
|
|
297
|
+
# (the engine then never finishes starting, and the gateway hangs in
|
|
298
|
+
# ``async_wait_for_start``). Passing the same set of addresses we put
|
|
299
|
+
# into the SRV record keeps zeroconf in sync with our advertisement
|
|
300
|
+
# and skips the unusable interfaces entirely.
|
|
301
|
+
zeroconf = AsyncZeroconf(interfaces=advertisement.parsed_addresses)
|
|
302
|
+
info = ServiceInfo(
|
|
303
|
+
advertisement.service_type,
|
|
304
|
+
advertisement.service_name,
|
|
305
|
+
port=advertisement.port,
|
|
306
|
+
properties=advertisement.properties,
|
|
307
|
+
server=advertisement.server,
|
|
308
|
+
parsed_addresses=advertisement.parsed_addresses,
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
await zeroconf.async_register_service(info, allow_name_change=True)
|
|
312
|
+
except Exception:
|
|
313
|
+
await zeroconf.async_close()
|
|
314
|
+
raise
|
|
315
|
+
self._zeroconf = zeroconf
|
|
316
|
+
self._service_info = info
|
|
317
|
+
registered_name = getattr(info, "name", advertisement.service_name)
|
|
318
|
+
if registered_name != advertisement.service_name:
|
|
319
|
+
logger.warning(
|
|
320
|
+
"mDNS service registered under a modified name %s (requested %s). "
|
|
321
|
+
"A previous gateway instance may not have shut down cleanly and its "
|
|
322
|
+
"registration is still visible on the network. The ESP32 still "
|
|
323
|
+
"discovers this gateway by service type, so auto-discovery keeps "
|
|
324
|
+
"working; the stale entry clears when its mDNS TTL expires.",
|
|
325
|
+
registered_name,
|
|
326
|
+
advertisement.service_name,
|
|
327
|
+
)
|
|
328
|
+
logger.info(
|
|
329
|
+
"mDNS advertising %s on port %d with addresses %s",
|
|
330
|
+
registered_name,
|
|
331
|
+
advertisement.port,
|
|
332
|
+
", ".join(advertisement.parsed_addresses),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
async def stop(self) -> None:
|
|
336
|
+
zeroconf = self._zeroconf
|
|
337
|
+
info = self._service_info
|
|
338
|
+
self._zeroconf = None
|
|
339
|
+
self._service_info = None
|
|
340
|
+
|
|
341
|
+
if zeroconf is None:
|
|
342
|
+
return
|
|
343
|
+
try:
|
|
344
|
+
if info is not None:
|
|
345
|
+
await zeroconf.async_unregister_service(info)
|
|
346
|
+
finally:
|
|
347
|
+
await zeroconf.async_close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ~/.config/stackchan-mcp/notify.yml
|
|
2
|
+
legacy_event:
|
|
3
|
+
enabled: false # self-defined method "stackchan/event" (existing, backward-compat)
|
|
4
|
+
channels:
|
|
5
|
+
enabled: false # standard "notifications/claude/channel"
|
|
6
|
+
jsonl:
|
|
7
|
+
enabled: false # JSONL append for out-of-process host integration
|
|
8
|
+
path: ~/.claude/stackchan-events.jsonl
|
|
9
|
+
# messages: override the wording delivered for each physical event.
|
|
10
|
+
# Omit this block entirely to keep the built-in experiential defaults
|
|
11
|
+
# ("head was tapped" / "head was stroked for {duration_ms}ms").
|
|
12
|
+
# The examples below customize the phrasing per event type; {duration_ms}
|
|
13
|
+
# is substituted from the event payload.
|
|
14
|
+
messages:
|
|
15
|
+
touch:
|
|
16
|
+
tap:
|
|
17
|
+
action: head_pat
|
|
18
|
+
template: "got a head pat"
|
|
19
|
+
stroke:
|
|
20
|
+
action: head_stroke
|
|
21
|
+
template: "head being stroked for {duration_ms}ms"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Notification configuration for Stack-chan physical events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Final
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .event_log import DEFAULT_LOG_PATH, PATH_ENV_VAR
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
CONFIG_ENV_VAR: Final[str] = "STACKCHAN_NOTIFY_CONFIG"
|
|
18
|
+
CONFIG_FILENAME: Final[str] = "stackchan-mcp/notify.yml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class MessageTemplate:
|
|
23
|
+
action: str
|
|
24
|
+
template: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class NotifyConfig:
|
|
29
|
+
legacy_event_enabled: bool
|
|
30
|
+
channels_enabled: bool
|
|
31
|
+
jsonl_enabled: bool
|
|
32
|
+
jsonl_path: Path
|
|
33
|
+
messages: dict[tuple[str, str], MessageTemplate]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DEFAULT_MESSAGE_TEMPLATES: Final[dict[tuple[str, str], MessageTemplate]] = {
|
|
37
|
+
("touch", "tap"): MessageTemplate(
|
|
38
|
+
action="head_pat",
|
|
39
|
+
template="head was tapped",
|
|
40
|
+
),
|
|
41
|
+
("touch", "stroke"): MessageTemplate(
|
|
42
|
+
action="head_stroke",
|
|
43
|
+
template="head was stroked for {duration_ms}ms",
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_notify_config_path() -> Path | None:
|
|
49
|
+
"""Return the first existing notify.yml path, or None."""
|
|
50
|
+
override = os.environ.get(CONFIG_ENV_VAR)
|
|
51
|
+
if override:
|
|
52
|
+
path = Path(override).expanduser()
|
|
53
|
+
if path.exists():
|
|
54
|
+
return path
|
|
55
|
+
logger.warning(
|
|
56
|
+
"%s points to a non-existent file: %s",
|
|
57
|
+
CONFIG_ENV_VAR,
|
|
58
|
+
path,
|
|
59
|
+
)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
63
|
+
candidates = []
|
|
64
|
+
if xdg_config_home:
|
|
65
|
+
candidates.append(Path(xdg_config_home).expanduser() / CONFIG_FILENAME)
|
|
66
|
+
candidates.append(Path.home() / ".config" / CONFIG_FILENAME)
|
|
67
|
+
|
|
68
|
+
for path in candidates:
|
|
69
|
+
if path.exists():
|
|
70
|
+
return path
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_notify_config() -> NotifyConfig:
|
|
75
|
+
"""Load the notification config, falling back to all-OFF on errors."""
|
|
76
|
+
path = resolve_notify_config_path()
|
|
77
|
+
if path is None:
|
|
78
|
+
return _default_config()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with path.open("r", encoding="utf-8") as f:
|
|
82
|
+
raw = yaml.safe_load(f)
|
|
83
|
+
return _parse_config(raw)
|
|
84
|
+
except (OSError, yaml.YAMLError, ValueError, TypeError) as exc:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Failed to load Stack-chan notify config from %s: %s",
|
|
87
|
+
path,
|
|
88
|
+
exc,
|
|
89
|
+
)
|
|
90
|
+
return _default_config()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render_template(template: str, payload: dict[str, Any]) -> str:
|
|
94
|
+
"""Render a user template while preserving unknown placeholders.
|
|
95
|
+
|
|
96
|
+
A malformed-but-yaml-valid template (e.g. ``{duration_ms.foo}`` or
|
|
97
|
+
``{unknown[0]}``) can raise ``AttributeError`` or ``TypeError`` from
|
|
98
|
+
``str.format_map``. These are caught here so a single bad user template
|
|
99
|
+
cannot crash the channels dispatch path on every physical event; the
|
|
100
|
+
original template string is returned as a defensive fallback.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
return template.format_map(_SafeFormatDict(payload))
|
|
104
|
+
except (IndexError, KeyError, ValueError, AttributeError, TypeError):
|
|
105
|
+
return template
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _default_config() -> NotifyConfig:
|
|
109
|
+
return NotifyConfig(
|
|
110
|
+
legacy_event_enabled=False,
|
|
111
|
+
channels_enabled=False,
|
|
112
|
+
jsonl_enabled=False,
|
|
113
|
+
jsonl_path=_resolve_jsonl_path(None),
|
|
114
|
+
messages=_default_messages(),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _default_messages() -> dict[tuple[str, str], MessageTemplate]:
|
|
119
|
+
return dict(DEFAULT_MESSAGE_TEMPLATES)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_config(raw: Any) -> NotifyConfig:
|
|
123
|
+
if raw is None:
|
|
124
|
+
raw = {}
|
|
125
|
+
if not isinstance(raw, dict):
|
|
126
|
+
raise ValueError("notify config root must be a mapping")
|
|
127
|
+
|
|
128
|
+
legacy_event_enabled = _parse_enabled(raw, "legacy_event")
|
|
129
|
+
channels_enabled = _parse_enabled(raw, "channels")
|
|
130
|
+
jsonl_section = _parse_section(raw, "jsonl")
|
|
131
|
+
jsonl_enabled = _parse_enabled(raw, "jsonl")
|
|
132
|
+
jsonl_path = _resolve_jsonl_path(_optional_string(jsonl_section, "path"))
|
|
133
|
+
messages = _parse_messages(raw.get("messages"))
|
|
134
|
+
|
|
135
|
+
return NotifyConfig(
|
|
136
|
+
legacy_event_enabled=legacy_event_enabled,
|
|
137
|
+
channels_enabled=channels_enabled,
|
|
138
|
+
jsonl_enabled=jsonl_enabled,
|
|
139
|
+
jsonl_path=jsonl_path,
|
|
140
|
+
messages=messages,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_enabled(root: dict[Any, Any], section_name: str) -> bool:
|
|
145
|
+
section = _parse_section(root, section_name)
|
|
146
|
+
enabled = section.get("enabled", False)
|
|
147
|
+
if not isinstance(enabled, bool):
|
|
148
|
+
raise ValueError(f"{section_name}.enabled must be a boolean")
|
|
149
|
+
return enabled
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_section(root: dict[Any, Any], section_name: str) -> dict[Any, Any]:
|
|
153
|
+
section = root.get(section_name, {})
|
|
154
|
+
if section is None:
|
|
155
|
+
return {}
|
|
156
|
+
if not isinstance(section, dict):
|
|
157
|
+
raise ValueError(f"{section_name} must be a mapping")
|
|
158
|
+
return section
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _optional_string(section: dict[Any, Any], key: str) -> str | None:
|
|
162
|
+
value = section.get(key)
|
|
163
|
+
if value is None:
|
|
164
|
+
return None
|
|
165
|
+
if not isinstance(value, str) or not value:
|
|
166
|
+
raise ValueError(f"{key} must be a non-empty string")
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_messages(raw_messages: Any) -> dict[tuple[str, str], MessageTemplate]:
|
|
171
|
+
messages = _default_messages()
|
|
172
|
+
if raw_messages is None:
|
|
173
|
+
return messages
|
|
174
|
+
if not isinstance(raw_messages, dict):
|
|
175
|
+
raise ValueError("messages must be a mapping")
|
|
176
|
+
|
|
177
|
+
for event_type, subtypes in raw_messages.items():
|
|
178
|
+
if not isinstance(event_type, str) or not event_type:
|
|
179
|
+
raise ValueError("messages event_type keys must be non-empty strings")
|
|
180
|
+
if not isinstance(subtypes, dict):
|
|
181
|
+
raise ValueError(f"messages.{event_type} must be a mapping")
|
|
182
|
+
for subtype, message in subtypes.items():
|
|
183
|
+
if not isinstance(subtype, str) or not subtype:
|
|
184
|
+
raise ValueError("messages subtype keys must be non-empty strings")
|
|
185
|
+
if not isinstance(message, dict):
|
|
186
|
+
raise ValueError(f"messages.{event_type}.{subtype} must be a mapping")
|
|
187
|
+
action = message.get("action")
|
|
188
|
+
template = message.get("template")
|
|
189
|
+
if not isinstance(action, str) or not action:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"messages.{event_type}.{subtype}.action must be a non-empty string"
|
|
192
|
+
)
|
|
193
|
+
if not isinstance(template, str) or not template:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"messages.{event_type}.{subtype}.template must be a non-empty string"
|
|
196
|
+
)
|
|
197
|
+
messages[(event_type, subtype)] = MessageTemplate(
|
|
198
|
+
action=action,
|
|
199
|
+
template=template,
|
|
200
|
+
)
|
|
201
|
+
return messages
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve_jsonl_path(configured_path: str | None) -> Path:
|
|
205
|
+
override = os.environ.get(PATH_ENV_VAR)
|
|
206
|
+
if override:
|
|
207
|
+
return _absolute_path(override)
|
|
208
|
+
if configured_path:
|
|
209
|
+
return _absolute_path(configured_path)
|
|
210
|
+
return DEFAULT_LOG_PATH
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _absolute_path(raw_path: str) -> Path:
|
|
214
|
+
path = Path(raw_path).expanduser()
|
|
215
|
+
if path.is_absolute():
|
|
216
|
+
return path
|
|
217
|
+
return path.resolve()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _SafeFormatDict(dict[str, Any]):
|
|
221
|
+
def __missing__(self, key: str) -> "_MissingPlaceholder":
|
|
222
|
+
return _MissingPlaceholder(key)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _MissingPlaceholder:
|
|
226
|
+
def __init__(self, key: str) -> None:
|
|
227
|
+
self._key = key
|
|
228
|
+
|
|
229
|
+
def __format__(self, spec: str) -> str:
|
|
230
|
+
if spec:
|
|
231
|
+
return "{" + self._key + ":" + spec + "}"
|
|
232
|
+
return "{" + self._key + "}"
|
|
233
|
+
|
|
234
|
+
def __str__(self) -> str:
|
|
235
|
+
return "{" + self._key + "}"
|