Claude CodeのHooksで詰まったメモ & Discordに通知を出してDiscordから対話できるようにする(Windows WSL VSCode)

最初の上手くいったサンプル
$ claude -v
1.0.40 (Claude Code)
~/.claude/settings.json
{
"model": "opus",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$YOURHOME/bin/claude-notify.sh complete '作業が完了しました'",
"description": "Notify when Claude Code finishes work"
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "$YOURHOME/bin/claude-notify.sh waiting 'ユーザーの応答を待っています'",
"description": "Notify when Claude Code is waiting for user input"
}
]
}
]
}
}
~/bin/claude-notify.sh
#!/bin/bash
# Claude Code notification script
# Usage: claude-notify.sh <type> [message]
TYPE=$1
MESSAGE=${2:-"Claude Code notification"}
# 通知方法の自動検出
notify() {
local title=$1
local message=$2
local urgency=${3:-normal}
local icon=${4:-dialog-information}
# notify-sendが利用可能な場合
if command -v notify-send &> /dev/null; then
notify-send "$title" "$message" -i "$icon" -u "$urgency"
# macOSの場合
elif command -v osascript &> /dev/null; then
osascript -e "display notification \"$message\" with title \"$title\""
# WSL環境の場合(PowerShell経由)
elif command -v powershell.exe &> /dev/null; then
powershell.exe -Command "
Add-Type -AssemblyName System.Windows.Forms
\$notification = New-Object System.Windows.Forms.NotifyIcon
\$notification.Icon = [System.Drawing.SystemIcons]::Information
\$notification.BalloonTipIcon = 'Info'
\$notification.BalloonTipTitle = '$title'
\$notification.BalloonTipText = '$message'
\$notification.Visible = \$true
\$notification.ShowBalloonTip(5000)
Start-Sleep -Seconds 5
\$notification.Dispose()
"
else
# フォールバック: ターミナルに表示
echo "[$title] $message" >&2
fi
}
case "$TYPE" in
"complete")
# 作業完了通知
notify "Claude Code" "作業が完了しました: $MESSAGE" "normal" "dialog-information"
;;
"waiting")
# 入力待ち通知(重要度高)
notify "Claude Code" "あなたの入力を待っています: $MESSAGE" "critical" "dialog-warning"
# 音声通知(利用可能な場合)
if command -v paplay &> /dev/null; then
# システムサウンドを再生
if [ -f "/usr/share/sounds/freedesktop/stereo/message.oga" ]; then
paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null
fi
elif command -v powershell.exe &> /dev/null; then
# WSL環境でビープ音
powershell.exe -Command "[console]::beep(1000,500)"
else
# 代替: ビープ音
echo -e '\a'
fi
;;
*)
# デフォルト通知
notify "Claude Code" "$MESSAGE" "normal" "dialog-information"
;;
esac

Claude Codeのバージョン
claudeで起動して、/status
を打つと
IDE Integration • /config
✔ Connected to VS Code extension
L Installed VS Code extension version 1.0.40 (server version: 1.0.34)
と出ていた。この不一致はVSCodeのリロードで解消した。
Claude自体のバージョンはさっき上げたばかりなので問題無いはず。

Claude Code自身がHooksを知っていそうだったので適当に書かせたら、
公式ドキュメント https://docs.anthropic.com/en/docs/claude-code/hooks
とは違うパスにファイルを設置しだした。
ユーザーホームの設定ファイルを ~/.config/claude-code/settings.json
に作っていた。
~/.claude/settings.json
が正しい。
また、設定ファイルの記法も誤っていたので、↑のリファレンスを読ませて書き直させた(最初の投稿のファイル)。

