brainiac 0.0.6 → 0.0.7
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/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +382 -11
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +15 -38
- data/lib/brainiac/config.rb +40 -11
- data/lib/brainiac/handlers/discord/api.rb +196 -0
- data/lib/brainiac/handlers/discord/config.rb +134 -0
- data/lib/brainiac/handlers/discord/delivery.rb +196 -0
- data/lib/brainiac/handlers/discord/gateway.rb +212 -0
- data/lib/brainiac/handlers/discord/message.rb +933 -0
- data/lib/brainiac/handlers/discord/reactions.rb +215 -0
- data/lib/brainiac/handlers/discord.rb +14 -1892
- data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
- data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
- data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
- data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
- data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
- data/lib/brainiac/handlers/fizzy.rb +12 -1290
- data/lib/brainiac/handlers/github.rb +149 -212
- data/lib/brainiac/handlers/shared/git.rb +203 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +79 -76
- data/lib/brainiac/helpers.rb +2 -121
- data/lib/brainiac/planning.rb +2 -2
- data/lib/brainiac/plugins.rb +129 -0
- data/lib/brainiac/restart.rb +94 -0
- data/lib/brainiac/routes/api.rb +427 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +7 -0
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
- data/monitor/waybar/setup.rb +232 -0
- data/monitor/waybar/status.rb +51 -0
- data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
- data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
- data/monitor/xbar/plugin.rb +155 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +91 -450
- data/templates/brainiac.json.example +9 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- metadata +30 -20
- data/lib/user_registry.rb +0 -159
- data/monitor/menubar.rb +0 -295
- data/monitor/setup-waybar-deploy-envs.rb +0 -121
- data/monitor/setup-waybar-deployments.rb +0 -96
- data/monitor/setup-waybar-module.rb +0 -113
- data/monitor/setup-xbar-plugin.rb +0 -35
- data/monitor/view-logs.rb +0 -119
- data/monitor/waybar-config-updater.rb +0 -56
- data/monitor/waybar-deployments.rb +0 -239
- data/monitor/waybar.rb +0 -146
- data/monitor/xbar.3s.rb +0 -179
- /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord REST API helpers.
|
|
4
|
+
#
|
|
5
|
+
# Low-level HTTP methods and convenience wrappers for the Discord v10 API.
|
|
6
|
+
# Used by the Discord handler itself, but also by GitHub (deploy notifications)
|
|
7
|
+
# and Zoho (email notifications).
|
|
8
|
+
|
|
9
|
+
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
10
|
+
|
|
11
|
+
# Emojis reserved for brainiac functionality — not treated as feedback
|
|
12
|
+
RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
|
|
13
|
+
|
|
14
|
+
def discord_api(method, path, token:, body: nil, log_errors: true)
|
|
15
|
+
uri = URI("#{DISCORD_API_BASE}#{path}")
|
|
16
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
17
|
+
http.use_ssl = true
|
|
18
|
+
|
|
19
|
+
req = case method
|
|
20
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
21
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
22
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
23
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
req["Authorization"] = "Bot #{token}"
|
|
27
|
+
req["Content-Type"] = "application/json"
|
|
28
|
+
req.body = body.to_json if body
|
|
29
|
+
|
|
30
|
+
response = http.request(req)
|
|
31
|
+
|
|
32
|
+
if response.code.to_i == 429
|
|
33
|
+
retry_after = JSON.parse(response.body)["retry_after"] || 1
|
|
34
|
+
LOG.warn "Discord rate limited, waiting #{retry_after}s"
|
|
35
|
+
sleep retry_after
|
|
36
|
+
return discord_api(method, path, token: token, body: body, log_errors: log_errors)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors
|
|
40
|
+
|
|
41
|
+
JSON.parse(response.body) unless response.body.nil? || response.body.empty?
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
LOG.error "Discord API error (#{method} #{path}): #{e.message}" if log_errors
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Channel & Message Operations ---
|
|
48
|
+
|
|
49
|
+
def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
|
|
50
|
+
messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
|
|
51
|
+
|
|
52
|
+
all_messages = messages.is_a?(Array) ? messages : []
|
|
53
|
+
|
|
54
|
+
# If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21).
|
|
55
|
+
# These messages have no content but point to the original message via referenced_message.
|
|
56
|
+
# We need to include that original message for full context.
|
|
57
|
+
if all_messages.any?
|
|
58
|
+
oldest = all_messages.last # API returns newest-first
|
|
59
|
+
all_messages << oldest["referenced_message"] if oldest && oldest["type"] == 21 && oldest["referenced_message"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return "" if all_messages.empty?
|
|
63
|
+
|
|
64
|
+
# Messages come newest-first from the API, reverse for chronological order
|
|
65
|
+
lines = all_messages.reverse.filter_map do |msg|
|
|
66
|
+
author = msg.dig("author", "username") || "unknown"
|
|
67
|
+
content = msg["content"]&.strip || ""
|
|
68
|
+
next if content.empty?
|
|
69
|
+
|
|
70
|
+
"#{author}: #{content}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return "" if lines.empty?
|
|
74
|
+
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
LOG.warn "Failed to fetch channel history: #{e.message}"
|
|
78
|
+
""
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_channel_info(channel_id, token:)
|
|
82
|
+
discord_api(:get, "/channels/#{channel_id}", token: token)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
|
|
86
|
+
discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fetch_guild_member(guild_id, user_id, token:)
|
|
90
|
+
discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Messaging ---
|
|
94
|
+
|
|
95
|
+
def send_discord_message(channel_id, content, token:, reply_to: nil)
|
|
96
|
+
body = { content: content }
|
|
97
|
+
body[:message_reference] = { message_id: reply_to } if reply_to
|
|
98
|
+
result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body)
|
|
99
|
+
if result && result["id"]
|
|
100
|
+
LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}"
|
|
101
|
+
else
|
|
102
|
+
LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
|
|
103
|
+
end
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def send_long_discord_message(channel_id, content, token:, reply_to: nil)
|
|
108
|
+
if content.length <= 2000
|
|
109
|
+
send_discord_message(channel_id, content, token: token, reply_to: reply_to)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
chunks = []
|
|
114
|
+
remaining = content
|
|
115
|
+
while remaining.length.positive?
|
|
116
|
+
if remaining.length <= 2000
|
|
117
|
+
chunks << remaining
|
|
118
|
+
remaining = ""
|
|
119
|
+
else
|
|
120
|
+
split_at = remaining.rindex("\n", 1990) || 1990
|
|
121
|
+
chunks << remaining[0...split_at]
|
|
122
|
+
remaining = remaining[split_at..].lstrip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
chunks.each_with_index do |chunk, i|
|
|
127
|
+
send_discord_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
|
|
128
|
+
sleep 0.5
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def send_discord_typing(channel_id, token:)
|
|
133
|
+
discord_api(:post, "/channels/#{channel_id}/typing", token: token)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Reactions ---
|
|
137
|
+
|
|
138
|
+
def add_discord_reaction(channel_id, message_id, emoji, token:)
|
|
139
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
140
|
+
discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def remove_discord_reaction(channel_id, message_id, emoji, token:)
|
|
144
|
+
encoded = URI.encode_www_form_component(emoji)
|
|
145
|
+
discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# --- Threads & Forums ---
|
|
149
|
+
|
|
150
|
+
def create_discord_thread(channel_id, message_id, name:, token:)
|
|
151
|
+
thread_name = name.length > 100 ? "#{name[0..96]}..." : name
|
|
152
|
+
discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
|
|
153
|
+
name: thread_name,
|
|
154
|
+
auto_archive_duration: 1440
|
|
155
|
+
})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def forum_channel?(channel_id, token:)
|
|
159
|
+
info = fetch_channel_info(channel_id, token: token)
|
|
160
|
+
info && info["type"] == 15
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def find_latest_forum_thread(channel_id, token:)
|
|
164
|
+
channel_info = fetch_channel_info(channel_id, token: token)
|
|
165
|
+
return nil unless channel_info && channel_info["guild_id"]
|
|
166
|
+
|
|
167
|
+
guild_id = channel_info["guild_id"]
|
|
168
|
+
result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token)
|
|
169
|
+
return nil unless result && result["threads"]
|
|
170
|
+
|
|
171
|
+
forum_threads = result["threads"]
|
|
172
|
+
.select { |t| t["parent_id"] == channel_id }
|
|
173
|
+
.sort_by { |t| t["id"].to_i }
|
|
174
|
+
.reverse
|
|
175
|
+
|
|
176
|
+
return nil if forum_threads.empty?
|
|
177
|
+
|
|
178
|
+
latest = forum_threads.first
|
|
179
|
+
LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}"
|
|
180
|
+
latest
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def create_forum_post(channel_id, title:, content:, token:)
|
|
184
|
+
thread_name = title.length > 100 ? "#{title[0..96]}..." : title
|
|
185
|
+
result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: {
|
|
186
|
+
name: thread_name,
|
|
187
|
+
message: { content: content },
|
|
188
|
+
auto_archive_duration: 1440
|
|
189
|
+
})
|
|
190
|
+
if result && result["id"]
|
|
191
|
+
LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}"
|
|
192
|
+
else
|
|
193
|
+
LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
|
|
194
|
+
end
|
|
195
|
+
result
|
|
196
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord configuration and shared state.
|
|
4
|
+
#
|
|
5
|
+
# Constants, config loading, and thread map persistence used across
|
|
6
|
+
# all Discord sub-modules.
|
|
7
|
+
|
|
8
|
+
DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
|
|
9
|
+
|
|
10
|
+
# Discord thread worktree map: tracks worktrees created for Discord thread conversations.
|
|
11
|
+
# Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
|
|
12
|
+
# Persisted to disk so sessions survive restarts.
|
|
13
|
+
DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
|
|
14
|
+
DISCORD_THREAD_MAP_MUTEX = Mutex.new
|
|
15
|
+
|
|
16
|
+
def load_discord_config
|
|
17
|
+
default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
18
|
+
return default unless File.exist?(DISCORD_CONFIG_FILE)
|
|
19
|
+
|
|
20
|
+
JSON.parse(File.read(DISCORD_CONFIG_FILE))
|
|
21
|
+
rescue JSON::ParserError => e
|
|
22
|
+
LOG.error "Failed to parse discord config: #{e.message}"
|
|
23
|
+
default
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
DISCORD_CONFIG = load_discord_config
|
|
27
|
+
|
|
28
|
+
def reload_discord_config!
|
|
29
|
+
DISCORD_CONFIG.replace(load_discord_config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Collect all agent Discord bot tokens from the registry.
|
|
33
|
+
# Returns { "galen" => "token...", "glados" => "token..." }
|
|
34
|
+
def discord_bot_tokens
|
|
35
|
+
tokens = {}
|
|
36
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
37
|
+
next unless entry.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
|
|
40
|
+
next unless token
|
|
41
|
+
|
|
42
|
+
tokens[key] = token
|
|
43
|
+
end
|
|
44
|
+
tokens
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Thread Map Persistence ---
|
|
48
|
+
|
|
49
|
+
def load_discord_thread_map
|
|
50
|
+
return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
|
|
51
|
+
|
|
52
|
+
JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def save_discord_thread_map(map)
|
|
58
|
+
File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# --- Channel/Project Routing ---
|
|
62
|
+
|
|
63
|
+
def find_project_for_discord_channel(channel_id)
|
|
64
|
+
mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id)
|
|
65
|
+
|
|
66
|
+
unless mapping
|
|
67
|
+
default_project = DISCORD_CONFIG["default_project"]
|
|
68
|
+
mapping = { "project" => default_project } if default_project
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return nil unless mapping
|
|
72
|
+
|
|
73
|
+
project_key = mapping["project"]
|
|
74
|
+
project_config = PROJECTS[project_key]
|
|
75
|
+
return nil unless project_config
|
|
76
|
+
|
|
77
|
+
[project_key, project_config, mapping]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Find the root message for a conversation thread.
|
|
81
|
+
# Walks back through message_reference chain to find the original message.
|
|
82
|
+
def find_root_message(message, channel_id, bot_token)
|
|
83
|
+
current_msg = message
|
|
84
|
+
visited = Set.new
|
|
85
|
+
max_depth = 20
|
|
86
|
+
walked = false
|
|
87
|
+
|
|
88
|
+
max_depth.times do
|
|
89
|
+
msg_id = current_msg["id"]
|
|
90
|
+
return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id)
|
|
91
|
+
|
|
92
|
+
visited << msg_id
|
|
93
|
+
|
|
94
|
+
ref = current_msg["message_reference"]
|
|
95
|
+
break unless ref
|
|
96
|
+
|
|
97
|
+
ref_msg_id = ref["message_id"]
|
|
98
|
+
ref_channel = ref["channel_id"] || channel_id
|
|
99
|
+
break unless ref_msg_id
|
|
100
|
+
|
|
101
|
+
referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
|
|
102
|
+
break unless referenced
|
|
103
|
+
|
|
104
|
+
current_msg = referenced
|
|
105
|
+
walked = true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
id: current_msg["id"],
|
|
110
|
+
content: walked ? current_msg["content"]&.strip : nil,
|
|
111
|
+
author: walked ? current_msg.dig("author", "username") : nil
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Build a Discord mention roster so the agent can @mention people and other bots.
|
|
116
|
+
def discord_mention_roster
|
|
117
|
+
lines = []
|
|
118
|
+
|
|
119
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
120
|
+
DISCORD_BOTS.each do |agent_key, info|
|
|
121
|
+
next unless info[:user_id]
|
|
122
|
+
|
|
123
|
+
display = fizzy_display_name(agent_key) || agent_key.capitalize
|
|
124
|
+
lines << " - #{display}: `<@#{info[:user_id]}>`"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
user_mappings = DISCORD_CONFIG["user_mappings"] || {}
|
|
129
|
+
user_mappings.each do |name, discord_id|
|
|
130
|
+
lines << " - #{name}: `<@#{discord_id}>`"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
lines.join("\n")
|
|
134
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discord draft delivery system.
|
|
4
|
+
#
|
|
5
|
+
# Response files land in draft/ with a .meta.json sidecar containing delivery info.
|
|
6
|
+
# After successful posting, both files move to posted/.
|
|
7
|
+
# A poller thread recovers orphaned drafts (e.g. after a server restart).
|
|
8
|
+
|
|
9
|
+
DISCORD_DRAFT_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "draft")
|
|
10
|
+
DISCORD_POSTED_DIR = File.join(BRAINIAC_DIR, "tmp", "discord", "posted")
|
|
11
|
+
FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
|
|
12
|
+
FileUtils.mkdir_p(DISCORD_POSTED_DIR)
|
|
13
|
+
|
|
14
|
+
# Shared thread map: when multiple agents are mentioned in the same message,
|
|
15
|
+
# the first to deliver creates the thread and stores its ID here so the rest
|
|
16
|
+
# post into the same thread instead of creating duplicates.
|
|
17
|
+
DISCORD_SHARED_THREADS = {}
|
|
18
|
+
DISCORD_SHARED_THREADS_MUTEX = Mutex.new
|
|
19
|
+
|
|
20
|
+
# Shared logic for posting a draft response file to Discord and moving it to posted/.
|
|
21
|
+
# Used by both the monitoring thread (happy path) and the poller (recovery path).
|
|
22
|
+
def deliver_discord_draft(response_file, meta_file)
|
|
23
|
+
return false unless File.exist?(meta_file)
|
|
24
|
+
|
|
25
|
+
lock_file = "#{meta_file}.lock"
|
|
26
|
+
begin
|
|
27
|
+
File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # rubocop:disable Lint/EmptyBlock
|
|
28
|
+
rescue Errno::EEXIST
|
|
29
|
+
return false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
meta = JSON.parse(File.read(meta_file))
|
|
33
|
+
bot_token = resolve_bot_token(meta["agent_key"], meta["agent_name"])
|
|
34
|
+
|
|
35
|
+
unless bot_token
|
|
36
|
+
FileUtils.rm_f(lock_file)
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless File.exist?(response_file)
|
|
41
|
+
FileUtils.rm_f(lock_file)
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
deliver_response_content(response_file, meta, bot_token)
|
|
46
|
+
archive_delivered_draft(response_file, meta_file, lock_file, meta["agent_name"])
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
|
|
50
|
+
File.delete(lock_file) if lock_file && File.exist?(lock_file)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve_bot_token(agent_key, agent_name)
|
|
55
|
+
token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
|
|
56
|
+
token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
|
|
57
|
+
LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" unless token
|
|
58
|
+
token
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deliver_response_content(response_file, meta, bot_token)
|
|
62
|
+
channel_id = meta["channel_id"]
|
|
63
|
+
message_id = meta["message_id"]
|
|
64
|
+
agent_key = meta["agent_key"]
|
|
65
|
+
agent_name = meta["agent_name"]
|
|
66
|
+
response = File.read(response_file).strip
|
|
67
|
+
|
|
68
|
+
if response.empty?
|
|
69
|
+
add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
|
|
70
|
+
send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
|
|
71
|
+
elsif meta["is_dm"] || meta["is_thread"] || message_id.nil?
|
|
72
|
+
deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
73
|
+
else
|
|
74
|
+
deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, meta["clean_content"] || "", bot_token)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def archive_delivered_draft(response_file, meta_file, lock_file, agent_name)
|
|
79
|
+
FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, File.basename(response_file))) if File.exist?(response_file)
|
|
80
|
+
FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, File.basename(meta_file)))
|
|
81
|
+
FileUtils.rm_f(lock_file)
|
|
82
|
+
LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Delivery to DMs, threads, and forum channels ---
|
|
86
|
+
|
|
87
|
+
def deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
88
|
+
if message_id.nil? && forum_channel?(channel_id, token: bot_token)
|
|
89
|
+
title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
|
|
90
|
+
if meta["forum_reply_to_latest"]
|
|
91
|
+
latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
|
|
92
|
+
if latest_thread
|
|
93
|
+
send_long_discord_message(latest_thread["id"], response, token: bot_token)
|
|
94
|
+
else
|
|
95
|
+
LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
|
|
96
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
97
|
+
end
|
|
98
|
+
else
|
|
99
|
+
create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
send_long_discord_message(channel_id, response, token: bot_token)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Delivery to channel messages (creates or joins a thread) ---
|
|
107
|
+
|
|
108
|
+
def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, clean_content, bot_token)
|
|
109
|
+
thread_id = nil
|
|
110
|
+
created_thread = false
|
|
111
|
+
|
|
112
|
+
DISCORD_SHARED_THREADS_MUTEX.synchronize do
|
|
113
|
+
thread_id = DISCORD_SHARED_THREADS[message_id]
|
|
114
|
+
|
|
115
|
+
# Tier 2: Ask Discord if a thread already exists on this message.
|
|
116
|
+
unless thread_id
|
|
117
|
+
original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
|
|
118
|
+
if original_msg&.dig("thread", "id")
|
|
119
|
+
thread_id = original_msg["thread"]["id"]
|
|
120
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
121
|
+
LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Tier 3: No thread exists anywhere — create one.
|
|
126
|
+
unless thread_id
|
|
127
|
+
display_name = fizzy_display_name(agent_key)
|
|
128
|
+
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
129
|
+
if thread && thread["id"]
|
|
130
|
+
thread_id = thread["id"]
|
|
131
|
+
DISCORD_SHARED_THREADS[message_id] = thread_id
|
|
132
|
+
created_thread = true
|
|
133
|
+
LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if thread_id
|
|
139
|
+
LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
|
|
140
|
+
|
|
141
|
+
# Propagate dispatch depth to the thread
|
|
142
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
143
|
+
thread_depth_key = "discord-#{thread_id}"
|
|
144
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
145
|
+
unless AGENT_DISPATCH_DEPTH[thread_depth_key]
|
|
146
|
+
if parent_info
|
|
147
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
148
|
+
LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
|
|
149
|
+
else
|
|
150
|
+
record_human_comment(thread_depth_key)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
send_discord_typing(thread_id, token: bot_token)
|
|
155
|
+
send_long_discord_message(thread_id, response, token: bot_token)
|
|
156
|
+
else
|
|
157
|
+
LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
|
|
158
|
+
send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- Draft Poller ---
|
|
163
|
+
|
|
164
|
+
DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
|
|
165
|
+
DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
|
|
166
|
+
|
|
167
|
+
def start_discord_draft_poller
|
|
168
|
+
Thread.new do
|
|
169
|
+
LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
|
|
170
|
+
loop do
|
|
171
|
+
sleep DISCORD_DRAFT_POLLER_INTERVAL
|
|
172
|
+
begin
|
|
173
|
+
# Clean up stale lock files (older than 60s) left by crashed deliveries
|
|
174
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
|
|
175
|
+
File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
|
|
179
|
+
next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
|
|
180
|
+
|
|
181
|
+
response_file = if meta_file.end_with?(".md.meta.json")
|
|
182
|
+
meta_file.sub(".md.meta.json", ".md")
|
|
183
|
+
else
|
|
184
|
+
meta_file.sub(".meta.json", ".md")
|
|
185
|
+
end
|
|
186
|
+
next unless File.exist?(response_file)
|
|
187
|
+
|
|
188
|
+
LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
|
|
189
|
+
deliver_discord_draft(response_file, meta_file)
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
LOG.error "[Discord] Draft poller error: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|