brainiac-discord 0.0.21 → 0.0.23
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b8cac77bcff754559623d7dc4b787a4fb405c8e34924b59fcd24a9d3db58d70
|
|
4
|
+
data.tar.gz: 027f50b17e55e46aa58c7305e62834b953054515c77da6a803ece5f90b5c3d35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80215591d21d89f226ae08f7c1ef246a7730bb36b9e71592f3b5ad06fdeee750c3e68508b8fd25071fb450754401946b614e8b28b9665660b8bf16f1b6a4a5c5
|
|
7
|
+
data.tar.gz: 543f0b0d398e220b8e9927b0638a2e67780f6bdf48375456d13a741faed14bc4bc2b51ac95a6fce3a2f2c85f846591b4c08381d0579d2b567a43a0860849d279
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Brainiac
|
|
10
|
+
module Plugins
|
|
11
|
+
module Discord
|
|
12
|
+
# Provisions a Discord bot for a new brainiac agent (`brainiac agent create`
|
|
13
|
+
# / `brainiac agent setup`).
|
|
14
|
+
#
|
|
15
|
+
# Discord has no API for creating applications, so the one manual step is
|
|
16
|
+
# "New Application → Bot → Reset Token" in the developer portal. Given that
|
|
17
|
+
# token, everything else is automated: bot username + avatar, app icon +
|
|
18
|
+
# description, the Message Content intent, the token in agents.json, the
|
|
19
|
+
# user_mappings entry, and the server invite link.
|
|
20
|
+
module AgentSetup
|
|
21
|
+
API_BASE = "https://discord.com/api/v10"
|
|
22
|
+
PORTAL_URL = "https://discord.com/developers/applications"
|
|
23
|
+
|
|
24
|
+
# Application flags that PATCH /applications/@me is allowed to change.
|
|
25
|
+
GATEWAY_PRESENCE_LIMITED = 1 << 13
|
|
26
|
+
GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15
|
|
27
|
+
GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19
|
|
28
|
+
EDITABLE_FLAGS = GATEWAY_PRESENCE_LIMITED | GATEWAY_GUILD_MEMBERS_LIMITED | GATEWAY_MESSAGE_CONTENT_LIMITED
|
|
29
|
+
|
|
30
|
+
# View channels, send/read messages, embeds, files, reactions, threads.
|
|
31
|
+
BOT_PERMISSIONS = [10, 11, 14, 15, 16, 6, 34, 35, 38].sum { |bit| 1 << bit }
|
|
32
|
+
|
|
33
|
+
APP_DESCRIPTION_LIMIT = 400
|
|
34
|
+
IMAGE_TYPES = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
35
|
+
".gif" => "image/gif", ".webp" => "image/webp" }.freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def run(agent_key, entry)
|
|
39
|
+
display_name = entry["display_name"] || agent_key.capitalize
|
|
40
|
+
token = registry.dig(agent_key, "env", "DISCORD_BOT_TOKEN")
|
|
41
|
+
|
|
42
|
+
if token && discord_config.dig("user_mappings", display_name)
|
|
43
|
+
puts " [Discord] ✓ #{display_name} already has a bot (skipping)"
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
token ||= prompt_for_token(display_name, entry["open_browser"])
|
|
48
|
+
return unless token
|
|
49
|
+
|
|
50
|
+
me = request(:get, "/users/@me", token)
|
|
51
|
+
unless me&.dig("id")
|
|
52
|
+
puts " [Discord] ✗ Discord rejected that token — re-run: brainiac agent setup #{agent_key}"
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
avatar = image_data_uri(entry["avatar_path"])
|
|
57
|
+
update_bot_user(me, display_name, avatar, token)
|
|
58
|
+
app = request(:get, "/applications/@me", token) || {}
|
|
59
|
+
update_application(app, entry["description"], avatar, token)
|
|
60
|
+
|
|
61
|
+
save_token(agent_key, token)
|
|
62
|
+
save_user_mapping(display_name, me["id"])
|
|
63
|
+
puts " [Discord] ✓ Saved DISCORD_BOT_TOKEN and user_mappings[#{display_name}] = #{me["id"]}"
|
|
64
|
+
puts " [Discord] Invite #{display_name} to your server:"
|
|
65
|
+
puts " #{invite_url(app["id"] || me["id"])}"
|
|
66
|
+
open_url(invite_url(app["id"] || me["id"])) if entry["open_browser"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def prompt_for_token(display_name, browser)
|
|
72
|
+
return nil unless $stdin.tty?
|
|
73
|
+
|
|
74
|
+
puts ""
|
|
75
|
+
puts " [Discord] Opening the developer portal in your browser…" if browser && open_url(PORTAL_URL)
|
|
76
|
+
puts " [Discord] Create a bot for #{display_name} (the only manual step):"
|
|
77
|
+
puts " 1. #{PORTAL_URL} → New Application → name it \"#{display_name}\""
|
|
78
|
+
puts " 2. Bot tab → Reset Token → copy it"
|
|
79
|
+
puts " Name, avatar, description and Message Content intent are set automatically."
|
|
80
|
+
print " Bot token (blank to skip): "
|
|
81
|
+
token = read_secret
|
|
82
|
+
puts ""
|
|
83
|
+
token.nil? || token.empty? ? nil : token
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Best-effort: launch the default browser without blocking on it.
|
|
87
|
+
# Returns false when no launcher is available (headless, SSH, ...).
|
|
88
|
+
def open_url(url)
|
|
89
|
+
launcher = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
90
|
+
pid = Process.spawn(launcher, url, %i[out err] => File::NULL)
|
|
91
|
+
Process.detach(pid)
|
|
92
|
+
true
|
|
93
|
+
rescue SystemCallError
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_secret
|
|
98
|
+
require "io/console"
|
|
99
|
+
$stdin.noecho(&:gets)&.strip
|
|
100
|
+
rescue Errno::ENOTTY, NoMethodError
|
|
101
|
+
$stdin.gets&.strip
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def update_bot_user(bot_user, display_name, avatar, token)
|
|
105
|
+
body = {}
|
|
106
|
+
body[:username] = display_name unless bot_user["username"] == display_name
|
|
107
|
+
body[:avatar] = avatar if avatar
|
|
108
|
+
return if body.empty?
|
|
109
|
+
|
|
110
|
+
if request(:patch, "/users/@me", token, body)
|
|
111
|
+
puts " [Discord] ✓ Bot user: #{body.keys.join(" + ")} updated"
|
|
112
|
+
else
|
|
113
|
+
puts " [Discord] ⚠ Couldn't update bot #{body.keys.join("/")} (username taken or rate limited?) — set it in the portal"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def update_application(app, description, avatar, token)
|
|
118
|
+
body = {}
|
|
119
|
+
body[:description] = truncate(description, APP_DESCRIPTION_LIMIT) if description
|
|
120
|
+
body[:icon] = avatar if avatar
|
|
121
|
+
if body.any? && !request(:patch, "/applications/@me", token, body)
|
|
122
|
+
puts " [Discord] ⚠ Couldn't update app #{body.keys.join("/")} — set it in the portal"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Separate request so a flags rejection doesn't lose the icon/description.
|
|
126
|
+
flags = ((app["flags"] || 0) & EDITABLE_FLAGS) | GATEWAY_MESSAGE_CONTENT_LIMITED
|
|
127
|
+
if request(:patch, "/applications/@me", token, { flags: flags })
|
|
128
|
+
puts " [Discord] ✓ App #{(body.keys + [:message_content_intent]).join(" + ")} set"
|
|
129
|
+
else
|
|
130
|
+
puts " [Discord] ⚠ Couldn't enable Message Content intent — Bot tab → Privileged Gateway Intents"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def invite_url(client_id)
|
|
135
|
+
"https://discord.com/oauth2/authorize?client_id=#{client_id}&scope=bot&permissions=#{BOT_PERMISSIONS}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def image_data_uri(path)
|
|
139
|
+
return nil unless path && File.file?(path)
|
|
140
|
+
|
|
141
|
+
mime = IMAGE_TYPES[File.extname(path).downcase]
|
|
142
|
+
return nil unless mime
|
|
143
|
+
|
|
144
|
+
"data:#{mime};base64,#{Base64.strict_encode64(File.binread(path))}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def truncate(text, limit)
|
|
148
|
+
text = text.strip
|
|
149
|
+
text.length > limit ? "#{text[0, limit - 1].rstrip}…" : text
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns the parsed body on 2xx, nil otherwise.
|
|
153
|
+
def request(method, path, token, body = nil)
|
|
154
|
+
uri = URI("#{API_BASE}#{path}")
|
|
155
|
+
klass = { get: Net::HTTP::Get, patch: Net::HTTP::Patch }.fetch(method)
|
|
156
|
+
req = klass.new(uri)
|
|
157
|
+
req["Authorization"] = "Bot #{token}"
|
|
158
|
+
req["Content-Type"] = "application/json"
|
|
159
|
+
req["User-Agent"] = "DiscordBot (https://github.com/stowzilla/brainiac-discord, 1.0)"
|
|
160
|
+
req.body = body.to_json if body
|
|
161
|
+
|
|
162
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
|
|
163
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
164
|
+
|
|
165
|
+
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def brainiac_dir
|
|
171
|
+
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def registry_file = File.join(brainiac_dir, "agents.json")
|
|
175
|
+
def config_file = File.join(brainiac_dir, "discord.json")
|
|
176
|
+
|
|
177
|
+
def registry
|
|
178
|
+
File.exist?(registry_file) ? JSON.parse(File.read(registry_file)) : {}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def discord_config
|
|
182
|
+
File.exist?(config_file) ? JSON.parse(File.read(config_file)) : {}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def save_token(agent_key, token)
|
|
186
|
+
reg = registry
|
|
187
|
+
reg[agent_key] ||= {}
|
|
188
|
+
reg[agent_key]["env"] ||= {}
|
|
189
|
+
reg[agent_key]["env"]["DISCORD_BOT_TOKEN"] = token
|
|
190
|
+
FileUtils.mkdir_p(brainiac_dir)
|
|
191
|
+
File.write(registry_file, JSON.pretty_generate(reg))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def save_user_mapping(display_name, user_id)
|
|
195
|
+
config = discord_config
|
|
196
|
+
config["user_mappings"] ||= {}
|
|
197
|
+
config["user_mappings"][display_name] = user_id
|
|
198
|
+
File.write(config_file, JSON.pretty_generate(config))
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "agent_setup"
|
|
6
7
|
|
|
7
8
|
module Brainiac
|
|
8
9
|
module Plugins
|
|
@@ -270,25 +271,12 @@ module Brainiac
|
|
|
270
271
|
%w[setup config default map owner token status]
|
|
271
272
|
end
|
|
272
273
|
|
|
273
|
-
# Called by brainiac CLI after `agent create`
|
|
274
|
+
# Called by brainiac CLI after `agent create` / `agent setup` — provisions
|
|
275
|
+
# the agent's Discord bot (see AgentSetup). Safe to re-run.
|
|
274
276
|
def self.on_agent_created(agent_key, entry)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
display_name = entry["display_name"] || agent_key.capitalize
|
|
278
|
-
puts ""
|
|
279
|
-
puts " [Discord] Configure Discord for #{display_name}?"
|
|
280
|
-
print " Discord user ID (or blank to skip): "
|
|
281
|
-
user_id = $stdin.gets&.chomp
|
|
282
|
-
return if user_id.nil? || user_id.empty?
|
|
283
|
-
|
|
284
|
-
config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "discord.json")
|
|
285
|
-
config = File.exist?(config_file) ? JSON.parse(File.read(config_file)) : {}
|
|
286
|
-
config["user_mappings"] ||= {}
|
|
287
|
-
config["user_mappings"][display_name] = user_id
|
|
288
|
-
File.write(config_file, JSON.pretty_generate(config))
|
|
289
|
-
puts " ✓ Added #{display_name} → #{user_id} to discord.json user_mappings"
|
|
277
|
+
AgentSetup.run(agent_key, entry)
|
|
290
278
|
rescue JSON::ParserError => e
|
|
291
|
-
puts " ⚠ Could not update
|
|
279
|
+
puts " ⚠ Could not update Discord config: #{e.message}"
|
|
292
280
|
end
|
|
293
281
|
|
|
294
282
|
# Called by brainiac CLI after `agent remove` — removes from Discord user_mappings.
|
|
@@ -714,8 +714,15 @@ module Brainiac
|
|
|
714
714
|
end
|
|
715
715
|
|
|
716
716
|
def resolve_overrides(clean_content, project_config, thread_model, thread_effort, thread_cli_provider)
|
|
717
|
-
|
|
717
|
+
inline_cli = detect_cli_provider(text: clean_content)
|
|
718
|
+
cli_provider = inline_cli || thread_cli_provider
|
|
718
719
|
has_explicit_model = explicit_model_tag?(clean_content, project_config, cli_provider_override: cli_provider)
|
|
720
|
+
|
|
721
|
+
# The stored model is a resolved ID from the previous provider's model map, so it
|
|
722
|
+
# means nothing to a different provider. Drop it on a switch and fall back to the
|
|
723
|
+
# new provider's default (unless this message also carries a model tag).
|
|
724
|
+
# Effort is kept: it's generic and gets clamped per provider anyway.
|
|
725
|
+
thread_model = nil if provider_switched?(inline_cli, thread_cli_provider)
|
|
719
726
|
has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
|
|
720
727
|
|
|
721
728
|
model = if has_explicit_model
|
|
@@ -737,6 +744,10 @@ module Brainiac
|
|
|
737
744
|
[model, effort, cli_provider]
|
|
738
745
|
end
|
|
739
746
|
|
|
747
|
+
def provider_switched?(inline_cli, prev_cli_provider)
|
|
748
|
+
!inline_cli.nil? && inline_cli != prev_cli_provider
|
|
749
|
+
end
|
|
750
|
+
|
|
740
751
|
# Persist inline overrides to the thread map (and work item map if a work item exists).
|
|
741
752
|
# Only writes when a new inline tag was detected (differs from previously stored values).
|
|
742
753
|
def persist_overrides(thread_map_key, clean_content, project_config,
|
|
@@ -761,7 +772,8 @@ module Brainiac
|
|
|
761
772
|
entry = map[thread_map_key]
|
|
762
773
|
if entry
|
|
763
774
|
entry["cli_provider"] = cli_provider if inline_cli
|
|
764
|
-
|
|
775
|
+
# Clear the old provider's model on a switch so it can't resurface later
|
|
776
|
+
entry["model"] = inline_model if inline_model || provider_switched?(inline_cli, prev_cli_provider)
|
|
765
777
|
entry["effort"] = effort if inline_effort
|
|
766
778
|
Config.save_thread_map(map)
|
|
767
779
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainiac-discord
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.23
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -103,6 +103,7 @@ extra_rdoc_files: []
|
|
|
103
103
|
files:
|
|
104
104
|
- README.md
|
|
105
105
|
- lib/brainiac/plugins/discord.rb
|
|
106
|
+
- lib/brainiac/plugins/discord/agent_setup.rb
|
|
106
107
|
- lib/brainiac/plugins/discord/api.rb
|
|
107
108
|
- lib/brainiac/plugins/discord/branching.rb
|
|
108
109
|
- lib/brainiac/plugins/discord/cli.rb
|