Author SHA1 Message Date
colcaroliandClaude Opus 5 858dbf4004 Catch typing() failures so Discord replies never go silent
reply_channel.typing() の async with を try の外に置いていたため、
その __aenter__(=タイピング表示リクエストの送信)が失敗すると on_message
自体が落ち、on_message 内の except にも届かず、ユーザーには何の応答も
返らなかった。スレッド作成には成功しても "Send Messages in Threads"
権限が無いだけでこの状態になりうる("Create Public Threads" とは別の
権限のため、片方だけ付与した構成は普通に起こる)。

try を async with の外側に広げ、typing() の失敗も含めて
"⚠️ 応答の生成に失敗しました" を返せるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:14:52 +09:00
colcaroliandClaude Opus 5 70735567f2 Thread Discord bot replies for a tidier channel
長い一般会話やリマインド確認のやり取りがチャンネルに直接積み上がって
埋もれるのを避けるため、全メンションへの返信をスレッドに集約する。

discord_bot._ensure_thread() を on_message の先頭、キーワード分岐より前に
挟み、以後の返信・タイピング表示・ReminderContext.channel_id をすべて
そこで決めた reply_channel 経由にする。

- 通常のチャンネルでのメンションは元メッセージからスレッドを新規作成する
  (名前は本文を90文字に切り詰め、自動アーカイブは1440分=1日。ブースト
  状況に関係なく常に選べる値を使う)
- 既にスレッド内でのメンションはそのスレッドを再利用し、二重に作らない
- DMや権限不足でスレッドを作れない場合は例外を握りつぶし、元のチャンネル
  へフォールバックする(Botの応答自体は止めない)
- message.reply() は常に元メッセージのチャンネル(スレッドの親)へ送って
  しまいスレッドには入らないため、_reply() でreply_channelがスレッドかどうか
  を見て送り先を明示的に使い分ける
- そのメンションから登録したリマインドの⏰通知も、登録元のスレッドに
  届くようにした(channel_idにスレッドのIDを渡すだけで済む。スレッドが
  自動アーカイブされていても送信時にDiscordが自動展開する)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:08:45 +09:00
colcaroliandClaude Opus 5 c6108a1b29 Route reminders by tool availability, not keyword matching
「7時にモーニングコールをお願い。」のような、REMINDER_KEYWORDS のどれとも
一致しない言い回しがリマインド機能を素通りし、ただの雑談として処理されて
いた(本番の api_logs で実例を確認)。「モーニングコール」「起こして」を
キーワードに追加しても、次の言い回しでまた同じことが起きるだけなので、
キーワード判定そのものをやめる。

discord_bot.on_message は雑談・天気のキーワード分岐はそのまま残し、それ以外の
全メンションで create_reminder / list_reminders / cancel_reminder の3ツールを
常に同席させる(reminders.build_tool_hint() が現在時刻の文脈を添える)。
使うかどうかはLLM自身の判断に委ねるため、キーワード一覧を事前に尽くす必要が
なくなる。本番のBedrock Nova 2 Liteで、実際に失敗していた文言が
create_reminder を正しく呼ぶことを確認済み。

