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,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lightweight metadata for brainiac-discord.
|
|
4
|
+
# Loaded by `brainiac help` without pulling in the full plugin runtime.
|
|
5
|
+
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module Brainiac
|
|
9
|
+
module Plugins
|
|
10
|
+
module Discord
|
|
11
|
+
# Returns true if Discord has at least one bot token configured.
|
|
12
|
+
def self.configured?
|
|
13
|
+
registry_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
|
|
14
|
+
return false unless File.exist?(registry_file)
|
|
15
|
+
|
|
16
|
+
registry = JSON.parse(File.read(registry_file))
|
|
17
|
+
registry.any? { |_k, v| v.is_a?(Hash) && v.dig("env", "DISCORD_BOT_TOKEN") }
|
|
18
|
+
rescue StandardError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Help text shown in `brainiac help` when the plugin is installed.
|
|
23
|
+
def self.help_text
|
|
24
|
+
" brainiac discord <command> Manage Discord bots (config, token, status, map)"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Discord
|
|
6
|
+
# Discord prompt templates.
|
|
7
|
+
#
|
|
8
|
+
# CHANNEL — Discord-specific rules prepended to every Discord session.
|
|
9
|
+
# SITUATION — The standard Discord dispatch template with conversation context.
|
|
10
|
+
module Prompts
|
|
11
|
+
CHANNEL = <<~PROMPT
|
|
12
|
+
## Discord Channel Rules
|
|
13
|
+
|
|
14
|
+
### Mentions
|
|
15
|
+
Discord does NOT support plain-text @mentions. Writing `@Galen` renders as plain text.
|
|
16
|
+
To actually mention someone, use the `<@USER_ID>` format. Here are the known IDs:
|
|
17
|
+
{{DISCORD_MENTION_ROSTER}}
|
|
18
|
+
|
|
19
|
+
If you need to mention someone not on this list, just write their name without the @ symbol.
|
|
20
|
+
Do NOT @mention other agent bots unless the user explicitly asks you to bring them into the conversation.
|
|
21
|
+
Mentioning another agent triggers an automated dispatch — doing it casually can cause loops.
|
|
22
|
+
|
|
23
|
+
### Formatting
|
|
24
|
+
Do NOT use HTML formatting. Use plain text or Discord markdown:
|
|
25
|
+
- ```code blocks``` for code
|
|
26
|
+
- **bold** for emphasis
|
|
27
|
+
- *italic* for softer emphasis
|
|
28
|
+
- > quotes for referencing
|
|
29
|
+
|
|
30
|
+
### Response Delivery
|
|
31
|
+
You MUST write your response to a file at `{{RESPONSE_FILE}}`.
|
|
32
|
+
Do NOT respond via stdout — your response will only be delivered if written to this file.
|
|
33
|
+
Keep it conversational and concise — Discord messages have a 2000 char limit
|
|
34
|
+
per message, though long responses will be split automatically.
|
|
35
|
+
|
|
36
|
+
### Scope
|
|
37
|
+
This is a conversational interaction — no card, no PR. You're here to answer questions,
|
|
38
|
+
discuss code, share knowledge, or help with whatever the user needs.
|
|
39
|
+
|
|
40
|
+
**Detect user intent:**
|
|
41
|
+
- If they're asking you to **implement, fix, build, update, or change** something → do the work
|
|
42
|
+
- If they're asking questions, discussing ideas, or seeking advice → respond conversationally
|
|
43
|
+
|
|
44
|
+
**When doing implementation work:**
|
|
45
|
+
1. Create a worktree branching from `origin/main` (or the default branch shown in Project Context):
|
|
46
|
+
`git worktree add -b discord-<topic>-<timestamp> ../<repo>--discord-<topic>-<timestamp> origin/main`
|
|
47
|
+
2. `cd` into the new worktree directory
|
|
48
|
+
3. Make the changes, test if applicable
|
|
49
|
+
4. Commit with a clear message
|
|
50
|
+
5. Push the branch
|
|
51
|
+
6. Summarize what you did in your response file
|
|
52
|
+
7. If it's substantial or needs review, mention opening a PR (but don't create it unless asked)
|
|
53
|
+
|
|
54
|
+
**When responding conversationally:**
|
|
55
|
+
- Answer questions about the codebase, architecture, conventions
|
|
56
|
+
- Search your brain (knowledge + persona) for relevant context
|
|
57
|
+
- Read files from registered project repos to investigate questions
|
|
58
|
+
- Update your knowledge or persona files if the conversation warrants it
|
|
59
|
+
|
|
60
|
+
### GIFs (optional)
|
|
61
|
+
You can optionally include a GIF in your Discord response to add personality.
|
|
62
|
+
To find one, search the local GIF API:
|
|
63
|
+
```
|
|
64
|
+
curl -s "http://localhost:4567/api/gif?q=your+search+terms"
|
|
65
|
+
```
|
|
66
|
+
This returns JSON with a `results` array. Each result has a `url` field — paste that
|
|
67
|
+
URL on its own line in your response and Discord will auto-embed it as an animated GIF.
|
|
68
|
+
|
|
69
|
+
**Guidelines:**
|
|
70
|
+
- GIFs should be RARE — include one in roughly 15% of responses, not more
|
|
71
|
+
- Default to NO GIF. Only include one when the moment is a genuine zinger — a perfectly landed joke, a dramatic reveal, a celebration that demands visual punctuation, or a response so good it needs the exclamation point of a GIF
|
|
72
|
+
- Skip GIFs for routine answers, technical implementation work, status updates, or when the tone doesn't call for one
|
|
73
|
+
- Match the GIF to the emotional tone — celebration, sarcasm, emphasis, humor
|
|
74
|
+
- Surprise is good — pick GIFs that are unexpected or perfectly timed, not generic
|
|
75
|
+
- Pick the most relevant result, not just the first one
|
|
76
|
+
- If the API returns no results or errors, just skip the GIF — don't mention it
|
|
77
|
+
|
|
78
|
+
### Thread Memory (CRITICAL for long conversations)
|
|
79
|
+
Discord threads drift — your context window only shows recent messages, not the full history.
|
|
80
|
+
When writing your memory file for a Discord thread session, you MUST include:
|
|
81
|
+
- The original question/topic that started the thread (from "Original Message" above or your prior memory)
|
|
82
|
+
- A condensed summary of ALL topics discussed so far, not just this session
|
|
83
|
+
- Any topic shifts that occurred — what changed and why
|
|
84
|
+
- The current topic/focus as of this session
|
|
85
|
+
This is the ONLY way future sessions will know what happened in the middle of the conversation.
|
|
86
|
+
|
|
87
|
+
PROMPT
|
|
88
|
+
|
|
89
|
+
SITUATION = <<~'PROMPT'
|
|
90
|
+
## Context
|
|
91
|
+
|
|
92
|
+
**From:** {{DISCORD_USER}} in #{{CHANNEL_NAME}}
|
|
93
|
+
{{REPLY_CONTEXT}}**Message:**
|
|
94
|
+
{{MESSAGE_BODY}}
|
|
95
|
+
|
|
96
|
+
{{THREAD_ROOT_CONTEXT}}### Recent Channel History
|
|
97
|
+
These are the messages immediately before the one above, for conversational context:
|
|
98
|
+
```
|
|
99
|
+
{{CHANNEL_HISTORY}}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
{{PROJECT_CONTEXT}}
|
|
103
|
+
|
|
104
|
+
**IMPORTANT: Write your response to `{{RESPONSE_FILE}}`. Do NOT reply via stdout.**
|
|
105
|
+
PROMPT
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Discord
|
|
6
|
+
# Discord reaction handler.
|
|
7
|
+
#
|
|
8
|
+
# Handles MESSAGE_REACTION_ADD events:
|
|
9
|
+
# - ❌ to cancel an active agent session
|
|
10
|
+
# - ❔/❓ to peek at the agent's thinking (last 10/20 lines)
|
|
11
|
+
# - 🧠 to stream the full thinking log to a thread
|
|
12
|
+
# - Non-reserved emojis logged as feedback to the agent's persona
|
|
13
|
+
module Reactions
|
|
14
|
+
class << self
|
|
15
|
+
def handle(reaction_data, agent_key, bot_token, bot_user_id)
|
|
16
|
+
channel_id = reaction_data["channel_id"]
|
|
17
|
+
message_id = reaction_data["message_id"]
|
|
18
|
+
user_id = reaction_data["user_id"]
|
|
19
|
+
emoji = reaction_data["emoji"]
|
|
20
|
+
emoji_name = emoji["name"]
|
|
21
|
+
|
|
22
|
+
agent_name = agent_display_name(agent_key) || agent_key.capitalize
|
|
23
|
+
|
|
24
|
+
# Ignore reactions from bots (including self)
|
|
25
|
+
return if user_id == bot_user_id
|
|
26
|
+
|
|
27
|
+
case emoji_name
|
|
28
|
+
when "❔", "❓"
|
|
29
|
+
handle_thinking_peek(agent_key, agent_name, channel_id, message_id, bot_token, line_count: emoji_name == "❔" ? 10 : 20)
|
|
30
|
+
when "🧠"
|
|
31
|
+
handle_thinking_stream(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
32
|
+
when "❌"
|
|
33
|
+
handle_cancel(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
34
|
+
else
|
|
35
|
+
unless Api::RESERVED_EMOJIS.include?(emoji_name)
|
|
36
|
+
Thread.new do
|
|
37
|
+
log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.message}" if defined?(LOG)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Strip ANSI escape codes and non-ASCII from log output for Discord display.
|
|
48
|
+
def strip_ansi(text)
|
|
49
|
+
text.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
50
|
+
.gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "")
|
|
51
|
+
.gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "")
|
|
52
|
+
.gsub(/\e[=>]/, "")
|
|
53
|
+
.gsub(/\[\?[0-9]+[lh]/, "")
|
|
54
|
+
.gsub("[K", "")
|
|
55
|
+
.encode("ASCII", invalid: :replace, undef: :replace, replace: "")
|
|
56
|
+
.strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_thinking_peek(agent_key, agent_name, channel_id, message_id, bot_token, line_count:)
|
|
60
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
61
|
+
|
|
62
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
63
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
64
|
+
|
|
65
|
+
unless session_info
|
|
66
|
+
LOG.info "[Discord:#{agent_name}] Thinking peek on #{message_id} but no active session found" if defined?(LOG)
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
log_file = session_info[:log_file]
|
|
71
|
+
unless log_file && File.exist?(log_file)
|
|
72
|
+
LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}" if defined?(LOG)
|
|
73
|
+
Api.send_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
LOG.info "[Discord:#{agent_name}] Reading last #{line_count} lines from #{log_file}" if defined?(LOG)
|
|
78
|
+
|
|
79
|
+
lines = File.readlines(log_file).last(line_count)
|
|
80
|
+
thinking_output = strip_ansi(lines.join)
|
|
81
|
+
|
|
82
|
+
response = "**Last #{line_count} lines:**\n```\n#{thinking_output}\n```"
|
|
83
|
+
Api.send_message(channel_id, response, token: bot_token, reply_to: message_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_thinking_stream(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
88
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
89
|
+
|
|
90
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
91
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
92
|
+
|
|
93
|
+
unless session_info
|
|
94
|
+
LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{message_id} but no active session found" if defined?(LOG)
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
log_file = session_info[:log_file]
|
|
99
|
+
unless log_file && File.exist?(log_file)
|
|
100
|
+
LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}" if defined?(LOG)
|
|
101
|
+
Api.send_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}" if defined?(LOG)
|
|
106
|
+
|
|
107
|
+
thread_response = Api.create_thread(channel_id, message_id, name: "🧠 Thinking Stream", token: bot_token)
|
|
108
|
+
unless thread_response && thread_response["id"]
|
|
109
|
+
LOG.error "[Discord:#{agent_name}] Failed to create thread, response: #{thread_response.inspect}" if defined?(LOG)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
thread_id = thread_response["id"]
|
|
114
|
+
stream_thinking_to_thread(log_file, thread_id, bot_token)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stream_thinking_to_thread(log_file, thread_id, bot_token)
|
|
119
|
+
thinking_content = strip_ansi(File.read(log_file))
|
|
120
|
+
|
|
121
|
+
chunks = []
|
|
122
|
+
current_chunk = ""
|
|
123
|
+
thinking_content.lines.each do |line|
|
|
124
|
+
if current_chunk.length + line.length > 1900
|
|
125
|
+
chunks << current_chunk
|
|
126
|
+
current_chunk = line
|
|
127
|
+
else
|
|
128
|
+
current_chunk += line
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
chunks << current_chunk unless current_chunk.empty?
|
|
132
|
+
|
|
133
|
+
chunks.each do |chunk|
|
|
134
|
+
Api.send_message(thread_id, "```\n#{chunk}\n```", token: bot_token)
|
|
135
|
+
sleep 0.5
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_cancel(agent_key, agent_name, channel_id, message_id, bot_token)
|
|
140
|
+
session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
|
|
141
|
+
|
|
142
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
143
|
+
session_info = ACTIVE_SESSIONS[session_key]
|
|
144
|
+
|
|
145
|
+
unless session_info
|
|
146
|
+
LOG.info "[Discord:#{agent_name}] ❌ reaction on #{message_id} but no active session found" if defined?(LOG)
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
LOG.info "[Discord:#{agent_name}] Cancelling session for message #{message_id} (PID: #{session_info[:pid]})" if defined?(LOG)
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
Process.kill("KILL", session_info[:pid])
|
|
154
|
+
LOG.info "[Discord:#{agent_name}] Killed agent process #{session_info[:pid]}" if defined?(LOG)
|
|
155
|
+
rescue Errno::ESRCH
|
|
156
|
+
LOG.warn "[Discord:#{agent_name}] Process #{session_info[:pid]} already exited" if defined?(LOG)
|
|
157
|
+
rescue Errno::EPERM
|
|
158
|
+
LOG.error "[Discord:#{agent_name}] Permission denied killing process #{session_info[:pid]}" if defined?(LOG)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
ACTIVE_SESSIONS.delete(session_key)
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
Api.remove_reaction(channel_id, message_id, "👀", token: bot_token)
|
|
165
|
+
Api.add_reaction(channel_id, message_id, "🛑", token: bot_token)
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.message}" if defined?(LOG)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
session_info[:draft_files]&.each { |file| FileUtils.rm_f(file) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
|
|
175
|
+
msg = Api.fetch_message(channel_id, message_id, token: bot_token, log_errors: false)
|
|
176
|
+
return unless msg&.dig("author", "bot")
|
|
177
|
+
|
|
178
|
+
bot_uid = Gateway.bot_user_id(agent_key)
|
|
179
|
+
return unless bot_uid && msg.dig("author", "id") == bot_uid
|
|
180
|
+
|
|
181
|
+
reactor = respond_to?(:find_user_by_discord_id) ? find_user_by_discord_id(user_id) : nil
|
|
182
|
+
reactor_name = reactor ? reactor["canonical_name"] : user_id
|
|
183
|
+
|
|
184
|
+
snippet = (msg["content"] || "")[0, 80].tr("\n", " ").strip
|
|
185
|
+
snippet = "#{snippet}..." if (msg["content"] || "").length > 80
|
|
186
|
+
|
|
187
|
+
feedback_dir = File.join(persona_dir_for(agent_name), "people")
|
|
188
|
+
FileUtils.mkdir_p(feedback_dir)
|
|
189
|
+
feedback_file = File.join(feedback_dir, "#{reactor_name.downcase.gsub(/[^a-z0-9]/, "-")}-feedback.md")
|
|
190
|
+
|
|
191
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
|
|
192
|
+
entry = "- #{timestamp} #{emoji_name} on: \"#{snippet}\" (channel: #{channel_id})\n"
|
|
193
|
+
|
|
194
|
+
if File.exist?(feedback_file)
|
|
195
|
+
File.open(feedback_file, "a") { |f| f.write(entry) }
|
|
196
|
+
else
|
|
197
|
+
File.write(feedback_file, "# Feedback from #{reactor_name}\n\n## Reaction Log\n#{entry}")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
LOG.info "[Discord:#{agent_name}] Logged #{emoji_name} feedback from #{reactor_name} on message #{message_id}" if defined?(LOG)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "discord/version"
|
|
4
|
+
require_relative "discord/metadata"
|
|
5
|
+
require_relative "discord/cli"
|
|
6
|
+
require_relative "discord/config"
|
|
7
|
+
require_relative "discord/prompts"
|
|
8
|
+
require_relative "discord/api"
|
|
9
|
+
require_relative "discord/delivery"
|
|
10
|
+
require_relative "discord/reactions"
|
|
11
|
+
require_relative "discord/message"
|
|
12
|
+
require_relative "discord/gateway"
|
|
13
|
+
|
|
14
|
+
module Brainiac
|
|
15
|
+
module Plugins
|
|
16
|
+
module Discord
|
|
17
|
+
class << self
|
|
18
|
+
# Called by Brainiac plugin system during server startup.
|
|
19
|
+
#
|
|
20
|
+
# @param app [Sinatra::Application] The running Brainiac server
|
|
21
|
+
def register(app)
|
|
22
|
+
# Load Discord config
|
|
23
|
+
Brainiac::Plugins::Discord::Config.load!
|
|
24
|
+
|
|
25
|
+
# Register channel prompt
|
|
26
|
+
Brainiac.register_channel_prompt(:discord, Brainiac::Plugins::Discord::Prompts::CHANNEL)
|
|
27
|
+
|
|
28
|
+
# Register as notification provider
|
|
29
|
+
register_notification_handler!
|
|
30
|
+
|
|
31
|
+
# Register crash handler
|
|
32
|
+
register_crash_handler!
|
|
33
|
+
|
|
34
|
+
# Start all per-agent Discord bot gateway connections
|
|
35
|
+
Brainiac::Plugins::Discord::Gateway.start_all!
|
|
36
|
+
|
|
37
|
+
# Start draft delivery poller
|
|
38
|
+
Brainiac::Plugins::Discord::Delivery.start_poller!
|
|
39
|
+
|
|
40
|
+
# Set up API routes
|
|
41
|
+
setup_routes(app)
|
|
42
|
+
|
|
43
|
+
LOG.info "[Discord] Plugin registered (#{Brainiac::Plugins::Discord::Gateway.bot_count} bots)"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def register_notification_handler!
|
|
49
|
+
Brainiac.on(:notify) do |ctx|
|
|
50
|
+
next unless ctx[:channel].to_s == "discord"
|
|
51
|
+
|
|
52
|
+
target = ctx[:target]
|
|
53
|
+
message = ctx[:message]
|
|
54
|
+
agent = ctx[:agent]
|
|
55
|
+
|
|
56
|
+
agent_key = agent&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
57
|
+
token = Gateway.bot_token(agent_key) ||
|
|
58
|
+
Gateway.discord_bot_tokens[agent_key] ||
|
|
59
|
+
Gateway.discord_bot_tokens.values.first
|
|
60
|
+
next unless token && target
|
|
61
|
+
|
|
62
|
+
# Handle forum posts
|
|
63
|
+
if ctx[:forum_title] && Api.forum_channel?(target, token: token)
|
|
64
|
+
Api.create_forum_post(target, title: ctx[:forum_title], content: message, token: token)
|
|
65
|
+
elsif ctx[:forum_reply_to_latest] && Api.forum_channel?(target, token: token)
|
|
66
|
+
latest = Api.find_latest_forum_thread(target, token: token)
|
|
67
|
+
if latest
|
|
68
|
+
Api.send_long_message(latest["id"], message, token: token)
|
|
69
|
+
else
|
|
70
|
+
Api.send_long_message(target, message, token: token)
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
Api.send_long_message(target, message, token: token)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
:discord # Signal that we handled it
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def register_crash_handler!
|
|
81
|
+
Brainiac.on(:agent_crashed) do |ctx|
|
|
82
|
+
next unless %i[discord cron].include?(ctx[:source])
|
|
83
|
+
|
|
84
|
+
source_context = ctx[:source_context] || {}
|
|
85
|
+
channel_id = source_context[:channel_id] || source_context.dig(:job, :notify_target) || source_context.dig(:job, :discord_channel_id)
|
|
86
|
+
next unless channel_id
|
|
87
|
+
|
|
88
|
+
bot_token = source_context[:bot_token]
|
|
89
|
+
unless bot_token
|
|
90
|
+
agent_key = source_context.dig(:job, :agent)&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
91
|
+
bot_token = Gateway.bot_token(agent_key) || Gateway.discord_bot_tokens.values.first
|
|
92
|
+
end
|
|
93
|
+
next unless bot_token
|
|
94
|
+
|
|
95
|
+
snippet = ctx[:snippet]
|
|
96
|
+
snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
|
|
97
|
+
message = "💥 **#{ctx[:agent_name]} crashed** (exit code #{ctx[:exit_status]})\nLog: `#{ctx[:log_file]}`#{snippet_block}"
|
|
98
|
+
|
|
99
|
+
Api.send_long_message(channel_id, message, token: bot_token, reply_to: source_context[:message_id])
|
|
100
|
+
:discord
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def setup_routes(app)
|
|
105
|
+
app.get "/api/discord" do
|
|
106
|
+
content_type :json
|
|
107
|
+
Brainiac::Plugins::Discord::Gateway.bots_status.to_json
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
app.get "/api/gif" do
|
|
111
|
+
content_type :json
|
|
112
|
+
query = params["q"]
|
|
113
|
+
halt 400, { error: "missing q param" }.to_json unless query && !query.empty?
|
|
114
|
+
|
|
115
|
+
results = Brainiac::Plugins::Discord::Api.search_gif(query)
|
|
116
|
+
{ results: results }.to_json
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brainiac-discord
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Davis
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: brainiac
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.0.9
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.0.9
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: websocket-client-simple
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.8'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.8'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.25'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.25'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.75'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.75'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop-performance
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.25'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.25'
|
|
96
|
+
description: Full Discord integration for Brainiac — per-agent bot gateway connections,
|
|
97
|
+
message handling, session supersede, draft delivery, reaction handlers (cancel,
|
|
98
|
+
thinking peek, feedback logging), worktree management, and GIF support. Uses Brainiac's
|
|
99
|
+
hook system for lifecycle integration.
|
|
100
|
+
executables: []
|
|
101
|
+
extensions: []
|
|
102
|
+
extra_rdoc_files: []
|
|
103
|
+
files:
|
|
104
|
+
- README.md
|
|
105
|
+
- lib/brainiac/plugins/discord.rb
|
|
106
|
+
- lib/brainiac/plugins/discord/api.rb
|
|
107
|
+
- lib/brainiac/plugins/discord/cli.rb
|
|
108
|
+
- lib/brainiac/plugins/discord/config.rb
|
|
109
|
+
- lib/brainiac/plugins/discord/delivery.rb
|
|
110
|
+
- lib/brainiac/plugins/discord/gateway.rb
|
|
111
|
+
- lib/brainiac/plugins/discord/message.rb
|
|
112
|
+
- lib/brainiac/plugins/discord/metadata.rb
|
|
113
|
+
- lib/brainiac/plugins/discord/prompts.rb
|
|
114
|
+
- lib/brainiac/plugins/discord/reactions.rb
|
|
115
|
+
- lib/brainiac/plugins/discord/version.rb
|
|
116
|
+
- lib/brainiac_discord.rb
|
|
117
|
+
homepage: https://github.com/stowzilla/brainiac-discord
|
|
118
|
+
licenses:
|
|
119
|
+
- MIT
|
|
120
|
+
metadata:
|
|
121
|
+
rubygems_mfa_required: 'true'
|
|
122
|
+
rdoc_options: []
|
|
123
|
+
require_paths:
|
|
124
|
+
- lib
|
|
125
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - ">="
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '3.4'
|
|
130
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: '0'
|
|
135
|
+
requirements: []
|
|
136
|
+
rubygems_version: 3.6.9
|
|
137
|
+
specification_version: 4
|
|
138
|
+
summary: Discord bot plugin for Brainiac
|
|
139
|
+
test_files: []
|