discord-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ {
2
+ "channelId": "your_channel_id_here"
3
+ }
package/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ DISCORD_BRIDGE_TOKEN=your_bot_token_here
2
+ DISCORD_BRIDGE_USER_ID=your_user_id_here
3
+ DISCORD_BRIDGE_PORT=13456
@@ -0,0 +1,40 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 24
19
+ registry-url: https://registry.npmjs.org
20
+
21
+ - name: Check if version changed
22
+ id: version
23
+ run: |
24
+ LOCAL_VERSION=$(node -p "require('./package.json').version")
25
+ NPM_VERSION=$(npm view discord-bridge version 2>/dev/null || echo "0.0.0")
26
+ if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
27
+ echo "changed=true" >> "$GITHUB_OUTPUT"
28
+ echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
29
+ else
30
+ echo "changed=false" >> "$GITHUB_OUTPUT"
31
+ echo "Version unchanged: $LOCAL_VERSION"
32
+ fi
33
+
34
+ - name: Install dependencies
35
+ if: steps.version.outputs.changed == 'true'
36
+ run: npm ci
37
+
38
+ - name: Publish
39
+ if: steps.version.outputs.changed == 'true'
40
+ run: npm publish --provenance --access public
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # discord-bridge
2
+
3
+ Claude Code と Discord の双方向通信ブリッジ。スマホから Claude Code を操作できます。
4
+
5
+ ## 機能
6
+
7
+ - **質問 & 返答** - Discord で質問して返答を待つ
8
+ - **通知** - 進捗・成功・警告・エラーを通知
9
+ - **ファイル共有** - ファイルを Discord に送信
10
+ - **メッセージ取得** - ユーザーからの指示を取得
11
+ - **リアルタイム待機** - SSE 接続で次の指示を待機
12
+ - **離席モード** - ターミナルを離れても Discord 経由で操作
13
+ - **Hooks** - 通知・停止イベントを自動で Discord に転送
14
+
15
+ ## インストール
16
+
17
+ ```bash
18
+ npm install -g discord-bridge
19
+ ```
20
+
21
+ ## セットアップ
22
+
23
+ ### 1. Discord Bot 作成
24
+
25
+ 1. https://discord.com/developers/applications にアクセス
26
+ 2. "New Application" をクリック → 名前を入力して作成
27
+ 3. 左メニュー "Bot" → "Reset Token" → トークンをコピー
28
+ 4. "Privileged Gateway Intents" で **MESSAGE CONTENT INTENT** を有効化
29
+ 5. 左メニュー "OAuth2" → "URL Generator"
30
+ - Scopes: `bot`
31
+ - Bot Permissions: `Send Messages`, `Read Message History`, `Attach Files`, `Embed Links`
32
+ 6. 生成された URL を開いて Bot をサーバーに追加
33
+
34
+ ### 2. ID の取得
35
+
36
+ Discord の設定 → 詳細設定 → **開発者モード** を有効化してから:
37
+
38
+ - **チャンネル ID**: チャンネル名を右クリック → "チャンネル ID をコピー"
39
+ - **ユーザー ID**: 自分の名前を右クリック → "ユーザー ID をコピー"
40
+
41
+ ### 3. 環境変数の設定
42
+
43
+ `~/.zshrc` に追加:
44
+
45
+ ```bash
46
+ export DISCORD_BRIDGE_TOKEN="your_bot_token_here"
47
+ export DISCORD_BRIDGE_USER_ID="your_user_id_here"
48
+ ```
49
+
50
+ 設定後: `source ~/.zshrc`
51
+
52
+ ### 4. プロジェクト設定
53
+
54
+ Claude Code を使うプロジェクトのルートに `.discord-bridge.json` を作成:
55
+
56
+ ```json
57
+ {
58
+ "channelId": "your_channel_id_here"
59
+ }
60
+ ```
61
+
62
+ ### 5. Claude Code プラグインのインストール
63
+
64
+ ```bash
65
+ discord-bridge install
66
+ ```
67
+
68
+ Claude Code を再起動してプラグインを読み込みます。
69
+
70
+ ## 使い方
71
+
72
+ ### サーバーの起動
73
+
74
+ ```bash
75
+ discord-bridge start
76
+ ```
77
+
78
+ ### ヘルスチェック
79
+
80
+ ```bash
81
+ discord-bridge status
82
+ ```
83
+
84
+ ### Claude Code での利用
85
+
86
+ Claude Code で以下のように話しかけてください:
87
+
88
+ - 「Discord にテスト通知を送って」 - 通知テスト
89
+ - 「離席する」 - 離席モード(Discord 経由で操作)
90
+
91
+ ## トラブルシューティング
92
+
93
+ - **Bot がオフライン**: `DISCORD_BRIDGE_TOKEN` が正しいか確認
94
+ - **メッセージが届かない**: MESSAGE CONTENT INTENT が有効か確認
95
+ - **チャンネルが見つからない**: `.discord-bridge.json` の `channelId` が正しいか確認
96
+ - **返答が受信されない**: `DISCORD_BRIDGE_USER_ID` が正しいか確認
97
+
98
+ ## ライセンス
99
+
100
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ import { spawn } from "child_process";
6
+ import { cpSync, existsSync, mkdirSync } from "fs";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const packageRoot = join(__dirname, "..");
11
+
12
+ const command = process.argv[2];
13
+
14
+ switch (command) {
15
+ case "start": {
16
+ const serverPath = join(packageRoot, "server", "index.js");
17
+ const child = spawn("node", [serverPath], {
18
+ stdio: "inherit",
19
+ env: process.env,
20
+ });
21
+ child.on("exit", (code) => process.exit(code ?? 0));
22
+ break;
23
+ }
24
+
25
+ case "install": {
26
+ const home = process.env.HOME;
27
+ if (!home) {
28
+ console.error("HOME environment variable is not set");
29
+ process.exit(1);
30
+ }
31
+ const target = join(home, ".claude", "plugins", "discord-bridge");
32
+ const source = join(packageRoot, "plugin");
33
+
34
+ if (!existsSync(source)) {
35
+ console.error("Plugin files not found");
36
+ process.exit(1);
37
+ }
38
+
39
+ mkdirSync(target, { recursive: true });
40
+ cpSync(source, target, { recursive: true });
41
+ console.log(`Plugin installed to ${target}`);
42
+ console.log("");
43
+ console.log("Restart Claude Code to load the plugin.");
44
+ break;
45
+ }
46
+
47
+ case "status": {
48
+ const port = process.env.DISCORD_BRIDGE_PORT || "13456";
49
+ try {
50
+ const res = await fetch(`http://localhost:${port}/health`);
51
+ const data = await res.json();
52
+ console.log(JSON.stringify(data, null, 2));
53
+ } catch {
54
+ console.error("Server is not running");
55
+ process.exit(1);
56
+ }
57
+ break;
58
+ }
59
+
60
+ default:
61
+ console.log("discord-bridge - Claude Code <-> Discord communication bridge");
62
+ console.log("");
63
+ console.log("Usage: discord-bridge <command>");
64
+ console.log("");
65
+ console.log("Commands:");
66
+ console.log(" start Start the Discord bridge server");
67
+ console.log(" install Install Claude Code plugin to ~/.claude/plugins/");
68
+ console.log(" status Check server health");
69
+ break;
70
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "discord-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code <-> Discord bidirectional communication bridge",
5
+ "type": "module",
6
+ "bin": {
7
+ "discord-bridge": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server/index.js"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "discord",
15
+ "bridge",
16
+ "cli"
17
+ ],
18
+ "author": "naichi",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "discord.js": "^14.16.0",
22
+ "express": "^5.0.0"
23
+ }
24
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "discord-bridge",
3
+ "description": "Bidirectional communication bridge between Claude Code and Discord. Send questions, receive instructions, share files, and get progress updates through a dedicated Discord channel.",
4
+ "version": "0.1.0",
5
+ "author": {
6
+ "name": "naichi"
7
+ }
8
+ }
@@ -0,0 +1,17 @@
1
+ # Discord Bridge Plugin for Claude Code
2
+
3
+ Claude Code と Discord の双方向通信ブリッジ。スマホから Claude Code を操作できます。
4
+
5
+ ## セットアップ
6
+
7
+ このプラグインは `discord-bridge install` コマンドで自動的にインストールされます。
8
+
9
+ 詳しいセットアップ手順はリポジトリの README を参照してください:
10
+ https://github.com/naichilab/discord-bridge-server
11
+
12
+ ## 使い方
13
+
14
+ Claude Code で以下のように話しかけてください:
15
+
16
+ - 「Discord にテスト通知を送って」 - 通知テスト
17
+ - 「離席する」 - 離席モード(Discord 経由で操作)
@@ -0,0 +1,27 @@
1
+ {
2
+ "description": "Discord bridge hooks for forwarding notifications and stop events",
3
+ "hooks": {
4
+ "Notification": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/notify-discord.sh",
10
+ "timeout": 15
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "Stop": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-hook.sh",
21
+ "timeout": 10
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Read hook input from stdin
5
+ HOOK_INPUT=$(cat)
6
+
7
+ DISCORD_TOKEN="${DISCORD_TOKEN:-}"
8
+ DISCORD_CHANNEL_ID="${DISCORD_CHANNEL_ID:-}"
9
+
10
+ if [[ -z "$DISCORD_TOKEN" ]] || [[ -z "$DISCORD_CHANNEL_ID" ]]; then
11
+ exit 0
12
+ fi
13
+
14
+ # Extract notification message
15
+ NOTIFICATION=$(echo "$HOOK_INPUT" | /usr/bin/python3 -c "
16
+ import sys, json
17
+ try:
18
+ data = json.load(sys.stdin)
19
+ print(data.get('notification', data.get('message', 'Claude Code notification')))
20
+ except:
21
+ print('Claude Code notification')
22
+ " 2>/dev/null || echo "Claude Code notification")
23
+
24
+ # Send via Discord REST API
25
+ curl -s -X POST \
26
+ "https://discord.com/api/v10/channels/${DISCORD_CHANNEL_ID}/messages" \
27
+ -H "Authorization: Bot ${DISCORD_TOKEN}" \
28
+ -H "Content-Type: application/json" \
29
+ -d "$(/usr/bin/python3 -c "
30
+ import json, sys
31
+ msg = sys.argv[1]
32
+ print(json.dumps({'embeds': [{'title': 'Notification', 'description': msg, 'color': 3447003}]}))
33
+ " "$NOTIFICATION")" \
34
+ > /dev/null 2>&1
35
+
36
+ exit 0
@@ -0,0 +1,35 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ HOOK_INPUT=$(cat)
5
+
6
+ DISCORD_TOKEN="${DISCORD_TOKEN:-}"
7
+ DISCORD_CHANNEL_ID="${DISCORD_CHANNEL_ID:-}"
8
+
9
+ if [[ -z "$DISCORD_TOKEN" ]] || [[ -z "$DISCORD_CHANNEL_ID" ]]; then
10
+ exit 0
11
+ fi
12
+
13
+ # Extract stop reason
14
+ REASON=$(echo "$HOOK_INPUT" | /usr/bin/python3 -c "
15
+ import sys, json
16
+ try:
17
+ data = json.load(sys.stdin)
18
+ print(data.get('reason', data.get('stopReason', 'Task completed')))
19
+ except:
20
+ print('Task completed')
21
+ " 2>/dev/null || echo "Task completed")
22
+
23
+ # Send stop notification
24
+ curl -s -X POST \
25
+ "https://discord.com/api/v10/channels/${DISCORD_CHANNEL_ID}/messages" \
26
+ -H "Authorization: Bot ${DISCORD_TOKEN}" \
27
+ -H "Content-Type: application/json" \
28
+ -d "$(/usr/bin/python3 -c "
29
+ import json, sys
30
+ msg = sys.argv[1]
31
+ print(json.dumps({'embeds': [{'title': 'Claude Code Stopped', 'description': msg, 'color': 15158332}]}))
32
+ " "$REASON")" \
33
+ > /dev/null 2>&1
34
+
35
+ exit 0
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: discord-communication
3
+ description: This skill should be used when the user asks to "communicate via Discord", "send a message to Discord", "check Discord for instructions", "ask a question on Discord", "notify on Discord", "share a file on Discord", "離席する", "離席モード", "Discord で待機して", "Discord待機", or when Claude Code needs to reach the user who is away from the terminal.
4
+ version: 3.0.0
5
+ ---
6
+
7
+ # Discord Communication Bridge
8
+
9
+ Discord 専用チャンネルを通じてユーザーとやりとりするためのスキル。
10
+ HTTP API サーバー (`localhost:13456`) 経由で Discord Bot と通信する。
11
+
12
+ ## 前提条件
13
+
14
+ サーバーが別プロセスで起動済みであること。
15
+ 起動していない場合は `discord-bridge start` で起動を促す。
16
+
17
+ ## ヘルパースクリプト
18
+
19
+ `$CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/` にラッパースクリプトを用意。
20
+ Bash ツールで直接実行する。
21
+
22
+ ## 使用方法
23
+
24
+ ### ヘルスチェック
25
+
26
+ ```bash
27
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-status.sh
28
+ ```
29
+
30
+ ### 通知(一方向)
31
+
32
+ ```bash
33
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-notify.sh "ビルド完了しました" success
34
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-notify.sh "エラーが発生しました" error
35
+ ```
36
+
37
+ レベル: `info`(既定), `success`, `warning`, `error`
38
+
39
+ ### 質問(返答待ち)
40
+
41
+ ```bash
42
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-ask.sh "続行しますか?"
43
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-ask.sh "どの方法で進めますか?" 300 "方法A" "方法B" "方法C"
44
+ ```
45
+
46
+ ### メッセージ取得
47
+
48
+ ```bash
49
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-messages.sh
50
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-messages.sh 20 true
51
+ ```
52
+
53
+ ### メッセージ待機 (SSE)
54
+
55
+ ```bash
56
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-wait.sh
57
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-wait.sh 21600
58
+ ```
59
+
60
+ デフォルトタイムアウト: 6時間 (21600秒)。SSE接続でメッセージをリアルタイム受信する。
61
+
62
+ ### ファイル送信
63
+
64
+ ```bash
65
+ bash $CLAUDE_PLUGIN_ROOT/skills/discord-comm/scripts/discord-send-file.sh /path/to/file.png "スクリーンショット"
66
+ ```
67
+
68
+ ## パターン
69
+
70
+ ### 長時間タスク
71
+
72
+ 1. `discord-notify.sh` (info) で開始を通知
73
+ 2. 定期的に `discord-messages.sh` で指示変更を確認
74
+ 3. `discord-notify.sh` (success) で完了通知
75
+ 4. 判断が必要な場合は `discord-ask.sh`
76
+
77
+ ### エラー発生時
78
+
79
+ 1. `discord-notify.sh` (error) でエラー報告
80
+ 2. `discord-ask.sh` で対応方針を確認
81
+ 3. タイムアウト時は返答が来るまで再度 `discord-ask.sh` を呼んで待機する
82
+
83
+ ### 待機モード
84
+
85
+ 1. `discord-notify.sh` で状況をまとめて報告
86
+ 2. `discord-wait.sh` で次の指示を待つ
87
+ 3. 受信した指示を実行
88
+
89
+ ## 離席モード(Discord 待受ループ)
90
+
91
+ ユーザーが「離席する」「Discord で待機して」等と言った場合、以下のループに入る。
92
+
93
+ ### 開始手順
94
+
95
+ 1. ヘルスチェックでサーバー接続を確認
96
+ 2. Discord に「離席モード開始」を通知
97
+ 3. ループ開始
98
+
99
+ ### ループ動作
100
+
101
+ 以下を繰り返す。**タイムアウトしても勝手に終了せず、再度待機する。**
102
+
103
+ ```
104
+ loop:
105
+ 1. discord-wait.sh (timeout: 21600) でメッセージを待つ(SSE接続、6時間)
106
+ ※ SSE接続時にサーバーが自動で「クライアント接続」をDebug通知する
107
+ レスポンス形式: {"status":"received","messages":[...]} (配列で複数件返る可能性あり)
108
+ 2. タイムアウトした場合 → 1 に戻る
109
+ 4. メッセージを受信した場合:
110
+ a. 全メッセージを確認し、最新のメッセージを指示として扱う
111
+ b. ただし「戻ったよ」「終了」「おわり」「bye」等の終了指示が含まれていれば → ループ終了
112
+ c. 「やめて」「中断」「ストップ」等のキャンセル指示が含まれていれば → その前の指示は無視
113
+ d. それ以外なら → 最新のメッセージを指示として処理を実行(下記「処理中の動作ルール」参照)
114
+ 5. 処理完了後 → 1 に戻る
115
+ ```
116
+
117
+ ### 処理中の動作ルール
118
+
119
+ 離席モードではターミナルが見えないため、**すべての発言・思考・行動を Discord に送る。**
120
+
121
+ #### 全出力転送
122
+
123
+ 通常ターミナルに表示するテキスト出力は、すべて `discord-notify.sh` で Discord にも送る。
124
+
125
+ - ※ 指示の受信・伝達は中継サーバーが自動通知するため手動通知は不要
126
+ - 指示の解釈と対応方針 → `"〇〇と解釈。△△で対応します"` (info) **← 必須**
127
+ - 処理の各ステップ → `"〇〇を実行中..."` (info)
128
+ - ツール実行結果の要約 → `"コンパイル結果: エラー0件"` (info)
129
+ - 判断・方針の説明 → `"〇〇のため、△△の方法で進めます"` (info)
130
+ - 処理完了 → 結果のまとめ (success) **← 必須**
131
+ - エラー発生 → エラー内容 (error) → `discord-ask.sh` で対応を確認
132
+
133
+ メッセージは簡潔にまとめつつ、何をしているか追えるレベルの粒度で送る。
134
+ 1つのツール実行ごとに1通知が目安。ただし連続する軽微な操作はまとめてよい。
135
+
136
+ #### 中断チェック(割り込み検知)
137
+
138
+ 処理中、ユーザーが Discord で「止まって」「ストップ」「中断」等を送る可能性がある。
139
+ **各ステップの合間に `discord-messages.sh` でキューをチェック**し、中断指示がないか確認する。
140
+
141
+ ```
142
+ チェックタイミング:
143
+ - ツール実行の前(Bash, Edit, Write 等を呼ぶ直前)
144
+ - 長いループの各イテレーション
145
+ - 判断ポイント(次に何をするか考える時)
146
+ ```
147
+
148
+ 中断指示を検出した場合:
149
+ 1. 現在の処理を安全な状態で止める
150
+ 2. `discord-notify.sh` (warning) で「中断しました。現在の状態: 〇〇」を報告
151
+ 3. `discord-wait.sh` で次の指示を待つ
152
+
153
+ 中断と判定するキーワード: 「止まって」「ストップ」「stop」「中断」「待って」「やめて」
154
+
155
+ #### ユーザー判断が必要な場合
156
+
157
+ - `discord-ask.sh` で質問し、選択肢を提示する
158
+ - 破壊的な操作(git push, ファイル削除等)は必ず確認を取る
159
+ - 不明確な指示は処理を始める前に明確化する
160
+
161
+ ### 終了時
162
+
163
+ 1. 離席モード中に行った作業のまとめを作成する
164
+ 2. そのまとめを Discord に `discord-notify.sh` で送信する
165
+ 3. 同じまとめをターミナルにもテキスト出力する
166
+ 4. Discord に「離席モード終了。ターミナルに戻ります。」を通知し、通常のターミナル入力待ちに戻る。
167
+
168
+ ### 注意事項
169
+
170
+ - 離席モード中もターミナルの作業ディレクトリは維持される
171
+ - 1回の指示で処理が完結するよう心がける
172
+ - Discord のメッセージ上限(2000文字)を超える場合は分割して送信する
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # Discord に質問を送信し返答を待つ
3
+ # Usage: discord-ask.sh "質問" [timeout_seconds] [option1] [option2] ...
4
+ QUESTION="${1:?Usage: discord-ask.sh QUESTION [TIMEOUT] [OPTIONS...]}"
5
+ TIMEOUT="${2:-300}"
6
+ shift 2 2>/dev/null || true
7
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
8
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
9
+ CHANNEL_ID=""
10
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
11
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
12
+ fi
13
+
14
+ JSON=$(python3 -c "
15
+ import json, sys
16
+ d = {'question': sys.argv[1], 'timeout_seconds': int(sys.argv[2])}
17
+ opts = sys.argv[4:]
18
+ if opts:
19
+ d['options'] = opts
20
+ if sys.argv[3]:
21
+ d['channelId'] = sys.argv[3]
22
+ print(json.dumps(d, ensure_ascii=False))
23
+ " "$QUESTION" "$TIMEOUT" "$CHANNEL_ID" "$@")
24
+
25
+ curl -s -X POST "http://localhost:${PORT}/ask" \
26
+ -H "Content-Type: application/json" \
27
+ -d "$JSON" \
28
+ --max-time "$((TIMEOUT + 5))"
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # キューに溜まった Discord メッセージを取得
3
+ # Usage: discord-messages.sh [count] [include_history]
4
+ COUNT="${1:-10}"
5
+ HISTORY="${2:-false}"
6
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
7
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
8
+ CHANNEL_ID=""
9
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
10
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
11
+ fi
12
+
13
+ QUERY="count=${COUNT}&include_history=${HISTORY}"
14
+ if [ -n "$CHANNEL_ID" ]; then
15
+ QUERY="${QUERY}&channelId=${CHANNEL_ID}"
16
+ fi
17
+
18
+ curl -s "http://localhost:${PORT}/messages?${QUERY}"
@@ -0,0 +1,26 @@
1
+ #!/bin/bash
2
+ # Discord に通知を送信
3
+ # Usage: discord-notify.sh "メッセージ" [info|success|warning|error] ["タイトル"]
4
+ MESSAGE="${1:?Usage: discord-notify.sh MESSAGE [LEVEL] [TITLE]}"
5
+ LEVEL="${2:-info}"
6
+ TITLE="${3:-}"
7
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
8
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
9
+ CHANNEL_ID=""
10
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
11
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
12
+ fi
13
+
14
+ JSON=$(python3 -c "
15
+ import json, sys
16
+ d = {'message': sys.argv[1], 'level': sys.argv[2]}
17
+ if sys.argv[3]:
18
+ d['title'] = sys.argv[3]
19
+ if sys.argv[4]:
20
+ d['channelId'] = sys.argv[4]
21
+ print(json.dumps(d, ensure_ascii=False))
22
+ " "$MESSAGE" "$LEVEL" "$TITLE" "$CHANNEL_ID")
23
+
24
+ curl -s -X POST "http://localhost:${PORT}/notify" \
25
+ -H "Content-Type: application/json" \
26
+ -d "$JSON"
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+ # Discord にファイルを送信
3
+ # Usage: discord-send-file.sh /path/to/file ["メッセージ"]
4
+ FILE_PATH="${1:?Usage: discord-send-file.sh FILE_PATH [MESSAGE]}"
5
+ MESSAGE="${2:-}"
6
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
7
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
8
+ CHANNEL_ID=""
9
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
10
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
11
+ fi
12
+
13
+ JSON=$(python3 -c "
14
+ import json, sys
15
+ d = {'file_path': sys.argv[1]}
16
+ if sys.argv[2]:
17
+ d['message'] = sys.argv[2]
18
+ if sys.argv[3]:
19
+ d['channelId'] = sys.argv[3]
20
+ print(json.dumps(d, ensure_ascii=False))
21
+ " "$FILE_PATH" "$MESSAGE" "$CHANNEL_ID")
22
+
23
+ curl -s -X POST "http://localhost:${PORT}/send-file" \
24
+ -H "Content-Type: application/json" \
25
+ -d "$JSON"
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # Discord Bridge ヘルスチェック
3
+ # Usage: discord-status.sh
4
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
5
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
6
+ CHANNEL_ID=""
7
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
8
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
9
+ fi
10
+
11
+ if [ -n "$CHANNEL_ID" ]; then
12
+ curl -s "http://localhost:${PORT}/health?channelId=${CHANNEL_ID}"
13
+ else
14
+ curl -s "http://localhost:${PORT}/health"
15
+ fi
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # Discord で次のメッセージを待機 (SSE notification + queue fetch)
3
+ # キューに複数メッセージがある場合は全件を返す
4
+ # Usage: discord-wait.sh [timeout_seconds]
5
+ TIMEOUT="${1:-21600}"
6
+ PORT="${DISCORD_BRIDGE_PORT:-13456}"
7
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
8
+ CHANNEL_ID=""
9
+ if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/.discord-bridge.json" ]; then
10
+ CHANNEL_ID=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/.discord-bridge.json'))['channelId'])" 2>/dev/null)
11
+ fi
12
+
13
+ if [ -z "$CHANNEL_ID" ]; then
14
+ echo '{"status":"error","error":"channelId not found in .discord-bridge.json"}'
15
+ exit 1
16
+ fi
17
+
18
+ BASE_URL="http://localhost:${PORT}"
19
+
20
+ # Health check — サーバーが起動していなければ即エラー終了
21
+ HEALTH=$(curl -sf "${BASE_URL}/health?channelId=${CHANNEL_ID}" 2>/dev/null)
22
+ if [ $? -ne 0 ]; then
23
+ echo '{"status":"error","error":"Discord bridge server is not running. Start it with: bash ~/projects/discord-bridge-server/start.sh"}'
24
+ exit 1
25
+ fi
26
+
27
+ # First check if there are already queued messages
28
+ QUEUED=$(curl -s "${BASE_URL}/messages?channelId=${CHANNEL_ID}&count=50")
29
+ RESULT=$(echo "$QUEUED" | python3 -c "
30
+ import json, sys
31
+ d = json.load(sys.stdin)
32
+ if d.get('count', 0) > 0:
33
+ print(json.dumps({'status': 'received', 'messages': d['messages']}, ensure_ascii=False))
34
+ " 2>/dev/null)
35
+
36
+ if [ -n "$RESULT" ]; then
37
+ echo "$RESULT"
38
+ exit 0
39
+ fi
40
+
41
+ # No queued messages — connect to SSE and wait for notify event
42
+ NOTIFIED=$(curl -s -N "${BASE_URL}/events?channelId=${CHANNEL_ID}" \
43
+ --max-time "$((TIMEOUT + 5))" 2>/dev/null \
44
+ | awk '/^event: notify$/ { print "1"; exit }')
45
+
46
+ if [ "$NOTIFIED" = "1" ]; then
47
+ # Fetch all messages from queue (reliable delivery)
48
+ QUEUED=$(curl -s "${BASE_URL}/messages?channelId=${CHANNEL_ID}&count=50")
49
+ RESULT=$(echo "$QUEUED" | python3 -c "
50
+ import json, sys
51
+ d = json.load(sys.stdin)
52
+ if d.get('count', 0) > 0:
53
+ print(json.dumps({'status': 'received', 'messages': d['messages']}, ensure_ascii=False))
54
+ " 2>/dev/null)
55
+
56
+ if [ -n "$RESULT" ]; then
57
+ echo "$RESULT"
58
+ else
59
+ echo '{"status":"error","error":"Notification received but no message in queue"}'
60
+ fi
61
+ else
62
+ echo "{\"status\":\"timeout\",\"error\":\"No reply received within ${TIMEOUT} seconds\"}"
63
+ fi
@@ -0,0 +1,478 @@
1
+ import express from "express";
2
+ import {
3
+ Client,
4
+ GatewayIntentBits,
5
+ EmbedBuilder,
6
+ AttachmentBuilder,
7
+ } from "discord.js";
8
+ import { readFile, stat } from "fs/promises";
9
+ import path from "path";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Configuration
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const CONFIG = {
16
+ token: process.env.DISCORD_BRIDGE_TOKEN,
17
+ userId: process.env.DISCORD_BRIDGE_USER_ID,
18
+ host: "127.0.0.1",
19
+ port: parseInt(process.env.DISCORD_BRIDGE_PORT || "13456", 10),
20
+ defaultTimeout: 5 * 60 * 1000,
21
+ maxTimeout: 30 * 60 * 1000,
22
+ maxFileSize: 8 * 1024 * 1024,
23
+ maxMessageHistory: 50,
24
+ sseKeepAliveInterval: 30 * 1000,
25
+ };
26
+
27
+ function validateConfig() {
28
+ const missing = [];
29
+ if (!CONFIG.token) missing.push("DISCORD_BRIDGE_TOKEN");
30
+ if (!CONFIG.userId) missing.push("DISCORD_BRIDGE_USER_ID");
31
+ if (missing.length > 0) {
32
+ throw new Error(
33
+ `Missing required environment variables: ${missing.join(", ")}`
34
+ );
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Discord Bot
40
+ // ---------------------------------------------------------------------------
41
+
42
+ let discordClient = null;
43
+
44
+ // Per-channel state
45
+ const channelCache = new Map();
46
+ const messageQueues = new Map();
47
+ const pendingQuestions = new Map();
48
+ const sseSubscribers = new Map();
49
+
50
+ function getMessageQueue(channelId) {
51
+ if (!messageQueues.has(channelId)) {
52
+ messageQueues.set(channelId, []);
53
+ }
54
+ return messageQueues.get(channelId);
55
+ }
56
+
57
+ function getSseSubscribers(channelId) {
58
+ if (!sseSubscribers.has(channelId)) {
59
+ sseSubscribers.set(channelId, new Set());
60
+ }
61
+ return sseSubscribers.get(channelId);
62
+ }
63
+
64
+ function broadcastSseEvent(channelId, event, data) {
65
+ const subscribers = sseSubscribers.get(channelId);
66
+ if (!subscribers || subscribers.size === 0) return false;
67
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
68
+ for (const res of subscribers) {
69
+ res.write(payload);
70
+ }
71
+ return true;
72
+ }
73
+
74
+ async function fetchChannel(channelId) {
75
+ if (channelCache.has(channelId)) {
76
+ return channelCache.get(channelId);
77
+ }
78
+ const ch = await discordClient.channels.fetch(channelId);
79
+ if (!ch || !ch.isTextBased()) {
80
+ throw new Error(
81
+ `Channel ${channelId} not found or is not a text channel`
82
+ );
83
+ }
84
+ channelCache.set(channelId, ch);
85
+ return ch;
86
+ }
87
+
88
+ async function initDiscord() {
89
+ discordClient = new Client({
90
+ intents: [
91
+ GatewayIntentBits.Guilds,
92
+ GatewayIntentBits.GuildMessages,
93
+ GatewayIntentBits.MessageContent,
94
+ ],
95
+ });
96
+
97
+ discordClient.on("messageCreate", (message) => {
98
+ if (message.author.id !== CONFIG.userId) return;
99
+ if (message.author.bot) return;
100
+
101
+ const chId = message.channel.id;
102
+
103
+ const parsed = {
104
+ content: message.content,
105
+ attachments: message.attachments.map((a) => ({
106
+ name: a.name,
107
+ url: a.url,
108
+ size: a.size,
109
+ contentType: a.contentType,
110
+ })),
111
+ timestamp: message.createdAt.toISOString(),
112
+ id: message.id,
113
+ };
114
+
115
+ // Priority: pendingQuestion (/ask) > queue + SSE notify
116
+ const pending = pendingQuestions.get(chId);
117
+ if (pending) {
118
+ clearTimeout(pending.timeoutId);
119
+ pendingQuestions.delete(chId);
120
+ pending.resolve(parsed);
121
+ } else {
122
+ // Always queue (SSE is unreliable for delivery confirmation)
123
+ const queue = getMessageQueue(chId);
124
+ queue.push(parsed);
125
+ if (queue.length > CONFIG.maxMessageHistory) {
126
+ queue.shift();
127
+ }
128
+
129
+ // Notify SSE subscribers that a new message is available
130
+ broadcastSseEvent(chId, "notify", {});
131
+
132
+ // Auto-ack: show queue depth
133
+ sendDebugMessage(message.channel, `受信: キュー${queue.length}`);
134
+ }
135
+ });
136
+
137
+ await discordClient.login(CONFIG.token);
138
+
139
+ await new Promise((resolve, reject) => {
140
+ if (discordClient.isReady()) {
141
+ resolve();
142
+ return;
143
+ }
144
+ discordClient.once("ready", resolve);
145
+ discordClient.once("error", reject);
146
+ });
147
+
148
+ console.log(`[discord-bridge] Bot connected as ${discordClient.user.tag}`);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Helpers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function createEmbed({ title, description, color = 0x7c3aed, fields = [] }) {
156
+ const embed = new EmbedBuilder()
157
+ .setTitle(title)
158
+ .setDescription(description)
159
+ .setColor(color);
160
+ for (const field of fields) {
161
+ embed.addFields(field);
162
+ }
163
+ return embed;
164
+ }
165
+
166
+ async function sendDebugMessage(channel, message) {
167
+ const embed = new EmbedBuilder()
168
+ .setDescription(`📡 ${message}`)
169
+ .setColor(0x95a5a6);
170
+ return channel.send({ embeds: [embed] }).catch(() => {});
171
+ }
172
+
173
+ async function sendMessage(channelId, content, embeds = [], files = []) {
174
+ const ch = await fetchChannel(channelId);
175
+ return ch.send({ content, embeds, files });
176
+ }
177
+
178
+ function waitForReply(channelId, timeoutMs) {
179
+ return new Promise((resolve, reject) => {
180
+ const timeoutId = setTimeout(() => {
181
+ pendingQuestions.delete(channelId);
182
+ reject(
183
+ new Error(`No reply received within ${timeoutMs / 1000} seconds`)
184
+ );
185
+ }, timeoutMs);
186
+ pendingQuestions.set(channelId, { resolve, reject, timeoutId });
187
+ });
188
+ }
189
+
190
+ function resolveChannelId(req) {
191
+ return req.body?.channelId || req.query?.channelId || null;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // HTTP API
196
+ // ---------------------------------------------------------------------------
197
+
198
+ const app = express();
199
+ app.use(express.json());
200
+
201
+ // ---- GET /health ----
202
+ app.get("/health", (_req, res) => {
203
+ const botReady = discordClient?.isReady() ?? false;
204
+ const channelId = _req.query.channelId;
205
+ const response = {
206
+ status: botReady ? "ok" : "disconnected",
207
+ bot: discordClient?.user?.tag ?? null,
208
+ };
209
+ if (channelId) {
210
+ response.channel = channelId;
211
+ response.queuedMessages = getMessageQueue(channelId).length;
212
+ response.sseSubscribers = getSseSubscribers(channelId).size;
213
+ }
214
+ res.json(response);
215
+ });
216
+
217
+ // ---- GET /events (SSE) ----
218
+ app.get("/events", (req, res) => {
219
+ const channelId = req.query.channelId;
220
+ if (!channelId) {
221
+ return res.status(400).json({ status: "error", error: "channelId is required" });
222
+ }
223
+
224
+ // Set SSE headers
225
+ res.setHeader("Content-Type", "text/event-stream");
226
+ res.setHeader("Cache-Control", "no-cache");
227
+ res.setHeader("Connection", "keep-alive");
228
+ res.flushHeaders();
229
+
230
+ // Send connected event
231
+ res.write(`event: connected\ndata: ${JSON.stringify({ channelId })}\n\n`);
232
+
233
+ // Notify Discord that a client connected
234
+ fetchChannel(channelId).then((ch) => {
235
+ sendDebugMessage(ch, "待機中");
236
+ }).catch(() => {});
237
+
238
+ // If there are queued messages, notify immediately so client can fetch via /messages
239
+ const queue = getMessageQueue(channelId);
240
+ if (queue.length > 0) {
241
+ res.write(`event: notify\ndata: {}\n\n`);
242
+ }
243
+
244
+ // Register subscriber
245
+ const subscribers = getSseSubscribers(channelId);
246
+ subscribers.add(res);
247
+
248
+ // Keep-alive ping
249
+ const pingInterval = setInterval(() => {
250
+ res.write(`event: ping\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`);
251
+ }, CONFIG.sseKeepAliveInterval);
252
+
253
+ // Cleanup on disconnect
254
+ req.on("close", () => {
255
+ clearInterval(pingInterval);
256
+ subscribers.delete(res);
257
+ if (subscribers.size === 0) {
258
+ sseSubscribers.delete(channelId);
259
+ }
260
+ });
261
+ });
262
+
263
+ // ---- POST /ask ----
264
+ app.post("/ask", async (req, res) => {
265
+ const { question, context, timeout_seconds = 300, options } = req.body;
266
+ const channelId = resolveChannelId(req);
267
+ if (!channelId) {
268
+ return res.status(400).json({ status: "error", error: "channelId is required" });
269
+ }
270
+ if (!question) {
271
+ return res.status(400).json({ status: "error", error: "question is required" });
272
+ }
273
+
274
+ const timeoutMs = Math.min(timeout_seconds * 1000, CONFIG.maxTimeout);
275
+ const fields = [];
276
+
277
+ if (context) {
278
+ fields.push({ name: "Context", value: context.slice(0, 1024) });
279
+ }
280
+ if (options && options.length > 0) {
281
+ fields.push({
282
+ name: "Suggested Replies",
283
+ value: options.map((opt, i) => `**${i + 1}.** ${opt}`).join("\n"),
284
+ });
285
+ }
286
+ fields.push({
287
+ name: "Timeout",
288
+ value: `${timeout_seconds}s`,
289
+ inline: true,
290
+ });
291
+ const embed = createEmbed({
292
+ title: "❓ Question",
293
+ description: question,
294
+ color: 0xe84393,
295
+ fields,
296
+ });
297
+
298
+ await sendMessage(channelId, `<@${CONFIG.userId}>`, [embed]);
299
+
300
+ try {
301
+ const reply = await waitForReply(channelId, timeoutMs);
302
+ res.json({
303
+ status: "replied",
304
+ reply: reply.content,
305
+ attachments: reply.attachments,
306
+ timestamp: reply.timestamp,
307
+ });
308
+ } catch (err) {
309
+ res.status(408).json({ status: "timeout", error: err.message });
310
+ }
311
+ });
312
+
313
+ // ---- POST /notify ----
314
+ app.post("/notify", async (req, res) => {
315
+ const { message, level = "info", title } = req.body;
316
+ const channelId = resolveChannelId(req);
317
+ if (!channelId) {
318
+ return res.status(400).json({ status: "error", error: "channelId is required" });
319
+ }
320
+ if (!message) {
321
+ return res.status(400).json({ status: "error", error: "message is required" });
322
+ }
323
+
324
+ const colorMap = {
325
+ info: 0x3498db,
326
+ success: 0x2ecc71,
327
+ warning: 0xf39c12,
328
+ error: 0xe74c3c,
329
+ debug: 0x95a5a6,
330
+ };
331
+ const iconMap = {
332
+ info: "\u2139\ufe0f",
333
+ success: "\u2705",
334
+ warning: "\u26a0\ufe0f",
335
+ error: "\u274c",
336
+ debug: "\ud83d\udce1",
337
+ };
338
+
339
+ if (level === "info") {
340
+ await sendMessage(channelId, message);
341
+ } else if (level === "debug") {
342
+ const ch = await fetchChannel(channelId);
343
+ await sendDebugMessage(ch, message);
344
+ } else {
345
+ const defaultTitle = `${iconMap[level]} ${level.charAt(0).toUpperCase() + level.slice(1)}`;
346
+ const embed = createEmbed({
347
+ title: title || defaultTitle,
348
+ description: message,
349
+ color: colorMap[level] ?? colorMap.info,
350
+ });
351
+ await sendMessage(channelId, null, [embed]);
352
+ }
353
+ res.json({ status: "sent", level });
354
+ });
355
+
356
+ // ---- POST /send-file ----
357
+ app.post("/send-file", async (req, res) => {
358
+ const { file_path, message } = req.body;
359
+ const channelId = resolveChannelId(req);
360
+ if (!channelId) {
361
+ return res.status(400).json({ status: "error", error: "channelId is required" });
362
+ }
363
+ if (!file_path) {
364
+ return res.status(400).json({ status: "error", error: "file_path is required" });
365
+ }
366
+
367
+ try {
368
+ const stats = await stat(file_path);
369
+ if (stats.size > CONFIG.maxFileSize) {
370
+ return res.status(413).json({
371
+ status: "error",
372
+ error: `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds 8MB limit`,
373
+ });
374
+ }
375
+ } catch {
376
+ return res.status(404).json({
377
+ status: "error",
378
+ error: `File not found: ${file_path}`,
379
+ });
380
+ }
381
+
382
+ const fileBuffer = await readFile(file_path);
383
+ const fileName = path.basename(file_path);
384
+ const attachment = new AttachmentBuilder(fileBuffer, { name: fileName });
385
+
386
+ const embed = createEmbed({
387
+ title: "📎 File",
388
+ description: message || `\`${fileName}\``,
389
+ color: 0x9b59b6,
390
+ });
391
+
392
+ await sendMessage(channelId, null, [embed], [attachment]);
393
+ res.json({ status: "sent", fileName });
394
+ });
395
+
396
+ // ---- GET /messages ----
397
+ app.get("/messages", async (req, res) => {
398
+ const channelId = resolveChannelId(req);
399
+ if (!channelId) {
400
+ return res.status(400).json({ status: "error", error: "channelId is required" });
401
+ }
402
+
403
+ const count = Math.min(
404
+ parseInt(req.query.count || "10", 10),
405
+ CONFIG.maxMessageHistory
406
+ );
407
+ const includeHistory = req.query.include_history === "true";
408
+ const messages = [];
409
+
410
+ const queue = getMessageQueue(channelId);
411
+ const queued = queue.splice(0, count);
412
+ messages.push(...queued.map((m) => ({ ...m, source: "queued" })));
413
+
414
+ // Notify Discord that messages were delivered to Claude Code (debug embed)
415
+ if (queued.length > 0) {
416
+ try {
417
+ const ch = await fetchChannel(channelId);
418
+ for (const msg of queued) {
419
+ const text = msg.content.replace(/\n/g, " ");
420
+ const preview = text.length > 5 ? text.slice(0, 5) + "......" : text;
421
+ sendDebugMessage(ch, `伝達: ${preview}`);
422
+ }
423
+ } catch { /* ignore */ }
424
+ }
425
+
426
+ if (includeHistory && messages.length < count) {
427
+ const remaining = count - messages.length;
428
+ const ch = await fetchChannel(channelId);
429
+ const fetched = await ch.messages.fetch({ limit: remaining });
430
+ const history = fetched
431
+ .filter((m) => m.author.id === CONFIG.userId && !m.author.bot)
432
+ .map((m) => ({
433
+ content: m.content,
434
+ attachments: m.attachments.map((a) => ({
435
+ name: a.name,
436
+ url: a.url,
437
+ size: a.size,
438
+ contentType: a.contentType,
439
+ })),
440
+ timestamp: m.createdAt.toISOString(),
441
+ id: m.id,
442
+ source: "history",
443
+ }));
444
+ messages.push(...history);
445
+ }
446
+
447
+ res.json({ status: "ok", count: messages.length, messages });
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Lifecycle
452
+ // ---------------------------------------------------------------------------
453
+
454
+ async function main() {
455
+ validateConfig();
456
+ await initDiscord();
457
+
458
+ app.listen(CONFIG.port, CONFIG.host, () => {
459
+ console.log(
460
+ `[discord-bridge] HTTP server listening on http://${CONFIG.host}:${CONFIG.port}`
461
+ );
462
+ });
463
+ }
464
+
465
+ process.on("SIGINT", () => {
466
+ if (discordClient) discordClient.destroy();
467
+ process.exit(0);
468
+ });
469
+
470
+ process.on("SIGTERM", () => {
471
+ if (discordClient) discordClient.destroy();
472
+ process.exit(0);
473
+ });
474
+
475
+ main().catch((err) => {
476
+ console.error(`[discord-bridge] Fatal: ${err.message}`);
477
+ process.exit(1);
478
+ });