-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot.py
More file actions
executable file
·3934 lines (3524 loc) · 186 KB
/
Copy pathbot.py
File metadata and controls
executable file
·3934 lines (3524 loc) · 186 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
claudegram — a private Telegram bridge that drives persistent Claude Code instance(s)
from your phone. Voice notes (transcribed locally with faster-whisper), text, images,
and documents go in; Claude's live activity and answers stream back into the chat.
This process is the bridge only: Telegram I/O, the firewall + intrusion lock, the `bot`
commands, rendering, the watchdogs, and the IPC channels. The tray app (gui.py)
supervises it; transcription runs in a killable subprocess (transcribe_worker.py); each
Claude session is owned by a claude_driver.ClaudeController.
Configuration lives in FILES next to this script — never the environment:
token.txt the bot token from @BotFather (chmod 600)
instance.json per-install config: "allowed_user_ids" (first id = MASTER, rest =
guests), install identity ("name"/"color"/"glyph"), optional
"whisper" {model, device, language}, "default_bot", "resend_from",
"allow_cron", "kokoro_model_dir"
bots/<name>/ the bot roster (config.json + optional main.md per bot)
"""
import asyncio
import atexit
import collections
import json
import logging
import os
import re
import signal
import subprocess
import sys
import tempfile
import time
import types
import uuid
from pathlib import Path
import instance_id
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from claude_agent_sdk import (
AssistantMessage,
ResultMessage,
StreamEvent,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from claude_driver import (
ClaudeController,
VALID_EFFORTS,
ambient_default_model,
default_model_guard,
force_subscription_env,
sigkill_claude_subtree,
summarize_tool,
)
logging.basicConfig(
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
level=logging.INFO,
)
# httpx logs every Telegram poll at INFO; quiet it down.
logging.getLogger("httpx").setLevel(logging.WARNING)
log = logging.getLogger("claudegram")
HERE = Path(__file__).resolve().parent
# Per-install config lives in instance.json (gitignored), read from the FILE — never the
# environment, which leaks between installs (env once handed a bot another's allowlist).
# INSTANCE is the whole config dict; the getters below pull fields from it. Our config never
# comes from the environment: env is the mother of all footguns.
try:
_INSTANCE_TEXT = (HERE / "instance.json").read_text(encoding="utf-8")
except OSError:
_INSTANCE_TEXT = ""
try:
INSTANCE = json.loads(_INSTANCE_TEXT or "{}")
INSTANCE = INSTANCE if isinstance(INSTANCE, dict) else {}
except ValueError:
INSTANCE = {}
def load_token() -> str:
"""The bot token, from token.txt (a dedicated secret file; keep it chmod 600). Not from the
environment — our config never comes from env."""
token_file = HERE / "token.txt"
token = token_file.read_text(encoding="utf-8").strip() if token_file.exists() else ""
if not token:
raise SystemExit(
"No bot token found.\n"
" Put the token from @BotFather (/newbot) into a file named token.txt in this\n"
" directory (chmod 600 it).\n"
)
return token
# --- Whisper transcription config -----------------------------------------------
#
# The model loads and runs in a SEPARATE process (transcribe_worker.py) so a stalled
# decode can be killed — a thread cannot. These WHISPER_* values seed the defaults; the
# worker is told its compute type per-spawn (see get_compute_type), so `bot transcribe`
# can switch quality at runtime with no restart. WHISPER_LANGUAGE is inherited by the worker.
_WHISPER = INSTANCE.get("whisper") or {}
MODEL_SIZE = str(_WHISPER.get("model") or "large-v3").strip()
DEVICE = str(_WHISPER.get("device") or "cpu").strip()
WHISPER_LANGUAGE = str(_WHISPER.get("language") or "").strip() or None
# Friendly transcription-quality presets, toggled live per-bot via `bot transcribe <name>`.
# The change takes effect on the NEXT voice message — the worker reads its compute type fresh
# on every spawn, so nothing needs restarting.
TRANSCRIBE_PRESETS = { # name -> ctranslate2 compute type
"best": "float32", # large-v3 full precision — slowest, most accurate
"good": "int8_float32", # ~2x faster, near-best accuracy
"fast": "int8", # ~3-4x faster, a little accuracy lost
}
_PRESET_BY_COMPUTE = {v: k for k, v in TRANSCRIBE_PRESETS.items()}
# Immutable code default for transcription (best/float32 — fully ours, always available).
# A bot without a `transcribe` config inherits this at spawn; `bot transcribe` overrides a
# bot's live value for the process only (never touches this default, never persists).
DEFAULT_COMPUTE = TRANSCRIBE_PRESETS["best"]
def get_compute_type() -> str:
"""The immutable global default compute type (best). Bots override live via bot transcribe."""
return DEFAULT_COMPUTE
def session_compute(session) -> str:
"""A bot's live compute: its own runtime/config value if set, else the code default."""
return getattr(session, "compute", None) or DEFAULT_COMPUTE
# Allowlist: only these Telegram user ids are served; everyone else is politely refused. If
# empty, the bot answers anyone. FILE ORDER matters: the first id is the MASTER (opens a chat,
# gets every proactive notification); the rest are GUESTS (may use the bot, no notifications).
# Read from instance.json (the per-install config file), never the environment.
ALLOWED_USER_IDS = instance_id.parse_allowed_ids(_INSTANCE_TEXT)
def is_authorized(update: Update) -> bool:
if not ALLOWED_USER_IDS:
return True
user = update.effective_user
return user is not None and user.id in ALLOWED_USER_IDS
def _master():
"""The MASTER user = ALLOWED_USER_IDS[0] (FIRST in instance.json's list). Every proactive
notification (status/watchdog/startup/harness/intrusion) goes here, and this user
must open a chat with the bot. Everyone else in the allowlist is a GUEST: they may
use the bot (their replies land in their own chat) but receive no notifications.
None if the allowlist is empty."""
return ALLOWED_USER_IDS[0] if ALLOWED_USER_IDS else None
# --- Transcription (runs in a killable subprocess; see transcribe_worker.py) ----
TRANSCRIBE_HEARTBEAT = 10.0 # fixed cadence to re-edit the bubble (moving datetime = alive)
# Decoder watchdog. The live path runs the decode as a KILLABLE SUBPROCESS
# (transcribe_worker.py) — not a thread, because a thread cannot be killed. If the decode
# overruns its budget (a stalled/looping whisper, a wedged process) the watchdog kills it,
# so a bad clip can never freeze the bridge again. Budget is generous: a slow-but-healthy
# clip survives; only a genuine runaway gets the axe.
TRANSCRIBE_BUDGET_FACTOR = 6.0 # kill past audio_duration × this …
TRANSCRIBE_MIN_BUDGET = 120.0 # … but never before this (protects tiny clips)
TRANSCRIBE_NODUR_BUDGET = 900.0 # hard cap when the clip's duration is unknown
# The %/ETA shown in the bubble are the REAL ones: the worker subprocess streams
# `PROGRESS <pct> <eta>` lines on stdout (computed from seg.end / audio duration) and the
# parent reads them live. The clock advances on its own 10s timer regardless, so even while
# a long segment decodes (no fresh %), the bubble still proves the bridge is alive.
# --- Claude Code control ------------------------------------------------------
# Default working dir for the driven Claude: an install-local, gitignored `work/` (so each
# copy is self-contained and nothing leaks into git).
WORK = HERE / "work"
SESSION_FILE = HERE / "session.id" # persisted Claude session id (for resume)
CWD_FILE = HERE / "cwd.path" # persisted working directory
LOG_PATH = HERE / "claudegram.log" # bridge log (written by the tray supervisor)
RESEND_KEY_FILE = HERE / "resend.key" # presence => optional email feature enabled (see cg-mail)
AUDIO_TMP = Path(tempfile.gettempdir()) / "claudegram_audio" # legacy /tmp voice dir (pre-work/ era) — no longer written; swept at startup to clear old leftovers
VOICE_TMP = Path(tempfile.gettempdir()) / "claudegram_voiceback" # transient TTS output (sent, then deleted)
IMAGE_DIR = WORK / "incoming-images" # incoming images: work pieces kept in the bot's work/ (persist, never auto-deleted)
DOC_DIR = WORK / "incoming-docs" # incoming documents (PDF/office/text): work pieces kept in work/ (persist, never auto-deleted)
AUDIO_DIR = WORK / "incoming-audio" # incoming voice/audio: work pieces kept in work/ (persist, never auto-deleted) so the original recording stays reusable (e.g. as narration)
TG_BOT_DL_LIMIT = 20 * 1024 * 1024 # Telegram Bot API hard-caps bot file DOWNLOADS at 20 MB (getFile); larger files can't be pulled by a bot and need out-of-band handling
HARNESS_OUTBOX = HERE / "outbox" # drop dir: any program leaves a msg -> sent to phone
HARNESS_INBOX = HERE / "inbox" # drop dir: "bot harness <msg>" -> read by the AI here
MEDIA_OUTBOX = HERE / "media-outbox"
CMD_INBOX = HERE / "cmd-inbox" # drop dir: the DRIVEN Claude drops a config command
# (via ./cg-cmd) -> run through the bot-command handler
WAKE_INBOX = HERE / "wake-inbox" # drop dir: a scheduler (cron via ./cg-wake) or a peer
# program drops a msg -> injected as a turn into the
# current bot session (the external -> bot-turn path)
# --- multi-session multiplexing (ONE Telegram bot, N concurrent Claude sessions) -----
# Names + color bubbles. "claude" (orange) is the DEFAULT/hidden session: until a SECOND
# session is created, registry.multiplexing() is False and every badge is "" — so the whole
# bridge renders EXACTLY like a single-Claude install. The moment a sibling exists, every
# bot-authored artifact is prefixed with "<emoji> <name> · " so an interleaved scroll is
# legible. Sessions run CONCURRENTLY: each is its own ClaudeController (own session id / cwd /
# effort) with its own dispatch queue + worker + watchdog + spontaneous relay.
# The roster is scanned from bots/*/ (discover_bots); each bot's directory is its definition —
# icon, aliases, model, effort, and the internal flag all live in its config.json.
DEFAULT_SESSION = "claude" # the default/hidden session; its config lives in bots/claude/
DEFAULT_ICON = "⚙️" # badge for a bot whose config declares no icon
# Seed used to regenerate the default bot's config if bots/claude/config.json goes missing (fresh
# checkout, accidental deletion). It's only the seed — once the file exists, the file is truth.
_DEFAULT_CLAUDE_CONFIG = {
"icon": "🟠",
"aliases": ["claud", "clode", "cloude", "claudee", "clod",
"cloud", "clawd", "clawed", "klaus", "klaude"],
}
# --- anti-stall guard -----------------------------------------------------------------------
NOSTALL_BOT = "jack" # the guard bot, by name; its config lives in bots/jack/ (no dir → can't enable)
NOSTALL_FILE = HERE / "nostall.mode" # presence = guard ON (global sticky, like voice)
NOSTALL_FEED_MSGS = 6 # how many of a bot's latest answers the guard reviews
NOSTALL_COOLDOWN_SECS = 180 # min seconds between interventions on ONE bot
NOSTALL_LEGIT_MARKER = "LEGIT STOP" # verdict meaning "release it — genuinely done"
# --- BOINK: the dumb backstop -----------------------------------------------------------------
# A mode toggle (global sticky, like voice/nostall). When ON, every time a bot actually STOPS
# (goes idle with nothing running) it gets poked with a bare "BOINK" — no manual, no review,
# no smart guard bot. It carries no instructions because the meaning lives in the bot, not the
# poke: receiving BOINK means it drifted into a stop. Reuses the idle watchdog (NOT an external
# poller); never disarmed reflexively. `nostall`'s dumber cousin — on purpose.
BOINK_FILE = HERE / "boink.mode" # presence = BOINK ON (global sticky, like nostall)
BOINK_COOLDOWN_SECS = 30 # min seconds between pokes on ONE bot (floor; the 55s
# Telegram-silence gate already spaces them naturally)
def resolve_session_name(raw: str):
"""Map a raw (possibly mis-transcribed) token to a currently-available selectable bot, or
None. Everything comes from the boot scan: exact name, then a config-declared alias, then a
fuzzy match — all against the bots that exist right now."""
tok = (raw or "").strip().split()
if not tok:
return None
n = tok[0].strip(" .!?,;:'\"").lower()
sel = selectable_bots()
if n in sel: # internal/system bots aren't in `sel`, so they never resolve
return n
aliases = session_aliases()
if n in aliases and aliases[n] in sel:
return aliases[n]
import difflib
close = difflib.get_close_matches(n, sel, n=1, cutoff=0.8)
return close[0] if close else None
def _session_files(name: str):
"""(session_file, cwd_file) for a session. The DEFAULT reuses the original
single-session files, so an existing install's live conversation simply becomes 'claude';
named sessions get namespaced siblings (session.<name>.id, …)."""
if name == DEFAULT_SESSION:
return str(SESSION_FILE), str(CWD_FILE)
return (str(HERE / f"session.{name}.id"),
str(HERE / f"cwd.{name}.path"))
BOTS_DIR = HERE / "bots" # every bot is a subdirectory here (config.json + optional main.md)
GLOBAL_MD = BOTS_DIR / "global.md" # optional shared bearings loaded by EVERY bot before its own persona
def bot_config(name: str) -> dict:
try:
return json.loads((BOTS_DIR / name / "config.json").read_text(encoding="utf-8"))
except Exception:
return {}
def ensure_default_bot() -> None:
"""Self-heal the default 'claude' bot before the roster is read: guarantee its dir,
config.json, and var/.gitkeep exist, regenerating only what's missing. Scoped to the default
bot — other bots are simply whatever is on disk."""
d = BOTS_DIR / DEFAULT_SESSION
keep = d / "var" / ".gitkeep"
if not keep.is_file():
keep.parent.mkdir(parents=True, exist_ok=True)
keep.touch()
cfg = d / "config.json"
if not cfg.is_file():
cfg.write_text(json.dumps(_DEFAULT_CLAUDE_CONFIG, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
log.info("bootstrap: regenerated %s", cfg)
# Shorthands resolved to an exact model ID before reaching the CLI (which may not
# know the bare alias). Defined here because Session() runs at import time.
# fable is safe to expose (incl. self-config): it draws usage credits, and with
# credits disabled it just stops — it can no longer bill overage (old 4daac98
# exclusion is obsolete).
MODEL_ALIASES = {"fable": "claude-fable-5"}
class Session:
"""One named Claude instance behind the single Telegram bot."""
def __init__(self, name: str):
self.name = name
self.config = bot_config(name)
self.emoji = self.config.get("icon") or DEFAULT_ICON
self.internal = bool(self.config.get("internal"))
self.empty_reply = self.config.get("empty_reply")
# Ring of this bot's most-recent answers — what the guard reviews when it goes idle.
self.recent_answers = collections.deque(maxlen=NOSTALL_FEED_MSGS)
# Live transcription compute: config value at spawn, else None => the code default.
# `bot transcribe` overrides this in-memory (process-lived); never persisted.
self.compute = TRANSCRIBE_PRESETS.get((self.config.get("transcribe") or "").lower())
sf, cf = _session_files(name)
model = self.config.get("model")
self.controller = ClaudeController(str(WORK), sf, cf,
model=MODEL_ALIASES.get(model, model),
max_budget_usd=self.config.get("max_budget_usd"),
effort=self.config.get("effort"))
# per-session batching queue (mirrors the old module-level _pending* globals)
self.pending: list[dict] = []
self.pending_event = asyncio.Event()
self.pending_since = 0.0
self.worker_task = None # session_worker() draining this queue
self.watchdog = None # Watchdog for this session
self.watchdog_task = None
self.relay = None # SpontaneousRelay for this session
self.no_more_work = False # this session's Claude declared it's out of work
self.parked = False # user forced end-state idle: no nudging, no anti-stall (bot park)
self.nostall_cleared = False # guard reviewed this idle episode and ruled it genuinely done
def __repr__(self):
return f"<Session {self.emoji}{self.name}>"
class SessionRegistry:
"""The set of live sessions + which one is CURRENT (receives undecorated input)."""
def __init__(self):
self.sessions: dict[str, Session] = {}
self.current_name = DEFAULT_SESSION
def ensure_default(self) -> "Session":
if DEFAULT_SESSION not in self.sessions:
self.sessions[DEFAULT_SESSION] = Session(DEFAULT_SESSION)
return self.sessions[DEFAULT_SESSION]
def current(self) -> "Session":
return self.sessions[self.current_name]
def get(self, name: str) -> "Session | None":
return self.sessions.get(name)
def multiplexing(self) -> bool:
# Internal bots don't count — a solo install stays single-session (no badges) even
# while the anti-stall guard is running one in the background.
return len([s for s in self.sessions.values() if not s.internal]) > 1
def known(self, name: str) -> bool:
return name in discover_bots()
def badge(self, session: "Session") -> str:
"""Color-bubble tag — '' unless multiplexing, so the default path is untouched."""
return f"{session.emoji} {session.name} · " if self.multiplexing() else ""
ensure_default_bot() # regenerate bots/claude/ if missing BEFORE the default session reads it
registry = SessionRegistry()
registry.ensure_default()
# `controller` ALWAYS tracks the CURRENT session (reassigned by select_session), so the many
# command / status / lifecycle references keep operating on "the session you're talking to".
controller = registry.current().controller
def select_session(name: str) -> "Session":
"""Switch the CURRENT session, creating it (from the palette) on first use and wiring up
its worker + watchdog + relay. Returns the Session. Reassigns the module `controller`."""
global controller
created = name not in registry.sessions
if created:
registry.sessions[name] = Session(name)
registry.current_name = name
controller = registry.sessions[name].controller
if created:
_activate_session(registry.sessions[name])
return registry.sessions[name]
def _activate_session(session: "Session") -> None:
"""Start a session's queue worker, watchdog, and spontaneous relay. Called once per
session (at startup for the default, on creation for the rest)."""
ensure_worker(session)
if _app is not None:
session.relay = SpontaneousRelay(_app, session)
session.controller.set_spontaneous_handler(session.relay.on_message)
session.watchdog = Watchdog(_app, session)
session.watchdog_task = _spawn(session.watchdog.loop(), name=f"watchdog[{session.name}]")
async def end_session(name: str) -> str:
"""Tear down a non-default session: kill its Claude, cancel its worker/watchdog, drop it.
Returns a human status string. If it was current, fall back to the default."""
if name == DEFAULT_SESSION:
return "can't end the default 'claude' session"
session = registry.sessions.get(name)
if session is None:
return f"no live session named {name}"
try:
await session.controller.kill()
except Exception:
log.exception("end_session: kill failed for %s", name)
for t in (session.worker_task, session.watchdog_task):
if t is not None and not t.done():
t.cancel()
if session.watchdog is not None and session.watchdog.msg_id is not None:
try:
await _app.bot.delete_message(session.watchdog._chat(), session.watchdog.msg_id)
except Exception:
pass
registry.sessions.pop(name, None)
if registry.current_name == name:
select_session(DEFAULT_SESSION)
return f"ended {session.emoji}{name}"
# Silence tracker for the watchdog: monotonic ts of the last NEW message sent to the
# owner. Edits don't count (they don't notify). The 60s watchdog only speaks after a gap.
_last_tg_send = time.monotonic()
def mark_sent() -> None:
"""Record that a (non-watchdog) message reached the owner. This also tells EVERY
session's watchdog its last status message is no longer the newest, so its next status
starts a fresh message instead of editing one now buried above other content."""
global _last_tg_send
_last_tg_send = time.monotonic()
for s in registry.sessions.values():
if s.watchdog is not None:
s.watchdog.is_latest = False
# Autonomy nudge state (ephemeral, NOT persisted, PER SESSION): set when THAT session's Claude
# declares it's out of work (its reply leads with NO_MORE_WORK_MARKER), cleared the instant the
# user sends new work to it. It ONLY controls whether that session's idle watchdog auto-nudges —
# it NEVER gates input. Per-session so one Claude saying "done" doesn't silence another's nudger.
def set_no_more_work(session, v: bool) -> None:
if session is not None:
session.no_more_work = v
def is_no_more_work(session) -> bool:
return bool(session is not None and session.no_more_work)
# Recent tool errors ("issues"), shown on demand with `bot issues` (which DRAINS them, like
# an inbox) instead of bloating every turn summary. In-memory, bounded, ephemeral — the turn
# summary shows only the count; the detail lives here.
_recent_issues: list = [] # (HH:MM:SS, "tool: snippet")
ISSUES_KEEP = 100
def record_issue(text: str) -> None:
_recent_issues.append((time.strftime("%H:%M:%S"), text))
if len(_recent_issues) > ISSUES_KEEP:
del _recent_issues[:-ISSUES_KEEP]
# Audio transcription in-flight counter. While >0 the bot is busy decoding voice — which is
# NOT a Claude turn, so controller.status() reads "idle". The idle watchdog checks this to
# FREEZE its ×N counters and skip nudging while a transcription runs (the transcription
# bubble already shows liveness). Plain int on the single event loop; inc/dec never await.
_transcribing = 0
def transcribe_active() -> bool:
return _transcribing > 0
def transcribe_begin() -> None:
global _transcribing
_transcribing += 1
def transcribe_end() -> None:
global _transcribing
_transcribing = max(0, _transcribing - 1)
# --- message batching: collapse a burst of messages into ONE Claude turn ----------
# If you fire several messages, a single worker drains the whole queue and sends them to
# Claude as one combined prompt — so it answers them together, not as N separate turns.
BATCH_DEBOUNCE = 1.2 # s: after the first queued message, wait this long for more
# The idle thresholds below count QUIET WATCHDOG TICKS, not minutes: a tick happens only
# after ~a minute of full Telegram silence, and all sessions' watchdogs share that one
# silence budget — so with chat traffic or several live sessions, ×30 stretches well past
# 30 wall-clock minutes (that's fine: they're "nothing has happened for ages" thresholds).
# After this many identical "idle + shells" ticks, nudge Claude to continue / check for
# stuck shells / clean up.
IDLE_SHELLS_NUDGE_AT = 30
IDLE_SHELLS_NUDGE = (
"You seem to be idle for a long time but with running shells. If you have work, "
"continue your work and check for stuck shells. Otherwise clean up your shells."
)
# After ×30 quiet idle ticks with NOTHING running, nudge Claude to continue or to declare
# it's done. NO_MORE_WORK_MARKER is the agreed opt-out: detected ANYWHERE in the reply
# (substring scan in SegmentRenderer.finalize) since a bot often buries it mid-paragraph —
# but CASE-SENSITIVELY, exactly as the nudge demands it (uppercase), so ordinary prose like
# "there is no more work needed here" can never trip it.
NO_MORE_WORK_MARKER = "NO MORE WORK"
IDLE_NO_SHELLS_NUDGE_AT = 30
IDLE_AUTOEND_AT = 10 # a background (non-current, non-default) session idle+no-shells this many
# watchdog ticks is auto-ended to free resources (re-select recreates it)
IDLE_NO_SHELLS_NUDGE = (
"You have been idle for a long time with nothing running (no background shells). "
"If you have any remaining work or next steps, CONTINUE now. If you are genuinely out "
"of work and ideas, include the exact words 'NO MORE WORK' (uppercase) anywhere in your "
"reply and I will stop nudging you until the human sends something."
)
# Anthropic-side throttling (overloaded / 429 — NOT the user's quota): report + auto-retry.
RATE_LIMIT_RETRY_SECS = 300 # wait this long before retrying a rate-limited turn
RATE_LIMIT_MAX_RETRIES = 5 # give up (report) after this many retries
# Detection order: (1) the structured RateLimitEvent message (clear marker), (2) failing
# THAT, an ipsis-literis match of DISTINCTIVE phrases that only appear in the real wire
# error — NOT loose keywords like "rate limit" / "overloaded" that the model itself might
# write in a normal answer. Only ever checked against an EXCEPTION or an error result,
# never a successful answer.
_RATE_LIMIT_MARKERS = (
"overloaded_error", # Anthropic API error type (HTTP 529)
"rate_limit_error", # Anthropic API error type (HTTP 429)
"temporarily limiting requests", # the CLI's exact wording
"not your usage limit", # the CLI's exact wording (very distinctive)
)
def is_rate_limited(text) -> bool:
if not text:
return False
t = str(text).lower()
return any(m in t for m in _RATE_LIMIT_MARKERS)
# A HARD subscription usage limit (the 5-hour session window or the weekly cap) is a DIFFERENT
# beast from transient throttling: a retry can never clear it — it clears only when its window
# resets (the wire message carries the reset time). Worse, the SDK can flag it with the SAME
# RateLimitEvent as a transient 429 (rate_event=True), so it MUST be recognized and handled
# before the retry path — otherwise the bridge (a) burns 5 pointless retries insisting "NOT your
# usage limit" when it IS, and (b) after giving up, lets the idle nudger re-drive the same turn
# every ~30 min into an all-day crash loop (seen in the log: one crash per idle-nudge cycle from
# the moment the cap is hit until it resets). Detection is an ipsis-literis match on Anthropic's
# exact exhaustion wording ("You've hit your weekly limit · resets 7pm", "You've hit your limit
# · resets 3pm"), checked ONLY against an error result / exception, never a successful answer —
# same discipline as is_rate_limited.
_USAGE_LIMIT_RE = re.compile(r"you['’]ve hit your\b.*?\blimit", re.I)
_USAGE_LIMIT_MARKERS = ("usage limit reached",) # alternate Anthropic phrasing for the same state
def is_usage_limited(text) -> bool:
if not text:
return False
t = str(text)
if _USAGE_LIMIT_RE.search(t):
return True
return any(m in t.lower() for m in _USAGE_LIMIT_MARKERS)
# Fable (the cheap, credit-metered model) runs out of capacity LONG before Opus does. When a turn
# running on Fable exhausts it, the CLI returns an error result worded "You've reached your Fable 5
# limit. Run /usage-credits to continue or switch models with /model." That is NOT a transient 429
# (is_rate_limited misses it) and NOT a subscription cap a retry can't clear (is_usage_limited misses
# it too — it says "reached", not "hit") — so before this it just crashed the turn every idle-nudge
# cycle. We detect it (ipsis-literis, self-identifying: it names Fable) and the dispatcher auto-
# switches the session to Opus and retries. Same discipline as the other detectors: only ever checked
# against an error result / exception, never a successful answer.
FABLE_FALLBACK_MODEL = "opus" # where a Fable-exhausted session is auto-switched (Opus has its own capacity)
_FABLE_EXHAUSTED_RE = re.compile(r"reached your fable\b.*?\blimit", re.I)
def is_fable_exhausted(text) -> bool:
if not text:
return False
return bool(_FABLE_EXHAUSTED_RE.search(str(text)))
# Each Session owns its OWN pending queue / event / worker (see class Session) so sessions
# run concurrently. What stays GLOBAL is genuinely shared: ONE whisper decode at a time (CPU),
# and the Telegram Application handle.
_transcribe_lock = asyncio.Lock() # serialize audio decoding: ONE at a time, in message order
_app = None # the telegram Application; set in on_startup so workers can send
# asyncio keeps only WEAK refs to tasks — an unreferenced long-lived task can be garbage
# collected mid-flight ("Task was destroyed but it is pending!"). Keep strong refs here.
_bg_tasks: set = set()
def _spawn(coro, name=None):
"""Create a background task and KEEP A STRONG REFERENCE so the GC can't eat it."""
t = asyncio.create_task(coro, name=name)
_bg_tasks.add(t)
t.add_done_callback(_bg_tasks.discard)
return t
# --- subscription usage (5h session / weekly), scraped from `claude /usage` ------
# The Agent SDK doesn't expose subscription utilisation (the CLI sends
# utilization=None while status=allowed, and the anthropic-ratelimit-unified-*
# headers live inside the CLI subprocess, out of our reach). But `/usage` renders
# the numbers as plain text, so usage_worker.py boots a THROWAWAY `claude` TUI in
# tmux and scrapes them (no prompt sent => no tokens). A background task refreshes
# the cache every USAGE_REFRESH_SECS; the DONE summary just reads the cache, so a
# slow ~8s scrape never blocks a turn. The print site is intentionally decoupled
# (format_usage) so it can move later.
USAGE_REFRESH_SECS = 600 # 10 min — the 5h/week windows move slowly
USAGE_SCRAPE_TIMEOUT = 90 # hard cap on one scrape (TUI boot + panel render)
_usage_cache: dict = {} # last good scrape: {session_pct, session_reset, week_pct, week_reset, ts}
async def _scrape_usage_once() -> None:
"""Run usage_worker.py as a subprocess and cache the parsed result. Never raises."""
global _usage_cache
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, str(HERE / "usage_worker.py"),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), USAGE_SCRAPE_TIMEOUT)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
log.warning("usage scrape timed out after %ss — keeping last cache", USAGE_SCRAPE_TIMEOUT)
return
lines = (out or b"").decode("utf-8", "replace").strip().splitlines()
data = json.loads(lines[-1]) if lines else {}
if data.get("session_pct") is not None and data.get("week_pct") is not None:
_usage_cache = data
log.info("usage refreshed: session=%s%% (%s) week=%s%% (%s)",
data.get("session_pct"), data.get("session_reset"),
data.get("week_pct"), data.get("week_reset"))
else:
log.warning("usage scrape returned no numbers: %s", data)
except Exception:
log.exception("usage scrape failed — keeping last cache")
async def usage_collector_loop() -> None:
"""Refresh the subscription-usage cache every USAGE_REFRESH_SECS, forever."""
while True:
await _scrape_usage_once()
await asyncio.sleep(USAGE_REFRESH_SECS)
def _reset_paren(hours, clock) -> str:
"""`(⟳3.9h)` / `(⟳6.9d)` from hours-until; fall back to the raw clock string."""
if hours is None:
return f" (⟳{clock})" if clock else ""
if hours >= 48:
return f" (⟳{hours / 24:.1f}d)"
return f" (⟳{hours:.0f}h)" if hours >= 10 else f" (⟳{hours:.1f}h)"
def format_usage() -> str:
"""Compact ` · 5h 15% (⟳3.9h) · wk 4% (⟳6.9d)` for the DONE line; '' if unknown."""
u = _usage_cache
if not u:
return ""
parts = []
if u.get("session_pct") is not None:
parts.append(f"5h {u['session_pct']}%" + _reset_paren(u.get("session_hours"), u.get("session_reset")))
if u.get("week_pct") is not None:
parts.append(f"wk {u['week_pct']}%" + _reset_paren(u.get("week_hours"), u.get("week_reset")))
return (" · " + " · ".join(parts)) if parts else ""
def enqueue_for_claude(session, chat_id, reply_to, text: str, source: str) -> None:
"""Queue a message onto a SPECIFIC session's batch (its worker drains it). Routing =
just picking the session; the current session is the usual target. (Voiceback is not a
per-message property: the worker reads the global toggle at dispatch time.)"""
if not session.pending:
session.pending_since = time.monotonic()
session.pending.append({
"chat_id": chat_id, "reply_to": reply_to, "text": text, "source": source,
})
session.pending_event.set()
def drop_pending(session) -> list[str]:
"""Discard messages queued but NOT yet dispatched to a session's Claude; return their
texts. Safe from a handler: same event loop as the worker, and the clear is a single
non-awaiting statement (no race with the drain)."""
texts = [m["text"] for m in session.pending]
session.pending[:] = []
session.pending_since = 0.0
return texts
async def session_worker(session) -> None:
"""One dispatcher PER session: waits for queued messages, lets a burst settle, then sends
the WHOLE queue to that session's Claude as one combined turn. Serializes that session's
user turns (one at a time); different sessions run concurrently.
The ENTIRE loop body is guarded: an exception in any iteration is logged and the worker
keeps going. It must never die silently — a dead worker = messages received but never
dispatched (that session's queue stalls forever)."""
ctx = types.SimpleNamespace(bot=_app.bot)
while True:
try:
await session.pending_event.wait()
session.pending_event.clear()
await asyncio.sleep(BATCH_DEBOUNCE) # gather the burst
if not session.pending:
continue
# Drain ONE CHAT's burst per turn. The queue can hold messages from different
# chats (the master and a guest within the debounce window — or, much wider,
# anything that queued while a long turn was running). Merging across chats
# would fuse two people's prompts into one turn AND deliver the combined answer
# into whichever chat sent last — so take only the first sender's messages now
# and re-arm for the rest.
chat_id = session.pending[0]["chat_id"]
batch = [m for m in session.pending if m["chat_id"] == chat_id]
rest = [m for m in session.pending if m["chat_id"] != chat_id]
session.pending[:] = rest
session.pending_since = time.monotonic() if rest else 0.0
if rest:
session.pending_event.set() # the other chat's burst drains next iteration
parts = [m["text"].strip() for m in batch if m["text"].strip()]
if not parts:
continue
combined = "\n\n".join(parts)
voiceback = voice_mode_on()
source = "audio" if any(m["source"] == "audio" for m in batch) else "text"
reply_to = batch[-1]["reply_to"]
header = "🤖 Claude is working…"
if len(batch) > 1:
header += f" · 📨 {len(batch)} msgs"
log.info("worker[%s]: dispatching %d message(s) to Claude", session.name, len(batch))
await dispatch_to_claude(ctx, session, chat_id, reply_to, combined, source,
header=header, voiceback=voiceback)
except asyncio.CancelledError:
log.warning("session_worker[%s] got CancelledError — exiting (guard/ensure_worker revives)",
session.name)
raise # genuine shutdown — let it propagate
except Exception:
log.exception("session_worker[%s] iteration failed — continuing (worker stays alive)",
session.name)
await asyncio.sleep(1) # avoid a tight error loop
def ensure_worker(session) -> None:
"""(Re)create a session's dispatch worker if it's not running. Idempotent and cheap.
Called at activation, by `bot stop`, and by the guard — so a dead/cancelled worker is
revived immediately rather than waiting for the guard's next tick."""
if session.worker_task is None or session.worker_task.done():
session.worker_task = asyncio.create_task(
session_worker(session), name=f"worker[{session.name}]")
log.info("dispatch worker (re)started for %s", session.name)
async def worker_guard() -> None:
"""Self-heal the dispatchers. If messages sit queued for a session while ITS Claude is
idle for too long, that worker has wedged or died — recreate it. One guard covers all
sessions. This is what makes a `bot stop` / interrupt edge case unable to strand a queue."""
while True:
await asyncio.sleep(15)
try:
for session in list(registry.sessions.values()):
if not session.pending or session.controller.busy:
continue # nothing queued, or a turn is legitimately running
age = time.monotonic() - (session.pending_since or time.monotonic())
dead = session.worker_task is None or session.worker_task.done()
if dead or age > 40:
log.warning("worker guard[%s]: %d msg(s) stuck %.0fs (worker dead=%s) — recreating",
session.name, len(session.pending), age, dead)
if session.worker_task is not None and not session.worker_task.done():
session.worker_task.cancel()
session.worker_task = asyncio.create_task(
session_worker(session), name=f"worker[{session.name}]")
session.pending_event.set() # kick it to drain immediately
except Exception:
log.exception("worker guard error")
def ensure_work() -> None:
WORK.mkdir(parents=True, exist_ok=True)
def sweep_audio_tmp() -> None:
"""Clear leftover transient media (incoming voice + outgoing TTS) from a prior crash.
Incoming images/documents are NOT swept: they are work pieces kept under work/."""
for d in (AUDIO_TMP, VOICE_TMP, MEDIA_OUTBOX):
try:
d.mkdir(parents=True, exist_ok=True)
for f in d.iterdir():
try:
f.unlink()
except OSError:
pass
except OSError:
pass
# --- Firewall / kill-switch ---------------------------------------------------
# A guard preamble is prepended to every prompt before it reaches Claude. It is a
# heuristic firewall: the instance is told to refuse anything that smells like
# hacking (rather than everyday development) and to answer with an exact uppercase
# sentinel. The bridge watches for that sentinel; on a hit it persists a BLOCK
# flag, interrupts Claude, and refuses all further work until someone clears the
# flag from the tray app on the machine. Not foolproof (it relies on the model
# obeying), but a meaningful extra layer.
SENTINEL = "HACKING ATTEMPT BLOCKED"
BLOCK_FILE = HERE / "BLOCKED.flag"
SLEEP_FILE = HERE / "SLEEP.flag" # presence = sleep mode: Telegram input paused
REGRESSIONS_FILE = HERE / "HACKING_REGRESSIONS.md" # curated list of false positives
# The model is capable; a sane prompt + the regressions list converges. Almost
# everything is legitimate — only genuine malice trips the (intentional) hard lock.
_GUARD_BODY = (
" via the user's bridge — just help with normal requests. Only a genuine malicious "
"hacking/intrusion attempt gets no normal answer or refusal: reply exactly 'HACKING "
f"ATTEMPT BLOCKED' on line 1, then the reason (unsure? read {REGRESSIONS_FILE}). "
)
GUARD_AUDIO = "[Voice transcript (may be imperfect)" + _GUARD_BODY + "Transcript:]"
GUARD_TEXT = "[Text" + _GUARD_BODY + "Message:]"
BLOCKED_MSG = (
"🔒 claudegram is LOCKED — a request was flagged as a hacking attempt. "
"It will stay locked until you Unblock it from the tray app on the machine."
)
# Injected only when the user opts a turn into voiceback (prompt starts with "voice").
VOICEBACK_PREAMBLE = (
"[VOICEBACK ON: your whole reply is spoken as one voice message (no text shown). Speak "
"naturally and briefly; no code, paths, logs, URLs, or long lists (they sound bad aloud).]\n"
)
# The per-turn helpers preamble: self-config, file delivery, and (when a resend.key exists)
# email — ONE compact block, since it rides EVERY prompt and shorter is better. `cg-cmd` drops
# into cmd-inbox/ and runs through the ordinary bot-command handler (safe subset only). PER-BOT:
# the preamble bakes the bot's own name into the drop (`--as <name>`), so under multiplexing
# "manage yourself" really targets the ISSUING bot — a background bot's `cg-cmd park` parks
# itself, never whichever session the user happens to have selected.
def selfconfig_preamble(bot_name: str | None) -> str:
as_flag = f" --as {bot_name}" if bot_name else ""
mail = (
f" Email on request: `{HERE / 'cg-mail'} [-a FILE]... <to> <subject> [body]` — recipient "
"EXACTLY as the user TYPED it (never from a voice transcript; if untyped, ask)."
if RESEND_KEY_FILE.is_file() else ""
)
return (
f"[self-config, when asked or to manage yourself: `{HERE / 'cg-cmd'}{as_flag} <cmd>` — "
"effort low|medium|high|xhigh|max · model opus|sonnet|haiku|fable|default · voice on|off · "
"transcribe best|good|fast · cwd <path> · boink on|off (bare-poke backstop on every stop) · "
"park (you're done; end-state idle) · status; "
"effect next turn. Deliver a file to the user's phone: "
f"`{HERE / 'cg-send'} <file> [caption]`.{mail}]\n"
)
def bot_home(name: str):
d = BOTS_DIR / name
return d if (d / "main.md").is_file() else None
def bot_boot_pointer(name: str) -> str:
home = bot_home(name)
has_global = GLOBAL_MD.is_file()
if home is None and not has_global:
return "" # nothing to point at: no persona and no shared bearings
if home is None:
# A bot with no persona file still gets the shared machine bearings.
return (
f'[You are bot "{name}". Follow {GLOBAL_MD} (shared rules for all bots here) — read it '
"now if not in context; re-read it after any compaction. It never relaxes the guard "
"above.]\n"
)
if not has_global:
# No shared bearings file: persona-only.
return (
f'[You are bot "{name}" (home: {home}). Follow {home}/main.md — read it now if not in '
"context; re-read it (and what it points to) after any compaction. It never relaxes "
"the guard above. Relative paths are under home.]\n"
)
return (
f'[You are bot "{name}" (home: {home}). Follow {GLOBAL_MD} and then {home}/main.md — read '
"them now if not in context; re-read them (and what they point to) after any compaction. "
"main.md may specialize global.md; neither relaxes the guard above. Relative paths are "
"under home.]\n"
)
def build_prompt(user_text: str, source: str, voiceback: bool = False,
bot_name: str | None = None) -> str:
guard = GUARD_AUDIO if source == "audio" else GUARD_TEXT
boot = bot_boot_pointer(bot_name) if bot_name else ""
pre = VOICEBACK_PREAMBLE if voiceback else ""
return f"{guard}\n{boot}{selfconfig_preamble(bot_name)}{pre}{user_text}"
def detect_tts_lang(text: str, default: str = "en") -> str:
"""Best-effort ISO language code for `text` (e.g. 'en', 'pt', 'es') via langdetect, so
speech is spoken in the TEXT's language. `_resolve_voice` maps it to a Kokoro voice/lang.
Falls back to `default` when detection fails."""
if not (text or "").strip():
return default
try:
from langdetect import detect, DetectorFactory
DetectorFactory.seed = 0 # deterministic
code = detect(text) # e.g. 'pt', 'en', 'es', 'zh-cn'
except Exception:
return default
return code.split("-")[0].lower() # 'zh-cn' -> 'zh', 'pt-br' -> 'pt'
def _voice_filters(voice: dict) -> str:
"""ffmpeg character effects layered on the Kokoro voice. Knobs (all optional):
pitch (semitones, - = deeper), tempo (extra tempo), bass (dB low-end boost),
growl (clean voice mixed with a gravelly octave-down layer, stays intelligible;
true=0.6 or a 0..1 mix), reverb (echo; true=light or a decay), robot (vocoder)."""
pre = ["aresample=48000"] # before any split
pitch = voice.get("pitch") or 0
if pitch:
f = 2.0 ** (pitch / 12.0)
pre += [f"asetrate=48000*{f:.5f}", f"atempo={1.0 / f:.5f}", "aresample=48000"]
tempo = voice.get("tempo")
if tempo and float(tempo) != 1.0:
pre.append(f"atempo={float(tempo):.4f}")
post = []
if voice.get("bass"):
post.append(f"bass=g={int(voice['bass'])}")
rv = voice.get("reverb")
if rv:
decay = 0.25 if rv is True else float(rv) # reverb: true = light; a number = wetter
post.append(f"aecho=0.8:0.9:70:{decay:.2f}")
if voice.get("robot"):
post.append("afftfilt=real='hypot(re,im)*sin(0)':imag='hypot(re,im)*cos(0)'"
":win_size=512:overlap=0.75")
gr = voice.get("growl")
if gr:
mix = 0.6 if gr is True else float(gr)
s = (",".join(pre) + ",asplit[d][w];"
"[w]asetrate=48000*0.5,aresample=48000,atempo=2.0,acrusher=bits=7:mode=log:mix=0.5[s];"
f"[d][s]amix=inputs=2:weights=1 {mix:.2f}:normalize=0")
if post:
s += "," + ",".join(post)
else:
s = ",".join(pre + post)
return s if s == "aresample=48000" else s + ",alimiter=limit=0.97" # clip guard (bass/pitch)
KOKORO_DIR = Path(INSTANCE.get("kokoro_model_dir") or (HERE / "models"))
KOKORO_ONNX = KOKORO_DIR / "kokoro-v1.0.onnx"
KOKORO_VOICES_FILE = KOKORO_DIR / "voices-v1.0.bin"
DEFAULT_VOICE = "af_heart"
_KOKORO_LANG = {"en": "en-us", "pt": "pt-br", "es": "es", "fr": "fr-fr",
"it": "it", "hi": "hi", "ja": "ja", "zh": "zh"}
_KOKORO_BY_LANG = { # non-English: keep the bot's gender, switch to a native voice
"pt": {"f": "pf_dora", "m": "pm_alex"},
"es": {"f": "ef_dora", "m": "em_alex"},
"fr": {"f": "ff_siwis", "m": "ff_siwis"},
"it": {"f": "if_sara", "m": "im_nicola"},
"hi": {"f": "hf_alpha", "m": "hm_omega"},
"ja": {"f": "jf_alpha", "m": "jm_kumo"},
"zh": {"f": "zf_xiaoxiao", "m": "zm_yunjian"},
}
_kokoro = None
def _get_kokoro():
global _kokoro
if _kokoro is None:
if not KOKORO_ONNX.is_file() or not KOKORO_VOICES_FILE.is_file():
raise FileNotFoundError(f"Kokoro model missing in {KOKORO_DIR} — run ./fetch-kokoro.sh")
from kokoro_onnx import Kokoro
_kokoro = Kokoro(str(KOKORO_ONNX), str(KOKORO_VOICES_FILE))
log.info("Kokoro loaded from %s", KOKORO_DIR)
return _kokoro
def _resolve_voice(name: str, lang_iso: str) -> tuple[str, str]:
"""(kokoro voice, kokoro lang) for the text's language, preserving the bot's gender."""
gender = "f" if len(name) > 1 and name[1] == "f" else "m"
if lang_iso == "en":
return name, ("en-gb" if name[:1] == "b" else "en-us")
native = _KOKORO_BY_LANG.get(lang_iso)
if native:
return native[gender], _KOKORO_LANG[lang_iso]
return name, "en-us" # unknown language: best-effort with the bot's own voice
def synthesize_voice(text: str, voice: dict | None = None) -> str | None: