2929
3030# ── CONFIG — all via environment variables ─────────────────────────────────
3131BOT_TOKEN = os .environ .get ("BOT_TOKEN" , "" )
32- TIMEZONE = os .environ .get ("TIMEZONE" , "Asia/Makassar" )
33- # Note: can be changed at runtime via /timezone command
32+ TIMEZONE = os .environ .get ("TIMEZONE" , "Asia/Makassar" ) # default = Bali WITA
3433ADMIN_CHAT_ID = int (os .environ .get ("ADMIN_CHAT_ID" , "0" ))
3534# ──────────────────────────────────────────────────────────────────────────
3635
4443
4544# user_state[chat_id] = { schedule, remind_minutes, job_ids[] }
4645user_state : dict = {}
46+ # Buffer for multi-part save codes (Telegram splits long messages)
47+ code_buffer : dict = {} # chat_id -> {"parts": [], "job": None}
4748stats = {"total_users" : set (), "schedules_loaded" : 0 }
4849scheduler = AsyncIOScheduler (timezone = TIMEZONE )
4950
@@ -452,7 +453,7 @@ async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
452453async def cmd_admin (update : Update , ctx : ContextTypes .DEFAULT_TYPE ):
453454 cid = update .effective_chat .id
454455 if cid != ADMIN_CHAT_ID :
455- return # silently ignore — non-admins don't even know this exists
456+ return # silently ignore
456457
457458 total_jobs = sum (len (u .get ("job_ids" , [])) for u in user_state .values ())
458459 loaded = sum (1 for u in user_state .values () if u .get ("schedule" ))
@@ -464,63 +465,46 @@ async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
464465 f"🟢 Active sessions: { len (user_state )} \n "
465466 f"📅 Sessions with schedule: { loaded } \n "
466467 f"🔔 Scheduled reminder jobs: { total_jobs } \n "
467- f"🌐 Timezone: { TIMEZONE } "
468+ f"🌐 Timezone: { TIMEZONE } \n \n "
469+ "Use /broadcast <message> to notify all users."
468470 )
469471 await update .message .reply_text (text , parse_mode = "Markdown" )
470472
471473
472- # ═══════════════════════════════════════════════════════════════════════════
473- # GENERIC MESSAGE HANDLER — tries to parse as Chronos code
474- # ═══════════════════════════════════════════════════════════════════════════
474+ async def cmd_broadcast (update : Update , ctx : ContextTypes .DEFAULT_TYPE ):
475+ """Admin-only: broadcast a message to all users who have ever loaded a schedule."""
476+ cid = update .effective_chat .id
477+ if cid != ADMIN_CHAT_ID :
478+ return
475479
476- async def handle_message (update : Update , ctx : ContextTypes .DEFAULT_TYPE ):
477- cid = update .effective_chat .id
478- text = update .message .text .strip ()
480+ if not ctx .args :
481+ await update .message .reply_text (
482+ "Usage: `/broadcast Your message here`\n "
483+ "Sends to all users who have loaded a schedule." ,
484+ parse_mode = "Markdown" ,
485+ )
486+ return
479487
480- # Heuristic: a Chronos save code is long with no spaces/newlines
481- if len (text ) > 50 and " " not in text and "\n " not in text :
488+ msg = " " .join (ctx .args )
489+ targets = list (stats ["total_users" ])
490+ ok , fail = 0 , 0
491+
492+ for uid in targets :
482493 try :
483- sch = parse_code (text )
484- if "events" not in sch and "tasks" not in sch :
485- raise ValueError ("missing events/tasks keys" )
486-
487- user_state .setdefault (cid , {"schedule" : None , "remind_minutes" : DEFAULT_REMIND_MINS , "job_ids" : []})
488- user_state [cid ]["schedule" ] = sch
489- stats ["total_users" ].add (cid )
490- stats ["schedules_loaded" ] += 1
491- count = reschedule (ctx .application , cid )
492-
493- tz = ZoneInfo (TIMEZONE )
494- today = datetime .now (tz ).date ()
495- today_evts = sum (1 for s , _ , _ in all_upcoming (sch , 1 ) if s .date () == today )
496- week_evts = len (all_upcoming (sch , 7 ))
497-
498- reply = (
499- f"✅ *Schedule loaded!*\n \n "
500- f"📁 *{ sch .get ('proj' ,'My Schedule' )} *\n "
501- f"📅 { len (sch .get ('events' ,[]))} events · "
502- f"{ len (sch .get ('tasks' ,[]))} unscheduled tasks\n "
503- f"🔔 { count } reminders set "
504- f"({ user_state [cid ]['remind_minutes' ]} min before + at start)\n \n "
494+ await ctx .bot .send_message (
495+ uid ,
496+ f"📢 *Message from Chronos*\n \n { msg } " ,
497+ parse_mode = "Markdown" ,
505498 )
506- if today_evts :
507- reply += f"You have *{ today_evts } * event(s) today → /today\n "
508- reply += f"*{ week_evts } * event(s) in the next 7 days → /week"
509-
510- await update .message .reply_text (reply , parse_mode = "Markdown" )
511- return
499+ ok += 1
500+ except Exception :
501+ fail += 1
512502
513- except Exception as ex :
514- log .debug ("Not a Chronos code: %s" , ex )
503+ await update .message .reply_text (
504+ f"📢 Broadcast sent\n ✅ { ok } delivered · ❌ { fail } failed" ,
505+ parse_mode = "Markdown" ,
506+ )
515507
516- # Fallback
517- ud = user_state .get (cid , {})
518- if not ud .get ("schedule" ):
519- await update .message .reply_text ("Paste your Chronos save code to get started, or use /help." )
520- else :
521- await update .message .reply_text (
522- "Try /today, /tomorrow, /week, /next — or paste a new Chronos code to update your schedule."
523- )
524508
525509async def cmd_timezone (update : Update , ctx : ContextTypes .DEFAULT_TYPE ):
526510 global TIMEZONE
@@ -557,7 +541,6 @@ async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
557541 TIMEZONE = tz_input
558542 scheduler .configure (timezone = TIMEZONE )
559543
560- # Reschedule all active users with new timezone
561544 count = 0
562545 for uid in user_state :
563546 if user_state [uid ].get ("schedule" ):
@@ -572,6 +555,106 @@ async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
572555 parse_mode = "Markdown" ,
573556 )
574557
558+
559+ # ═══════════════════════════════════════════════════════════════════════════
560+ # MULTI-PART CODE BUFFER
561+ # ═══════════════════════════════════════════════════════════════════════════
562+
563+ async def try_parse_buffer (ctx : ContextTypes .DEFAULT_TYPE ):
564+ """Called 3s after last code chunk — joins all parts and tries to parse."""
565+ cid = ctx .job .data ["cid" ]
566+ app = ctx .job .data ["app" ]
567+ buf = code_buffer .pop (cid , None )
568+ if not buf :
569+ return
570+
571+ full_code = "" .join (buf ["parts" ])
572+ try :
573+ sch = parse_code (full_code )
574+ if "events" not in sch and "tasks" not in sch :
575+ raise ValueError ("missing keys" )
576+
577+ user_state .setdefault (cid , {"schedule" : None , "remind_minutes" : DEFAULT_REMIND_MINS , "job_ids" : []})
578+ user_state [cid ]["schedule" ] = sch
579+ stats ["total_users" ].add (cid )
580+ stats ["schedules_loaded" ] += 1
581+ count = reschedule (app , cid )
582+
583+ tz = ZoneInfo (TIMEZONE )
584+ today = datetime .now (tz ).date ()
585+ today_evts = sum (1 for s , _ , _ in all_upcoming (sch , 1 ) if s .date () == today )
586+ week_evts = len (all_upcoming (sch , 7 ))
587+
588+ reply = (
589+ f"✅ *Schedule loaded!*\n \n "
590+ f"📁 *{ sch .get ('proj' ,'My Schedule' )} *\n "
591+ f"📅 { len (sch .get ('events' ,[]))} events · "
592+ f"{ len (sch .get ('tasks' ,[]))} unscheduled tasks\n "
593+ f"🔔 { count } reminders set "
594+ f"({ user_state [cid ]['remind_minutes' ]} min before + at start)\n \n "
595+ )
596+ if today_evts :
597+ reply += f"You have *{ today_evts } * event(s) today → /today\n "
598+ reply += f"*{ week_evts } * event(s) in the next 7 days → /week"
599+ await ctx .bot .send_message (cid , reply , parse_mode = "Markdown" )
600+
601+ except Exception as ex :
602+ log .debug ("Buffer parse failed (%d chars): %s" , len (full_code ), ex )
603+ await ctx .bot .send_message (
604+ cid ,
605+ "❌ Couldn't read that code — make sure you copy the *entire* save code from Chronos." ,
606+ parse_mode = "Markdown" ,
607+ )
608+
609+
610+ # ═══════════════════════════════════════════════════════════════════════════
611+ # GENERIC MESSAGE HANDLER
612+ # ═══════════════════════════════════════════════════════════════════════════
613+
614+ async def handle_message (update : Update , ctx : ContextTypes .DEFAULT_TYPE ):
615+ cid = update .effective_chat .id
616+ text = update .message .text .strip ()
617+
618+ # Could be a chunk of a Chronos save code — buffer and assemble
619+ looks_like_code = len (text ) > 10 and " " not in text and "\n " not in text
620+
621+ if looks_like_code :
622+ if cid not in code_buffer :
623+ code_buffer [cid ] = {"parts" : [], "job" : None }
624+ code_buffer [cid ]["parts" ].append (text )
625+
626+ # Cancel previous timer if any
627+ old_job = code_buffer [cid ].get ("job" )
628+ if old_job :
629+ try :
630+ old_job .schedule_removal ()
631+ except Exception :
632+ pass
633+
634+ # Wait 3s for more parts, then parse the assembled code
635+ job = ctx .application .job_queue .run_once (
636+ try_parse_buffer ,
637+ when = 3 ,
638+ data = {"cid" : cid , "app" : ctx .application },
639+ name = f"codeparse_{ cid } " ,
640+ )
641+ code_buffer [cid ]["job" ] = job
642+ return
643+
644+ # Fallback
645+ ud = user_state .get (cid , {})
646+ if not ud .get ("schedule" ):
647+ await update .message .reply_text ("Paste your Chronos save code to get started, or use /help." )
648+ else :
649+ await update .message .reply_text (
650+ "Try /today, /tomorrow, /week, /next — or paste a new Chronos code to update your schedule."
651+ )
652+
653+
654+ # ═══════════════════════════════════════════════════════════════════════════
655+ # POST INIT
656+ # ═══════════════════════════════════════════════════════════════════════════
657+
575658async def post_init (app : Application ):
576659 scheduler .start ()
577660 log .info ("Scheduler started" )
@@ -588,18 +671,19 @@ def main():
588671
589672 app = Application .builder ().token (BOT_TOKEN ).post_init (post_init ).build ()
590673
591- app .add_handler (CommandHandler ("start" , cmd_start ))
592- app .add_handler (CommandHandler ("help" , cmd_help ))
593- app .add_handler (CommandHandler ("today" , cmd_today ))
594- app .add_handler (CommandHandler ("tomorrow" , cmd_tomorrow ))
595- app .add_handler (CommandHandler ("week" , cmd_week ))
596- app .add_handler (CommandHandler ("next" , cmd_next ))
597- app .add_handler (CommandHandler ("remind" , cmd_remind ))
598- app .add_handler (CommandHandler ("status" , cmd_status ))
599- app .add_handler (CommandHandler ("admin" , cmd_admin ))
600- app .add_handler (CommandHandler ("timezone" , cmd_timezone ))
674+ app .add_handler (CommandHandler ("start" , cmd_start ))
675+ app .add_handler (CommandHandler ("help" , cmd_help ))
676+ app .add_handler (CommandHandler ("today" , cmd_today ))
677+ app .add_handler (CommandHandler ("tomorrow" , cmd_tomorrow ))
678+ app .add_handler (CommandHandler ("week" , cmd_week ))
679+ app .add_handler (CommandHandler ("next" , cmd_next ))
680+ app .add_handler (CommandHandler ("remind" , cmd_remind ))
681+ app .add_handler (CommandHandler ("status" , cmd_status ))
682+ app .add_handler (CommandHandler ("admin" , cmd_admin ))
683+ app .add_handler (CommandHandler ("broadcast" , cmd_broadcast ))
684+ app .add_handler (CommandHandler ("timezone" , cmd_timezone ))
601685 app .add_handler (MessageHandler (filters .TEXT & ~ filters .COMMAND , handle_message ))
602-
686+
603687 log .info ("Chronos Bot running — timezone: %s" , TIMEZONE )
604688 app .run_polling (allowed_updates = Update .ALL_TYPES )
605689
0 commit comments