Skip to content

Commit 0cdde71

Browse files
Update chronos_bot.py
timezone and admin
1 parent 3ec76cf commit 0cdde71

1 file changed

Lines changed: 124 additions & 32 deletions

File tree

bot/chronos_bot.py

Lines changed: 124 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
Setup:
99
1. pip install "python-telegram-bot[job-queue]" apscheduler
1010
2. Talk to @BotFather on Telegram → /newbot → copy the token
11-
3. Set BOT_TOKEN and TIMEZONE below
11+
3. Set environment variables: BOT_TOKEN, TIMEZONE, ADMIN_CHAT_ID
1212
4. python chronos_bot.py
1313
"""
1414

1515
import base64
1616
import json
1717
import logging
18+
import os
1819
from datetime import datetime, timedelta, date
1920
from zoneinfo import ZoneInfo # Python 3.9+
2021

@@ -26,12 +27,11 @@
2627
from apscheduler.schedulers.asyncio import AsyncIOScheduler
2728
from apscheduler.triggers.date import DateTrigger
2829

29-
# ── CONFIG — edit these two lines ─────────────────────────────────────────
30-
import os
31-
BOT_TOKEN = os.environ.get("BOT_TOKEN", "")
32-
# or switch the two lines above for this line below (if self hosted):
33-
# BOT_TOKEN = "YOUR_BOT_TOKEN_HERE"
34-
TIMEZONE = "Asia/Makassar" # Bali = WITA (UTC+8)
30+
# ── CONFIG — all via environment variables ─────────────────────────────────
31+
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
34+
ADMIN_CHAT_ID = int(os.environ.get("ADMIN_CHAT_ID", "0"))
3535
# ──────────────────────────────────────────────────────────────────────────
3636

3737
DEFAULT_REMIND_MINS = 15
@@ -44,6 +44,7 @@
4444

4545
# user_state[chat_id] = { schedule, remind_minutes, job_ids[] }
4646
user_state: dict = {}
47+
stats = {"total_users": set(), "schedules_loaded": 0}
4748
scheduler = AsyncIOScheduler(timezone=TIMEZONE)
4849

4950
# ═══════════════════════════════════════════════════════════════════════════
@@ -52,7 +53,6 @@
5253

5354
def parse_code(code: str) -> dict:
5455
"""Decode a Chronos base64 save code → dict."""
55-
# Handle missing base64 padding
5656
padded = code.strip() + "=" * (-len(code.strip()) % 4)
5757
raw = base64.b64decode(padded).decode("utf-8")
5858
return json.loads(raw)
@@ -86,7 +86,6 @@ def occurrences(ev: dict, ws_dt: datetime, days_ahead: int = 30) -> list[tuple]:
8686
out = []
8787

8888
if not rec:
89-
# Single event
9089
d = ev.get("day", 0)
9190
sm = ev_start_min(ev, d)
9291
edt = ws_dt + timedelta(days=d, minutes=sm)
@@ -143,7 +142,7 @@ def fmt_dur(mins: int) -> str:
143142

144143

145144
def fmt_ev(ev: dict, s: datetime, e: datetime) -> str:
146-
rec_icon = " 🔄" if ev.get("recurring") else ""
145+
rec_icon = " 🔄" if ev.get("recurring") else ""
147146
lock_icon = " 🔒" if ev.get("locked") else ""
148147
return (
149148
f"• {s.strftime('%H:%M')}{e.strftime('%H:%M')} "
@@ -180,9 +179,9 @@ def reschedule(app: Application, chat_id: int) -> int:
180179
return 0
181180

182181
clear_jobs(chat_id)
183-
remind_mins = ud.get("remind_minutes", DEFAULT_REMIND_MINS)
184-
now = datetime.now(ZoneInfo(TIMEZONE))
185-
job_ids = []
182+
remind_mins = ud.get("remind_minutes", DEFAULT_REMIND_MINS)
183+
now = datetime.now(ZoneInfo(TIMEZONE))
184+
job_ids = []
186185
remind_count = 0
187186

188187
for s, e, ev in all_upcoming(ud["schedule"], days_ahead=30):
@@ -248,13 +247,29 @@ def reschedule(app: Application, chat_id: int) -> int:
248247
/next — Your next upcoming event
249248
/remind 15 — Set reminder lead-time in minutes (0 = disable)
250249
/status — Schedule summary
250+
/timezone — Show or change timezone
251251
/help — This message
252252
""".strip()
253253

254254

255255
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
256-
cid = update.effective_chat.id
256+
cid = update.effective_chat.id
257+
user = update.effective_user
257258
user_state.setdefault(cid, {"schedule": None, "remind_minutes": DEFAULT_REMIND_MINS, "job_ids": []})
259+
stats["total_users"].add(cid)
260+
261+
# Notify admin of new user
262+
if ADMIN_CHAT_ID and cid != ADMIN_CHAT_ID:
263+
try:
264+
name = user.username or user.first_name or str(cid)
265+
await ctx.bot.send_message(
266+
ADMIN_CHAT_ID,
267+
f"👤 New user: @{name} (`{cid}`)",
268+
parse_mode="Markdown",
269+
)
270+
except Exception:
271+
pass
272+
258273
await update.message.reply_text(HELP_TEXT, parse_mode="Markdown")
259274

