diff --git a/CLAUDE.md b/CLAUDE.md index 20ee51c..477b314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,8 +108,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte `main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。 - **メンション応答** (`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`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は 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 ストリーミング diff --git a/backend/app/core/discord/discord_bot.py b/backend/app/core/discord/discord_bot.py index 92020ca..3b64284 100644 --- a/backend/app/core/discord/discord_bot.py +++ b/backend/app/core/discord/discord_bot.py @@ -28,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に渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。""" @@ -66,11 +108,18 @@ async def on_message(message: discord.Message): record_message(content) - async with message.channel.typing(): - try: + # 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル + # (新規作成したスレッド、または元のチャンネル)に統一する。 + 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) @@ -90,18 +139,20 @@ async def on_message(message: discord.Message): messages.append({"role": "system", "content": build_tool_hint()}) messages.append({"role": "user", "content": content}) + # channel_id にスレッドのIDを渡すことで、⏰通知もこの会話に届く。 + # スレッドが自動アーカイブされていても、送信時にDiscordが自動で展開する。 ctx = ReminderContext( - channel_id=str(message.channel.id), + 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: diff --git a/docs/architecture.md b/docs/architecture.md index 63146c6..f30b4ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -195,6 +195,23 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時 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 つ。 | ジョブ | トリガー | 内容 |