Initial commit: add reminderBot project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
leo 2026-03-05 11:40:58 +08:00
commit f453a7917e
25 changed files with 1286 additions and 0 deletions

4
.env.example Normal file
View File

@ -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

39
.gitignore vendored Normal file
View File

@ -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/

15
Dockerfile Normal file
View File

@ -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"]

125
README.md Normal file
View File

@ -0,0 +1,125 @@
# Telegram 提醒机器人
一个功能完善的 Telegram 提醒机器人,支持多用户、多种提醒类型和中国节假日规避。
## 功能特性
- **多种提醒类型**:一次性 / 每日 / 每周 / 间隔重复
- **时间窗口控制**:间隔提醒支持设置活跃时间段(如 09:0022: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** — 容器化部署

12
bot/__init__.py Normal file
View File

@ -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",
]

9
bot/config.py Normal file
View File

@ -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")

12
bot/handlers/__init__.py Normal file
View File

@ -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",
]

68
bot/handlers/callback.py Normal file
View File

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

49
bot/handlers/list.py Normal file
View File

@ -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

294
bot/handlers/reminder.py Normal file
View File

@ -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)],
)

51
bot/handlers/start.py Normal file
View File

@ -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")

69
bot/main.py Normal file
View File

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

5
bot/models/__init__.py Normal file
View File

@ -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"]

22
bot/models/database.py Normal file
View File

@ -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)

105
bot/models/reminder.py Normal file
View File

@ -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")

36
bot/models/user.py Normal file
View File

@ -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

10
bot/scheduler/__init__.py Normal file
View File

@ -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",
]

100
bot/scheduler/executor.py Normal file
View File

@ -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

View File

@ -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

10
bot/states.py Normal file
View File

@ -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

19
bot/utils/__init__.py Normal file
View File

@ -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",
]

12
bot/utils/holiday.py Normal file
View File

@ -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

73
bot/utils/keyboards.py Normal file
View File

@ -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}"
),
]
]
)

14
docker-compose.yml Normal file
View File

@ -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

6
requirements.txt Normal file
View File

@ -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