Skip to content

Commit 006a81f

Browse files
Update chronos_bot.py
1 parent ab39ceb commit 006a81f

1 file changed

Lines changed: 146 additions & 62 deletions

File tree

bot/chronos_bot.py

Lines changed: 146 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@
2929

3030
# ── CONFIG — all via environment variables ─────────────────────────────────
3131
BOT_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
3433
ADMIN_CHAT_ID = int(os.environ.get("ADMIN_CHAT_ID", "0"))
3534
# ──────────────────────────────────────────────────────────────────────────
3635

@@ -44,6 +43,8 @@
4443

4544
# user_state[chat_id] = { schedule, remind_minutes, job_ids[] }
4645
user_state: dict = {}
46+
# Buffer for multi-part save codes (Telegram splits long messages)
47+
code_buffer: dict = {} # chat_id -> {"parts": [], "job": None}
4748
stats = {"total_users": set(), "schedules_loaded": 0}
4849
scheduler = AsyncIOScheduler(timezone=TIMEZONE)
4950

@@ -452,7 +453,7 @@ async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
452453
async 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

525509
async 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+
575658
async 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

Comments
 (0)