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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Discord
6
+ # Discord configuration — loads ~/.brainiac/discord.json.
7
+ # Provides channel mappings, authorization, user mappings, and project routing.
8
+ module Config
9
+ DISCORD_CONFIG_FILE = File.join(
10
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
11
+ "discord.json"
12
+ )
13
+
14
+ DISCORD_THREAD_MAP_FILE = File.join(
15
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
16
+ "discord_thread_map.json"
17
+ )
18
+
19
+ @config = {}
20
+ @thread_map_mutex = Mutex.new
21
+
22
+ class << self
23
+ attr_reader :config, :thread_map_mutex
24
+
25
+ def load!
26
+ @config = if File.exist?(DISCORD_CONFIG_FILE)
27
+ JSON.parse(File.read(DISCORD_CONFIG_FILE))
28
+ else
29
+ { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
30
+ end
31
+ rescue JSON::ParserError => e
32
+ LOG.error "[Discord] Failed to parse discord.json: #{e.message}" if defined?(LOG)
33
+ @config = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
34
+ end
35
+
36
+ def reload!
37
+ load!
38
+ end
39
+
40
+ def current
41
+ @config
42
+ end
43
+
44
+ def default_project
45
+ @config["default_project"]
46
+ end
47
+
48
+ def owner_discord_id
49
+ @config["owner_discord_id"]
50
+ end
51
+
52
+ def dashboard_token
53
+ @config["dashboard_token"]
54
+ end
55
+
56
+ def giphy_api_key
57
+ @config["giphy_api_key"]
58
+ end
59
+
60
+ def channel_mappings
61
+ @config["channel_mappings"] || {}
62
+ end
63
+
64
+ def user_mappings
65
+ @config["user_mappings"] || {}
66
+ end
67
+
68
+ def authorized_role_ids
69
+ if @config["role_mappings"]
70
+ @config["role_mappings"].values
71
+ elsif @config["authorized_role_ids"].is_a?(Hash)
72
+ @config["authorized_role_ids"].values
73
+ else
74
+ @config["authorized_role_ids"] || []
75
+ end.map(&:to_s)
76
+ end
77
+
78
+ def authorized_user_ids
79
+ @config["authorized_user_ids"] || []
80
+ end
81
+
82
+ # Find the project for a given Discord channel.
83
+ # Returns [project_key, project_config, mapping] or nil.
84
+ def find_project_for_channel(channel_id)
85
+ mapping = channel_mappings[channel_id]
86
+
87
+ unless mapping
88
+ default = default_project
89
+ mapping = { "project" => default } if default
90
+ end
91
+
92
+ return nil unless mapping
93
+
94
+ project_key = mapping["project"]
95
+ project_config = PROJECTS[project_key]
96
+ return nil unless project_config
97
+
98
+ [project_key, project_config, mapping]
99
+ end
100
+
101
+ # --- Thread Map Persistence ---
102
+
103
+ def load_thread_map
104
+ return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
105
+
106
+ JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
107
+ rescue JSON::ParserError
108
+ {}
109
+ end
110
+
111
+ def save_thread_map(map)
112
+ File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Discord
8
+ # Discord draft delivery system.
9
+ #
10
+ # Response files land in draft/ with a .meta.json sidecar containing delivery info.
11
+ # After successful posting, both files move to posted/.
12
+ # A poller thread recovers orphaned drafts (e.g. after a server restart).
13
+ module Delivery
14
+ BRAINIAC_DIR_PATH = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
15
+ DRAFT_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "draft")
16
+ POSTED_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "posted")
17
+
18
+ POLLER_INTERVAL = 5 # seconds
19
+ DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
20
+
21
+ # Shared thread map: when multiple agents are mentioned in the same message,
22
+ # the first to deliver creates the thread and stores its ID here so the rest
23
+ # post into the same thread instead of creating duplicates.
24
+ @shared_threads = {}
25
+ @shared_threads_mutex = Mutex.new
26
+
27
+ class << self
28
+ attr_reader :shared_threads, :shared_threads_mutex
29
+
30
+ def ensure_dirs!
31
+ FileUtils.mkdir_p(DRAFT_DIR)
32
+ FileUtils.mkdir_p(POSTED_DIR)
33
+ end
34
+
35
+ def start_poller!
36
+ ensure_dirs!
37
+ Thread.new do
38
+ LOG.info "[Discord] Draft poller started, checking #{DRAFT_DIR} every #{POLLER_INTERVAL}s" if defined?(LOG)
39
+ loop do
40
+ sleep POLLER_INTERVAL
41
+ poll_drafts
42
+ rescue StandardError => e
43
+ LOG.error "[Discord] Draft poller error: #{e.message}" if defined?(LOG)
44
+ end
45
+ end
46
+ end
47
+
48
+ # Shared logic for posting a draft response file to Discord and moving it to posted/.
49
+ def deliver_draft(response_file, meta_file)
50
+ return false unless File.exist?(meta_file)
51
+
52
+ lock_file = "#{meta_file}.lock"
53
+ begin
54
+ File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # rubocop:disable Lint/EmptyBlock
55
+ rescue Errno::EEXIST
56
+ return false
57
+ end
58
+
59
+ meta = JSON.parse(File.read(meta_file))
60
+ bot_token = resolve_bot_token(meta["agent_key"], meta["agent_name"])
61
+
62
+ unless bot_token
63
+ FileUtils.rm_f(lock_file)
64
+ return false
65
+ end
66
+
67
+ unless File.exist?(response_file)
68
+ FileUtils.rm_f(lock_file)
69
+ return false
70
+ end
71
+
72
+ deliver_response_content(response_file, meta, bot_token)
73
+ archive_delivered(response_file, meta_file, lock_file, meta["agent_name"])
74
+ true
75
+ rescue StandardError => e
76
+ LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}" if defined?(LOG)
77
+ File.delete(lock_file) if lock_file && File.exist?(lock_file)
78
+ false
79
+ end
80
+
81
+ private
82
+
83
+ def poll_drafts
84
+ # Clean up stale lock files (older than 60s) left by crashed deliveries
85
+ Dir.glob(File.join(DRAFT_DIR, "*.lock")).each do |lock_file|
86
+ File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
87
+ end
88
+
89
+ Dir.glob(File.join(DRAFT_DIR, "*.meta.json")).each do |meta_file|
90
+ next if (Time.now - File.mtime(meta_file)) < DRAFT_MIN_AGE
91
+
92
+ response_file = if meta_file.end_with?(".md.meta.json")
93
+ meta_file.sub(".md.meta.json", ".md")
94
+ else
95
+ meta_file.sub(".meta.json", ".md")
96
+ end
97
+ next unless File.exist?(response_file)
98
+
99
+ LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}" if defined?(LOG)
100
+ deliver_draft(response_file, meta_file)
101
+ end
102
+ end
103
+
104
+ def resolve_bot_token(agent_key, agent_name)
105
+ token = Gateway.bot_token(agent_key)
106
+ token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
107
+ LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" if !token && defined?(LOG)
108
+ token
109
+ end
110
+
111
+ def deliver_response_content(response_file, meta, bot_token)
112
+ channel_id = meta["channel_id"]
113
+ message_id = meta["message_id"]
114
+ agent_key = meta["agent_key"]
115
+ agent_name = meta["agent_name"]
116
+ response = File.read(response_file).strip
117
+
118
+ if response.empty?
119
+ Api.add_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
120
+ Api.send_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
121
+ elsif meta["is_dm"] || meta["is_thread"] || message_id.nil?
122
+ deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
123
+ else
124
+ deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, meta["clean_content"] || "", bot_token)
125
+ end
126
+ end
127
+
128
+ def archive_delivered(response_file, meta_file, lock_file, agent_name)
129
+ FileUtils.mv(response_file, File.join(POSTED_DIR, File.basename(response_file))) if File.exist?(response_file)
130
+ FileUtils.mv(meta_file, File.join(POSTED_DIR, File.basename(meta_file)))
131
+ FileUtils.rm_f(lock_file)
132
+ LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/" if defined?(LOG)
133
+ end
134
+
135
+ def deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
136
+ if message_id.nil? && Api.forum_channel?(channel_id, token: bot_token)
137
+ title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
138
+ if meta["forum_reply_to_latest"]
139
+ latest_thread = Api.find_latest_forum_thread(channel_id, token: bot_token)
140
+ if latest_thread
141
+ Api.send_long_message(latest_thread["id"], response, token: bot_token)
142
+ else
143
+ LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post" if defined?(LOG)
144
+ Api.create_forum_post(channel_id, title: title, content: response, token: bot_token)
145
+ end
146
+ else
147
+ Api.create_forum_post(channel_id, title: title, content: response, token: bot_token)
148
+ end
149
+ else
150
+ Api.send_long_message(channel_id, response, token: bot_token)
151
+ end
152
+ end
153
+
154
+ def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, clean_content, bot_token)
155
+ thread_id = nil
156
+ created_thread = false
157
+
158
+ @shared_threads_mutex.synchronize do
159
+ thread_id = @shared_threads[message_id]
160
+
161
+ unless thread_id
162
+ original_msg = Api.request(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
163
+ if original_msg&.dig("thread", "id")
164
+ thread_id = original_msg["thread"]["id"]
165
+ @shared_threads[message_id] = thread_id
166
+ LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API" if defined?(LOG)
167
+ end
168
+ end
169
+
170
+ unless thread_id
171
+ display_name = agent_display_name(agent_key)
172
+ thread = Api.create_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
173
+ if thread && thread["id"]
174
+ thread_id = thread["id"]
175
+ @shared_threads[message_id] = thread_id
176
+ created_thread = true
177
+ LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}" if defined?(LOG)
178
+ end
179
+ end
180
+ end
181
+
182
+ if thread_id
183
+ LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" if !created_thread && defined?(LOG)
184
+
185
+ # Propagate dispatch depth to the thread
186
+ propagate_dispatch_depth(channel_id, thread_id, agent_name)
187
+
188
+ Api.send_typing(thread_id, token: bot_token)
189
+ Api.send_long_message(thread_id, response, token: bot_token)
190
+ else
191
+ LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply" if defined?(LOG)
192
+ Api.send_long_message(channel_id, response, token: bot_token, reply_to: message_id)
193
+ end
194
+ end
195
+
196
+ def propagate_dispatch_depth(channel_id, thread_id, agent_name)
197
+ return unless defined?(AGENT_DISPATCH_DEPTH) && respond_to?(:record_human_comment)
198
+
199
+ parent_depth_key = "discord-#{channel_id}"
200
+ thread_depth_key = "discord-#{thread_id}"
201
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
202
+ return if AGENT_DISPATCH_DEPTH[thread_depth_key]
203
+
204
+ if parent_info
205
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
206
+ LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}" if defined?(LOG)
207
+ else
208
+ record_human_comment(thread_depth_key)
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket-client-simple"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Discord
8
+ # Discord WebSocket gateway connections.
9
+ #
10
+ # Each agent with a DISCORD_BOT_TOKEN gets its own persistent WebSocket
11
+ # connection. The gateway dispatches MESSAGE_CREATE, MESSAGE_UPDATE,
12
+ # and MESSAGE_REACTION_ADD events to handler functions.
13
+ module Gateway
14
+ GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
15
+
16
+ # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
17
+ @bots = {}
18
+ @bots_mutex = Mutex.new
19
+ @all_ready_logged = false
20
+
21
+ class << self
22
+ attr_reader :bots, :bots_mutex
23
+
24
+ def bot_count
25
+ @bots_mutex.synchronize { @bots.size }
26
+ end
27
+
28
+ # Iterate over bots under mutex.
29
+ def each_bot(&)
30
+ @bots_mutex.synchronize do
31
+ @bots.each(&)
32
+ end
33
+ end
34
+
35
+ # Get a bot's token.
36
+ def bot_token(agent_key)
37
+ @bots_mutex.synchronize { @bots.dig(agent_key, :token) }
38
+ end
39
+
40
+ # Get a bot's user_id.
41
+ def bot_user_id(agent_key)
42
+ @bots_mutex.synchronize { @bots.dig(agent_key, :user_id) }
43
+ end
44
+
45
+ # Collect all agent Discord bot tokens from the registry.
46
+ # Returns { "galen" => "token...", "glados" => "token..." }
47
+ def discord_bot_tokens
48
+ tokens = {}
49
+ AGENT_REGISTRY.each do |key, entry|
50
+ next unless entry.is_a?(Hash)
51
+
52
+ token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
53
+ next unless token
54
+
55
+ tokens[key] = token
56
+ end
57
+ tokens
58
+ end
59
+
60
+ # Start all per-agent Discord bots.
61
+ def start_all!
62
+ tokens = discord_bot_tokens
63
+ if tokens.empty?
64
+ LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled" if defined?(LOG)
65
+ return
66
+ end
67
+
68
+ LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}" if defined?(LOG)
69
+
70
+ @bots_mutex.synchronize do
71
+ tokens.each do |agent_key, token|
72
+ @bots[agent_key] = { token: token, status: "starting", user_id: nil }
73
+ end
74
+ end
75
+
76
+ tokens.each do |agent_key, token|
77
+ start_gateway_for(agent_key, token)
78
+ sleep 1 # Stagger connections to avoid rate limits
79
+ end
80
+ end
81
+
82
+ # Summary of all bot statuses for the API endpoint.
83
+ def bots_status
84
+ @bots_mutex.synchronize do
85
+ @bots.transform_values do |info|
86
+ { status: info[:status], user_id: info[:user_id] }
87
+ end
88
+ end
89
+ end
90
+
91
+ # Detect if a message author is a known bot (local or remote).
92
+ # Returns the agent_key of the sender, or nil if unknown.
93
+ def detect_sender_agent(author, current_agent_key)
94
+ sender_id = author["id"]
95
+ sender_agent_key = nil
96
+
97
+ @bots_mutex.synchronize do
98
+ @bots.each do |key, info|
99
+ if info[:user_id] == sender_id && key != current_agent_key
100
+ sender_agent_key = key
101
+ break
102
+ end
103
+ end
104
+ end
105
+
106
+ unless sender_agent_key
107
+ Config.user_mappings.each do |name, discord_id|
108
+ if discord_id == sender_id
109
+ sender_agent_key = name.downcase
110
+ break
111
+ end
112
+ end
113
+ end
114
+
115
+ if !sender_agent_key && defined?(LOG)
116
+ LOG.info "[Discord:#{current_agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}"
117
+ end
118
+
119
+ sender_agent_key
120
+ end
121
+
122
+ private
123
+
124
+ def start_gateway_for(agent_key, bot_token)
125
+ Thread.new do
126
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
127
+ bot_user_id = nil
128
+
129
+ loop do
130
+ bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
131
+ rescue StandardError => e
132
+ @bots_mutex.synchronize do
133
+ @bots[agent_key][:status] = "error" if @bots[agent_key]
134
+ end
135
+ LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..." if defined?(LOG)
136
+ sleep 5
137
+ end
138
+ end
139
+ end
140
+
141
+ def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
142
+ @bots_mutex.synchronize do
143
+ @bots[agent_key] ||= {}
144
+ @bots[agent_key][:status] = "connecting"
145
+ @bots[agent_key][:token] = bot_token
146
+ end
147
+
148
+ LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." if defined?(LOG) && LOG.respond_to?(:debug)
149
+ heartbeat_thread = nil
150
+ last_sequence = nil
151
+ ws = WebSocket::Client::Simple.connect(GATEWAY_URL)
152
+
153
+ ws.on :message do |msg|
154
+ next if msg.data.nil? || msg.data.empty?
155
+
156
+ payload = JSON.parse(msg.data)
157
+ last_sequence = payload["s"] if payload["s"]
158
+ heartbeat_thread, bot_user_id = Gateway.send(
159
+ :handle_gateway_op,
160
+ ws, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence
161
+ )
162
+ rescue StandardError => e
163
+ LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}" if defined?(LOG)
164
+ end
165
+
166
+ ws.on :open do
167
+ LOG.debug "[Discord:#{agent_display}] WebSocket connected" if defined?(LOG) && LOG.respond_to?(:debug)
168
+ end
169
+
170
+ ws.on :close do |_e|
171
+ @bots_mutex.synchronize do
172
+ @bots[agent_key][:status] = "disconnected" if @bots[agent_key]
173
+ end
174
+ LOG.warn "[Discord:#{agent_display}] WebSocket closed" if defined?(LOG)
175
+ heartbeat_thread&.kill
176
+ end
177
+
178
+ ws.on :error do |e|
179
+ LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}" if defined?(LOG)
180
+ end
181
+
182
+ wait_for_disconnect(ws, agent_display)
183
+ bot_user_id
184
+ end
185
+
186
+ def wait_for_disconnect(websocket, agent_display)
187
+ loop do
188
+ sleep 1
189
+ next if websocket.open?
190
+
191
+ LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..." if defined?(LOG)
192
+ sleep 5
193
+ break
194
+ end
195
+ end
196
+
197
+ def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
198
+ op = payload["op"]
199
+ data = payload["d"]
200
+
201
+ case op
202
+ when 10
203
+ heartbeat_thread = start_heartbeat(websocket, data["heartbeat_interval"], agent_display, last_sequence)
204
+ send_identify(websocket, bot_token, agent_display)
205
+ when 0
206
+ bot_user_id = handle_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
207
+ when 1
208
+ websocket.send({ op: 1, d: last_sequence }.to_json)
209
+ when 7
210
+ LOG.info "[Discord:#{agent_display}] Reconnect requested" if defined?(LOG)
211
+ websocket.close
212
+ when 9
213
+ LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s" if defined?(LOG)
214
+ sleep 5
215
+ send_identify(websocket, bot_token, agent_display)
216
+ when 11 then nil
217
+ end
218
+
219
+ [heartbeat_thread, bot_user_id]
220
+ end
221
+
222
+ def start_heartbeat(websocket, interval_ms, agent_display, last_sequence)
223
+ LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{interval_ms}ms" if defined?(LOG) && LOG.respond_to?(:debug)
224
+ Thread.new do
225
+ loop do
226
+ sleep(interval_ms / 1000.0)
227
+ websocket.send({ op: 1, d: last_sequence }.to_json)
228
+ end
229
+ end
230
+ end
231
+
232
+ def send_identify(websocket, bot_token, agent_display)
233
+ LOG.debug "[Discord:#{agent_display}] Sending IDENTIFY" if defined?(LOG) && LOG.respond_to?(:debug)
234
+ websocket.send({
235
+ op: 2,
236
+ d: {
237
+ token: bot_token,
238
+ intents: 46_593,
239
+ properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" }
240
+ }
241
+ }.to_json)
242
+ end
243
+
244
+ def handle_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
245
+ case payload["t"]
246
+ when "READY"
247
+ bot_user_id = data.dig("user", "id")
248
+ mark_bot_ready(agent_key, agent_display, bot_user_id, data)
249
+ when "MESSAGE_CREATE"
250
+ Thread.new do
251
+ Message.handle(data, agent_key, bot_token, bot_user_id)
252
+ rescue StandardError => e
253
+ LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
254
+ end
255
+ when "MESSAGE_UPDATE"
256
+ if data["edited_timestamp"]
257
+ Thread.new do
258
+ Message.handle(data, agent_key, bot_token, bot_user_id)
259
+ rescue StandardError => e
260
+ if defined?(LOG)
261
+ LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
262
+ end
263
+ end
264
+ end
265
+ when "MESSAGE_REACTION_ADD"
266
+ Thread.new do
267
+ Reactions.handle(data, agent_key, bot_token, bot_user_id)
268
+ rescue StandardError => e
269
+ LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
270
+ end
271
+ end
272
+
273
+ bot_user_id
274
+ end
275
+
276
+ def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
277
+ @bots_mutex.synchronize do
278
+ @bots[agent_key][:user_id] = bot_user_id
279
+ @bots[agent_key][:status] = "ready"
280
+ end
281
+ guild_count = data["guilds"]&.size || 0
282
+ LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})" if defined?(LOG)
283
+
284
+ @bots_mutex.synchronize do
285
+ if !@all_ready_logged && @bots.all? { |_, info| info[:status] == "ready" }
286
+ @all_ready_logged = true
287
+ LOG.info "[Discord] All bots connected." if defined?(LOG)
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end