brainiac-discord 0.0.1
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 +7 -0
- data/README.md +110 -0
- data/lib/brainiac/plugins/discord/api.rb +286 -0
- data/lib/brainiac/plugins/discord/cli.rb +274 -0
- data/lib/brainiac/plugins/discord/config.rb +118 -0
- data/lib/brainiac/plugins/discord/delivery.rb +215 -0
- data/lib/brainiac/plugins/discord/gateway.rb +295 -0
- data/lib/brainiac/plugins/discord/message.rb +863 -0
- data/lib/brainiac/plugins/discord/metadata.rb +28 -0
- data/lib/brainiac/plugins/discord/prompts.rb +109 -0
- data/lib/brainiac/plugins/discord/reactions.rb +206 -0
- data/lib/brainiac/plugins/discord/version.rb +9 -0
- data/lib/brainiac/plugins/discord.rb +122 -0
- data/lib/brainiac_discord.rb +4 -0
- metadata +139 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Discord
|
|
6
|
+
# Discord configuration — loads ~/.brainiac/discord.json.
|
|
7
|
+
# Provides channel mappings, authorization, user mappings, and project routing.
|
|
8
|
+
module Config
|
|
9
|
+
DISCORD_CONFIG_FILE = File.join(
|
|
10
|
+
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
|
|
11
|
+
"discord.json"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
DISCORD_THREAD_MAP_FILE = File.join(
|
|
15
|
+
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
|
|
16
|
+
"discord_thread_map.json"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@config = {}
|
|
20
|
+
@thread_map_mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
attr_reader :config, :thread_map_mutex
|
|
24
|
+
|
|
25
|
+
def load!
|
|
26
|
+
@config = if File.exist?(DISCORD_CONFIG_FILE)
|
|
27
|
+
JSON.parse(File.read(DISCORD_CONFIG_FILE))
|
|
28
|
+
else
|
|
29
|
+
{ "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
30
|
+
end
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
LOG.error "[Discord] Failed to parse discord.json: #{e.message}" if defined?(LOG)
|
|
33
|
+
@config = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reload!
|
|
37
|
+
load!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current
|
|
41
|
+
@config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_project
|
|
45
|
+
@config["default_project"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def owner_discord_id
|
|
49
|
+
@config["owner_discord_id"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dashboard_token
|
|
53
|
+
@config["dashboard_token"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def giphy_api_key
|
|
57
|
+
@config["giphy_api_key"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def channel_mappings
|
|
61
|
+
@config["channel_mappings"] || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def user_mappings
|
|
65
|
+
@config["user_mappings"] || {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def authorized_role_ids
|
|
69
|
+
if @config["role_mappings"]
|
|
70
|
+
@config["role_mappings"].values
|
|
71
|
+
elsif @config["authorized_role_ids"].is_a?(Hash)
|
|
72
|
+
@config["authorized_role_ids"].values
|
|
73
|
+
else
|
|
74
|
+
@config["authorized_role_ids"] || []
|
|
75
|
+
end.map(&:to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authorized_user_ids
|
|
79
|
+
@config["authorized_user_ids"] || []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Find the project for a given Discord channel.
|
|
83
|
+
# Returns [project_key, project_config, mapping] or nil.
|
|
84
|
+
def find_project_for_channel(channel_id)
|
|
85
|
+
mapping = channel_mappings[channel_id]
|
|
86
|
+
|
|
87
|
+
unless mapping
|
|
88
|
+
default = default_project
|
|
89
|
+
mapping = { "project" => default } if default
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return nil unless mapping
|
|
93
|
+
|
|
94
|
+
project_key = mapping["project"]
|
|
95
|
+
project_config = PROJECTS[project_key]
|
|
96
|
+
return nil unless project_config
|
|
97
|
+
|
|
98
|
+
[project_key, project_config, mapping]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- Thread Map Persistence ---
|
|
102
|
+
|
|
103
|
+
def load_thread_map
|
|
104
|
+
return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
|
|
105
|
+
|
|
106
|
+
JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def save_thread_map(map)
|
|
112
|
+
File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Discord
|
|
8
|
+
# Discord draft delivery system.
|
|
9
|
+
#
|
|
10
|
+
# Response files land in draft/ with a .meta.json sidecar containing delivery info.
|
|
11
|
+
# After successful posting, both files move to posted/.
|
|
12
|
+
# A poller thread recovers orphaned drafts (e.g. after a server restart).
|
|
13
|
+
module Delivery
|
|
14
|
+
BRAINIAC_DIR_PATH = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
15
|
+
DRAFT_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "draft")
|
|
16
|
+
POSTED_DIR = File.join(BRAINIAC_DIR_PATH, "tmp", "discord", "posted")
|
|
17
|
+
|
|
18
|
+
POLLER_INTERVAL = 5 # seconds
|
|
19
|
+
DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
|
|
20
|
+
|
|
21
|
+
# Shared thread map: when multiple agents are mentioned in the same message,
|
|
22
|
+
# the first to deliver creates the thread and stores its ID here so the rest
|
|
23
|
+
# post into the same thread instead of creating duplicates.
|
|
24
|
+
@shared_threads = {}
|
|
25
|
+
@shared_threads_mutex = Mutex.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :shared_threads, :shared_threads_mutex
|
|
29
|
+
|
|
30
|
+
def ensure_dirs!
|
|
31
|
+
FileUtils.mkdir_p(DRAFT_DIR)
|
|
32
|
+
FileUtils.mkdir_p(POSTED_DIR)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start_poller!
|
|
36
|
+
ensure_dirs!
|
|
37
|
+
Thread.new do
|
|
38
|
+
LOG.info "[Discord] Draft poller started, checking #{DRAFT_DIR} every #{POLLER_INTERVAL}s" if defined?(LOG)
|
|
39
|
+
loop do
|
|
40
|
+
sleep POLLER_INTERVAL
|
|
41
|
+
poll_drafts
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
LOG.error "[Discord] Draft poller error: #{e.message}" if defined?(LOG)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Shared logic for posting a draft response file to Discord and moving it to posted/.
|
|
49
|
+
def deliver_draft(response_file, meta_file)
|
|
50
|
+
return false unless File.exist?(meta_file)
|
|
51
|
+
|
|
52
|
+
lock_file = "#{meta_file}.lock"
|
|
53
|
+
begin
|
|
54
|
+
File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # rubocop:disable Lint/EmptyBlock
|
|
55
|
+
rescue Errno::EEXIST
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
meta = JSON.parse(File.read(meta_file))
|
|
60
|
+
bot_token = resolve_bot_token(meta["agent_key"], meta["agent_name"])
|
|
61
|
+
|
|
62
|
+
unless bot_token
|
|
63
|
+
FileUtils.rm_f(lock_file)
|
|
64
|
+
return false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless File.exist?(response_file)
|
|
68
|
+
FileUtils.rm_f(lock_file)
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
deliver_response_content(response_file, meta, bot_token)
|
|
73
|
+
archive_delivered(response_file, meta_file, lock_file, meta["agent_name"])
|
|
74
|
+
true
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}" if defined?(LOG)
|
|
77
|
+
File.delete(lock_file) if lock_file && File.exist?(lock_file)
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def poll_drafts
|
|
84
|
+
# Clean up stale lock files (older than 60s) left by crashed deliveries
|
|
85
|
+
Dir.glob(File.join(DRAFT_DIR, "*.lock")).each do |lock_file|
|
|
86
|
+
File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Dir.glob(File.join(DRAFT_DIR, "*.meta.json")).each do |meta_file|
|
|
90
|
+
next if (Time.now - File.mtime(meta_file)) < DRAFT_MIN_AGE
|
|
91
|
+
|
|
92
|
+
response_file = if meta_file.end_with?(".md.meta.json")
|
|
93
|
+
meta_file.sub(".md.meta.json", ".md")
|
|
94
|
+
else
|
|
95
|
+
meta_file.sub(".meta.json", ".md")
|
|
96
|
+
end
|
|
97
|
+
next unless File.exist?(response_file)
|
|
98
|
+
|
|
99
|
+
LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}" if defined?(LOG)
|
|
100
|
+
deliver_draft(response_file, meta_file)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def resolve_bot_token(agent_key, agent_name)
|
|
105
|
+
token = Gateway.bot_token(agent_key)
|
|
106
|
+
token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
|
|
107
|
+
LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" if !token && defined?(LOG)
|
|
108
|
+
token
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def deliver_response_content(response_file, meta, bot_token)
|
|
112
|
+
channel_id = meta["channel_id"]
|
|
113
|
+
message_id = meta["message_id"]
|
|
114
|
+
agent_key = meta["agent_key"]
|
|
115
|
+
agent_name = meta["agent_name"]
|
|
116
|
+
response = File.read(response_file).strip
|
|
117
|
+
|
|
118
|
+
if response.empty?
|
|
119
|
+
Api.add_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
|
|
120
|
+
Api.send_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
|
|
121
|
+
elsif meta["is_dm"] || meta["is_thread"] || message_id.nil?
|
|
122
|
+
deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
123
|
+
else
|
|
124
|
+
deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, meta["clean_content"] || "", bot_token)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def archive_delivered(response_file, meta_file, lock_file, agent_name)
|
|
129
|
+
FileUtils.mv(response_file, File.join(POSTED_DIR, File.basename(response_file))) if File.exist?(response_file)
|
|
130
|
+
FileUtils.mv(meta_file, File.join(POSTED_DIR, File.basename(meta_file)))
|
|
131
|
+
FileUtils.rm_f(lock_file)
|
|
132
|
+
LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/" if defined?(LOG)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def deliver_to_dm_or_forum(response, channel_id, message_id, agent_name, meta, bot_token)
|
|
136
|
+
if message_id.nil? && Api.forum_channel?(channel_id, token: bot_token)
|
|
137
|
+
title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
|
|
138
|
+
if meta["forum_reply_to_latest"]
|
|
139
|
+
latest_thread = Api.find_latest_forum_thread(channel_id, token: bot_token)
|
|
140
|
+
if latest_thread
|
|
141
|
+
Api.send_long_message(latest_thread["id"], response, token: bot_token)
|
|
142
|
+
else
|
|
143
|
+
LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post" if defined?(LOG)
|
|
144
|
+
Api.create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
Api.create_forum_post(channel_id, title: title, content: response, token: bot_token)
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
Api.send_long_message(channel_id, response, token: bot_token)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent_name, clean_content, bot_token)
|
|
155
|
+
thread_id = nil
|
|
156
|
+
created_thread = false
|
|
157
|
+
|
|
158
|
+
@shared_threads_mutex.synchronize do
|
|
159
|
+
thread_id = @shared_threads[message_id]
|
|
160
|
+
|
|
161
|
+
unless thread_id
|
|
162
|
+
original_msg = Api.request(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
|
|
163
|
+
if original_msg&.dig("thread", "id")
|
|
164
|
+
thread_id = original_msg["thread"]["id"]
|
|
165
|
+
@shared_threads[message_id] = thread_id
|
|
166
|
+
LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API" if defined?(LOG)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
unless thread_id
|
|
171
|
+
display_name = agent_display_name(agent_key)
|
|
172
|
+
thread = Api.create_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
173
|
+
if thread && thread["id"]
|
|
174
|
+
thread_id = thread["id"]
|
|
175
|
+
@shared_threads[message_id] = thread_id
|
|
176
|
+
created_thread = true
|
|
177
|
+
LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}" if defined?(LOG)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if thread_id
|
|
183
|
+
LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" if !created_thread && defined?(LOG)
|
|
184
|
+
|
|
185
|
+
# Propagate dispatch depth to the thread
|
|
186
|
+
propagate_dispatch_depth(channel_id, thread_id, agent_name)
|
|
187
|
+
|
|
188
|
+
Api.send_typing(thread_id, token: bot_token)
|
|
189
|
+
Api.send_long_message(thread_id, response, token: bot_token)
|
|
190
|
+
else
|
|
191
|
+
LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply" if defined?(LOG)
|
|
192
|
+
Api.send_long_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def propagate_dispatch_depth(channel_id, thread_id, agent_name)
|
|
197
|
+
return unless defined?(AGENT_DISPATCH_DEPTH) && respond_to?(:record_human_comment)
|
|
198
|
+
|
|
199
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
200
|
+
thread_depth_key = "discord-#{thread_id}"
|
|
201
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
202
|
+
return if AGENT_DISPATCH_DEPTH[thread_depth_key]
|
|
203
|
+
|
|
204
|
+
if parent_info
|
|
205
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
206
|
+
LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}" if defined?(LOG)
|
|
207
|
+
else
|
|
208
|
+
record_human_comment(thread_depth_key)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket-client-simple"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Discord
|
|
8
|
+
# Discord WebSocket gateway connections.
|
|
9
|
+
#
|
|
10
|
+
# Each agent with a DISCORD_BOT_TOKEN gets its own persistent WebSocket
|
|
11
|
+
# connection. The gateway dispatches MESSAGE_CREATE, MESSAGE_UPDATE,
|
|
12
|
+
# and MESSAGE_REACTION_ADD events to handler functions.
|
|
13
|
+
module Gateway
|
|
14
|
+
GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
15
|
+
|
|
16
|
+
# Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
|
|
17
|
+
@bots = {}
|
|
18
|
+
@bots_mutex = Mutex.new
|
|
19
|
+
@all_ready_logged = false
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
attr_reader :bots, :bots_mutex
|
|
23
|
+
|
|
24
|
+
def bot_count
|
|
25
|
+
@bots_mutex.synchronize { @bots.size }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Iterate over bots under mutex.
|
|
29
|
+
def each_bot(&)
|
|
30
|
+
@bots_mutex.synchronize do
|
|
31
|
+
@bots.each(&)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get a bot's token.
|
|
36
|
+
def bot_token(agent_key)
|
|
37
|
+
@bots_mutex.synchronize { @bots.dig(agent_key, :token) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get a bot's user_id.
|
|
41
|
+
def bot_user_id(agent_key)
|
|
42
|
+
@bots_mutex.synchronize { @bots.dig(agent_key, :user_id) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Collect all agent Discord bot tokens from the registry.
|
|
46
|
+
# Returns { "galen" => "token...", "glados" => "token..." }
|
|
47
|
+
def discord_bot_tokens
|
|
48
|
+
tokens = {}
|
|
49
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
50
|
+
next unless entry.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
|
|
53
|
+
next unless token
|
|
54
|
+
|
|
55
|
+
tokens[key] = token
|
|
56
|
+
end
|
|
57
|
+
tokens
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Start all per-agent Discord bots.
|
|
61
|
+
def start_all!
|
|
62
|
+
tokens = discord_bot_tokens
|
|
63
|
+
if tokens.empty?
|
|
64
|
+
LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled" if defined?(LOG)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}" if defined?(LOG)
|
|
69
|
+
|
|
70
|
+
@bots_mutex.synchronize do
|
|
71
|
+
tokens.each do |agent_key, token|
|
|
72
|
+
@bots[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
tokens.each do |agent_key, token|
|
|
77
|
+
start_gateway_for(agent_key, token)
|
|
78
|
+
sleep 1 # Stagger connections to avoid rate limits
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Summary of all bot statuses for the API endpoint.
|
|
83
|
+
def bots_status
|
|
84
|
+
@bots_mutex.synchronize do
|
|
85
|
+
@bots.transform_values do |info|
|
|
86
|
+
{ status: info[:status], user_id: info[:user_id] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Detect if a message author is a known bot (local or remote).
|
|
92
|
+
# Returns the agent_key of the sender, or nil if unknown.
|
|
93
|
+
def detect_sender_agent(author, current_agent_key)
|
|
94
|
+
sender_id = author["id"]
|
|
95
|
+
sender_agent_key = nil
|
|
96
|
+
|
|
97
|
+
@bots_mutex.synchronize do
|
|
98
|
+
@bots.each do |key, info|
|
|
99
|
+
if info[:user_id] == sender_id && key != current_agent_key
|
|
100
|
+
sender_agent_key = key
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unless sender_agent_key
|
|
107
|
+
Config.user_mappings.each do |name, discord_id|
|
|
108
|
+
if discord_id == sender_id
|
|
109
|
+
sender_agent_key = name.downcase
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if !sender_agent_key && defined?(LOG)
|
|
116
|
+
LOG.info "[Discord:#{current_agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sender_agent_key
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def start_gateway_for(agent_key, bot_token)
|
|
125
|
+
Thread.new do
|
|
126
|
+
agent_display = agent_display_name(agent_key) || agent_key.capitalize
|
|
127
|
+
bot_user_id = nil
|
|
128
|
+
|
|
129
|
+
loop do
|
|
130
|
+
bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
@bots_mutex.synchronize do
|
|
133
|
+
@bots[agent_key][:status] = "error" if @bots[agent_key]
|
|
134
|
+
end
|
|
135
|
+
LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..." if defined?(LOG)
|
|
136
|
+
sleep 5
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
|
|
142
|
+
@bots_mutex.synchronize do
|
|
143
|
+
@bots[agent_key] ||= {}
|
|
144
|
+
@bots[agent_key][:status] = "connecting"
|
|
145
|
+
@bots[agent_key][:token] = bot_token
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." if defined?(LOG) && LOG.respond_to?(:debug)
|
|
149
|
+
heartbeat_thread = nil
|
|
150
|
+
last_sequence = nil
|
|
151
|
+
ws = WebSocket::Client::Simple.connect(GATEWAY_URL)
|
|
152
|
+
|
|
153
|
+
ws.on :message do |msg|
|
|
154
|
+
next if msg.data.nil? || msg.data.empty?
|
|
155
|
+
|
|
156
|
+
payload = JSON.parse(msg.data)
|
|
157
|
+
last_sequence = payload["s"] if payload["s"]
|
|
158
|
+
heartbeat_thread, bot_user_id = Gateway.send(
|
|
159
|
+
:handle_gateway_op,
|
|
160
|
+
ws, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence
|
|
161
|
+
)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}" if defined?(LOG)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
ws.on :open do
|
|
167
|
+
LOG.debug "[Discord:#{agent_display}] WebSocket connected" if defined?(LOG) && LOG.respond_to?(:debug)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
ws.on :close do |_e|
|
|
171
|
+
@bots_mutex.synchronize do
|
|
172
|
+
@bots[agent_key][:status] = "disconnected" if @bots[agent_key]
|
|
173
|
+
end
|
|
174
|
+
LOG.warn "[Discord:#{agent_display}] WebSocket closed" if defined?(LOG)
|
|
175
|
+
heartbeat_thread&.kill
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
ws.on :error do |e|
|
|
179
|
+
LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}" if defined?(LOG)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
wait_for_disconnect(ws, agent_display)
|
|
183
|
+
bot_user_id
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def wait_for_disconnect(websocket, agent_display)
|
|
187
|
+
loop do
|
|
188
|
+
sleep 1
|
|
189
|
+
next if websocket.open?
|
|
190
|
+
|
|
191
|
+
LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..." if defined?(LOG)
|
|
192
|
+
sleep 5
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
|
|
198
|
+
op = payload["op"]
|
|
199
|
+
data = payload["d"]
|
|
200
|
+
|
|
201
|
+
case op
|
|
202
|
+
when 10
|
|
203
|
+
heartbeat_thread = start_heartbeat(websocket, data["heartbeat_interval"], agent_display, last_sequence)
|
|
204
|
+
send_identify(websocket, bot_token, agent_display)
|
|
205
|
+
when 0
|
|
206
|
+
bot_user_id = handle_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
|
|
207
|
+
when 1
|
|
208
|
+
websocket.send({ op: 1, d: last_sequence }.to_json)
|
|
209
|
+
when 7
|
|
210
|
+
LOG.info "[Discord:#{agent_display}] Reconnect requested" if defined?(LOG)
|
|
211
|
+
websocket.close
|
|
212
|
+
when 9
|
|
213
|
+
LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s" if defined?(LOG)
|
|
214
|
+
sleep 5
|
|
215
|
+
send_identify(websocket, bot_token, agent_display)
|
|
216
|
+
when 11 then nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
[heartbeat_thread, bot_user_id]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def start_heartbeat(websocket, interval_ms, agent_display, last_sequence)
|
|
223
|
+
LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{interval_ms}ms" if defined?(LOG) && LOG.respond_to?(:debug)
|
|
224
|
+
Thread.new do
|
|
225
|
+
loop do
|
|
226
|
+
sleep(interval_ms / 1000.0)
|
|
227
|
+
websocket.send({ op: 1, d: last_sequence }.to_json)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def send_identify(websocket, bot_token, agent_display)
|
|
233
|
+
LOG.debug "[Discord:#{agent_display}] Sending IDENTIFY" if defined?(LOG) && LOG.respond_to?(:debug)
|
|
234
|
+
websocket.send({
|
|
235
|
+
op: 2,
|
|
236
|
+
d: {
|
|
237
|
+
token: bot_token,
|
|
238
|
+
intents: 46_593,
|
|
239
|
+
properties: { os: RUBY_PLATFORM, browser: "brainiac", device: "brainiac" }
|
|
240
|
+
}
|
|
241
|
+
}.to_json)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_dispatch(payload, data, agent_key, agent_display, bot_token, bot_user_id)
|
|
245
|
+
case payload["t"]
|
|
246
|
+
when "READY"
|
|
247
|
+
bot_user_id = data.dig("user", "id")
|
|
248
|
+
mark_bot_ready(agent_key, agent_display, bot_user_id, data)
|
|
249
|
+
when "MESSAGE_CREATE"
|
|
250
|
+
Thread.new do
|
|
251
|
+
Message.handle(data, agent_key, bot_token, bot_user_id)
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
254
|
+
end
|
|
255
|
+
when "MESSAGE_UPDATE"
|
|
256
|
+
if data["edited_timestamp"]
|
|
257
|
+
Thread.new do
|
|
258
|
+
Message.handle(data, agent_key, bot_token, bot_user_id)
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
if defined?(LOG)
|
|
261
|
+
LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
when "MESSAGE_REACTION_ADD"
|
|
266
|
+
Thread.new do
|
|
267
|
+
Reactions.handle(data, agent_key, bot_token, bot_user_id)
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
bot_user_id
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def mark_bot_ready(agent_key, agent_display, bot_user_id, data)
|
|
277
|
+
@bots_mutex.synchronize do
|
|
278
|
+
@bots[agent_key][:user_id] = bot_user_id
|
|
279
|
+
@bots[agent_key][:status] = "ready"
|
|
280
|
+
end
|
|
281
|
+
guild_count = data["guilds"]&.size || 0
|
|
282
|
+
LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})" if defined?(LOG)
|
|
283
|
+
|
|
284
|
+
@bots_mutex.synchronize do
|
|
285
|
+
if !@all_ready_logged && @bots.all? { |_, info| info[:status] == "ready" }
|
|
286
|
+
@all_ready_logged = true
|
|
287
|
+
LOG.info "[Discord] All bots connected." if defined?(LOG)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|