brainiac-discord 0.0.22 → 0.0.24

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: 0c4e465a488b1eb8ff21dc46d539271cd85a35086fbed35a32e49bad609ee949
4
- data.tar.gz: 5095805d24f0422c6ad9fe0ea7bb363965732b6d2b6df047d00295ce14b05194
3
+ metadata.gz: 7111ffc72dd19a6df6442ba8634e288c5085f06d696d51941a9fe64f9223a7db
4
+ data.tar.gz: 6748908008c71f05cdf84eff403bfdeee5ef30bc950acb327de7e5556bbdfc3d
5
5
  SHA512:
6
- metadata.gz: e8c98fba862167e6c12096161dfb5fd964b12c6cb938c767279b00c2002a0770993c0f3a68b6b9f35fd391fbecba83d19f6fb21b1d1972a05aad4adcd89c78ff
7
- data.tar.gz: 37d9072132b6c961f15c7237f9b5bae98db6ddf0933f39ea9bfee4c9b983a85eb8b007e6cf2b11d8fc19fe75c261f733429cbad1827d124c4c47cbdaa811147e
6
+ metadata.gz: a01f1a444b3714dce15727b9c6572295f20b77d1891abaac462da1ee49ebcc3cefedcf44a7e18d00e2eeb339c0a3c70d3513a481a4c8524751aa91d3d7fc59f1
7
+ data.tar.gz: 72e2b469f446a22464134230c5d16a0c7d2d4ea3fee06adb450e795441b4061f87e9f0346ef8be50be573e9f355f6a15857e944ccc5469f3a40d337644f8fa71
@@ -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` — prompts for Discord user ID.
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
- return unless $stdin.tty?
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 discord.json: #{e.message}"
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.
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.22"
6
+ VERSION = "0.0.24"
7
7
  end
8
8
  end
9
9
  end
@@ -65,6 +65,21 @@ module Brainiac
65
65
  end
66
66
  end
67
67
 
68
+ # `agent create` writes the agent before the Discord step saves its token,
69
+ # so the server can see the agent first and only get the token here.
70
+ Brainiac.on(:agent_updated) do |ctx|
71
+ agent_key = ctx[:agent_key]
72
+ token = ((ctx[:entry].is_a?(Hash) && ctx[:entry]["env"]) || {})["DISCORD_BOT_TOKEN"]
73
+ next if token == Gateway.bot_token(agent_key)
74
+
75
+ Config.reload! # pick up the user_mappings entry the setup step wrote
76
+ Gateway.stop_bot!(agent_key)
77
+ if token
78
+ Gateway.start_bot!(agent_key, token)
79
+ LOG.info "[Discord] Started bot for #{ctx[:display_name]} (token added/changed)" if defined?(LOG)
80
+ end
81
+ end
82
+
68
83
  Brainiac.on(:agent_removed) do |ctx|
69
84
  agent_key = ctx[:agent_key]
70
85
 
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.22
4
+ version: 0.0.24
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