Initial commit: add reminderBot project structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
f453a7917e
4
.env.example
Normal file
4
.env.example
Normal 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
39
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
125
README.md
Normal file
@ -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** — 容器化部署
|
||||||
12
bot/__init__.py
Normal file
12
bot/__init__.py
Normal 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
9
bot/config.py
Normal 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
12
bot/handlers/__init__.py
Normal 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
68
bot/handlers/callback.py
Normal 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
49
bot/handlers/list.py
Normal 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
294
bot/handlers/reminder.py
Normal 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
51
bot/handlers/start.py
Normal 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
69
bot/main.py
Normal 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
5
bot/models/__init__.py
Normal 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
22
bot/models/database.py
Normal 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
105
bot/models/reminder.py
Normal 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
36
bot/models/user.py
Normal 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
10
bot/scheduler/__init__.py
Normal 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
100
bot/scheduler/executor.py
Normal 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
|
||||||
127
bot/scheduler/job_manager.py
Normal file
127
bot/scheduler/job_manager.py
Normal 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
10
bot/states.py
Normal 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
19
bot/utils/__init__.py
Normal 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
12
bot/utils/holiday.py
Normal 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
73
bot/utils/keyboards.py
Normal 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
14
docker-compose.yml
Normal 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
6
requirements.txt
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user