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:
parent
265a329f2c
commit
9ac7e88ac6
@ -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",
|
||||
]
|
||||
|
||||
@ -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
72
bot/handlers/snooze.py
Normal 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()
|
||||
10
bot/main.py
10
bot/main.py
@ -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...")
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -9,4 +9,5 @@ services:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
Loading…
Reference in New Issue
Block a user