brainiac-discord 0.0.3 → 0.0.5
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/lib/brainiac/plugins/discord/cli.rb +39 -0
- data/lib/brainiac/plugins/discord/config.rb +12 -0
- data/lib/brainiac/plugins/discord/gateway.rb +44 -1
- data/lib/brainiac/plugins/discord/message.rb +115 -7
- data/lib/brainiac/plugins/discord/thread_cleanup.rb +117 -0
- data/lib/brainiac/plugins/discord/version.rb +1 -1
- data/lib/brainiac/plugins/discord.rb +28 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5edd0068ecc82d20372d269791ff8e750fcec8106f4adcc99fdef3c252e03ada
|
|
4
|
+
data.tar.gz: b30c999fc3497d7608463975a3d9f4a8c09b135e3d7bc9da1099b6a5306594c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f65f1deefed415c9b3a038162ad6c59714f2677c9ce8ec201e880ce9ae9b159571cb4910a7515268d0e09ad262e69011ea6f7b1c40471bc846ea5f84fa74d366
|
|
7
|
+
data.tar.gz: 23ba4bda9f6eb4e9797e6724ad57f12908da3176f116eacdaa11f8b5c3462647180cb8c0a1a1766c00ac12de49c2216e8fdf84da9ad719dcde0bae42307851f9
|
|
@@ -269,6 +269,45 @@ module Brainiac
|
|
|
269
269
|
def self.completions
|
|
270
270
|
%w[setup config default map owner token status]
|
|
271
271
|
end
|
|
272
|
+
|
|
273
|
+
# Called by brainiac CLI after `agent create` — prompts for Discord user ID.
|
|
274
|
+
def self.on_agent_created(agent_key, entry)
|
|
275
|
+
return unless $stdin.tty?
|
|
276
|
+
|
|
277
|
+
display_name = entry["display_name"] || agent_key.capitalize
|
|
278
|
+
puts ""
|
|
279
|
+
puts " [Discord] Configure Discord for #{display_name}?"
|
|
280
|
+
print " Discord user ID (or blank to skip): "
|
|
281
|
+
user_id = $stdin.gets&.chomp
|
|
282
|
+
return if user_id.nil? || user_id.empty?
|
|
283
|
+
|
|
284
|
+
config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "discord.json")
|
|
285
|
+
config = File.exist?(config_file) ? JSON.parse(File.read(config_file)) : {}
|
|
286
|
+
config["user_mappings"] ||= {}
|
|
287
|
+
config["user_mappings"][display_name] = user_id
|
|
288
|
+
File.write(config_file, JSON.pretty_generate(config))
|
|
289
|
+
puts " ✓ Added #{display_name} → #{user_id} to discord.json user_mappings"
|
|
290
|
+
rescue JSON::ParserError => e
|
|
291
|
+
puts " ⚠ Could not update discord.json: #{e.message}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Called by brainiac CLI after `agent remove` — removes from Discord user_mappings.
|
|
295
|
+
def self.on_agent_removed(agent_key, display_name)
|
|
296
|
+
config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "discord.json")
|
|
297
|
+
return unless File.exist?(config_file)
|
|
298
|
+
|
|
299
|
+
config = JSON.parse(File.read(config_file))
|
|
300
|
+
mappings = config["user_mappings"]
|
|
301
|
+
return unless mappings
|
|
302
|
+
|
|
303
|
+
removed = mappings.delete(display_name) || mappings.delete(agent_key)
|
|
304
|
+
return unless removed
|
|
305
|
+
|
|
306
|
+
File.write(config_file, JSON.pretty_generate(config))
|
|
307
|
+
puts " [Discord] Removed #{display_name} from discord.json user_mappings"
|
|
308
|
+
rescue JSON::ParserError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
272
311
|
end
|
|
273
312
|
end
|
|
274
313
|
end
|
|
@@ -75,6 +75,18 @@ module Brainiac
|
|
|
75
75
|
end.map(&:to_s)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Find the Discord role ID for a given agent key.
|
|
79
|
+
# Checks role_mappings and authorized_role_ids (when Hash) by agent display name.
|
|
80
|
+
# Returns the role ID string, or nil if not found.
|
|
81
|
+
def role_id_for_agent(agent_key)
|
|
82
|
+
roles = @config["role_mappings"] || (@config["authorized_role_ids"].is_a?(Hash) ? @config["authorized_role_ids"] : nil)
|
|
83
|
+
return nil unless roles
|
|
84
|
+
|
|
85
|
+
# Try capitalized key first (e.g., "galen" → "Galen"), then display name from registry
|
|
86
|
+
agent_name = agent_display_name(agent_key) || agent_key.capitalize
|
|
87
|
+
roles[agent_name]&.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
78
90
|
def authorized_user_ids
|
|
79
91
|
@config["authorized_user_ids"] || []
|
|
80
92
|
end
|
|
@@ -79,6 +79,27 @@ module Brainiac
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Start a single bot gateway connection dynamically (e.g. after agent is added).
|
|
83
|
+
# No-op if the bot is already running.
|
|
84
|
+
def start_bot!(agent_key, token)
|
|
85
|
+
@bots_mutex.synchronize do
|
|
86
|
+
return if @bots[agent_key]
|
|
87
|
+
|
|
88
|
+
@bots[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
89
|
+
end
|
|
90
|
+
start_gateway_for(agent_key, token)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stop a bot gateway connection (e.g. after agent is removed).
|
|
94
|
+
# Removes it from the bots hash so it won't be matched in detect_sender_agent.
|
|
95
|
+
def stop_bot!(agent_key)
|
|
96
|
+
info = @bots_mutex.synchronize { @bots.delete(agent_key) }
|
|
97
|
+
return unless info
|
|
98
|
+
|
|
99
|
+
info[:thread]&.kill
|
|
100
|
+
LOG.info "[Discord] Gateway stopped for #{agent_key}" if defined?(LOG)
|
|
101
|
+
end
|
|
102
|
+
|
|
82
103
|
# Summary of all bot statuses for the API endpoint.
|
|
83
104
|
def bots_status
|
|
84
105
|
@bots_mutex.synchronize do
|
|
@@ -122,7 +143,7 @@ module Brainiac
|
|
|
122
143
|
private
|
|
123
144
|
|
|
124
145
|
def start_gateway_for(agent_key, bot_token)
|
|
125
|
-
Thread.new do
|
|
146
|
+
thread = Thread.new do
|
|
126
147
|
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
127
148
|
bot_user_id = nil
|
|
128
149
|
|
|
@@ -136,6 +157,7 @@ module Brainiac
|
|
|
136
157
|
sleep 5
|
|
137
158
|
end
|
|
138
159
|
end
|
|
160
|
+
@bots_mutex.synchronize { @bots[agent_key][:thread] = thread if @bots[agent_key] }
|
|
139
161
|
end
|
|
140
162
|
|
|
141
163
|
def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
|
|
@@ -273,6 +295,26 @@ module Brainiac
|
|
|
273
295
|
rescue StandardError => e
|
|
274
296
|
LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
275
297
|
end
|
|
298
|
+
when "THREAD_UPDATE"
|
|
299
|
+
if data.dig("thread_metadata", "archived")
|
|
300
|
+
Thread.new do
|
|
301
|
+
ThreadCleanup.handle_archive(data, agent_key, agent_display)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
if defined?(LOG)
|
|
304
|
+
LOG.error "[Discord:#{agent_display}] Error handling thread archive: " \
|
|
305
|
+
"#{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
when "THREAD_DELETE"
|
|
310
|
+
Thread.new do
|
|
311
|
+
ThreadCleanup.handle_archive(data, agent_key, agent_display)
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
if defined?(LOG)
|
|
314
|
+
LOG.error "[Discord:#{agent_display}] Error handling thread delete: " \
|
|
315
|
+
"#{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
276
318
|
end
|
|
277
319
|
|
|
278
320
|
bot_user_id
|
|
@@ -281,6 +323,7 @@ module Brainiac
|
|
|
281
323
|
def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
|
|
282
324
|
@bots_mutex.synchronize do
|
|
283
325
|
@bots[agent_key][:user_id] = bot_user_id
|
|
326
|
+
@bots[agent_key][:username] = data.dig("user", "username")
|
|
284
327
|
@bots[agent_key][:status] = "ready"
|
|
285
328
|
end
|
|
286
329
|
guild_count = data["guilds"]&.size || 0
|
|
@@ -24,14 +24,22 @@ module Brainiac
|
|
|
24
24
|
|
|
25
25
|
mentions = message["mentions"] || []
|
|
26
26
|
mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s } ||
|
|
27
|
-
content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
|
|
27
|
+
content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/) ||
|
|
28
|
+
role_mentioned?(message, content, agent_key)
|
|
28
29
|
return if sender_agent_key && !validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
|
|
29
30
|
|
|
30
31
|
is_reply_to_bot, referenced_message = detect_reply_to_bot(message, channel_id, mentioned, bot_token, bot_user_id)
|
|
31
32
|
channel_info, is_thread, is_dm, in_own_thread = detect_channel_context(message, channel_id, mentioned, is_reply_to_bot, bot_token,
|
|
32
33
|
bot_user_id)
|
|
33
34
|
return if should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_key, mentions, content)
|
|
34
|
-
|
|
35
|
+
|
|
36
|
+
is_thread_participant = !mentioned && !in_own_thread && is_thread &&
|
|
37
|
+
thread_participant?(channel_id, message_id, bot_user_id, bot_token)
|
|
38
|
+
return unless mentioned || in_own_thread || is_dm || is_reply_to_bot || is_thread_participant
|
|
39
|
+
|
|
40
|
+
# When another agent is explicitly mentioned, thread participants who weren't
|
|
41
|
+
# addressed should not activate — the message was directed at someone specific.
|
|
42
|
+
return if is_thread_participant && other_agent_mentioned?(mentions, content, agent_key)
|
|
35
43
|
|
|
36
44
|
record_human_comment("discord-#{channel_id}") unless is_bot
|
|
37
45
|
|
|
@@ -59,6 +67,14 @@ module Brainiac
|
|
|
59
67
|
tags = parse_inline_tags(clean_content)
|
|
60
68
|
project_key, project_config = resolve_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
|
|
61
69
|
|
|
70
|
+
# Thread owners bypass intent checking ONLY when they're the sole agent in
|
|
71
|
+
# the thread (1-on-1 with the human). If other agents have participated,
|
|
72
|
+
# everyone goes through intent checking unless explicitly @mentioned.
|
|
73
|
+
solo_thread_owner = in_own_thread && solo_in_thread?(channel_history, agent_key)
|
|
74
|
+
if in_own_thread && defined?(LOG)
|
|
75
|
+
LOG.info "[Discord:#{agent_name}] Own thread — #{solo_thread_owner ? "solo (bypassing intent)" : "multi-agent (intent check applies)"}"
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
route_dispatch(
|
|
63
79
|
agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: is_bot,
|
|
64
80
|
channel_id: channel_id, message_id: message_id, message: message,
|
|
@@ -67,12 +83,44 @@ module Brainiac
|
|
|
67
83
|
channel_info: channel_info, parent_channel_id: parent_channel_id,
|
|
68
84
|
discord_user: discord_user, reply_context: reply_context,
|
|
69
85
|
channel_history: channel_history, project_key: project_key,
|
|
70
|
-
project_config: project_config, attachment_paths: attachment_paths
|
|
86
|
+
project_config: project_config, attachment_paths: attachment_paths,
|
|
87
|
+
directly_addressed: mentioned || is_reply_to_bot || is_dm || solo_thread_owner
|
|
71
88
|
)
|
|
72
89
|
end
|
|
73
90
|
|
|
74
91
|
private
|
|
75
92
|
|
|
93
|
+
# Check if the agent's Discord role was @mentioned in this message.
|
|
94
|
+
# Discord role mentions use <@&ROLE_ID> syntax and populate mention_roles array.
|
|
95
|
+
def role_mentioned?(message, content, agent_key)
|
|
96
|
+
role_id = Config.role_id_for_agent(agent_key)
|
|
97
|
+
return false unless role_id
|
|
98
|
+
|
|
99
|
+
mention_roles = message["mention_roles"] || []
|
|
100
|
+
mention_roles.any? { |r| r.to_s == role_id } ||
|
|
101
|
+
content.match?(/<@&#{Regexp.escape(role_id)}>/)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if another agent bot is explicitly mentioned in this message.
|
|
105
|
+
# When a user @mentions a specific agent, thread participants who weren't
|
|
106
|
+
# mentioned should stay silent — no intent check needed.
|
|
107
|
+
def other_agent_mentioned?(mentions, content, agent_key)
|
|
108
|
+
mention_roles = []
|
|
109
|
+
|
|
110
|
+
Gateway.each_bot do |key, info|
|
|
111
|
+
next if key == agent_key
|
|
112
|
+
next unless info[:user_id]
|
|
113
|
+
|
|
114
|
+
return true if mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
|
|
115
|
+
content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
|
|
116
|
+
|
|
117
|
+
role_id = Config.role_id_for_agent(key)
|
|
118
|
+
mention_roles << role_id if role_id
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
mention_roles.any? { |rid| content.match?(/<@&#{Regexp.escape(rid)}>/) }
|
|
122
|
+
end
|
|
123
|
+
|
|
76
124
|
def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
|
|
77
125
|
return false unless mentioned
|
|
78
126
|
|
|
@@ -158,6 +206,62 @@ module Brainiac
|
|
|
158
206
|
other_bot_mentioned
|
|
159
207
|
end
|
|
160
208
|
|
|
209
|
+
# Check if this bot was previously mentioned in the thread (making it a participant).
|
|
210
|
+
# Uses a small history fetch to avoid excessive API calls.
|
|
211
|
+
def thread_participant?(channel_id, before_message_id, bot_user_id, bot_token)
|
|
212
|
+
messages = Api.request(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=50", token: bot_token)
|
|
213
|
+
return false unless messages.is_a?(Array)
|
|
214
|
+
|
|
215
|
+
bot_id_str = bot_user_id.to_s
|
|
216
|
+
messages.any? do |msg|
|
|
217
|
+
mentions = msg["mentions"] || []
|
|
218
|
+
mentions.any? { |m| m["id"].to_s == bot_id_str } ||
|
|
219
|
+
(msg["content"] || "").match?(/<@!?#{Regexp.escape(bot_id_str)}>/) ||
|
|
220
|
+
msg.dig("author", "id").to_s == bot_id_str
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
LOG.warn "[Discord] Failed to check thread participation: #{e.message}" if defined?(LOG)
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Check if this agent is the only bot in the thread (1-on-1 with the human).
|
|
228
|
+
# Uses the already-fetched channel_history to avoid extra API calls.
|
|
229
|
+
# channel_history is a formatted string ("Username: message\n...") from fetch_channel_history.
|
|
230
|
+
# Returns true when no other agent bots have posted in the thread history,
|
|
231
|
+
# meaning it's safe to skip intent checking (the message is obviously for us).
|
|
232
|
+
def solo_in_thread?(channel_history, agent_key)
|
|
233
|
+
return true unless channel_history.is_a?(String) && !channel_history.empty?
|
|
234
|
+
|
|
235
|
+
other_bot_usernames = []
|
|
236
|
+
Gateway.each_bot do |key, info|
|
|
237
|
+
next if key == agent_key
|
|
238
|
+
next unless info[:username]
|
|
239
|
+
|
|
240
|
+
other_bot_usernames << info[:username]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Also check user_mappings for remote agent bots not connected locally
|
|
244
|
+
agent_keys = []
|
|
245
|
+
Gateway.each_bot { |key, _| agent_keys << key }
|
|
246
|
+
Config.user_mappings.each do |name, _discord_id|
|
|
247
|
+
normalized = name.downcase.gsub(/[^a-z0-9]/, "-")
|
|
248
|
+
next if normalized == agent_key
|
|
249
|
+
next if agent_keys.include?(normalized)
|
|
250
|
+
|
|
251
|
+
# Only include names that look like agent bots (present in agent registry)
|
|
252
|
+
next unless defined?(AGENT_REGISTRY) && AGENT_REGISTRY.key?(normalized)
|
|
253
|
+
|
|
254
|
+
other_bot_usernames << name
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
return true if other_bot_usernames.empty?
|
|
258
|
+
|
|
259
|
+
history_lines = channel_history.split("\n")
|
|
260
|
+
history_lines.none? do |line|
|
|
261
|
+
other_bot_usernames.any? { |username| line.start_with?("#{username}: ") }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
161
265
|
def prepare_content(content, bot_user_id)
|
|
162
266
|
content.gsub(/<@!?#{bot_user_id}>/, "").strip
|
|
163
267
|
end
|
|
@@ -276,7 +380,8 @@ module Brainiac
|
|
|
276
380
|
def route_dispatch(agent_key:, agent_name:, bot_token:, is_bot:, channel_id:, message_id:, message:,
|
|
277
381
|
clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
|
|
278
382
|
channel_info:, parent_channel_id:, discord_user:, reply_context:,
|
|
279
|
-
channel_history:, project_key:, project_config:, attachment_paths
|
|
383
|
+
channel_history:, project_key:, project_config:, attachment_paths:,
|
|
384
|
+
directly_addressed: false)
|
|
280
385
|
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
281
386
|
supersede_key = "discord-#{agent_key}-#{channel_id}"
|
|
282
387
|
if session_active?(session_key)
|
|
@@ -285,9 +390,12 @@ module Brainiac
|
|
|
285
390
|
end
|
|
286
391
|
handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
|
|
287
392
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
393
|
+
unless directly_addressed
|
|
394
|
+
if intent_skip?(clean_content, agent_name: agent_name, source: :discord,
|
|
395
|
+
channel: "Discord #{is_thread ? "thread" : "channel"}", context: channel_history)
|
|
396
|
+
LOG.info "[Discord:#{agent_name}] Intent skip — not dispatching for: #{clean_content[0..80]}" if defined?(LOG)
|
|
397
|
+
return
|
|
398
|
+
end
|
|
291
399
|
end
|
|
292
400
|
|
|
293
401
|
Thread.new do
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Brainiac
|
|
7
|
+
module Plugins
|
|
8
|
+
module Discord
|
|
9
|
+
# Handles cleanup of Discord thread worktrees when threads are archived or deleted.
|
|
10
|
+
#
|
|
11
|
+
# When a thread is archived (manually closed or auto-archived due to inactivity)
|
|
12
|
+
# or deleted, this module looks up the associated worktree in discord_thread_map.json,
|
|
13
|
+
# verifies it has no uncommitted changes, removes the worktree + branch, and removes
|
|
14
|
+
# the thread map entry.
|
|
15
|
+
module ThreadCleanup
|
|
16
|
+
class << self
|
|
17
|
+
def handle_archive(data, agent_key, agent_display)
|
|
18
|
+
thread_id = data["id"]
|
|
19
|
+
return unless thread_id
|
|
20
|
+
|
|
21
|
+
thread_map_key = "#{agent_key}:#{thread_id}"
|
|
22
|
+
|
|
23
|
+
entry = Config.thread_map_mutex.synchronize do
|
|
24
|
+
map = Config.load_thread_map
|
|
25
|
+
map[thread_map_key]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless entry
|
|
29
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — no worktree tracked, nothing to clean up" if defined?(LOG)
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
worktree_path = entry["worktree"]
|
|
34
|
+
branch = entry["branch"]
|
|
35
|
+
project_key = entry["project"]
|
|
36
|
+
chat_mode = entry["chat_mode"]
|
|
37
|
+
|
|
38
|
+
if chat_mode
|
|
39
|
+
cleanup_chat_mode_dir(worktree_path, thread_map_key, agent_display, thread_id)
|
|
40
|
+
else
|
|
41
|
+
cleanup_worktree(worktree_path, branch, project_key, thread_map_key, agent_display, thread_id)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def cleanup_chat_mode_dir(dir_path, thread_map_key, agent_display, thread_id)
|
|
48
|
+
if dir_path && File.directory?(dir_path)
|
|
49
|
+
FileUtils.rm_rf(dir_path)
|
|
50
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — removed chat mode dir #{dir_path}" if defined?(LOG)
|
|
51
|
+
elsif defined?(LOG)
|
|
52
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — chat mode dir already gone"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
remove_thread_map_entry(thread_map_key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cleanup_worktree(worktree_path, branch, project_key, thread_map_key, agent_display, thread_id)
|
|
59
|
+
unless worktree_path && File.directory?(worktree_path)
|
|
60
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — worktree already removed, cleaning up map entry" if defined?(LOG)
|
|
61
|
+
remove_thread_map_entry(thread_map_key)
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
status_output, = Open3.capture3("git", "status", "--porcelain", chdir: worktree_path)
|
|
66
|
+
unless status_output.strip.empty?
|
|
67
|
+
if defined?(LOG)
|
|
68
|
+
LOG.warn "[Discord:#{agent_display}] Thread #{thread_id} archived — " \
|
|
69
|
+
"worktree #{worktree_path} has uncommitted changes, skipping cleanup"
|
|
70
|
+
end
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
project_config = defined?(PROJECTS) ? PROJECTS[project_key] : nil
|
|
75
|
+
repo_path = project_config&.dig("repo_path")
|
|
76
|
+
|
|
77
|
+
unless repo_path && File.directory?(repo_path)
|
|
78
|
+
if defined?(LOG)
|
|
79
|
+
LOG.warn "[Discord:#{agent_display}] Thread #{thread_id} archived — " \
|
|
80
|
+
"cannot find repo for project '#{project_key}', removing directory directly"
|
|
81
|
+
end
|
|
82
|
+
FileUtils.rm_rf(worktree_path)
|
|
83
|
+
remove_thread_map_entry(thread_map_key)
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
Open3.capture3("git", "worktree", "remove", worktree_path, "--force", chdir: repo_path)
|
|
89
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — removed worktree #{worktree_path}" if defined?(LOG)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
LOG.warn "[Discord:#{agent_display}] Failed to remove worktree #{worktree_path}: #{e.message}" if defined?(LOG)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if branch
|
|
95
|
+
begin
|
|
96
|
+
Open3.capture3("git", "branch", "-D", branch, chdir: repo_path)
|
|
97
|
+
LOG.info "[Discord:#{agent_display}] Thread #{thread_id} archived — deleted branch #{branch}" if defined?(LOG)
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
LOG.warn "[Discord:#{agent_display}] Failed to delete branch #{branch}: #{e.message}" if defined?(LOG)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
remove_thread_map_entry(thread_map_key)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def remove_thread_map_entry(thread_map_key)
|
|
107
|
+
Config.thread_map_mutex.synchronize do
|
|
108
|
+
map = Config.load_thread_map
|
|
109
|
+
map.delete(thread_map_key)
|
|
110
|
+
Config.save_thread_map(map)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -9,6 +9,7 @@ require_relative "discord/api"
|
|
|
9
9
|
require_relative "discord/delivery"
|
|
10
10
|
require_relative "discord/reactions"
|
|
11
11
|
require_relative "discord/message"
|
|
12
|
+
require_relative "discord/thread_cleanup"
|
|
12
13
|
require_relative "discord/gateway"
|
|
13
14
|
|
|
14
15
|
module Brainiac
|
|
@@ -31,6 +32,9 @@ module Brainiac
|
|
|
31
32
|
# Register crash handler
|
|
32
33
|
register_crash_handler!
|
|
33
34
|
|
|
35
|
+
# Register agent lifecycle hooks
|
|
36
|
+
register_agent_lifecycle_hooks!
|
|
37
|
+
|
|
34
38
|
# Start all per-agent Discord bot gateway connections
|
|
35
39
|
Brainiac::Plugins::Discord::Gateway.start_all!
|
|
36
40
|
|
|
@@ -45,6 +49,30 @@ module Brainiac
|
|
|
45
49
|
|
|
46
50
|
private
|
|
47
51
|
|
|
52
|
+
def register_agent_lifecycle_hooks!
|
|
53
|
+
Brainiac.on(:agent_added) do |ctx|
|
|
54
|
+
agent_key = ctx[:agent_key]
|
|
55
|
+
entry = ctx[:entry]
|
|
56
|
+
display_name = ctx[:display_name]
|
|
57
|
+
next unless entry.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
|
|
60
|
+
if token
|
|
61
|
+
# Start a gateway connection for the new agent's bot
|
|
62
|
+
Gateway.start_bot!(agent_key, token)
|
|
63
|
+
LOG.info "[Discord] Started bot for new agent: #{display_name}" if defined?(LOG)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Brainiac.on(:agent_removed) do |ctx|
|
|
68
|
+
agent_key = ctx[:agent_key]
|
|
69
|
+
|
|
70
|
+
# Stop the gateway connection if running
|
|
71
|
+
Gateway.stop_bot!(agent_key)
|
|
72
|
+
LOG.info "[Discord] Stopped bot for removed agent: #{agent_key}" if defined?(LOG)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
48
76
|
def register_notification_handler!
|
|
49
77
|
Brainiac.on(:notify) do |ctx|
|
|
50
78
|
next unless ctx[:channel].to_s == "discord"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainiac-discord
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -112,6 +112,7 @@ files:
|
|
|
112
112
|
- lib/brainiac/plugins/discord/metadata.rb
|
|
113
113
|
- lib/brainiac/plugins/discord/prompts.rb
|
|
114
114
|
- lib/brainiac/plugins/discord/reactions.rb
|
|
115
|
+
- lib/brainiac/plugins/discord/thread_cleanup.rb
|
|
115
116
|
- lib/brainiac/plugins/discord/version.rb
|
|
116
117
|
- lib/brainiac_discord.rb
|
|
117
118
|
homepage: https://github.com/stowzilla/brainiac-discord
|