From 8962f793c1d314c70b80d7b0f64dd568389f9b1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:01:16 +0000 Subject: [PATCH 1/8] Initial plan From d1a0810fcfa549a46e5dece041b0bf605fb22eb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:14:54 +0000 Subject: [PATCH 2/8] refactor: move helpers and namespace top-level constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Kernel helpers into QBot::Helpers module and namespace all top-level constants under QBot. Also move find_prefix/cmd_prefix and load_by_glob into appropriate QBot modules. - Wrap all helpers in lib/qbot/helpers.rb in QBot::Helpers module - Include QBot::Helpers in Discordrb::Commands::CommandContainer - Wrap t() helper in QBot::Helpers module - Namespace Modules → QBot::Modules - Namespace XKCD → QBot::XKCD - Namespace TIO → QBot::TIO - Namespace ArchWiki → QBot::ArchWiki - Namespace XSConverter → QBot::XSConverter - Namespace TPDict → QBot::TPDict - Namespace SPGen → QBot::SPGen - Namespace NamedStringIO → QBot::NamedStringIO - Namespace CommandEventIntercept → QBot::CommandEventIntercept - Update all references to use new namespaces - Move find_prefix/cmd_prefix into QBot module - Move load_by_glob into QBot::Database module Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- lib/qbot/arch_wiki.rb | 88 +++++------ lib/qbot/cli.rb | 2 +- lib/qbot/db.rb | 14 +- lib/qbot/helpers.rb | 193 ++++++++++++------------ lib/qbot/hooks.rb | 2 +- lib/qbot/i18n.rb | 12 +- lib/qbot/init.rb | 38 ++--- lib/qbot/modules.rb | 29 ++-- lib/qbot/patches.rb | 34 +++-- lib/qbot/sitelenpona.rb | 144 +++++++++--------- lib/qbot/tio.rb | 102 ++++++------- lib/qbot/tpdict.rb | 122 ++++++++-------- lib/qbot/xkcd.rb | 30 ++-- lib/qbot/xsampa.rb | 316 ++++++++++++++++++++-------------------- modules/arch.rb | 2 +- modules/languages.rb | 2 +- modules/sitelenpona.rb | 8 +- modules/tio.rb | 4 +- modules/tokipona.rb | 8 +- modules/xkcd.rb | 8 +- 20 files changed, 594 insertions(+), 564 deletions(-) diff --git a/lib/qbot/arch_wiki.rb b/lib/qbot/arch_wiki.rb index d1278ab..da30b21 100644 --- a/lib/qbot/arch_wiki.rb +++ b/lib/qbot/arch_wiki.rb @@ -1,59 +1,61 @@ # frozen_string_literal: true -## -# Arch wiki searching etc -module ArchWiki - API_BASE = 'https://wiki.archlinux.org/api.php' - - # this lint is incorrect - # rubocop: disable Style/BlockDelimiters - PageInfo = Data.define(:title, :pageid, :url) do - def self.query(pageid) - res = ArchWiki.url_info(pageid) - val = res['pages'].values.first - - title = val['title'] - url = val['canonicalurl'] - - new(pageid:, title:, url:) +module QBot + ## + # Arch wiki searching etc + module ArchWiki + API_BASE = 'https://wiki.archlinux.org/api.php' + + # this lint is incorrect + # rubocop: disable Style/BlockDelimiters + PageInfo = Data.define(:title, :pageid, :url) do + def self.query(pageid) + res = QBot::ArchWiki.url_info(pageid) + val = res['pages'].values.first + + title = val['title'] + url = val['canonicalurl'] + + new(pageid:, title:, url:) + end end - end - # rubocop: enable Style/BlockDelimiters + # rubocop: enable Style/BlockDelimiters - def self.client - @client ||= MediawikiApi::Client.new(API_BASE) - end + def self.client + @client ||= MediawikiApi::Client.new(API_BASE) + end - def self.query(...) - client.query(...).data - end + def self.query(...) + client.query(...).data + end - def self.prop(...) - client.prop(...).data - end + def self.prop(...) + client.prop(...).data + end - def self.url_info(pageid) - prop('info', inprop: 'url', pageids: pageid) - end + def self.url_info(pageid) + prop('info', inprop: 'url', pageids: pageid) + end - def self.find_exact_page(title) - res = query(titles: title, inprop: 'url') + def self.find_exact_page(title) + res = query(titles: title, inprop: 'url') - return nil if res['pages'].key?('-1') + return nil if res['pages'].key?('-1') - res['pages'].values.first - end + res['pages'].values.first + end - def self.search_pages(srsearch, srlimit:) - res = query(list: 'search', srsearch:, srlimit:) + def self.search_pages(srsearch, srlimit:) + res = query(list: 'search', srsearch:, srlimit:) - res['search'] - end + res['search'] + end - def self.find_page(title) - res = find_exact_page(title) || search_pages(title, srlimit: 1)&.first - return nil unless res + def self.find_page(title) + res = find_exact_page(title) || search_pages(title, srlimit: 1)&.first + return nil unless res - PageInfo.query(res['pageid']) + PageInfo.query(res['pageid']) + end end end diff --git a/lib/qbot/cli.rb b/lib/qbot/cli.rb index d1ff228..16bb463 100644 --- a/lib/qbot/cli.rb +++ b/lib/qbot/cli.rb @@ -26,7 +26,7 @@ def self.run_cli name = cmd.shift begin - Modules.load_module name + QBot::Modules.load_module name rescue LoadError puts "Module not found: #{name}" end diff --git a/lib/qbot/db.rb b/lib/qbot/db.rb index 32a2561..5583b6b 100644 --- a/lib/qbot/db.rb +++ b/lib/qbot/db.rb @@ -18,14 +18,14 @@ def self.init_db def self.load_seed define_schema end - end -end -def load_by_glob(*path_components) - glob = File.join(__dir__, *path_components) + def self.load_by_glob(*path_components) + glob = File.join(__dir__, *path_components) - Dir[glob].each { load _1 } + Dir[glob].each { load _1 } + end + end end -load_by_glob('db', 'concerns', '*.rb') -load_by_glob('db', 'models', '*.rb') +QBot::Database.load_by_glob('db', 'concerns', '*.rb') +QBot::Database.load_by_glob('db', 'models', '*.rb') diff --git a/lib/qbot/helpers.rb b/lib/qbot/helpers.rb index 8149c73..d9698c5 100644 --- a/lib/qbot/helpers.rb +++ b/lib/qbot/helpers.rb @@ -1,118 +1,127 @@ # frozen_string_literal: true -def embed(text = nil, target: nil) - target ||= QBot.bot.embed_target - reply_target = target.is_a?(Discordrb::Events::MessageEvent) ? target.message : nil - - target.send_embed('', nil, nil, false, false, reply_target) do |m| - m.description = text if text - yield m if block_given? +## +# Helper methods for command modules +module QBot::Helpers + module_function + + def embed(text = nil, target: nil) + target ||= QBot.bot.embed_target + reply_target = target.is_a?(Discordrb::Events::MessageEvent) ? target.message : nil + + target.send_embed('', nil, nil, false, false, reply_target) do |m| + m.description = text if text + yield m if block_given? + end end -end -def prefixed(text) = "#{QBot.bot.current_prefix}#{text}" + def prefixed(text) = "#{QBot.bot.current_prefix}#{text}" -def log_embed(event, channel, user, extra) - embed(target: channel) do |m| - m.author = { name: user.distinct, icon_url: user.avatar_url } - m.title = 'Command execution' + def log_embed(event, channel, user, extra) + embed(target: channel) do |m| + m.author = { name: user.distinct, icon_url: user.avatar_url } + m.title = 'Command execution' - m.fields = [ - { name: 'Command', value: event.message.to_s.truncate(1024), inline: true }, - { name: 'User ID', value: user.id, inline: true } - ] + m.fields = [ + { name: 'Command', value: event.message.to_s.truncate(1024), inline: true }, + { name: 'User ID', value: user.id, inline: true } + ] - m.fields << [{ name: 'Information', value: extra }] if extra + m.fields << [{ name: 'Information', value: extra }] if extra - m.timestamp = Time.now + m.timestamp = Time.now + end end -end -def console_log(event, extra = nil) - QBot.log.info("command execution by #{event.author.distinct} on #{event.server.id}: " \ - "#{event.message}#{extra && "; #{extra}"}") -end + def console_log(event, extra = nil) + QBot.log.info("command execution by #{event.author.distinct} on #{event.server.id}: " \ + "#{event.message}#{extra && "; #{extra}"}") + end -def log(event, extra = nil) - console_log(event, extra) + def log(event, extra = nil) + console_log(event, extra) - chan_id = ServerConfig.for(event.server.id)[:log_channel_id] - return unless chan_id + chan_id = ServerConfig.for(event.server.id)[:log_channel_id] + return unless chan_id - begin - lc = event.bot.channel(chan_id) - log_embed(event, lc, event.author, extra) - rescue Discordrb::Errors::UnknownChannel - event.server.owner.pm(t('log-channel-gone')) + begin + lc = event.bot.channel(chan_id) + log_embed(event, lc, event.author, extra) + rescue Discordrb::Errors::UnknownChannel + event.server.owner.pm(QBot::Helpers.t('log-channel-gone')) + end end -end -# Listen for a user response -def user_response(event) - response = event.bot.add_await!( - Discordrb::Events::MentionEvent, - in: event.channel, - from: event.author - ) + # Listen for a user response + def user_response(event) + response = event.bot.add_await!( + Discordrb::Events::MentionEvent, + in: event.channel, + from: event.author + ) - parse_int(response.message.text.split.first) -end + parse_int(response.message.text.split.first) + end -def unescape(str) = "\"#{str}\"".undump + def unescape(str) = "\"#{str}\"".undump -def cmd_target(event, arg) - id = arg.to_i + def cmd_target(event, arg) + id = arg.to_i - if id.zero? - event.message.mentions[0] || event.author - else - event.bot.user(id) + if id.zero? + event.message.mentions[0] || event.author + else + event.bot.user(id) + end end -end -def to_word(num) - numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - words = %w[zero one two three four five six seven eight nine ten] - map = numbers.zip(words).to_h - map[num] || num -end + def to_word(num) + numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + words = %w[zero one two three four five six seven eight nine ten] + map = numbers.zip(words).to_h + map[num] || num + end -## -# Maps a single-digit integer to its corresponding Unicode emoji -def to_emoji(num) - if num.between?(0, 9) - # digit + Variation Selector-16 + Combining Enclosing Keycap - "#{num}\uFE0F\u20E3" - elsif num == 10 - # Keycap Digit Ten emoji - "\u{1F51F}" - else - raise ArgumentError, "#{num} does not correspond to an emoji" + ## + # Maps a single-digit integer to its corresponding Unicode emoji + def to_emoji(num) + if num.between?(0, 9) + # digit + Variation Selector-16 + Combining Enclosing Keycap + "#{num}\uFE0F\u20E3" + elsif num == 10 + # Keycap Digit Ten emoji + "\u{1F51F}" + else + raise ArgumentError, "#{num} does not correspond to an emoji" + end end -end -## -# Tries to parse an Integer from a value. Returns nil on failure. -def parse_int(num) - Integer(num) -rescue ArgumentError, TypeError - nil -end + ## + # Tries to parse an Integer from a value. Returns nil on failure. + def parse_int(num) + Integer(num) + rescue ArgumentError, TypeError + nil + end -## -# Extracts everything past the nth word in a string. -# Uses the same rules as `String#split`. -# Returns nil on error. -def after_nth_word(n_words, str) - re = / - \A # Anchor to start of string - (?: - \S+\s+ # Match a word and the spaces after... - ){#{n_words}} # ...n times - \K # Reset match - .* # Match everything that comes after - \z # Anchor to end of string - /mx - - str[re] + ## + # Extracts everything past the nth word in a string. + # Uses the same rules as `String#split`. + # Returns nil on error. + def after_nth_word(n_words, str) + re = / + \A # Anchor to start of string + (?: + \S+\s+ # Match a word and the spaces after... + ){#{n_words}} # ...n times + \K # Reset match + .* # Match everything that comes after + \z # Anchor to end of string + /mx + + str[re] + end end + +# Include helpers in CommandContainer so they are available in command blocks +Discordrb::Commands::CommandContainer.include QBot::Helpers diff --git a/lib/qbot/hooks.rb b/lib/qbot/hooks.rb index 7e8f883..8c2721c 100644 --- a/lib/qbot/hooks.rb +++ b/lib/qbot/hooks.rb @@ -25,7 +25,7 @@ def execute_command(name, event, arguments, chained = false, check_permissions = @embed_target = event # Expose the current prefix - @current_prefix = find_prefix(event.message) + @current_prefix = QBot.find_prefix(event.message) execute!(name, event, arguments, chained, check_permissions) end diff --git a/lib/qbot/i18n.rb b/lib/qbot/i18n.rb index 6181b5b..acfc47e 100644 --- a/lib/qbot/i18n.rb +++ b/lib/qbot/i18n.rb @@ -27,8 +27,12 @@ def format_value(value, ...) option :language, TLocaleEnum.new, default: I18n.default_locale end -def t(tid, *fields) - I18n.translate!(tid) % fields -rescue I18n::MissingTranslationData - "#{I18n.translate(tid)} #{fields.inspect}" +module QBot::Helpers + module_function + + def t(tid, *fields) + I18n.translate!(tid) % fields + rescue I18n::MissingTranslationData + "#{I18n.translate(tid)} #{fields.inspect}" + end end diff --git a/lib/qbot/init.rb b/lib/qbot/init.rb index 94a6bbd..80db9e0 100644 --- a/lib/qbot/init.rb +++ b/lib/qbot/init.rb @@ -2,24 +2,6 @@ require 'active_support/ordered_options' -def find_prefix(message) - if message.channel.pm? - QBot.config.default_prefix - else - ServerConfig.for(message.server.id).server_prefix - end -end - -def cmd_prefix(message) - pfx = find_prefix(message) - - if message.text.start_with?("#{pfx} ") - message.text[(pfx.length + 1)..] - elsif message.text.start_with?(pfx) - message.text[pfx.length..] - end -end - # Initialization code for the bot module QBot class << self @@ -28,6 +10,24 @@ class << self @scheduler = nil + def self.find_prefix(message) + if message.channel.pm? + QBot.config.default_prefix + else + ServerConfig.for(message.server.id).server_prefix + end + end + + def self.cmd_prefix(message) + pfx = find_prefix(message) + + if message.text.start_with?("#{pfx} ") + message.text[(pfx.length + 1)..] + elsif message.text.start_with?(pfx) + message.text[pfx.length..] + end + end + def self.init_log @log = Discordrb::Logger.new(true) end @@ -93,7 +93,7 @@ def self.run! @scheduler = Rufus::Scheduler.new @log.debug 'Init modules' - Modules.load_all + QBot::Modules.load_all @log.info 'Initializing connection...' diff --git a/lib/qbot/modules.rb b/lib/qbot/modules.rb index 4d96f68..ee75bba 100644 --- a/lib/qbot/modules.rb +++ b/lib/qbot/modules.rb @@ -1,23 +1,26 @@ # frozen_string_literal: true -# Manages bot modules -module Modules - @loaded_modules = Set.new +module QBot + ## + # Manages bot modules + module Modules + @loaded_modules = Set.new - class << self; attr_accessor :all; end + class << self; attr_accessor :all; end - def self.load_module(name) - QBot.log.info "Loading module: #{name}" - load File.join(__dir__, *%W[.. .. modules #{name}.rb]) + def self.load_module(name) + QBot.log.info "Loading module: #{name}" + load File.join(__dir__, *%W[.. .. .. modules #{name}.rb]) - QBot.bot.include!(name.camelize.constantize) + QBot.bot.include!(name.camelize.constantize) - @loaded_modules << name.to_sym - end + @loaded_modules << name.to_sym + end - def self.load_all - QBot.config.modules.each do |name| - load_module(name) + def self.load_all + QBot.config.modules.each do |name| + load_module(name) + end end end end diff --git a/lib/qbot/patches.rb b/lib/qbot/patches.rb index d690ec8..3b0fe40 100644 --- a/lib/qbot/patches.rb +++ b/lib/qbot/patches.rb @@ -21,32 +21,34 @@ def respond_wrapped(content, tts: false, embed: nil, attachments: nil, end end -## -# StringIO derivative that presents a fake path to discordrb -class NamedStringIO < StringIO - attr_accessor :path +module QBot + ## + # StringIO derivative that presents a fake path to discordrb + class NamedStringIO < StringIO + attr_accessor :path - def initialize(string = '', mode = nil, path: 'image.png') - @path = path - super(string, mode) + def initialize(string = '', mode = nil, path: 'image.png') + @path = path + super(string, mode) + end end -end -# reimplementing discordrb's ignore_bot with a whitelist -module CommandEventIntercept - # rubocop: disable Style/OptionalBooleanParameter - def call(event, arguments, chained = false, check_permissions = true) - # rubocop:enable Style/OptionalBooleanParameter - return if event.author.bot_account && !(QBot.config.bot_id_allowlist.include? event.author.id) + # reimplementing discordrb's ignore_bot with a whitelist + module CommandEventIntercept + # rubocop: disable Style/OptionalBooleanParameter + def call(event, arguments, chained = false, check_permissions = true) + # rubocop:enable Style/OptionalBooleanParameter + return if event.author.bot_account && !(QBot.config.bot_id_allowlist.include? event.author.id) - super(event, arguments, chained, check_permissions) + super(event, arguments, chained, check_permissions) + end end end module Discordrb module Commands class Command - prepend CommandEventIntercept + prepend QBot::CommandEventIntercept end end end diff --git a/lib/qbot/sitelenpona.rb b/lib/qbot/sitelenpona.rb index 1adc519..4af5b6b 100644 --- a/lib/qbot/sitelenpona.rb +++ b/lib/qbot/sitelenpona.rb @@ -1,92 +1,94 @@ # frozen_string_literal: true -## -# sitelen pona image generation -module SPGen - include Magick - - DrawOptions = Struct.new( - 'DrawOptions', - :fontface, - :fontsize, - :bg_color, - :fg_color, - :width, - :border, - keyword_init: true - ) do - def initialize(...) - super - self.fontface ||= 'linja suwi' - self.fontsize ||= 32 - self.bg_color ||= 'white' - self.fg_color ||= 'black' - self.width ||= 500 - self.border ||= 5 +module QBot + ## + # sitelen pona image generation + module SPGen + include Magick + + DrawOptions = Struct.new( + 'DrawOptions', + :fontface, + :fontsize, + :bg_color, + :fg_color, + :width, + :border, + keyword_init: true + ) do + def initialize(...) + super + self.fontface ||= 'linja suwi' + self.fontsize ||= 32 + self.bg_color ||= 'white' + self.fg_color ||= 'black' + self.width ||= 500 + self.border ||= 5 + end + + def glyph_style + QBot::SPGen.metadata_for(self.fontface).glyph_style + end end - def glyph_style - SPGen.metadata_for(self.fontface).glyph_style - end - end + def self.generate_image(markup, options) + options => { width:, border:, bg_color: } - def self.generate_image(markup, options) - options => { width:, border:, bg_color: } + images = Image.read("pango:#{markup}") { |img| + img.define('pango', 'wrap', 'word-char') + img.background_color = bg_color + img.size = "#{width - (2 * border)}x" + } - images = Image.read("pango:#{markup}") { |img| - img.define('pango', 'wrap', 'word-char') - img.background_color = bg_color - img.size = "#{width - (2 * border)}x" - } + output = images.first + output.trim! + output.border!(border, border, bg_color) + end - output = images.first - output.trim! - output.border!(border, border, bg_color) - end + def self.gen_markup(text, options) + options => { fontface: face, fontsize: size, fg_color: } - def self.gen_markup(text, options) - options => { fontface: face, fontsize: size, fg_color: } + <<~PANGO + + #{text} + + PANGO + end - <<~PANGO - - #{text} - - PANGO - end + def self.draw_text(text, options = nil, **kwargs) + options ||= DrawOptions.new(**kwargs) - def self.draw_text(text, options = nil, **kwargs) - options ||= DrawOptions.new(**kwargs) + markup = gen_markup(text, options) + image = generate_image(markup, options) - markup = gen_markup(text, options) - image = generate_image(markup, options) + blob_png = image.to_blob { _1.format = 'png' } + image.destroy! - blob_png = image.to_blob { _1.format = 'png' } - image.destroy! + blob_png + end - blob_png - end + def self.load_metadata_file + path = File.join(__dir__, *%w[.. .. share fonts tokipona metadata.yml]) + YAML.load_file(path, symbolize_names: true) + end - def self.load_metadata_file - path = File.join(__dir__, *%w[.. .. share fonts tokipona metadata.yml]) - YAML.load_file(path, symbolize_names: true) - end + @font_metadata = nil + @font_entry = nil + def self.font_metadata + unless @font_metadata + yaml = load_metadata_file - @font_metadata = nil - @font_entry = nil - def self.font_metadata - unless @font_metadata - yaml = load_metadata_file + keys = yaml.dig(:schema, :fonts).map(&:to_sym) + @font_entry = Struct.new('FontEntry', *keys, keyword_init: true) - keys = yaml.dig(:schema, :fonts).map(&:to_sym) - @font_entry = Struct.new('FontEntry', *keys, keyword_init: true) + @font_metadata = yaml[:fonts].map { @font_entry.new(**_1) } + end - @font_metadata = yaml[:fonts].map { @font_entry.new(**_1) } + @font_metadata end - @font_metadata - end - - def self.metadata_for(typeface) - font_metadata.find { _1.typeface == typeface } + def self.metadata_for(typeface) + font_metadata.find { _1.typeface == typeface } + end end end diff --git a/lib/qbot/tio.rb b/lib/qbot/tio.rb index 816560f..2e64d34 100644 --- a/lib/qbot/tio.rb +++ b/lib/qbot/tio.rb @@ -6,73 +6,75 @@ require 'nokogiri' require 'zlib' -## -# Try It Online API interface -module TIO - API_BASE = 'https://tio.run' - - def self.run_endpoint - @run_endpoint ||= URI.parse( - API_BASE + - Nokogiri::HTML - .parse(URI.parse(API_BASE).open) - .xpath('//head/script[2]/@src')[0] - ).open.readlines.grep(/^var runURL/)[0][14..-4] - end +module QBot + ## + # Try It Online API interface + module TIO + API_BASE = 'https://tio.run' + + def self.run_endpoint + @run_endpoint ||= URI.parse( + API_BASE + + Nokogiri::HTML + .parse(URI.parse(API_BASE).open) + .xpath('//head/script[2]/@src')[0] + ).open.readlines.grep(/^var runURL/)[0][14..-4] + end - def self.gzdeflate(str) = - Zlib::Deflate.new(nil, -Zlib::MAX_WBITS).deflate(str, Zlib::FINISH) + def self.gzdeflate(str) = + Zlib::Deflate.new(nil, -Zlib::MAX_WBITS).deflate(str, Zlib::FINISH) - def self.gzinflate(str) = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(str) + def self.gzinflate(str) = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(str) - def self.languages = - JSON.parse(URI.parse("#{API_BASE}/languages.json").open.read) + def self.languages = + JSON.parse(URI.parse("#{API_BASE}/languages.json").open.read) - def self.languages_by_category(category) = - languages.filter { |_, v| v['categories'].include? category.to_s } + def self.languages_by_category(category) = + languages.filter { |_, v| v['categories'].include? category.to_s } - def self.file(name, body) = "F#{name}\0#{body.size}\0#{body}" + def self.file(name, body) = "F#{name}\0#{body.size}\0#{body}" - def self.var(name, args) = - "V#{name}\0#{args.size}\0#{args.map { |a| "#{a}\0" }.join}" + def self.var(name, args) = + "V#{name}\0#{args.size}\0#{args.map { |a| "#{a}\0" }.join}" - def self.make_req(language, code, flags, input, arguments) - val = var('lang', [language]) + var('args', arguments) + def self.make_req(language, code, flags, input, arguments) + val = var('lang', [language]) + var('args', arguments) - if flags - val += var('TIO_OPTIONS', flags) unless language.start_with? 'java-' - val += var('TIO_CFLAGS', flags) - end + if flags + val += var('TIO_OPTIONS', flags) unless language.start_with? 'java-' + val += var('TIO_CFLAGS', flags) + end - val += file('.code.tio', code) - val += file('.input.tio', input) if input - val += 'R' + val += file('.code.tio', code) + val += file('.input.tio', input) if input + val += 'R' - gzdeflate(val) - end + gzdeflate(val) + end - def self.settings = '/' + def self.settings = '/' - def self.new_token = - Random.new.bytes(16).unpack('C16').map { format '%02x', _1 }.join + def self.new_token = + Random.new.bytes(16).unpack('C16').map { format '%02x', _1 }.join - def self.post_req(uri, data) - https = Net::HTTP.new(uri.host, uri.port) - https.use_ssl = true + def self.post_req(uri, data) + https = Net::HTTP.new(uri.host, uri.port) + https.use_ssl = true - request = Net::HTTP::Post.new(uri.path) - request.body = data + request = Net::HTTP::Post.new(uri.path) + request.body = data - https.request(request) - end + https.request(request) + end - def self.run(language, code, flags = nil, input = nil, arguments = []) - req_body = make_req(language, code, flags, input, arguments) + def self.run(language, code, flags = nil, input = nil, arguments = []) + req_body = make_req(language, code, flags, input, arguments) - uri = URI("#{API_BASE}#{run_endpoint}#{settings}#{new_token}") - post_res = post_req(uri, req_body) + uri = URI("#{API_BASE}#{run_endpoint}#{settings}#{new_token}") + post_res = post_req(uri, req_body) - res = gzinflate(post_res.body[10..]) - res.split(res[0..15])[1..].map(&:chomp) + res = gzinflate(post_res.body[10..]) + res.split(res[0..15])[1..].map(&:chomp) + end end end diff --git a/lib/qbot/tpdict.rb b/lib/qbot/tpdict.rb index db95849..f7b464e 100644 --- a/lib/qbot/tpdict.rb +++ b/lib/qbot/tpdict.rb @@ -2,77 +2,79 @@ require 'singleton' -## -# Toki Pona dictionary -class TPDict - include Singleton - - attr_accessor :tp_inli, :pu - - SOURCES = [ - 'http://tokipona.org/compounds.txt', - 'http://tokipona.org/nimi_pi_pu_ala.txt', - 'http://tokipona.org/nimi_pu.txt' - ].freeze - - FREQ_MAP = { - 81..100 => '⁵', - 61..80 => '⁴', - 41..60 => '³', - 21..40 => '²', - 11..20 => '¹', - 0..10 => '⁰' - }.freeze - - def sourcelist = SOURCES.join(', ') - - # rubocop: disable Security/Open - def get_all(urls) = urls.map { YAML.safe_load(URI.open(_1)) } - # rubocop: enable Security/Open - - def merge_defs(yamls) = yamls.each_with_object({}) { |l, r| r.merge!(l) } - - def process_tp_inli(input) - input.transform_values do |v| - v.map do |usage| - *w, c = usage.split - [w.join(' '), c.to_i] +module QBot + ## + # Toki Pona dictionary + class TPDict + include Singleton + + attr_accessor :tp_inli, :pu + + SOURCES = [ + 'http://tokipona.org/compounds.txt', + 'http://tokipona.org/nimi_pi_pu_ala.txt', + 'http://tokipona.org/nimi_pu.txt' + ].freeze + + FREQ_MAP = { + 81..100 => '⁵', + 61..80 => '⁴', + 41..60 => '³', + 21..40 => '²', + 11..20 => '¹', + 0..10 => '⁰' + }.freeze + + def sourcelist = SOURCES.join(', ') + + # rubocop: disable Security/Open + def get_all(urls) = urls.map { YAML.safe_load(URI.open(_1)) } + # rubocop: enable Security/Open + + def merge_defs(yamls) = yamls.each_with_object({}) { |l, r| r.merge!(l) } + + def process_tp_inli(input) + input.transform_values do |v| + v.map do |usage| + *w, c = usage.split + [w.join(' '), c.to_i] + end end end - end - def load_tp_inli = process_tp_inli(merge_defs(get_all(SOURCES))) + def load_tp_inli = process_tp_inli(merge_defs(get_all(SOURCES))) - def load_pu - YAML - .unsafe_load_file(File.join(__dir__, *%w[.. .. share tokipona pu.yml])) - .transform_values(&:symbolize_keys) - end + def load_pu + YAML + .unsafe_load_file(File.join(__dir__, *%w[.. .. share tokipona pu.yml])) + .transform_values(&:symbolize_keys) + end - def initialize - @tp_inli = load_tp_inli - @pu = load_pu - end + def initialize + @tp_inli = load_tp_inli + @pu = load_pu + end - def freq_char(freq) = FREQ_MAP.select { _1.include? freq }.values.first + def freq_char(freq) = FREQ_MAP.select { _1.include? freq }.values.first - def freqlist(vals) = vals.map { |k, v| "#{k}#{freq_char(v)}" }.join(', ') + def freqlist(vals) = vals.map { |k, v| "#{k}#{freq_char(v)}" }.join(', ') - def query_tp_inli(query, limit: 0, overflow_text: '[...]') - data = @tp_inli[query] || (return nil) + def query_tp_inli(query, limit: 0, overflow_text: '[...]') + data = @tp_inli[query] || (return nil) - if limit.zero? || data.size <= limit - freqlist(data) - else - "#{freqlist(data.first(8))}, #{overflow_text}" + if limit.zero? || data.size <= limit + freqlist(data) + else + "#{freqlist(data.first(8))}, #{overflow_text}" + end end - end - def query_pu(query) - data = @pu[query] || (return nil) + def query_pu(query) + data = @pu[query] || (return nil) - data.map { |(type, desc)| - "*~#{type}~* #{desc}" - }.join("\n") + data.map { |(type, desc)| + "*~#{type}~* #{desc}" + }.join("\n") + end end end diff --git a/lib/qbot/xkcd.rb b/lib/qbot/xkcd.rb index 94498df..587a713 100644 --- a/lib/qbot/xkcd.rb +++ b/lib/qbot/xkcd.rb @@ -7,23 +7,25 @@ require 'json' require 'uri' -## -# XKCD query interface -class XKCD - def self.random_id - URI.open('https://dynamic.xkcd.com/random/comic/', allow_redirections: :all) - .base_uri.to_s - .split('/').last.to_i - end +module QBot + ## + # XKCD query interface + class XKCD + def self.random_id + URI.open('https://dynamic.xkcd.com/random/comic/', allow_redirections: :all) + .base_uri.to_s + .split('/').last.to_i + end - def self.parse_info(io) = JSON.parse(io.read).symbolize_keys + def self.parse_info(io) = JSON.parse(io.read).symbolize_keys - def self.get_info(id) = - parse_info(URI.open("https://xkcd.com/#{id}/info.0.json")) + def self.get_info(id) = + parse_info(URI.open("https://xkcd.com/#{id}/info.0.json")) - def self.latest_info = parse_info(URI.open('https://xkcd.com/info.0.json')) + def self.latest_info = parse_info(URI.open('https://xkcd.com/info.0.json')) - def self.random_info = get_info(random_id) + def self.random_info = get_info(random_id) - def self.comic_url(info) = "https://xkcd.com/#{info[:num]}" + def self.comic_url(info) = "https://xkcd.com/#{info[:num]}" + end end diff --git a/lib/qbot/xsampa.rb b/lib/qbot/xsampa.rb index d49f32f..bc85ea8 100644 --- a/lib/qbot/xsampa.rb +++ b/lib/qbot/xsampa.rb @@ -1,163 +1,165 @@ # frozen_string_literal: true -# rubocop: disable Metrics/ModuleLength -# X-SAMPA to IPA conversion -module XSConverter - XSAMPA_MAP = { - 'b_<' => 'ɓ', - 'd`' => 'ɖ', - 'd_<' => 'ɗ', - 'g_<' => 'ɠ', - 'h\\' => 'ɦ', - 'j\\' => 'ʝ', - 'l`' => 'ɭ', - 'l\\' => 'ɺ', - 'n`' => 'ɳ', - 'p\\' => 'ɸ', - 'r`' => 'ɽ', - 'r\\' => 'ɹ', - 'r\\`' => 'ɻ', - 's`' => 'ʂ', - 's\\' => 'ɕ', - 't`' => 'ʈ', - 'v\\' => 'ʋ', - 'x\\' => 'ɧ', - 'z`' => 'ʐ', - 'z\\' => 'ʑ', - 'A' => 'ɑ', - 'B' => 'β', - 'B\\' => 'ʙ', - 'C' => 'ç', - 'D' => 'ð', - 'E' => 'ɛ', - 'F' => 'ɱ', - 'G' => 'ɣ', - 'G\\' => 'ɢ', - 'G\\_<' => 'ʛ', - 'H' => 'ɥ', - 'H\\' => 'ʜ', - 'I' => 'ɪ', - 'I\\' => 'ᵻ', - 'J' => 'ɲ', - 'J\\' => 'ɟ', - 'J\\_<' => 'ʄ', - 'K' => 'ɬ', - 'K\\' => 'ɮ', - 'L' => 'ʎ', - 'L\\' => 'ʟ', - 'M' => 'ɯ', - 'M\\' => 'ɰ', - 'N' => 'ŋ', - 'N\\' => 'ɴ', - 'O' => 'ɔ', - 'O\\' => 'ʘ', - 'P' => 'ʋ', - 'Q' => 'ɒ', - 'R' => 'ʁ', - 'R\\' => 'ʀ', - 'S' => 'ʃ', - 'T' => 'θ', - 'U' => 'ʊ', - 'U\\' => 'ᵿ', - 'V' => 'ʌ', - 'W' => 'ʍ', - 'X' => 'χ', - 'X\\' => 'ħ', - 'Y' => 'ʏ', - 'Z' => 'ʒ', - '.' => '.', - '"' => 'ˈ', - '%' => 'ˌ', - "'" => 'ʲ', - '_j' => 'ʲ', - ':' => 'ː', - ':\\' => 'ˑ', - '-' => '', - '@' => 'ə', - '@\\' => 'ɘ', - '{' => 'æ', - '}' => 'ʉ', - '1' => 'ɨ', - '2' => 'ø', - '3' => 'ɜ', - '3\\' => 'ɞ', - '4' => 'ɾ', - '5' => 'ɫ', - '6' => 'ɐ', - '7' => 'ɤ', - '8' => 'ɵ', - '9' => 'œ', - '&' => 'ɶ', - '?' => 'ʔ', - '?\\' => 'ʕ', - '<\\' => 'ʢ', - '>\\' => 'ʡ', - '^' => 'ꜛ', - '!' => 'ꜜ', - '!\\' => 'ǃ', - '|' => '|', - '|\\' => 'ǀ', - '||' => '‖', - '|\\|\\' => 'ǁ', - '=\\' => 'ǂ', - '-\\' => '‿', - '_"' => ' ̈', - '_+' => '̟', - '_-' => '̠', - '_/' => '̌', - '_0' => '̥', - '_<' => '', - '=' => '̩', - '_=' => '̩', - '_>' => 'ʼ', - '_?\\' => 'ˤ', - '_\\' => '̂', - '_^' => '̯', - '_}' => '̚', - '`' => '˞', - '~' => '̃', - '_~' => '̃', - '_A' => '̘', - '_a' => '̺', - '_B' => '̏', - '_B_L' => '᷅', - '_c' => '̜', - '_d' => '̪', - '_e' => '̴', - '' => '↘', - '_F' => '̂', - '_G' => 'ˠ', - '_H' => '́', - '_H_T' => '᷄', - '_h' => 'ʰ', - '_k' => '̰', - '_L' => '̀', - '_l' => 'ˡ', - '_M' => '̄', - '_m' => '̻', - '_N' => '̼', - '_n' => 'ⁿ', - '_O' => '̹', - '_o' => '̞', - '_q' => '̙', - '' => '↗', - '_R' => '̌', - '_R_F' => '᷈', - '_r' => '̝', - '_T' => '̋', - '_t' => '̤', - '_v' => '̬', - '_w' => 'ʷ', - '_X' => '̆', - '_x' => '̽' - }.sort_by { |k, _| k.length }.reverse!.freeze +module QBot + # rubocop: disable Metrics/ModuleLength + # X-SAMPA to IPA conversion + module XSConverter + XSAMPA_MAP = { + 'b_<' => 'ɓ', + 'd`' => 'ɖ', + 'd_<' => 'ɗ', + 'g_<' => 'ɠ', + 'h\\' => 'ɦ', + 'j\\' => 'ʝ', + 'l`' => 'ɭ', + 'l\\' => 'ɺ', + 'n`' => 'ɳ', + 'p\\' => 'ɸ', + 'r`' => 'ɽ', + 'r\\' => 'ɹ', + 'r\\`' => 'ɻ', + 's`' => 'ʂ', + 's\\' => 'ɕ', + 't`' => 'ʈ', + 'v\\' => 'ʋ', + 'x\\' => 'ɧ', + 'z`' => 'ʐ', + 'z\\' => 'ʑ', + 'A' => 'ɑ', + 'B' => 'β', + 'B\\' => 'ʙ', + 'C' => 'ç', + 'D' => 'ð', + 'E' => 'ɛ', + 'F' => 'ɱ', + 'G' => 'ɣ', + 'G\\' => 'ɢ', + 'G\\_<' => 'ʛ', + 'H' => 'ɥ', + 'H\\' => 'ʜ', + 'I' => 'ɪ', + 'I\\' => 'ᵻ', + 'J' => 'ɲ', + 'J\\' => 'ɟ', + 'J\\_<' => 'ʄ', + 'K' => 'ɬ', + 'K\\' => 'ɮ', + 'L' => 'ʎ', + 'L\\' => 'ʟ', + 'M' => 'ɯ', + 'M\\' => 'ɰ', + 'N' => 'ŋ', + 'N\\' => 'ɴ', + 'O' => 'ɔ', + 'O\\' => 'ʘ', + 'P' => 'ʋ', + 'Q' => 'ɒ', + 'R' => 'ʁ', + 'R\\' => 'ʀ', + 'S' => 'ʃ', + 'T' => 'θ', + 'U' => 'ʊ', + 'U\\' => 'ᵿ', + 'V' => 'ʌ', + 'W' => 'ʍ', + 'X' => 'χ', + 'X\\' => 'ħ', + 'Y' => 'ʏ', + 'Z' => 'ʒ', + '.' => '.', + '"' => 'ˈ', + '%' => 'ˌ', + "'" => 'ʲ', + '_j' => 'ʲ', + ':' => 'ː', + ':\\' => 'ˑ', + '-' => '', + '@' => 'ə', + '@\\' => 'ɘ', + '{' => 'æ', + '}' => 'ʉ', + '1' => 'ɨ', + '2' => 'ø', + '3' => 'ɜ', + '3\\' => 'ɞ', + '4' => 'ɾ', + '5' => 'ɫ', + '6' => 'ɐ', + '7' => 'ɤ', + '8' => 'ɵ', + '9' => 'œ', + '&' => 'ɶ', + '?' => 'ʔ', + '?\\' => 'ʕ', + '<\\' => 'ʢ', + '>\\' => 'ʡ', + '^' => 'ꜛ', + '!' => 'ꜜ', + '!\\' => 'ǃ', + '|' => '|', + '|\\' => 'ǀ', + '||' => '‖', + '|\\|\\' => 'ǁ', + '=\\' => 'ǂ', + '-\\' => '‿', + '_"' => ' ̈', + '_+' => '̟', + '_-' => '̠', + '_/' => '̌', + '_0' => '̥', + '_<' => '', + '=' => '̩', + '_=' => '̩', + '_>' => 'ʼ', + '_?\\' => 'ˤ', + '_\\' => '̂', + '_^' => '̯', + '_}' => '̚', + '`' => '˞', + '~' => '̃', + '_~' => '̃', + '_A' => '̘', + '_a' => '̺', + '_B' => '̏', + '_B_L' => '᷅', + '_c' => '̜', + '_d' => '̪', + '_e' => '̴', + '' => '↘', + '_F' => '̂', + '_G' => 'ˠ', + '_H' => '́', + '_H_T' => '᷄', + '_h' => 'ʰ', + '_k' => '̰', + '_L' => '̀', + '_l' => 'ˡ', + '_M' => '̄', + '_m' => '̻', + '_N' => '̼', + '_n' => 'ⁿ', + '_O' => '̹', + '_o' => '̞', + '_q' => '̙', + '' => '↗', + '_R' => '̌', + '_R_F' => '᷈', + '_r' => '̝', + '_T' => '̋', + '_t' => '̤', + '_v' => '̬', + '_w' => 'ʷ', + '_X' => '̆', + '_x' => '̽' + }.sort_by { |k, _| k.length }.reverse!.freeze - def self.convert(ipa) - XSAMPA_MAP.each do |k, v| - ipa.gsub!(k, v) - end + def self.convert(ipa) + XSAMPA_MAP.each do |k, v| + ipa.gsub!(k, v) + end - ipa + ipa + end end + # rubocop: enable Metrics/ModuleLength end -# rubocop: enable Metrics/ModuleLength diff --git a/modules/arch.rb b/modules/arch.rb index ebae465..97de96b 100644 --- a/modules/arch.rb +++ b/modules/arch.rb @@ -16,7 +16,7 @@ module Arch min_args: 1 } do |event, *_| query = after_nth_word(1, event.text) - page = ArchWiki.find_page(query) + page = QBot::ArchWiki.find_page(query) next embed t('arch.wiki.no-results') unless page diff --git a/modules/languages.rb b/modules/languages.rb index 463de35..000a287 100644 --- a/modules/languages.rb +++ b/modules/languages.rb @@ -14,6 +14,6 @@ module Languages # fix mention escapes text.gsub!('\\@', '@') - embed XSConverter.convert(text) + embed QBot::XSConverter.convert(text) end end diff --git a/modules/sitelenpona.rb b/modules/sitelenpona.rb index f7afb14..711c199 100644 --- a/modules/sitelenpona.rb +++ b/modules/sitelenpona.rb @@ -38,7 +38,7 @@ def describe_validation UserConfig.extend_schema do group :sitelenpona do - defaults = SPGen::DrawOptions.new + defaults = QBot::SPGen::DrawOptions.new option :fg_color, TString.new, default: defaults.fg_color option :bg_color, TString.new, default: defaults.bg_color @@ -63,7 +63,7 @@ def self.draw_options(user) symbols = %i[fontface fontsize bg_color fg_color] keywords = symbols.to_h { [_1, cfg[:sitelenpona, _1]] } - SPGen::DrawOptions.new(**keywords) + QBot::SPGen::DrawOptions.new(**keywords) end def self.name_as_glyphs(member, options) @@ -107,7 +107,7 @@ def self.get_sp_params(event) } do |event, _| text, options = get_sp_params(event) - file = NamedStringIO.new( + file = QBot::NamedStringIO.new( SPGen.draw_text(text, options), path: "#{event.author.id}.png" ) @@ -126,7 +126,7 @@ def self.get_sp_params(event) options.font_face = font.typeface filename = "#{event.author.id}.png" - file = NamedStringIO.new( + file = QBot::NamedStringIO.new( SPGen.draw_text(text, options), path: filename ) diff --git a/modules/tio.rb b/modules/tio.rb index c7de931..4cc0e4c 100644 --- a/modules/tio.rb +++ b/modules/tio.rb @@ -34,7 +34,7 @@ def self.get_codespans(text) } do |event, lang, *_args| code, input = get_codespans(event.message.text) - raw_res = TIO.run(lang, code, nil, input)[0] + raw_res = QBot::TIO.run(lang, code, nil, input)[0] .encode('UTF-8', invalid: :replace, undef: :replace, replace: '�') res = raw_res.gsub('```', '\\```').gsub('@', "\\@\u200D") msg = embed "```\n#{res}\n```" do |m| @@ -50,7 +50,7 @@ def self.get_codespans(text) min_args: 0, max_args: 1 } do |_, cat| - langs = cat ? (TIO.languages_by_category(cat) || []) : TIO.languages + langs = cat ? (QBot::TIO.languages_by_category(cat) || []) : QBot::TIO.languages embed langs.keys.join(', ').truncate(2048) end diff --git a/modules/tokipona.rb b/modules/tokipona.rb index 6d499cf..839b107 100644 --- a/modules/tokipona.rb +++ b/modules/tokipona.rb @@ -8,7 +8,7 @@ module Tokipona def self.tpo_field(text) = { name: 'tokipona.org', value: text } def self.pu_desc(query) - dict = TPDict.instance + dict = QBot::TPDict.instance res = dict.query_pu(query) res || t('tokipona.nimi.not-found') @@ -19,7 +19,7 @@ def self.pu_desc(query) usage: '.nimi ', min_args: 1 } do |event, *_| - dict = TPDict.instance + dict = QBot::TPDict.instance query = after_nth_word(1, event.text) tpo_res = @@ -42,7 +42,7 @@ def self.pu_desc(query) usage: '.tpo ', min_args: 1 } do |event, *_| - dict = TPDict.instance + dict = QBot::TPDict.instance query = after_nth_word(1, event.text) res = dict.query_tp_inli(query) @@ -62,7 +62,7 @@ def self.pu_desc(query) } do embed do |m| m.title = t('tokipona.attrib.title') - m.description = t('tokipona.attrib.text', TPDict.instance.sourcelist) + m.description = t('tokipona.attrib.text', QBot::TPDict.instance.sourcelist) end end end diff --git a/modules/xkcd.rb b/modules/xkcd.rb index 4e2b53f..46a5e8f 100644 --- a/modules/xkcd.rb +++ b/modules/xkcd.rb @@ -7,7 +7,7 @@ module Xkcd def self.xkcd_embed(info) embed do |m| m.title = "xkcd: #{info[:safe_title]}" - m.url = XKCD.comic_url(info) + m.url = QBot::XKCD.comic_url(info) m.image = { url: info[:img] } m.footer = { text: info[:alt] } end @@ -20,14 +20,14 @@ def self.xkcd_embed(info) } do |_, *args| case args in [/^[lL]/, *] - xkcd_embed(XKCD.latest_info) + xkcd_embed(QBot::XKCD.latest_info) in [arg, *] num = parse_int(arg) next embed t('nyi') unless num - xkcd_embed(XKCD.get_info(num)) + xkcd_embed(QBot::XKCD.get_info(num)) else - xkcd_embed(XKCD.random_info) + xkcd_embed(QBot::XKCD.random_info) end end end From ecc798c6c4faa45909ca58d6e92857dce930452f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:17:05 +0000 Subject: [PATCH 3/8] refactor: split colors.rb into separate concerns Split the large colors.rb module into separate files for commands, embeds, and events following the principle of single responsibility per file. - Create modules/colors/commands.rb for command definitions - Create modules/colors/embeds.rb for RCEmbed and CCREmbed classes - Create modules/colors/events.rb for event handlers - Update main colors.rb to require sub-files and include event container - Replace direct t() calls with QBot::Helpers.t() in isolated files Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- modules/colors.rb | 337 +------------------------------------ modules/colors/commands.rb | 194 +++++++++++++++++++++ modules/colors/embeds.rb | 91 ++++++++++ modules/colors/events.rb | 52 ++++++ 4 files changed, 340 insertions(+), 334 deletions(-) create mode 100644 modules/colors/commands.rb create mode 100644 modules/colors/embeds.rb create mode 100644 modules/colors/events.rb diff --git a/modules/colors.rb b/modules/colors.rb index 3ef5283..4d88663 100644 --- a/modules/colors.rb +++ b/modules/colors.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require_relative 'colors/wrapped_color_role' +require_relative 'colors/embeds' +require_relative 'colors/commands' +require_relative 'colors/events' ServerConfig.extend_schema do option :use_bare_colors, TBoolean.new, default: false @@ -10,344 +13,10 @@ default: 'on_join' end -# rubocop: disable Metrics/ModuleLength - ## # Color role assignment module Colors extend Discordrb::Commands::CommandContainer - ## - # Check if a string represents a hex color code, '#XXXXXX' or 'XXXXXX' - def self.hex_code?(string) = string.match?(/^#?[[:xdigit:]]{6}$/) - - ## - # Assigns a role to a member, ensuring they have only one from a given list. - def self.assign_color_role(member, new_role) - all_roles = ColorRole.for(member.server).map(&:role) - - if member.role?(new_role) - embed t('colors.assign-role.already-have') - else - member.modify_roles(new_role, all_roles, 'Change color') - embed t('colors.assign-role.success', new_role.name) - end - end - - command :color, { - aliases: [:c], - help_available: true, - usage: '.c ', - min_args: 1 - } do |event, *args| - new_role = ColorRole.search(event.server, args.join(' ')) - next embed t('colors.color.not-found') unless new_role - - Colors.assign_color_role(event.author, new_role.role) - end - - command :closestcolor, { - aliases: [:cc], - help_available: true, - usage: '.cc ', - min_args: 1, - max_args: 1 - } do |event, target| - is_valid = Colors.hex_code?(target) - next embed t('colors.closest.invalid-hex', target) unless is_valid - - closest = ColorRole.find_closest_on(server, target) - embed t('colors.closest.found', closest.hex_code) - - Colors.assign_color_role(event.author, closest.role) - end - - command :listcolors, { - aliases: [:lc], - help_available: true, - usage: '.lc', - min_args: 0, - max_args: 0 - } do |event, *_args| - entries = ColorRole.for(event.server) - - lines = entries.map.with_index { |r, i| r.to_list_line(i, entries.count) } - - embed do |m| - m.title = t('colors.list.title') - m.description = lines.join("\n") - end - end - - ## - # Randomize colors embed - class RCEmbed - attr_accessor :msg, :count - - private def mk_embed(description = '') - { title: t('colors.rc.begin', @count), description: } - end - - def initialize(count) - @count = count - @msg = yield mk_embed - end - - def progress=(index) - embed = mk_embed(t('colors.rc.progress', index, @count)) - @msg.edit('', embed) - end - - def finish! - embed = { title: t('colors.rc.success', @count) } - @msg.edit('', embed) - end - end - - def self.find_targets(server, roles) - server.members.reject { _1.roles.intersect? roles } - end - - def self.randomize_color_roles(server, &) - roles = ColorRole.for(server).map(&:role) - targets = find_targets(server, roles) - - m = RCEmbed.new(targets.count, &) - - targets.each_with_index do |target, index| - target.add_role(roles.sample, 'Randomly assigning color role') - m.progress = index - end - - m.finish! - end - - ## - # Randomly assign a color role to members who do not have one - command :randcolors, { - aliases: [:rc], - help_available: true, - usage: '.rc', - min_args: 0, - max_args: 0 - } do |event| - next embed t('no_perms') unless event.author.permission?(:manage_roles) - - randomize_color_roles(event.server) { event.send_embed('', _1) } - end - - ## - # Embed for creating color roles - class CCREmbed - attr_accessor :msg, :embeds - - def update_msg! - @msg.edit('', embeds.values) - end - - def initialize(old_count) - @embeds = {} - @old_count = old_count - @new_count = nil - - deleting_embed = { - title: t('colors.ccr.deleting', old_count), - description: '' - } - - @embeds[:deleting] = deleting_embed - - @msg = yield @embeds.values - end - - def show_role_delete!(role, index) - message = \ - t('colors.ccr.deleted', index, @old_count, role.name) - - @embeds[:deleting][:description] += "#{message}\n" - - update_msg! - end - - def begin_create_stage!(count) - @new_count = count - - creating_embed = { - title: t('colors.ccr.creating', count), - description: '' - } - - @embeds[:creating] = creating_embed - - update_msg! - end - - def show_role_create!(role, index) - message = \ - t('colors.ccr.created', index, @new_count, role.mention, role.hex_code) - - @embeds[:creating][:description] += "#{message}\n" - - update_msg! - end - - def success! - success_embed = { title: t('colors.ccr.success', @new_count) } - @embeds[:success] = success_embed - - update_msg! - end - end - - # rubocop: disable Metrics/MethodLength, Metrics/AbcSize - def self.create_color_roles(server, lightness, radius, count, &) - # Delete all roles detected as auto-generated color roles - old_roles = ColorRole.for(server, bare: false, extra: false) - - m = CCREmbed.new(old_roles.count, &) - - old_roles.each_with_index do |role, index| - m.show_role_delete!(role, index + 1) - role.destroy! - end - - QBot.bot.init_cache # otherwise the old roles will stay in cache :( - - m.begin_create_stage!(count) - - new_colors = ColorRole.color_ring(lightness, radius, count) - - new_colors.each_with_index do |hex, index| - role = ColorRole.create_generated(server, hex, index) - role.move_to_bottom! - - m.show_role_create!(role, index + 1) - end - - QBot.bot.init_cache # otherwise roles will be seen in reverse order :( - - m.success! - end - # rubocop: enable Metrics/MethodLength, Metrics/AbcSize - - command :gencolors, { - aliases: %i[createcolorroles ccr], - help_available: true, - usage: '.gencolors ', - min_args: 3, - max_args: 3, - arg_types: [Float, Float, Integer] - } do |event, l, r, c| - next embed t('no_perms') unless event.author.permission?(:manage_roles) - - create_color_roles(event.server, l, r, c) { event.send_embed('', _1) } - end - - command :extracolorroles, { - aliases: %i[ecr], - help_available: true, - usage: '.extracolorroles', - min_args: 0, - max_args: 0 - } do |event| - records = ExtraColorRole.for(event.server) - next embed t('colors.extra-roles.list.empty') if records.empty? - - roles = records.pluck(:role_id).map { event.server.role(_1) } - - embed do |m| - m.title = t('colors.extra-roles.list.title') - m.description = roles.map { |role| - color_code = role.color.hex.rjust(6, '0') - "`##{color_code}`: `#{role.id}` #{role.mention}" - }.join("\n") - end - end - - command :addextracolorrole, { - aliases: %i[aecr], - help_available: true, - usage: '.addextracolorrole ', - min_args: 1, - max_args: 1, - arg_types: [Discordrb::Role] - } do |event, role| - next embed t('colors.extra-roles.bad-role') unless role - - ExtraColorRole.for(event.server).create(role_id: role.id) - embed t('colors.extra-roles.add.success', role.mention) - rescue ActiveRecord::RecordNotUnique - embed t('colors.extra-roles.add.duplicate', role.mention) - end - - command :delextracolorrole, { - aliases: %i[decr], - help_available: true, - usage: '.delextracolorrole ', - min_args: 1, - max_args: 1, - arg_types: [Discordrb::Role] - } do |event, role| - next embed t('colors.extra-roles.bad-role') unless role - - ExtraColorRole.for(event.server).find_by!(role_id: role.id).destroy - embed t('colors.extra-roles.del.success', role.mention) - rescue ActiveRecord::RecordNotFound - embed t('colors.extra-roles.del.not-found', role.mention) - end -end -# rubocop: enable Metrics/ModuleLength - -## Event container for the Colors module -module ColorsEvents - extend Discordrb::EventContainer - - # Color roles on join - - member_leave do |event| - PendingMember.for(event.server).destroy_by(user_id: event.user.id) - end - - def give_random_color(server, user) - new_role = Colors::ColorRole.for(server).sample.role - user.add_role(new_role) - end - - member_join do |event| - opt = ServerConfig.for(event.server)[:auto_assign_colors] - - give_random_color(event.server, event.user) if opt == 'on_join' - end - - raw(type: :GUILD_MEMBER_ADD) do |event| - server_id = event.data['guild_id'].to_i - opt = ServerConfig.for(server_id)[:auto_assign_colors] - - if opt == 'on_screening_pass' && event.data['pending'] - user_id = event.data.dig('user', 'id') - PendingMember.create!(server_id:, user_id:) - end - end - - raw(type: :GUILD_MEMBER_UPDATE) do |event| - server_id = event.data['guild_id'].to_i - opt = ServerConfig.for(server_id)[:auto_assign_colors] - - if opt == 'on_screening_pass' && event.data['pending'] == false - user_id = event.data.dig('user', 'id') - record = PendingMember.find_by!(server_id:, user_id:) - - server = event.bot.server(server_id) - member = server.member(user_id) - - give_random_color(server, member) - record.destroy! - end - rescue ActiveRecord::RecordNotFound - nil - end -end - -module Colors include! ColorsEvents end diff --git a/modules/colors/commands.rb b/modules/colors/commands.rb new file mode 100644 index 0000000..cb79511 --- /dev/null +++ b/modules/colors/commands.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module Colors + ## + # Check if a string represents a hex color code, '#XXXXXX' or 'XXXXXX' + def self.hex_code?(string) = string.match?(/^#?[[:xdigit:]]{6}$/) + + ## + # Assigns a role to a member, ensuring they have only one from a given list. + def self.assign_color_role(member, new_role) + all_roles = ColorRole.for(member.server).map(&:role) + + if member.role?(new_role) + embed QBot::Helpers.t('colors.assign-role.already-have') + else + member.modify_roles(new_role, all_roles, 'Change color') + embed QBot::Helpers.t('colors.assign-role.success', new_role.name) + end + end + + command :color, { + aliases: [:c], + help_available: true, + usage: '.c ', + min_args: 1 + } do |event, *args| + new_role = ColorRole.search(event.server, args.join(' ')) + next embed QBot::Helpers.t('colors.color.not-found') unless new_role + + Colors.assign_color_role(event.author, new_role.role) + end + + command :closestcolor, { + aliases: [:cc], + help_available: true, + usage: '.cc ', + min_args: 1, + max_args: 1 + } do |event, target| + is_valid = Colors.hex_code?(target) + next embed QBot::Helpers.t('colors.closest.invalid-hex', target) unless is_valid + + closest = ColorRole.find_closest_on(event.server, target) + embed QBot::Helpers.t('colors.closest.found', closest.hex_code) + + Colors.assign_color_role(event.author, closest.role) + end + + command :listcolors, { + aliases: [:lc], + help_available: true, + usage: '.lc', + min_args: 0, + max_args: 0 + } do |event, *_args| + entries = ColorRole.for(event.server) + + lines = entries.map.with_index { |r, i| r.to_list_line(i, entries.count) } + + embed do |m| + m.title = QBot::Helpers.t('colors.list.title') + m.description = lines.join("\n") + end + end + + def self.find_targets(server, roles) + server.members.reject { _1.roles.intersect? roles } + end + + def self.randomize_color_roles(server, &) + roles = ColorRole.for(server).map(&:role) + targets = find_targets(server, roles) + + m = RCEmbed.new(targets.count, &) + + targets.each_with_index do |target, index| + target.add_role(roles.sample, 'Randomly assigning color role') + m.progress = index + end + + m.finish! + end + + ## + # Randomly assign a color role to members who do not have one + command :randcolors, { + aliases: [:rc], + help_available: true, + usage: '.rc', + min_args: 0, + max_args: 0 + } do |event| + next embed QBot::Helpers.t('no_perms') unless event.author.permission?(:manage_roles) + + randomize_color_roles(event.server) { event.send_embed('', _1) } + end + + # rubocop: disable Metrics/MethodLength, Metrics/AbcSize + def self.create_color_roles(server, lightness, radius, count, &) + # Delete all roles detected as auto-generated color roles + old_roles = ColorRole.for(server, bare: false, extra: false) + + m = CCREmbed.new(old_roles.count, &) + + old_roles.each_with_index do |role, index| + m.show_role_delete!(role, index + 1) + role.destroy! + end + + QBot.bot.init_cache # otherwise the old roles will stay in cache :( + + m.begin_create_stage!(count) + + new_colors = ColorRole.color_ring(lightness, radius, count) + + new_colors.each_with_index do |hex, index| + role = ColorRole.create_generated(server, hex, index) + role.move_to_bottom! + + m.show_role_create!(role, index + 1) + end + + QBot.bot.init_cache # otherwise roles will be seen in reverse order :( + + m.success! + end + # rubocop: enable Metrics/MethodLength, Metrics/AbcSize + + command :gencolors, { + aliases: %i[createcolorroles ccr], + help_available: true, + usage: '.gencolors ', + min_args: 3, + max_args: 3, + arg_types: [Float, Float, Integer] + } do |event, l, r, c| + next embed QBot::Helpers.t('no_perms') unless event.author.permission?(:manage_roles) + + create_color_roles(event.server, l, r, c) { event.send_embed('', _1) } + end + + command :extracolorroles, { + aliases: %i[ecr], + help_available: true, + usage: '.extracolorroles', + min_args: 0, + max_args: 0 + } do |event| + records = ExtraColorRole.for(event.server) + next embed QBot::Helpers.t('colors.extra-roles.list.empty') if records.empty? + + roles = records.pluck(:role_id).map { event.server.role(_1) } + + embed do |m| + m.title = QBot::Helpers.t('colors.extra-roles.list.title') + m.description = roles.map { |role| + color_code = role.color.hex.rjust(6, '0') + "`##{color_code}`: `#{role.id}` #{role.mention}" + }.join("\n") + end + end + + command :addextracolorrole, { + aliases: %i[aecr], + help_available: true, + usage: '.addextracolorrole ', + min_args: 1, + max_args: 1, + arg_types: [Discordrb::Role] + } do |event, role| + next embed QBot::Helpers.t('colors.extra-roles.bad-role') unless role + + ExtraColorRole.for(event.server).create(role_id: role.id) + embed QBot::Helpers.t('colors.extra-roles.add.success', role.mention) + rescue ActiveRecord::RecordNotUnique + embed QBot::Helpers.t('colors.extra-roles.add.duplicate', role.mention) + end + + command :delextracolorrole, { + aliases: %i[decr], + help_available: true, + usage: '.delextracolorrole ', + min_args: 1, + max_args: 1, + arg_types: [Discordrb::Role] + } do |event, role| + next embed QBot::Helpers.t('colors.extra-roles.bad-role') unless role + + ExtraColorRole.for(event.server).find_by!(role_id: role.id).destroy + embed QBot::Helpers.t('colors.extra-roles.del.success', role.mention) + rescue ActiveRecord::RecordNotFound + embed QBot::Helpers.t('colors.extra-roles.del.not-found', role.mention) + end +end diff --git a/modules/colors/embeds.rb b/modules/colors/embeds.rb new file mode 100644 index 0000000..c3cbd23 --- /dev/null +++ b/modules/colors/embeds.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Colors + ## + # Randomize colors embed + class RCEmbed + attr_accessor :msg, :count + + private def mk_embed(description = '') + { title: QBot::Helpers.t('colors.rc.begin', @count), description: } + end + + def initialize(count) + @count = count + @msg = yield mk_embed + end + + def progress=(index) + embed = mk_embed(QBot::Helpers.t('colors.rc.progress', index, @count)) + @msg.edit('', embed) + end + + def finish! + embed = { title: QBot::Helpers.t('colors.rc.success', @count) } + @msg.edit('', embed) + end + end + + ## + # Embed for creating color roles + class CCREmbed + attr_accessor :msg, :embeds + + def update_msg! + @msg.edit('', embeds.values) + end + + def initialize(old_count) + @embeds = {} + @old_count = old_count + @new_count = nil + + deleting_embed = { + title: QBot::Helpers.t('colors.ccr.deleting', old_count), + description: '' + } + + @embeds[:deleting] = deleting_embed + + @msg = yield @embeds.values + end + + def show_role_delete!(role, index) + message = \ + QBot::Helpers.t('colors.ccr.deleted', index, @old_count, role.name) + + @embeds[:deleting][:description] += "#{message}\n" + + update_msg! + end + + def begin_create_stage!(count) + @new_count = count + + creating_embed = { + title: QBot::Helpers.t('colors.ccr.creating', count), + description: '' + } + + @embeds[:creating] = creating_embed + + update_msg! + end + + def show_role_create!(role, index) + message = \ + QBot::Helpers.t('colors.ccr.created', index, @new_count, role.mention, role.hex_code) + + @embeds[:creating][:description] += "#{message}\n" + + update_msg! + end + + def success! + success_embed = { title: QBot::Helpers.t('colors.ccr.success', @new_count) } + @embeds[:success] = success_embed + + update_msg! + end + end +end diff --git a/modules/colors/events.rb b/modules/colors/events.rb new file mode 100644 index 0000000..1f12c7f --- /dev/null +++ b/modules/colors/events.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +## +# Event container for the Colors module +module ColorsEvents + extend Discordrb::EventContainer + + # Color roles on join + + member_leave do |event| + PendingMember.for(event.server).destroy_by(user_id: event.user.id) + end + + def give_random_color(server, user) + new_role = Colors::ColorRole.for(server).sample.role + user.add_role(new_role) + end + + member_join do |event| + opt = ServerConfig.for(event.server)[:auto_assign_colors] + + give_random_color(event.server, event.user) if opt == 'on_join' + end + + raw(type: :GUILD_MEMBER_ADD) do |event| + server_id = event.data['guild_id'].to_i + opt = ServerConfig.for(server_id)[:auto_assign_colors] + + if opt == 'on_screening_pass' && event.data['pending'] + user_id = event.data.dig('user', 'id') + PendingMember.create!(server_id:, user_id:) + end + end + + raw(type: :GUILD_MEMBER_UPDATE) do |event| + server_id = event.data['guild_id'].to_i + opt = ServerConfig.for(server_id)[:auto_assign_colors] + + if opt == 'on_screening_pass' && event.data['pending'] == false + user_id = event.data.dig('user', 'id') + record = PendingMember.find_by!(server_id:, user_id:) + + server = event.bot.server(server_id) + member = server.member(user_id) + + give_random_color(server, member) + record.destroy! + end + rescue ActiveRecord::RecordNotFound + nil + end +end From 235c3a91c5b21f93383f1ae896da90f4ad8b3de0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:19:18 +0000 Subject: [PATCH 4/8] refactor: extract view layer presenters for arch, queries, and colors Begin extracting a view layer by creating presenter modules that separate presentation logic from business logic in command modules. - Create modules/arch/presenters.rb with package formatting methods - Create modules/queries/presenters.rb with query field formatting - Create modules/colors/presenters.rb with extra role formatting - Update arch.rb to use Arch::Presenters - Update queries.rb to use Queries::Presenters - Update colors/commands.rb to use Colors::Presenters - Separate embed data structure creation from rendering Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- modules/arch.rb | 48 +++++++++--------------------- modules/arch/presenters.rb | 55 +++++++++++++++++++++++++++++++++++ modules/colors/commands.rb | 7 ++--- modules/colors/presenters.rb | 16 ++++++++++ modules/queries.rb | 8 ++--- modules/queries/presenters.rb | 17 +++++++++++ 6 files changed, 108 insertions(+), 43 deletions(-) create mode 100644 modules/arch/presenters.rb create mode 100644 modules/colors/presenters.rb create mode 100644 modules/queries/presenters.rb diff --git a/modules/arch.rb b/modules/arch.rb index 97de96b..c66e570 100644 --- a/modules/arch.rb +++ b/modules/arch.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'arch/presenters' + # every day at 3 AM: QBot.scheduler.cron '0 3 * * *' do UpdateArchReposJob.perform_later @@ -27,22 +29,14 @@ module Arch end def self.package_field(pkg) - pkg => {repo:, name:, version:, desc:} - date = pkg.builddate.strftime('%Y-%m-%d') - - { - name: "#{repo}/#{name}", - value: <<~VAL - #{desc} - #{t('arch.ps.result-footer', version, date, pkg.web_url)} - VAL - } + Presenters.package_field(pkg) end def self.package_search_embed(query, pkgs) + data = Presenters.package_search_embed_data(query, pkgs) embed do |m| - m.title = t('arch.ps.title', query) - m.fields = pkgs.first(5).map { package_field(_1) } + m.title = data[:title] + m.fields = data[:fields] end end @@ -61,32 +55,18 @@ def self.package_search_embed(query, pkgs) package_search_embed(query, results) end - # rubocop: disable Metrics/MethodLength, Metrics/AbcSize def self.package_embed(pkg) - csize = pkg.csize.to_fs(:human_size) - isize = pkg.isize.to_fs(:human_size) - license = pkg.license.join(', ') - + data = Presenters.package_embed_data(pkg) embed do |m| - m.color = 0x0088cc - - m.title = "#{pkg.repo}/#{pkg.name}" - m.url = pkg.web_url - m.description = pkg.desc - - m.fields = [ - { name: t('arch.package.url'), value: pkg.url }, - { name: t('arch.package.license'), value: license, inline: true }, - { name: t('arch.package.csize'), value: csize, inline: true }, - { name: t('arch.package.isize'), value: isize, inline: true }, - { name: t('arch.package.packager'), value: pkg.packager } - ] - - m.footer = { text: t('arch.package.version', pkg.version) } - m.timestamp = pkg.builddate + m.color = data[:color] + m.title = data[:title] + m.url = data[:url] + m.description = data[:description] + m.fields = data[:fields] + m.footer = data[:footer] + m.timestamp = data[:timestamp] end end - # rubocop: enable Metrics/MethodLength, Metrics/AbcSize command :package, { aliases: [:p], diff --git a/modules/arch/presenters.rb b/modules/arch/presenters.rb new file mode 100644 index 0000000..c911187 --- /dev/null +++ b/modules/arch/presenters.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Arch + ## + # Presenter methods for Arch module embeds and formatting + module Presenters + ## + # Format a package as a field for embed display + def self.package_field(pkg) + pkg => {repo:, name:, version:, desc:} + date = pkg.builddate.strftime('%Y-%m-%d') + + { + name: "#{repo}/#{name}", + value: <<~VAL + #{desc} + #{QBot::Helpers.t('arch.ps.result-footer', version, date, pkg.web_url)} + VAL + } + end + + ## + # Build embed data structure for package search results + def self.package_search_embed_data(query, pkgs) + { + title: QBot::Helpers.t('arch.ps.title', query), + fields: pkgs.first(5).map { package_field(_1) } + } + end + + ## + # Build embed data structure for package details + def self.package_embed_data(pkg) + csize = pkg.csize.to_fs(:human_size) + isize = pkg.isize.to_fs(:human_size) + license = pkg.license.join(', ') + + { + color: 0x0088cc, + title: "#{pkg.repo}/#{pkg.name}", + url: pkg.web_url, + description: pkg.desc, + fields: [ + { name: QBot::Helpers.t('arch.package.url'), value: pkg.url }, + { name: QBot::Helpers.t('arch.package.license'), value: license, inline: true }, + { name: QBot::Helpers.t('arch.package.csize'), value: csize, inline: true }, + { name: QBot::Helpers.t('arch.package.isize'), value: isize, inline: true }, + { name: QBot::Helpers.t('arch.package.packager'), value: pkg.packager } + ], + footer: { text: QBot::Helpers.t('arch.package.version', pkg.version) }, + timestamp: pkg.builddate + } + end + end +end diff --git a/modules/colors/commands.rb b/modules/colors/commands.rb index cb79511..e87eb1a 100644 --- a/modules/colors/commands.rb +++ b/modules/colors/commands.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'presenters' + module Colors ## # Check if a string represents a hex color code, '#XXXXXX' or 'XXXXXX' @@ -153,10 +155,7 @@ def self.create_color_roles(server, lightness, radius, count, &) embed do |m| m.title = QBot::Helpers.t('colors.extra-roles.list.title') - m.description = roles.map { |role| - color_code = role.color.hex.rjust(6, '0') - "`##{color_code}`: `#{role.id}` #{role.mention}" - }.join("\n") + m.description = Presenters.extra_color_roles_description(roles) end end diff --git a/modules/colors/presenters.rb b/modules/colors/presenters.rb new file mode 100644 index 0000000..3ac2510 --- /dev/null +++ b/modules/colors/presenters.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Colors + ## + # Presenter methods for Colors module embeds and formatting + module Presenters + ## + # Format extra color roles for embed display + def self.extra_color_roles_description(roles) + roles.map { |role| + color_code = role.color.hex.rjust(6, '0') + "`##{color_code}`: `#{role.id}` #{role.mention}" + }.join("\n") + end + end +end diff --git a/modules/queries.rb b/modules/queries.rb index 4f61252..7010a3a 100644 --- a/modules/queries.rb +++ b/modules/queries.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'queries/presenters' + # Support channel management module Queries extend Discordrb::Commands::CommandContainer @@ -20,11 +22,7 @@ module Queries end def self.query_field(query) - query => { id:, created_at:, text: } - - name = t('queries.oq.entry-name', id, query.user.distinct, created_at) - - { name:, value: text } + Presenters.query_field(query) end command :openqueries, { diff --git a/modules/queries/presenters.rb b/modules/queries/presenters.rb new file mode 100644 index 0000000..44c7098 --- /dev/null +++ b/modules/queries/presenters.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Queries + ## + # Presenter methods for Queries module embeds and formatting + module Presenters + ## + # Format a query as a field for embed display + def self.query_field(query) + query => { id:, created_at:, text: } + + name = QBot::Helpers.t('queries.oq.entry-name', id, query.user.distinct, created_at) + + { name:, value: text } + end + end +end From bd6c221828602dc07667973c1607dff0f84226bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:58:57 +0000 Subject: [PATCH 5/8] chore: add startup test infrastructure and documentation Add infrastructure to test qbot startup in Nix environment: - Create db/ and var/ directories with .gitkeep files - Add test_startup.sh script for automated testing - Add STARTUP_TEST.md documentation - Update .gitignore for database files The configuration file (config/global.yml) is created locally but gitignored. Users should create it from global.yml.example with appropriate credentials. Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- .gitignore | 4 ++ STARTUP_TEST.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ db/.gitkeep | 0 test_startup.sh | 77 +++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 STARTUP_TEST.md create mode 100644 db/.gitkeep create mode 100755 test_startup.sh diff --git a/.gitignore b/.gitignore index 2e6b9d7..dc7523e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ var/** .git /.gem_rbs_collection/** + +# Database files +db/*.sqlite3 +db/*.sqlite3-* diff --git a/STARTUP_TEST.md b/STARTUP_TEST.md new file mode 100644 index 0000000..5dfd245 --- /dev/null +++ b/STARTUP_TEST.md @@ -0,0 +1,110 @@ +# QBot Startup Test Guide + +This document describes how to test that qbot can start up properly. + +## Prerequisites + +- Nix with flakes support +- Access to qbot repository + +## Quick Start + +The easiest way to test the bot startup is using the provided test script: + +```bash +# Enter the Nix development environment +nix develop + +# Run the automated test +./test_startup.sh +``` + +The script will check prerequisites, validate the configuration, and attempt to start the bot. + +## Configuration + +A minimal configuration file should be created at `config/global.yml` with: +- Placeholder token (e.g., `test_token_placeholder`) +- Test client ID (e.g., `123456789`) +- All default modules enabled +- SQLite database configuration + +Example configuration is available in `config/global.yml.example`. + +## Directory Setup + +The following directories are needed: +- `db/` - For SQLite database files (preserved in git with `.gitkeep`) +- `var/` - For state directory (preserved in git with `.gitkeep`) + +These directories are tracked in git but their contents are gitignored. + +## Manual Testing + +### Option 1: Using Nix Development Shell + +```bash +# Enter the Nix development environment +nix develop + +# Run the bot with no-console flag to avoid interactive mode +./qbot --no-console +``` + +The bot will: +1. Parse the configuration file +2. Initialize the logger +3. Set up the database connection +4. Load all modules +5. Attempt to connect to Discord (will fail with invalid token, which is expected) + +### Option 2: Direct Execution + +```bash +# From within the Nix shell +bundle exec ruby qbot +``` + +## Expected Behavior + +The bot should: +- ✓ Successfully parse `config/global.yml` +- ✓ Initialize logger and print the logo +- ✓ Create database schema if it doesn't exist +- ✓ Load all 16 configured modules +- ✗ Fail to connect to Discord (expected with test token) + +The connection failure is expected and acceptable for this test. + +## Validating Success + +Success criteria: +1. No configuration parsing errors +2. No module loading errors +3. Database initializes correctly +4. Bot reaches the Discord connection stage + +The test token will cause a connection error like: +``` +Error: Invalid authentication token +``` + +This is expected and indicates the bot successfully initialized up to the connection phase. + +## Files in This Repository + +- `config/global.yml.example` - Example configuration (committed to git) +- `config/global.yml` - Your actual configuration (gitignored, create from example) +- `db/.gitkeep` - Preserves db/ directory in git +- `var/.gitkeep` - Preserves var/ directory in git +- `test_startup.sh` - Automated startup test script +- `STARTUP_TEST.md` - This documentation + +## Cleanup + +To clean up test artifacts: +```bash +rm -rf db/*.sqlite3 var/* +``` + +Note: Keep `config/global.yml` for future testing but do not commit it with real credentials. diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test_startup.sh b/test_startup.sh new file mode 100755 index 0000000..8254bb0 --- /dev/null +++ b/test_startup.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Test script to verify qbot can start up in Nix environment + +set -e + +echo "=== QBot Startup Test ===" +echo "" + +# Check if we're in a Nix environment +if ! command -v bundle &> /dev/null; then + echo "Error: bundle command not found." + echo "Please run this script from within the Nix development shell:" + echo " nix develop" + echo " ./test_startup.sh" + exit 1 +fi + +echo "✓ Nix environment detected" + +# Check configuration exists +if [ ! -f "config/global.yml" ]; then + echo "Error: config/global.yml not found" + echo "Please create a configuration file first." + exit 1 +fi + +echo "✓ Configuration file exists" + +# Check required directories +for dir in db var; do + if [ ! -d "$dir" ]; then + echo "Creating directory: $dir" + mkdir -p "$dir" + fi +done + +echo "✓ Required directories exist" + +# Test configuration parsing +echo "" +echo "Testing configuration parsing..." +ruby -e "require 'yaml'; YAML.load_file('config/global.yml'); puts '✓ Configuration parses correctly'" + +echo "" +echo "=== Attempting to start qbot ===" +echo "Note: The bot will fail to connect to Discord with the test token." +echo "This is expected. We're only testing initialization." +echo "" + +# Run the bot with a timeout to prevent hanging +# Use --no-console to avoid interactive mode +timeout 30 ./qbot --no-console 2>&1 | head -50 || { + exit_code=$? + if [ $exit_code -eq 124 ]; then + echo "" + echo "=== Test Result: TIMEOUT ===" + echo "The bot ran for 30 seconds without crashing." + echo "This suggests successful initialization up to Discord connection." + else + echo "" + echo "=== Test Result: EARLY EXIT ===" + echo "Exit code: $exit_code" + echo "Check the output above for errors during initialization." + fi +} + +echo "" +echo "=== Startup Test Complete ===" +echo "" +echo "Expected behavior:" +echo " - Configuration should parse successfully" +echo " - Logo should be displayed" +echo " - Database should initialize" +echo " - Modules should load" +echo " - Discord connection will fail (expected with test token)" +echo "" +echo "If you saw the logo and module loading messages, the test was successful!" From 5e24ce8ac43a8e125d2e28dd534f98d7d72d199f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:59:12 +0000 Subject: [PATCH 6/8] chore: add var/.gitkeep and update .gitignore to preserve it --- .gitignore | 1 + var/.gitkeep | 0 2 files changed, 1 insertion(+) create mode 100644 var/.gitkeep diff --git a/.gitignore b/.gitignore index dc7523e..46230b8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ config/global.yml **/yt-credentials.yml var/** +!var/.gitkeep .idea diff --git a/var/.gitkeep b/var/.gitkeep new file mode 100644 index 0000000..e69de29 From 8f925ceeba3b3c2e2207af14368cf59b2209e89a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:07:37 +0000 Subject: [PATCH 7/8] revert: remove unnecessary test infrastructure Remove test_startup.sh, STARTUP_TEST.md, and var/.gitkeep as they are not needed. The proper way to test qbot startup is simply using `nix run`. The var directory is configurable and doesn't need to be preserved in git. Only db/.gitkeep remains as the database needs a directory. Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- .gitignore | 1 - STARTUP_TEST.md | 110 ------------------------------------------------ test_startup.sh | 77 --------------------------------- var/.gitkeep | 0 4 files changed, 188 deletions(-) delete mode 100644 STARTUP_TEST.md delete mode 100755 test_startup.sh delete mode 100644 var/.gitkeep diff --git a/.gitignore b/.gitignore index 46230b8..dc7523e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ config/global.yml **/yt-credentials.yml var/** -!var/.gitkeep .idea diff --git a/STARTUP_TEST.md b/STARTUP_TEST.md deleted file mode 100644 index 5dfd245..0000000 --- a/STARTUP_TEST.md +++ /dev/null @@ -1,110 +0,0 @@ -# QBot Startup Test Guide - -This document describes how to test that qbot can start up properly. - -## Prerequisites - -- Nix with flakes support -- Access to qbot repository - -## Quick Start - -The easiest way to test the bot startup is using the provided test script: - -```bash -# Enter the Nix development environment -nix develop - -# Run the automated test -./test_startup.sh -``` - -The script will check prerequisites, validate the configuration, and attempt to start the bot. - -## Configuration - -A minimal configuration file should be created at `config/global.yml` with: -- Placeholder token (e.g., `test_token_placeholder`) -- Test client ID (e.g., `123456789`) -- All default modules enabled -- SQLite database configuration - -Example configuration is available in `config/global.yml.example`. - -## Directory Setup - -The following directories are needed: -- `db/` - For SQLite database files (preserved in git with `.gitkeep`) -- `var/` - For state directory (preserved in git with `.gitkeep`) - -These directories are tracked in git but their contents are gitignored. - -## Manual Testing - -### Option 1: Using Nix Development Shell - -```bash -# Enter the Nix development environment -nix develop - -# Run the bot with no-console flag to avoid interactive mode -./qbot --no-console -``` - -The bot will: -1. Parse the configuration file -2. Initialize the logger -3. Set up the database connection -4. Load all modules -5. Attempt to connect to Discord (will fail with invalid token, which is expected) - -### Option 2: Direct Execution - -```bash -# From within the Nix shell -bundle exec ruby qbot -``` - -## Expected Behavior - -The bot should: -- ✓ Successfully parse `config/global.yml` -- ✓ Initialize logger and print the logo -- ✓ Create database schema if it doesn't exist -- ✓ Load all 16 configured modules -- ✗ Fail to connect to Discord (expected with test token) - -The connection failure is expected and acceptable for this test. - -## Validating Success - -Success criteria: -1. No configuration parsing errors -2. No module loading errors -3. Database initializes correctly -4. Bot reaches the Discord connection stage - -The test token will cause a connection error like: -``` -Error: Invalid authentication token -``` - -This is expected and indicates the bot successfully initialized up to the connection phase. - -## Files in This Repository - -- `config/global.yml.example` - Example configuration (committed to git) -- `config/global.yml` - Your actual configuration (gitignored, create from example) -- `db/.gitkeep` - Preserves db/ directory in git -- `var/.gitkeep` - Preserves var/ directory in git -- `test_startup.sh` - Automated startup test script -- `STARTUP_TEST.md` - This documentation - -## Cleanup - -To clean up test artifacts: -```bash -rm -rf db/*.sqlite3 var/* -``` - -Note: Keep `config/global.yml` for future testing but do not commit it with real credentials. diff --git a/test_startup.sh b/test_startup.sh deleted file mode 100755 index 8254bb0..0000000 --- a/test_startup.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Test script to verify qbot can start up in Nix environment - -set -e - -echo "=== QBot Startup Test ===" -echo "" - -# Check if we're in a Nix environment -if ! command -v bundle &> /dev/null; then - echo "Error: bundle command not found." - echo "Please run this script from within the Nix development shell:" - echo " nix develop" - echo " ./test_startup.sh" - exit 1 -fi - -echo "✓ Nix environment detected" - -# Check configuration exists -if [ ! -f "config/global.yml" ]; then - echo "Error: config/global.yml not found" - echo "Please create a configuration file first." - exit 1 -fi - -echo "✓ Configuration file exists" - -# Check required directories -for dir in db var; do - if [ ! -d "$dir" ]; then - echo "Creating directory: $dir" - mkdir -p "$dir" - fi -done - -echo "✓ Required directories exist" - -# Test configuration parsing -echo "" -echo "Testing configuration parsing..." -ruby -e "require 'yaml'; YAML.load_file('config/global.yml'); puts '✓ Configuration parses correctly'" - -echo "" -echo "=== Attempting to start qbot ===" -echo "Note: The bot will fail to connect to Discord with the test token." -echo "This is expected. We're only testing initialization." -echo "" - -# Run the bot with a timeout to prevent hanging -# Use --no-console to avoid interactive mode -timeout 30 ./qbot --no-console 2>&1 | head -50 || { - exit_code=$? - if [ $exit_code -eq 124 ]; then - echo "" - echo "=== Test Result: TIMEOUT ===" - echo "The bot ran for 30 seconds without crashing." - echo "This suggests successful initialization up to Discord connection." - else - echo "" - echo "=== Test Result: EARLY EXIT ===" - echo "Exit code: $exit_code" - echo "Check the output above for errors during initialization." - fi -} - -echo "" -echo "=== Startup Test Complete ===" -echo "" -echo "Expected behavior:" -echo " - Configuration should parse successfully" -echo " - Logo should be displayed" -echo " - Database should initialize" -echo " - Modules should load" -echo " - Discord connection will fail (expected with test token)" -echo "" -echo "If you saw the logo and module loading messages, the test was successful!" diff --git a/var/.gitkeep b/var/.gitkeep deleted file mode 100644 index e69de29..0000000 From 369b856011d03eb9a80c1a82e8633bfb3b774e00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:30:08 +0000 Subject: [PATCH 8/8] fix: update load_by_glob call in jobs.rb to use QBot::Database After moving load_by_glob to QBot::Database module, jobs.rb was still calling it as a top-level method, causing NoMethodError at startup. Update the call to QBot::Database.load_by_glob('jobs', '*.rb'). Co-authored-by: anna328p <9790772+anna328p@users.noreply.github.com> --- lib/qbot/jobs.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/qbot/jobs.rb b/lib/qbot/jobs.rb index 0f20b3d..8051143 100644 --- a/lib/qbot/jobs.rb +++ b/lib/qbot/jobs.rb @@ -7,4 +7,4 @@ def self.logger = Logger.new($stdout) ActiveJob::Base.queue_adapter = :delayed_job -load_by_glob('jobs', '*.rb') +QBot::Database.load_by_glob('jobs', '*.rb')