260275

@@ -284,8 +299,8 @@ async def cmd_today(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
284299

285300

286301
async def cmd_tomorrow(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
287-
cid = update.effective_chat.id
288-
ud = user_state.get(cid, {})
302+
cid = update.effective_chat.id
303+
ud = user_state.get(cid, {})
289304
if not ud.get("schedule"):
290305
await update.message.reply_text("No schedule loaded yet — paste your Chronos save code!")
291306
return
@@ -305,8 +320,8 @@ async def cmd_tomorrow(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
305320

306321

307322
async def cmd_week(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
308-
cid = update.effective_chat.id
309-
ud = user_state.get(cid, {})
323+
cid = update.effective_chat.id
324+
ud = user_state.get(cid, {})
310325
if not ud.get("schedule"):
311326
await update.message.reply_text("No schedule loaded yet — paste your Chronos save code!")
312327
return
@@ -319,7 +334,7 @@ async def cmd_week(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
319334
await update.message.reply_text("📭 Nothing in the next 7 days.")
320335
return
321336

322-
lines = ["📆 *Next 7 days*\n"]
337+
lines = ["📆 *Next 7 days*\n"]
323338
cur_day = None
324339
for s, e, ev in evts:
325340
d = s.date()
@@ -346,17 +361,17 @@ async def cmd_next(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
346361
await update.message.reply_text("No schedule loaded yet — paste your Chronos save code!")
347362
return
348363

349-
tz = ZoneInfo(TIMEZONE)
350-
now = datetime.now(tz)
364+
tz = ZoneInfo(TIMEZONE)
365+
now = datetime.now(tz)
351366
upcoming = [(s, e, ev) for s, e, ev in all_upcoming(ud["schedule"], 7) if s > now]
352367

353368
if not upcoming:
354369
await update.message.reply_text("📭 No upcoming events in the next 7 days.")
355370
return
356371

357-
s, e, ev = upcoming[0]
358-
delta = s - now
359-
hrs, rem = divmod(int(delta.total_seconds()), 3600)
372+
s, e, ev = upcoming[0]
373+
delta = s - now
374+
hrs, rem = divmod(int(delta.total_seconds()), 3600)
360375
mins_left = rem // 60
361376

362377
if delta.days >= 1:
@@ -398,7 +413,10 @@ async def cmd_remind(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
398413
count = reschedule(ctx.application, cid) if user_state[cid].get("schedule") else 0
399414

400415
if mins == 0:
401-
await update.message.reply_text("✅ Early reminders *disabled* — you'll still get a ping exactly at start time.", parse_mode="Markdown")
416+
await update.message.reply_text(
417+
"✅ Early reminders *disabled* — you'll still get a ping exactly at start time.",
418+
parse_mode="Markdown",
419+
)
402420
else:
403421
await update.message.reply_text(
404422
f"✅ Reminder lead-time set to *{mins} min*. {count} upcoming reminders rescheduled.",
@@ -424,12 +442,33 @@ async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
424442
f"*Events:* {len(sch.get('events',[]))}\n"
425443
f"*Unscheduled tasks:* {len(sch.get('tasks',[]))}\n"
426444
f"*Reminder lead-time:* {ud.get('remind_minutes', DEFAULT_REMIND_MINS)} min\n"
427-
f"*Active jobs:* {len(ud.get('job_ids',[]))}\n\n"
445+
f"*Active jobs:* {len(ud.get('job_ids',[]))}\n"
446+
f"*Timezone:* {TIMEZONE}\n\n"
428447
"Paste a new Chronos code any time to update."
429448
)
430449
await update.message.reply_text(text, parse_mode="Markdown")
431450

432451

452+
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
453+
cid = update.effective_chat.id
454+
if cid != ADMIN_CHAT_ID:
455+
return # silently ignore — non-admins don't even know this exists
456+
457+
total_jobs = sum(len(u.get("job_ids", [])) for u in user_state.values())
458+
loaded = sum(1 for u in user_state.values() if u.get("schedule"))
459+
460+
text = (
461+
f"📊 *Chronos Bot — Admin Stats*\n\n"
462+
f"👥 Unique users: {len(stats['total_users'])}\n"
463+
f"📥 Schedules loaded: {stats['schedules_loaded']}\n"
464+
f"🟢 Active sessions: {len(user_state)}\n"
465+
f"📅 Sessions with schedule: {loaded}\n"
466+
f"🔔 Scheduled reminder jobs: {total_jobs}\n"
467+
f"🌐 Timezone: {TIMEZONE}"
468+
)
469+
await update.message.reply_text(text, parse_mode="Markdown")
470+
471+
433472
# ═══════════════════════════════════════════════════════════════════════════
434473
# GENERIC MESSAGE HANDLER — tries to parse as Chronos code
435474
# ═══════════════════════════════════════════════════════════════════════════
@@ -438,7 +477,7 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
438477
cid = update.effective_chat.id
439478
text = update.message.text.strip()
440479

441-
# Heuristic: a Chronos save code is long, no spaces/newlines
480+
# Heuristic: a Chronos save code is long with no spaces/newlines
442481
if len(text) > 50 and " " not in text and "\n" not in text:
443482
try:
444483
sch = parse_code(text)
@@ -447,10 +486,12 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
447486

448487
user_state.setdefault(cid, {"schedule": None, "remind_minutes": DEFAULT_REMIND_MINS, "job_ids": []})
449488
user_state[cid]["schedule"] = sch
489+
stats["total_users"].add(cid)
490+
stats["schedules_loaded"] += 1
450491
count = reschedule(ctx.application, cid)
451492

452-
tz = ZoneInfo(TIMEZONE)
453-
today = datetime.now(tz).date()
493+
tz = ZoneInfo(TIMEZONE)
494+
today = datetime.now(tz).date()
454495
today_evts = sum(1 for s, _, _ in all_upcoming(sch, 1) if s.date() == today)
455496
week_evts = len(all_upcoming(sch, 7))
456497

@@ -481,14 +522,62 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
481522
"Try /today, /tomorrow, /week, /next — or paste a new Chronos code to update your schedule."
482523
)
483524

525+
async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
526+
global TIMEZONE
527+
cid = update.effective_chat.id
528+
529+
if not ctx.args:
530+
now = datetime.now(ZoneInfo(TIMEZONE))
531+
await update.message.reply_text(
532+
f"🌐 Current timezone: *{TIMEZONE}*\n"
533+
f"🕐 Local time now: *{now.strftime('%H:%M, %a %d %b')}*\n\n"
534+
"Change with `/timezone Asia/Singapore`\n"
535+
"Other examples:\n"
536+
"`/timezone Asia/Makassar` — Bali\n"
537+
"`/timezone Asia/Singapore` — SGT\n"
538+
"`/timezone Europe/Moscow` — Moscow\n"
539+
"`/timezone Europe/London` — London\n"
540+
"`/timezone America/New_York` — New York",
541+
parse_mode="Markdown",
542+
)
543+
return
544+
545+
tz_input = ctx.args[0].strip()
546+
try:
547+
ZoneInfo(tz_input) # validates it
548+
except Exception:
549+
await update.message.reply_text(
550+
f"❌ Unknown timezone `{tz_input}`.\n"
551+
"Use a standard tz name like `Asia/Singapore` or `Europe/London`.\n"
552+
"Full list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones",
553+
parse_mode="Markdown",
554+
)
555+
return
484556

557+
TIMEZONE = tz_input
558+
scheduler.configure(timezone=TIMEZONE)
559+
560+
# Reschedule all active users with new timezone
561+
count = 0
562+
for uid in user_state:
563+
if user_state[uid].get("schedule"):
564+
reschedule(ctx.application, uid)
565+
count += 1
566+
567+
now = datetime.now(ZoneInfo(TIMEZONE))
568+
await update.message.reply_text(
569+
f"✅ Timezone set to *{TIMEZONE}*\n"
570+
f"🕐 Local time now: *{now.strftime('%H:%M, %a %d %b')}*\n"
571+
f"🔔 Rescheduled reminders for {count} active user(s).",
572+
parse_mode="Markdown",
573+
)
485574
# ═══════════════════════════════════════════════════════════════════════════
486575
# ENTRY POINT
487576
# ═══════════════════════════════════════════════════════════════════════════
488577

489578
def main():
490-
if BOT_TOKEN == "YOUR_BOT_TOKEN_HERE":
491-
print("❌ Set BOT_TOKEN in chronos_bot.py first!")
579+
if not BOT_TOKEN:
580+
print("❌ BOT_TOKEN environment variable not set!")
492581
return
493582

494583
app = Application.builder().token(BOT_TOKEN).build()
@@ -501,10 +590,13 @@ def main():
501590
app.add_handler(CommandHandler("next", cmd_next))
502591
app.add_handler(CommandHandler("remind", cmd_remind))
503592
app.add_handler(CommandHandler("status", cmd_status))
593+
app.add_handler(CommandHandler("admin", cmd_admin))
594+
app.add_handler(CommandHandler("timezone", cmd_timezone))
504595
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
596+
505597

506598
scheduler.start()
507-
log.info("Chronos Bot running — waiting for messages...")
599+
log.info("Chronos Bot running — timezone: %s", TIMEZONE)
508600
app.run_polling(allowed_updates=Update.ALL_TYPES)
509601

510602

0 commit comments

Comments
 (0)