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')