brainiac-discord 0.0.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cdd8beafe4070560e4caaaa465d2b1e9dde33defa1b7fdc429467864776d3c21
4
- data.tar.gz: 8714538569c7c1211a93ffa567401fe54197ce975fb3946e1524550f67cacb97
3
+ metadata.gz: 5edd0068ecc82d20372d269791ff8e750fcec8106f4adcc99fdef3c252e03ada
4
+ data.tar.gz: b30c999fc3497d7608463975a3d9f4a8c09b135e3d7bc9da1099b6a5306594c2
5
5
  SHA512:
6
- metadata.gz: a072c85e99b507dc4cdf65ec04cb9ed1d83e3ef22ef6ba77bd2866b7e04d947920cd607e087be3c93b06d362ef2e94127903e5087b8e9b7a85c456be72684b6f
7
- data.tar.gz: 481b0308e707c86fddc515ba26a079bb23c31bb1f0f0f42da00cc7d798af52a24f6f8ea40f75356675f856f037b4e7dafa4174e1d62589569379bdc36e26f401
6
+ metadata.gz: f65f1deefed415c9b3a038162ad6c59714f2677c9ce8ec201e880ce9ae9b159571cb4910a7515268d0e09ad262e69011ea6f7b1c40471bc846ea5f84fa74d366
7
+ data.tar.gz: 23ba4bda9f6eb4e9797e6724ad57f12908da3176f116eacdaa11f8b5c3462647180cb8c0a1a1766c00ac12de49c2216e8fdf84da9ad719dcde0bae42307851f9
@@ -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
@@ -295,6 +295,26 @@ module Brainiac
295
295
  rescue StandardError => e
296
296
  LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
297
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
298
318
  end
299
319
 
300
320
  bot_user_id
@@ -303,6 +323,7 @@ module Brainiac
303
323
  def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
304
324
  @bots_mutex.synchronize do
305
325
  @bots[agent_key][:user_id] = bot_user_id
326
+ @bots[agent_key][:username] = data.dig("user", "username")
306
327
  @bots[agent_key][:status] = "ready"
307
328
  end
308
329
  guild_count = data["guilds"]&.size || 0
@@ -24,7 +24,8 @@ 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)
@@ -36,6 +37,10 @@ module Brainiac
36
37
  thread_participant?(channel_id, message_id, bot_user_id, bot_token)
37
38
  return unless mentioned || in_own_thread || is_dm || is_reply_to_bot || is_thread_participant
38
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)
43
+
39
44
  record_human_comment("discord-#{channel_id}") unless is_bot
40
45
 
41
46
  clean_content = prepare_content(content, bot_user_id)
@@ -62,6 +67,14 @@ module Brainiac
62
67
  tags = parse_inline_tags(clean_content)
63
68
  project_key, project_config = resolve_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
64
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
+
65
78
  route_dispatch(
66
79
  agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: is_bot,
67
80
  channel_id: channel_id, message_id: message_id, message: message,
@@ -70,12 +83,44 @@ module Brainiac
70
83
  channel_info: channel_info, parent_channel_id: parent_channel_id,
71
84
  discord_user: discord_user, reply_context: reply_context,
72
85
  channel_history: channel_history, project_key: project_key,
73
- 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
74
88
  )
75
89
  end
76
90
 
77
91
  private
78
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
+
79
124
  def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, content, channel_id)
80
125
  return false unless mentioned
81
126
 
@@ -179,6 +224,44 @@ module Brainiac
179
224
  false
180
225
  end
181
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
+
182
265
  def prepare_content(content, bot_user_id)
183
266
  content.gsub(/<@!?#{bot_user_id}>/, "").strip
184
267
  end
@@ -297,7 +380,8 @@ module Brainiac
297
380
  def route_dispatch(agent_key:, agent_name:, bot_token:, is_bot:, channel_id:, message_id:, message:,
298
381
  clean_content:, clean_content_for_prompt:, chat_mode:, is_thread:, is_dm:,
299
382
  channel_info:, parent_channel_id:, discord_user:, reply_context:,
300
- channel_history:, project_key:, project_config:, attachment_paths:)
383
+ channel_history:, project_key:, project_config:, attachment_paths:,
384
+ directly_addressed: false)
301
385
  session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
302
386
  supersede_key = "discord-#{agent_key}-#{channel_id}"
303
387
  if session_active?(session_key)
@@ -306,9 +390,12 @@ module Brainiac
306
390
  end
307
391
  handle_supersede(is_bot, supersede_key, discord_user, agent_name, bot_token)
308
392
 
309
- if intent_skip?(clean_content, agent_name: agent_name, source: :discord, channel: "Discord #{is_thread ? "thread" : "channel"}")
310
- LOG.info "[Discord:#{agent_name}] Intent skip — not dispatching for: #{clean_content[0..80]}" if defined?(LOG)
311
- return
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
312
399
  end
313
400
 
314
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
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.4"
6
+ VERSION = "0.0.5"
7
7
  end
8
8
  end
9
9
  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
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
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