reminders.handle_reminder_request() は resolve_reminder_tools(messages, ctx,
model) に一般化し、discord_bot 側で組み立てたmessages(人格プロンプト+天気
コンテキスト+ツール文脈)をそのまま受け取れるようにした。tool calling
非対応のモデルが設定されていた場合は、1ラウンド目の例外を検知してツール
なしの通常応答にフォールバックする。ツール呼び出しが一般会話の呼び出しに
統合された以上、それだけ別モデルを割り当てる余地がなくなったため
REMINDER_MODEL は廃止する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 01:17:03 +09:00
colcaroli 0ee9fac3f7 Merge pull request 'Make Discord reminder failures diagnosable' (#10) from fix/reminder-error-visibility into main
Reviewed-on: #10
2026-08-23 15:11:52 +00:00
6 changed files with 150 additions and 87 deletions
+3 -3
View File
@@ -107,9 +107,10 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。
- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する
- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする
- **スレッド化** (`discord_bot._ensure_thread()`): 全メンションへの返信をスレッドに集約する。元メッセージからスレッドを新規作成し(既にスレッド内でのメンションなら流用)、以後の返信・タイピング表示・そのメンションから登録したリマインドの⏰通知はすべてそのスレッドに届く。DM や権限不足でスレッドを作れない場合は元のチャンネルへフォールバックする
- **定期ジョブ** (`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 で絞るため他人のリマインドには触れられない
- **リマインド** (`discord/reminders.py`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
### SSE ストリーミング
@@ -166,7 +167,6 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `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 の署名鍵 |
+2 -3
View File
@@ -74,14 +74,13 @@ USELESS_TIPS_MODEL=
DISCORD_WEBHOOK_URL_TIPS=
# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する)
# 全メンションでツールを常に同席させ、使うかどうかはLLMが判断する(キーワード判定なし)ため、
# WEATHER_NOTIFY_MODEL(未設定ならDEFAULT_MODEL)は tool calling 対応モデルである必要がある
# 期限切れのリマインドを探しにいく間隔(秒)
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
-3
View File
@@ -99,6 +99,3 @@ REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SE
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")
+68 -21
View File
@@ -11,11 +11,7 @@ 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.reminders import ReminderContext, build_tool_hint, resolve_reminder_tools
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
@@ -32,6 +28,48 @@ client = discord.Client(intents=intents)
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈")
# スレッドの自動アーカイブまでの時間(分)。サーバーのブースト状況に関係なく常に選べる値。
_THREAD_AUTO_ARCHIVE_MINUTES = 1440
def _thread_name(content: str) -> str:
name = content.replace("\n", " ").strip() or "会話"
return name[:90] + "…" if len(name) > 90 else name
async def _ensure_thread(message: discord.Message, content: str) -> "discord.abc.MessageableChannel":
"""返信先を決める。
既にスレッド内でのメンションならそのスレッドをそのまま使う。DMにはスレッドが
存在しないのでチャンネルのまま。それ以外は元メッセージからスレッドを新規作成し、
以後の会話をそこに集約する。作成に失敗した場合(権限不足など)は例外を握りつぶし、
元のチャンネルへフォールバックする(Botの応答自体は止めない)。
"""
channel = message.channel
if isinstance(channel, discord.Thread) or message.guild is None:
return channel
try:
return await message.create_thread(
name=_thread_name(content), auto_archive_duration=_THREAD_AUTO_ARCHIVE_MINUTES,
)
except Exception:
logger.warning(
"Failed to create thread for message %s; replying in channel instead", message.id, exc_info=True,
)
return channel
async def _reply(message: discord.Message, reply_channel: "discord.abc.MessageableChannel", text: str) -> None:
"""reply_channel が元のチャンネルのままなら通常どおり message.reply()(引用UI付き)で返す。
スレッドを新規作成した場合、message.reply() は元メッセージのチャンネル(スレッドの親)へ
送ってしまいスレッドには入らないため、reply_channel へ直接 send() する。
"""
if reply_channel is message.channel:
await message.reply(text)
else:
await reply_channel.send(text)
async def _build_weather_context() -> str:
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
@@ -70,20 +108,18 @@ async def on_message(message: discord.Message):
record_message(content)
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
# 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
# (新規作成したスレッド、または元のチャンネル)に統一する。
reply_channel = await _ensure_thread(message, content)
try:
# typing() の開始(=タイピング表示のリクエスト送信)自体が権限不足で失敗しうる
# (スレッド作成には成功しても "Send Messages in Threads" が無いケースなど)ため、
# try の外に置くと on_message ごと落ちて何の返信もされない。try の内側に入れる。
async with reply_channel.typing():
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
redirect = await _generate_useless_talk_redirect(content)
await message.reply(redirect)
await _reply(message, reply_channel, redirect)
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
seed_tags = await generate_tags(content)
@@ -97,15 +133,26 @@ async def on_message(message: discord.Message):
weather_context = await _build_weather_context()
messages.append({"role": "system", "content": weather_context})
# リマインドはキーワードでは振り分けない。「モーニングコール」のように
# 決め打ちの言い回しから外れる依頼も拾えるよう、ツールを常に同席させて
# 使うかどうかはLLM自身に判断させる。
messages.append({"role": "system", "content": build_tool_hint()})
messages.append({"role": "user", "content": content})
reply = await complete(messages, model=WEATHER_NOTIFY_MODEL)
# channel_id にスレッドのIDを渡すことで、⏰通知もこの会話に届く。
# スレッドが自動アーカイブされていても、送信時にDiscordが自動で展開する。
ctx = ReminderContext(
channel_id=str(reply_channel.id),
user_id=str(message.author.id),
user_name=message.author.display_name,
)
reply = await resolve_reminder_tools(messages, ctx, model=WEATHER_NOTIFY_MODEL)
if reply:
record_message(reply)
await message.reply(reply or "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
await message.reply("⚠️ 応答の生成に失敗しました")
await _reply(message, reply_channel, reply or "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
await _reply(message, reply_channel, "⚠️ 応答の生成に失敗しました")
async def _generate_useless_talk_redirect(content: str) -> str:
+45 -50
View File
@@ -1,8 +1,10 @@
"""Discord Bot のリマインド機能。
メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders /
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。
キーワードでは振り分けない。discord_bot.on_message は全メンションで
resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の
3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、
「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える
(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
@@ -23,27 +25,13 @@ from app.core.config import (
DEFAULT_MODEL,
REMINDER_LIST_LIMIT,
REMINDER_MAX_AHEAD_DAYS,
REMINDER_MODEL,
WEATHER_NOTIFY_MODEL,
)
from app.core.llm import complete
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
@@ -62,10 +50,6 @@ class ReminderContext:
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()
@@ -267,34 +251,40 @@ def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str:
db.close()
def _system_prompt() -> str:
def build_tool_hint() -> str:
"""create_reminder 等を使わせるための時刻文脈。system メッセージとして差し込む。
discord_bot.on_message がキーワード判定なしに全メンションでこれを同席させるので、
ここにはリマインドに関する指示だけを書く(お天気アプリとしての人格や口調は
DISCORD_BOT_SYSTEM_PROMPT 側の責務のまま)。
"""
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時と解釈してかまいませんが、"
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n"
"ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように"
"リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder "
"ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を"
"基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、"
"その解釈を返事の中で明示してください。\n"
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。"
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
"リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。"
)
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str:
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。
async def resolve_reminder_tools(
messages: list[dict],
ctx: ReminderContext,
model: str | None = None,
max_rounds: int = 3,
) -> str:
"""会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は
その旨の固定文を返す(Botが無言になるのを避ける)。
discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない
モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック
する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。
"""
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)
used_model = model or DEFAULT_MODEL
working = list(messages)
for round_no in range(1, max_rounds + 1):
try:
@@ -304,12 +294,19 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
# 省いても挙動は変わらず、その隠れた依存だけが消える。
response = await litellm.acompletion(
model=used_model,
messages=messages,
messages=working,
tools=REMINDER_TOOLS,
)
except Exception:
logger.exception("Reminder LLM call failed [model=%s round=%d]", used_model, round_no)
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
if round_no == 1:
# 1ラウンド目からして失敗=モデルがそもそも tools 付き呼び出しを
# 受け付けない可能性が高い。ツールなしの通常応答として救済する。
logger.info(
"Model %s rejected a tools-attached call; falling back to plain completion", used_model,
)
return await complete(messages, model=used_model)
logger.exception("Reminder-aware completion failed mid-loop [model=%s round=%d]", used_model, round_no)
return "⚠️ 応答の生成に失敗しました"
# tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり
# 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。
@@ -319,15 +316,13 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
logger.exception(
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
)
return "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。"
return "⚠️ 応答の生成に失敗しました"
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 "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
return (choice_message.content or "").strip()
messages.append({
working.append({
"role": "assistant",
"content": choice_message.content or "",
"tool_calls": [
@@ -349,7 +344,7 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
result = _run_tool(tc.function.name, args, ctx)
except _ToolFailure:
return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
working.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)
+32 -7
View File
@@ -181,14 +181,36 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、
先にマッチしたものが返信を返して終わる。
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、
先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。
| 分岐 | キーワード例 | 動作 |
|---|---|---|
| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す |
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
| 通常応答 | (上記に当てはまらない全て) | `reminders.resolve_reminder_tools()` に渡す |
リマインドはキーワードで振り分けない。雑談以外の全メンション(天気キーワード込み)で
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを常に同席させ、使うかどうかは
LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
#### スレッド化(`discord_bot._ensure_thread()` / `_reply()`)
全メンションへの返信を Discord のスレッドに集約する。`on_message` の先頭、キーワード分岐より前に
`reply_channel = await _ensure_thread(message, content)` を呼び、以後の返信・タイピング表示・
`ReminderContext.channel_id` はすべて `reply_channel` を通す。
- 通常のチャンネルでのメンションは、元メッセージから `message.create_thread()` で新規スレッドを作る
(名前は本文を 90 文字に切り詰めたもの、自動アーカイブ 1440 分=1日)
- 既にスレッド内でのメンションはそのスレッドをそのまま使い、新規作成はしない
- DM(`message.guild is None`)や、権限不足など何らかの理由でスレッド作成に失敗した場合は
例外を握りつぶして元のチャンネルへフォールバックする(Bot の応答自体は止めない)
- `_reply()` は `reply_channel` が元のチャンネルのままなら `message.reply()`(引用UI付き)、
新規スレッドを作った場合は `reply_channel.send()` を使う。`message.reply()` は常に元メッセージの
チャンネル(スレッドの親)へ送ってしまいスレッドには入らないため、明示的に使い分けが要る
- そのメンションから登録したリマインドは `channel_id` にスレッドの ID が入るため、⏰通知も同じ
スレッドに届く。スレッドが自動アーカイブされていても、送信すると Discord 側で自動的に展開される
APScheduler に登録されるジョブは 3 つ。
@@ -202,11 +224,14 @@ APScheduler に登録されるジョブは 3 つ。
一回きりのリマインドのみを扱う(繰り返しには非対応)。
- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、
自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、
- **登録**: `resolve_reminder_tools()` が `discord_bot.on_message` の全メンションで
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを差し込み、自然言語から
日時と用件を取り出させる。`build_tool_hint()` が現在時刻と曜日を system メッセージに足すので、
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
`REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、
**tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す)
discord_bot 側が渡す `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)で、一般会話・天気応答と
共通。**tool calling 対応モデルであることが望ましい** — 非対応モデルがツール付き呼び出し自体を
拒否した場合は、1 ラウンド目の例外を検知してツールなしの通常応答にフォールバックする
(リマインド機能は使えないが、一般会話・天気応答は道連れにならない)
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
エラー文をモデルに返し、モデルに聞き直させる
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。