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 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