Author SHA1 Message Date
colcaroli 24268cb453 Merge branch 'main' into feature/tool-based-reminder-routing 2026-08-23 17:16:27 +00:00
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
3 changed files with 78 additions and 9 deletions
+2 -1
View File
@@ -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 ストリーミング
+59 -8
View File
@@ -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:
+17
View File
@@ -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 つ。
| ジョブ | トリガー | 内容 |