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