Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ var/**
.git

/.gem_rbs_collection/**

# Database files
db/*.sqlite3
db/*.sqlite3-*
Empty file added db/.gitkeep
Empty file.
88 changes: 45 additions & 43 deletions lib/qbot/arch_wiki.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/qbot/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions lib/qbot/db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
193 changes: 101 additions & 92 deletions lib/qbot/helpers.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.fields << [{ ... }] appends an Array to m.fields, producing a nested array of fields. Discord embeds expect fields to be an array of hashes; append a single hash instead so the optional "Information" field renders correctly.

Suggested change
m.fields << [{ name: 'Information', value: extra }] if extra
m.fields << { name: 'Information', value: extra } if extra

Copilot uses AI. Check for mistakes.

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
2 changes: 1 addition & 1 deletion lib/qbot/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions lib/qbot/i18n.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading