brainiac 0.0.6 → 0.0.8
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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +472 -44
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +27 -74
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +40 -76
- data/lib/brainiac/handlers/discord/api.rb +196 -0
- data/lib/brainiac/handlers/discord/config.rb +134 -0
- data/lib/brainiac/handlers/discord/delivery.rb +196 -0
- data/lib/brainiac/handlers/discord/gateway.rb +212 -0
- data/lib/brainiac/handlers/discord/message.rb +933 -0
- data/lib/brainiac/handlers/discord/reactions.rb +215 -0
- data/lib/brainiac/handlers/discord.rb +14 -1892
- data/lib/brainiac/handlers/github.rb +134 -317
- data/lib/brainiac/handlers/shared/git.rb +190 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +103 -153
- data/lib/brainiac/helpers.rb +43 -455
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +154 -0
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +112 -0
- data/lib/brainiac/routes/api.rb +411 -0
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +8 -1
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
- data/monitor/waybar/setup.rb +232 -0
- data/monitor/waybar/status.rb +51 -0
- data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
- data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
- data/monitor/xbar/plugin.rb +149 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +44 -551
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +8 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- data/templates/users.json.example +0 -3
- metadata +25 -23
- data/lib/brainiac/card_index.rb +0 -389
- data/lib/brainiac/deployments.rb +0 -258
- data/lib/brainiac/handlers/fizzy.rb +0 -1292
- data/lib/brainiac/planning.rb +0 -237
- data/lib/user_registry.rb +0 -159
- data/monitor/menubar.rb +0 -295
- data/monitor/setup-waybar-deploy-envs.rb +0 -121
- data/monitor/setup-waybar-deployments.rb +0 -96
- data/monitor/setup-waybar-module.rb +0 -113
- data/monitor/setup-xbar-plugin.rb +0 -35
- data/monitor/view-logs.rb +0 -119
- data/monitor/waybar-config-updater.rb +0 -56
- data/monitor/waybar-deployments.rb +0 -239
- data/monitor/waybar.rb +0 -146
- data/monitor/xbar.3s.rb +0 -179
- data/templates/fizzy.json.example +0 -24
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord draft delivery system.
|
|
4
|
+
#
|
|
5
|
+
# Response files land in draft/ with a .meta.json sidecar containing delivery info.
|
|
6
|
+
# After successful posting, both files move to posted/.
|
|
7
|
+
# A poller thread recovers orphaned drafts (e.g. after a server restart).
|
|
8
|
+
|
|
9
|
+
DISCORD_DRAFT_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "draft")
|
|
10
|
+
DISCORD_POSTED_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "posted")
|
|
11
|
+
FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
|
|
12
|
+
FileUtils.mkdir_p(DISCORD_POSTED_DIR)
|
|
13
|
+
|
|
14
|
+
# Shared thread map: when multiple agents are mentioned in the same message,
|
|
15
|
+
# the first to deliver creates the thread and stores its ID here so the rest
|
|
16
|
+
# post into the same thread instead of creating duplicates.
|
|
17
|
+
DISCORD_SHARED_THREADS = {}
|
|
18
|
+
DISCORD_SHARED_THREADS_MUTEX = Mutex.new
|
|
19
|
+
|
|
20
|
+
# Shared logic for posting a draft response file to Discord and moving it to posted/.
|
|
21
|
+
# Used by both the monitoring thread (happy path) and the poller (recovery path).
|
|
22
|
+
def deliver_discord_draft(response_file, meta_file)
|
|
23
|
+
return false unless File.exist?(meta_file)
|
|
24
|
+
|
|
25
|
+
lock_file = "#{meta_file}.lock"
|
|
26
|
+
begin
|
|
27
|
+
File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # rubocop:disable Lint/EmptyBlock
|
|
28
|
+
rescue Errno::EEXIST
|
|
29
|
+
return false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
meta = JSON.parse(File.read(meta_file))
|
|
33
|
+
bot_token = resolve_bot_token(meta["agent_key"], meta["agent_name"])
|
|
34
|
+
|
|
35
|
+
unless bot_token
|
|
36
|
+
FileUtils.rm_f(lock_file)
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless File.exist?(response_file)
|
|
41
|
+
FileUtils.rm_f(lock_file)
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
deliver_response_content(response_file, meta, bot_token)
|
|
46
|
+
archive_delivered_draft(response_file, meta_file, lock_file, meta["agent_name"])
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
|
|
50
|
+
File.delete(lock_file) if lock_file && File.exist?(lock_file)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve_bot_token(agent_key, agent_name)
|
|
55
|
+
token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
|
|
56
|
+
token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
|
|
57
|
+
LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" unless token
|
|
58
|
+
token
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deliver_response_content(response_file, meta, bot_token)
|
|
62
|
+
channel_id = meta["channel_id"]
|
|
63
|
+
message_id = meta["message_id"]
|
|
64
|
+
agent_key = meta["agent_key"]
|
|
65
|
+
agent_name = meta["agent_name"]
|
|
66
|
+
response = File.read(response_file).strip
|
|
67
|
+
|
|
68
|
+
if response.empty?
|
|
69
|
+
add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
|
|
70
|
+
send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
|
|
71
|
+
elsif meta["is_dm"] || meta["is_thread"] || message_id.nil?
|
|
72
|
+
deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
73
|
+
else
|
|
74
|
+
deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, meta["clean_content"] || "", bot_token)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def archive_delivered_draft(response_file, meta_file, lock_file, agent_name)
|
|
79
|
+
FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, File.basename(response_file))) if File.exist?(response_file)
|
|
80
|
+
FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, File.basename(meta_file)))
|
|
81
|
+
FileUtils.rm_f(lock_file)
|
|
82
|
+
LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Delivery to DMs, threads, and forum channels ---
|
|
86
|
+
|
|
87
|
+
def deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
88
|
+
if message_id.nil? && forum_channel?(channel_id, token: bot_token)
|
|
89
|
+
title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
|
|
90
|
+
if meta["forum_reply_to_latest"]
|
|
91
|
+
latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
|
|
92
|
+
if latest_thread
|
|
93
|
+
send_long_discord_message(latest_thread["id"], response, token: bot_token)
|
|
94
|
+
else
|
|
95
|
+
LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
|
|
96
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
97
|
+
end
|
|
98
|
+
else
|
|
99
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
send_long_discord_message(channel_id, response, token: bot_token)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Delivery to channel messages (creates or joins a thread) ---
|
|
107
|
+
|
|
108
|
+
def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, clean_content, bot_token)
|
|
109
|
+
thread_id = nil
|
|
110
|
+
created_thread = false
|
|
111
|
+
|
|
112
|
+
DISCORD_SHARED_THREADS_MUTEX.synchronize do
|
|
113
|
+
thread_id = DISCORD_SHARED_THREADS[message_id]
|
|
114
|
+
|
|
115
|
+
# Tier 2: Ask Discord if a thread already exists on this message.
|
|
116
|
+
unless thread_id
|
|
117
|
+
original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
|
|
118
|
+
if original_msg&.dig("thread", "id")
|
|
119
|
+
thread_id = original_msg["thread"]["id"]
|
|
120
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
121
|
+
LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Tier 3: No thread exists anywhere — create one.
|
|
126
|
+
unless thread_id
|
|
127
|
+
display_name = agent_display_name(agent_key)
|
|
128
|
+
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
129
|
+
if thread && thread["id"]
|
|
130
|
+
thread_id = thread["id"]
|
|
131
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
132
|
+
created_thread = true
|
|
133
|
+
LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if thread_id
|
|
139
|
+
LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
|
|
140
|
+
|
|
141
|
+
# Propagate dispatch depth to the thread
|
|
142
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
143
|
+
thread_depth_key = "discord-#{thread_id}"
|
|
144
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
145
|
+
unless AGENT_DISPATCH_DEPTH[thread_depth_key]
|
|
146
|
+
if parent_info
|
|
147
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
148
|
+
LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
|
|
149
|
+
else
|
|
150
|
+
record_human_comment(thread_depth_key)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
send_discord_typing(thread_id, token: bot_token)
|
|
155
|
+
send_long_discord_message(thread_id, response, token: bot_token)
|
|
156
|
+
else
|
|
157
|
+
LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
|
|
158
|
+
send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- Draft Poller ---
|
|
163
|
+
|
|
164
|
+
DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
|
|
165
|
+
DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
|
|
166
|
+
|
|
167
|
+
def start_discord_draft_poller
|
|
168
|
+
Thread.new do
|
|
169
|
+
LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
|
|
170
|
+
loop do
|
|
171
|
+
sleep DISCORD_DRAFT_POLLER_INTERVAL
|
|
172
|
+
begin
|
|
173
|
+
# Clean up stale lock files (older than 60s) left by crashed deliveries
|
|
174
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
|
|
175
|
+
File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
|
|
179
|
+
next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
|
|
180
|
+
|
|
181
|
+
response_file = if meta_file.end_with?(".md.meta.json")
|
|
182
|
+
meta_file.sub(".md.meta.json", ".md")
|
|
183
|
+
else
|
|
184
|
+
meta_file.sub(".meta.json", ".md")
|
|
185
|
+
end
|
|
186
|
+
next unless File.exist?(response_file)
|
|
187
|
+
|
|
188
|
+
LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
|
|
189
|
+
deliver_discord_draft(response_file, meta_file)
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
LOG.error "[Discord] Draft poller error: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord WebSocket gateway connections.
|
|
4
|
+
#
|
|
5
|
+
# Each agent with a DISCORD_BOT_TOKEN gets its own persistent WebSocket
|
|
6
|
+
# connection. The gateway dispatches MESSAGE_CREATE, MESSAGE_UPDATE,
|
|
7
|
+
# and MESSAGE_REACTION_ADD events to handler functions.
|
|
8
|
+
|
|
9
|
+
DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
10
|
+
|
|
11
|
+
# Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
|
|
12
|
+
DISCORD_BOTS = {}
|
|
13
|
+
DISCORD_BOTS_MUTEX = Mutex.new
|
|
14
|
+
DISCORD_ALL_READY_LOGGED = { done: false }
|
|
15
|
+
|
|
16
|
+
def start_discord_gateway_for(agent_key, bot_token)
|
|
17
|
+
Thread.new do
|
|
18
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
19
|
+
bot_user_id = nil
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
25
|
+
DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key]
|
|
26
|
+
end
|
|
27
|
+
LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..."
|
|
28
|
+
sleep 5
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
|
|
34
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
35
|
+
DISCORD_BOTS[agent_key] ||= {}
|
|
36
|
+
DISCORD_BOTS[agent_key][:status] = "connecting"
|
|
37
|
+
DISCORD_BOTS[agent_key][:token] = bot_token
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..."
|
|
41
|
+
heartbeat_thread = nil
|
|
42
|
+
last_sequence = nil
|
|
43
|
+
ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL)
|
|
44
|
+
|
|
45
|
+
ws.on :message do |msg|
|
|
46
|
+
next if msg.data.nil? || msg.data.empty?
|
|
47
|
+
|
|
48
|
+
payload = JSON.parse(msg.data)
|
|
49
|
+
last_sequence = payload["s"] if payload["s"]
|
|
50
|
+
heartbeat_thread, bot_user_id = handle_gateway_op(
|
|
51
|
+
ws, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence
|
|
52
|
+
)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ws.on :open do
|
|
58
|
+
LOG.debug "[Discord:#{agent_display}] WebSocket connected"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ws.on :close do |e|
|
|
62
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
63
|
+
DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key]
|
|
64
|
+
end
|
|
65
|
+
LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}"
|
|
66
|
+
heartbeat_thread&.kill
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
ws.on :error do |e|
|
|
70
|
+
LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
wait_for_disconnect(ws, agent_display)
|
|
74
|
+
bot_user_id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_for_disconnect(websocket, agent_display)
|
|
78
|
+
loop do
|
|
79
|
+
sleep 1
|
|
80
|
+
next if websocket.open?
|
|
81
|
+
|
|
82
|
+
LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..."
|
|
83
|
+
sleep 5
|
|
84
|
+
break
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Handle a single gateway opcode. Returns [heartbeat_thread, bot_user_id].
|
|
89
|
+
def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
|
|
90
|
+
op = payload["op"]
|
|
91
|
+
data = payload["d"]
|
|
92
|
+
|
|
93
|
+
case op
|
|
94
|
+
when 10
|
|
95
|
+
heartbeat_thread = start_gateway_heartbeat(websocket, data["heartbeat_interval"], agent_display, last_sequence)
|
|
96
|
+
send_identify(websocket, bot_token, agent_display)
|
|
97
|
+
when 0
|
|
98
|
+
bot_user_id = handle_gateway_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
|
|
99
|
+
when 1
|
|
100
|
+
websocket.send({ op: 1, d: last_sequence }.to_json)
|
|
101
|
+
when 7
|
|
102
|
+
LOG.info "[Discord:#{agent_display}] Reconnect requested"
|
|
103
|
+
websocket.close
|
|
104
|
+
when 9
|
|
105
|
+
LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s"
|
|
106
|
+
sleep 5
|
|
107
|
+
send_identify(websocket, bot_token, agent_display)
|
|
108
|
+
when 11 then nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[heartbeat_thread, bot_user_id]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def start_gateway_heartbeat(websocket, interval_ms, agent_display, last_sequence)
|
|
115
|
+
LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{interval_ms}ms"
|
|
116
|
+
Thread.new do
|
|
117
|
+
loop do
|
|
118
|
+
sleep(interval_ms / 1000.0)
|
|
119
|
+
websocket.send({ op: 1, d: last_sequence }.to_json)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def send_identify(websocket, bot_token, agent_display)
|
|
125
|
+
LOG.debug "[Discord:#{agent_display}] Sending IDENTIFY"
|
|
126
|
+
websocket.send({
|
|
127
|
+
op: 2,
|
|
128
|
+
d: {
|
|
129
|
+
token: bot_token,
|
|
130
|
+
intents: 46_593,
|
|
131
|
+
properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" }
|
|
132
|
+
}
|
|
133
|
+
}.to_json)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_gateway_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
|
|
137
|
+
case payload["t"]
|
|
138
|
+
when "READY"
|
|
139
|
+
bot_user_id = data.dig("user", "id")
|
|
140
|
+
mark_bot_ready(agent_key, agent_display, bot_user_id, data)
|
|
141
|
+
when "MESSAGE_CREATE"
|
|
142
|
+
Thread.new do
|
|
143
|
+
handle_discord_message(data, agent_key, bot_token, bot_user_id)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
146
|
+
end
|
|
147
|
+
when "MESSAGE_UPDATE"
|
|
148
|
+
if data["edited_timestamp"]
|
|
149
|
+
Thread.new do
|
|
150
|
+
handle_discord_message(data, agent_key, bot_token, bot_user_id)
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
when "MESSAGE_REACTION_ADD"
|
|
156
|
+
Thread.new do
|
|
157
|
+
handle_discord_reaction(data, agent_key, bot_token, bot_user_id)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
bot_user_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
|
|
167
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
168
|
+
DISCORD_BOTS[agent_key][:user_id] = bot_user_id
|
|
169
|
+
DISCORD_BOTS[agent_key][:status] = "ready"
|
|
170
|
+
end
|
|
171
|
+
guild_count = data["guilds"]&.size || 0
|
|
172
|
+
LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})"
|
|
173
|
+
LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}"
|
|
174
|
+
|
|
175
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
176
|
+
if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" }
|
|
177
|
+
DISCORD_ALL_READY_LOGGED[:done] = true
|
|
178
|
+
LOG.info "[Discord] All bots connected."
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Start all per-agent Discord bots.
|
|
184
|
+
def start_all_discord_gateways
|
|
185
|
+
tokens = discord_bot_tokens
|
|
186
|
+
if tokens.empty?
|
|
187
|
+
LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled"
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
|
|
192
|
+
|
|
193
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
194
|
+
tokens.each do |agent_key, token|
|
|
195
|
+
DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
tokens.each do |agent_key, token|
|
|
200
|
+
start_discord_gateway_for(agent_key, token)
|
|
201
|
+
sleep 1 # Stagger connections to avoid rate limits
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Summary of all bot statuses for the API endpoint.
|
|
206
|
+
def discord_bots_status
|
|
207
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
208
|
+
DISCORD_BOTS.transform_values do |info|
|
|
209
|
+
{ status: info[:status], user_id: info[:user_id] }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|