Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
1295 lines (1078 loc) · 52 KB
/
Copy pathmain.py
File metadata and controls
1295 lines (1078 loc) · 52 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
import asyncio
import importlib
import logging
import os
import sys
from functools import partial
from pathlib import Path
from urllib.parse import urlparse
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.error import BadRequest, NetworkError
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
TypeHandler,
filters,
)
from condor import paths
from condor.persistence import SafePicklePersistence
from condor.telemetry import taps as telemetry_taps
from handlers import cancel_command, clear_all_input_states
from utils.auth import restricted
from utils.config import (
LOCAL_MODE,
TELEGRAM_TOKEN,
USE_TAILSCALE,
WEB_HOST,
WEB_PORT,
WEB_URL,
ConfigError,
check_local_user,
check_startup_config,
)
# Enable logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
logger = logging.getLogger(__name__)
# Suppress httpx INFO-level request logging — python-telegram-bot embeds the
# bot token in the request path (api.telegram.org/bot<TOKEN>/getUpdates), and
# httpx's default INFO logs the full URL on every call. With long-poll firing
# every ~10s, the token ends up in every log handler (journald, files, etc.),
# which makes safe log sharing impossible. Suppressing to WARNING preserves
# real HTTP errors while removing the token leak.
logging.getLogger("httpx").setLevel(logging.WARNING)
def _get_start_menu_keyboard(is_admin: bool = False) -> InlineKeyboardMarkup:
"""Build the start menu inline keyboard."""
keyboard = [
[
InlineKeyboardButton("🔌 Servers", callback_data="start:config_servers"),
InlineKeyboardButton("🔑 Keys", callback_data="start:config_keys"),
InlineKeyboardButton("🌐 Gateway", callback_data="start:config_gateway"),
],
]
if is_admin:
keyboard.append([InlineKeyboardButton("👑 Admin", callback_data="start:admin")])
keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="start:cancel")])
return InlineKeyboardMarkup(keyboard)
def _dashboard_button_url(url: str) -> str | None:
"""The dashboard link Telegram will accept in an inline URL button, if any.
Telegram rejects ``localhost`` outright ("Wrong HTTP URL") but accepts the
loopback IP, so the local default (``http://localhost:8088``) still gets a
button by swapping the host — which is what a Telegram client running on
the same machine needs anyway. Any other dotless hostname (a bare LAN name)
has no such equivalent, so those keep the copy-paste link only.
"""
parsed = urlparse(url)
host = parsed.hostname or ""
if host == "localhost":
parsed = parsed._replace(netloc=parsed.netloc.replace(host, "127.0.0.1", 1))
return parsed.geturl()
if "." not in host:
return None
return url
@restricted
async def web_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Generate a one-time login link for the web dashboard."""
from condor.web.auth import create_login_token
user = update.effective_user
token = create_login_token(user.id, user.username or "", user.first_name or "")
url = f"{WEB_URL}/login?token={token}"
_hostname = urlparse(WEB_URL).hostname or ""
is_localhost = (
"localhost" in WEB_URL or "127.0.0.1" in WEB_URL or "." not in _hostname
)
button_url = _dashboard_button_url(url)
keyboard = (
InlineKeyboardMarkup(
[[InlineKeyboardButton("🌐 Open Dashboard", url=button_url)]]
)
if button_url
else None
)
if is_localhost:
# A loopback button only opens on the machine running the bot, so the
# raw link stays visible for a phone on the same network to adapt.
text = (
f"🌐 *Web Dashboard*\n\n"
f"Open this link in your browser:\n`{url}`\n\n"
f"_Link valid for 5 minutes\\._"
)
else:
text = (
"🌐 *Web Dashboard*\n\n"
"Tap the button below to open the dashboard\\.\n"
"_Link valid for 5 minutes\\._"
)
try:
await update.message.reply_text(
text, reply_markup=keyboard, parse_mode="MarkdownV2"
)
except BadRequest:
# Telegram refuses URL buttons it can't parse (host without a public
# TLD, an unusual scheme). Losing the button must not lose the link.
if keyboard is None:
raise
await update.message.reply_text(
f"🌐 *Web Dashboard*\n\n"
f"Open this link in your browser:\n`{url}`\n\n"
f"_Link valid for 5 minutes\\._",
parse_mode="MarkdownV2",
)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Start the conversation and display available commands (BotFather style)."""
from config_manager import UserRole, get_config_manager
from utils.auth import _capture_identity, _notify_admin_new_user
tg_user = update.effective_user
user_id = tg_user.id
username = tg_user.username or ""
# What to print where the reply says "Username:". An absent handle is shown
# as absent rather than stored as the "No username" string the record used
# to carry (FEAT-088) — the sentinel was a display default that leaked into
# config.yml and became a name the admin panel then had to render.
handle_display = f"@{username}" if username else "not set"
cm = get_config_manager()
role = cm.get_user_role(user_id)
# Handle blocked users
if role == UserRole.BLOCKED:
await update.message.reply_text("Access denied.")
return
# Handle pending users
if role == UserRole.PENDING:
reply_text = f"""Access Pending
Your access request is awaiting admin approval.
Your Info:
User ID: {user_id}
Username: {handle_display}
You will be notified when approved."""
await update.message.reply_text(reply_text)
return
# Handle new users - register as pending
if role is None:
is_new = cm.register_pending(
user_id,
username,
first_name=tg_user.first_name,
last_name=tg_user.last_name,
)
if is_new:
await _notify_admin_new_user(context, user_id, username)
reply_text = f"""Access Request Submitted
Your request has been sent to the admin for approval.
Your Info:
User ID: {user_id}
Username: {handle_display}
You will be notified when approved."""
await update.message.reply_text(reply_text)
return
# User is approved (USER or ADMIN role)
_capture_identity(update)
clear_all_input_states(context)
reply_text = """I can help you create and manage trading bots on any CEX or DEX using Hummingbot API servers\\.
See [this manual](https://condor.hummingbot.org/introduction) if you're new to Condor\\.
You can control me by sending these commands:
/keys \\- add exchange API keys
/portfolio \\- view balances across exchanges
/bots \\- deploy and manage trading bots
/trade \\- place CEX and DEX orders
/agent \\- AI trading assistant
/web \\- open the web dashboard"""
await update.message.reply_text(
reply_text, parse_mode="MarkdownV2", disable_web_page_preview=True
)
@restricted
async def start_callback_handler(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Handle callbacks from the start menu."""
query = update.callback_query
await query.answer()
data = query.data
action = data.split(":")[1] if ":" in data else data
# Handle cancel - delete the message
if action == "cancel":
await query.message.delete()
return
# Handle navigation to config options
if data.startswith("start:"):
if action == "config_servers":
from handlers import clear_all_input_states
from handlers.config.servers import show_api_servers
clear_all_input_states(context)
await show_api_servers(query, context)
elif action == "config_keys":
from handlers import clear_all_input_states
from handlers.config.api_keys import show_api_keys
clear_all_input_states(context)
await show_api_keys(query, context)
elif action == "config_gateway":
from handlers import clear_all_input_states
from handlers.config.gateway import show_gateway_menu
clear_all_input_states(context)
context.user_data.pop("dex_state", None)
context.user_data.pop("cex_state", None)
await show_gateway_menu(query, context)
elif action == "admin":
from handlers import clear_all_input_states
from handlers.admin import _show_admin_menu
clear_all_input_states(context)
await _show_admin_menu(query, context)
# Modules the handlers import from that do not live under ``handlers/``, and
# that are safe to re-execute. Everything under ``handlers/`` itself is
# discovered by :func:`_discover_handler_modules`.
#
# ``routines/`` is deliberately absent: routines have their own mtime-aware
# discovery in ``routines.base.discover_routines(force_reload=True)``, which
# owns reimporting individual routine modules. Only the base module is listed,
# so that machinery itself stays fresh.
_EXTRA_RELOAD_MODULES = (
"config_manager",
"utils.auth",
"utils.telegram_formatters",
"routines.base",
)
def _discover_handler_modules() -> list[str]:
"""Every module under ``handlers/``, children before parents.
Derived rather than hand-listed. A hand-maintained list drifts, and it
drifts *silently*: ``reload_handlers`` skips any name not in
``sys.modules``, so a stale entry is a no-op and a missing entry means the
watcher logs a successful reload while the running bot keeps the old code.
That is worse than no hot-reload, because it reports success. The list this
replaced named three ``handlers.dex.swap_*`` modules that no longer exist
and omitted six that do, including ``handlers.dex.router``.
Naming every module matters because ``importlib.reload`` is NOT recursive:
reloading ``handlers.dex`` re-executes its ``__init__``, but its
``from .router import ...`` re-binds the *cached* ``handlers.dex.router``,
so an edit there is missed unless that module is reloaded in its own right.
For the same reason children sort before parents: by the time a package's
``__init__`` re-executes, the submodules it imports from have already been
refreshed.
"""
root = Path(__file__).parent / "handlers"
modules: set[str] = set()
for path in root.rglob("*.py"):
parts = path.relative_to(root.parent).with_suffix("").parts
# Per-agent runtime stores can sit under a watched tree (FEAT-003) and
# are data, not code — the file watcher skips them for the same reason.
if "__pycache__" in parts or "store" in parts:
continue
if parts[-1] == "__init__":
parts = parts[:-1]
if parts:
modules.add(".".join(parts))
return sorted(modules, key=lambda name: (-name.count("."), name))
def reload_handlers():
"""Reload all handler modules."""
# NOTE: no "condor.runtime.*" module belongs here. The runtime holds live
# subprocess handles (agent sessions); re-executing those modules resets
# the registry and silently orphans every running agent. The discovery
# above only walks handlers/, so it cannot pull them in.
modules_to_reload = [*_EXTRA_RELOAD_MODULES, *_discover_handler_modules()]
reloaded = 0
for module_name in modules_to_reload:
if module_name in sys.modules:
importlib.reload(sys.modules[module_name])
logger.debug(f"Reloaded module: {module_name}")
reloaded += 1
logger.info(f"Reloaded {reloaded} modules")
# Re-register fetch functions after reload (preserves in-memory cache)
try:
from condor.server_data_service import register_default_fetches as sds_register
sds_register()
except Exception as e:
logger.warning(f"Failed to re-register SDS fetches: {e}")
def register_handlers(application: Application) -> None:
"""Register all command handlers."""
# Import fresh versions after reload
from handlers.admin import admin_command
from handlers.admin.update import update_command
from handlers.agents import (
agent_callback_handler,
agent_command,
agent_voice_handler,
stop_command,
)
from handlers.bots import (
bots_callback_handler,
bots_command,
get_bots_document_handler,
new_bot_command,
)
from handlers.cex import cex_callback_handler
from handlers.config import get_config_callback_handler, get_modify_value_handler
from handlers.config.api_keys import keys_command
from handlers.config.gateway import gateway_command
from handlers.config.servers import servers_command
from handlers.delegations import delegations_callback_handler, delegations_command
from handlers.dex import dex_callback_handler, lp_command
from handlers.executors import executors_callback_handler, executors_command
from handlers.memory import memory_callback_handler, memory_command
from handlers.portfolio import get_portfolio_callback_handler, portfolio_command
from handlers.routines import routines_callback_handler, routines_command
from handlers.trading import trade_command as unified_trade_command
from handlers.trading.router import unified_trade_callback_handler
# Clear existing handlers
application.handlers.clear()
# Usage telemetry observer (FEAT-023). PTB dispatches every update to every
# group, so one handler in group -1 sees every command and every callback
# without touching a single handler below. It only reads — it must never
# call into @restricted, or observing would become an authorization side
# effect — and it is a no-op unless the admin opted in.
application.add_handler(TypeHandler(Update, telemetry_taps.telegram_tap), group=-1)
# Add command handlers
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("portfolio", portfolio_command))
application.add_handler(CommandHandler("bots", bots_command))
application.add_handler(CommandHandler("new_bot", new_bot_command))
application.add_handler(
CommandHandler("trade", unified_trade_command)
) # Unified trade (CEX + DEX)
application.add_handler(
CommandHandler("swap", unified_trade_command)
) # Alias for /trade
application.add_handler(CommandHandler("lp", lp_command))
application.add_handler(CommandHandler("routines", routines_command))
application.add_handler(CommandHandler("executors", executors_command))
application.add_handler(CommandHandler("agent", agent_command))
# Interrupts the answer in flight without touching the session — the
# dashboard's Stop button, for a surface that has no buttons while streaming.
application.add_handler(CommandHandler("stop", stop_command))
application.add_handler(CommandHandler("delegations", delegations_command))
application.add_handler(CommandHandler("memory", memory_command))
# Universal escape hatch for flows that arm a "next message is the answer"
# input mode. Every such prompt advertises /cancel.
application.add_handler(CommandHandler("cancel", cancel_command))
# Add configuration commands (direct access)
application.add_handler(CommandHandler("servers", servers_command))
application.add_handler(CommandHandler("keys", keys_command))
application.add_handler(CommandHandler("gateway", gateway_command))
application.add_handler(CommandHandler("admin", admin_command))
application.add_handler(CommandHandler("update", update_command))
application.add_handler(CommandHandler("web", web_command))
# Add callback query handler for start menu navigation
application.add_handler(
CallbackQueryHandler(start_callback_handler, pattern="^start:")
)
# Add unified trade callback handler BEFORE cex/dex handlers (for connector switching)
application.add_handler(
CallbackQueryHandler(unified_trade_callback_handler, pattern="^trade:")
)
# Add callback query handlers for trading operations
application.add_handler(CallbackQueryHandler(cex_callback_handler, pattern="^cex:"))
application.add_handler(CallbackQueryHandler(dex_callback_handler, pattern="^dex:"))
application.add_handler(
CallbackQueryHandler(bots_callback_handler, pattern="^bots:")
)
application.add_handler(
CallbackQueryHandler(routines_callback_handler, pattern="^routines:")
)
application.add_handler(
CallbackQueryHandler(executors_callback_handler, pattern="^executors:")
)
# Add agent callback handler
application.add_handler(
CallbackQueryHandler(agent_callback_handler, pattern="^agent:")
)
# Add delegations callback handler (/delegations list + view + stop)
application.add_handler(
CallbackQueryHandler(delegations_callback_handler, pattern="^deleg:")
)
# Add memory callback handler (/memory review + delete)
application.add_handler(
CallbackQueryHandler(memory_callback_handler, pattern="^memory:")
)
# Add admin callback handler
from handlers.admin import admin_callback_handler
application.add_handler(
CallbackQueryHandler(admin_callback_handler, pattern="^admin:")
)
# Telemetry consent prompt (FEAT-023): three buttons, admin only
from condor.telemetry import prompt as telemetry_prompt
application.add_handler(
CallbackQueryHandler(
telemetry_prompt.callback_handler,
pattern=f"^{telemetry_prompt.CALLBACK_PREFIX}:",
)
)
# Add callback query handler for portfolio settings
application.add_handler(get_portfolio_callback_handler())
# Add callback query handler for config menu
application.add_handler(get_config_callback_handler())
# Add UNIFIED message handler for ALL text input
# This single handler routes to: CLOB trading, DEX trading, and Config flows
# based on context state. This avoids issues with multiple MessageHandlers
# competing for the same filter.
application.add_handler(get_modify_value_handler())
# Add voice message handler for agent transcription
application.add_handler(MessageHandler(filters.VOICE, agent_voice_handler))
# Add document handler for file uploads (e.g., config files in /bots)
application.add_handler(get_bots_document_handler())
logger.info("Handlers registered successfully")
async def sync_server_permissions() -> None:
"""
Ensure all servers in config have permission entries.
Registers any unregistered servers with admin as owner.
"""
from config_manager import get_config_manager
cm = get_config_manager()
for server_name in cm.list_servers():
cm.ensure_server_registered(server_name)
logger.info("Synced server permissions")
async def register_bot_commands(application: Application) -> None:
"""Register the Telegram command menus (public for everyone, admin overlay).
Extracted from ``startup`` so it can also run on hot-reload — otherwise a
newly added command (e.g. /delegations) gets its dispatch handler reloaded
but never shows up in the menu until a full process restart.
"""
from telegram import (
BotCommandScopeAllGroupChats,
BotCommandScopeAllPrivateChats,
BotCommandScopeChat,
BotCommandScopeDefault,
)
from utils.config import ADMIN_USER_ID
# Clear any previously set commands for all scopes to avoid stale overrides
for scope in [
BotCommandScopeDefault(),
BotCommandScopeAllPrivateChats(),
BotCommandScopeAllGroupChats(),
]:
try:
await application.bot.delete_my_commands(scope=scope)
except Exception:
pass
if ADMIN_USER_ID:
try:
await application.bot.delete_my_commands(
scope=BotCommandScopeChat(chat_id=int(ADMIN_USER_ID))
)
except Exception:
pass
# 1) Public commands — registered by default for ALL users (default scope is
# the universal fallback every user resolves to unless a more specific
# scope overrides it). Wrapped independently so a transient failure here
# never blocks the admin step (or the rest of startup) from running.
commands = [
BotCommand("start", "Welcome message and setup"),
BotCommand("portfolio", "View balances across exchanges"),
BotCommand("agent", "AI trading assistant"),
BotCommand("stop", "Stop the answer being generated"),
BotCommand("delegations", "Monitor background agent tasks"),
BotCommand("memory", "Review what the assistant remembers about you"),
BotCommand("executors", "Deploy and manage trading executors"),
BotCommand("bots", "Deploy and manage trading bots"),
BotCommand("new_bot", "Create bot configurations"),
BotCommand("routines", "Run configurable Python scripts"),
BotCommand("trade", "Place CEX and DEX orders"),
BotCommand("lp", "Liquidity pool management"),
BotCommand("servers", "Manage Hummingbot API servers"),
BotCommand("keys", "Configure exchange API credentials"),
BotCommand("gateway", "Gateway for DEX trading"),
BotCommand("web", "Open the web dashboard"),
BotCommand("cancel", "Abort the current input flow"),
]
try:
await application.bot.set_my_commands(commands)
except Exception as e:
logger.warning(f"Failed to set public commands: {e}", exc_info=True)
# 2) Admin-only commands — layered on top of the public ones, visible only in
# the admin user's own command menu (chat scope overrides the default).
if ADMIN_USER_ID:
admin_commands = commands + [
BotCommand("admin", "Admin panel - manage users and access"),
BotCommand("update", "Check for and install updates"),
]
try:
await application.bot.set_my_commands(
admin_commands, scope=BotCommandScopeChat(chat_id=int(ADMIN_USER_ID))
)
except Exception as e:
logger.warning(f"Failed to set admin-specific commands: {e}", exc_info=True)
async def _notify_interrupted_runs(bot, report) -> None:
"""Tell each owner, once, what the restart found.
One summary per chat rather than a message per run: a crash with several
live loops would otherwise spam the user at the worst possible moment.
"""
from condor.notifications import announce, user_for_chat
by_chat: dict[int, list] = {}
for run in report.interrupted:
status = None
try:
from condor.runtime.registry_file import read_status
status = read_status(run.session_dir)
except Exception:
pass
chat_id = (status or {}).get("chat_id")
if chat_id:
by_chat.setdefault(int(chat_id), []).append(run)
for chat_id, runs in by_chat.items():
lines = [f"Found {len(runs)} interrupted run(s) after restart:"]
for run in runs:
suffix = " — restarted" if run.restarted else ""
lines.append(f"• {run.label} (last tick {run.last_tick}){suffix}")
text = "\n".join(lines)
# Telegram and the bell (FEAT-048) in one call: ``announce`` resolves
# the sender once and files the notice exactly once, including when the
# sender *is* the bell (local mode, FEAT-049). A private chat id is the
# owner's user id; a group has no dashboard owner, so a group summary is
# simply not filed anywhere.
try:
await announce(
user_for_chat(chat_id), chat_id, text, kind="system", bot=bot
)
except Exception:
logger.warning("Could not notify chat %s about interrupted runs", chat_id)
def _outbound_bot(application: Application):
"""The object this process sends user-facing messages through.
Telegram mode: the real PTB bot, exactly as before. Local mode: there is no
Telegram behind the placeholder token, so outbound messages go to the
dashboard bell instead (:class:`condor.notifications.NotifyBot`, FEAT-048).
Everything downstream — the routine store, the session health monitor, the
interrupted-run summaries — keeps calling ``send_message`` and neither knows
nor cares which surface it reached.
"""
if not LOCAL_MODE:
return application.bot
from condor.notifications import NotifyBot
return NotifyBot()
# Strong references to fire-and-forget boot tasks. asyncio only holds a weak
# one, so a task nobody awaits can be garbage-collected mid-flight; keeping the
# handle here (and discarding it on completion) is the documented fix.
_BACKGROUND_TASKS: set[asyncio.Task] = set()
def _spawn_background(coro, label: str) -> asyncio.Task:
"""Run ``coro`` off the critical path, logging rather than swallowing errors.
For work the process wants done soon but must not wait for — cache warm-up
being the case that used to gate the uvicorn bind behind a dead server's
connect timeouts.
"""
task = asyncio.create_task(coro)
_BACKGROUND_TASKS.add(task)
task.add_done_callback(_BACKGROUND_TASKS.discard)
def _report(t: asyncio.Task) -> None:
if not t.cancelled() and t.exception() is not None:
logger.error("%s failed: %s", label, t.exception())
task.add_done_callback(_report)
return task
async def startup(application: Application) -> None:
"""Bring the process up: commands, caches, supervisors, boot reconciliation.
Deliberately *not* wired as PTB's ``post_init``. That hook only fires from
``run_polling``/``run_webhook``, and :func:`_run_dual` drives the lifecycle
by hand (``initialize`` → ``start_polling`` → ``start``) so it can run
uvicorn alongside the bot. Registering it on the builder would look correct
and never run — which is exactly how boot reconciliation silently died.
Called explicitly from :func:`_run_dual`, before the first update is served.
"""
# First, before anything reads a conversation or reconciles a delegation:
# settle where the runtime store lives (FEAT-051). Idempotent, so this is a
# no-op on every boot after the first; it is a named public function rather
# than inline code because a second entry point (a CLI, a worker) would have
# to call it too.
from condor.migrations import ensure_migrated
ensure_migrated()
# Sync server permissions (ensures all servers have ownership entries)
await sync_server_permissions()
# Preload Whisper model in background so first voice message is fast
import asyncio
from utils.transcribe import DEFAULT_MODEL, _get_model
asyncio.get_event_loop().run_in_executor(None, _get_model, DEFAULT_MODEL)
# Same reason, different work: bringing the backtest archive to its tiered
# v2 layout (FEAT-075) parses and re-compresses every saved payload, which
# is minutes on a store measured in gigabytes. It is deliberately NOT part
# of ``ensure_migrated`` above -- that one runs before the first update is
# served. The archive degrades honestly while this runs (summaries report
# ``status: unknown`` and the dashboard says "indexing"), so the tab stays
# usable throughout, and the work is per-file and idempotent so a crash
# resumes rather than restarts.
from condor.backtest_store import migrate_backtest_archive
asyncio.get_event_loop().run_in_executor(None, migrate_backtest_archive)
# Whatever this process pushes at users from here on. In local mode there is
# no Telegram to push to, so it is the dashboard bell (FEAT-048) instead —
# which is what keeps the documented "context.bot is never None" contract
# true for routines with no bot behind them.
outbound_bot = _outbound_bot(application)
# Register command menus (public + admin overlay). Pure Telegram: there is
# no command menu to publish when nothing polls.
if not LOCAL_MODE:
await register_bot_commands(application)
# Restore scheduled routine jobs from persistence
from handlers.routines import restore_scheduled_jobs
await restore_scheduled_jobs(application)
# Inject Telegram bot into routine store so web-triggered routines can send messages
from condor.routine_store import get_routine_store
get_routine_store().set_bot(outbound_bot)
# Start ServerDataService (unified server-centric cache)
from condor.server_data_service import get_server_data_service
from condor.server_data_service import register_default_fetches as sds_register
sds_register()
sds = get_server_data_service()
sds.start()
# Warm the cache in the background, never on the critical path: every
# subscription's initial fetch opens a client, and client creation is
# serialized per server (ConfigManager._client_locks), so one unreachable
# server used to hold the Telegram start and the uvicorn bind behind a
# queue of connect timeouts. A cold read degrades to what a down server
# already returns (None) and the poll loop fills it in; a reachable server
# is warm within a second either way.
_spawn_background(sds.auto_subscribe_servers(), "SDS auto-subscribe")
# Ride the ticker-pool poll the SDS just started: an hourly price snapshot
# per server is the only source of 24h change on the CLOB side, and it costs
# no upstream request (FEAT-053).
from condor import ticker_history
ticker_history.install_listener()
# Start agent session health monitor. The health monitor is process
# lifecycle, not a session operation, so it is driven off the module
# directly rather than through the client facade.
from condor.runtime import sessions as runtime_sessions
from condor.runtime.confirmations import get_registry
await runtime_sessions.start_health_monitor(outbound_bot)
# Sweeps expired approvals so a request nobody answers is denied, not leaked.
await get_registry().start()
# Settle whatever the previous process left running: mark orphaned loops
# interrupted, restart only the ones that opted in, and tell the owner once.
from condor.runtime.loops import get_supervisor
try:
report = await get_supervisor().reconcile_boot()
if report.total:
logger.warning(
"Boot reconciliation: %d interrupted, %d restarted",
report.total,
len(report.restarted),
)
await _notify_interrupted_runs(outbound_bot, report)
except Exception:
logger.exception("Boot reconciliation failed; continuing startup")
# Schedule periodic update checks (notifies admin)
from handlers.admin.update import schedule_update_checks
schedule_update_checks(application)
# Usage telemetry (FEAT-023). init() resolves the consent level once so the
# taps never read the disk on a hot path, and only materializes the
# install's random ids when telemetry is actually on. The jobs are
# registered either way and return immediately while consent is absent, so
# opting in mid-run works without a restart.
from condor import telemetry
try:
level = telemetry.init(hosted=True)
telemetry_taps.register_jobs(application)
logger.info("Telemetry level: %s", level)
except Exception:
logger.exception("Telemetry init failed (continuing without it)")
# Conversation sharing (FEAT-054, FEAT-055). Two jobs, deliberately not part
# of the telemetry block above: they share no consent record, no queue and
# no endpoint with it. Both are free on an install where nobody has opted
# in — the delivery job finds an empty queue and the sweep finds no user at
# ``always``, and neither touches the network.
try:
from condor.sharing import share as sharing
from condor.sharing import sweep as sharing_sweep
sharing.register_jobs(application)
sharing_sweep.register_jobs(application)
except Exception:
logger.exception("Sharing job registration failed (continuing without it)")
# Reflection (FEAT-073): read a finished conversation back once, for the
# facts and the intents in it. The sharing sweep's neighbour by shape and
# not by subject — it shares no consent record, no queue and no endpoint
# with it, and nothing it reads leaves the machine. Free on an install with
# nothing finished to read, and off entirely with CONDOR_REFLECTION=off.
try:
from condor.agents import reflection
reflection.register_jobs(application)
except Exception:
logger.exception("Reflection job registration failed (continuing without it)")
# Start file watcher
asyncio.create_task(watch_and_reload(application))
async def teardown(application: Application) -> None:
"""Wind the process down: stop supervisors, flush state, close clients.
The counterpart to :func:`startup`, and not PTB's ``post_shutdown`` for the
same reason: ``Application.shutdown()`` does not call that hook either.
Called explicitly from :func:`_run_dual`.
"""
from condor.runtime import client as runtime
from condor.runtime import sessions as runtime_sessions
from condor.runtime.confirmations import get_registry
await runtime_sessions.stop_health_monitor()
await get_registry().stop()
await runtime.destroy_all()
# Stop all trading agents. Graceful stop, deliberately NOT the shutdown
# sequence — winding down positions is an emergency action, not what a
# restart should do. Each engine records its final state on the way out.
from condor.runtime.conversations import flush_all as flush_conversations
from condor.runtime.loops import get_supervisor
from condor.runtime.state import flush_all
await get_supervisor().stop_all()
# Writes are debounced, so force the last one out on a clean shutdown.
flush_all()
# A prompt still streaming when the bot went down holds its turn in
# memory; write it out rather than losing the last thing that was said.
flush_conversations()
# Stop WebSocket manager
from condor.web.ws_manager import get_ws_manager
get_ws_manager().stop()
# Stop ServerDataService
from condor.server_data_service import get_server_data_service
get_server_data_service().stop()
# Close cached Hummingbot API clients (ConfigManager)
from config_manager import get_config_manager
await get_config_manager().close_all_clients()
# Close MCP hummingbot client
from mcp_servers.hummingbot_api.hummingbot_client import hummingbot_client
await hummingbot_client.close()
# Record the clean exit and give the outbox one last chance. Both are no-ops
# unless the admin opted in, and neither can fail the shutdown.
try:
from condor import telemetry
telemetry.shutdown("signal")
await telemetry.flush("teardown")
except Exception:
logger.debug("Telemetry teardown failed", exc_info=True)
async def watch_and_reload(application: Application) -> None:
"""Watch for file changes and reload handlers automatically."""
try:
from watchfiles import DefaultFilter, awatch
except ImportError:
logger.warning(
"watchfiles not installed. Auto-reload disabled. Install with: uv add watchfiles"
)
return
handlers_path = Path(__file__).parent / "handlers"
routines_path = Path(__file__).parent / "routines"
# ``agents/`` is deliberately NOT watched: AgentStore reads AGENT.md from
# disk on every call, so there is no cache to bust (the assistant cache that
# justified watching it is gone with FEAT-033), while agents write journals
# and strategy state under the same tree — watching it would thrash reloads.
watch_paths = [handlers_path, routines_path]
logger.info(f"👀 Watching for changes in: {', '.join(str(p) for p in watch_paths)}")
class _ReloadFilter(DefaultFilter):
"""Ignore per-agent runtime stores (FEAT-003).
A store can sit under a watched tree (an agent-owned ``routines/`` dir
next to its ``store/``), so without this every memory/skill write would
thrash a full handler reload.
"""
def __call__(self, change, path: str) -> bool:
if f"{os.sep}store{os.sep}" in path:
return False
return super().__call__(change, path)
async for changes in awatch(*watch_paths, watch_filter=_ReloadFilter()):
logger.info(f"📝 Detected changes: {changes}")
try:
reload_handlers()
register_handlers(application)
# Refresh the Telegram command menus too, so a newly added/removed
# command shows up without requiring a full process restart.
await register_bot_commands(application)
logger.info("✅ Auto-reloaded handlers successfully")
except Exception as e:
logger.error(f"❌ Auto-reload failed: {e}", exc_info=True)
def get_persistence() -> SafePicklePersistence:
"""
Build a persistence object that works both locally and in Docker.
- Uses an env var override if provided.
- Defaults to <project_root>/data/condor_bot_data.pickle, resolved through
condor.paths.data_dir() so $CONDOR_DATA_DIR repoints the whole
operational store at once rather than this one file.
- Ensures the parent directory exists, but does NOT create the file.
- Uses SafePicklePersistence for atomic writes, backup recovery,
and ephemeral key filtering.
"""
default_path = paths.data_dir() / "condor_bot_data.pickle"
persistence_path = Path(os.getenv("CONDOR_PERSISTENCE_FILE", default_path))
# Make sure the directory exists; the file will be created by PTB
persistence_path.parent.mkdir(parents=True, exist_ok=True)
return SafePicklePersistence(filepath=persistence_path, update_interval=10)
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle errors gracefully."""
if isinstance(context.error, NetworkError):
# Expected and self-healing; reported as an upstream blip, not a bug.
telemetry_taps.on_upstream_error("telegram", "poll", "network")
logger.warning(f"Network error (will retry): {context.error}")
return
# Type, a hash of the message, and our own stack frames. Never the message
# itself — that is where balances, hostnames and keys leak.
telemetry_taps.on_error(context.error, where="telegram", surface="telegram")
logger.exception("Exception while handling an update:", exc_info=context.error)
async def send_to_telegram(
self, chat_id: int, message: str, parse_mode: str = "Markdown"
):
"""Sends a message to a specific Telegram chat."""
await self.bot.send_message(chat_id=chat_id, text=message, parse_mode=parse_mode)
async def send_to_all(self, message: str, parse_mode: str = "Markdown"):
"""Sends a message to all users who have started the bot."""
for chat_id in self.user_data:
try:
await self.bot.send_message(
chat_id=chat_id, text=message, parse_mode=parse_mode
)
except Exception as e:
logger.warning(f"Failed to send message to chat {chat_id}: {e}")
def main() -> None:
"""Run the bot."""
# Refuse to start on a configuration that cannot mean what it says: telegram
# mode (the default) with no token used to surface as an InvalidToken
# traceback from inside PTB, and must never be quietly read as "local mode".
# check_local_user() is the same idea one layer in — local mode logs in with
# no password, so who it logs in as is settled here, at boot, and not as a
# 500 from /auth/local-login with the browser already open.
try:
check_startup_config()
check_local_user()
except ConfigError as exc:
logger.error("%s", exc)
raise SystemExit(1)