"""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}" class _ToolFailure(Exception): """ツール実行そのものが失敗した(DBエラーなど)。モデルに投げ返さず即座に諦めるための印。""" def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str: """ツール1回分だけDBセッションを開いて実行する。 LLMとの往復のあいだセッションを握り続けると、SQLite のロックを数秒〜数十秒 保持することになり、同時に走る API リクエストと競合して "database is locked" を起こしうる。開いて閉じるのをツール単位にすることでそれを避ける。 """ db = SessionLocal() try: return execute_reminder_tool(db, name, args, ctx) except Exception as e: logger.exception("Reminder tool %s failed (args=%r)", name, args) db.rollback() raise _ToolFailure(name) from e finally: db.close() 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() logger.info("Reminder request from %s [model=%s]: %s", ctx.user_name, used_model, content) for round_no in range(1, max_rounds + 1): try: # tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、 # それを握り潰しているのは app.core.llm の litellm.drop_params=True という # 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので # 省いても挙動は変わらず、その隠れた依存だけが消える。 response = await litellm.acompletion( model=used_model, messages=messages, tools=REMINDER_TOOLS, ) except Exception: logger.exception("Reminder LLM call failed [model=%s round=%d]", used_model, round_no) return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。" # tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり # 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。 try: choice_message = response.choices[0].message except (AttributeError, IndexError, KeyError, TypeError): logger.exception( "Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response, ) return "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。" tool_calls = getattr(choice_message, "tool_calls", None) if not tool_calls: reply = (choice_message.content or "").strip() logger.info("Reminder request finished without tools [round=%d]", round_no) 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 = {} try: result = _run_tool(tc.function.name, args, ctx) except _ToolFailure: return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。" messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) # max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。 logger.warning("Reminder tool loop exceeded %d rounds [model=%s]", max_rounds, used_model) return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。" 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()