Schedule reminders by mentioning the Discord bot

メンション本文にリマインド系のキーワードが含まれていたとき、LLM に
create_reminder / list_reminders / cancel_reminder の3ツールを渡し、
自然言語から日時と用件を取り出して reminders テーブルに保存する。
system メッセージに現在時刻を入れてあるので『明日』『30分後』のような
相対表現はモデル側が絶対日時に直す。扱うのは一回きりの通知のみ。

配信は APScheduler の discord_reminders ジョブ(既定30秒間隔)が担い、
Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者を
メンションして送るため、専用の Webhook URL 設定は要らない。

- 日時は他テーブルと同じく UTC で保存し、ユーザー・LLM とのやり取りは
  ローカル時刻で行って reminders.py の境界で変換する
- 過去・REMINDER_MAX_AHEAD_DAYS より先・解釈不能な日時はツール結果として
  エラー文をモデルに返し、聞き直させる
- 一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れない
- 配信に5回失敗したら notified_at を立てて諦める(チャンネル削除などで
  ポーリングのたびにエラーログを出し続けるのを防ぐ)。カウンタは
  プロセス内のみなので、再起動すればまた試す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:29:55 +09:00
co-authored by Claude Opus 5
parent 3b30dab650
commit c1aba42cf8
8 changed files with 499 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
"""Discord Bot のリマインド機能。
メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders /
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
Webhook ではなく Bot クライアントから送るため追加の URL 設定は要らない。
日時の扱い: DB は他のテーブルと同じくUTC。ユーザーとLLMとのやり取りはすべて
このマシンのローカル時刻(JST想定)で行い、この境界で相互に変換する。
"""
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import discord
import litellm
from app.core.config import (
DEFAULT_MODEL,
REMINDER_LIST_LIMIT,
REMINDER_MAX_AHEAD_DAYS,
REMINDER_MODEL,
WEATHER_NOTIFY_MODEL,
)
from app.db.database import SessionLocal
from app.db.models import Reminder
logger = logging.getLogger(__name__)
# メンション本文にこれらが含まれるとリマインド処理に入る(discord_bot.on_message を参照)
REMINDER_KEYWORDS = (
"リマインド",
"リマインダ",
"remind",
"思い出させ",
"忘れないように",
"忘れずに",
"覚えといて",
"覚えておいて",
"アラーム",
)
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
_MAX_DELIVERY_ATTEMPTS = 5
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
_delivery_failures: dict[int, int] = {}
_AT_FORMATS = ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S")
@dataclass
class ReminderContext:
"""リマインドを頼んできた相手。配信先チャンネルと、メンションする相手を決めるのに使う。"""
channel_id: str
user_id: str
user_name: str
def _model() -> str:
return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL
def _local_now() -> datetime:
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
return datetime.now().astimezone()
def _to_local(value: datetime) -> datetime:
"""DBから読んだUTCの日時を、表示用にローカル時刻へ直す。"""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(_local_now().tzinfo)
def _parse_at(text: str) -> datetime:
"""LLMが返した "YYYY-MM-DD HH:MM" をローカル時刻の aware な datetime にする。
解釈できない場合は ValueError を投げ、呼び出し側がその旨をツール結果としてLLMに返す。
"""
cleaned = (text or "").strip()
for fmt in _AT_FORMATS:
try:
return datetime.strptime(cleaned, fmt).replace(tzinfo=_local_now().tzinfo)
except ValueError:
continue
raise ValueError(f"日時 '{text}' を解釈できませんでした")
CREATE_REMINDER_TOOL = {
"type": "function",
"function": {
"name": "create_reminder",
"description": "指定した日時にDiscordへ通知するリマインドを登録する。"
"ユーザーが『あとで教えて』『◯時になったら知らせて』などと頼んできたときに使う。"
"一回きりの通知のみで、繰り返しには対応していない。",
"parameters": {
"type": "object",
"properties": {
"at": {
"type": "string",
"description": "通知する日時。必ず 'YYYY-MM-DD HH:MM' 形式のローカル時刻で指定する。"
"『明日の朝9時』『30分後』のような相対表現は、システムメッセージで与えられた"
"現在時刻をもとに自分で計算して絶対日時に直すこと。",
},
"content": {
"type": "string",
"description": "通知するときに読み上げる用件。ユーザーの言葉をそのまま短くまとめる。",
},
},
"required": ["at", "content"],
},
},
}
LIST_REMINDERS_TOOL = {
"type": "function",
"function": {
"name": "list_reminders",
"description": "このユーザーの未通知のリマインド一覧を取得する。"
"『リマインド何が入ってる?』のように予定を尋ねられたときや、"
"取り消し対象のIDを知りたいときに使う。",
"parameters": {"type": "object", "properties": {}},
},
}
CANCEL_REMINDER_TOOL = {
"type": "function",
"function": {
"name": "cancel_reminder",
"description": "登録済みのリマインドを取り消す。IDが分からない場合は先に list_reminders で調べること。",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "取り消すリマインドのID(list_reminders が返す番号)"},
},
"required": ["id"],
},
},
}
REMINDER_TOOLS = [CREATE_REMINDER_TOOL, LIST_REMINDERS_TOOL, CANCEL_REMINDER_TOOL]
def _create_reminder(db, args: dict, ctx: ReminderContext) -> str:
content = (args.get("content") or "").strip()
if not content:
return "エラー: 通知する用件が空です。ユーザーに何をリマインドすればよいか聞き返してください。"
try:
due_local = _parse_at(args.get("at", ""))
except ValueError as e:
return f"エラー: {e}。'YYYY-MM-DD HH:MM' の形式で指定し直してください。"
now_local = _local_now()
if due_local <= now_local:
return (
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は現在時刻 {now_local:%Y-%m-%d %H:%M} より過去です。"
"未来の日時を指定し直すか、ユーザーに確認してください。"
)
if due_local - now_local > timedelta(days=REMINDER_MAX_AHEAD_DAYS):
return (
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は{REMINDER_MAX_AHEAD_DAYS}日以上先です。"
"年を取り違えていないか確認してください。"
)
reminder = Reminder(
channel_id=ctx.channel_id,
user_id=ctx.user_id,
user_name=ctx.user_name,
content=content,
due_at=due_local.astimezone(timezone.utc),
)
db.add(reminder)
db.commit()
db.refresh(reminder)
logger.info("Reminder created: id=%s due=%s user=%s", reminder.id, due_local, ctx.user_name)
return (
f"リマインドを登録しました(ID {reminder.id})。"
f"{due_local:%Y-%m-%d %H:%M} に「{content}」を通知します。"
"ユーザーへの返事には、いつ何を通知するのかを必ず含めてください。"
)
def _list_reminders(db, ctx: ReminderContext) -> str:
rows = (
db.query(Reminder)
.filter(
Reminder.user_id == ctx.user_id,
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
)
.order_by(Reminder.due_at)
.limit(REMINDER_LIST_LIMIT)
.all()
)
if not rows:
return "このユーザーの未通知のリマインドはありません。"
lines = [f"ID {r.id}: {_to_local(r.due_at):%Y-%m-%d %H:%M} 「{r.content}」" for r in rows]
return "未通知のリマインド:\n" + "\n".join(lines)
def _cancel_reminder(db, args: dict, ctx: ReminderContext) -> str:
reminder_id = args.get("id")
reminder = (
db.query(Reminder)
.filter(
Reminder.id == reminder_id,
Reminder.user_id == ctx.user_id,
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
)
.first()
)
if reminder is None:
return (
f"エラー: ID {reminder_id} の未通知のリマインドは見つかりませんでした。"
"list_reminders で確認してください。"
)
reminder.cancelled_at = datetime.now(timezone.utc)
db.commit()
logger.info("Reminder cancelled: id=%s user=%s", reminder_id, ctx.user_name)
return f"ID {reminder_id}「{reminder.content}」のリマインドを取り消しました。"
def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> str:
"""リマインド系ツールを実行し、LLMに返す結果テキストを作る。
一覧・取り消しは ctx.user_id で絞るため、他人のリマインドには触れられない。
"""
if name == "create_reminder":
return _create_reminder(db, args, ctx)
if name == "list_reminders":
return _list_reminders(db, ctx)
if name == "cancel_reminder":
return _cancel_reminder(db, args, ctx)
return f"エラー: 未知のツール {name}"
def _system_prompt() -> str:
now = _local_now()
weekday = "月火水木金土日"[now.weekday()]
return (
"あなたはDiscordでリマインドを預かるアシスタントです。"
"ユーザーの依頼に応じて create_reminder / list_reminders / cancel_reminder ツールを使ってください。\n"
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。"
"『明日』『30分後』『今週末』などの相対表現は、この現在時刻を基準に絶対日時へ変換すること。\n"
"時刻が指定されていない『明日』のような依頼は朝9時と解釈してかまいませんが、"
"その解釈を返事の中で明示してください。\n"
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。"
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
)
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str:
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は
その旨の固定文を返す(Botが無言になるのを避ける)。
"""
messages: list[dict] = [
{"role": "system", "content": _system_prompt()},
{"role": "user", "content": content},
]
used_model = _model()
db = SessionLocal()
try:
for _ in range(max_rounds):
try:
response = await litellm.acompletion(
model=used_model,
messages=messages,
tools=REMINDER_TOOLS,
tool_choice="auto",
)
except Exception:
logger.exception("Reminder LLM call failed [model=%s]", used_model)
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None)
if not tool_calls:
reply = (choice_message.content or "").strip()
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
messages.append({
"role": "assistant",
"content": choice_message.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in tool_calls
],
})
for tc in tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
result = execute_reminder_tool(db, tc.function.name, args, ctx)
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。
logger.warning("Reminder tool loop exceeded %d rounds", max_rounds)
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
finally:
db.close()
async def deliver_due_reminders() -> None:
"""期限を過ぎた未通知のリマインドを、登録元のチャンネルへ配信する。
scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに呼ぶ。Bot がまだ起動しきって
いない間は何もしないので、バックエンド停止中に期限を迎えたぶんは起動後にまとめて届く。
"""
# discord_bot が scheduler 経由でこのモジュールを読むため、モジュールレベルで
# import すると循環 import になる。
from app.core.discord.discord_bot import client
if not client.is_ready():
return
db = SessionLocal()
try:
due = (
db.query(Reminder)
.filter(
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
Reminder.due_at <= datetime.now(timezone.utc),
)
.order_by(Reminder.due_at)
.all()
)
for reminder in due:
try:
channel = client.get_channel(int(reminder.channel_id))
if channel is None:
channel = await client.fetch_channel(int(reminder.channel_id))
# カテゴリやフォーラムそのものには投稿できない(登録時のチャンネルが
# 別種のものに作り替えられた場合に起きうる)ので、送れる相手かを確かめる。
if not isinstance(channel, discord.abc.Messageable):
raise TypeError(f"channel {reminder.channel_id} is not messageable")
await channel.send(f"⏰ <@{reminder.user_id}> リマインドの時間です!\n> {reminder.content}")
except Exception:
failures = _delivery_failures.get(reminder.id, 0) + 1
_delivery_failures[reminder.id] = failures
if failures < _MAX_DELIVERY_ATTEMPTS:
logger.exception(
"Failed to deliver reminder id=%s (attempt %d/%d)",
reminder.id, failures, _MAX_DELIVERY_ATTEMPTS,
)
continue
logger.error("Giving up on reminder id=%s after %d attempts", reminder.id, failures)
_delivery_failures.pop(reminder.id, None)
reminder.notified_at = datetime.now(timezone.utc)
db.commit()
except Exception:
logger.exception("Failed to process due reminders")
finally:
db.close()