zillacore 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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,1643 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord bot handlers: per-agent gateway connections, message handling, API helpers.
|
|
4
|
+
#
|
|
5
|
+
# Each agent with a `discord_bot_token` in the agent registry gets its own
|
|
6
|
+
# Discord bot connection. Users @mention @Galen or @GLaDOS directly in Discord
|
|
7
|
+
# rather than a single shared bot.
|
|
8
|
+
|
|
9
|
+
require "English"
|
|
10
|
+
DISCORD_CONFIG_FILE = File.join(ZILLACORE_DIR, "discord.json")
|
|
11
|
+
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
12
|
+
DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
13
|
+
|
|
14
|
+
# Draft/posted directories for resilient Discord response delivery.
|
|
15
|
+
# Response files land in draft/ with a .meta.json sidecar containing delivery info.
|
|
16
|
+
# After successful posting, both files move to posted/.
|
|
17
|
+
# A poller thread recovers orphaned drafts (e.g. after a server restart).
|
|
18
|
+
DISCORD_DRAFT_DIR = File.join(ZILLACORE_DIR, "tmp", "discord", "draft")
|
|
19
|
+
DISCORD_POSTED_DIR = File.join(ZILLACORE_DIR, "tmp", "discord", "posted")
|
|
20
|
+
FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
|
|
21
|
+
FileUtils.mkdir_p(DISCORD_POSTED_DIR)
|
|
22
|
+
|
|
23
|
+
# Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
|
|
24
|
+
DISCORD_BOTS = {}
|
|
25
|
+
DISCORD_BOTS_MUTEX = Mutex.new
|
|
26
|
+
DISCORD_ALL_READY_LOGGED = { done: false }
|
|
27
|
+
|
|
28
|
+
# Shared thread map: when multiple agents are mentioned in the same message,
|
|
29
|
+
# the first to deliver creates the thread and stores its ID here so the rest
|
|
30
|
+
# post into the same thread instead of creating duplicates.
|
|
31
|
+
# Key: original message_id, Value: thread channel ID
|
|
32
|
+
DISCORD_SHARED_THREADS = {}
|
|
33
|
+
DISCORD_SHARED_THREADS_MUTEX = Mutex.new
|
|
34
|
+
|
|
35
|
+
# Zillacore restart queue: when an agent works on zillacore itself, queue a restart
|
|
36
|
+
# instead of doing it immediately. A background thread checks every 30s and only
|
|
37
|
+
# restarts when no other agents are running, preventing mid-session kills.
|
|
38
|
+
# Using a hash instead of a constant to allow mutation inside synchronize blocks
|
|
39
|
+
ZILLACORE_RESTART_STATE = { queued: false, triggered_by: nil }
|
|
40
|
+
ZILLACORE_RESTART_MUTEX = Mutex.new
|
|
41
|
+
|
|
42
|
+
def queue_zillacore_restart(agent_name)
|
|
43
|
+
ZILLACORE_RESTART_MUTEX.synchronize do
|
|
44
|
+
unless ZILLACORE_RESTART_STATE[:queued]
|
|
45
|
+
ZILLACORE_RESTART_STATE[:queued] = true
|
|
46
|
+
ZILLACORE_RESTART_STATE[:triggered_by] = agent_name
|
|
47
|
+
LOG.info "[ZillaCore] #{agent_name} queued a restart — will execute when all agents finish"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Send a Discord notification about zillacore restart/startup using any available bot token.
|
|
53
|
+
def send_restart_notification(message)
|
|
54
|
+
channel_id = DISCORD_CONFIG["notification_channel_id"]
|
|
55
|
+
return unless channel_id
|
|
56
|
+
|
|
57
|
+
tokens = discord_bot_tokens
|
|
58
|
+
# Prefer the triggering agent's token, fall back to first available
|
|
59
|
+
triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] }
|
|
60
|
+
token = tokens[triggered_by&.downcase] || tokens.values.first
|
|
61
|
+
return unless token
|
|
62
|
+
|
|
63
|
+
send_discord_message(channel_id, message, token: token)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
LOG.warn "[ZillaCore] Failed to send restart notification: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def any_agents_running?
|
|
69
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
70
|
+
ACTIVE_SESSIONS.any? do |_key, info|
|
|
71
|
+
Process.kill(0, info[:pid])
|
|
72
|
+
true
|
|
73
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_zillacore_restart_monitor
|
|
80
|
+
Thread.new do
|
|
81
|
+
LOG.info "[ZillaCore] Restart monitor started, checking every 30s"
|
|
82
|
+
loop do
|
|
83
|
+
sleep 30
|
|
84
|
+
restart_needed = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] }
|
|
85
|
+
|
|
86
|
+
if restart_needed && !any_agents_running?
|
|
87
|
+
triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] }
|
|
88
|
+
LOG.info "[ZillaCore] All agents finished, executing restart..."
|
|
89
|
+
ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] = false }
|
|
90
|
+
|
|
91
|
+
send_restart_notification("🔄 Restarting zillacore (triggered by #{triggered_by || "unknown"})...")
|
|
92
|
+
|
|
93
|
+
# Schedule restart: stop now, start in 3 seconds
|
|
94
|
+
# This ensures the current process fully exits before the new one starts
|
|
95
|
+
Thread.new do
|
|
96
|
+
sleep 1 # Give time for log to flush
|
|
97
|
+
|
|
98
|
+
# Spawn a delayed restart command that will execute after we exit
|
|
99
|
+
# Inherit current PATH so zillacore binary can be found regardless of install location
|
|
100
|
+
# Process.detach ensures the spawned process survives when parent exits
|
|
101
|
+
pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "sh", "-c", "sleep 3 && zillacore server --daemon",
|
|
102
|
+
out: "/dev/null", err: "/dev/null")
|
|
103
|
+
Process.detach(pid)
|
|
104
|
+
|
|
105
|
+
sleep 1
|
|
106
|
+
LOG.info "[ZillaCore] Stopping server, new instance will start in 3 seconds..."
|
|
107
|
+
Sinatra::Application.quit!
|
|
108
|
+
sleep 0.5 # Give Sinatra a moment to shut down gracefully
|
|
109
|
+
exit! # Force exit to kill all threads immediately
|
|
110
|
+
end
|
|
111
|
+
elsif restart_needed
|
|
112
|
+
active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size }
|
|
113
|
+
LOG.info "[ZillaCore] Restart queued but #{active_count} agent(s) still running, waiting..."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def load_discord_config
|
|
120
|
+
default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
121
|
+
return default unless File.exist?(DISCORD_CONFIG_FILE)
|
|
122
|
+
|
|
123
|
+
JSON.parse(File.read(DISCORD_CONFIG_FILE))
|
|
124
|
+
rescue JSON::ParserError => e
|
|
125
|
+
LOG.error "Failed to parse discord config: #{e.message}"
|
|
126
|
+
default
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
DISCORD_CONFIG = load_discord_config
|
|
130
|
+
|
|
131
|
+
def reload_discord_config!
|
|
132
|
+
DISCORD_CONFIG.replace(load_discord_config)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Collect all agent Discord bot tokens from the registry.
|
|
136
|
+
# Returns { "galen" => "token...", "glados" => "token..." }
|
|
137
|
+
def discord_bot_tokens
|
|
138
|
+
tokens = {}
|
|
139
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
140
|
+
next unless entry.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
|
|
143
|
+
next unless token
|
|
144
|
+
|
|
145
|
+
tokens[key] = token
|
|
146
|
+
end
|
|
147
|
+
tokens
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- Discord REST API ---
|
|
151
|
+
|
|
152
|
+
def discord_api(method, path, token:, body: nil, log_errors: true)
|
|
153
|
+
uri = URI("#{DISCORD_API_BASE}#{path}")
|
|
154
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
155
|
+
http.use_ssl = true
|
|
156
|
+
|
|
157
|
+
req = case method
|
|
158
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
159
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
160
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
161
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
req["Authorization"] = "Bot #{token}"
|
|
165
|
+
req["Content-Type"] = "application/json"
|
|
166
|
+
req.body = body.to_json if body
|
|
167
|
+
|
|
168
|
+
response = http.request(req)
|
|
169
|
+
|
|
170
|
+
if response.code.to_i == 429
|
|
171
|
+
retry_after = JSON.parse(response.body)["retry_after"] || 1
|
|
172
|
+
LOG.warn "Discord rate limited, waiting #{retry_after}s"
|
|
173
|
+
sleep retry_after
|
|
174
|
+
return discord_api(method, path, token: token, body: body, log_errors: log_errors)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors
|
|
178
|
+
|
|
179
|
+
JSON.parse(response.body) unless response.body.nil? || response.body.empty?
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
LOG.error "Discord API error (#{method} #{path}): #{e.message}" if log_errors
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
|
|
186
|
+
messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
|
|
187
|
+
|
|
188
|
+
all_messages = messages.is_a?(Array) ? messages : []
|
|
189
|
+
|
|
190
|
+
# If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21).
|
|
191
|
+
# These messages have no content but point to the original message via referenced_message.
|
|
192
|
+
# We need to include that original message for full context.
|
|
193
|
+
if all_messages.any?
|
|
194
|
+
oldest = all_messages.last # API returns newest-first
|
|
195
|
+
if oldest && oldest["type"] == 21 && oldest["referenced_message"]
|
|
196
|
+
# Prepend the actual starter message content
|
|
197
|
+
all_messages << oldest["referenced_message"]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
return "" if all_messages.empty?
|
|
202
|
+
|
|
203
|
+
# Messages come newest-first from the API, reverse for chronological order
|
|
204
|
+
lines = all_messages.reverse.filter_map do |msg|
|
|
205
|
+
author = msg.dig("author", "username") || "unknown"
|
|
206
|
+
content = msg["content"]&.strip || ""
|
|
207
|
+
next if content.empty?
|
|
208
|
+
|
|
209
|
+
"#{author}: #{content}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return "" if lines.empty?
|
|
213
|
+
|
|
214
|
+
lines.join("\n")
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
LOG.warn "Failed to fetch channel history: #{e.message}"
|
|
217
|
+
""
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fetch_channel_info(channel_id, token:)
|
|
221
|
+
discord_api(:get, "/channels/#{channel_id}", token: token)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def forum_channel?(channel_id, token:)
|
|
225
|
+
info = fetch_channel_info(channel_id, token: token)
|
|
226
|
+
info && info["type"] == 15
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def find_latest_forum_thread(channel_id, token:)
|
|
230
|
+
# Get the guild ID from the channel info, then list active threads
|
|
231
|
+
channel_info = fetch_channel_info(channel_id, token: token)
|
|
232
|
+
return nil unless channel_info && channel_info["guild_id"]
|
|
233
|
+
|
|
234
|
+
guild_id = channel_info["guild_id"]
|
|
235
|
+
result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token)
|
|
236
|
+
return nil unless result && result["threads"]
|
|
237
|
+
|
|
238
|
+
# Filter to threads in this forum channel, sort by creation (newest first)
|
|
239
|
+
forum_threads = result["threads"]
|
|
240
|
+
.select { |t| t["parent_id"] == channel_id }
|
|
241
|
+
.sort_by { |t| t["id"].to_i }
|
|
242
|
+
.reverse
|
|
243
|
+
|
|
244
|
+
return nil if forum_threads.empty?
|
|
245
|
+
|
|
246
|
+
latest = forum_threads.first
|
|
247
|
+
LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}"
|
|
248
|
+
latest
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def create_forum_post(channel_id, title:, content:, token:)
|
|
252
|
+
thread_name = title.length > 100 ? "#{title[0..96]}..." : title
|
|
253
|
+
result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: {
|
|
254
|
+
name: thread_name,
|
|
255
|
+
message: { content: content },
|
|
256
|
+
auto_archive_duration: 1440
|
|
257
|
+
})
|
|
258
|
+
if result && result["id"]
|
|
259
|
+
LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}"
|
|
260
|
+
else
|
|
261
|
+
LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
|
|
262
|
+
end
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def send_discord_message(channel_id, content, token:, reply_to: nil)
|
|
267
|
+
body = { content: content }
|
|
268
|
+
body[:message_reference] = { message_id: reply_to } if reply_to
|
|
269
|
+
result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body)
|
|
270
|
+
if result && result["id"]
|
|
271
|
+
LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}"
|
|
272
|
+
else
|
|
273
|
+
LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
|
|
274
|
+
end
|
|
275
|
+
result
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def send_discord_typing(channel_id, token:)
|
|
279
|
+
discord_api(:post, "/channels/#{channel_id}/typing", token: token)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
|
|
283
|
+
discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Emojis reserved for zillacore functionality — not treated as feedback
|
|
287
|
+
RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
|
|
288
|
+
|
|
289
|
+
def add_discord_reaction(channel_id, message_id, emoji, token:)
|
|
290
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
291
|
+
discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def remove_discord_reaction(channel_id, message_id, emoji, token:)
|
|
295
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
296
|
+
discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def create_discord_thread(channel_id, message_id, name:, token:)
|
|
300
|
+
thread_name = name.length > 100 ? "#{name[0..96]}..." : name
|
|
301
|
+
discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
|
|
302
|
+
name: thread_name,
|
|
303
|
+
auto_archive_duration: 1440
|
|
304
|
+
})
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def fetch_guild_member(guild_id, user_id, token:)
|
|
308
|
+
discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def send_long_discord_message(channel_id, content, token:, reply_to: nil)
|
|
312
|
+
if content.length <= 2000
|
|
313
|
+
send_discord_message(channel_id, content, token: token, reply_to: reply_to)
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
chunks = []
|
|
318
|
+
remaining = content
|
|
319
|
+
while remaining.length.positive?
|
|
320
|
+
if remaining.length <= 2000
|
|
321
|
+
chunks << remaining
|
|
322
|
+
remaining = ""
|
|
323
|
+
else
|
|
324
|
+
split_at = remaining.rindex("\n", 1990) || 1990
|
|
325
|
+
chunks << remaining[0...split_at]
|
|
326
|
+
remaining = remaining[split_at..].lstrip
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
chunks.each_with_index do |chunk, i|
|
|
331
|
+
send_discord_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
|
|
332
|
+
sleep 0.5
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def find_project_for_discord_channel(channel_id)
|
|
337
|
+
mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id)
|
|
338
|
+
|
|
339
|
+
unless mapping
|
|
340
|
+
default_project = DISCORD_CONFIG["default_project"]
|
|
341
|
+
mapping = { "project" => default_project } if default_project
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
return nil unless mapping
|
|
345
|
+
|
|
346
|
+
project_key = mapping["project"]
|
|
347
|
+
project_config = PROJECTS[project_key]
|
|
348
|
+
return nil unless project_config
|
|
349
|
+
|
|
350
|
+
[project_key, project_config, mapping]
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Find the root message for a conversation thread.
|
|
354
|
+
# Walks back through message_reference chain to find the original message.
|
|
355
|
+
# Returns { id: root_message_id, content: root_message_text, author: username }
|
|
356
|
+
# or { id: current_message_id, content: nil, author: nil } if already the root.
|
|
357
|
+
def find_root_message(message, channel_id, bot_token)
|
|
358
|
+
current_msg = message
|
|
359
|
+
visited = Set.new
|
|
360
|
+
max_depth = 20 # Prevent infinite loops
|
|
361
|
+
walked = false
|
|
362
|
+
|
|
363
|
+
max_depth.times do
|
|
364
|
+
msg_id = current_msg["id"]
|
|
365
|
+
return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id) # Loop detected
|
|
366
|
+
|
|
367
|
+
visited << msg_id
|
|
368
|
+
|
|
369
|
+
ref = current_msg["message_reference"]
|
|
370
|
+
break unless ref
|
|
371
|
+
|
|
372
|
+
ref_msg_id = ref["message_id"]
|
|
373
|
+
ref_channel = ref["channel_id"] || channel_id
|
|
374
|
+
break unless ref_msg_id
|
|
375
|
+
|
|
376
|
+
# Fetch the referenced message
|
|
377
|
+
referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
|
|
378
|
+
break unless referenced
|
|
379
|
+
|
|
380
|
+
current_msg = referenced
|
|
381
|
+
walked = true
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
{
|
|
385
|
+
id: current_msg["id"],
|
|
386
|
+
content: walked ? current_msg["content"]&.strip : nil,
|
|
387
|
+
author: walked ? current_msg.dig("author", "username") : nil
|
|
388
|
+
}
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Build a Discord mention roster so the agent can @mention people and other bots.
|
|
392
|
+
# Discord requires `<@USER_ID>` syntax — plain text "@Name" doesn't work.
|
|
393
|
+
# Sources:
|
|
394
|
+
# - Other agent bots: DISCORD_BOTS (populated at gateway READY)
|
|
395
|
+
# - Human users: discord.json "user_mappings" (manually maintained)
|
|
396
|
+
def discord_mention_roster
|
|
397
|
+
lines = []
|
|
398
|
+
|
|
399
|
+
# Agent bots
|
|
400
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
401
|
+
DISCORD_BOTS.each do |agent_key, info|
|
|
402
|
+
next unless info[:user_id]
|
|
403
|
+
|
|
404
|
+
display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
405
|
+
lines << " - #{display}: `<@#{info[:user_id]}>`"
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Human users from config
|
|
410
|
+
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
411
|
+
user_mappings.each do |name, discord_id|
|
|
412
|
+
lines << " - #{name}: `<@#{discord_id}>`"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
lines.join("\n")
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Handle an incoming Discord message for a specific agent bot.
|
|
419
|
+
# agent_key: the lowercase agent key (e.g. "galen")
|
|
420
|
+
# bot_token: the Discord bot token for this agent
|
|
421
|
+
# bot_user_id: the Discord user ID of this bot
|
|
422
|
+
def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
423
|
+
channel_id = message["channel_id"]
|
|
424
|
+
message_id = message["id"]
|
|
425
|
+
author = message["author"]
|
|
426
|
+
content = message["content"] || ""
|
|
427
|
+
|
|
428
|
+
is_bot = !author["bot"].nil?
|
|
429
|
+
|
|
430
|
+
# Identify if the author is a known agent bot (local or remote).
|
|
431
|
+
# Local agents are in DISCORD_BOTS; remote agents (running on other machines)
|
|
432
|
+
# are recognized via discord.json "user_mappings".
|
|
433
|
+
sender_agent_key = nil
|
|
434
|
+
if is_bot
|
|
435
|
+
sender_id = author["id"]
|
|
436
|
+
|
|
437
|
+
# Check local bots first
|
|
438
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
439
|
+
DISCORD_BOTS.each do |key, info|
|
|
440
|
+
if info[:user_id] == sender_id && key != agent_key
|
|
441
|
+
sender_agent_key = key
|
|
442
|
+
break
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Check user_mappings for remote agents
|
|
448
|
+
unless sender_agent_key
|
|
449
|
+
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
450
|
+
user_mappings.each do |name, discord_id|
|
|
451
|
+
if discord_id == sender_id
|
|
452
|
+
sender_agent_key = name.downcase
|
|
453
|
+
break
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Unknown bot or self — ignore entirely
|
|
459
|
+
unless sender_agent_key
|
|
460
|
+
LOG.info "[Discord:#{agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}, bot=#{author["bot"]}"
|
|
461
|
+
return
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
mentions = message["mentions"] || []
|
|
466
|
+
mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s }
|
|
467
|
+
|
|
468
|
+
# Discord doesn't always populate the mentions array for bot-to-bot mentions.
|
|
469
|
+
# Check the raw content for mention patterns as a fallback.
|
|
470
|
+
mentioned ||= content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
|
|
471
|
+
|
|
472
|
+
# Check for @everyone mention (DISABLED — agents need to cool it)
|
|
473
|
+
# unless mentioned
|
|
474
|
+
# mentioned = message['mention_everyone'] == true
|
|
475
|
+
# end
|
|
476
|
+
|
|
477
|
+
# Cross-agent bot mention: only proceed if this bot is explicitly @mentioned
|
|
478
|
+
# and the dispatch depth hasn't been exceeded (prevents infinite loops).
|
|
479
|
+
if sender_agent_key
|
|
480
|
+
unless mentioned
|
|
481
|
+
fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
482
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
483
|
+
# LOG.info "[Discord:#{agent_display}] Ignoring cross-agent message from #{sender_display} — not mentioned (content: #{content[0..100]})"
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Skip dispatch when the message is a Fizzy card creation/assignment
|
|
488
|
+
# announcement. The Fizzy webhook handles card assignments — dispatching
|
|
489
|
+
# here too causes the mentioned agent to respond in Discord instead of
|
|
490
|
+
# (or in addition to) the new card.
|
|
491
|
+
if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
|
|
492
|
+
sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
493
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
494
|
+
LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — Fizzy card creation/assignment (handled by webhook)"
|
|
495
|
+
return
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
depth_key = "discord-#{channel_id}"
|
|
499
|
+
unless agent_dispatch_allowed?(depth_key)
|
|
500
|
+
sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
|
|
501
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
502
|
+
LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
|
|
503
|
+
return
|
|
504
|
+
end
|
|
505
|
+
record_agent_dispatch(depth_key)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Detect if the message is a reply to one of this bot's own messages.
|
|
509
|
+
# Discord replies include a `message_reference` but don't automatically add
|
|
510
|
+
# the referenced author to the `mentions` array, so we check explicitly.
|
|
511
|
+
# We also cache the referenced message for later use as reply context.
|
|
512
|
+
is_reply_to_bot = false
|
|
513
|
+
referenced_message = nil
|
|
514
|
+
if message["message_reference"]
|
|
515
|
+
ref_msg_id = message.dig("message_reference", "message_id")
|
|
516
|
+
ref_channel = message.dig("message_reference", "channel_id") || channel_id
|
|
517
|
+
if ref_msg_id
|
|
518
|
+
referenced_message = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
|
|
519
|
+
is_reply_to_bot = !mentioned && referenced_message && referenced_message.dig("author", "id") == bot_user_id
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Detect if inside a thread (follow-up conversation) or a DM.
|
|
524
|
+
# Only call the API if the message doesn't already have an explicit @mention,
|
|
525
|
+
# to avoid unnecessary API calls on every message.
|
|
526
|
+
channel_info = nil
|
|
527
|
+
is_thread = false
|
|
528
|
+
is_dm = false
|
|
529
|
+
in_own_thread = false
|
|
530
|
+
|
|
531
|
+
if !mentioned && !is_reply_to_bot
|
|
532
|
+
channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
|
|
533
|
+
is_thread = channel_info && [11, 12].include?(channel_info["type"])
|
|
534
|
+
is_dm = channel_info && channel_info["type"] == 1
|
|
535
|
+
in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# If we'd respond only because we own the thread (not explicitly mentioned,
|
|
539
|
+
# not a reply to us), check whether the human is explicitly talking to a
|
|
540
|
+
# DIFFERENT agent. If so, stand down — they're directing the conversation
|
|
541
|
+
# elsewhere and we shouldn't butt in.
|
|
542
|
+
if in_own_thread && !mentioned && !is_reply_to_bot && !is_bot
|
|
543
|
+
other_bot_mentioned = false
|
|
544
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
545
|
+
DISCORD_BOTS.each do |key, info|
|
|
546
|
+
next if key == agent_key # skip self
|
|
547
|
+
next unless info[:user_id]
|
|
548
|
+
|
|
549
|
+
next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
|
|
550
|
+
content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
|
|
551
|
+
|
|
552
|
+
other_bot_mentioned = true
|
|
553
|
+
break
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Also check user_mappings for remote agent bots
|
|
558
|
+
unless other_bot_mentioned
|
|
559
|
+
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
560
|
+
user_mappings.each_value do |discord_id|
|
|
561
|
+
next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } ||
|
|
562
|
+
content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/)
|
|
563
|
+
|
|
564
|
+
other_bot_mentioned = true
|
|
565
|
+
break
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
if other_bot_mentioned
|
|
570
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
571
|
+
LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
|
|
572
|
+
return
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# In DMs, threads the bot created, and replies to the bot's own messages,
|
|
577
|
+
# respond without requiring an explicit @mention.
|
|
578
|
+
# In guild channels, require an explicit @mention.
|
|
579
|
+
return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
|
|
580
|
+
|
|
581
|
+
# Human message resets the cross-agent dispatch depth for this channel/thread
|
|
582
|
+
record_human_comment("discord-#{channel_id}") unless is_bot
|
|
583
|
+
|
|
584
|
+
clean_content = content.gsub(/<@!?#{bot_user_id}>/, "").strip
|
|
585
|
+
|
|
586
|
+
# Handle attachments (images, gifs, etc.)
|
|
587
|
+
attachments = message["attachments"] || []
|
|
588
|
+
attachment_paths = []
|
|
589
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
590
|
+
attachments.each do |att|
|
|
591
|
+
url = att["url"]
|
|
592
|
+
filename = att["filename"]
|
|
593
|
+
content_type = att["content_type"] || ""
|
|
594
|
+
|
|
595
|
+
# Only process image attachments
|
|
596
|
+
next unless content_type.start_with?("image/")
|
|
597
|
+
|
|
598
|
+
# Download to temp directory
|
|
599
|
+
temp_dir = File.join(ZILLACORE_DIR, "tmp", "discord", "attachments")
|
|
600
|
+
FileUtils.mkdir_p(temp_dir)
|
|
601
|
+
temp_path = File.join(temp_dir, "#{message_id}-#{filename}")
|
|
602
|
+
|
|
603
|
+
begin
|
|
604
|
+
uri = URI(url)
|
|
605
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
606
|
+
http.use_ssl = true
|
|
607
|
+
response = http.get(uri.path + (uri.query ? "?#{uri.query}" : ""))
|
|
608
|
+
|
|
609
|
+
if response.code.to_i == 200
|
|
610
|
+
File.binwrite(temp_path, response.body)
|
|
611
|
+
attachment_paths << temp_path
|
|
612
|
+
LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})"
|
|
613
|
+
else
|
|
614
|
+
LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}"
|
|
615
|
+
end
|
|
616
|
+
rescue StandardError => e
|
|
617
|
+
LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.message}"
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Append attachment paths to the message content so kiro-cli can process them
|
|
622
|
+
unless attachment_paths.empty?
|
|
623
|
+
clean_content += "\n\n" unless clean_content.empty?
|
|
624
|
+
clean_content += attachment_paths.join("\n")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
return if clean_content.empty? && attachment_paths.empty?
|
|
628
|
+
|
|
629
|
+
# Build reply context from the cached referenced message.
|
|
630
|
+
reply_context = ""
|
|
631
|
+
if referenced_message && referenced_message["content"]
|
|
632
|
+
ref_author = referenced_message.dig("author", "username") || "unknown"
|
|
633
|
+
ref_text = referenced_message["content"].strip
|
|
634
|
+
reply_context = "**Replying to #{ref_author}:**\n> #{ref_text}\n\n" unless ref_text.empty?
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Fetch recent channel history so the agent has conversational context.
|
|
638
|
+
# (Moved after is_thread detection below — needs thread status for limit)
|
|
639
|
+
|
|
640
|
+
discord_user = author["username"]
|
|
641
|
+
discord_user_id = author["id"]
|
|
642
|
+
|
|
643
|
+
# The agent name comes directly from the bot identity — no detection needed
|
|
644
|
+
agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
645
|
+
|
|
646
|
+
# Fetch channel_info if we haven't already (mentioned path skipped it)
|
|
647
|
+
unless channel_info
|
|
648
|
+
channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
|
|
649
|
+
is_thread = channel_info && [11, 12].include?(channel_info["type"])
|
|
650
|
+
is_dm = channel_info && channel_info["type"] == 1
|
|
651
|
+
end
|
|
652
|
+
parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
|
|
653
|
+
|
|
654
|
+
# Fetch recent channel history — threads get a larger window since they're bounded conversations.
|
|
655
|
+
history_limit = is_thread ? 25 : 10
|
|
656
|
+
channel_history = fetch_discord_channel_history(channel_id, message_id, token: bot_token, limit: history_limit)
|
|
657
|
+
|
|
658
|
+
LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{if is_dm
|
|
659
|
+
"DM"
|
|
660
|
+
else
|
|
661
|
+
is_thread ? "thread" : "channel"
|
|
662
|
+
end} #{channel_id}: #{clean_content[0..100]}"
|
|
663
|
+
|
|
664
|
+
reload_projects!
|
|
665
|
+
reload_agent_registry!
|
|
666
|
+
reload_discord_config!
|
|
667
|
+
|
|
668
|
+
# Authorization
|
|
669
|
+
authorized_users = DISCORD_CONFIG["authorized_user_ids"] || []
|
|
670
|
+
|
|
671
|
+
# Support both role_mappings (hash) and authorized_role_ids (array or hash)
|
|
672
|
+
# If authorized_role_ids is a hash, treat it like role_mappings
|
|
673
|
+
authorized_roles = if DISCORD_CONFIG["role_mappings"]
|
|
674
|
+
DISCORD_CONFIG["role_mappings"].values
|
|
675
|
+
elsif DISCORD_CONFIG["authorized_role_ids"].is_a?(Hash)
|
|
676
|
+
DISCORD_CONFIG["authorized_role_ids"].values
|
|
677
|
+
else
|
|
678
|
+
DISCORD_CONFIG["authorized_role_ids"] || []
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Ensure all role IDs are strings (Discord API returns strings)
|
|
682
|
+
authorized_roles = authorized_roles.map(&:to_s)
|
|
683
|
+
|
|
684
|
+
unless authorized_users.empty? && authorized_roles.empty?
|
|
685
|
+
user_authorized = authorized_users.include?(discord_user_id)
|
|
686
|
+
|
|
687
|
+
# Fetch member roles — message.member is not always populated, so we need to
|
|
688
|
+
# fetch guild member info separately if we have a guild_id
|
|
689
|
+
member_roles = message.dig("member", "roles") || []
|
|
690
|
+
|
|
691
|
+
# If member roles aren't in the message and we have a guild_id, fetch them
|
|
692
|
+
if member_roles.empty? && message["guild_id"]
|
|
693
|
+
guild_member = fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
|
|
694
|
+
member_roles = guild_member["roles"] || [] if guild_member
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
role_authorized = member_roles.intersect?(authorized_roles)
|
|
698
|
+
|
|
699
|
+
unless user_authorized || role_authorized
|
|
700
|
+
LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
|
|
701
|
+
add_discord_reaction(channel_id, message_id, "🚫", token: bot_token)
|
|
702
|
+
return
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Inline tags: [project:my-project] and [model] anywhere in the message.
|
|
707
|
+
# Both are parsed for routing/config and stripped from the prompt content.
|
|
708
|
+
inline_project_key = nil
|
|
709
|
+
if (proj_match = clean_content.match(/\[project:(\S+)\]/i))
|
|
710
|
+
inline_project_key = proj_match[1]
|
|
711
|
+
clean_content = clean_content.sub(proj_match[0], "").strip
|
|
712
|
+
LOG.info "[Discord:#{agent_name}] Detected inline project tag: #{inline_project_key}"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Strip model tag (e.g. [opus], [sonnet]) from prompt content — detect_model
|
|
716
|
+
# reads the original clean_content later, but we save the tag-free version
|
|
717
|
+
# for the actual prompt so the bracket noise doesn't leak through.
|
|
718
|
+
inline_model_tag = clean_content.match(/\[\w+\]/)
|
|
719
|
+
clean_content_for_prompt = inline_model_tag ? clean_content.sub(inline_model_tag[0], "").strip : clean_content
|
|
720
|
+
|
|
721
|
+
# Strip effort tag (e.g. [effort:high]) from prompt content
|
|
722
|
+
clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
|
|
723
|
+
|
|
724
|
+
# Find project: inline override > channel mapping > default_project
|
|
725
|
+
if inline_project_key && PROJECTS.key?(inline_project_key)
|
|
726
|
+
project_key = inline_project_key
|
|
727
|
+
project_config = PROJECTS[inline_project_key]
|
|
728
|
+
LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})"
|
|
729
|
+
else
|
|
730
|
+
if inline_project_key && !PROJECTS.key?(inline_project_key)
|
|
731
|
+
LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}"
|
|
732
|
+
Thread.new { add_discord_reaction(channel_id, message_id, "⚠️", token: bot_token) }
|
|
733
|
+
end
|
|
734
|
+
project_key, project_config, _mapping = find_project_for_discord_channel(parent_channel_id)
|
|
735
|
+
if project_key
|
|
736
|
+
LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}"
|
|
737
|
+
else
|
|
738
|
+
LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)"
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
743
|
+
supersede_key = "discord-#{agent_key}-#{channel_id}"
|
|
744
|
+
|
|
745
|
+
if session_active?(session_key)
|
|
746
|
+
add_discord_reaction(channel_id, message_id, "⏳", token: bot_token)
|
|
747
|
+
return
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Supersede: if a human sends a follow-up within 60s, kill the previous agent run
|
|
751
|
+
if !is_bot && (prev = find_supersedable_session(supersede_key))
|
|
752
|
+
LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}"
|
|
753
|
+
kill_session(prev[:session_key])
|
|
754
|
+
# React on the OLD message to show it was cancelled
|
|
755
|
+
if prev[:message_id] && prev[:channel_id]
|
|
756
|
+
Thread.new do
|
|
757
|
+
remove_discord_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token)
|
|
758
|
+
add_discord_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token)
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
# Clean up draft files from the superseded session so the poller doesn't deliver stale responses
|
|
762
|
+
(prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# React in background — don't block the dispatch path
|
|
766
|
+
# Remove 🛑 if it exists (user may have cancelled and is now retrying via edit)
|
|
767
|
+
Thread.new do
|
|
768
|
+
remove_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
|
|
769
|
+
add_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Build project context
|
|
773
|
+
if project_config
|
|
774
|
+
repo_path = project_config["repo_path"]
|
|
775
|
+
# Fetch latest from origin so worktrees branch from up-to-date main
|
|
776
|
+
debounced_repo_fetch(repo_path)
|
|
777
|
+
default_branch = get_default_branch(repo_path)
|
|
778
|
+
lines = ["## Project Context"]
|
|
779
|
+
lines << "Project: #{project_key}"
|
|
780
|
+
lines << "Source directory: `#{repo_path}`"
|
|
781
|
+
lines << "Default branch: `#{default_branch}`"
|
|
782
|
+
lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"]
|
|
783
|
+
lines << ""
|
|
784
|
+
lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, go directly to `#{repo_path}` — do NOT search for it."
|
|
785
|
+
lines << ""
|
|
786
|
+
lines << "### All registered projects"
|
|
787
|
+
PROJECTS.each do |key, cfg|
|
|
788
|
+
lines << "- **#{key}**: `#{cfg["repo_path"]}`"
|
|
789
|
+
end
|
|
790
|
+
context = lines.join("\n")
|
|
791
|
+
LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})"
|
|
792
|
+
else
|
|
793
|
+
lines = ["## Project Context"]
|
|
794
|
+
lines << "No specific project mapped to this channel."
|
|
795
|
+
lines << ""
|
|
796
|
+
lines << "### Registered projects (use `[project:name]` to target one)"
|
|
797
|
+
PROJECTS.each do |key, cfg|
|
|
798
|
+
lines << "- **#{key}**: `#{cfg["repo_path"]}`"
|
|
799
|
+
end
|
|
800
|
+
context = lines.join("\n")
|
|
801
|
+
LOG.info "[Discord:#{agent_name}] No project context - showing available projects"
|
|
802
|
+
end
|
|
803
|
+
project_context = context
|
|
804
|
+
|
|
805
|
+
# Prepare files — response goes to draft/ so the poller can recover it after restarts
|
|
806
|
+
response_dir = File.join(ZILLACORE_DIR, "tmp")
|
|
807
|
+
FileUtils.mkdir_p(response_dir)
|
|
808
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
809
|
+
response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
|
|
810
|
+
response_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.md")
|
|
811
|
+
|
|
812
|
+
channel_name = channel_info&.dig("name") || channel_id
|
|
813
|
+
|
|
814
|
+
# Find the root message for this conversation thread.
|
|
815
|
+
# All messages in a thread should share the same memory file.
|
|
816
|
+
# Also captures root message content so the agent always has the original context.
|
|
817
|
+
root_message = find_root_message(message, channel_id, bot_token)
|
|
818
|
+
root_message_id = root_message[:id]
|
|
819
|
+
card_id = "discord-#{channel_id}-#{root_message_id}"
|
|
820
|
+
|
|
821
|
+
# Build thread root context — inject the original question/message that started
|
|
822
|
+
# this thread so the agent never loses sight of it, even in long conversations.
|
|
823
|
+
thread_root_context = ""
|
|
824
|
+
if is_thread && root_message[:content] && !root_message[:content].empty?
|
|
825
|
+
root_author = root_message[:author] || "unknown"
|
|
826
|
+
thread_root_context = "### Original Message (thread starter)\n#{root_author}: #{root_message[:content]}\n\n"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Detect planning mode
|
|
830
|
+
planning_info = detect_planning_mode(
|
|
831
|
+
text: clean_content,
|
|
832
|
+
tags: [],
|
|
833
|
+
card_internal_id: card_id,
|
|
834
|
+
card_number: nil
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
|
|
838
|
+
|
|
839
|
+
if planning_info
|
|
840
|
+
# Planning mode — use planning prompt
|
|
841
|
+
planning_card_id = planning_info[:card_id]
|
|
842
|
+
LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
|
|
843
|
+
|
|
844
|
+
prompt = render_planning_prompt(PROMPT_DISCORD,
|
|
845
|
+
{ "DISCORD_USER" => discord_user,
|
|
846
|
+
"CHANNEL_NAME" => channel_name,
|
|
847
|
+
"MESSAGE_BODY" => clean_content_for_prompt.sub(/\[plan\]/i, "").strip,
|
|
848
|
+
"REPLY_CONTEXT" => reply_context,
|
|
849
|
+
"CHANNEL_HISTORY" => channel_history,
|
|
850
|
+
"THREAD_ROOT_CONTEXT" => thread_root_context,
|
|
851
|
+
"PROJECT_CONTEXT" => project_context,
|
|
852
|
+
"RESPONSE_FILE" => response_file,
|
|
853
|
+
"CARD_ID" => planning_card_id,
|
|
854
|
+
"COMMENT_CREATOR" => discord_user,
|
|
855
|
+
"DISCORD_MENTION_ROSTER" => discord_mention_roster },
|
|
856
|
+
brain_context: brain_context,
|
|
857
|
+
agent_name: agent_name,
|
|
858
|
+
channel: :discord)
|
|
859
|
+
else
|
|
860
|
+
# Normal mode
|
|
861
|
+
prompt = render_prompt(PROMPT_DISCORD,
|
|
862
|
+
{ "DISCORD_USER" => discord_user,
|
|
863
|
+
"CHANNEL_NAME" => channel_name,
|
|
864
|
+
"MESSAGE_BODY" => clean_content_for_prompt,
|
|
865
|
+
"REPLY_CONTEXT" => reply_context,
|
|
866
|
+
"CHANNEL_HISTORY" => channel_history,
|
|
867
|
+
"THREAD_ROOT_CONTEXT" => thread_root_context,
|
|
868
|
+
"PROJECT_CONTEXT" => project_context,
|
|
869
|
+
"RESPONSE_FILE" => response_file,
|
|
870
|
+
"CARD_ID" => card_id,
|
|
871
|
+
"COMMENT_CREATOR" => discord_user,
|
|
872
|
+
"DISCORD_MENTION_ROSTER" => discord_mention_roster },
|
|
873
|
+
brain_context: brain_context,
|
|
874
|
+
agent_name: agent_name,
|
|
875
|
+
channel: :discord)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
work_dir = project_config ? project_config["repo_path"] : Dir.pwd
|
|
879
|
+
|
|
880
|
+
prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
|
|
881
|
+
File.write(prompt_file, prompt)
|
|
882
|
+
|
|
883
|
+
# Write delivery metadata sidecar so the poller can post this response
|
|
884
|
+
# even if the monitoring thread dies (e.g. server restart).
|
|
885
|
+
meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
|
|
886
|
+
File.write(meta_file, JSON.pretty_generate({
|
|
887
|
+
channel_id: channel_id,
|
|
888
|
+
message_id: message_id,
|
|
889
|
+
agent_key: agent_key,
|
|
890
|
+
agent_name: agent_name,
|
|
891
|
+
is_dm: is_dm,
|
|
892
|
+
is_thread: is_thread,
|
|
893
|
+
clean_content: clean_content[0..80],
|
|
894
|
+
created_at: Time.now.iso8601
|
|
895
|
+
}))
|
|
896
|
+
|
|
897
|
+
# Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments
|
|
898
|
+
model = project_config ? detect_model(project_config, text: clean_content) : nil
|
|
899
|
+
|
|
900
|
+
# Detect effort override — [effort:high] syntax
|
|
901
|
+
effort = project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
902
|
+
|
|
903
|
+
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
904
|
+
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
905
|
+
|
|
906
|
+
resolved = project_config ? resolve_project_cli_config(project_config) : {}
|
|
907
|
+
agent_cli = resolved["agent_cli"] || "kiro-cli"
|
|
908
|
+
agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
|
|
909
|
+
agent_model_flag = resolved["agent_model_flag"] || "--model"
|
|
910
|
+
agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
|
|
911
|
+
|
|
912
|
+
cmd = [agent_cli]
|
|
913
|
+
cmd.push("--agent", agent_config_name)
|
|
914
|
+
cmd.concat(agent_cli_args.split)
|
|
915
|
+
add_trust_tools!(cmd, agent_cli_args)
|
|
916
|
+
cmd.push(agent_model_flag, model) if agent_model_flag && !agent_model_flag.empty? && model
|
|
917
|
+
cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
|
|
918
|
+
|
|
919
|
+
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
|
|
920
|
+
LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
|
|
921
|
+
|
|
922
|
+
spawn_env = {}
|
|
923
|
+
agent_env = agent_env_for(agent_name)
|
|
924
|
+
unless agent_env.empty?
|
|
925
|
+
spawn_env.merge!(agent_env)
|
|
926
|
+
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Capture HEAD before spawning so we can detect if THIS session made commits
|
|
930
|
+
head_before = nil
|
|
931
|
+
if project_config
|
|
932
|
+
pk = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
933
|
+
if pk == "zillacore"
|
|
934
|
+
head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir)
|
|
935
|
+
head_before = head_before.strip
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
pid = spawn(spawn_env, *cmd,
|
|
940
|
+
chdir: work_dir,
|
|
941
|
+
in: prompt_file,
|
|
942
|
+
out: [log_file, "w"],
|
|
943
|
+
err: %i[child out])
|
|
944
|
+
|
|
945
|
+
register_session(session_key, pid, log_file: log_file,
|
|
946
|
+
message_id: message_id, channel_id: channel_id,
|
|
947
|
+
supersede_key: supersede_key,
|
|
948
|
+
draft_files: [response_file, meta_file],
|
|
949
|
+
agent_name: agent_name)
|
|
950
|
+
|
|
951
|
+
Thread.new do
|
|
952
|
+
Process.wait(pid)
|
|
953
|
+
exit_status = $CHILD_STATUS
|
|
954
|
+
|
|
955
|
+
# Check if session was cancelled (removed from ACTIVE_SESSIONS by reaction handler)
|
|
956
|
+
session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
|
|
957
|
+
|
|
958
|
+
# If the process was killed by a signal (superseded or cancelled), skip response delivery
|
|
959
|
+
if exit_status.signaled? || session_cancelled
|
|
960
|
+
reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})"
|
|
961
|
+
LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{message_id}"
|
|
962
|
+
# Clean up draft/meta files so the poller doesn't deliver a stale response
|
|
963
|
+
[response_file, meta_file].each { |f| FileUtils.rm_f(f) }
|
|
964
|
+
Thread.new do
|
|
965
|
+
sleep 300
|
|
966
|
+
[prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
|
|
967
|
+
end
|
|
968
|
+
next
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (exit: #{exit_status.exitstatus})"
|
|
972
|
+
|
|
973
|
+
# Notify if the agent crashed (non-zero exit)
|
|
974
|
+
if exit_status.exitstatus && exit_status.exitstatus != 0
|
|
975
|
+
notify_agent_crash(
|
|
976
|
+
exit_status: exit_status.exitstatus, log_file: log_file,
|
|
977
|
+
agent_name: agent_name, source: :discord,
|
|
978
|
+
source_context: { channel_id: channel_id, message_id: message_id, bot_token: bot_token },
|
|
979
|
+
project_config: project_config
|
|
980
|
+
)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# If the agent didn't write to the response file, extract it from the log.
|
|
984
|
+
# Agents should write to the file directly, but this is a fallback for when
|
|
985
|
+
# they respond via stdout instead.
|
|
986
|
+
if !File.exist?(response_file) && File.exist?(log_file)
|
|
987
|
+
log_content = File.read(log_file)
|
|
988
|
+
|
|
989
|
+
# Detect known fatal error patterns from kiro-cli and write a clean
|
|
990
|
+
# user-facing message instead of leaking raw internal errors to Discord.
|
|
991
|
+
if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i)
|
|
992
|
+
LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{message_id}"
|
|
993
|
+
File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._")
|
|
994
|
+
elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
|
|
995
|
+
LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
|
|
996
|
+
FileUtils.rm_f(meta_file)
|
|
997
|
+
else
|
|
998
|
+
# Strip ANSI codes and kiro-cli UI noise
|
|
999
|
+
clean_output = log_content
|
|
1000
|
+
.gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") # ANSI escape codes (including cursor visibility)
|
|
1001
|
+
.gsub(/\e\][^\a]*\a/, "") # OSC sequences
|
|
1002
|
+
.delete("\r") # Carriage returns
|
|
1003
|
+
.gsub(/^.*?(using tool:.*?)$/m, "") # Tool usage lines
|
|
1004
|
+
.gsub(/^.*?✓.*?$/m, "") # Success checkmarks
|
|
1005
|
+
.gsub(/^.*?▸.*?$/m, "") # Timing lines
|
|
1006
|
+
.gsub(/^.*?Loading\.\.\..*?$/m, "") # Loading indicators
|
|
1007
|
+
.gsub(/^.*?Completed in.*?$/m, "") # Completion messages
|
|
1008
|
+
.strip
|
|
1009
|
+
|
|
1010
|
+
# Only write if there's actual content
|
|
1011
|
+
if !clean_output.empty? && clean_output.length > 20
|
|
1012
|
+
File.write(response_file, clean_output)
|
|
1013
|
+
LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)"
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# Deliver Discord response FIRST for faster human feedback
|
|
1019
|
+
remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
1020
|
+
sleep 0.5 # Breathing room to avoid Discord rate limits
|
|
1021
|
+
|
|
1022
|
+
delivered = deliver_discord_draft(response_file, meta_file)
|
|
1023
|
+
|
|
1024
|
+
# If deliver returned false, check whether the poller already handled it
|
|
1025
|
+
# (files moved to posted/) or the response genuinely doesn't exist.
|
|
1026
|
+
unless delivered
|
|
1027
|
+
response_basename = File.basename(response_file)
|
|
1028
|
+
already_posted = File.exist?(File.join(DISCORD_POSTED_DIR, response_basename))
|
|
1029
|
+
unless already_posted
|
|
1030
|
+
LOG.warn "[Discord:#{agent_name}] No response produced for message #{message_id}"
|
|
1031
|
+
add_discord_reaction(channel_id, message_id, "😶", token: bot_token)
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Re-index brain AFTER response delivery (Discord bypasses run_agent, so we handle it here)
|
|
1036
|
+
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
1037
|
+
if qmd_status.success?
|
|
1038
|
+
LOG.info "[Brain] qmd update completed after #{agent_name} Discord session"
|
|
1039
|
+
else
|
|
1040
|
+
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
brain_push(message: "#{agent_name}: discord-#{message_id}")
|
|
1044
|
+
|
|
1045
|
+
# Restart zillacore if THIS session actually changed code
|
|
1046
|
+
# Compare HEAD now vs before the agent ran — only restart if commits were made or files are dirty
|
|
1047
|
+
if project_config && head_before
|
|
1048
|
+
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
1049
|
+
if project_key == "zillacore"
|
|
1050
|
+
chdir = project_config["repo_path"]
|
|
1051
|
+
head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
|
|
1052
|
+
git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
1053
|
+
if head_after.strip != head_before || !git_status.strip.empty?
|
|
1054
|
+
queue_zillacore_restart(agent_name)
|
|
1055
|
+
else
|
|
1056
|
+
LOG.info "[ZillaCore] #{agent_name} Discord session on zillacore had no changes — skipping restart"
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
Thread.new do
|
|
1062
|
+
sleep 300
|
|
1063
|
+
[prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
|
|
1064
|
+
end
|
|
1065
|
+
rescue StandardError => e
|
|
1066
|
+
LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}"
|
|
1067
|
+
add_discord_reaction(channel_id, message_id, "❌", token: bot_token)
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# --- Discord Reaction Handler ---
|
|
1072
|
+
# Handles MESSAGE_REACTION_ADD events. Currently supports:
|
|
1073
|
+
# - ❌ reaction to cancel an active agent session
|
|
1074
|
+
def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
|
|
1075
|
+
channel_id = reaction_data["channel_id"]
|
|
1076
|
+
message_id = reaction_data["message_id"]
|
|
1077
|
+
user_id = reaction_data["user_id"]
|
|
1078
|
+
emoji = reaction_data["emoji"]
|
|
1079
|
+
emoji_name = emoji["name"]
|
|
1080
|
+
|
|
1081
|
+
agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
1082
|
+
|
|
1083
|
+
# Ignore reactions from bots (including self)
|
|
1084
|
+
return if user_id == bot_user_id
|
|
1085
|
+
|
|
1086
|
+
# Handle ❔ or ❓ reactions (thinking file inspection)
|
|
1087
|
+
if ["❔", "❓"].include?(emoji_name)
|
|
1088
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
1089
|
+
line_count = emoji_name == "❔" ? 10 : 20
|
|
1090
|
+
|
|
1091
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
1092
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
1093
|
+
|
|
1094
|
+
unless session_info
|
|
1095
|
+
LOG.info "[Discord:#{agent_name}] #{emoji_name} reaction on #{message_id} but no active session found"
|
|
1096
|
+
return
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
log_file = session_info[:log_file]
|
|
1100
|
+
unless log_file && File.exist?(log_file)
|
|
1101
|
+
LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
|
|
1102
|
+
send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
|
|
1103
|
+
return
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
LOG.info "[Discord:#{agent_name}] Reading last #{line_count} lines from #{log_file}"
|
|
1107
|
+
|
|
1108
|
+
# Read last N lines from the log file
|
|
1109
|
+
lines = File.readlines(log_file).last(line_count)
|
|
1110
|
+
thinking_output = lines.join
|
|
1111
|
+
|
|
1112
|
+
# Strip all ANSI escape codes and non-ASCII characters
|
|
1113
|
+
thinking_output = thinking_output.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") # All CSI sequences
|
|
1114
|
+
.gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "") # Alternative CSI notation
|
|
1115
|
+
.gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "") # OSC sequences
|
|
1116
|
+
.gsub(/\e[=>]/, "") # Other escape sequences
|
|
1117
|
+
.gsub(/\[\?[0-9]+[lh]/, "") # Cursor visibility
|
|
1118
|
+
.gsub("[K", "") # Clear line
|
|
1119
|
+
.encode("ASCII", invalid: :replace, undef: :replace, replace: "") # Strip non-ASCII
|
|
1120
|
+
.strip
|
|
1121
|
+
|
|
1122
|
+
# Post to Discord as a code block
|
|
1123
|
+
response = "**Last #{line_count} lines:**\n```\n#{thinking_output}\n```"
|
|
1124
|
+
send_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
1125
|
+
end
|
|
1126
|
+
return
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# Handle 🧠 reaction (stream full thinking to thread)
|
|
1130
|
+
if emoji_name == "🧠"
|
|
1131
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
1132
|
+
|
|
1133
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
1134
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
1135
|
+
|
|
1136
|
+
unless session_info
|
|
1137
|
+
LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{message_id} but no active session found"
|
|
1138
|
+
return
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
log_file = session_info[:log_file]
|
|
1142
|
+
unless log_file && File.exist?(log_file)
|
|
1143
|
+
LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
|
|
1144
|
+
send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
|
|
1145
|
+
return
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}"
|
|
1149
|
+
|
|
1150
|
+
# Create thread
|
|
1151
|
+
thread_response = create_discord_thread(channel_id, message_id, name: "🧠 Thinking Stream", token: bot_token)
|
|
1152
|
+
unless thread_response && thread_response["id"]
|
|
1153
|
+
LOG.error "[Discord:#{agent_name}] Failed to create thread, response: #{thread_response.inspect}"
|
|
1154
|
+
return
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
thread_id = thread_response["id"]
|
|
1158
|
+
LOG.info "[Discord:#{agent_name}] Thread created: #{thread_id}"
|
|
1159
|
+
|
|
1160
|
+
# Read and clean full thinking file
|
|
1161
|
+
thinking_content = File.read(log_file)
|
|
1162
|
+
thinking_content = thinking_content.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
1163
|
+
.gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "")
|
|
1164
|
+
.gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "")
|
|
1165
|
+
.gsub(/\e[=>]/, "")
|
|
1166
|
+
.gsub(/\[\?[0-9]+[lh]/, "")
|
|
1167
|
+
.gsub("[K", "")
|
|
1168
|
+
.encode("ASCII", invalid: :replace, undef: :replace, replace: "")
|
|
1169
|
+
.strip
|
|
1170
|
+
|
|
1171
|
+
# Split into 1900-char chunks (leave room for code blocks)
|
|
1172
|
+
chunks = []
|
|
1173
|
+
current_chunk = ""
|
|
1174
|
+
thinking_content.lines.each do |line|
|
|
1175
|
+
if current_chunk.length + line.length > 1900
|
|
1176
|
+
chunks << current_chunk
|
|
1177
|
+
current_chunk = line
|
|
1178
|
+
else
|
|
1179
|
+
current_chunk += line
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
chunks << current_chunk unless current_chunk.empty?
|
|
1183
|
+
|
|
1184
|
+
# Post chunks to thread
|
|
1185
|
+
chunks.each do |chunk|
|
|
1186
|
+
send_discord_message(thread_id, "```\n#{chunk}\n```", token: bot_token)
|
|
1187
|
+
sleep 0.5 # Rate limit protection
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
return
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# --- Feedback logging for non-reserved emojis ---
|
|
1194
|
+
unless RESERVED_EMOJIS.include?(emoji_name)
|
|
1195
|
+
Thread.new do
|
|
1196
|
+
log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
|
|
1197
|
+
rescue StandardError => e
|
|
1198
|
+
LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.message}"
|
|
1199
|
+
end
|
|
1200
|
+
return
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# Only handle ❌ reactions beyond this point
|
|
1204
|
+
return unless emoji_name == "❌"
|
|
1205
|
+
|
|
1206
|
+
# Check if there's an active session for this message
|
|
1207
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
1208
|
+
|
|
1209
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
1210
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
1211
|
+
|
|
1212
|
+
unless session_info
|
|
1213
|
+
LOG.info "[Discord:#{agent_name}] ❌ reaction on #{message_id} but no active session found"
|
|
1214
|
+
return
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
LOG.info "[Discord:#{agent_name}] Cancelling session for message #{message_id} (PID: #{session_info[:pid]})"
|
|
1218
|
+
|
|
1219
|
+
# Kill the agent process
|
|
1220
|
+
begin
|
|
1221
|
+
Process.kill("KILL", session_info[:pid])
|
|
1222
|
+
LOG.info "[Discord:#{agent_name}] Killed agent process #{session_info[:pid]}"
|
|
1223
|
+
rescue Errno::ESRCH
|
|
1224
|
+
LOG.warn "[Discord:#{agent_name}] Process #{session_info[:pid]} already exited"
|
|
1225
|
+
rescue Errno::EPERM
|
|
1226
|
+
LOG.error "[Discord:#{agent_name}] Permission denied killing process #{session_info[:pid]}"
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
# Remove from active sessions
|
|
1230
|
+
ACTIVE_SESSIONS.delete(session_key)
|
|
1231
|
+
|
|
1232
|
+
# Update reactions: remove 👀, add 🛑
|
|
1233
|
+
begin
|
|
1234
|
+
remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
1235
|
+
add_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
|
|
1236
|
+
rescue StandardError => e
|
|
1237
|
+
LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.message}"
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
# Clean up draft files if they exist
|
|
1241
|
+
session_info[:draft_files]&.each do |file|
|
|
1242
|
+
FileUtils.rm_f(file)
|
|
1243
|
+
end
|
|
1244
|
+
end
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
# --- Emoji Feedback Logging ---
|
|
1248
|
+
# Logs non-reserved emoji reactions on bot messages to the agent's persona feedback file.
|
|
1249
|
+
# No LLM call, no dispatch — just a file append.
|
|
1250
|
+
|
|
1251
|
+
def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
|
|
1252
|
+
# Verify the message was posted by this bot (quiet — bots get reactions from channels they can't access)
|
|
1253
|
+
msg = fetch_discord_message(channel_id, message_id, token: bot_token, log_errors: false)
|
|
1254
|
+
return unless msg&.dig("author", "bot")
|
|
1255
|
+
|
|
1256
|
+
bot_user_id = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :user_id) }
|
|
1257
|
+
return unless bot_user_id && msg.dig("author", "id") == bot_user_id
|
|
1258
|
+
|
|
1259
|
+
# Resolve reactor to canonical name
|
|
1260
|
+
reactor = find_user_by_discord_id(user_id)
|
|
1261
|
+
reactor_name = reactor ? reactor["canonical_name"] : user_id
|
|
1262
|
+
|
|
1263
|
+
# Build a brief context snippet from the message
|
|
1264
|
+
snippet = (msg["content"] || "")[0, 80].tr("\n", " ").strip
|
|
1265
|
+
snippet = "#{snippet}..." if (msg["content"] || "").length > 80
|
|
1266
|
+
|
|
1267
|
+
# Append to persona feedback file
|
|
1268
|
+
feedback_dir = File.join(persona_dir_for(agent_name), "people")
|
|
1269
|
+
FileUtils.mkdir_p(feedback_dir)
|
|
1270
|
+
feedback_file = File.join(feedback_dir, "#{reactor_name.downcase.gsub(/[^a-z0-9]/, "-")}-feedback.md")
|
|
1271
|
+
|
|
1272
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
|
|
1273
|
+
entry = "- #{timestamp} #{emoji_name} on: \"#{snippet}\" (channel: #{channel_id})\n"
|
|
1274
|
+
|
|
1275
|
+
# Create file with header if new
|
|
1276
|
+
if File.exist?(feedback_file)
|
|
1277
|
+
File.open(feedback_file, "a") { |f| f.write(entry) }
|
|
1278
|
+
else
|
|
1279
|
+
File.write(feedback_file, "# Feedback from #{reactor_name}\n\n## Reaction Log\n#{entry}")
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
LOG.info "[Discord:#{agent_name}] Logged #{emoji_name} feedback from #{reactor_name} on message #{message_id}"
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
# --- Discord Draft Delivery ---
|
|
1286
|
+
# Shared logic for posting a draft response file to Discord and moving it to posted/.
|
|
1287
|
+
# Used by both the monitoring thread (happy path) and the poller (recovery path).
|
|
1288
|
+
|
|
1289
|
+
def deliver_discord_draft(response_file, meta_file)
|
|
1290
|
+
return false unless File.exist?(meta_file)
|
|
1291
|
+
|
|
1292
|
+
# Simple file-based lock to prevent the monitoring thread and poller
|
|
1293
|
+
# from delivering the same draft simultaneously.
|
|
1294
|
+
lock_file = "#{meta_file}.lock"
|
|
1295
|
+
begin
|
|
1296
|
+
File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # atomic create-or-fail
|
|
1297
|
+
rescue Errno::EEXIST
|
|
1298
|
+
return false # Another thread is already delivering this draft
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
meta = JSON.parse(File.read(meta_file))
|
|
1302
|
+
channel_id = meta["channel_id"]
|
|
1303
|
+
message_id = meta["message_id"]
|
|
1304
|
+
agent_key = meta["agent_key"]
|
|
1305
|
+
agent_name = meta["agent_name"]
|
|
1306
|
+
is_dm = meta["is_dm"]
|
|
1307
|
+
is_thread = meta["is_thread"]
|
|
1308
|
+
clean_content = meta["clean_content"] || ""
|
|
1309
|
+
|
|
1310
|
+
# Look up the bot token from the current registry
|
|
1311
|
+
bot_token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
|
|
1312
|
+
bot_token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
|
|
1313
|
+
|
|
1314
|
+
unless bot_token
|
|
1315
|
+
LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft"
|
|
1316
|
+
FileUtils.rm_f(lock_file)
|
|
1317
|
+
return false
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
if File.exist?(response_file)
|
|
1321
|
+
response = File.read(response_file).strip
|
|
1322
|
+
if response.empty?
|
|
1323
|
+
add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
|
|
1324
|
+
send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
|
|
1325
|
+
elsif is_dm || is_thread || message_id.nil?
|
|
1326
|
+
# DMs, threads, and cron jobs (no message_id) need special handling
|
|
1327
|
+
# Check if this is a forum channel
|
|
1328
|
+
if message_id.nil? && forum_channel?(channel_id, token: bot_token)
|
|
1329
|
+
title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
|
|
1330
|
+
if meta["forum_reply_to_latest"]
|
|
1331
|
+
latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
|
|
1332
|
+
if latest_thread
|
|
1333
|
+
send_long_discord_message(latest_thread["id"], response, token: bot_token)
|
|
1334
|
+
else
|
|
1335
|
+
LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
|
|
1336
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
1337
|
+
end
|
|
1338
|
+
else
|
|
1339
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
1340
|
+
end
|
|
1341
|
+
else
|
|
1342
|
+
# Regular DM, thread, or text channel
|
|
1343
|
+
send_long_discord_message(channel_id, response, token: bot_token)
|
|
1344
|
+
end
|
|
1345
|
+
else
|
|
1346
|
+
# Check if another agent (local OR remote) already created a thread
|
|
1347
|
+
# for this message. Three-tier lookup:
|
|
1348
|
+
# 1. Local in-memory cache (DISCORD_SHARED_THREADS) — fast, same-machine
|
|
1349
|
+
# 2. Discord API — fetch the original message and check its `thread` field
|
|
1350
|
+
# This is the cross-machine fix: if machine B's agent finishes after
|
|
1351
|
+
# machine A already created a thread, the API will reveal it.
|
|
1352
|
+
# 3. Create a new thread if neither found one.
|
|
1353
|
+
# The mutex still covers the local check + create to prevent same-machine races.
|
|
1354
|
+
thread_id = nil
|
|
1355
|
+
created_thread = false
|
|
1356
|
+
DISCORD_SHARED_THREADS_MUTEX.synchronize do
|
|
1357
|
+
thread_id = DISCORD_SHARED_THREADS[message_id]
|
|
1358
|
+
|
|
1359
|
+
# Tier 2: Ask Discord if a thread already exists on this message.
|
|
1360
|
+
# This catches threads created by agents on other machines.
|
|
1361
|
+
unless thread_id
|
|
1362
|
+
original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
|
|
1363
|
+
if original_msg&.dig("thread", "id")
|
|
1364
|
+
thread_id = original_msg["thread"]["id"]
|
|
1365
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
1366
|
+
LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
|
|
1367
|
+
end
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# Tier 3: No thread exists anywhere — create one.
|
|
1371
|
+
unless thread_id
|
|
1372
|
+
display_name = fizzy_display_name(agent_key)
|
|
1373
|
+
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
1374
|
+
if thread && thread["id"]
|
|
1375
|
+
thread_id = thread["id"]
|
|
1376
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
1377
|
+
created_thread = true
|
|
1378
|
+
LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
if thread_id
|
|
1384
|
+
LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
|
|
1385
|
+
|
|
1386
|
+
# Propagate the parent channel's dispatch depth to the thread so
|
|
1387
|
+
# cross-agent mentions inside the thread aren't blocked immediately.
|
|
1388
|
+
# The human's message was in the parent channel, but agent responses
|
|
1389
|
+
# land in this thread (different channel_id). Without this, the
|
|
1390
|
+
# thread's depth key has no entry and agent_dispatch_allowed? returns false.
|
|
1391
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
1392
|
+
thread_depth_key = "discord-#{thread_id}"
|
|
1393
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
1394
|
+
unless AGENT_DISPATCH_DEPTH[thread_depth_key]
|
|
1395
|
+
if parent_info
|
|
1396
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
1397
|
+
LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
|
|
1398
|
+
else
|
|
1399
|
+
# No parent depth entry (edge case) — initialize with current time
|
|
1400
|
+
record_human_comment(thread_depth_key)
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
send_discord_typing(thread_id, token: bot_token)
|
|
1405
|
+
send_long_discord_message(thread_id, response, token: bot_token)
|
|
1406
|
+
else
|
|
1407
|
+
LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
|
|
1408
|
+
send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
1409
|
+
end
|
|
1410
|
+
end
|
|
1411
|
+
else
|
|
1412
|
+
# Response file doesn't exist yet — agent may still be running
|
|
1413
|
+
FileUtils.rm_f(lock_file)
|
|
1414
|
+
return false
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
# Move both files to posted/
|
|
1418
|
+
basename = File.basename(response_file)
|
|
1419
|
+
meta_basename = File.basename(meta_file)
|
|
1420
|
+
FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, basename)) if File.exist?(response_file)
|
|
1421
|
+
FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, meta_basename))
|
|
1422
|
+
FileUtils.rm_f(lock_file)
|
|
1423
|
+
LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
|
|
1424
|
+
true
|
|
1425
|
+
rescue StandardError => e
|
|
1426
|
+
LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
|
|
1427
|
+
File.delete(lock_file) if lock_file && File.exist?(lock_file)
|
|
1428
|
+
false
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# Poller thread: scans draft/ for orphaned response files and delivers them.
|
|
1432
|
+
# Runs every 5 seconds. Only attempts delivery if the response file exists
|
|
1433
|
+
# (meaning the agent finished) and the meta file is at least 30 seconds old
|
|
1434
|
+
# (giving the monitoring thread a chance to handle it first).
|
|
1435
|
+
|
|
1436
|
+
DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
|
|
1437
|
+
DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
|
|
1438
|
+
|
|
1439
|
+
def start_discord_draft_poller
|
|
1440
|
+
Thread.new do
|
|
1441
|
+
LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
|
|
1442
|
+
loop do
|
|
1443
|
+
sleep DISCORD_DRAFT_POLLER_INTERVAL
|
|
1444
|
+
begin
|
|
1445
|
+
# Clean up stale lock files (older than 60s) left by crashed deliveries
|
|
1446
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
|
|
1447
|
+
File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
|
|
1451
|
+
# Don't race the monitoring thread — wait for the file to age
|
|
1452
|
+
next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
|
|
1453
|
+
|
|
1454
|
+
# Cron metas: foo.md.meta.json → foo.md
|
|
1455
|
+
# Discord metas: foo.meta.json → foo.md
|
|
1456
|
+
response_file = if meta_file.end_with?(".md.meta.json")
|
|
1457
|
+
meta_file.sub(".md.meta.json", ".md")
|
|
1458
|
+
else
|
|
1459
|
+
meta_file.sub(".meta.json", ".md")
|
|
1460
|
+
end
|
|
1461
|
+
next unless File.exist?(response_file)
|
|
1462
|
+
|
|
1463
|
+
LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
|
|
1464
|
+
deliver_discord_draft(response_file, meta_file)
|
|
1465
|
+
end
|
|
1466
|
+
rescue StandardError => e
|
|
1467
|
+
LOG.error "[Discord] Draft poller error: #{e.message}"
|
|
1468
|
+
end
|
|
1469
|
+
end
|
|
1470
|
+
end
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
# --- Discord Gateway (one per agent bot) ---
|
|
1474
|
+
|
|
1475
|
+
def start_discord_gateway_for(agent_key, bot_token)
|
|
1476
|
+
Thread.new do
|
|
1477
|
+
agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
1478
|
+
bot_user_id = nil
|
|
1479
|
+
|
|
1480
|
+
loop do
|
|
1481
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1482
|
+
DISCORD_BOTS[agent_key] ||= {}
|
|
1483
|
+
DISCORD_BOTS[agent_key][:status] = "connecting"
|
|
1484
|
+
DISCORD_BOTS[agent_key][:token] = bot_token
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..."
|
|
1488
|
+
|
|
1489
|
+
heartbeat_thread = nil
|
|
1490
|
+
last_sequence = nil
|
|
1491
|
+
|
|
1492
|
+
ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL)
|
|
1493
|
+
|
|
1494
|
+
ws.on :message do |msg|
|
|
1495
|
+
next if msg.data.nil? || msg.data.empty?
|
|
1496
|
+
|
|
1497
|
+
payload = JSON.parse(msg.data)
|
|
1498
|
+
op = payload["op"]
|
|
1499
|
+
data = payload["d"]
|
|
1500
|
+
last_sequence = payload["s"] if payload["s"]
|
|
1501
|
+
|
|
1502
|
+
case op
|
|
1503
|
+
when 10 # Hello
|
|
1504
|
+
heartbeat_interval = data["heartbeat_interval"]
|
|
1505
|
+
LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{heartbeat_interval}ms"
|
|
1506
|
+
|
|
1507
|
+
heartbeat_thread&.kill
|
|
1508
|
+
heartbeat_thread = Thread.new do
|
|
1509
|
+
loop do
|
|
1510
|
+
sleep(heartbeat_interval / 1000.0)
|
|
1511
|
+
ws.send({ op: 1, d: last_sequence }.to_json)
|
|
1512
|
+
end
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1515
|
+
ws.send({
|
|
1516
|
+
op: 2,
|
|
1517
|
+
d: {
|
|
1518
|
+
token: bot_token,
|
|
1519
|
+
intents: 46_593,
|
|
1520
|
+
properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" }
|
|
1521
|
+
}
|
|
1522
|
+
}.to_json)
|
|
1523
|
+
|
|
1524
|
+
when 0 # Dispatch
|
|
1525
|
+
case payload["t"]
|
|
1526
|
+
when "READY"
|
|
1527
|
+
bot_user_id = data.dig("user", "id")
|
|
1528
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1529
|
+
DISCORD_BOTS[agent_key][:user_id] = bot_user_id
|
|
1530
|
+
DISCORD_BOTS[agent_key][:status] = "ready"
|
|
1531
|
+
end
|
|
1532
|
+
guild_count = data["guilds"]&.size || 0
|
|
1533
|
+
LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})"
|
|
1534
|
+
LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}"
|
|
1535
|
+
|
|
1536
|
+
# Check if all bots are now ready (log once)
|
|
1537
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1538
|
+
if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" }
|
|
1539
|
+
DISCORD_ALL_READY_LOGGED[:done] = true
|
|
1540
|
+
LOG.info "[Discord] All bots connected."
|
|
1541
|
+
end
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
when "MESSAGE_CREATE"
|
|
1545
|
+
Thread.new do
|
|
1546
|
+
handle_discord_message(data, agent_key, bot_token, bot_user_id)
|
|
1547
|
+
rescue StandardError => e
|
|
1548
|
+
LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1551
|
+
when "MESSAGE_UPDATE"
|
|
1552
|
+
# Discord sends MESSAGE_UPDATE for embed/link preview resolution,
|
|
1553
|
+
# not just human edits. Only dispatch if edited_timestamp is set
|
|
1554
|
+
# (real edit) — otherwise it's just Discord enriching the message.
|
|
1555
|
+
if data["edited_timestamp"]
|
|
1556
|
+
Thread.new do
|
|
1557
|
+
handle_discord_message(data, agent_key, bot_token, bot_user_id)
|
|
1558
|
+
rescue StandardError => e
|
|
1559
|
+
LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
1560
|
+
end
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
when "MESSAGE_REACTION_ADD"
|
|
1564
|
+
Thread.new do
|
|
1565
|
+
handle_discord_reaction(data, agent_key, bot_token, bot_user_id)
|
|
1566
|
+
rescue StandardError => e
|
|
1567
|
+
LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
when 1 then ws.send({ op: 1, d: last_sequence }.to_json)
|
|
1572
|
+
when 7 then LOG.info "[Discord:#{agent_display}] Reconnect requested"
|
|
1573
|
+
ws.close
|
|
1574
|
+
when 9 then LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s"
|
|
1575
|
+
sleep 5
|
|
1576
|
+
ws.send({ op: 2, d: { token: bot_token, intents: 46_593,
|
|
1577
|
+
properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" } } }.to_json)
|
|
1578
|
+
when 11 then nil # Heartbeat ACK
|
|
1579
|
+
end
|
|
1580
|
+
rescue StandardError => e
|
|
1581
|
+
LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
ws.on :open do
|
|
1585
|
+
LOG.debug "[Discord:#{agent_display}] WebSocket connected"
|
|
1586
|
+
end
|
|
1587
|
+
|
|
1588
|
+
ws.on :close do |e|
|
|
1589
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1590
|
+
DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key]
|
|
1591
|
+
end
|
|
1592
|
+
LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}"
|
|
1593
|
+
heartbeat_thread&.kill
|
|
1594
|
+
end
|
|
1595
|
+
|
|
1596
|
+
ws.on :error do |e|
|
|
1597
|
+
LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}"
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
loop do
|
|
1601
|
+
sleep 1
|
|
1602
|
+
next if ws.open?
|
|
1603
|
+
|
|
1604
|
+
LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..."
|
|
1605
|
+
sleep 5
|
|
1606
|
+
break
|
|
1607
|
+
end
|
|
1608
|
+
rescue StandardError => e
|
|
1609
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1610
|
+
DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key]
|
|
1611
|
+
end
|
|
1612
|
+
LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..."
|
|
1613
|
+
sleep 5
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
end
|
|
1617
|
+
|
|
1618
|
+
# Start all per-agent Discord bots.
|
|
1619
|
+
def start_all_discord_gateways
|
|
1620
|
+
tokens = discord_bot_tokens
|
|
1621
|
+
if tokens.empty?
|
|
1622
|
+
LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled"
|
|
1623
|
+
return
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
|
|
1627
|
+
tokens.each do |agent_key, token|
|
|
1628
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1629
|
+
DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
1630
|
+
end
|
|
1631
|
+
start_discord_gateway_for(agent_key, token)
|
|
1632
|
+
sleep 1 # Stagger connections to avoid rate limits
|
|
1633
|
+
end
|
|
1634
|
+
end
|
|
1635
|
+
|
|
1636
|
+
# Summary of all bot statuses for the API endpoint.
|
|
1637
|
+
def discord_bots_status
|
|
1638
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1639
|
+
DISCORD_BOTS.transform_values do |info|
|
|
1640
|
+
{ status: info[:status], user_id: info[:user_id] }
|
|
1641
|
+
end
|
|
1642
|
+
end
|
|
1643
|
+
end
|