feat: replace fixed 10-min snooze with user-defined snooze duration

- Add snooze.py handler: prompts user for snooze minutes and schedules
  a one-shot job with a cancel button (cancel_snooze_<id>)
- Refactor callback.py: support compound action prefix parsing for
  cancel_snooze_*, switch snooze action to prompt-based flow
- Add snooze_action_keyboard() in keyboards.py; update snooze button label
- Register handle_snooze_input in group=1 to avoid ConversationHandler conflict
- Filter completed once-type reminders from get_user_reminders()
- Fix DateTrigger: compare and localize once_time in UTC consistently

Co-Authored-By: claude-sonnet-4-6 <noreply@anthropic.com>
This commit is contained in:
leo 2026-03-05 15:19:02 +08:00
parent 265a329f2c
commit 9ac7e88ac6
8 changed files with 136 additions and 18 deletions

View File

@ -2,6 +2,7 @@ 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
from bot.handlers.snooze import handle_snooze_input
__all__ = [
"start",
@ -9,4 +10,5 @@ __all__ = [
"reminder_conv_handler",
"list_reminders",
"handle_callback",
"handle_snooze_input",
]

View File

@ -14,8 +14,14 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer()
data = query.data
action, reminder_id_str = data.split("_", 1)
reminder_id = int(reminder_id_str)
# Support compound actions like "cancel_snooze_123"
if data.startswith("cancel_snooze_"):
action = "cancel_snooze"
reminder_id = int(data[len("cancel_snooze_"):])
else:
action, reminder_id_str = data.split("_", 1)
reminder_id = int(reminder_id_str)
session = Session()
try:
@ -31,20 +37,23 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
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,
)
tz = pytz.timezone("Asia/Shanghai")
display_time = snooze_time.astimezone(tz).strftime('%H:%M')
# Ask user for snooze minutes
context.user_data["snooze_reminder_id"] = reminder_id
context.user_data["snooze_chat_id"] = query.message.chat_id
await query.edit_message_text(
f"⏰ 已延期10分钟将在 {display_time} 再次提醒。"
"⏰ 请输入延期的分钟数例如10"
)
elif action == "cancel_snooze":
job_name = f"snooze_{reminder_id}"
jobs = context.job_queue.get_jobs_by_name(job_name)
if jobs:
for job in jobs:
job.schedule_removal()
await query.edit_message_text(f"❌ 已关闭延期提醒:{reminder.title}")
else:
await query.edit_message_text("延期提醒不存在或已过期。")
elif action == "pause":
reminder.is_active = False
session.commit()
@ -69,3 +78,4 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.edit_message_text("操作失败,请稍后重试。")
finally:
Session.remove()

72
bot/handlers/snooze.py Normal file
View File

@ -0,0 +1,72 @@
from datetime import datetime, timedelta, timezone
import pytz
from telegram import Update
from telegram.ext import ContextTypes
from bot.models.database import Session
from bot.models.reminder import Reminder
from bot.utils.keyboards import snooze_action_keyboard
async def handle_snooze_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle user input for snooze minutes."""
if "snooze_reminder_id" not in context.user_data:
return
reminder_id = context.user_data.pop("snooze_reminder_id")
chat_id = context.user_data.pop("snooze_chat_id", None)
try:
minutes = int(update.message.text.strip())
if minutes <= 0 or minutes > 1440:
await update.message.reply_text("请输入 1-1440 之间的分钟数。")
return
except ValueError:
await update.message.reply_text("请输入有效的数字。")
return
session = Session()
try:
reminder = session.get(Reminder, reminder_id)
if reminder is None:
await update.message.reply_text("提醒不存在或已被删除。")
return
snooze_time_utc = datetime.now(timezone.utc) + timedelta(minutes=minutes)
# For once-type reminders, update the once_time and reschedule
if reminder.reminder_type == "once":
reminder.once_time = snooze_time_utc
reminder.is_active = True
session.commit()
# Reschedule using APScheduler
from bot.scheduler.job_manager import add_reminder_job
add_reminder_job(reminder_id)
else:
# For recurring reminders, use temporary job_queue
from bot.scheduler.executor import execute_reminder
_rid = reminder_id
async def snooze_callback(ctx):
await execute_reminder(_rid, ctx.bot)
context.job_queue.run_once(
snooze_callback,
when=snooze_time_utc,
name=f"snooze_{reminder_id}",
)
tz = pytz.timezone("Asia/Shanghai")
display_time = snooze_time_utc.astimezone(tz).strftime("%Y-%m-%d %H:%M")
# Only show cancel button for recurring reminders (once-type uses DB now)
keyboard = None if reminder.reminder_type == "once" else snooze_action_keyboard(reminder_id)
await update.message.reply_text(
f"⏰ 已延期 {minutes} 分钟\n将在 {display_time} 再次提醒:{reminder.title}",
reply_markup=keyboard,
)
finally:
Session.remove()

View File

@ -13,6 +13,7 @@ from telegram.ext import (
from bot.config import BOT_TOKEN, LOG_LEVEL
from bot.handlers import (
handle_callback,
handle_snooze_input,
help_command,
list_reminders,
reminder_conv_handler,
@ -39,7 +40,7 @@ def main() -> None:
# Initialize scheduler
init_scheduler(app.bot)
# Register handlers
# Register handlers (group=0)
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(MessageHandler(filters.Regex("^❓ 帮助$"), help_command))
@ -51,6 +52,13 @@ def main() -> None:
app.add_handler(CallbackQueryHandler(handle_callback))
# Snooze minutes input in group=1, after ConversationHandler.
# handle_snooze_input checks user_data and returns early if not waiting for input.
app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_snooze_input),
group=1,
)
# Graceful shutdown
def signal_handler(sig, frame):
logger.info("Shutting down...")

View File

@ -62,7 +62,19 @@ class Reminder(Base):
@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()
from sqlalchemy import and_, or_
return (
session.query(cls)
.filter(
cls.user_id == user_id,
or_(
cls.reminder_type != "once",
and_(cls.reminder_type == "once", cls.is_active == True),
),
)
.order_by(cls.created_at)
.all()
)
def type_display(self) -> str:
mapping = {

View File

@ -102,9 +102,10 @@ def _build_trigger(reminder: Reminder):
run_time = reminder.once_time
if run_time.tzinfo is None:
run_time = pytz.utc.localize(run_time)
if run_time <= datetime.now(timezone.utc).astimezone(SHANGHAI_TZ):
# Check if already passed (compare in UTC)
if run_time <= datetime.now(timezone.utc):
return None # already passed
return DateTrigger(run_date=run_time, timezone=SHANGHAI_TZ)
return DateTrigger(run_date=run_time, timezone=pytz.utc)
if rtype == "daily":
if not reminder.daily_time:

View File

@ -45,7 +45,7 @@ def reminder_action_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
[
InlineKeyboardButton("✅ 完成", callback_data=f"done_{reminder_id}"),
InlineKeyboardButton(
"⏰ 延期10分钟", callback_data=f"snooze_{reminder_id}"
"⏰ 延期", callback_data=f"snooze_{reminder_id}"
),
],
[
@ -56,6 +56,18 @@ def reminder_action_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
)
def snooze_action_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"❌ 关闭延期提醒", callback_data=f"cancel_snooze_{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"

View File

@ -9,4 +9,5 @@ services:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TZ=Asia/Shanghai