commit f453a7917e27a7b0b66863bea530fcf276b006fa Author: leo Date: Thu Mar 5 11:40:58 2026 +0800 Initial commit: add reminderBot project structure Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ea3aa1f --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +BOT_TOKEN=your_telegram_bot_token_here +DATABASE_URL=sqlite:////app/data/reminders.db +LOG_LEVEL=INFO +DEFAULT_TIMEZONE=Asia/Shanghai diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f144b20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# 环境变量(包含敏感信息,不纳入版本控制) +.env + +# Python 缓存 +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.pyc + +# 虚拟环境 +venv/ +.venv/ +env/ +ENV/ + +# 运行时数据(动态生成,不纳入版本控制) +data/*.db +data/*.sqlite +data/*.sqlite3 + +# 日志文件(运行时生成) +logs/*.log +logs/*.log.* + +# IDE / 编辑器 +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# macOS +.DS_Store + +# 测试覆盖率 +.coverage +htmlcov/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4baf1f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot/ ./bot/ + +RUN mkdir -p /app/data /app/logs + +CMD ["python", "-m", "bot.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..97ed212 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Telegram 提醒机器人 + +一个功能完善的 Telegram 提醒机器人,支持多用户、多种提醒类型和中国节假日规避。 + +## 功能特性 + +- **多种提醒类型**:一次性 / 每日 / 每周 / 间隔重复 +- **时间窗口控制**:间隔提醒支持设置活跃时间段(如 09:00–22:00) +- **中国节假日规避**:可选跳过节假日 +- **交互式操作**:内联按钮支持完成、延期、暂停、删除 +- **多用户隔离**:每位用户独立管理自己的提醒 +- **Docker 部署**:容器化部署,数据持久化 + +## 快速开始 + +### 1. 获取 Bot Token + +向 [@BotFather](https://t.me/BotFather) 发送 `/newbot` 命令获取 Token。 + +### 2. 配置环境变量 + +```bash +cp .env.example .env +# 编辑 .env,填写 BOT_TOKEN +``` + +### 3. 启动 + +**Docker 方式(推荐)**: +```bash +docker-compose up -d +``` + +**本地运行**: +```bash +pip install -r requirements.txt +python -m bot.main +``` + +## 使用说明 + +### 命令 + +| 命令 | 说明 | +|------|------| +| `/start` | 显示欢迎信息和主菜单 | +| `/new` | 新建提醒 | +| `/list` | 查看我的提醒 | +| `/help` | 帮助信息 | + +### 创建提醒 + +1. 发送 `/new` 或点击 **➕ 新建提醒** +2. 选择提醒类型 +3. 输入标题(必填)和描述(可选) +4. 根据类型输入时间参数 +5. 选择是否跳过节假日 +6. 确认创建 + +### 提醒类型 + +| 类型 | 说明 | 时间格式示例 | +|------|------|-------------| +| 一次性 | 在指定日期时间提醒一次 | `2026-06-01 09:00` | +| 每日 | 每天固定时间提醒 | `09:00` | +| 每周 | 每周指定星期几提醒(1=周一,7=周日)| `1,3,5` 然后 `09:00` | +| 间隔 | 在时间窗口内每 N 分钟提醒一次 | 间隔 `30`,窗口 `09:00-22:00` | + +### 提醒操作 + +收到提醒消息后,可点击以下按钮: + +- ✅ **完成**:标记为完成,停止后续提醒 +- ⏰ **延期10分钟**:10分钟后再次提醒 +- ⏸ **暂停**:暂停提醒(可在列表中恢复) +- 🗑 **删除**:永久删除提醒 + +## 项目结构 + +``` +reminderBot/ +├── bot/ +│ ├── main.py # 应用入口 +│ ├── config.py # 配置管理 +│ ├── states.py # 会话状态常量 +│ ├── handlers/ # 命令处理器 +│ │ ├── start.py # /start 欢迎 +│ │ ├── reminder.py # 创建提醒向导 +│ │ ├── list.py # 提醒列表 +│ │ └── callback.py # 内联按钮回调 +│ ├── models/ # 数据模型 +│ │ ├── database.py # 数据库连接 +│ │ ├── user.py # 用户模型 +│ │ └── reminder.py # 提醒模型 +│ ├── scheduler/ # 调度器 +│ │ ├── job_manager.py # 任务管理 +│ │ └── executor.py # 执行器 +│ └── utils/ # 工具函数 +│ ├── holiday.py # 节假日判断 +│ └── keyboards.py # 键盘布局 +├── data/ # SQLite 数据库(持久化卷) +├── logs/ # 日志(持久化卷) +├── .env.example +├── requirements.txt +├── Dockerfile +└── docker-compose.yml +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `BOT_TOKEN` | Telegram Bot Token | 必填 | +| `DATABASE_URL` | SQLite 数据库路径 | `sqlite:////app/data/reminders.db` | +| `LOG_LEVEL` | 日志级别 | `INFO` | +| `DEFAULT_TIMEZONE` | 默认时区 | `Asia/Shanghai` | + +## 技术栈 + +- **Python 3.11** +- **python-telegram-bot 20.7** — Bot 框架 +- **APScheduler 3.10** — 任务调度 +- **SQLAlchemy 2.0** — ORM +- **chinese-calendar** — 中国节假日判断 +- **Docker** — 容器化部署 diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..cdaadf7 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,12 @@ +from bot.models import init_db, Session, User, Reminder, ReminderLog +from bot.scheduler import init_scheduler, shutdown_scheduler + +__all__ = [ + "init_db", + "Session", + "User", + "Reminder", + "ReminderLog", + "init_scheduler", + "shutdown_scheduler", +] diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..162fd1c --- /dev/null +++ b/bot/config.py @@ -0,0 +1,9 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +BOT_TOKEN: str = os.environ["BOT_TOKEN"] +DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////app/data/reminders.db") +LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") +DEFAULT_TIMEZONE: str = os.getenv("DEFAULT_TIMEZONE", "Asia/Shanghai") diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..c872ad9 --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,12 @@ +from bot.handlers.start import start, help_command +from bot.handlers.reminder import reminder_conv_handler +from bot.handlers.list import list_reminders +from bot.handlers.callback import handle_callback + +__all__ = [ + "start", + "help_command", + "reminder_conv_handler", + "list_reminders", + "handle_callback", +] diff --git a/bot/handlers/callback.py b/bot/handlers/callback.py new file mode 100644 index 0000000..97c9c46 --- /dev/null +++ b/bot/handlers/callback.py @@ -0,0 +1,68 @@ +from datetime import datetime, timedelta, timezone + +from telegram import Update +from telegram.ext import ContextTypes + +from bot.models.database import Session +from bot.models.reminder import Reminder +from bot.scheduler.job_manager import add_reminder_job, remove_reminder_job + + +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + + data = query.data + action, reminder_id_str = data.split("_", 1) + reminder_id = int(reminder_id_str) + + session = Session() + try: + reminder = session.get(Reminder, reminder_id) + if reminder is None: + await query.edit_message_text("提醒不存在或已被删除。") + return + + if action == "done": + reminder.is_active = False + session.commit() + remove_reminder_job(reminder_id) + await query.edit_message_text(f"✅ 已完成提醒:{reminder.title}") + + elif action == "snooze": + # Snooze for 10 minutes - send another reminder + from bot.scheduler.executor import execute_reminder + + snooze_time = datetime.now(timezone.utc) + timedelta(minutes=10) + context.job_queue.run_once( + lambda ctx: execute_reminder(reminder_id, ctx.bot), + when=snooze_time, + ) + await query.edit_message_text( + f"⏰ 已延期10分钟,将在 {snooze_time.strftime('%H:%M')} 再次提醒。" + ) + + elif action == "pause": + reminder.is_active = False + session.commit() + remove_reminder_job(reminder_id) + await query.edit_message_text(f"⏸ 已暂停提醒:{reminder.title}") + + elif action == "resume": + reminder.is_active = True + session.commit() + add_reminder_job(reminder_id) + await query.edit_message_text(f"▶️ 已恢复提醒:{reminder.title}") + + elif action == "delete": + title = reminder.title + session.delete(reminder) + session.commit() + remove_reminder_job(reminder_id) + await query.edit_message_text(f"🗑 已删除提醒:{title}") + + except Exception: + session.rollback() + await query.edit_message_text("操作失败,请稍后重试。") + finally: + Session.remove() diff --git a/bot/handlers/list.py b/bot/handlers/list.py new file mode 100644 index 0000000..c38c06a --- /dev/null +++ b/bot/handlers/list.py @@ -0,0 +1,49 @@ +from telegram import Update +from telegram.ext import ContextTypes + +from bot.models.database import Session +from bot.models.reminder import Reminder +from bot.models.user import User +from bot.utils.keyboards import list_item_keyboard + + +async def list_reminders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + session = Session() + try: + user_obj = User.get_or_create(session, user.id, user.username) + reminders = Reminder.get_user_reminders(session, user_obj.id) + + if not reminders: + await update.message.reply_text( + "你还没有创建任何提醒。\n发送 /new 创建第一个提醒吧!" + ) + return + + await update.message.reply_text(f"📋 *你的提醒列表*(共 {len(reminders)} 个)", parse_mode="Markdown") + + for reminder in reminders: + status = "✅ 活跃" if reminder.is_active else "⏸ 已暂停" + text = ( + f"*{_escape(reminder.title)}*\n" + f"类型:{reminder.type_display()}\n" + f"时间:{reminder.schedule_summary()}\n" + f"跳过节假日:{'是' if reminder.skip_holidays else '否'}\n" + f"状态:{status}" + ) + if reminder.description: + text += f"\n描述:{_escape(reminder.description)}" + + await update.message.reply_text( + text, + parse_mode="Markdown", + reply_markup=list_item_keyboard(reminder.id, reminder.is_active), + ) + finally: + Session.remove() + + +def _escape(text: str) -> str: + for ch in ["_", "*", "`", "["]: + text = text.replace(ch, f"\\{ch}") + return text diff --git a/bot/handlers/reminder.py b/bot/handlers/reminder.py new file mode 100644 index 0000000..a310697 --- /dev/null +++ b/bot/handlers/reminder.py @@ -0,0 +1,294 @@ +from datetime import datetime, timezone + +import pytz +from telegram import ReplyKeyboardRemove, Update +from telegram.ext import ( + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +from bot.models.database import Session +from bot.models.reminder import Reminder +from bot.models.user import User +from bot.scheduler.job_manager import add_reminder_job +from bot.states import ( + CHOOSE_TYPE, + CONFIRM, + INPUT_DESC, + INPUT_HOLIDAY, + INPUT_INTERVAL_MINUTES, + INPUT_INTERVAL_WINDOW, + INPUT_TIME, + INPUT_TITLE, + INPUT_WEEKLY_DAYS, +) +from bot.utils.keyboards import ( + confirm_keyboard, + main_keyboard, + reminder_type_keyboard, + yes_no_keyboard, +) + + +async def new_reminder(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.clear() + await update.message.reply_text( + "让我们创建一个新提醒!\n\n请选择提醒类型:", + reply_markup=reminder_type_keyboard(), + ) + return CHOOSE_TYPE + + +async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = update.message.text.strip() + if text == "取消": + return await cancel(update, context) + + type_map = {"一次性": "once", "每日": "daily", "每周": "weekly", "间隔": "interval"} + reminder_type = type_map.get(text) + + if reminder_type is None: + await update.message.reply_text("请选择有效的提醒类型。") + return CHOOSE_TYPE + + context.user_data["reminder_type"] = reminder_type + await update.message.reply_text( + "请输入提醒标题(简短描述):", reply_markup=ReplyKeyboardRemove() + ) + return INPUT_TITLE + + +async def input_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + title = update.message.text.strip() + if not title: + await update.message.reply_text("标题不能为空,请重新输入:") + return INPUT_TITLE + + context.user_data["title"] = title + await update.message.reply_text("请输入提醒描述(可选,直接发送"跳过"):") + return INPUT_DESC + + +async def input_desc(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = update.message.text.strip() + if text and text != "跳过": + context.user_data["description"] = text + + reminder_type = context.user_data["reminder_type"] + + if reminder_type == "once": + await update.message.reply_text( + "请输入提醒时间(格式:YYYY-MM-DD HH:MM)\n例如:2026-03-10 14:30" + ) + elif reminder_type == "daily": + await update.message.reply_text("请输入每日提醒时间(格式:HH:MM)\n例如:09:00") + elif reminder_type == "weekly": + await update.message.reply_text( + "请输入星期几提醒(用逗号分隔,周一=1,周日=7)\n例如:1,3,5 表示周一、周三、周五" + ) + elif reminder_type == "interval": + await update.message.reply_text("请输入间隔分钟数(例如:30 表示每30分钟)") + + return INPUT_TIME + + +async def input_time(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = update.message.text.strip() + reminder_type = context.user_data["reminder_type"] + tz = pytz.timezone("Asia/Shanghai") + + try: + if reminder_type == "once": + dt = datetime.strptime(text, "%Y-%m-%d %H:%M") + dt_aware = tz.localize(dt) + if dt_aware <= datetime.now(timezone.utc).astimezone(tz): + await update.message.reply_text("时间必须是未来时间,请重新输入:") + return INPUT_TIME + context.user_data["once_time"] = dt_aware.astimezone(timezone.utc) + return await ask_holiday(update, context) + + elif reminder_type == "daily": + h, m = map(int, text.split(":")) + if not (0 <= h < 24 and 0 <= m < 60): + raise ValueError + context.user_data["daily_time"] = f"{h:02d}:{m:02d}" + return await ask_holiday(update, context) + + elif reminder_type == "weekly": + days = [int(d.strip()) for d in text.split(",")] + if not all(1 <= d <= 7 for d in days): + raise ValueError + # Convert to APScheduler format (Mon=0, Sun=6) + context.user_data["weekly_days"] = ",".join(str((d - 1) % 7) for d in days) + await update.message.reply_text("请输入提醒时间(格式:HH:MM)\n例如:09:00") + return INPUT_TIME # stay in INPUT_TIME for time input + + elif reminder_type == "interval": + minutes = int(text) + if minutes <= 0: + raise ValueError + context.user_data["interval_minutes"] = minutes + await update.message.reply_text( + "请输入时间窗口(格式:HH:MM-HH:MM)\n例如:09:00-22:00" + ) + return INPUT_INTERVAL_WINDOW + + except Exception: + await update.message.reply_text("格式错误,请重新输入:") + return INPUT_TIME + + # For weekly after time input + if reminder_type == "weekly" and "daily_time" in context.user_data: + return await ask_holiday(update, context) + + return INPUT_TIME + + +async def input_interval_window( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> int: + text = update.message.text.strip() + try: + start, end = text.split("-") + sh, sm = map(int, start.strip().split(":")) + eh, em = map(int, end.strip().split(":")) + if not (0 <= sh < 24 and 0 <= sm < 60 and 0 <= eh < 24 and 0 <= em < 60): + raise ValueError + context.user_data["interval_start_time"] = f"{sh:02d}:{sm:02d}" + context.user_data["interval_end_time"] = f"{eh:02d}:{em:02d}" + return await ask_holiday(update, context) + except Exception: + await update.message.reply_text("格式错误,请重新输入(例如:09:00-22:00):") + return INPUT_INTERVAL_WINDOW + + +async def ask_holiday(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.message.reply_text( + "是否在中国节假日跳过提醒?", reply_markup=yes_no_keyboard() + ) + return INPUT_HOLIDAY + + +async def input_holiday(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = update.message.text.strip() + if text == "取消": + return await cancel(update, context) + + context.user_data["skip_holidays"] = text == "是" + + # Show summary + summary = _build_summary(context.user_data) + await update.message.reply_text( + f"请确认提醒信息:\n\n{summary}", reply_markup=confirm_keyboard() + ) + return CONFIRM + + +async def confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = update.message.text.strip() + if text == "❌ 取消": + return await cancel(update, context) + + if text != "✅ 确认创建": + await update.message.reply_text("请点击按钮确认或取消。") + return CONFIRM + + # Save to database + user = update.effective_user + session = Session() + try: + user_obj = User.get_or_create(session, user.id, user.username) + + reminder = Reminder( + user_id=user_obj.id, + title=context.user_data["title"], + description=context.user_data.get("description"), + reminder_type=context.user_data["reminder_type"], + once_time=context.user_data.get("once_time"), + daily_time=context.user_data.get("daily_time"), + weekly_days=context.user_data.get("weekly_days"), + interval_minutes=context.user_data.get("interval_minutes"), + interval_start_time=context.user_data.get("interval_start_time"), + interval_end_time=context.user_data.get("interval_end_time"), + skip_holidays=context.user_data.get("skip_holidays", False), + is_active=True, + ) + session.add(reminder) + session.commit() + reminder_id = reminder.id + + # Schedule the job + add_reminder_job(reminder_id) + + await update.message.reply_text( + "✅ 提醒创建成功!", reply_markup=main_keyboard() + ) + except Exception: + session.rollback() + await update.message.reply_text( + "❌ 创建失败,请稍后重试。", reply_markup=main_keyboard() + ) + finally: + Session.remove() + + context.user_data.clear() + return ConversationHandler.END + + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.clear() + await update.message.reply_text("已取消创建提醒。", reply_markup=main_keyboard()) + return ConversationHandler.END + + +def _build_summary(data: dict) -> str: + lines = [f"标题:{data['title']}"] + if data.get("description"): + lines.append(f"描述:{data['description']}") + + rtype = data["reminder_type"] + type_names = {"once": "一次性", "daily": "每日", "weekly": "每周", "interval": "间隔"} + lines.append(f"类型:{type_names[rtype]}") + + if rtype == "once": + dt = data["once_time"] + tz = pytz.timezone("Asia/Shanghai") + lines.append(f"时间:{dt.astimezone(tz).strftime('%Y-%m-%d %H:%M')}") + elif rtype == "daily": + lines.append(f"时间:每天 {data['daily_time']}") + elif rtype == "weekly": + day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + days = ", ".join(day_names[int(d)] for d in data["weekly_days"].split(",")) + lines.append(f"时间:{days} {data['daily_time']}") + elif rtype == "interval": + lines.append( + f"间隔:每 {data['interval_minutes']} 分钟\n" + f"时间窗口:{data['interval_start_time']} - {data['interval_end_time']}" + ) + + lines.append(f"跳过节假日:{'是' if data.get('skip_holidays') else '否'}") + return "\n".join(lines) + + +# Build the ConversationHandler +reminder_conv_handler = ConversationHandler( + entry_points=[ + CommandHandler("new", new_reminder), + MessageHandler(filters.Regex("^➕ 新建提醒$"), new_reminder), + ], + states={ + CHOOSE_TYPE: [MessageHandler(filters.TEXT & ~filters.COMMAND, choose_type)], + INPUT_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_title)], + INPUT_DESC: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_desc)], + INPUT_TIME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_time)], + INPUT_INTERVAL_WINDOW: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, input_interval_window) + ], + INPUT_HOLIDAY: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_holiday)], + CONFIRM: [MessageHandler(filters.TEXT & ~filters.COMMAND, confirm)], + }, + fallbacks=[CommandHandler("cancel", cancel)], +) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..7e91968 --- /dev/null +++ b/bot/handlers/start.py @@ -0,0 +1,51 @@ +from telegram import Update +from telegram.ext import ContextTypes + +from bot.models.database import Session +from bot.models.user import User +from bot.utils.keyboards import main_keyboard + +WELCOME_TEXT = ( + "👋 *欢迎使用提醒机器人!*\n\n" + "我可以帮你管理各类提醒,支持:\n" + "• 一次性提醒\n" + "• 每日定时提醒\n" + "• 每周指定日期提醒\n" + "• 按间隔重复提醒(支持时间窗口)\n" + "• 自动跳过中国节假日\n\n" + "使用下方按钮或发送 /new 开始创建第一个提醒吧!" +) + +HELP_TEXT = ( + "*命令列表*\n\n" + "/start — 显示欢迎信息\n" + "/new — 新建提醒\n" + "/list — 查看我的提醒\n" + "/help — 显示帮助\n\n" + "*提醒类型说明*\n\n" + "• *一次性*:在指定日期时间提醒一次\n" + "• *每日*:每天在固定时间提醒\n" + "• *每周*:每周指定星期几在固定时间提醒\n" + "• *间隔*:在指定时间段内每隔 N 分钟提醒一次\n\n" + "收到提醒后可点击按钮:\n" + "✅ 完成 | ⏰ 延期10分钟 | ⏸ 暂停 | 🗑 删除" +) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + session = Session() + try: + User.get_or_create(session, user.id, user.username) + finally: + Session.remove() + + await update.message.reply_text( + WELCOME_TEXT, + parse_mode="Markdown", + reply_markup=main_keyboard(), + ) + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.message.reply_text(HELP_TEXT, parse_mode="Markdown") diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..1499ea6 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,69 @@ +import logging +import signal +import sys + +from telegram.ext import ( + Application, + CallbackQueryHandler, + CommandHandler, + MessageHandler, + filters, +) + +from bot.config import BOT_TOKEN, LOG_LEVEL +from bot.handlers import ( + handle_callback, + help_command, + list_reminders, + reminder_conv_handler, + start, +) +from bot.models import init_db +from bot.scheduler import init_scheduler, shutdown_scheduler + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=getattr(logging, LOG_LEVEL, logging.INFO), +) +logger = logging.getLogger(__name__) + + +def main() -> None: + # Initialize database + init_db() + logger.info("Database initialized") + + # Build application + app = Application.builder().token(BOT_TOKEN).build() + + # Initialize scheduler + init_scheduler(app.bot) + + # Register handlers + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(MessageHandler(filters.Regex("^❓ 帮助$"), help_command)) + + app.add_handler(reminder_conv_handler) + + app.add_handler(CommandHandler("list", list_reminders)) + app.add_handler(MessageHandler(filters.Regex("^📋 我的提醒$"), list_reminders)) + + app.add_handler(CallbackQueryHandler(handle_callback)) + + # Graceful shutdown + def signal_handler(sig, frame): + logger.info("Shutting down...") + shutdown_scheduler() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Start bot + logger.info("Bot started") + app.run_polling(allowed_updates=["message", "callback_query"]) + + +if __name__ == "__main__": + main() diff --git a/bot/models/__init__.py b/bot/models/__init__.py new file mode 100644 index 0000000..c52a427 --- /dev/null +++ b/bot/models/__init__.py @@ -0,0 +1,5 @@ +from bot.models.database import Base, Session, init_db +from bot.models.user import User +from bot.models.reminder import Reminder, ReminderLog + +__all__ = ["Base", "Session", "init_db", "User", "Reminder", "ReminderLog"] diff --git a/bot/models/database.py b/bot/models/database.py new file mode 100644 index 0000000..5316d68 --- /dev/null +++ b/bot/models/database.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, scoped_session, sessionmaker + +from bot.config import DATABASE_URL + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}, +) +SessionFactory = sessionmaker(bind=engine) +Session = scoped_session(SessionFactory) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + from bot.models.user import User # noqa: F401 + from bot.models.reminder import Reminder, ReminderLog # noqa: F401 + + Base.metadata.create_all(engine) diff --git a/bot/models/reminder.py b/bot/models/reminder.py new file mode 100644 index 0000000..c2d2ca5 --- /dev/null +++ b/bot/models/reminder.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, Session, mapped_column, relationship + +from bot.models.database import Base + + +class Reminder(Base): + __tablename__ = "reminders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + + title: Mapped[str] = mapped_column(String(256), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + + # once / daily / weekly / monthly / interval + reminder_type: Mapped[str] = mapped_column(String(32), nullable=False) + + once_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + daily_time: Mapped[Optional[str]] = mapped_column(String(5)) # "HH:MM" + weekly_days: Mapped[Optional[str]] = mapped_column(String(32)) # "0,1,2" (Mon=0) + monthly_day: Mapped[Optional[int]] = mapped_column(Integer) + interval_minutes: Mapped[Optional[int]] = mapped_column(Integer) + interval_start_time: Mapped[Optional[str]] = mapped_column(String(5)) # "HH:MM" + interval_end_time: Mapped[Optional[str]] = mapped_column(String(5)) # "HH:MM" + + skip_holidays: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + next_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + logs: Mapped[List["ReminderLog"]] = relationship( + "ReminderLog", back_populates="reminder", cascade="all, delete-orphan" + ) + + @classmethod + def get_active(cls, session: Session) -> List["Reminder"]: + return session.query(cls).filter_by(is_active=True).all() + + @classmethod + def get_user_reminders(cls, session: Session, user_id: int) -> List["Reminder"]: + return session.query(cls).filter_by(user_id=user_id).order_by(cls.created_at).all() + + def type_display(self) -> str: + mapping = { + "once": "一次性", + "daily": "每日", + "weekly": "每周", + "monthly": "每月", + "interval": "间隔", + } + return mapping.get(self.reminder_type, self.reminder_type) + + def schedule_summary(self) -> str: + if self.reminder_type == "once" and self.once_time: + return self.once_time.strftime("%Y-%m-%d %H:%M") + if self.reminder_type == "daily" and self.daily_time: + return f"每天 {self.daily_time}" + if self.reminder_type == "weekly" and self.weekly_days and self.daily_time: + day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + days = ", ".join(day_names[int(d)] for d in self.weekly_days.split(",")) + return f"{days} {self.daily_time}" + if self.reminder_type == "interval" and self.interval_minutes: + return ( + f"每 {self.interval_minutes} 分钟" + f"({self.interval_start_time}–{self.interval_end_time})" + ) + return "—" + + +class ReminderLog(Base): + __tablename__ = "reminder_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + reminder_id: Mapped[int] = mapped_column( + Integer, ForeignKey("reminders.id"), nullable=False + ) + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + # sent / snoozed / completed / failed + status: Mapped[str] = mapped_column(String(32), default="sent") + + reminder: Mapped["Reminder"] = relationship("Reminder", back_populates="logs") diff --git a/bot/models/user.py b/bot/models/user.py new file mode 100644 index 0000000..06b5ee1 --- /dev/null +++ b/bot/models/user.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, Session, mapped_column + +from bot.models.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) + username: Mapped[str | None] = mapped_column(String(128)) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + @classmethod + def get_or_create( + cls, session: Session, telegram_id: int, username: str | None = None + ) -> "User": + user = session.query(cls).filter_by(telegram_id=telegram_id).first() + if user is None: + user = cls(telegram_id=telegram_id, username=username) + session.add(user) + session.commit() + elif username and user.username != username: + user.username = username + user.updated_at = datetime.now(timezone.utc) + session.commit() + return user diff --git a/bot/scheduler/__init__.py b/bot/scheduler/__init__.py new file mode 100644 index 0000000..99af831 --- /dev/null +++ b/bot/scheduler/__init__.py @@ -0,0 +1,10 @@ +from bot.scheduler.job_manager import init_scheduler, shutdown_scheduler, add_reminder_job, remove_reminder_job +from bot.scheduler.executor import execute_reminder + +__all__ = [ + "init_scheduler", + "shutdown_scheduler", + "add_reminder_job", + "remove_reminder_job", + "execute_reminder", +] diff --git a/bot/scheduler/executor.py b/bot/scheduler/executor.py new file mode 100644 index 0000000..9681de7 --- /dev/null +++ b/bot/scheduler/executor.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +import pytz +from telegram import Bot + +from bot.models.database import Session +from bot.models.reminder import Reminder, ReminderLog +from bot.utils.holiday import is_holiday +from bot.utils.keyboards import reminder_action_keyboard + +logger = logging.getLogger(__name__) + + +async def execute_reminder(reminder_id: int, bot: Bot) -> None: + """Send a reminder message. Called by APScheduler.""" + session = Session() + try: + reminder = session.get(Reminder, reminder_id) + if reminder is None or not reminder.is_active: + return + + now_utc = datetime.now(timezone.utc) + tz = pytz.timezone("Asia/Shanghai") + now_local = now_utc.astimezone(tz) + + # Skip on holidays if requested + if reminder.skip_holidays and is_holiday(now_local.date()): + logger.info("Skipping reminder %d on holiday", reminder_id) + return + + # For interval reminders, check time window + if reminder.reminder_type == "interval": + if not _in_window(now_local, reminder.interval_start_time, reminder.interval_end_time): + logger.debug("Reminder %d outside time window, skipping", reminder_id) + return + + # Build message + user = reminder.logs # just to load relationship lazily – not needed here + text = _build_message(reminder) + keyboard = reminder_action_keyboard(reminder.id) + + # Fetch the telegram_id from users table + from bot.models.user import User # avoid circular import at module level + user_obj = session.get(User, reminder.user_id) + if user_obj is None: + return + + await bot.send_message( + chat_id=user_obj.telegram_id, + text=text, + parse_mode="Markdown", + reply_markup=keyboard, + ) + + # Update reminder timestamps + reminder.last_sent_at = now_utc + log = ReminderLog(reminder_id=reminder.id, sent_at=now_utc, status="sent") + session.add(log) + + # Deactivate one-time reminders after firing + if reminder.reminder_type == "once": + reminder.is_active = False + + session.commit() + logger.info("Sent reminder %d to user %d", reminder_id, user_obj.telegram_id) + + except Exception: + logger.exception("Error executing reminder %d", reminder_id) + session.rollback() + finally: + Session.remove() + + +def _in_window(now_local: datetime, start: str | None, end: str | None) -> bool: + if not start or not end: + return True + sh, sm = map(int, start.split(":")) + eh, em = map(int, end.split(":")) + current_minutes = now_local.hour * 60 + now_local.minute + start_minutes = sh * 60 + sm + end_minutes = eh * 60 + em + return start_minutes <= current_minutes <= end_minutes + + +def _build_message(reminder: Reminder) -> str: + lines = [f"⏰ *{_escape(reminder.title)}*"] + if reminder.description: + lines.append(f"\n{_escape(reminder.description)}") + lines.append(f"\n_类型:{reminder.type_display()} | {reminder.schedule_summary()}_") + return "\n".join(lines) + + +def _escape(text: str) -> str: + """Escape Markdown v1 special chars.""" + for ch in ["_", "*", "`", "["]: + text = text.replace(ch, f"\\{ch}") + return text diff --git a/bot/scheduler/job_manager.py b/bot/scheduler/job_manager.py new file mode 100644 index 0000000..8bded49 --- /dev/null +++ b/bot/scheduler/job_manager.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Optional + +import pytz +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger +from telegram import Bot + +from bot.models.database import Session +from bot.models.reminder import Reminder +from bot.scheduler.executor import execute_reminder + +logger = logging.getLogger(__name__) + +_scheduler: Optional[AsyncIOScheduler] = None +_bot: Optional[Bot] = None + +SHANGHAI_TZ = pytz.timezone("Asia/Shanghai") + + +def init_scheduler(bot: Bot) -> AsyncIOScheduler: + global _scheduler, _bot + _bot = bot + _scheduler = AsyncIOScheduler(timezone=SHANGHAI_TZ) + _load_all_reminders() + _scheduler.start() + logger.info("Scheduler started") + return _scheduler + + +def shutdown_scheduler() -> None: + if _scheduler and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Scheduler stopped") + + +def _load_all_reminders() -> None: + session = Session() + try: + reminders = Reminder.get_active(session) + for reminder in reminders: + _add_job(reminder) + logger.info("Loaded %d active reminders", len(reminders)) + finally: + Session.remove() + + +def add_reminder_job(reminder_id: int) -> None: + session = Session() + try: + reminder = session.get(Reminder, reminder_id) + if reminder: + _add_job(reminder) + finally: + Session.remove() + + +def remove_reminder_job(reminder_id: int) -> None: + if _scheduler is None: + return + job_id = f"reminder_{reminder_id}" + if _scheduler.get_job(job_id): + _scheduler.remove_job(job_id) + logger.info("Removed job %s", job_id) + + +def _add_job(reminder: Reminder) -> None: + if _scheduler is None or _bot is None: + return + + job_id = f"reminder_{reminder.id}" + # Remove existing job if any + if _scheduler.get_job(job_id): + _scheduler.remove_job(job_id) + + trigger = _build_trigger(reminder) + if trigger is None: + return + + _scheduler.add_job( + execute_reminder, + trigger=trigger, + id=job_id, + kwargs={"reminder_id": reminder.id, "bot": _bot}, + replace_existing=True, + misfire_grace_time=60, + ) + logger.info("Scheduled job %s (type=%s)", job_id, reminder.reminder_type) + + +def _build_trigger(reminder: Reminder): + rtype = reminder.reminder_type + + if rtype == "once": + if reminder.once_time is None: + return None + run_time = reminder.once_time + if run_time.tzinfo is None: + run_time = SHANGHAI_TZ.localize(run_time) + if run_time <= datetime.now(timezone.utc).astimezone(SHANGHAI_TZ): + return None # already passed + return DateTrigger(run_date=run_time, timezone=SHANGHAI_TZ) + + if rtype == "daily": + if not reminder.daily_time: + return None + h, m = map(int, reminder.daily_time.split(":")) + return CronTrigger(hour=h, minute=m, timezone=SHANGHAI_TZ) + + if rtype == "weekly": + if not reminder.weekly_days or not reminder.daily_time: + return None + h, m = map(int, reminder.daily_time.split(":")) + dow = reminder.weekly_days # e.g. "0,2,4" + return CronTrigger(day_of_week=dow, hour=h, minute=m, timezone=SHANGHAI_TZ) + + if rtype == "interval": + if not reminder.interval_minutes: + return None + return IntervalTrigger(minutes=reminder.interval_minutes, timezone=SHANGHAI_TZ) + + return None diff --git a/bot/states.py b/bot/states.py new file mode 100644 index 0000000..c3adb11 --- /dev/null +++ b/bot/states.py @@ -0,0 +1,10 @@ +# ConversationHandler states for creating a reminder +CHOOSE_TYPE = 0 +INPUT_TITLE = 1 +INPUT_DESC = 2 +INPUT_TIME = 3 +INPUT_WEEKLY_DAYS = 4 +INPUT_INTERVAL_MINUTES = 5 +INPUT_INTERVAL_WINDOW = 6 +INPUT_HOLIDAY = 7 +CONFIRM = 8 diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..72951ab --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,19 @@ +from bot.utils.holiday import is_holiday +from bot.utils.keyboards import ( + main_keyboard, + reminder_type_keyboard, + yes_no_keyboard, + confirm_keyboard, + reminder_action_keyboard, + list_item_keyboard, +) + +__all__ = [ + "is_holiday", + "main_keyboard", + "reminder_type_keyboard", + "yes_no_keyboard", + "confirm_keyboard", + "reminder_action_keyboard", + "list_item_keyboard", +] diff --git a/bot/utils/holiday.py b/bot/utils/holiday.py new file mode 100644 index 0000000..dc846ab --- /dev/null +++ b/bot/utils/holiday.py @@ -0,0 +1,12 @@ +from datetime import date + +try: + import chinese_calendar as calendar + + def is_holiday(d: date) -> bool: + return calendar.is_holiday(d) + +except Exception: + # Fallback: treat weekends as holidays if library unavailable + def is_holiday(d: date) -> bool: # type: ignore[misc] + return d.weekday() >= 5 diff --git a/bot/utils/keyboards.py b/bot/utils/keyboards.py new file mode 100644 index 0000000..29332a2 --- /dev/null +++ b/bot/utils/keyboards.py @@ -0,0 +1,73 @@ +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup + + +def main_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + [ + ["➕ 新建提醒", "📋 我的提醒"], + ["❓ 帮助"], + ], + resize_keyboard=True, + ) + + +def reminder_type_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + [ + ["一次性", "每日"], + ["每周", "间隔"], + ["取消"], + ], + resize_keyboard=True, + one_time_keyboard=True, + ) + + +def yes_no_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + [["是", "否"], ["取消"]], + resize_keyboard=True, + one_time_keyboard=True, + ) + + +def confirm_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + [["✅ 确认创建", "❌ 取消"]], + resize_keyboard=True, + one_time_keyboard=True, + ) + + +def reminder_action_keyboard(reminder_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("✅ 完成", callback_data=f"done_{reminder_id}"), + InlineKeyboardButton( + "⏰ 延期10分钟", callback_data=f"snooze_{reminder_id}" + ), + ], + [ + InlineKeyboardButton("⏸ 暂停", callback_data=f"pause_{reminder_id}"), + InlineKeyboardButton("🗑 删除", callback_data=f"delete_{reminder_id}"), + ], + ] + ) + + +def list_item_keyboard(reminder_id: int, is_active: bool) -> InlineKeyboardMarkup: + toggle_label = "⏸ 暂停" if is_active else "▶️ 启用" + toggle_action = "pause" if is_active else "resume" + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + toggle_label, callback_data=f"{toggle_action}_{reminder_id}" + ), + InlineKeyboardButton( + "🗑 删除", callback_data=f"delete_{reminder_id}" + ), + ] + ] + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a07952 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" + +services: + reminderbot: + build: . + container_name: reminder_bot + restart: unless-stopped + env_file: + - .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - TZ=Asia/Shanghai diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c150d7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-telegram-bot==20.7 +APScheduler==3.10.4 +SQLAlchemy==2.0.23 +chinese-calendar==1.9.0 +python-dotenv==1.0.0 +pytz==2023.3