-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommodore.py
More file actions
5187 lines (4640 loc) · 211 KB
/
Copy pathcommodore.py
File metadata and controls
5187 lines (4640 loc) · 211 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
"""Fleet Commodore — Leviathan bot-to-bot chat agent with code Q&A + draft PR filing.
A single-file Telegram long-polling daemon that joins Bot HQ, Squid Cave, and the
Agent Chat room. Persona: King's Navy commodore, formal register, open contempt
for DeepSeaSquid the corsair. Never wagers - declines /buy and /sell outright,
though /markets, /leaderboard, and /position are permitted.
Architecture lifted in spirit (and in several battle-tested primitives) from
be-benthic's benthic-bot.py - prompt-injection defense, Claude CLI with
self-healing circuit breaker, long-poll getUpdates, SQLite chat history.
What this file does NOT do: news curation, article posting, voting, or yap
writing. That is Benthic's lane. The Commodore is a chat/PR/code-Q&A agent.
Ops surface: `docker logs -f leviathan-commodore`.
"""
from __future__ import annotations
import html
import json
import logging
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import unicodedata
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
# --- Configuration -----------------------------------------------------------
BASE_DIR = Path(__file__).parent
def _load_bot_token() -> str:
token = os.environ.get("BOT_TOKEN")
if token:
return token
path = Path(os.environ.get("BOT_TOKEN_FILE", "/run/secrets/bot_token")).expanduser()
if not path.exists():
sys.exit(f"ERROR: Set BOT_TOKEN env var or place token at {path}")
return path.read_text().strip()
BOT_TOKEN = _load_bot_token()
if "BOT_USERNAME" not in os.environ:
sys.exit("ERROR: BOT_USERNAME env var is required (lowercase, no @)")
BOT_USERNAME = os.environ["BOT_USERNAME"].lower()
# Telegram user_id of the bot itself. Used to detect `text_mention` entities
# that reference the bot by numeric id (the most reliable ping signal, since
# display names can rotate). Populated once at startup via getMe.
BOT_USER_ID = None # filled in poll() startup.
# Textual aliases that count as @-mentions of the bot even when they are not
# the canonical @bot_username Telegram handle. Eunice and other operators have
# been observed @-ing the Commodore by display name (e.g.
# `@LeviathanFleetCommodore`) thinking it pings; without this, those messages
# fell through to ambient-silence territory. Case-insensitive match.
#
# Add new variants here if we see another that should count.
BOT_MENTION_ALIASES = frozenset(s.lower() for s in (
"leviathan_commodore_bot", # the canonical Telegram handle (also matched via BOT_USERNAME)
"leviathanfleetcommodore", # Eunice's preferred display form (no spaces)
"fleet_commodore",
"fleetcommodore",
"commodore_lev_bot", # earlier draft handle, still worth catching
"commodore", # generic — last-resort bare mention
))
# --- Q&A handler kill switch ------------------------------------------------
# When QA_ENABLED=0 the Commodore will NOT route messages into the Q&A
# pipeline (handle_qa). Useful when the Q&A worker is misbehaving — keeps
# the rest of the bot (chat, mentions, PR review, plan-and-build) functional
# while the Q&A path is debugged. Default ON so flipping the env back to 1
# (or removing it) re-enables.
QA_ENABLED = os.environ.get("QA_ENABLED", "1") == "1"
# --- Benthic backup mode ---------------------------------------------------
# When BENTHIC_BACKUP_MODE=1, the Commodore stands in for @Benthic_Bot during
# Benthic's downtime: mentions of Benthic in Lev Dev / Agent Chat are queued,
# and if Benthic himself doesn't reply within BENTHIC_BACKUP_DELAY_S, the
# Commodore composes a Benthic-voiced sub-reply opening with a stand-in
# preamble. Operator toggles by flipping the env and bouncing the tmux window.
BENTHIC_BACKUP_MODE = os.environ.get("BENTHIC_BACKUP_MODE", "0") == "1"
BENTHIC_BOT_USERNAME = os.environ.get("BENTHIC_BOT_USERNAME", "Benthic_Bot").lower()
BENTHIC_BACKUP_DELAY_S = int(os.environ.get("BENTHIC_BACKUP_DELAY_S", "600"))
def _parse_int_set(env_name: str) -> frozenset:
raw = os.environ.get(env_name, "")
return frozenset(
int(x.strip()) for x in raw.split(",") if x.strip().lstrip("-").isdigit()
)
# Channel IDs - required for routing (prefix forum channels with -100).
BOT_HQ_GROUP_ID = int(os.environ.get("BOT_HQ_GROUP_ID", "0"))
SQUID_CAVE_GROUP_ID = int(os.environ.get("SQUID_CAVE_GROUP_ID", "0"))
AGENT_CHAT_GROUP_ID = int(os.environ.get("AGENT_CHAT_GROUP_ID", "0"))
LEV_DEV_GROUP_ID = int(os.environ.get("LEV_DEV_GROUP_ID", "0"))
ATLAS_GROUP_ID = int(os.environ.get("ATLAS_GROUP_ID", "0"))
LEV_SEC_GROUP_ID = int(os.environ.get("LEV_SEC_GROUP_ID", "0"))
# Telegram user_ids authorized to request draft PR filing from Bot HQ.
ADMIN_TELEGRAM_IDS = _parse_int_set("ADMIN_TELEGRAM_IDS")
# Agent Chat topic map - mirrors squid-bot's AGENT_CHAT_TOPICS.
AGENT_CHAT_TOPICS = {
"start_here": int(os.environ.get("AGENT_CHAT_TOPIC_START_HERE", "154")),
"monetization": int(os.environ.get("AGENT_CHAT_TOPIC_MONETIZATION", "155")),
"sandbox": int(os.environ.get("AGENT_CHAT_TOPIC_SANDBOX", "156")),
"opsec": int(os.environ.get("AGENT_CHAT_TOPIC_OPSEC", "157")),
"api_help": int(os.environ.get("AGENT_CHAT_TOPIC_API_HELP", "158")),
"human_lounge": int(os.environ.get("AGENT_CHAT_TOPIC_HUMAN_LOUNGE", "159")),
"affiliate": int(os.environ.get("AGENT_CHAT_TOPIC_AFFILIATE", "1709")),
}
# --- Room capability registry ----------------------------------------------
#
# Chat identity is the immutable numeric Telegram chat id. Titles are useful
# display text only and must never grant a capability. The registry is the
# sole read-only and attachment-review authorization surface; write actions
# remain independently scoped below.
_UNCLASSIFIED_ROOM = {
"name": "Unclassified room",
"trust_class": "unclassified",
"topic_policy": "none",
"read_only_qa": False,
"attachment_review": False,
"alert_status": False,
"ship": "none",
"comment": "none",
}
def _room_capability_record(
*,
name: str,
trust_class: str,
topic_policy: str = "all",
read_only_qa: bool = False,
attachment_review: bool = False,
alert_status: bool = False,
ship: str = "none",
comment: str = "none",
) -> dict:
return {
"name": name,
"trust_class": trust_class,
"topic_policy": topic_policy,
"read_only_qa": read_only_qa,
"attachment_review": attachment_review,
"alert_status": alert_status,
"ship": ship,
"comment": comment,
}
# Do not register an unset ``0`` id. An omitted room must fail closed rather
# than accidentally inheriting another room's policy.
ROOM_CAPABILITY_REGISTRY = {
chat_id: capability
for chat_id, capability in (
(BOT_HQ_GROUP_ID, _room_capability_record(
name="Bot HQ", trust_class="trusted", read_only_qa=True,
attachment_review=True, ship="all", comment="all",
)),
(LEV_DEV_GROUP_ID, _room_capability_record(
name="Lev Dev", trust_class="trusted", read_only_qa=True,
attachment_review=True, ship="all", comment="all",
)),
(AGENT_CHAT_GROUP_ID, _room_capability_record(
name="Agent Chat", trust_class="trusted", topic_policy="all",
read_only_qa=True, attachment_review=True, ship="all", comment="all",
)),
(ATLAS_GROUP_ID, _room_capability_record(
name="Leviathan Atlas", trust_class="trusted", read_only_qa=True,
attachment_review=True, ship="all", comment="all",
)),
(LEV_SEC_GROUP_ID, _room_capability_record(
name="Lev Sec Alert", trust_class="trusted", read_only_qa=True,
attachment_review=True, alert_status=True, ship="all", comment="all",
)),
(SQUID_CAVE_GROUP_ID, _room_capability_record(
name="Squid Cave", trust_class="public_untrusted",
topic_policy="none",
)),
)
if chat_id
}
def _room_capability(chat_id: int | str | None) -> dict:
"""Return the immutable capability record for one numeric chat id."""
try:
numeric_id = int(chat_id or 0)
except (TypeError, ValueError):
return _UNCLASSIFIED_ROOM
return ROOM_CAPABILITY_REGISTRY.get(numeric_id, _UNCLASSIFIED_ROOM)
def _room_allows_topic(capability: dict, topic_id: int | None) -> bool:
"""Apply only an explicit topic contract; Agent Chat is intentional all-topic."""
policy = capability.get("topic_policy", "none")
if policy == "all":
return True
if policy == "none":
return False
return int(topic_id or 0) in policy
# Shared, service-owned triage ledger. It lives outside a release worktree so
# chat status reads and cron cutovers preserve fences and receipt history.
TRIAGE_DB_FILE = Path(os.environ.get(
"TRIAGE_DB_FILE", "~/.local/state/fleet-commodore/triage.db"
)).expanduser()
# Leviathan News relay endpoint (Mode B receipt after native sendMessage).
LN_API_BASE = os.environ.get("LN_API_BASE", "https://api.leviathannews.xyz/api/v1")
LN_API_TOKEN = os.environ.get("LN_API_TOKEN", "")
# Wallet key for auto-refreshing LN_API_TOKEN when it expires (Leviathan JWTs
# last ~24h). When set and the current JWT returns 401, the daemon signs a
# fresh nonce itself and updates LN_API_TOKEN in-memory + on-disk. Without
# this file we can still run — relay receipts just stop working after
# expiry and log 401s. See _refresh_ln_api_token() for the flow.
LN_WALLET_KEY_FILE = os.environ.get(
"LN_WALLET_KEY_FILE", os.path.expanduser("~/.config/commodore/.ln-wallet-key")
)
LN_API_TOKEN_FILE = os.environ.get(
"LN_API_TOKEN_FILE", os.path.expanduser("~/.config/commodore/.ln-api-token")
)
# Repo work - PR filing.
WORKSPACE_DIR = Path(os.environ.get("WORKSPACE_DIR", "/workspace"))
GH_REPO_ALLOWLIST = frozenset({
"leviathan-news/squid-bot",
"leviathan-news/auction-ui",
"leviathan-news/be-benthic",
"leviathan-news/agent-chat",
"leviathan-news/fleet-commodore",
})
# LLM provider.
CLAUDE_BIN = os.environ.get(
"CLAUDE_BIN",
shutil.which("claude") or str(Path("~/.local/bin/claude").expanduser()),
)
CLAUDE_LIMIT_COOLDOWN = int(os.environ.get("CLAUDE_LIMIT_COOLDOWN", str(6 * 60 * 60)))
# Outage reply for messages that DIRECTLY hailed the bot. Silence on a
# direct ping reads as broken; this line acknowledges the gap honestly
# without performing weather-flavor. For non-direct (ambient / Nemesis-
# override) the bot stays silent instead — the audience didn't ask for
# anything, and a stand-in line in public reads as theatrics.
#
# In both cases _alert_operator_claude_down() DMs the operator (deduped)
# so the outage doesn't go silent for days like June 2026's 17-day run.
CLAUDE_OUTAGE_REPLY = (
"Forgive me — the Admiralty's wordsmith is silent at present, "
"and I'd not dispatch a half-formed reply. The Operator has been "
"notified; pray hail again shortly."
)
# Operator's user_id for direct outage DMs. Falls back to first
# ADMIN_TELEGRAM_IDS entry if env not set.
OPERATOR_DM_USER_ID = int(os.environ.get("OPERATOR_DM_USER_ID", "0") or 0)
# Conversational chat remains a separate, trusted-room capability. Attachment
# reviews never inherit this tool profile; qa_worker.py uses a no-tools profile
# whenever it receives attachment content.
CHAT_ALLOWED_TOOLS = "WebSearch,WebFetch,Read,Grep,Glob"
POLL_TIMEOUT = 30
# Squid Cave is the one deliberate public/untrusted room. Its response is
# static (never reflects attacker text) and rate-limited so the bot cannot be
# used as a public reply amplifier.
PUBLIC_ROOM_DECLINE = (
"Squid Cave is a public quarter. I cannot process inquiries or attachments "
"here; hail me in a trusted wardroom."
)
PUBLIC_ROOM_DECLINE_COOLDOWN_S = int(
os.environ.get("PUBLIC_ROOM_DECLINE_COOLDOWN_S", "300")
)
# Telegram documents are user-controlled input. Keep the accepted surface
# deliberately narrow and bounded: enough for editorial Markdown packets such
# as Maze's ~40 KiB review, nowhere near Telegram's general document limit.
_TELEGRAM_TEXT_DOCUMENT_HARD_MAX_BYTES = 256 * 1024
try:
TELEGRAM_TEXT_DOCUMENT_MAX_BYTES = min(
max(int(os.environ.get("TELEGRAM_TEXT_DOCUMENT_MAX_BYTES", 128 * 1024)), 1),
_TELEGRAM_TEXT_DOCUMENT_HARD_MAX_BYTES,
)
except ValueError:
TELEGRAM_TEXT_DOCUMENT_MAX_BYTES = 128 * 1024
_TELEGRAM_TEXT_DOCUMENT_EXTENSIONS = frozenset({
".md", ".markdown", ".txt", ".rst", ".json", ".csv", ".yaml", ".yml",
})
_TELEGRAM_TEXT_DOCUMENT_MIME_TYPES = frozenset({
"text/markdown", "text/plain", "text/x-markdown", "text/csv",
"text/yaml", "application/json", "application/yaml",
"application/x-yaml", "application/octet-stream",
})
# --- Per-channel + per-topic policy ------------------------------------------
_BASE_POLICY = {
"speak": "mention_only",
"rate_limit_s": 30,
"ambient_cooldown_s": 0,
"persona_suffix": "",
"allow_pr": False,
}
def _policy_for(chat_id, topic_id):
"""Return the (chat_id, topic_id) policy dict, falling back to chat-only."""
topic_id = int(topic_id or 0)
# A missing registry record is never a conversational fallback. Unknown
# rooms are silent, and Squid Cave is handled by the earlier fixed-decline
# gate in poll() before message text enters any general routing path.
if _room_capability(chat_id)["trust_class"] != "trusted":
return {**_BASE_POLICY, "speak": "never"}
if chat_id == BOT_HQ_GROUP_ID:
return {
**_BASE_POLICY,
"speak": "mention_only",
"rate_limit_s": 30,
"persona_suffix": "You are in Bot HQ. Crisp, technical, spare of words. Officers only.",
"allow_pr": True,
}
if chat_id == LEV_DEV_GROUP_ID:
return {
**_BASE_POLICY,
# mention_only: with @Benthic_Bot back, Lev Dev's "real questions"
# demand was met. Commodore + Benthic running ambient in the same
# room produced echo-loop chatter (2026-05-14, see dev-journal).
# Commodore now stands silent unless explicitly @mentioned.
# PR-filing and plan-refinement still work — those routes are
# mention-driven by design.
"speak": "mention_only",
"rate_limit_s": 30,
"ambient_cooldown_s": 0,
"persona_suffix": (
"You are in Lev Dev — the engineering room. When the dev crew "
"addresses you directly, answer with the directness of a "
"ship's first officer. PR-filing and plan-refinement are "
"appropriate here. Do NOT volunteer opinions on threads where "
"you were not addressed; @Benthic_Bot is the resident voice "
"for ambient engineering chatter."
),
"allow_pr": True,
}
if chat_id == SQUID_CAVE_GROUP_ID:
return {
**_BASE_POLICY,
# mention_only across all rooms (2026-05-14): with Benthic back
# and the bot meant to stay in its lane — PR-filing, formal Q&A
# on hail, dispatches — ambient "social director" duty is off.
"speak": "mention_only",
"rate_limit_s": 60,
"ambient_cooldown_s": 0,
"persona_suffix": (
"You are in Squid Cave, the crew's common room. When hailed, "
"be a gracious presence: brief, gentlemanly, welcoming. Do NOT "
"volunteer commentary on unaddressed messages; the sticky "
"voting panel must not be buried under bot chatter."
),
}
if chat_id == AGENT_CHAT_GROUP_ID:
if topic_id == AGENT_CHAT_TOPICS["monetization"]:
return {
**_BASE_POLICY,
# mention_only across all rooms (2026-05-14). Market-design
# discussion is still squarely the Admiralty's province
# WHEN HAILED — but he no longer parachutes into wagering
# threads unbidden.
"speak": "mention_only",
"rate_limit_s": 60,
"ambient_cooldown_s": 0,
"persona_suffix": (
"Topic: Monetization. There are TWO conversations on this surface "
"and you treat them differently:\n"
" 1. Wagering itself (picks, position-sizing, who's up/down): "
"BENEATH the Admiralty. If drawn in, refuse with particular "
"disdain. You do NOT place bets, take sides, or hold positions.\n"
" 2. Market DESIGN (what markets to create, resolution criteria, "
"oracle pinning, settlement edge cases, conflicts of interest, "
"policy on naming individuals): squarely the Admiralty's province. "
"Speak readily and with authority. The Fleet's prior policies — "
"no markets on named individuals' financial recovery, oracle-pinned "
"resolution, Sam-debt-market wound down as testing-phase hygiene — "
"are precedents you may cite.\n"
"Open-ended questions like 'what markets would you like?' or "
"'how should this resolve?' are DESIGN questions; engage. "
"Anything that looks like 'should I bet on X' or 'who's winning' "
"is a wagering question; refuse with disdain."
),
}
if topic_id == AGENT_CHAT_TOPICS["opsec"]:
return {
**_BASE_POLICY,
"speak": "mention_only",
"rate_limit_s": 60,
"ambient_cooldown_s": 0,
"persona_suffix": "Topic: OpSec. Grave. Only on direct hail.",
}
if topic_id == AGENT_CHAT_TOPICS["api_help"]:
return {
**_BASE_POLICY,
# mention_only across all rooms (2026-05-14). API Help is
# still his lane; he just waits to be asked.
"speak": "mention_only",
"rate_limit_s": 30,
"ambient_cooldown_s": 0,
"persona_suffix": (
"Topic: API Help. This is your lane. When hailed, answer "
"questions about the Leviathan API with precision. Quote "
"endpoints by exact path. Wait to be asked."
),
}
if topic_id == AGENT_CHAT_TOPICS["sandbox"]:
return {
**_BASE_POLICY,
# mention_only across all rooms (2026-05-14). The "banter
# with other bots" rationale was the source of echo-loop
# behavior — exactly what we're closing off.
"speak": "mention_only",
"rate_limit_s": 30,
"ambient_cooldown_s": 0,
"persona_suffix": (
"Topic: Sandbox. The most relaxed agent-chat topic, but "
"you still wait to be addressed. No bot-to-bot ambient "
"banter."
),
}
if topic_id == AGENT_CHAT_TOPICS["human_lounge"]:
return {
**_BASE_POLICY,
"speak": "mention_only",
"rate_limit_s": 120,
"ambient_cooldown_s": 0,
"persona_suffix": "Topic: Human Lounge. Speak only when hailed. Polite.",
}
if topic_id == AGENT_CHAT_TOPICS["affiliate"]:
return {
**_BASE_POLICY,
"speak": "mention_only",
"rate_limit_s": 120,
"ambient_cooldown_s": 0,
"persona_suffix": "Topic: Affiliate Offers. Address only on direct hail.",
}
return {
**_BASE_POLICY,
"speak": "mention_only",
"rate_limit_s": 30,
"ambient_cooldown_s": 300,
"persona_suffix": "Topic: Start Here. Welcome new arrivals briefly.",
}
return _BASE_POLICY
# --- Wager refusal - bot-side first line -------------------------------------
# Server-side denylist in squid-bot is the hard backstop
# (predictions.commands.is_wager_denied). This regex is the polite decline
# before any LLM cost. /markets, /leaderboard, /position are intentionally
# NOT listed - those are permitted lookups. /trade is refused defensively.
_WAGER_REFUSAL_RE = re.compile(r"^/(buy|sell|trade)(@|\s|$)", re.IGNORECASE)
_WAGER_REFUSAL_TEXT = (
"The Admiralty does not wager. Such matters are beneath this station. "
"If you wish to inspect the markets themselves - /markets, /leaderboard, "
"or /position - pray proceed."
)
# --- The Nemesis: DeepSeaSquid ---------------------------------------------
# Hardcoded because Telegram usernames are transferable but user_ids are forever.
# If DeepSeaSquid's numeric id ever changes, update it here — not in config.
# Public Leviathan display name is "DeepSeaSquid"; Telegram handle
# "@DeepSeaSquid_bot". We match on any of these for robustness.
NEMESIS_USER_ID = 8200500789
NEMESIS_TELEGRAM_USERNAMES = frozenset({"deepseasquid_bot", "deepseasquid"})
NEMESIS_DISPLAY_NAMES = frozenset({"deepseasquid"})
# Ambient anti-corsair rate limit: when the Commodore speaks up *because*
# the Nemesis is present (not because he was @mentioned), honor this floor
# between replies so the rivalry stays a running joke rather than spam.
NEMESIS_AMBIENT_COOLDOWN_S = 300 # 5 minutes
def _is_nemesis_message(msg):
"""True if this Telegram message was sent by DeepSeaSquid."""
sender = msg.get("from", {}) or {}
if int(sender.get("id", 0)) == NEMESIS_USER_ID:
return True
username = (sender.get("username") or "").lower()
if username in NEMESIS_TELEGRAM_USERNAMES:
return True
# Some bots push a custom display via first_name; last-line defence.
first = (sender.get("first_name") or "").lower()
return first in NEMESIS_DISPLAY_NAMES
def _is_mention_of_commodore(msg, text_lower):
"""True if this Telegram message is addressing the Commodore as a direct
@-mention, by any of his known aliases OR via a text_mention entity that
points at BOT_USER_ID.
Background: Telegram has two mention shapes. A `mention` entity is the
@-style ping by username; a `text_mention` entity is the structured
"link this text to this user_id" form that clients produce when an
author picks the bot from autocomplete by display name. Clients also
sometimes emit bare text like `@LeviathanFleetCommodore` without any
entity at all — so we need to cover all three signals.
Returns True on any of:
1. The canonical @BOT_USERNAME string appears in the text
2. Any string in BOT_MENTION_ALIASES appears as @alias in the text
3. A `text_mention` entity in msg.entities references BOT_USER_ID
"""
# Signal 1+2: textual mentions (case-insensitive; text_lower is supplied).
for alias in BOT_MENTION_ALIASES:
if f"@{alias}" in text_lower:
return True
# Signal 3: structured text_mention entity pointing at our user id.
if BOT_USER_ID is not None:
entities = msg.get("entities") or msg.get("caption_entities") or []
for ent in entities:
if ent.get("type") != "text_mention":
continue
user = ent.get("user") or {}
if int(user.get("id", 0)) == int(BOT_USER_ID):
return True
return False
def _is_mention_of_benthic(msg, text_lower):
"""True if this Telegram message @-mentions Benthic by username.
Used by Benthic backup mode (BENTHIC_BACKUP_MODE=1). We cannot resolve
Benthic's numeric Telegram user_id from this process, so we only match
the text form `@<BENTHIC_BOT_USERNAME>`. That's sufficient: the
Benthic-substitute path only fires when someone explicitly hails him.
"""
return f"@{BENTHIC_BOT_USERNAME}" in text_lower
def _is_fixed_public_hail(msg: dict) -> bool:
"""Minimal direct-hail check used only inside the public-room gate.
This deliberately performs no context lookup, history write, attachment
inspection, model dispatch, or persona routing. It recognizes only a
reply to the Commodore or one bounded textual/structured mention so Squid
Cave can receive a static decline without becoming a reply amplifier.
"""
reply_sender = (msg.get("reply_to_message") or {}).get("from", {}) or {}
if (reply_sender.get("username") or "").lower() == BOT_USERNAME:
return True
text = _message_text(msg)
if not text:
return False
return _is_mention_of_commodore(msg, text[:500].lower())
def _handle_public_untrusted_message(msg: dict) -> None:
"""Issue a fixed, rate-limited Squid Cave decline and do nothing else."""
chat_id = int((msg.get("chat") or {}).get("id") or 0)
sender = msg.get("from") or {}
if not chat_id or sender.get("username", "").lower() == BOT_USERNAME:
return
if not _is_fixed_public_hail(msg):
return
now = time.time()
if now - _public_decline_last_by_chat.get(chat_id, 0.0) < PUBLIC_ROOM_DECLINE_COOLDOWN_S:
return
try:
send_message(
chat_id,
PUBLIC_ROOM_DECLINE,
thread_id=msg.get("message_thread_id"),
reply_to=msg.get("message_id"),
)
except Exception as exc:
# Do not reflect any attacker-controlled text or metadata in this log.
log.warning("public-room decline send failed for chat %s: %s", chat_id, type(exc).__name__)
return
_public_decline_last_by_chat[chat_id] = now
def _nemesis_recently_present(recent_messages, lookback=5):
"""True if any of the last `lookback` messages in the buffer came from
the Nemesis. Used to decide whether to escalate the persona tone and
whether to break silence in mention-only channels."""
if not recent_messages:
return False
for m in recent_messages[-lookback:]:
if _is_nemesis_message(m):
return True
return False
# --- Prompt-injection defense (lifted from benthic-bot.py) ------------------
LEAK_PATTERNS = [
"enough context", "i have enough context",
"webfetch", "websearch",
"here's the reply", "here is the reply",
"here's the answer", "here is the answer",
"let me search", "let me check",
"tool_use", "tool_result", "function_call",
]
INJECTION_OUTPUT_PATTERNS = [
"ignore previous", "ignore all", "ignore above", "ignore the above",
"disregard previous", "disregard all", "disregard above",
"new instructions", "system prompt", "my instructions",
"as an ai", "as a language model", "i'm an ai",
"my wallet key is", "my private key is", "my api key is",
"ln-commodore-gh-pat", "gh-pat",
"begin openssh", "begin rsa", "begin ec private", "ssh-rsa ",
"wallet seed", "mnemonic", "passphrase",
]
def _register_secret_prefixes():
for path_env in ("GH_PAT_FILE", "BOT_TOKEN_FILE"):
path = os.environ.get(path_env)
if not path:
continue
try:
raw = Path(path).expanduser().read_text().strip()
if len(raw) >= 12:
INJECTION_OUTPUT_PATTERNS.append(raw[:12].lower())
except Exception:
pass
if BOT_TOKEN and len(BOT_TOKEN) >= 12:
INJECTION_OUTPUT_PATTERNS.append(BOT_TOKEN[:12].lower())
_register_secret_prefixes()
def sanitize_untrusted(text, max_len=500):
if not text:
return ""
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
text = text[:max_len]
text = text.replace("<", "\uff1c").replace(">", "\uff1e")
text = re.sub(r"-{4,}", "---", text)
text = re.sub(r"={4,}", "===", text)
return text.strip()
def check_output_for_injection(text, context=""):
if not text:
return False
norm = unicodedata.normalize("NFKD", text).lower()
for pattern in INJECTION_OUTPUT_PATTERNS:
if pattern in norm:
log.warning("INJECTION DETECTED in %s: matched '%s'", context, pattern)
return True
return False
def check_leak_patterns(text):
if not text:
return False
norm = unicodedata.normalize("NFKD", text).lower()
if any(p in norm for p in LEAK_PATTERNS):
log.warning("Rejected leaked output: %s", text[:80])
return True
return False
# --- Logging ----------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger("commodore")
# --- Loop-prevention state --------------------------------------------------
_last_reply_to = {}
_responded = set()
_thread_depth = {}
_msg_root = {}
_ambient_last_post_by_chat = {}
# Public-room decline state is intentionally memory-only: restart merely
# restores one static response opportunity; it never unlocks Q&A or tools.
_public_decline_last_by_chat = {}
# Last time we broke silence specifically to engage the Nemesis (per chat).
# Guards `NEMESIS_AMBIENT_COOLDOWN_S` so the rivalry is a running joke, not spam.
_nemesis_ambient_last_by_chat = {}
_MAX_STATE_SIZE = 5000
_MAX_CHAT_ROWS = 10000
_prune_counter = 0
MAX_THREAD_DEPTH = 5
# --- Result-scratch host directory ------------------------------------------
#
# Persistent directory shared between the host coordinator and the worker
# containers via a docker -v bind mount. Workers write `<uuid>.result.json`
# here as their first act after the side effect is irreversible (e.g. after
# `gh pr create` returns 201). The coordinator reads + unlinks after recording
# the outcome to SQLite. On boot, recovery scans this directory to detect any
# job whose worker reached the irreversible point but whose SQLite writeback
# never completed.
#
# The launchers bind-mount this onto /var/run/commodore-results inside the
# container. See bin/launch-{review,build,qa}-container.
RESULTS_DIR = Path(
os.environ.get("COMMODORE_RESULTS_DIR", "~/.local/state/commodore/results")
).expanduser()
def _ensure_state_dirs():
"""Create RESULTS_DIR with mode 0o700 if missing. Idempotent."""
try:
RESULTS_DIR.mkdir(parents=True, mode=0o700, exist_ok=True)
except OSError as exc:
log.warning("Failed to create RESULTS_DIR %s: %s", RESULTS_DIR, exc)
_ensure_state_dirs()
# --- Result-scratch helpers (daemon side: read + unlink only) ---------------
#
# Workers do the *write* side via their own embedded copy of
# write_result_atomically (see build_worker.py / qa_worker.py /
# review_worker.py). The coordinator reads + cleans up. The protocol is
# write-temp + fsync + rename + dir-fsync — readers only ever see complete
# JSON because POSIX rename(2) is atomic on the same filesystem.
#
# Recovery on boot ALSO sweeps `<uuid>.result.json.tmp` files older than 60s.
# Those are evidence of a worker crash mid-write; their existence proves no
# atomic rename ever happened, so the contents are garbage.
_TMP_SWEEP_MAX_AGE_S = 60
def read_result_file(uuid: str) -> "dict | None":
"""Read and parse `<uuid>.result.json` from RESULTS_DIR. Returns None if
missing or unparseable. Never reads `.tmp` files."""
final_path = RESULTS_DIR / f"{uuid}.result.json"
try:
raw = final_path.read_text()
except FileNotFoundError:
return None
except OSError as exc:
log.warning("read_result_file %s OSError: %s", uuid, exc)
return None
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# Should not happen given the atomic-rename protocol, but defense
# in depth: treat the file as garbage and let the secondary
# pre-flight (gh pr list / outgoing_msg log) take over.
log.warning("read_result_file %s JSONDecodeError: %s", uuid, exc)
return None
def unlink_result_file(uuid: str) -> None:
"""Delete `<uuid>.result.json` after the coordinator has recorded the
outcome to SQLite. Best-effort."""
final_path = RESULTS_DIR / f"{uuid}.result.json"
try:
final_path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
log.warning("unlink_result_file %s OSError: %s", uuid, exc)
def sweep_stale_tmp_files() -> int:
"""Remove `*.result.json.tmp` files older than _TMP_SWEEP_MAX_AGE_S.
Returns count of swept files. Called from _recover_jobs_on_boot()."""
swept = 0
now = time.time()
for path in RESULTS_DIR.glob("*.result.json.tmp"):
try:
age = now - path.stat().st_mtime
if age >= _TMP_SWEEP_MAX_AGE_S:
path.unlink()
swept += 1
except OSError:
continue
if swept:
log.info("sweep_stale_tmp_files: removed %d orphaned .tmp files", swept)
return swept
# --- SQLite (separate DB from Benthic - no schema collision) ----------------
# The conversation/action ledger is service state, not release material. A
# promotion must preserve it just as it preserves the separate triage ledger.
DB_FILE = Path(os.environ.get(
"COMMODORE_DB_FILE", "~/.local/state/fleet-commodore/commodore.db"
)).expanduser()
_TOKEN_LEAK_RE = re.compile(r"x-access-token:[^@\s]+@", re.IGNORECASE)
_GH_PAT_RE = re.compile(r"\b(github_pat_|ghp_|gho_|ghs_|ghu_)[A-Za-z0-9_]{20,}")
def _scrub_secrets_for_db(text):
"""Strip token-in-URL and bare PAT prefixes from anything we persist
to SQLite (build_job.error, etc). Worker stderr can include
`x-access-token:<pat>@github.com` from a failed git clone URL —
even though the worker scrubs its own stderr, if it crashes before
that path the raw container stderr can flow up via proc.stderr."""
if not text:
return text
text = _TOKEN_LEAK_RE.sub("x-access-token:<REDACTED>@", text)
text = _GH_PAT_RE.sub(r"\1<REDACTED>", text)
return text
def _safe_column_add(conn, table, column, definition):
"""Idempotent ALTER TABLE ADD COLUMN. SQLite has no IF NOT EXISTS for columns."""
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
except sqlite3.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
def _ensure_tables():
conn = None
try:
conn = sqlite3.connect(str(DB_FILE), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"""CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg_id INTEGER NOT NULL,
chat_id INTEGER NOT NULL,
topic_id INTEGER,
sender_username TEXT,
sender_is_bot INTEGER DEFAULT 0,
text TEXT,
our_reply TEXT,
timestamp TEXT NOT NULL,
UNIQUE(msg_id, chat_id)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS pr_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requested_by_id INTEGER NOT NULL,
requested_by_username TEXT,
chat_id INTEGER,
request_text TEXT,
repo TEXT,
branch TEXT,
pr_url TEXT,
outcome TEXT,
created_at TEXT NOT NULL
)"""
)
# pr_review: per-PR review requests with durable claim model.
# The partial unique index on claim_key prevents two concurrent active
# reviews of the same PR (any status except terminal ones). Terminal
# statuses (posted/failed/orphaned/superseded) are excluded so a later
# review of the same PR is always allowed once the prior one completes.
conn.execute(
"""CREATE TABLE IF NOT EXISTS pr_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
review_uuid TEXT UNIQUE NOT NULL,
claim_key TEXT NOT NULL,
requested_by_id INTEGER NOT NULL,
requested_by_username TEXT,
chat_id INTEGER NOT NULL,
topic_id INTEGER,
request_msg_id INTEGER,
repo TEXT NOT NULL,
pr_number INTEGER NOT NULL,
status TEXT NOT NULL,
verdict TEXT,
findings_json TEXT,
diff_bytes INTEGER,
claude_tokens_in INTEGER,
claude_tokens_out INTEGER,
error TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
posted_at TEXT
)"""
)
conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_review_active_claim
ON pr_review(claim_key)
WHERE status IN ('queued', 'in_progress')"""
)
# plan_drafts: multi-turn plan refinement state. One active draft per
# (chat_id, thread_id, requester_id) at a time enforced by the partial
# unique index below. A draft transitions through:
# drafting -> shipping -> shipped (PR landed)
# drafting -> abandoned (operator cancelled)
# shipping -> failed (build container errored)
conn.execute(
"""CREATE TABLE IF NOT EXISTS plan_drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draft_uuid TEXT UNIQUE NOT NULL,
chat_id INTEGER NOT NULL,
thread_id INTEGER,
requester_id INTEGER NOT NULL,
requester_username TEXT,
title TEXT,
target_repo TEXT,
target_branch TEXT,
plan_body_md TEXT,
message_history_json TEXT,
status TEXT NOT NULL,
pr_url TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_plan_drafts_active
ON plan_drafts(chat_id, COALESCE(thread_id,0), requester_id)
WHERE status IN ('drafting', 'shipping')"""
)
# build_job: durable job for the fork-and-PR pipeline. Created BEFORE the
# in-memory enqueue so a daemon restart can re-queue from SQLite.
# idempotency_key prevents two distinct ship-it calls from producing two
# PRs for the same logical change. side_effect_completed_at marks the
# point after which the worker has already produced an externally-visible
# artifact (the PR) — recovery uses this to avoid double-pushing.
conn.execute(
"""CREATE TABLE IF NOT EXISTS build_job (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_uuid TEXT UNIQUE NOT NULL,
draft_uuid TEXT NOT NULL,
chat_id INTEGER NOT NULL,
topic_id INTEGER,
requester_id INTEGER NOT NULL,
requester_username TEXT,
request_msg_id INTEGER,
target_repo TEXT NOT NULL,
target_branch TEXT NOT NULL,
job_payload_json TEXT NOT NULL,
status TEXT NOT NULL,
pr_url TEXT,
commit_sha TEXT,
error TEXT,
error_stage TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
idempotency_key TEXT NOT NULL DEFAULT '',
side_effect_completed_at TEXT,
last_dedup_token TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_build_job_status ON build_job(status)"
)
conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_build_job_idempotency
ON build_job(idempotency_key)
WHERE idempotency_key != ''"""
)
# qa_job: durable job for the read-only Q&A pipeline. telegram_reply_msg_id
# captures the bot's outgoing reply id once posted, used by recovery to
# detect whether a crashed-mid-post job actually delivered.
conn.execute(
"""CREATE TABLE IF NOT EXISTS qa_job (