Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
BOT_TOKEN='your-bot-token'
DB_HOST= ""
DB_NAME= ""
DB_USER=""
DB_PASS=""
DB_PORT=""
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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.
123 changes: 24 additions & 99 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import os
import logging
import sqlite3
import uuid
import re
import io
Expand All @@ -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
)
Expand All @@ -26,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.")
Expand All @@ -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["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)
Expand Down Expand Up @@ -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.')
Expand Down Expand Up @@ -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 ''
Expand All @@ -461,24 +427,17 @@ 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
await query.answer()
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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import os
import psycopg2


class Database:
_initialized = False

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() 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() 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() 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() 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()
Loading