brainiac-discord 0.0.1
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.
- checksums.yaml +7 -0
- data/README.md +110 -0
- data/lib/brainiac/plugins/discord/api.rb +286 -0
- data/lib/brainiac/plugins/discord/cli.rb +274 -0
- data/lib/brainiac/plugins/discord/config.rb +118 -0
- data/lib/brainiac/plugins/discord/delivery.rb +215 -0
- data/lib/brainiac/plugins/discord/gateway.rb +295 -0
- data/lib/brainiac/plugins/discord/message.rb +863 -0
- data/lib/brainiac/plugins/discord/metadata.rb +28 -0
- data/lib/brainiac/plugins/discord/prompts.rb +109 -0
- data/lib/brainiac/plugins/discord/reactions.rb +206 -0
- data/lib/brainiac/plugins/discord/version.rb +9 -0
- data/lib/brainiac/plugins/discord.rb +122 -0
- data/lib/brainiac_discord.rb +4 -0
- metadata +139 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 223dd60e819a0b61ff8b4f82e27cf88774d431b2a2673f82e5e5d7724ca6ed9b
|
|
4
|
+
data.tar.gz: 1777674edaec7db3e0772bdc3fa55d3f7438b1c0fba4c2450c50cd78ffa46442
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3a2f08e64fa903bd185a7c4a09a279a5dd23425b744e59f0e5e4bd17bc7d9adb841bef15d7f1c09b614ff89587fac1ed01a11bf38edb10059e06956e21816358
|
|
7
|
+
data.tar.gz: 3dd471e951c05a947837a55ab5f9c6510e798eb41d2d86c65ab13c0264b29804e48de6cf430654639d5076db912c1093bee6578ad307a0bde56ad51a51309cb8
|
data/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# brainiac-discord
|
|
2
|
+
|
|
3
|
+
Discord bot plugin for [Brainiac](https://github.com/stowzilla/brainiac) — the AI agent orchestration platform.
|
|
4
|
+
|
|
5
|
+
Each agent gets its own Discord bot. Users @mention @Galen or @GLaDOS directly — no shared bot, no agent name detection needed.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Per-agent bots** — each agent with a `DISCORD_BOT_TOKEN` gets its own WebSocket gateway connection
|
|
10
|
+
- **Session supersede** — follow-up messages within 60s kill the previous run and restart with updated context
|
|
11
|
+
- **Cancel via ❌** — react to cancel an active agent session
|
|
12
|
+
- **Thinking peek** — react ❔/❓ to see the last 10/20 lines of agent output
|
|
13
|
+
- **Thinking stream** — react 🧠 to get the full agent log streamed to a thread
|
|
14
|
+
- **Emoji feedback** — non-reserved emoji reactions are logged as feedback to the agent's persona
|
|
15
|
+
- **Thread isolation** — conversations get their own threads with worktree persistence
|
|
16
|
+
- **Forum support** — cron jobs can post to forum channels
|
|
17
|
+
- **GIF support** — agents can search and embed GIFs via GIPHY API
|
|
18
|
+
- **Draft delivery** — file-based response delivery survives server restarts
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
brainiac install discord
|
|
24
|
+
brainiac restart
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or for local development:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
brainiac install discord --path ~/Code/brainiac-discord
|
|
31
|
+
brainiac restart
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
### 1. Create Discord Applications
|
|
37
|
+
|
|
38
|
+
Create one Discord application per agent at https://discord.com/developers/applications:
|
|
39
|
+
|
|
40
|
+
1. Click "New Application", name it after the agent
|
|
41
|
+
2. Go to "Bot" tab → enable **Message Content Intent**
|
|
42
|
+
3. Copy the bot token
|
|
43
|
+
|
|
44
|
+
### 2. Register Tokens
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
brainiac discord token galen "BOT_TOKEN_FOR_GALEN"
|
|
48
|
+
brainiac discord token glados "BOT_TOKEN_FOR_GLADOS"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Invite Bots
|
|
52
|
+
|
|
53
|
+
Use the OAuth2 URL Generator with `bot` scope and these permissions:
|
|
54
|
+
- Send Messages, Create Public Threads, Send Messages in Threads
|
|
55
|
+
- Add Reactions, Read Message History
|
|
56
|
+
|
|
57
|
+
Permission integer: `326417591296`
|
|
58
|
+
|
|
59
|
+
### 4. Configure
|
|
60
|
+
|
|
61
|
+
Set a default project:
|
|
62
|
+
```bash
|
|
63
|
+
brainiac discord default marketplace
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Map channels to projects:
|
|
67
|
+
```bash
|
|
68
|
+
brainiac discord map 1234567890 brainiac
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 5. Start
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
brainiac server
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
All bots connect automatically as background threads.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Stored in `~/.brainiac/discord.json`:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"default_project": "marketplace",
|
|
86
|
+
"owner_discord_id": "YOUR_DISCORD_USER_ID",
|
|
87
|
+
"channel_mappings": {
|
|
88
|
+
"0987654321": { "project": "brainiac" }
|
|
89
|
+
},
|
|
90
|
+
"user_mappings": {
|
|
91
|
+
"Andy": "123456789012345678"
|
|
92
|
+
},
|
|
93
|
+
"authorized_role_ids": [],
|
|
94
|
+
"authorized_user_ids": [],
|
|
95
|
+
"giphy_api_key": "your-giphy-api-key"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cd ~/Code/brainiac-discord
|
|
103
|
+
bundle install
|
|
104
|
+
rake test
|
|
105
|
+
rake rubocop
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Brainiac
|
|
8
|
+
module Plugins
|
|
9
|
+
module Discord
|
|
10
|
+
# Discord REST API helpers.
|
|
11
|
+
#
|
|
12
|
+
# Low-level HTTP methods and convenience wrappers for the Discord v10 API.
|
|
13
|
+
# Used by the Discord handler itself, but also available for other plugins
|
|
14
|
+
# (e.g. GitHub deploy notifications, Zoho email notifications).
|
|
15
|
+
module Api
|
|
16
|
+
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
17
|
+
|
|
18
|
+
# Emojis reserved for brainiac functionality — not treated as feedback
|
|
19
|
+
RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def request(method, path, token:, body: nil, log_errors: true)
|
|
23
|
+
uri = URI("#{DISCORD_API_BASE}#{path}")
|
|
24
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
25
|
+
http.use_ssl = true
|
|
26
|
+
|
|
27
|
+
req = case method
|
|
28
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
29
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
30
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
31
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
req["Authorization"] = "Bot #{token}"
|
|
35
|
+
req["Content-Type"] = "application/json"
|
|
36
|
+
req.body = body.to_json if body
|
|
37
|
+
|
|
38
|
+
response = http.request(req)
|
|
39
|
+
|
|
40
|
+
if response.code.to_i == 429
|
|
41
|
+
retry_after = JSON.parse(response.body)["retry_after"] || 1
|
|
42
|
+
LOG.warn "[Discord] Rate limited, waiting #{retry_after}s" if defined?(LOG)
|
|
43
|
+
sleep retry_after
|
|
44
|
+
return request(method, path, token: token, body: body, log_errors: log_errors)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if response.code.to_i >= 400 && log_errors && defined?(LOG)
|
|
48
|
+
LOG.error "[Discord] API error (#{method} #{path}): HTTP #{response.code} - #{response.body}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
JSON.parse(response.body) unless response.body.nil? || response.body.empty?
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
LOG.error "[Discord] API error (#{method} #{path}): #{e.message}" if log_errors && defined?(LOG)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- Channel & Message Operations ---
|
|
58
|
+
|
|
59
|
+
def fetch_channel_history(channel_id, before_message_id, token:, limit: 10)
|
|
60
|
+
messages = request(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
|
|
61
|
+
|
|
62
|
+
all_messages = messages.is_a?(Array) ? messages : []
|
|
63
|
+
|
|
64
|
+
if all_messages.any?
|
|
65
|
+
oldest = all_messages.last
|
|
66
|
+
all_messages << oldest["referenced_message"] if oldest && oldest["type"] == 21 && oldest["referenced_message"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return "" if all_messages.empty?
|
|
70
|
+
|
|
71
|
+
lines = all_messages.reverse.filter_map do |msg|
|
|
72
|
+
author = msg.dig("author", "username") || "unknown"
|
|
73
|
+
content = msg["content"]&.strip || ""
|
|
74
|
+
next if content.empty?
|
|
75
|
+
|
|
76
|
+
"#{author}: #{content}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
return "" if lines.empty?
|
|
80
|
+
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
LOG.warn "[Discord] Failed to fetch channel history: #{e.message}" if defined?(LOG)
|
|
84
|
+
""
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fetch_channel_info(channel_id, token:)
|
|
88
|
+
request(:get, "/channels/#{channel_id}", token: token)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fetch_message(channel_id, message_id, token:, log_errors: true)
|
|
92
|
+
request(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fetch_guild_member(guild_id, user_id, token:)
|
|
96
|
+
request(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- Messaging ---
|
|
100
|
+
|
|
101
|
+
def send_message(channel_id, content, token:, reply_to: nil)
|
|
102
|
+
body = { content: content }
|
|
103
|
+
body[:message_reference] = { message_id: reply_to } if reply_to
|
|
104
|
+
result = request(:post, "/channels/#{channel_id}/messages", token: token, body: body)
|
|
105
|
+
if result && result["id"]
|
|
106
|
+
LOG.info "[Discord] Message posted to channel #{channel_id}, message_id: #{result["id"]}" if defined?(LOG)
|
|
107
|
+
elsif defined?(LOG)
|
|
108
|
+
LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
|
|
109
|
+
end
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def send_long_message(channel_id, content, token:, reply_to: nil)
|
|
114
|
+
if content.length <= 2000
|
|
115
|
+
send_message(channel_id, content, token: token, reply_to: reply_to)
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
chunks = split_content(content)
|
|
120
|
+
chunks.each_with_index do |chunk, i|
|
|
121
|
+
send_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
|
|
122
|
+
sleep 0.5
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def send_typing(channel_id, token:)
|
|
127
|
+
request(:post, "/channels/#{channel_id}/typing", token: token)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# --- Reactions ---
|
|
131
|
+
|
|
132
|
+
def add_reaction(channel_id, message_id, emoji, token:)
|
|
133
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
134
|
+
request(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def remove_reaction(channel_id, message_id, emoji, token:)
|
|
138
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
139
|
+
request(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# --- Threads & Forums ---
|
|
143
|
+
|
|
144
|
+
def create_thread(channel_id, message_id, name:, token:)
|
|
145
|
+
thread_name = name.length > 100 ? "#{name[0..96]}..." : name
|
|
146
|
+
request(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
|
|
147
|
+
name: thread_name,
|
|
148
|
+
auto_archive_duration: 1440
|
|
149
|
+
})
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def forum_channel?(channel_id, token:)
|
|
153
|
+
info = fetch_channel_info(channel_id, token: token)
|
|
154
|
+
info && info["type"] == 15
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_latest_forum_thread(channel_id, token:)
|
|
158
|
+
channel_info = fetch_channel_info(channel_id, token: token)
|
|
159
|
+
return nil unless channel_info && channel_info["guild_id"]
|
|
160
|
+
|
|
161
|
+
guild_id = channel_info["guild_id"]
|
|
162
|
+
result = request(:get, "/guilds/#{guild_id}/threads/active", token: token)
|
|
163
|
+
return nil unless result && result["threads"]
|
|
164
|
+
|
|
165
|
+
forum_threads = result["threads"]
|
|
166
|
+
.select { |t| t["parent_id"] == channel_id }
|
|
167
|
+
.sort_by { |t| t["id"].to_i }
|
|
168
|
+
.reverse
|
|
169
|
+
|
|
170
|
+
return nil if forum_threads.empty?
|
|
171
|
+
|
|
172
|
+
latest = forum_threads.first
|
|
173
|
+
LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}" if defined?(LOG)
|
|
174
|
+
latest
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def create_forum_post(channel_id, title:, content:, token:)
|
|
178
|
+
thread_name = title.length > 100 ? "#{title[0..96]}..." : title
|
|
179
|
+
result = request(:post, "/channels/#{channel_id}/threads", token: token, body: {
|
|
180
|
+
name: thread_name,
|
|
181
|
+
message: { content: content },
|
|
182
|
+
auto_archive_duration: 1440
|
|
183
|
+
})
|
|
184
|
+
if result && result["id"]
|
|
185
|
+
LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}" if defined?(LOG)
|
|
186
|
+
elsif defined?(LOG)
|
|
187
|
+
LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
|
|
188
|
+
end
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# --- GIF Search ---
|
|
193
|
+
|
|
194
|
+
def search_gif(query)
|
|
195
|
+
api_key = Config.giphy_api_key
|
|
196
|
+
return [] unless api_key
|
|
197
|
+
|
|
198
|
+
uri = URI("https://api.giphy.com/v1/gifs/search")
|
|
199
|
+
uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
|
|
200
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
201
|
+
http.use_ssl = true
|
|
202
|
+
response = http.get(uri)
|
|
203
|
+
return [] unless response.code.to_i == 200
|
|
204
|
+
|
|
205
|
+
data = JSON.parse(response.body)
|
|
206
|
+
(data["data"] || []).map { |g| { "url" => g.dig("images", "original", "url") || g["url"] } }
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
LOG.warn "[Discord] GIF search error: #{e.message}" if defined?(LOG)
|
|
209
|
+
[]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# --- Helpers ---
|
|
213
|
+
|
|
214
|
+
def find_root_message(message, channel_id, bot_token)
|
|
215
|
+
current_msg = message
|
|
216
|
+
visited = Set.new
|
|
217
|
+
max_depth = 20
|
|
218
|
+
walked = false
|
|
219
|
+
|
|
220
|
+
max_depth.times do
|
|
221
|
+
msg_id = current_msg["id"]
|
|
222
|
+
return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id)
|
|
223
|
+
|
|
224
|
+
visited << msg_id
|
|
225
|
+
|
|
226
|
+
ref = current_msg["message_reference"]
|
|
227
|
+
break unless ref
|
|
228
|
+
|
|
229
|
+
ref_msg_id = ref["message_id"]
|
|
230
|
+
ref_channel = ref["channel_id"] || channel_id
|
|
231
|
+
break unless ref_msg_id
|
|
232
|
+
|
|
233
|
+
referenced = request(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
|
|
234
|
+
break unless referenced
|
|
235
|
+
|
|
236
|
+
current_msg = referenced
|
|
237
|
+
walked = true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
id: current_msg["id"],
|
|
242
|
+
content: walked ? current_msg["content"]&.strip : nil,
|
|
243
|
+
author: walked ? current_msg.dig("author", "username") : nil
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Build a Discord mention roster so the agent can @mention people and other bots.
|
|
248
|
+
def mention_roster
|
|
249
|
+
lines = []
|
|
250
|
+
|
|
251
|
+
Gateway.each_bot do |agent_key, info|
|
|
252
|
+
next unless info[:user_id]
|
|
253
|
+
|
|
254
|
+
display = agent_display_name(agent_key) || agent_key.capitalize
|
|
255
|
+
lines << " - #{display}: `<@#{info[:user_id]}>`"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
Config.user_mappings.each do |name, discord_id|
|
|
259
|
+
lines << " - #{name}: `<@#{discord_id}>`"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
lines.join("\n")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def split_content(content)
|
|
268
|
+
chunks = []
|
|
269
|
+
remaining = content
|
|
270
|
+
while remaining.length.positive?
|
|
271
|
+
if remaining.length <= 2000
|
|
272
|
+
chunks << remaining
|
|
273
|
+
remaining = ""
|
|
274
|
+
else
|
|
275
|
+
split_at = remaining.rindex("\n", 1990) || 1990
|
|
276
|
+
chunks << remaining[0...split_at]
|
|
277
|
+
remaining = remaining[split_at..].lstrip
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
chunks
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Brainiac
|
|
8
|
+
module Plugins
|
|
9
|
+
module Discord
|
|
10
|
+
# CLI subcommands for brainiac-discord plugin.
|
|
11
|
+
#
|
|
12
|
+
# Invoked when a user runs `brainiac discord <command>`.
|
|
13
|
+
# Manages discord.json config and agent bot tokens.
|
|
14
|
+
module Cli
|
|
15
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
16
|
+
DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
|
|
17
|
+
AGENT_REGISTRY_FILE = File.join(BRAINIAC_DIR, "agents.json")
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def run(args)
|
|
21
|
+
command = args.shift
|
|
22
|
+
|
|
23
|
+
case command
|
|
24
|
+
when "config"
|
|
25
|
+
cmd_config
|
|
26
|
+
when "map"
|
|
27
|
+
cmd_map(args)
|
|
28
|
+
when "default"
|
|
29
|
+
cmd_default(args)
|
|
30
|
+
when "token"
|
|
31
|
+
cmd_token(args)
|
|
32
|
+
when "status"
|
|
33
|
+
cmd_status
|
|
34
|
+
when "owner"
|
|
35
|
+
cmd_owner(args)
|
|
36
|
+
when "setup"
|
|
37
|
+
cmd_setup
|
|
38
|
+
else
|
|
39
|
+
print_help
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def cmd_config
|
|
46
|
+
if File.exist?(DISCORD_CONFIG_FILE)
|
|
47
|
+
puts File.read(DISCORD_CONFIG_FILE)
|
|
48
|
+
else
|
|
49
|
+
puts "No Discord config found at #{DISCORD_CONFIG_FILE}"
|
|
50
|
+
puts "Run 'brainiac discord setup' to configure Discord."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cmd_map(args)
|
|
55
|
+
channel_id = args[0]
|
|
56
|
+
project_key = args[1]
|
|
57
|
+
|
|
58
|
+
unless channel_id && project_key
|
|
59
|
+
puts "Usage: brainiac discord map <channel-id> <project-key>"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
config = load_discord_config
|
|
64
|
+
config["channel_mappings"] ||= {}
|
|
65
|
+
config["channel_mappings"][channel_id] = { "project" => project_key }
|
|
66
|
+
|
|
67
|
+
save_discord_config(config)
|
|
68
|
+
puts "✓ Mapped channel #{channel_id} → project '#{project_key}'"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cmd_default(args)
|
|
72
|
+
project_key = args[0]
|
|
73
|
+
|
|
74
|
+
unless project_key
|
|
75
|
+
puts "Usage: brainiac discord default <project-key>"
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
config = load_discord_config
|
|
80
|
+
config["default_project"] = project_key
|
|
81
|
+
|
|
82
|
+
save_discord_config(config)
|
|
83
|
+
puts "✓ Default project: #{project_key}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cmd_token(args)
|
|
87
|
+
agent_key = args[0]
|
|
88
|
+
token = args[1]
|
|
89
|
+
|
|
90
|
+
unless agent_key && token
|
|
91
|
+
puts "Usage: brainiac discord token <agent-key> <bot-token>"
|
|
92
|
+
puts " Sets the DISCORD_BOT_TOKEN env var for an agent in the registry."
|
|
93
|
+
puts " Example: brainiac discord token galen Bot_TOKEN_HERE"
|
|
94
|
+
exit 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
registry = File.exist?(AGENT_REGISTRY_FILE) ? JSON.parse(File.read(AGENT_REGISTRY_FILE)) : {}
|
|
98
|
+
registry[agent_key] ||= {}
|
|
99
|
+
registry[agent_key]["env"] ||= {}
|
|
100
|
+
registry[agent_key]["env"]["DISCORD_BOT_TOKEN"] = token
|
|
101
|
+
|
|
102
|
+
FileUtils.mkdir_p(BRAINIAC_DIR)
|
|
103
|
+
File.write(AGENT_REGISTRY_FILE, JSON.pretty_generate(registry))
|
|
104
|
+
puts "✓ Set DISCORD_BOT_TOKEN for '#{agent_key}'"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def cmd_status
|
|
108
|
+
server_url = detect_server_url
|
|
109
|
+
begin
|
|
110
|
+
uri = URI("#{server_url}/api/discord")
|
|
111
|
+
response = Net::HTTP.get_response(uri)
|
|
112
|
+
data = JSON.parse(response.body)
|
|
113
|
+
if data["enabled"]
|
|
114
|
+
bots = data["bots"] || {}
|
|
115
|
+
if bots.empty?
|
|
116
|
+
puts "Discord: enabled but no bots configured"
|
|
117
|
+
else
|
|
118
|
+
puts "Discord bots:"
|
|
119
|
+
bots.each do |agent, info|
|
|
120
|
+
puts " #{agent}: #{info["status"]} (user_id: #{info["user_id"] || "n/a"})"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
puts "Default project: #{data.dig("config", "default_project") || "none"}"
|
|
124
|
+
puts "Channel mappings: #{data.dig("config", "channel_mappings")}"
|
|
125
|
+
else
|
|
126
|
+
puts "Discord: disabled (#{data["reason"]})"
|
|
127
|
+
end
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
puts "Could not reach server at #{server_url}: #{e.message}"
|
|
130
|
+
puts "Is the server running? Check with: brainiac status"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def cmd_owner(args)
|
|
135
|
+
discord_id = args[0]
|
|
136
|
+
config = load_discord_config
|
|
137
|
+
if discord_id
|
|
138
|
+
config["owner_discord_id"] = discord_id
|
|
139
|
+
save_discord_config(config)
|
|
140
|
+
puts "✓ Owner set to #{discord_id}"
|
|
141
|
+
elsif config["owner_discord_id"]
|
|
142
|
+
puts "Owner: #{config["owner_discord_id"]}"
|
|
143
|
+
else
|
|
144
|
+
puts "No owner set."
|
|
145
|
+
puts "Usage: brainiac discord owner <discord-user-id>"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def cmd_setup
|
|
150
|
+
puts "Discord Setup"
|
|
151
|
+
puts "============="
|
|
152
|
+
puts ""
|
|
153
|
+
|
|
154
|
+
# Copy example config if none exists
|
|
155
|
+
unless File.exist?(DISCORD_CONFIG_FILE)
|
|
156
|
+
template = File.expand_path("../../../../templates/discord.json.example", __dir__)
|
|
157
|
+
if File.exist?(template)
|
|
158
|
+
FileUtils.mkdir_p(BRAINIAC_DIR)
|
|
159
|
+
FileUtils.cp(template, DISCORD_CONFIG_FILE)
|
|
160
|
+
puts "✓ Created #{DISCORD_CONFIG_FILE} from template"
|
|
161
|
+
puts ""
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
config = load_discord_config
|
|
166
|
+
|
|
167
|
+
if config["default_project"]
|
|
168
|
+
puts "✓ Default project: #{config["default_project"]}"
|
|
169
|
+
else
|
|
170
|
+
puts "⚠ No default project set."
|
|
171
|
+
puts " Set one with: brainiac discord default <project-key>"
|
|
172
|
+
end
|
|
173
|
+
puts ""
|
|
174
|
+
|
|
175
|
+
registry = File.exist?(AGENT_REGISTRY_FILE) ? JSON.parse(File.read(AGENT_REGISTRY_FILE)) : {}
|
|
176
|
+
agents_with_tokens = registry.select { |_k, v| v.is_a?(Hash) && v.dig("env", "DISCORD_BOT_TOKEN") }
|
|
177
|
+
if agents_with_tokens.any?
|
|
178
|
+
puts "✓ #{agents_with_tokens.size} agent(s) have Discord bot tokens:"
|
|
179
|
+
agents_with_tokens.each do |key, entry|
|
|
180
|
+
display = entry["display_name"] || key.capitalize
|
|
181
|
+
puts " #{display} (#{key})"
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
puts "⚠ No agents have Discord bot tokens configured."
|
|
185
|
+
puts " Add one with: brainiac discord token <agent-key> <bot-token>"
|
|
186
|
+
puts ""
|
|
187
|
+
puts " To get a bot token:"
|
|
188
|
+
puts " 1. Go to https://discord.com/developers/applications"
|
|
189
|
+
puts " 2. Create a new application (one per agent)"
|
|
190
|
+
puts " 3. Go to Bot tab → Copy token"
|
|
191
|
+
puts " 4. Enable MESSAGE CONTENT intent"
|
|
192
|
+
end
|
|
193
|
+
puts ""
|
|
194
|
+
|
|
195
|
+
mappings = config["channel_mappings"] || {}
|
|
196
|
+
if mappings.any?
|
|
197
|
+
puts "✓ #{mappings.size} channel mapping(s) configured"
|
|
198
|
+
else
|
|
199
|
+
puts " No channel mappings (using default project for all channels)"
|
|
200
|
+
puts " Optionally map specific channels: brainiac discord map <channel-id> <project>"
|
|
201
|
+
end
|
|
202
|
+
puts ""
|
|
203
|
+
|
|
204
|
+
if agents_with_tokens.any? && config["default_project"]
|
|
205
|
+
puts "✓ Discord is configured! Start with: brainiac server"
|
|
206
|
+
else
|
|
207
|
+
puts "Complete the steps above, then start with: brainiac server"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def print_help
|
|
212
|
+
puts <<~HELP
|
|
213
|
+
Usage: brainiac discord <command>
|
|
214
|
+
|
|
215
|
+
Commands:
|
|
216
|
+
setup Interactive setup guide
|
|
217
|
+
config Show Discord config
|
|
218
|
+
default <project> Set default project for all channels
|
|
219
|
+
map <channel-id> <project> Map a specific channel to a project
|
|
220
|
+
owner [<discord-user-id>] Set/show machine owner (for version notifications)
|
|
221
|
+
token <agent-key> <bot-token> Set Discord bot token for an agent
|
|
222
|
+
status Check Discord bot status (via server API)
|
|
223
|
+
|
|
224
|
+
Each agent gets its own Discord bot. Users @mention @Galen or @GLaDOS
|
|
225
|
+
directly in Discord — no shared bot needed.
|
|
226
|
+
|
|
227
|
+
Quick start:
|
|
228
|
+
brainiac discord token galen "BOT_TOKEN_FOR_GALEN"
|
|
229
|
+
brainiac discord default marketplace
|
|
230
|
+
brainiac server
|
|
231
|
+
HELP
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def load_discord_config
|
|
235
|
+
if File.exist?(DISCORD_CONFIG_FILE)
|
|
236
|
+
JSON.parse(File.read(DISCORD_CONFIG_FILE))
|
|
237
|
+
else
|
|
238
|
+
{ "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
239
|
+
end
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
{ "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def save_discord_config(config)
|
|
245
|
+
FileUtils.mkdir_p(BRAINIAC_DIR)
|
|
246
|
+
File.write(DISCORD_CONFIG_FILE, JSON.pretty_generate(config))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def detect_server_url
|
|
250
|
+
config_file = File.join(BRAINIAC_DIR, "brainiac.json")
|
|
251
|
+
if File.exist?(config_file)
|
|
252
|
+
config = JSON.parse(File.read(config_file))
|
|
253
|
+
config["server_url"] || "http://localhost:4567"
|
|
254
|
+
else
|
|
255
|
+
"http://localhost:4567"
|
|
256
|
+
end
|
|
257
|
+
rescue JSON::ParserError
|
|
258
|
+
"http://localhost:4567"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Plugin CLI entry point — called by brainiac core's plugin delegation.
|
|
264
|
+
def self.cli(args)
|
|
265
|
+
Cli.run(args)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Subcommand names for bash completion.
|
|
269
|
+
def self.completions
|
|
270
|
+
%w[setup config default map owner token status]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|