brainiac-discord 0.0.17 → 0.0.18
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/api.rb +4 -1
- data/lib/brainiac/plugins/discord/branching.rb +195 -0
- data/lib/brainiac/plugins/discord/message.rb +73 -0
- data/lib/brainiac/plugins/discord/reactions.rb +122 -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: 81f74969c95690e118d85fe590b2e2d35b39609937e807afaa2865ac58c8846d
|
|
4
|
+
data.tar.gz: cde31da7b3a0b2a4b84bff28b4b1e2fa99fe6ba7e7e8e372b572b1d0ea085c5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 397af906d3f427dab7723fe20d3cc25840a35e109e4e054f9cf9689337cbcc8ccc376e0b14403d5dd38ede9fc25b3b2e2efa368371dae012b14c6dd5e9f0aa03
|
|
7
|
+
data.tar.gz: 8201ec6515468a4ed1a1f4e63ecc8bdf66c57e333a096c00c216559bb4a1466443928ea6c35542efdd77f80bea0c80a2555916cf053ffff41e6e721f329029ba
|
|
@@ -16,7 +16,10 @@ module Brainiac
|
|
|
16
16
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
17
17
|
|
|
18
18
|
# Emojis reserved for brainiac functionality — not treated as feedback
|
|
19
|
-
RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
|
|
19
|
+
RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠 🌿 🍴 🍽️].freeze
|
|
20
|
+
|
|
21
|
+
# All emojis that trigger conversation forking
|
|
22
|
+
FORK_EMOJIS = %w[🌿 🍴 🍽️].freeze
|
|
20
23
|
|
|
21
24
|
class << self
|
|
22
25
|
def request(method, path, token:, body: nil, log_errors: true)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Discord
|
|
6
|
+
# Conversation branching — fork a conversation into a new thread.
|
|
7
|
+
#
|
|
8
|
+
# Supports two triggers:
|
|
9
|
+
# - Fork emoji reactions (🌿, , 🍴, 🍽️) on any message → creates thread from that message
|
|
10
|
+
# - [fork] or [fork:topic] inline tag → creates thread from the tagged message
|
|
11
|
+
#
|
|
12
|
+
# Branched threads start "unbound" — no worktree, no project lock-in.
|
|
13
|
+
# The agent can discuss, search brain, and read files across any registered project.
|
|
14
|
+
# When implementation work is requested, the worktree is created at that point.
|
|
15
|
+
#
|
|
16
|
+
# Threading limitations:
|
|
17
|
+
# - You can create a thread from a message in a regular text channel
|
|
18
|
+
# - You CANNOT create a thread from a message inside an existing thread
|
|
19
|
+
# - When the source message is in a thread, we create a standalone thread in the parent channel
|
|
20
|
+
module Branching
|
|
21
|
+
class << self
|
|
22
|
+
# Fork a conversation from a message into a new thread.
|
|
23
|
+
#
|
|
24
|
+
# @param source_channel_id [String] The channel where the source message lives
|
|
25
|
+
# @param source_message_id [String] The message to branch from
|
|
26
|
+
# @param source_message [Hash, nil] The source message object (avoids re-fetch if already have it)
|
|
27
|
+
# @param source_is_thread [Boolean] Whether the source channel is a thread
|
|
28
|
+
# @param parent_channel_id [String] The parent text channel (same as source if not in a thread)
|
|
29
|
+
# @param topic [String, nil] Optional thread title (defaults to message content snippet)
|
|
30
|
+
# @param agent_key [String] The agent handling this branch
|
|
31
|
+
# @param agent_name [String] Display name of the agent
|
|
32
|
+
# @param bot_token [String] Discord bot token
|
|
33
|
+
# @param project_key [String, nil] Optional project to inherit (not locked until implementation)
|
|
34
|
+
# @param fork_emoji [String] The emoji that triggered the fork (default: 🌿)
|
|
35
|
+
# @return [Hash, nil] The created thread object, or nil on failure
|
|
36
|
+
def fork_conversation(source_channel_id:, source_message_id:, agent_key:, agent_name:, bot_token:,
|
|
37
|
+
source_message: nil, source_is_thread: false, parent_channel_id: nil,
|
|
38
|
+
topic: nil, project_key: nil, fork_emoji: "🌿")
|
|
39
|
+
# Fetch the source message if not provided
|
|
40
|
+
source_message ||= Api.fetch_message(source_channel_id, source_message_id, token: bot_token)
|
|
41
|
+
unless source_message
|
|
42
|
+
LOG.warn "[Discord:#{agent_name}] Cannot fork — failed to fetch message #{source_message_id}" if defined?(LOG)
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build thread title
|
|
47
|
+
content = source_message["content"]&.strip || ""
|
|
48
|
+
content = strip_inline_tags(content)
|
|
49
|
+
thread_title = build_thread_title(topic, content, agent_name)
|
|
50
|
+
|
|
51
|
+
# Create the thread
|
|
52
|
+
# If the source message is inside a thread, we can't create a sub-thread from it.
|
|
53
|
+
# Instead, create a standalone thread in the parent channel.
|
|
54
|
+
thread = if source_is_thread && parent_channel_id
|
|
55
|
+
create_standalone_thread(parent_channel_id, thread_title, agent_name, bot_token)
|
|
56
|
+
else
|
|
57
|
+
Api.create_thread(source_channel_id, source_message_id, name: thread_title, token: bot_token)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless thread && thread["id"]
|
|
61
|
+
LOG.error "[Discord:#{agent_name}] Failed to create branch thread from message #{source_message_id}" if defined?(LOG)
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
thread_id = thread["id"]
|
|
66
|
+
LOG.info "[Discord:#{agent_name}] Created branch thread #{thread_id}: \"#{thread_title}\"" if defined?(LOG)
|
|
67
|
+
|
|
68
|
+
# Register as an unbound branch (no worktree yet)
|
|
69
|
+
register_unbound_branch(
|
|
70
|
+
agent_key: agent_key, agent_name: agent_name,
|
|
71
|
+
thread_id: thread_id, project_key: project_key,
|
|
72
|
+
source_channel_id: source_channel_id, source_message_id: source_message_id
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# React on the source message to indicate branching
|
|
76
|
+
Thread.new { Api.add_reaction(source_channel_id, source_message_id, fork_emoji, token: bot_token) }
|
|
77
|
+
|
|
78
|
+
# Post an intro message in the new thread
|
|
79
|
+
# Include a reference to the original message when created as a standalone thread
|
|
80
|
+
if source_is_thread
|
|
81
|
+
post_branch_intro_with_reference(thread_id, agent_name, source_channel_id, source_message_id, bot_token)
|
|
82
|
+
else
|
|
83
|
+
post_branch_intro(thread_id, agent_name, bot_token)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
thread
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Legacy method signature for backward compatibility with [fork] tag handling.
|
|
90
|
+
# The message.rb handler uses the old parameter names.
|
|
91
|
+
def fork_conversation_legacy(channel_id:, message_id:, agent_key:, agent_name:, bot_token:, topic: nil, project_key: nil)
|
|
92
|
+
fork_conversation(
|
|
93
|
+
source_channel_id: channel_id,
|
|
94
|
+
source_message_id: message_id,
|
|
95
|
+
source_message: nil,
|
|
96
|
+
source_is_thread: false,
|
|
97
|
+
parent_channel_id: channel_id,
|
|
98
|
+
topic: topic,
|
|
99
|
+
agent_key: agent_key,
|
|
100
|
+
agent_name: agent_name,
|
|
101
|
+
bot_token: bot_token,
|
|
102
|
+
project_key: project_key
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Create a standalone thread (not connected to a message).
|
|
109
|
+
# Used when forking from inside an existing thread.
|
|
110
|
+
# Discord API: POST /channels/<channel_id>/threads
|
|
111
|
+
def create_standalone_thread(channel_id, name, agent_name, bot_token)
|
|
112
|
+
thread_name = name.length > 100 ? "#{name[0..96]}..." : name
|
|
113
|
+
result = Api.request(:post, "/channels/#{channel_id}/threads", token: bot_token, body: {
|
|
114
|
+
name: thread_name,
|
|
115
|
+
type: 11, # PUBLIC_THREAD
|
|
116
|
+
auto_archive_duration: 1440
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if result && result["id"]
|
|
120
|
+
LOG.info "[Discord:#{agent_name}] Created standalone thread #{result["id"]} in channel #{channel_id}" if defined?(LOG)
|
|
121
|
+
elsif defined?(LOG)
|
|
122
|
+
LOG.error "[Discord:#{agent_name}] Failed to create standalone thread: #{result.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Strip inline tags from content for cleaner thread titles.
|
|
129
|
+
#
|
|
130
|
+
# The bracket-tag regex uses an atomic group ((?>...)) around the tag
|
|
131
|
+
# name so the engine can't backtrack into overlapping alternatives on
|
|
132
|
+
# malformed input like "[p:[?:[?:...". Without it, the "p"/"?"
|
|
133
|
+
# alternatives combined with the unbounded [^\]]+ argument create
|
|
134
|
+
# polynomial-time backtracking (ReDoS) on adversarial strings.
|
|
135
|
+
def strip_inline_tags(content)
|
|
136
|
+
content
|
|
137
|
+
.gsub(/\[(?>fork|branch|project|effort|cli|profile|p|deploy|chat|question|\?|fresh|plan|worktree|workitem)(?::[^\]]*)?\]/i, "")
|
|
138
|
+
.gsub(/\[\w+\]/i, "") # Model tags and others
|
|
139
|
+
.gsub(/<@!?\d+>/, "") # Discord mentions
|
|
140
|
+
.strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_thread_title(topic, content, agent_name)
|
|
144
|
+
if topic && topic != true
|
|
145
|
+
"#{agent_name}: #{topic}"
|
|
146
|
+
elsif !content.empty?
|
|
147
|
+
snippet = content[0..60]
|
|
148
|
+
snippet = "#{snippet}..." if content.length > 60
|
|
149
|
+
"#{agent_name}: #{snippet}"
|
|
150
|
+
else
|
|
151
|
+
"#{agent_name}: Branch"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def register_unbound_branch(agent_key:, agent_name:, thread_id:, project_key:, source_channel_id:, source_message_id:)
|
|
156
|
+
thread_map_key = "#{agent_key}:#{thread_id}"
|
|
157
|
+
|
|
158
|
+
Config.thread_map_mutex.synchronize do
|
|
159
|
+
map = Config.load_thread_map
|
|
160
|
+
map[thread_map_key] = {
|
|
161
|
+
"channel_id" => thread_id,
|
|
162
|
+
"project" => project_key,
|
|
163
|
+
"unbound" => true, # No worktree yet — created on demand
|
|
164
|
+
"source_channel_id" => source_channel_id,
|
|
165
|
+
"source_message_id" => source_message_id,
|
|
166
|
+
"created_at" => Time.now.iso8601
|
|
167
|
+
}
|
|
168
|
+
Config.save_thread_map(map)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
LOG.info "[Discord:#{agent_name}] Registered unbound branch #{thread_map_key} (project: #{project_key || "none"})" if defined?(LOG)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def post_branch_intro(thread_id, agent_name, bot_token)
|
|
175
|
+
intro = "_Branched conversation. I'll work in this thread — the original stays undisturbed._"
|
|
176
|
+
Api.send_message(thread_id, intro, token: bot_token)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
LOG.warn "[Discord:#{agent_name}] Failed to post branch intro: #{e.message}" if defined?(LOG)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Post intro with a reference to the original message (for standalone threads).
|
|
182
|
+
def post_branch_intro_with_reference(thread_id, agent_name, source_channel_id, source_message_id, bot_token)
|
|
183
|
+
# Discord message link format: https://discord.com/channels/guild_id/channel_id/message_id
|
|
184
|
+
# For within the same server, we can use a relative link
|
|
185
|
+
message_link = "https://discord.com/channels/@me/#{source_channel_id}/#{source_message_id}"
|
|
186
|
+
intro = "_Branched from [this message](#{message_link}). I'll work in this thread — the original stays undisturbed._"
|
|
187
|
+
Api.send_message(thread_id, intro, token: bot_token)
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
LOG.warn "[Discord:#{agent_name}] Failed to post branch intro with reference: #{e.message}" if defined?(LOG)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -66,6 +66,18 @@ module Brainiac
|
|
|
66
66
|
return unless authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
|
|
67
67
|
|
|
68
68
|
tags = parse_inline_tags(clean_content)
|
|
69
|
+
|
|
70
|
+
# [fork] or [fork:topic] — branch the conversation before dispatching
|
|
71
|
+
if tags[:fork_branch]
|
|
72
|
+
return handle_fork_branch(
|
|
73
|
+
tags: tags, agent_key: agent_key, agent_name: agent_name, bot_token: bot_token,
|
|
74
|
+
channel_id: channel_id, message_id: message_id, message: message,
|
|
75
|
+
clean_content: clean_content, parent_channel_id: parent_channel_id,
|
|
76
|
+
discord_user: discord_user, attachment_paths: attachment_paths,
|
|
77
|
+
is_thread: is_thread
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
69
81
|
project_key, project_config = resolve_project(tags[:project], parent_channel_id, agent_name, channel_id, message_id, bot_token)
|
|
70
82
|
|
|
71
83
|
# Thread owners bypass intent checking ONLY when they're the sole agent in
|
|
@@ -91,6 +103,67 @@ module Brainiac
|
|
|
91
103
|
|
|
92
104
|
private
|
|
93
105
|
|
|
106
|
+
# Handle [fork] or [fork:topic] tag — create a branch thread and dispatch to it.
|
|
107
|
+
# The branched thread starts unbound (no worktree) and inherits the parent's project.
|
|
108
|
+
def handle_fork_branch(tags:, agent_key:, agent_name:, bot_token:, channel_id:, message_id:,
|
|
109
|
+
message:, clean_content:, parent_channel_id:, discord_user:, attachment_paths:, is_thread:)
|
|
110
|
+
topic = tags[:fork_branch] == true ? nil : tags[:fork_branch]
|
|
111
|
+
|
|
112
|
+
# Resolve project from channel mapping (inherited, but not locked until implementation)
|
|
113
|
+
project_key, _project_config, _mapping = Config.find_project_for_channel(parent_channel_id)
|
|
114
|
+
|
|
115
|
+
# Create the branch thread
|
|
116
|
+
thread = Branching.fork_conversation(
|
|
117
|
+
source_channel_id: channel_id,
|
|
118
|
+
source_message_id: message_id,
|
|
119
|
+
source_message: nil, # Will be fetched by Branching
|
|
120
|
+
source_is_thread: is_thread,
|
|
121
|
+
parent_channel_id: parent_channel_id,
|
|
122
|
+
topic: topic,
|
|
123
|
+
agent_key: agent_key,
|
|
124
|
+
agent_name: agent_name,
|
|
125
|
+
bot_token: bot_token,
|
|
126
|
+
project_key: project_key
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
unless thread
|
|
130
|
+
LOG.error "[Discord:#{agent_name}] Failed to create fork branch" if defined?(LOG)
|
|
131
|
+
Thread.new { Api.add_reaction(channel_id, message_id, "⚠️", token: bot_token) }
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
branch_thread_id = thread["id"]
|
|
136
|
+
LOG.info "[Discord:#{agent_name}] Forked to thread #{branch_thread_id}, dispatching there" if defined?(LOG)
|
|
137
|
+
|
|
138
|
+
# Now dispatch to the new thread
|
|
139
|
+
# Re-synthesize a message event as if it came from the new thread
|
|
140
|
+
branch_message = message.dup
|
|
141
|
+
branch_message["channel_id"] = branch_thread_id
|
|
142
|
+
|
|
143
|
+
# Fetch fresh channel info for the new thread
|
|
144
|
+
branch_channel_info = Api.fetch_channel_info(branch_thread_id, token: bot_token)
|
|
145
|
+
|
|
146
|
+
# Build context for the branched thread (no prior history since it's new)
|
|
147
|
+
branch_channel_history = ""
|
|
148
|
+
|
|
149
|
+
# Resolve project again (same result, but for clarity)
|
|
150
|
+
branch_project_key, branch_project_config = resolve_project(tags[:project], parent_channel_id, agent_name,
|
|
151
|
+
branch_thread_id, message_id, bot_token)
|
|
152
|
+
|
|
153
|
+
# Dispatch to the branch
|
|
154
|
+
route_dispatch(
|
|
155
|
+
agent_key: agent_key, agent_name: agent_name, bot_token: bot_token, is_bot: false,
|
|
156
|
+
channel_id: branch_thread_id, message_id: message_id, message: branch_message,
|
|
157
|
+
clean_content: clean_content, clean_content_for_prompt: tags[:clean_text],
|
|
158
|
+
chat_mode: tags[:chat_mode], fresh: tags[:fresh], is_thread: true, is_dm: false,
|
|
159
|
+
channel_info: branch_channel_info, parent_channel_id: parent_channel_id,
|
|
160
|
+
discord_user: discord_user, reply_context: "",
|
|
161
|
+
channel_history: branch_channel_history, project_key: branch_project_key,
|
|
162
|
+
project_config: branch_project_config, attachment_paths: attachment_paths,
|
|
163
|
+
directly_addressed: true # User explicitly forked to us
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
94
167
|
# Check if the agent's Discord role was @mentioned in this message.
|
|
95
168
|
# Discord role mentions use <@&ROLE_ID> syntax and populate mention_roles array.
|
|
96
169
|
def role_mentioned?(message, content, agent_key)
|
|
@@ -11,6 +11,16 @@ module Brainiac
|
|
|
11
11
|
# - 🧠 to stream the full thinking log to a thread
|
|
12
12
|
# - Non-reserved emojis logged as feedback to the agent's persona
|
|
13
13
|
module Reactions
|
|
14
|
+
# Global deduplication for fork reactions. Tracks which messages have
|
|
15
|
+
# already been forked to prevent duplicates from multiple agents or
|
|
16
|
+
# repeated Discord events.
|
|
17
|
+
#
|
|
18
|
+
# Key: "channel_id:message_id" -> Time the fork was handled
|
|
19
|
+
# Entries expire after 60 seconds (well beyond Discord's duplicate window).
|
|
20
|
+
FORK_DEDUP = {}
|
|
21
|
+
FORK_DEDUP_MUTEX = Mutex.new
|
|
22
|
+
FORK_DEDUP_TTL = 60 # seconds
|
|
23
|
+
|
|
14
24
|
class << self
|
|
15
25
|
def handle(reaction_data, agent_key, bot_token, bot_user_id)
|
|
16
26
|
channel_id = reaction_data["channel_id"]
|
|
@@ -31,6 +41,8 @@ module Brainiac
|
|
|
31
41
|
handle_thinking_stream(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
32
42
|
when "❌"
|
|
33
43
|
handle_cancel(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
44
|
+
when "🌿", "", "🍴", "🍽️"
|
|
45
|
+
handle_branch(agent_key, agent_name, channel_id, message_id, bot_token, emoji: emoji_name)
|
|
34
46
|
else
|
|
35
47
|
unless Api::RESERVED_EMOJIS.include?(emoji_name)
|
|
36
48
|
Thread.new do
|
|
@@ -171,6 +183,116 @@ module Brainiac
|
|
|
171
183
|
end
|
|
172
184
|
end
|
|
173
185
|
|
|
186
|
+
# Handle fork reactions (🌿, , 🍴, 🍽️) — fork the conversation into a new thread.
|
|
187
|
+
# The reacted message becomes the root of the new branch.
|
|
188
|
+
#
|
|
189
|
+
# Multi-agent coordination uses a two-phase approach:
|
|
190
|
+
# 1. Global deduplication — atomic check-and-claim ensures only one agent proceeds
|
|
191
|
+
# 2. Ownership check — determines which specific agent should handle it
|
|
192
|
+
#
|
|
193
|
+
# This prevents both the race condition (multiple agents passing ownership check
|
|
194
|
+
# simultaneously) and duplicate events (Discord sending the same reaction multiple times).
|
|
195
|
+
def handle_branch(agent_key, agent_name, channel_id, message_id, bot_token, emoji: "🌿")
|
|
196
|
+
LOG.info "[Discord:#{agent_name}] #{emoji} fork reaction on message #{message_id} in channel #{channel_id}" if defined?(LOG)
|
|
197
|
+
|
|
198
|
+
dedup_key = "#{channel_id}:#{message_id}"
|
|
199
|
+
|
|
200
|
+
# Fetch source message to determine ownership
|
|
201
|
+
source_message = Api.fetch_message(channel_id, message_id, token: bot_token)
|
|
202
|
+
unless source_message
|
|
203
|
+
LOG.warn "[Discord:#{agent_name}] #{emoji} skipping — couldn't fetch message #{message_id}" if defined?(LOG)
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Check if this agent should handle the fork (based on mentions/authorship/fallback)
|
|
208
|
+
unless should_handle_fork?(agent_key, source_message)
|
|
209
|
+
LOG.info "[Discord:#{agent_name}] #{emoji} skipping — another agent should handle this fork" if defined?(LOG)
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Atomic claim — only one agent gets past this point
|
|
214
|
+
unless claim_fork!(dedup_key, agent_key)
|
|
215
|
+
LOG.info "[Discord:#{agent_name}] #{emoji} skipping — fork already claimed by another process" if defined?(LOG)
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
LOG.info "[Discord:#{agent_name}] #{emoji} handling fork for message #{message_id}" if defined?(LOG)
|
|
220
|
+
|
|
221
|
+
# Check if we're in a thread — get parent channel for project resolution
|
|
222
|
+
channel_info = Api.fetch_channel_info(channel_id, token: bot_token)
|
|
223
|
+
is_thread = channel_info && [11, 12].include?(channel_info["type"])
|
|
224
|
+
parent_channel_id = is_thread ? channel_info["parent_id"] : channel_id
|
|
225
|
+
|
|
226
|
+
# Resolve project from channel mapping (inherited, but not locked)
|
|
227
|
+
project_key, _project_config, _mapping = Config.find_project_for_channel(parent_channel_id)
|
|
228
|
+
|
|
229
|
+
thread = Branching.fork_conversation(
|
|
230
|
+
source_channel_id: channel_id,
|
|
231
|
+
source_message_id: message_id,
|
|
232
|
+
source_message: source_message,
|
|
233
|
+
source_is_thread: is_thread,
|
|
234
|
+
parent_channel_id: parent_channel_id,
|
|
235
|
+
topic: nil, # Auto-generate from message content
|
|
236
|
+
agent_key: agent_key,
|
|
237
|
+
agent_name: agent_name,
|
|
238
|
+
bot_token: bot_token,
|
|
239
|
+
project_key: project_key,
|
|
240
|
+
fork_emoji: emoji
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
Api.add_reaction(channel_id, message_id, "⚠️", token: bot_token) unless thread
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Atomically claim a fork operation. Returns true if this caller wins,
|
|
247
|
+
# false if already claimed. Cleans up expired entries while holding the lock.
|
|
248
|
+
def claim_fork!(dedup_key, agent_key)
|
|
249
|
+
FORK_DEDUP_MUTEX.synchronize do
|
|
250
|
+
now = Time.now
|
|
251
|
+
|
|
252
|
+
# Clean up expired entries
|
|
253
|
+
FORK_DEDUP.delete_if { |_, claimed_at| now - claimed_at > FORK_DEDUP_TTL }
|
|
254
|
+
|
|
255
|
+
# Check if already claimed
|
|
256
|
+
return false if FORK_DEDUP.key?(dedup_key)
|
|
257
|
+
|
|
258
|
+
# Claim it
|
|
259
|
+
FORK_DEDUP[dedup_key] = now
|
|
260
|
+
LOG.debug "[Discord] Fork claimed: #{dedup_key} by #{agent_key}" if defined?(LOG) && LOG.respond_to?(:debug)
|
|
261
|
+
true
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Determine if this agent should handle a fork reaction.
|
|
266
|
+
# Only one agent should handle each fork to avoid duplicates.
|
|
267
|
+
def should_handle_fork?(agent_key, source_message)
|
|
268
|
+
bot_user_id = Gateway.bot_user_id(agent_key)&.to_s
|
|
269
|
+
return false unless bot_user_id
|
|
270
|
+
|
|
271
|
+
# 1. If this bot authored the message → handle it
|
|
272
|
+
return true if source_message.dig("author", "id").to_s == bot_user_id
|
|
273
|
+
|
|
274
|
+
# 2. If this bot was @mentioned in the message → handle it
|
|
275
|
+
mentions = source_message["mentions"] || []
|
|
276
|
+
content = source_message["content"] || ""
|
|
277
|
+
if mentions.any? { |m| m["id"].to_s == bot_user_id } ||
|
|
278
|
+
content.match?(/<@!?#{Regexp.escape(bot_user_id)}>/)
|
|
279
|
+
return true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# 3. Fallback: deterministic selection among all active bots
|
|
283
|
+
# Pick the bot with the lexicographically smallest agent_key
|
|
284
|
+
active_bot_keys = []
|
|
285
|
+
Gateway.each_bot do |key, info|
|
|
286
|
+
active_bot_keys << key if info[:user_id]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
return false if active_bot_keys.empty?
|
|
290
|
+
|
|
291
|
+
# Pick the smallest key to ensure deterministic selection
|
|
292
|
+
designated_handler = active_bot_keys.min
|
|
293
|
+
agent_key == designated_handler
|
|
294
|
+
end
|
|
295
|
+
|
|
174
296
|
def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
|
|
175
297
|
msg = Api.fetch_message(channel_id, message_id, token: bot_token, log_errors: false)
|
|
176
298
|
return unless msg&.dig("author", "bot")
|
|
@@ -8,6 +8,7 @@ require_relative "discord/prompts"
|
|
|
8
8
|
require_relative "discord/api"
|
|
9
9
|
require_relative "discord/delivery"
|
|
10
10
|
require_relative "discord/reactions"
|
|
11
|
+
require_relative "discord/branching"
|
|
11
12
|
require_relative "discord/message"
|
|
12
13
|
require_relative "discord/thread_cleanup"
|
|
13
14
|
require_relative "discord/gateway"
|
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.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -104,6 +104,7 @@ files:
|
|
|
104
104
|
- README.md
|
|
105
105
|
- lib/brainiac/plugins/discord.rb
|
|
106
106
|
- lib/brainiac/plugins/discord/api.rb
|
|
107
|
+
- lib/brainiac/plugins/discord/branching.rb
|
|
107
108
|
- lib/brainiac/plugins/discord/cli.rb
|
|
108
109
|
- lib/brainiac/plugins/discord/config.rb
|
|
109
110
|
- lib/brainiac/plugins/discord/delivery.rb
|