From c4c98528c951e2d5e0e0fb262841ae1d1b99a717 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 22:08:52 +0300 Subject: [PATCH 1/9] feat: refactor database handling to use PostgreSQL and implement CRUD operations --- bot.py | 119 +++++++++++---------------------------------------------- db.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 97 deletions(-) create mode 100644 db.py diff --git a/bot.py b/bot.py index 2969d59..d859320 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,5 @@ import os import logging -import sqlite3 import uuid import re import io @@ -9,6 +8,8 @@ from dotenv import load_dotenv from dateutil.parser import parse as date_parse +from db import Database + from telegram import ( Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup ) @@ -39,36 +40,14 @@ ) logger = logging.getLogger(__name__) + +host=os.environ["DB_HOST"], +database=os.environ["DB_NAME"], +user=os.environ["DB_USER"], +password=os.environ["DB_PASS"], +port=os.environ.get("DB_PORT") # DB setup -DB_FILE = 'opportunities.db' -def init_db(): - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute(''' - CREATE TABLE IF NOT EXISTS opportunities ( - opp_id TEXT PRIMARY KEY, - user_id INTEGER, - title TEXT, - opp_type TEXT, - deadline TEXT, - priority TEXT, - description TEXT, - message_text TEXT, - link TEXT, - archived INTEGER DEFAULT 0, - done INTEGER DEFAULT 0, - missed_notified INTEGER DEFAULT 0 - ) - ''') - # Safe migration for existing databases - for col, defn in [("link", "TEXT"), ("missed_notified", "INTEGER DEFAULT 0")]: - try: - c.execute(f"ALTER TABLE opportunities ADD COLUMN {col} {defn}") - except sqlite3.OperationalError: - pass - conn.commit() - conn.close() -init_db() +db = Database(host,database,user,password,port) # Conversation states DEADLINE, TYPE, PRIORITY, TITLE, DESCRIPTION, LINK, CONFIRM = range(7) @@ -355,15 +334,7 @@ async def confirm_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) - link = context.user_data.get('link', '') try: - conn = sqlite3.connect(DB_FILE) - conn.execute( - 'INSERT INTO opportunities ' - '(opp_id, user_id, title, opp_type, deadline, priority, description, message_text, link) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - (opp_id, user_id, title, opp_type, deadline.isoformat(), priority, desc, message_text, link) - ) - conn.commit() - conn.close() + db.add_opportunity(opp_id, user_id, title, opp_type, deadline.isoformat(), priority, desc, message_text, link) except Exception as e: logger.error('DB error: %s', e) await query.edit_message_text('⚠️ Error saving. Please try again.') @@ -433,15 +404,10 @@ def schedule_reminders(job_queue, user_id, opp_id, deadline, priority, title, de async def check_missed(context: ContextTypes.DEFAULT_TYPE) -> None: """Fires once daily; notifies each overdue opportunity ONCE only.""" now = datetime.now() - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute( - 'SELECT user_id, opp_id, title, description, opp_type, link, deadline ' - 'FROM opportunities ' - 'WHERE deadline < ? AND archived = 0 AND done = 0 AND missed_notified = 0', - (now.isoformat(),) - ) - for uid, opp_id, title, desc, opp_type, link, dl_str in c.fetchall(): + missed = db.get_missed_opportunities(now.isoformat()) + + for row in missed: + uid, opp_id, title, desc, opp_type, link, dl_str = row try: dl = datetime.fromisoformat(str(dl_str)) desc_s = str(desc) if desc else '' @@ -461,11 +427,9 @@ async def check_missed(context: ContextTypes.DEFAULT_TYPE) -> None: InlineKeyboardButton('✅ Mark as Done', callback_data=f'done_{opp_id}') ]]) await context.bot.send_message(chat_id=uid, text=msg, reply_markup=keyboard, parse_mode='Markdown') - conn.execute('UPDATE opportunities SET missed_notified = 1 WHERE opp_id = ?', (opp_id,)) - conn.commit() + db.mark_missed_notified(opp_id) except Exception as exc: logger.error('Missed-notify failed for %s: %s', opp_id, exc) - conn.close() async def mark_done_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: query = update.callback_query @@ -473,12 +437,7 @@ async def mark_done_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) if query.data.startswith('done_'): opp_id = query.data.split('_')[1] user_id = query.from_user.id - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute('UPDATE opportunities SET done=1, archived=1 WHERE opp_id = ? AND user_id = ?', (opp_id, user_id)) - updated = c.rowcount - conn.commit() - conn.close() + updated = db.mark_done(opp_id, user_id) if updated > 0: for job in context.job_queue.jobs(): if job.name and opp_id in job.name: @@ -490,11 +449,7 @@ async def mark_done_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) async def list_opps(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.message.from_user.id - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute('SELECT opp_id, title, opp_type, deadline, priority, description FROM opportunities WHERE user_id = ? AND archived = 0 AND done = 0 ORDER BY deadline', (user_id,)) - opps = c.fetchall() - conn.close() + opps = db.get_active_opportunities(user_id) if not opps: await update.message.reply_text("No active opportunities.") return @@ -513,12 +468,7 @@ async def delete(update: Update, context: ContextTypes.DEFAULT_TYPE): return opp_id = context.args[0] user_id = update.message.from_user.id - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute('DELETE FROM opportunities WHERE opp_id = ? AND user_id = ?', (opp_id, user_id)) - deleted = c.rowcount - conn.commit() - conn.close() + deleted = db.delete_opportunity(opp_id, user_id) if deleted > 0: for job in context.job_queue.jobs(): if job.name and opp_id in job.name: @@ -533,12 +483,7 @@ async def archive(update: Update, context: ContextTypes.DEFAULT_TYPE): return opp_id = context.args[0] user_id = update.message.from_user.id - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute('UPDATE opportunities SET archived=1 WHERE opp_id = ? AND user_id = ?', (opp_id, user_id)) - updated = c.rowcount - conn.commit() - conn.close() + updated = db.archive_opportunity(opp_id, user_id) if updated > 0: for job in context.job_queue.jobs(): if job.name and opp_id in job.name: @@ -553,12 +498,7 @@ async def done(update: Update, context: ContextTypes.DEFAULT_TYPE): return opp_id = context.args[0] user_id = update.message.from_user.id - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute('UPDATE opportunities SET done=1, archived=1 WHERE opp_id = ? AND user_id = ?', (opp_id, user_id)) - updated = c.rowcount - conn.commit() - conn.close() + updated = db.mark_done(opp_id, user_id) if updated > 0: for job in context.job_queue.jobs(): if job.name and opp_id in job.name: @@ -571,15 +511,7 @@ async def summary(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.message.from_user.id now = datetime.now() week_end = now + timedelta(days=7) - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute( - 'SELECT COUNT(*), opp_type FROM opportunities ' - 'WHERE user_id = ? AND deadline >= ? AND deadline <= ? AND archived=0 AND done=0 GROUP BY opp_type', - (user_id, now.isoformat(), week_end.isoformat()) - ) - sums = c.fetchall() - conn.close() + sums = db.get_weekly_summary(user_id, now.isoformat(), week_end.isoformat()) if not sums: await update.message.reply_text("No upcoming this week.") return @@ -594,14 +526,7 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): # --- Reschedule reminders on startup --- def reschedule_all_reminders(job_queue: JobQueue): """Re-registers all pending reminders after a bot restart.""" - conn = sqlite3.connect(DB_FILE) - c = conn.cursor() - c.execute( - 'SELECT user_id, opp_id, title, deadline, priority, description, opp_type, link ' - 'FROM opportunities WHERE archived = 0 AND done = 0' - ) - rows = c.fetchall() - conn.close() + rows = db.get_all_active_reminders() now = datetime.now() for user_id, opp_id, title, dl_str, priority, desc, opp_type, link in rows: try: diff --git a/db.py b/db.py new file mode 100644 index 0000000..c22417d --- /dev/null +++ b/db.py @@ -0,0 +1,108 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor +from dotenv import load_dotenv + +load_dotenv() + +class Database: + _initialized = False # class-level flag to prevent repeated init + + def __init__(self, host, database, user, password, port): + self.conn = psycopg2.connect( + host=host, + database=database, + user=user, + password=password, + port=port + ) + self.conn.autocommit = True + + self.init_db() # TODO: find better way to not re init db on every call + + + def init_db(self): + """Create table and ensure missing columns exist""" + with self.conn.cursor() as c: + c.execute(''' + CREATE TABLE IF NOT EXISTS opportunities ( + opp_id TEXT PRIMARY KEY, + user_id BIGINT, + title TEXT, + opp_type TEXT, + deadline TEXT, + priority TEXT, + description TEXT, + message_text TEXT, + archived INTEGER DEFAULT 0, + done INTEGER DEFAULT 0 + ) + ''') + + c.execute("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS link TEXT") + c.execute("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS missed_notified INTEGER DEFAULT 0") + + # ---------------- CRUD Methods ---------------- # + + def add_opportunity(self, opp_id, user_id, title, opp_type, deadline, priority, desc, message_text, link=None): + with self.conn.cursor() as c: + c.execute(''' + INSERT INTO opportunities + (opp_id, user_id, title, opp_type, deadline, priority, description, message_text, link) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ''', (opp_id, user_id, title, opp_type, deadline, priority, desc, message_text, link)) + + def get_missed_opportunities(self, now_iso): + with self.conn.cursor(cursor_factory=RealDictCursor) as c: + c.execute(''' + SELECT user_id, opp_id, title, description, opp_type, link, deadline + FROM opportunities + WHERE deadline < %s AND archived = 0 AND done = 0 AND missed_notified = 0 + ''', (now_iso,)) + return c.fetchall() + + def mark_missed_notified(self, opp_id): + with self.conn.cursor() as c: + c.execute('UPDATE opportunities SET missed_notified = 1 WHERE opp_id = %s', (opp_id,)) + + def mark_done(self, opp_id, user_id): + with self.conn.cursor() as c: + c.execute('UPDATE opportunities SET done=1, archived=1 WHERE opp_id = %s AND user_id = %s', (opp_id, user_id)) + return c.rowcount + + def get_active_opportunities(self, user_id): + with self.conn.cursor(cursor_factory=RealDictCursor) as c: + c.execute(''' + SELECT opp_id, title, opp_type, deadline, priority, description + FROM opportunities + WHERE user_id = %s AND archived = 0 AND done = 0 ORDER BY deadline + ''', (user_id,)) + return c.fetchall() + + def delete_opportunity(self, opp_id, user_id): + with self.conn.cursor() as c: + c.execute('DELETE FROM opportunities WHERE opp_id = %s AND user_id = %s', (opp_id, user_id)) + return c.rowcount + + def archive_opportunity(self, opp_id, user_id): + with self.conn.cursor() as c: + c.execute('UPDATE opportunities SET archived=1 WHERE opp_id = %s AND user_id = %s', (opp_id, user_id)) + return c.rowcount + + def get_weekly_summary(self, user_id, now_iso, week_end_iso): + with self.conn.cursor(cursor_factory=RealDictCursor) as c: + c.execute(''' + SELECT COUNT(*) as count, opp_type FROM opportunities + WHERE user_id = %s AND deadline >= %s AND deadline <= %s AND archived=0 AND done=0 + GROUP BY opp_type + ''', (user_id, now_iso, week_end_iso)) + return c.fetchall() + + def get_all_active_reminders(self): + with self.conn.cursor(cursor_factory=RealDictCursor) as c: + c.execute(''' + SELECT user_id, opp_id, title, deadline, priority, description, opp_type, link + FROM opportunities + WHERE archived = 0 AND done = 0 + ''') + return c.fetchall() \ No newline at end of file From c72262a1e97f38d6ebe0f6e228f445651f0088c2 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 22:13:57 +0300 Subject: [PATCH 2/9] docs: Update readme to include postgres environment configuration --- .env.example | 6 ++++++ README.md | 37 +++++++++++++++++++++++++++++++++---- requirements.txt | 3 ++- 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d52d743 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +BOT_TOKEN='your-bot-token' +DB_HOST= "" +DB_NAME= "" +DB_USER="" +DB_PASS="" +DB_PORT="" \ No newline at end of file diff --git a/README.md b/README.md index 3bc81b7..77038d0 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,26 @@ So I created **OppTickBot** — a personal tool that turned into something I now - Python 3.10+ - [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22+ (with job-queue extra) -- SQLite for storing opportunities +- PostgreSQL for storing opportunities - dateutil + regex for date parsing - Pillow + pytesseract (optional) for OCR on images +### Database Structure + +The project uses PostgreSQL. The main table is `opportunities` which maps to the following schema: +- `opp_id` (TEXT PRIMARY KEY): Unique identifier. +- `user_id` (BIGINT): Telegram user ID. +- `title` (TEXT): Opportunity title. +- `opp_type` (TEXT): Category (Internship, Scholarship, Event, etc.). +- `deadline` (TEXT): Datetime for the deadline. +- `priority` (TEXT): Priority level. +- `description` (TEXT): Extracted or user-provided description. +- `message_text` (TEXT): Original raw text. +- `link` (TEXT): Related URL. +- `archived` (INTEGER DEFAULT 0): Whether it is archived. +- `done` (INTEGER DEFAULT 0): Whether it is completed. +- `missed_notified` (INTEGER DEFAULT 0): Has user been notified of missing deadline. + ### Setup (Local Development) ```bash @@ -74,8 +90,21 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate # 3. Install dependencies pip install -r requirements.txt -# 4. Create .env file with your token -echo "BOT_TOKEN=your_bot_token_here" > .env +# 4. Create .env file with your credentials +cp .env.example .env + +# Open the .env file and populate it with your actual values: +# BOT_TOKEN="your-bot-token" +# DB_HOST="localhost" +# DB_NAME="opptick_db" +# DB_USER="postgres" +# DB_PASS="password" +# DB_PORT="5432" + # 5. Run the bot -python bot.py \ No newline at end of file +python bot.py +``` + +**TODO listed in `db.py`**: +- In `Database.init_db()` there is a TODO to "find a better way to not re-init db on every call". Currently, creating a new `Database` instance triggers `init_db()` which checks and executes `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements. Ideally, this should happen once at application startup or via a standalone migration script to avoid unnecessary query overhead. diff --git a/requirements.txt b/requirements.txt index 95eff61..9f32235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ python-telegram-bot[job-queue]>=22.0 python-dateutil>=2.8 python-dotenv>=1.0 pillow>=10.0 -pytesseract>=0.3 \ No newline at end of file +pytesseract>=0.3 +psycopg2-binary \ No newline at end of file From 201ce45ad3d94ad38356c2347f9852af6f611cab Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 22:32:12 +0300 Subject: [PATCH 3/9] refactor: streamline environment loading and database cursor usage --- bot.py | 14 +++++++------- db.py | 13 +++++-------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/bot.py b/bot.py index d859320..72793b1 100644 --- a/bot.py +++ b/bot.py @@ -27,8 +27,8 @@ OCR_AVAILABLE = False # Load environment -if os.path.exists(".env"): - load_dotenv() + +load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") if not BOT_TOKEN: raise ValueError("BOT_TOKEN missing! Set in .env or Railway Variables.") @@ -41,11 +41,11 @@ logger = logging.getLogger(__name__) -host=os.environ["DB_HOST"], -database=os.environ["DB_NAME"], -user=os.environ["DB_USER"], -password=os.environ["DB_PASS"], -port=os.environ.get("DB_PORT") +host=os.environ["DB_HOST"] +database=os.environ["DB_NAME"] +user=os.environ["DB_USER"] +password=os.environ["DB_PASS"] +port=os.environ["DB_PORT"] # DB setup db = Database(host,database,user,password,port) diff --git a/db.py b/db.py index c22417d..c689e6f 100644 --- a/db.py +++ b/db.py @@ -1,12 +1,9 @@ import os import psycopg2 -from psycopg2.extras import RealDictCursor -from dotenv import load_dotenv -load_dotenv() class Database: - _initialized = False # class-level flag to prevent repeated init + _initialized = False def __init__(self, host, database, user, password, port): self.conn = psycopg2.connect( @@ -53,7 +50,7 @@ def add_opportunity(self, opp_id, user_id, title, opp_type, deadline, priority, ''', (opp_id, user_id, title, opp_type, deadline, priority, desc, message_text, link)) def get_missed_opportunities(self, now_iso): - with self.conn.cursor(cursor_factory=RealDictCursor) as c: + with self.conn.cursor() as c: c.execute(''' SELECT user_id, opp_id, title, description, opp_type, link, deadline FROM opportunities @@ -71,7 +68,7 @@ def mark_done(self, opp_id, user_id): return c.rowcount def get_active_opportunities(self, user_id): - with self.conn.cursor(cursor_factory=RealDictCursor) as c: + with self.conn.cursor() as c: c.execute(''' SELECT opp_id, title, opp_type, deadline, priority, description FROM opportunities @@ -90,7 +87,7 @@ def archive_opportunity(self, opp_id, user_id): return c.rowcount def get_weekly_summary(self, user_id, now_iso, week_end_iso): - with self.conn.cursor(cursor_factory=RealDictCursor) as c: + with self.conn.cursor() as c: c.execute(''' SELECT COUNT(*) as count, opp_type FROM opportunities WHERE user_id = %s AND deadline >= %s AND deadline <= %s AND archived=0 AND done=0 @@ -99,7 +96,7 @@ def get_weekly_summary(self, user_id, now_iso, week_end_iso): return c.fetchall() def get_all_active_reminders(self): - with self.conn.cursor(cursor_factory=RealDictCursor) as c: + with self.conn.cursor() as c: c.execute(''' SELECT user_id, opp_id, title, deadline, priority, description, opp_type, link FROM opportunities From d46c030af188fbe4d94bab7ba05f2009383dbc7b Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:10:29 +0300 Subject: [PATCH 4/9] deps: add Flask to requirements for web framework support --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f32235..c021426 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ python-dateutil>=2.8 python-dotenv>=1.0 pillow>=10.0 pytesseract>=0.3 -psycopg2-binary \ No newline at end of file +psycopg2-binary +flask \ No newline at end of file From 3e043fe290c8dcca1d0dbe19b76e073a295376c3 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:12:27 +0300 Subject: [PATCH 5/9] feat: implement Flask web application to handle incoming webhook requests --- bot.py | 55 ++++++++++++++++++++++++++---------------------------- web_app.py | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 web_app.py diff --git a/bot.py b/bot.py index 72793b1..f3600d2 100644 --- a/bot.py +++ b/bot.py @@ -15,7 +15,7 @@ ) from telegram.ext import ( Application, CommandHandler, MessageHandler, ConversationHandler, - ContextTypes, filters, CallbackQueryHandler, JobQueue, ChatMemberHandler + ContextTypes, filters, CallbackQueryHandler, ChatMemberHandler ) # OCR dependencies @@ -523,34 +523,25 @@ async def summary(update: Update, context: ContextTypes.DEFAULT_TYPE): async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): logger.warning('Update caused error: %s', context.error) -# --- Reschedule reminders on startup --- -def reschedule_all_reminders(job_queue: JobQueue): - """Re-registers all pending reminders after a bot restart.""" - rows = db.get_all_active_reminders() - now = datetime.now() - for user_id, opp_id, title, dl_str, priority, desc, opp_type, link in rows: - try: - deadline = datetime.fromisoformat(dl_str) - if deadline > now: - schedule_reminders( - job_queue, user_id, opp_id, deadline, - priority or '', title or '', desc or '', opp_type or 'Other', link or '' - ) - except Exception as exc: - logger.error('Startup reschedule failed for %s: %s', opp_id, exc) +# # --- Reschedule reminders on startup --- +# def reschedule_all_reminders(job_queue: JobQueue): +# """Re-registers all pending reminders after a bot restart.""" +# rows = db.get_all_active_reminders() +# now = datetime.now() +# for user_id, opp_id, title, dl_str, priority, desc, opp_type, link in rows: +# try: +# deadline = datetime.fromisoformat(dl_str) +# if deadline > now: +# schedule_reminders( +# job_queue, user_id, opp_id, deadline, +# priority or '', title or '', desc or '', opp_type or 'Other', link or '' +# ) +# except Exception as exc: +# logger.error('Startup reschedule failed for %s: %s', opp_id, exc) # --- Main --- -def main(): - application = Application.builder().token(BOT_TOKEN).job_queue(JobQueue()).build() - reschedule_all_reminders(application.job_queue) - if 'missed_job' not in application.bot_data: - application.job_queue.run_repeating( - check_missed, - interval=timedelta(days=1), - first=datetime.now() + timedelta(minutes=2) - ) - application.bot_data['missed_job'] = True - +def build_application(token): + application = Application.builder().token(token).build() conv_handler = ConversationHandler( entry_points=[ MessageHandler(filters.UpdateType.MESSAGE & ~filters.COMMAND, handle_forward) @@ -579,7 +570,13 @@ def main(): application.add_error_handler(error_handler) logger.info('OppTick started.') - application.run_polling(allowed_updates=Update.ALL_TYPES) + # application.run_polling(allowed_updates=Update.ALL_TYPES) + return application if __name__ == '__main__': - main() + application = build_application(BOT_TOKEN) + application.run_polling(allowed_updates=Update.ALL_TYPES) + + + + diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..a650005 --- /dev/null +++ b/web_app.py @@ -0,0 +1,40 @@ +import asyncio +from bot import build_application +from dotenv import load_dotenv +import os +from telegram import Update +from flask import Flask, request, jsonify +load_dotenv() +BOT_TOKEN = os.getenv("BOT_TOKEN") + + +telegram_app = build_application(BOT_TOKEN) +event_loop = asyncio.get_event_loop() + + +app = Flask(__name__) + +async def _safe_process_update(data): + """Helper to safely initialize and process update""" + # Initialize implementation if needed (lazy loading) + if not telegram_app._initialized: + await telegram_app.initialize() + + update = Update.de_json(data, telegram_app.bot) + await telegram_app.process_update(update) + + + +@app.route("/webhook/", methods=["POST"], strict_slashes=True) +def bot_endpoint(): + if request.method == "POST": + # Run async code in a fresh loop for this request + # asyncio.run(_safe_process_update(request.json)) + event_loop.run_until_complete(_safe_process_update(request.json)) + return "OK", 200 + return "OK", 200 + +@app.route("/webhook/health") +def health(): + return jsonify({"status": "ok"}), 200 + From 4abfa96e9ebdfdccf6ba0722168d797390e91f69 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:43:49 +0300 Subject: [PATCH 6/9] feat: refactor configuration management and enhance webhook handling in Flask app --- bot.py | 41 ++------------------------ config.py | 29 ++++++++++++++++++ web_app.py | 86 ++++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 101 insertions(+), 55 deletions(-) create mode 100644 config.py diff --git a/bot.py b/bot.py index f3600d2..0d5743a 100644 --- a/bot.py +++ b/bot.py @@ -5,17 +5,17 @@ import io from datetime import datetime, timedelta -from dotenv import load_dotenv from dateutil.parser import parse as date_parse from db import Database +from config import BOT_TOKEN,logger, db from telegram import ( Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup ) from telegram.ext import ( Application, CommandHandler, MessageHandler, ConversationHandler, - ContextTypes, filters, CallbackQueryHandler, ChatMemberHandler + ContextTypes, filters, CallbackQueryHandler, ChatMemberHandler, JobQueue ) # OCR dependencies @@ -26,28 +26,6 @@ except ImportError: OCR_AVAILABLE = False -# Load environment - -load_dotenv() -BOT_TOKEN = os.getenv("BOT_TOKEN") -if not BOT_TOKEN: - raise ValueError("BOT_TOKEN missing! Set in .env or Railway Variables.") - -# Logging -logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO -) -logger = logging.getLogger(__name__) - - -host=os.environ["DB_HOST"] -database=os.environ["DB_NAME"] -user=os.environ["DB_USER"] -password=os.environ["DB_PASS"] -port=os.environ["DB_PORT"] -# DB setup -db = Database(host,database,user,password,port) # Conversation states DEADLINE, TYPE, PRIORITY, TITLE, DESCRIPTION, LINK, CONFIRM = range(7) @@ -523,21 +501,6 @@ async def summary(update: Update, context: ContextTypes.DEFAULT_TYPE): async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): logger.warning('Update caused error: %s', context.error) -# # --- Reschedule reminders on startup --- -# def reschedule_all_reminders(job_queue: JobQueue): -# """Re-registers all pending reminders after a bot restart.""" -# rows = db.get_all_active_reminders() -# now = datetime.now() -# for user_id, opp_id, title, dl_str, priority, desc, opp_type, link in rows: -# try: -# deadline = datetime.fromisoformat(dl_str) -# if deadline > now: -# schedule_reminders( -# job_queue, user_id, opp_id, deadline, -# priority or '', title or '', desc or '', opp_type or 'Other', link or '' -# ) -# except Exception as exc: -# logger.error('Startup reschedule failed for %s: %s', opp_id, exc) # --- Main --- def build_application(token): diff --git a/config.py b/config.py new file mode 100644 index 0000000..bc9dba6 --- /dev/null +++ b/config.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv +import logging + +from db import Database + +# Load environment variables once +load_dotenv() + +# Configuration Variables +BOT_TOKEN = os.getenv("BOT_TOKEN") +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_NAME = os.getenv("DB_NAME") +DB_USER = os.getenv("DB_USER") +DB_PASS = os.getenv("DB_PASS") +DB_PORT = os.getenv("DB_PORT", "5432") + +# Validation +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN is missing! Please set it in .env") + +# Logging Configuration +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger("OppTick") + +db = Database(DB_HOST,DB_NAME,DB_USER,DB_PASS,DB_PORT) diff --git a/web_app.py b/web_app.py index a650005..3b70f7f 100644 --- a/web_app.py +++ b/web_app.py @@ -1,40 +1,94 @@ import asyncio -from bot import build_application -from dotenv import load_dotenv import os -from telegram import Update from flask import Flask, request, jsonify -load_dotenv() -BOT_TOKEN = os.getenv("BOT_TOKEN") +from datetime import datetime +from telegram import Update, Bot +from bot import build_application +from config import BOT_TOKEN,logger, db +app = Flask(__name__) +# Initialize Telegram App +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN is missing!") telegram_app = build_application(BOT_TOKEN) -event_loop = asyncio.get_event_loop() +# Event Loop Management +try: + loop = asyncio.get_running_loop() +except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) -app = Flask(__name__) +async def vercel_cron_reminders(): + """Checks upcoming deadlines and sends reminders.""" + bot = Bot(token=BOT_TOKEN) + now = datetime.now() + try: + rows = db.get_all_active_reminders() + except Exception as e: + logger.error(f"Failed to fetch active reminders: {e}") + return + + for user_id, opp_id, title, dl_str, priority, desc, opp_type, link in rows: + try: + deadline = datetime.fromisoformat(dl_str) + days_left = (deadline - now).days + + # Reminder Logic + remind_days = [14, 7, 3, 2, 1, 0] if 'High' in priority else [7, 3, 1, 0] + + if days_left in remind_days: + short_desc = (desc[:120] + '…') if len(desc) > 120 else desc + + header_msg = f"⏰ *{days_left} day(s) left!*" if days_left > 0 else "🚨 *TODAY is the deadline!*" + link_msg = f"\n🔗 {link}" if link else "" + + msg = ( + f"{header_msg}\n\n" + f"📌 *ID:* `{opp_id}`\n" + f"🏷️ *Title:* {title}\n" + f"🗂️ *Type:* {opp_type}\n" + f"📄 *Description:* {short_desc}" + f"{link_msg}" + ) + await bot.send_message(chat_id=user_id, text=msg, parse_mode='Markdown') + except Exception as e: + logger.error(f"Failed to send reminder for {opp_id}: {e}") async def _safe_process_update(data): - """Helper to safely initialize and process update""" - # Initialize implementation if needed (lazy loading) + """Wait for bot initialization then process raw update data.""" if not telegram_app._initialized: await telegram_app.initialize() - + update = Update.de_json(data, telegram_app.bot) await telegram_app.process_update(update) - - -@app.route("/webhook/", methods=["POST"], strict_slashes=True) +@app.route("/webhook/", methods=["POST"], strict_slashes=False) def bot_endpoint(): + """Receives updates from Telegram.""" if request.method == "POST": - # Run async code in a fresh loop for this request - # asyncio.run(_safe_process_update(request.json)) - event_loop.run_until_complete(_safe_process_update(request.json)) + loop.run_until_complete(_safe_process_update(request.json)) return "OK", 200 return "OK", 200 +@app.get("/cron") +def cron_trigger(): #TODO: add token verification to now allow anyone to make this call + """Triggered by external cron service (like Vercel Cron).""" + try: + loop.run_until_complete(vercel_cron_reminders()) + return jsonify({"status": "reminders sent"}), 200 + except Exception as e: + logger.error(f"Cron job failed: {e}") + return jsonify({"status": "failed", "error": str(e)}), 500 + @app.route("/webhook/health") def health(): return jsonify({"status": "ok"}), 200 +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8000)) + app.run(host="0.0.0.0", port=port) + + + From d19a3bc693b3a0225ab1bdced00a8d9bec6b6776 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:44:16 +0300 Subject: [PATCH 7/9] feat: add Vercel configuration for deploying Flask web application --- vercel.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..ab68c9b --- /dev/null +++ b/vercel.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "builds": [ + { + "src": "web_app.py", + "use": "@vercel/python" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "web_app.py" + } + ], + "crons": [ + { + "path": "/cron", + "schedule": "30 20 * * *" + } + ] +} \ No newline at end of file From 5f56ad01c99a89bc3dc2aa51f677753f32bcdaaa Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:44:44 +0300 Subject: [PATCH 8/9] update .gitignore to handle .vercel --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1c0a637..f7b14c2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ Thumbs.db +.vercel From 711fc207e04da8253547c9d797af7060b1f06d47 Mon Sep 17 00:00:00 2001 From: Jeyididya Date: Thu, 5 Mar 2026 23:53:19 +0300 Subject: [PATCH 9/9] docs: add deployment instructions for Vercel and cron job setup --- README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 77038d0..53e3638 100644 --- a/README.md +++ b/README.md @@ -106,5 +106,37 @@ cp .env.example .env python bot.py ``` -**TODO listed in `db.py`**: -- In `Database.init_db()` there is a TODO to "find a better way to not re-init db on every call". Currently, creating a new `Database` instance triggers `init_db()` which checks and executes `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements. Ideally, this should happen once at application startup or via a standalone migration script to avoid unnecessary query overhead. +### Deployment (Vercel) + +You can deploy this bot as a serverless function on Vercel. + +1. **Install Vercel CLI**: `npm i -g vercel` +2. **Deploy**: + ```bash + vercel --prod + ``` + (You will be asked to authenticate if it's your first time.) +3. **Set Environment Variables**: + If you have a local `.env` file, Vercel can automatically import it during setup. + Alternatively, go to your **Vercel Project Settings > Environment Variables** and add: + `BOT_TOKEN`, `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS`, `DB_PORT`. + +4. **Set Telegram Webhook**: + After deployment, get your Vercel URL (e.g., `https://your-project.vercel.app`) and set the webhook: + ``` + https://api.telegram.org/bot/setWebhook?url=https://your-project.vercel.app/webhook/ + ``` + +5. **Cron Jobs (Reminders)**: + - A `vercel.json` file is configured to run a daily cron job that triggers the reminder logic. + ```js + "schedule": "30 20 * * *" + ``` + - **Note on Free Tier**: Vercel's free tier supports cron jobs but with limitations (e.g., once a day). + - Alternatively, you can use an external service like [cron-job.org](https://cron-job.org) to hit `https://your-project.vercel.app/cron` at your preferred frequency. + +**TODOs**: +- `db.py`: In `Database.init_db()` there is a TODO to "find a better way to not re-init db on every call". +- `web_app.py`: Secure the `/cron` endpoint with a secret token/API key to prevent unauthorized triggering. + +