88Setup:
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
1515import base64
1616import json
1717import logging
18+ import os
1819from datetime import datetime , timedelta , date
1920from zoneinfo import ZoneInfo # Python 3.9+
2021
2627from apscheduler .schedulers .asyncio import AsyncIOScheduler
2728from 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
3737DEFAULT_REMIND_MINS = 15
4444
4545# user_state[chat_id] = { schedule, remind_minutes, job_ids[] }
4646user_state : dict = {}
47+ stats = {"total_users" : set (), "schedules_loaded" : 0 }
4748scheduler = AsyncIOScheduler (timezone = TIMEZONE )
4849
4950# ═══════════════════════════════════════════════════════════════════════════
5253
5354def 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
145144def 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
255255async 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
286301async 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
307322async 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
489578def 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