Discord通知について、好きに書かせるとPythonでプロジェクトを書き始めた
以下はドキュメント
Claude Code Discord通知セットアップガイド
概要
Claude CodeからのDiscord通知とリアクション制御を設定するためのガイドです。
セットアップ手順
1. Discord Webhookの作成
- Discordサーバーの設定を開く
- 「連携サービス」→「ウェブフック」を選択
- 「新しいウェブフック」をクリック
- 名前を設定(例: Claude Code Notifications)
- WebhookのURLをコピー
2. Discord Botの作成
- Discord Developer Portalにアクセス
- 「New Application」をクリック
- アプリケーション名を入力(例: Claude Code Bot)
- 「Bot」セクションに移動
- 「Add Bot」をクリック
- Bot TokenをコピーしてRESETを押さないように注意
3. Botの権限設定
- 「Bot」セクションで以下を確認:
- 「Public Bot」のチェックを外す(プライベート使用の場合)
- 「Requires OAuth2 Code Grant」のチェックを外す
- 「OAuth2」→「URL Generator」に移動
- Scopesで「bot」を選択
- Bot Permissionsで以下を選択:
- View Channels
- Send Messages
- Read Message History
- Add Reactions
- Manage Messages
- 生成されたURLをコピーしてブラウザで開く
- Botを招待するサーバーを選択
4. 設定ファイルの作成
cp ~/.claude/discord-config.example.json ~/.claude/discord-config.json
設定ファイルを編集:
{
"webhook_url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN",
"bot_token": "YOUR_BOT_TOKEN",
"channel_id": "YOUR_CHANNEL_ID",
"allowed_users": ["YOUR_DISCORD_USER_ID"],
"reaction_timeout": 300,
"reactions": {
"approve": "✅",
"deny": "❌",
"pause": "⏸️"
}
}
5. Discord IDの取得方法
- Discord設定で「詳細設定」→「開発者モード」を有効化
- チャンネルを右クリック→「IDをコピー」でchannel_idを取得
- ユーザー名を右クリック→「IDをコピー」でuser_idを取得
6. Botの起動
# バックグラウンドで起動
nohup claude-discord-bot > ~/logs/claude-discord-bot.log 2>&1 &
# または、systemdサービスとして設定(推奨)
動作確認
テスト通知を送信
# 通常の通知
~/bin/claude-notify.sh complete "テスト作業が完了しました"
# 権限要求通知(Discord通知になる)
~/bin/claude-notify.sh permission "sudo apt update を実行しようとしています"
# 入力待ち通知(Discord通知になる)
~/bin/claude-notify.sh waiting "ファイル名を入力してください"
Discordでの操作
- ✅: 承認(approved)
- ❌: 拒否(denied)
- 💬: 確認(acknowledged)
トラブルシューティング
通知が届かない場合
- Discord設定ファイルが正しく設定されているか確認
- Webhook URLが有効か確認
- jqコマンドがインストールされているか確認:
sudo apt install jq
Botが反応しない場合
- Botがオンラインか確認
- Bot TokenとChannel IDが正しいか確認
- Botに必要な権限があるか確認
- allowed_usersにあなたのDiscord IDが含まれているか確認
ログの確認
# Bot のログ
tail -f ~/logs/claude-discord-bot.log
# 一時ファイルの確認
ls -la /tmp/claude-discord-*
セキュリティ注意事項
- Bot TokenとWebhook URLは秘密情報です。Gitにコミットしないでください
- discord-config.jsonのパーミッションを制限することを推奨:
chmod 600 ~/.claude/discord-config.json
- allowed_usersで承認されたユーザーのみがBotを操作できるようにしてください

