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:
@@ -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()
|
||||
Reference in New Issue
Block a user