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.
- package/.discord-bridge.json.example +3 -0
- package/.env.example +3 -0
- package/.github/workflows/publish.yml +40 -0
- package/README.md +100 -0
- package/bin/cli.js +70 -0
- package/package.json +24 -0
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/README.md +17 -0
- package/plugin/hooks/hooks.json +27 -0
- package/plugin/hooks/scripts/notify-discord.sh +36 -0
- package/plugin/hooks/scripts/stop-hook.sh +35 -0
- package/plugin/skills/discord-comm/SKILL.md +172 -0
- package/plugin/skills/discord-comm/scripts/discord-ask.sh +28 -0
- package/plugin/skills/discord-comm/scripts/discord-messages.sh +18 -0
- package/plugin/skills/discord-comm/scripts/discord-notify.sh +26 -0
- package/plugin/skills/discord-comm/scripts/discord-send-file.sh +25 -0
- package/plugin/skills/discord-comm/scripts/discord-status.sh +15 -0
- package/plugin/skills/discord-comm/scripts/discord-wait.sh +63 -0
- package/server/index.js +478 -0
package/.env.example
ADDED
|
@@ -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
|
+
}
|
package/plugin/README.md
ADDED
|
@@ -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
|
package/server/index.js
ADDED
|
@@ -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
|
+
});
|