機能したコード
bin/claude-discord-bot
#!/bin/bash
# Claude Discord Bot launcher script
PROJECT_DIR="$HOME/projects/claude-discord-bot"
# Run the bot using uv
cd "$PROJECT_DIR" && exec uv run python run.py "$@"
bin/claude-notify.sh
#!/bin/bash
# Claude Code notification script
# Usage: claude-notify.sh <type> [message] [details]
TYPE=$1
MESSAGE=${2:-"Claude Code notification"}
DETAILS=${3:-""}
# Check if Discord config exists
DISCORD_CONFIG="$HOME/.claude/discord-config.json"
USE_DISCORD=false
if [ -f "$DISCORD_CONFIG" ] && [ -f "$HOME/bin/claude-discord-notify.sh" ]; then
# Check if webhook URL is configured (without jq)
if grep -q '"webhook_url"' "$DISCORD_CONFIG" && grep -q 'discord.com/api/webhooks' "$DISCORD_CONFIG"; then
USE_DISCORD=true
fi
fi
# 通知方法の自動検出
notify() {
local title=$1
local message=$2
local urgency=${3:-normal}
local icon=${4:-dialog-information}
# notify-sendが利用可能な場合
if command -v notify-send &> /dev/null; then
notify-send "$title" "$message" -i "$icon" -u "$urgency"
# macOSの場合
elif command -v osascript &> /dev/null; then
osascript -e "display notification \"$message\" with title \"$title\""
# WSL環境の場合(PowerShell経由)
elif command -v powershell.exe &> /dev/null; then
powershell.exe -Command "
Add-Type -AssemblyName System.Windows.Forms
\$notification = New-Object System.Windows.Forms.NotifyIcon
\$notification.Icon = [System.Drawing.SystemIcons]::Information
\$notification.BalloonTipIcon = 'Info'
\$notification.BalloonTipTitle = '$title'
\$notification.BalloonTipText = '$message'
\$notification.Visible = \$true
\$notification.ShowBalloonTip(5000)
Start-Sleep -Seconds 5
\$notification.Dispose()
"
else
# フォールバック: ターミナルに表示
echo "[$title] $message" >&2
fi
}
# Discord通知を優先的に使用
if [ "$USE_DISCORD" = true ] && ([ "$TYPE" = "waiting" ] || [ "$TYPE" = "permission" ]); then
# Discord通知スクリプトを呼び出し
"$HOME/bin/claude-discord-notify.sh" "$TYPE" "$MESSAGE" "$DETAILS"
exit $?
fi
# 通常の通知処理
case "$TYPE" in
"complete")
# 作業完了通知
notify "Claude Code" "作業が完了しました: $MESSAGE" "normal" "dialog-information"
# Discord通知も送信(応答待ち不要なので)
if [ "$USE_DISCORD" = true ]; then
"$HOME/bin/claude-discord-notify.sh" "$TYPE" "$MESSAGE" "$DETAILS" &
fi
;;
"waiting"|"permission")
# 入力待ち通知(重要度高)
notify "Claude Code" "あなたの入力を待っています: $MESSAGE" "critical" "dialog-warning"
# 音声通知(利用可能な場合)
if command -v paplay &> /dev/null; then
# システムサウンドを再生
if [ -f "/usr/share/sounds/freedesktop/stereo/message.oga" ]; then
paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null
fi
elif command -v powershell.exe &> /dev/null; then
# WSL環境でビープ音
powershell.exe -Command "[console]::beep(1000,500)"
else
# 代替: ビープ音
echo -e '\a'
fi
;;
*)
# デフォルト通知
notify "Claude Code" "$MESSAGE" "normal" "dialog-information"
;;
esac
bin/claude-discord-notify.sh
#!/bin/bash
# Claude Code Discord notification script with rich embeds
# Usage: claude-discord-notify.sh <type> [message] [details]
TYPE=$1
MESSAGE=${2:-"Claude Code notification"}
DETAILS=${3:-""}
# Configuration file path
CONFIG_FILE="$HOME/.claude/discord-config.json"
# Load Discord webhook URL from config
if [ -f "$CONFIG_FILE" ]; then
WEBHOOK_URL=$(jq -r '.webhook_url' "$CONFIG_FILE" 2>/dev/null)
BOT_TOKEN=$(jq -r '.bot_token' "$CONFIG_FILE" 2>/dev/null)
CHANNEL_ID=$(jq -r '.channel_id' "$CONFIG_FILE" 2>/dev/null)
else
echo "Error: Discord config file not found at $CONFIG_FILE" >&2
exit 1
fi
if [ -z "$WEBHOOK_URL" ]; then
echo "Error: Discord webhook URL not configured" >&2
exit 1
fi
# Response file for bot communication
RESPONSE_FILE="/tmp/claude-discord-response-$$"
MESSAGE_ID_FILE="/tmp/claude-discord-message-id-$$"
# Colors for different notification types
case "$TYPE" in
"complete")
COLOR="5763719" # Green
TITLE="✅ 作業完了"
EMOJI="✅"
;;
"waiting")
COLOR="16705372" # Orange
TITLE="⏳ 入力待機中"
EMOJI="⏳"
;;
"permission")
COLOR="15105570" # Red
TITLE="🔐 権限要求"
EMOJI="🔐"
;;
"error")
COLOR="15158332" # Dark Red
TITLE="❌ エラー"
EMOJI="❌"
;;
*)
COLOR="3447003" # Blue
TITLE="ℹ️ 通知"
EMOJI="ℹ️"
;;
esac
# Get current timestamp
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
# Get additional context information
HOSTNAME=$(hostname)
USER=$(whoami)
PWD=$(pwd)
# Create rich embed JSON
create_embed() {
local question_type=""
local action_required=""
# Determine question type and required action
case "$TYPE" in
"permission")
question_type="コマンド実行の許可"
action_required="✅ 承認 | ❌ 拒否"
;;
"waiting")
question_type="ユーザー入力待ち"
action_required="💬 返信が必要"
;;
*)
question_type="情報"
action_required="アクションは不要"
;;
esac
cat <<EOF
{
"username": "Claude Code Assistant",
"avatar_url": "https://www.anthropic.com/images/icons/apple-touch-icon.png",
"content": "$EMOJI **Claude Code からの通知**",
"embeds": [{
"title": "$TITLE",
"description": "$MESSAGE",
"color": $COLOR,
"fields": [
{
"name": "📁 作業ディレクトリ",
"value": "\`$PWD\`",
"inline": true
},
{
"name": "👤 ユーザー",
"value": "\`$USER@$HOSTNAME\`",
"inline": true
},
{
"name": "📝 詳細",
"value": "${DETAILS:-なし}",
"inline": false
},
{
"name": "🎯 必要なアクション",
"value": "$action_required",
"inline": false
}
],
"footer": {
"text": "Claude Code Hook System",
"icon_url": "https://www.anthropic.com/favicon.ico"
},
"timestamp": "$TIMESTAMP"
}]
}
EOF
}
# Send notification to Discord
send_notification() {
local response=$(curl -s -X POST "$WEBHOOK_URL?wait=true" \
-H "Content-Type: application/json" \
-d "$(create_embed)")
# Extract message ID from response
local message_id=$(echo "$response" | jq -r '.id' 2>/dev/null)
if [ -n "$message_id" ] && [ "$message_id" != "null" ]; then
echo "$message_id" > "$MESSAGE_ID_FILE"
echo "Discord notification sent. Message ID: $message_id" >&2
# For permission/waiting types, add reaction options
if [ "$TYPE" = "permission" ] || [ "$TYPE" = "waiting" ]; then
# Store metadata for bot to process
cat <<EOF > "${MESSAGE_ID_FILE}.meta"
{
"message_id": "$message_id",
"type": "$TYPE",
"process_id": "$$",
"response_file": "$RESPONSE_FILE",
"timestamp": $(date +%s)
}
EOF
fi
else
echo "Failed to send Discord notification" >&2
return 1
fi
}
# Wait for response (for interactive notifications)
wait_for_response() {
if [ "$TYPE" = "permission" ] || [ "$TYPE" = "waiting" ]; then
echo "Waiting for Discord response at $RESPONSE_FILE..." >&2
# Wait for response file to be created by bot
local timeout=300 # 5 minutes timeout
local elapsed=0
while [ ! -f "$RESPONSE_FILE" ] && [ $elapsed -lt $timeout ]; do
sleep 1
elapsed=$((elapsed + 1))
done
if [ -f "$RESPONSE_FILE" ]; then
local response=$(cat "$RESPONSE_FILE")
echo "Received response: $response" >&2
# Clean up
rm -f "$RESPONSE_FILE" "${MESSAGE_ID_FILE}" "${MESSAGE_ID_FILE}.meta"
# Return appropriate exit code based on response
case "$response" in
"approved"|"yes"|"true")
exit 0
;;
"denied"|"no"|"false")
exit 1
;;
*)
exit 2
;;
esac
else
echo "Timeout waiting for response" >&2
rm -f "${MESSAGE_ID_FILE}" "${MESSAGE_ID_FILE}.meta"
exit 3
fi
fi
}
# Main execution
send_notification
wait_for_response
projects/claude-discord-bot/run.py
#!/usr/bin/env python3
"""Entry point for the Discord bot"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from claude_discord_bot.main import main
import asyncio
if __name__ == "__main__":
asyncio.run(main())
projects/claude-discord-bot/src/claude_discord_bot/main.py
#!/usr/bin/env python3
"""
Claude Code Discord Bot
Monitors reactions on Claude Code notifications and sends responses back
"""
import asyncio
import json
import os
import sys
import time
from pathlib import Path
from typing import Dict, Optional
import discord
from discord.ext import commands, tasks
# Load configuration
CONFIG_PATH = Path.home() / ".claude" / "discord-config.json"
TEMP_DIR = Path("/tmp")
class ClaudeDiscordBot(commands.Bot):
def __init__(self):
# Use minimal intents - we only need to track reactions
intents = discord.Intents.default()
intents.reactions = True
intents.guilds = True
super().__init__(command_prefix='!', intents=intents)
self.config = self.load_config()
self.pending_messages: Dict[str, dict] = {}
def load_config(self) -> dict:
"""Load configuration from JSON file"""
if not CONFIG_PATH.exists():
print(f"Error: Config file not found at {CONFIG_PATH}")
sys.exit(1)
with open(CONFIG_PATH) as f:
return json.load(f)
async def on_ready(self):
"""Bot is ready and connected"""
print(f'Bot connected as {self.user}')
self.check_message_files.start()
@tasks.loop(seconds=1)
async def check_message_files(self):
"""Check for new message metadata files"""
meta_files = list(TEMP_DIR.glob("claude-discord-message-id-*.meta"))
if meta_files:
print(f"Found {len(meta_files)} metadata files")
for meta_file in meta_files:
try:
with open(meta_file) as f:
metadata = json.load(f)
message_id = metadata['message_id']
# Skip if already tracking
if message_id in self.pending_messages:
continue
# Check if message is not too old (5 minutes)
if time.time() - metadata['timestamp'] > 300:
meta_file.unlink()
continue
# Add to tracking
self.pending_messages[message_id] = metadata
# Add reactions to the message
channel = self.get_channel(int(self.config['channel_id']))
if channel:
try:
message = await channel.fetch_message(int(message_id))
if metadata['type'] == 'permission':
await message.add_reaction(self.config['reactions']['approve'])
await message.add_reaction(self.config['reactions']['deny'])
elif metadata['type'] == 'waiting':
# For waiting type, also add approve/deny options
await message.add_reaction(self.config['reactions']['approve'])
await message.add_reaction(self.config['reactions']['deny'])
await message.add_reaction('💬')
except discord.NotFound:
print(f"Message {message_id} not found")
del self.pending_messages[message_id]
meta_file.unlink()
except Exception as e:
print(f"Error processing meta file {meta_file}: {e}")
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
"""Handle reaction additions"""
# Ignore bot's own reactions
if payload.user_id == self.user.id:
return
# Check if user is allowed
if str(payload.user_id) not in self.config.get('allowed_users', []):
return
message_id = str(payload.message_id)
# Check if this is a tracked message
if message_id not in self.pending_messages:
return
metadata = self.pending_messages[message_id]
response_file = Path(metadata['response_file'])
# Determine response based on reaction
emoji = str(payload.emoji)
response = None
if emoji == self.config['reactions']['approve']:
response = "approved"
elif emoji == self.config['reactions']['deny']:
response = "denied"
elif emoji == '💬':
# For waiting type, just acknowledge
response = "acknowledged"
if response:
print(f"Writing response '{response}' to {response_file}")
# Write response to file
try:
response_file.write_text(response)
print(f"Successfully wrote response to {response_file}")
except Exception as e:
print(f"Error writing response: {e}")
# Clean up tracking
del self.pending_messages[message_id]
# Update message to show it was processed
channel = self.get_channel(payload.channel_id)
if channel:
try:
message = await channel.fetch_message(payload.message_id)
# Add a checkmark to show it was processed
await message.add_reaction('☑️')
# Edit the message to show who responded
user = await self.fetch_user(payload.user_id)
if message.embeds:
embed = message.embeds[0]
embed.add_field(
name="✅ 処理済み",
value=f"処理者: {user.mention}\n応答: {response}",
inline=False
)
await message.edit(embed=embed)
except discord.NotFound:
pass
async def cleanup_old_messages(self):
"""Clean up old pending messages"""
current_time = time.time()
to_remove = []
for message_id, metadata in self.pending_messages.items():
if current_time - metadata['timestamp'] > self.config.get('reaction_timeout', 300):
to_remove.append(message_id)
# Write timeout response
response_file = Path(metadata['response_file'])
response_file.write_text("timeout")
for message_id in to_remove:
del self.pending_messages[message_id]
async def main():
"""Main entry point"""
bot = ClaudeDiscordBot()
try:
await bot.start(bot.config['bot_token'])
except KeyboardInterrupt:
await bot.close()
except Exception as e:
print(f"Error: {e}")
await bot.close()
if __name__ == "__main__":
asyncio.run(main())
.claude/discord-config.example.json
{
"webhook_url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN",
"bot_token": "YOUR_BOT_TOKEN",
"channel_id": "YOUR_CHANNEL_ID",
"allowed_users": ["YOUR_DISCORD_USER_ID"],
"reaction_timeout": 300,
"reactions": {
"approve": "✅",
"deny": "❌",
"pause": "⏸️"
}
}
これで作業完了・ユーザー許可を求めるメッセージがDiscordに流れるようにはなった。
が、リアクションをDiscord上でしても、Discord botがそれを検知こそすれど、claudeを実行している端末に応答が返ることは無い。
Pythonスクリプトだけいかにあげた。
まあ通知が来るだけでよしとは言えるが、Discordに通知を出すならDiscordでClaude Codeと相互作用をしたい。
そこで
がよさそう
多分最初からこれでよかった

会話のログをDiscordに流すtoolsを追加した
明示的にこの会話をDiscordに流して、と指定すればしばらくDiscordに作ったスレッドにメッセージを流してくれる
human-in-the-loopとは主目的とみる画面が違う気がするので、別レポジトリ(別のMCP)として作る方がいい気がしてきた
実装を参考にさせていただきながら、新規レポジトリにする

作った
作業ディレクトリもスレッド名に出しておかないと何のログだかわかんないよなってことで出した

Slack版もほしい
作った
動いてるOK