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 +4 -4
- data/lib/brainiac/plugins/discord/config.rb +12 -0
- data/lib/brainiac/plugins/discord/gateway.rb +21 -0
- data/lib/brainiac/plugins/discord/message.rb +93 -6
- 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 +1 -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
|
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
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
|