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.reminder import reminder_conv_handler
|
||||||
from bot.handlers.list import list_reminders
|
from bot.handlers.list import list_reminders
|
||||||
from bot.handlers.callback import handle_callback
|
from bot.handlers.callback import handle_callback
|
||||||
|
from bot.handlers.snooze import handle_snooze_input
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"start",
|
"start",
|
||||||
@ -9,4 +10,5 @@ __all__ = [
|
|||||||
"reminder_conv_handler",
|
"reminder_conv_handler",
|
||||||
"list_reminders",
|
"list_reminders",
|
||||||
"handle_callback",
|
"handle_callback",
|
||||||
|
"handle_snooze_input",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -14,6 +14,12 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
await query.answer()
|
await query.answer()
|
||||||
|
|
||||||
data = query.data
|
data = query.data
|
||||||
|
|
||||||
|
# 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)
|
action, reminder_id_str = data.split("_", 1)
|
||||||
reminder_id = int(reminder_id_str)
|
reminder_id = int(reminder_id_str)
|
||||||
|
|
||||||
@ -31,20 +37,23 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
await query.edit_message_text(f"✅ 已完成提醒:{reminder.title}")
|
await query.edit_message_text(f"✅ 已完成提醒:{reminder.title}")
|
||||||
|
|
||||||
elif action == "snooze":
|
elif action == "snooze":
|
||||||
# Snooze for 10 minutes - send another reminder
|
# Ask user for snooze minutes
|
||||||
from bot.scheduler.executor import execute_reminder
|
context.user_data["snooze_reminder_id"] = reminder_id
|
||||||
|
context.user_data["snooze_chat_id"] = query.message.chat_id
|
||||||
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')
|
|
||||||
await query.edit_message_text(
|
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":
|
elif action == "pause":
|
||||||
reminder.is_active = False
|
reminder.is_active = False
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -69,3 +78,4 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
await query.edit_message_text("操作失败,请稍后重试。")
|
await query.edit_message_text("操作失败,请稍后重试。")
|
||||||
finally:
|
finally:
|
||||||
Session.remove()
|
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.config import BOT_TOKEN, LOG_LEVEL
|
||||||
from bot.handlers import (
|
from bot.handlers import (
|
||||||
handle_callback,
|
handle_callback,
|
||||||
|
handle_snooze_input,
|
||||||
help_command,
|
help_command,
|
||||||
list_reminders,
|
list_reminders,
|
||||||
reminder_conv_handler,
|
reminder_conv_handler,
|
||||||
@ -39,7 +40,7 @@ def main() -> None:
|
|||||||
# Initialize scheduler
|
# Initialize scheduler
|
||||||
init_scheduler(app.bot)
|
init_scheduler(app.bot)
|
||||||
|
|
||||||
# Register handlers
|
# Register handlers (group=0)
|
||||||
app.add_handler(CommandHandler("start", start))
|
app.add_handler(CommandHandler("start", start))
|
||||||
app.add_handler(CommandHandler("help", help_command))
|
app.add_handler(CommandHandler("help", help_command))
|
||||||
app.add_handler(MessageHandler(filters.Regex("^❓ 帮助$"), help_command))
|
app.add_handler(MessageHandler(filters.Regex("^❓ 帮助$"), help_command))
|
||||||
@ -51,6 +52,13 @@ def main() -> None:
|
|||||||
|
|
||||||
app.add_handler(CallbackQueryHandler(handle_callback))
|
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
|
# Graceful shutdown
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
|
|||||||
@ -62,7 +62,19 @@ class Reminder(Base):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_user_reminders(cls, session: Session, user_id: int) -> List["Reminder"]:
|
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:
|
def type_display(self) -> str:
|
||||||
mapping = {
|
mapping = {
|
||||||
|
|||||||
@ -102,9 +102,10 @@ def _build_trigger(reminder: Reminder):
|
|||||||
run_time = reminder.once_time
|
run_time = reminder.once_time
|
||||||
if run_time.tzinfo is None:
|
if run_time.tzinfo is None:
|
||||||
run_time = pytz.utc.localize(run_time)
|
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 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 rtype == "daily":
|
||||||
if not reminder.daily_time:
|
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("✅ 完成", callback_data=f"done_{reminder_id}"),
|
||||||
InlineKeyboardButton(
|
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:
|
def list_item_keyboard(reminder_id: int, is_active: bool) -> InlineKeyboardMarkup:
|
||||||
toggle_label = "⏸ 暂停" if is_active else "▶️ 启用"
|
toggle_label = "⏸ 暂停" if is_active else "▶️ 启用"
|
||||||
toggle_action = "pause" if is_active else "resume"
|
toggle_action = "pause" if is_active else "resume"
|
||||||
|
|||||||
@ -9,4 +9,5 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
|
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user