From c1aba42cf8c610767f0660f3a31eeb3b3e53f3fd Mon Sep 17 00:00:00 2001 From: zukki259 Date: Sun, 23 Aug 2026 23:29:55 +0900 Subject: [PATCH] Schedule reminders by mentioning the Discord bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit メンション本文にリマインド系のキーワードが含まれていたとき、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 --- CLAUDE.md | 12 + backend/.env.example | 10 + backend/app/core/config.py | 11 + backend/app/core/discord/discord_bot.py | 14 + backend/app/core/discord/reminders.py | 375 ++++++++++++++++++++++++ backend/app/core/discord/scheduler.py | 10 + backend/app/db/models.py | 21 ++ docs/architecture.md | 46 +++ 8 files changed, 499 insertions(+) create mode 100644 backend/app/core/discord/reminders.py diff --git a/CLAUDE.md b/CLAUDE.md index 55381d4..a547d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,14 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte - **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる - **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`) +### Discord Bot + +`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。 + +- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する +- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ +- **リマインド** (`discord/reminders.py`): メンションで自然言語登録する**一回きり**の通知(繰り返し非対応)。LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを渡し、system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。**tool calling 対応モデルが必須**。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない + ### SSE ストリーミング `POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 @@ -154,5 +162,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte | `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 | | `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 | | `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 | +| `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) | +| `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する | +| `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 | +| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/.env.example b/backend/.env.example index f3ecf73..4821e91 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -73,6 +73,16 @@ USELESS_TIPS_MODEL= # 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う DISCORD_WEBHOOK_URL_TIPS= +# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する) +# 期限切れのリマインドを探しにいく間隔(秒) +REMINDER_CHECK_INTERVAL_SECONDS=30 +# これより先の日時は登録を拒否する(日数) +REMINDER_MAX_AHEAD_DAYS=365 +REMINDER_LIST_LIMIT=20 +# 日時の解釈に使うモデル。tool calling 対応モデルを指定すること +# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバック) +REMINDER_MODEL= + # アプリ設定 DATABASE_URL=sqlite:///./playchat.db MEMORY_SUMMARIZE_THRESHOLD=20 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 911d6a9..cfd9ade 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -88,3 +88,14 @@ USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS", USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL") # 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL") + +# Discord Bot のリマインド機能(メンションで登録し、時刻になったら同じチャンネルへ通知する) +# 期限切れのリマインドを探しにいく間隔。短いほど通知の遅れが減るがDBを引く回数は増える。 +REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SECONDS", "30")) +# これより先の日時を指定されたら登録を拒否する(LLMの年の取り違えを弾くため) +REMINDER_MAX_AHEAD_DAYS: int = int(os.getenv("REMINDER_MAX_AHEAD_DAYS", "365")) +# list_reminders が一度に返す件数 +REMINDER_LIST_LIMIT: int = int(os.getenv("REMINDER_LIST_LIMIT", "20")) +# リマインドの解釈に使うモデル。tool callingに対応したモデルを指定すること +# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバックする) +REMINDER_MODEL: str | None = os.getenv("REMINDER_MODEL") diff --git a/backend/app/core/discord/discord_bot.py b/backend/app/core/discord/discord_bot.py index 3bb6c3a..fd69a03 100644 --- a/backend/app/core/discord/discord_bot.py +++ b/backend/app/core/discord/discord_bot.py @@ -11,6 +11,11 @@ from app.core.config import ( WEATHER_NOTIFY_MODEL, ) from app.core.llm import complete +from app.core.discord.reminders import ( + REMINDER_KEYWORDS, + ReminderContext, + handle_reminder_request, +) from app.core.discord.scheduler import send_useless_tip from app.core.memory_kb.tagging import find_related_tag, generate_tags from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry @@ -67,6 +72,15 @@ async def on_message(message: discord.Message): async with message.channel.typing(): try: + if any(keyword in content for keyword in REMINDER_KEYWORDS): + ctx = ReminderContext( + channel_id=str(message.channel.id), + user_id=str(message.author.id), + user_name=message.author.display_name, + ) + await message.reply(await handle_reminder_request(content, ctx)) + return + if any(keyword in content for keyword in USELESS_TALK_KEYWORDS): redirect = await _generate_useless_talk_redirect(content) await message.reply(redirect) diff --git a/backend/app/core/discord/reminders.py b/backend/app/core/discord/reminders.py new file mode 100644 index 0000000..9fba293 --- /dev/null +++ b/backend/app/core/discord/reminders.py @@ -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() diff --git a/backend/app/core/discord/scheduler.py b/backend/app/core/discord/scheduler.py index ae79ae9..891597b 100644 --- a/backend/app/core/discord/scheduler.py +++ b/backend/app/core/discord/scheduler.py @@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger from app.core.config import ( DISCORD_WEBHOOK_URL, DISCORD_WEBHOOK_URL_TIPS, + REMINDER_CHECK_INTERVAL_SECONDS, USELESS_TIPS_ENABLED, USELESS_TIPS_INTERVAL_HOURS, USELESS_TIPS_MODEL, @@ -21,6 +22,7 @@ from app.core.config import ( ) from app.core.llm import complete from app.core.discord.notify import send_discord_message +from app.core.discord.reminders import deliver_due_reminders from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather.weather_cache import set_latest from app.core.memory_kb.word_memory import pick_random_word @@ -144,4 +146,12 @@ def start_scheduler() -> None: ) logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS) + scheduler.add_job( + deliver_due_reminders, + trigger=IntervalTrigger(seconds=REMINDER_CHECK_INTERVAL_SECONDS), + id="discord_reminders", + replace_existing=True, + ) + logger.info("Reminder delivery job started: every %d second(s)", REMINDER_CHECK_INTERVAL_SECONDS) + scheduler.start() diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 3e5a11d..b3933a6 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -190,3 +190,24 @@ class ApiLog(Base): error: Mapped[str | None] = mapped_column(Text, nullable=True) latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + + +class Reminder(Base): + """Discord Bot がメンションで受け付けた一回きりのリマインド。 + + 登録は app.core.discord.reminders の create_reminder ツール、配信は同モジュールの + deliver_due_reminders() を APScheduler が定期的に呼ぶことで行う。 + 日時は他のテーブルと同じくUTCで持ち、ユーザーとのやり取りの際にローカル時刻へ直す。 + """ + __tablename__ = "reminders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + channel_id: Mapped[str] = mapped_column(String) # 登録された(=配信先の)チャンネル + user_id: Mapped[str] = mapped_column(String) # 依頼者。配信時にメンションする + user_name: Mapped[str] = mapped_column(String) # 一覧表示とログ用の表示名 + content: Mapped[str] = mapped_column(Text) + due_at: Mapped[datetime] = mapped_column(UtcDateTime) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + # 配信済み。配信を諦めた場合もここを立てて再試行を止める(deliver_due_reminders を参照) + notified_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) diff --git a/docs/architecture.md b/docs/architecture.md index b24411b..56e41e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,10 @@ backend/ │ │ ├── search/ │ │ │ └── websearch.py # Tavily による web_search ツール │ │ ├── discord/ # Discord Bot・定期通知 +│ │ │ ├── discord_bot.py # メンションへの返信(天気 / 雑談 / リマインドで分岐) +│ │ │ ├── scheduler.py # APScheduler ジョブ(天気通知・Tips・リマインド配信) +│ │ │ ├── reminders.py # リマインドの登録ツールと配信ジョブ +│ │ │ └── notify.py # Webhook 送信 │ │ ├── settings_store.py # GUI 設定を os.environ へ反映 │ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン │ └── db/ @@ -71,6 +75,7 @@ backend/ | `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 | | `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 | | `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ | +| `reminders` | id, channel_id, user_id, user_name, content, due_at, created_at, notified_at, cancelled_at | Discord Bot が預かった一回きりのリマインド(`due_at` は UTC) | ### コンテキスト管理(`core/memory_kb/`) @@ -172,6 +177,47 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時 バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。 +### Discord Bot(`core/discord/`) + +`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。 + +`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、 +先にマッチしたものが返信を返して終わる。 + +| 分岐 | キーワード例 | 動作 | +|---|---|---| +| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す | +| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 | +| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 | + +APScheduler に登録されるジョブは 3 つ。 + +| ジョブ | トリガー | 内容 | +|---|---|---| +| `weather_laundry_notification` | Cron(`WEATHER_NOTIFY_HOUR:MINUTE`) | 明日の天気と洗濯指数を Webhook で投稿 | +| `useless_tips` | Interval(`USELESS_TIPS_INTERVAL_HOURS`) | どうでもいい Tips を Webhook で投稿(3時台のみ抑制) | +| `discord_reminders` | Interval(`REMINDER_CHECK_INTERVAL_SECONDS`) | 期限を過ぎたリマインドを配信 | + +#### リマインド(`core/discord/reminders.py`) + +一回きりのリマインドのみを扱う(繰り返しには非対応)。 + +- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、 + 自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、 + 『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは + `REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、 + **tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す) +- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として + エラー文をモデルに返し、モデルに聞き直させる +- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。 + そのため専用の Webhook URL 設定は要らない +- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて + このマシンのローカル時刻で行い、`reminders.py` の境界で変換する +- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら + `notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので + バックエンドを再起動すればまた試す +- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される + --- ## フロントエンド