brainiac 0.0.9 → 0.0.11

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.
@@ -1,196 +0,0 @@
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
@@ -1,134 +0,0 @@
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 = agent_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
@@ -1,196 +0,0 @@
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 = agent_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