linoleum-club/main.rb

344 lines
12 KiB
Ruby

require "pp"
require "dotenv"
Dotenv.load("config/.env")
require "active_support/all"
require "discordrb"
require "toml-rb"
require_relative "send_puzzle_job"
require_relative "update_status_job"
require_relative "db"
$bot = Discordrb::Bot.new(token: ENV["DISCORD_TOKEN"], client_id: ENV["DISCORD_CLIENT_ID"])
## Setup Discord Globals
$bot.run :async
at_exit { $bot.stop }
$server_id, $server = $bot.servers.find { |id, s| s.name == "The Linoleum Club" }
$puzzle_solver_role = $server.roles.find { |r| r.name == "Puzzle Solver" }
$puzzle_pings_role = $server.roles.find { |r| r.name == "Puzzle Pings" }
$puzzle_channel = $server.channels.find { |c| c.name == "puzzle" }
$study_channel = $server.channels.find { |c| c.name == "sylvains-study" }
$me = $server.users.find { |u| u.username == "matthiasportzel" }
## Discord Command Helper
# Maps command name to snowflake
$command_name_cache = {}
def get_command_by_name command_name
if $command_name_cache.has_key? command_name
p "cached #{command_name}: #{command_id}"
return $command_name_cache[command_name]
end
command = $bot.get_application_commands(server_id: $server_id).filter {|n| n.name == command_name} .first
p "caching #{command_name}: #{command.id}"
$command_name_cache[command_name] = command.id
return command.id
end
# options is a hash with a :type symbol
# https://drb.shardlab.dev/v3.5.0/Discordrb/Interactions/OptionBuilder.html#string-instance_method
def define_command text, options
command_name = text.parameterize.to_sym
p command_name
# TODO: We need this to not run for every command every time because we get rate-limited every time
command = $bot.register_application_command command_name, text, server_id: $server_id do |option_builder|
options.each do |option|
p "Adding option: #{option}"
option_builder.send option[:type], option[:name], option[:description] || "", required: option[:required] || true
end
end
$command_name_cache[command_name] = command.id
$bot.application_command command_name do |event|
p "Command #{command_name} triggered"
begin
yield event
rescue => err
# TODO: This is ugly
# Ideally we should also print to stderr and keep running
$study_channel.send "```rb\n#{PP.pp(err, "")}\n#{err.backtrace.join("\n")}\n```"
end
end
end
## Commands
# /solve
define_command("Solve", [
{ type: :string, name: "solution", description: "Your solution to the puzzle", required: true }
]) do |event|
submission = event.options["solution"]
current_puzzle = Puzzle.current.first
if !submission.present?
event.respond content: "That's not an answer"
return
end
solved = false
if current_puzzle.solution.present?
solved = current_puzzle.solution == submission.strip
elsif current_puzzle.check_solution.present?
discord_username = event.user.username
unless discord_username.present?
$study_channel.send "User with no username needs manual handling"
return
end
# submission is also in scope, from above
solved = eval current_puzzle.check_solution, binding
else
raise "No way to handle this puzzle"
end
if solved then
event.user.add_role $puzzle_solver_role
event.respond content: "Correct! You've gained full access for this week.", ephemeral: true
event.channel.send "<@#{event.user.id}> has given the solution and entered the club."
# Create "solve" instance
Solve.create discord_id: event.user.id, puzzle_id: current_puzzle.id
# TODO: Update "solves" count role
else
event.respond content: "Hmm, that's not the answer we're looking for.", ephemeral: true
end
end
# /add-puzzle
EXAMPLE_TOML = <<EOF
[[puzzles]]
text = """
Can you navigate in 3D space without seeing it?
The below list consists of alternating letters and numbers. These form pairs: first a direction, then a distance. There are six directions:
* "N": North (negative y)
* "E": East (positive x)
* "S": South (positive y)
* "W": West (negative x)
* "I": In (positive z)
* "O": Out (negative z)
If you start at (0, 0, 0), and follow all of these movement instructions in order, what are your coordinates at the end? Format as (X, Y, Z). Good luck!
`W 3 N 5 I 2 W 3 E 2 O 1 I 5 S 2 S 8 O 2 I 8 W 5 W 6 W 2 W 1 E 7 O 9`
"""
solution = "(-11, 5, 3)"
EOF
define_command("Add Puzzle", []) do |event|
event.defer ephemeral: false
event.user.dm "We'd love to have you submit a puzzle to The Linoleum Club! A good puzzle is easy for a skilled programmer and possible for a novice programmer, with sufficient effort.\nAfter you submit your puzzle, it will be reviewed, solved, and edited by The Club's admin team (just Matthias), before being used as a weekly puzzle in the next couple of weeks."
event.user.dm "To submit your puzzle, DM me (Dr. Sylvain) a TOML block with the fields `text` and `solution` in the `[[puzzles]]` array. Here's an example:"
event.user.dm "```toml\n#{EXAMPLE_TOML}```"
event.user.dm "If you want to include triple back-ticks in your puzzle-text, just don't wrap the TOML block with them"
event.edit_response content: "DM'd you instructions!"
end
# Listen for DMs and treat them as puzzle submissions
$bot.message do |event|
# If this is a DM
next unless event.channel == event.user.dm
toml_text = event.content
if toml_text.starts_with? "```" and toml_text.ends_with "```"
toml_text = toml_text.sub(/\A```(?:toml)?/, "").sub(/```\z/, "").strip
end
# parse toml
begin
puzzle_data = TomlRB.parse(toml_text)
rescue => TomlRB::ParseError
# if toml parse fails, react to the message with ?
event.message.react("\u2753")
# Continue waiting for messages
next
end
# validate toml fields, sending error messages
unless puzzle_data.has_key? "puzzles" and puzzle_data["puzzles"].is_a? Array
event.channel.send "Please include your puzzle submission(s) in the `puzzles` Array.\n=> https://toml.io/en/v1.0.0#array-of-tables"
next
end
puzzle_data["puzzles"].each do |puzzle|
unless event.user == $me
# If it's not me, validate the puzzle
unless puzzle.has_key? "text" and (puzzle.has_key? "check_solution" or puzzle.has_key? "solution") and puzzle.keys.length == 2
event.channel.send "Invalid puzzle. Please include only the `text` and `solution` keys."
next
end
end
# create and save the puzzle object
p = Puzzle.new puzzle
unless p.valid?
event.channel.send "Invalid Puzzle. IDK why"
next
end
# Save the user's id who submitted it
p.author_id = event.user.id unless p.author_id.present?
p.save
event.channel.send "Successfully added puzzle!"
end
end
# /ping-opt-out
define_command("Ping Opt Out", []) do |event|
event.user.remove_role $puzzle_pings_role
event.respond content: "Removed your ping role", ephemeral: true
end
# Listen for member joins and add the $puzzle_pings_role, wa-ha-ha!
$bot.member_join do |event|
event.user.add_role $puzzle_pings_role
end
# --- Admin commands ---
# /remove-commands
# There's probably a better way to do this re-loading
define_command "Remove commands", [] do |event|
if event.user != $me
event.respond content: "No you did-aint!", ephemeral: false
break
end
p "Removing all commands"
# Discord displays "Linoleum Club is thinking..." until we edit the response
event.defer ephemeral: false
commands = $bot.get_application_commands(server_id: $server_id)
commands.each {|cmd| $bot.delete_application_command cmd.id, server_id: $server_id}
event.edit_response content: "Removed all commands!"
end
# /solve-for <discord_id>
define_command "Solve for", [
{ type: :string, name: "user_discord_id", description: "The Discord ID of the user", required: true }
] do |event|
if event.user != $me
event.respond content: "This is an admin command to add the solved role to other people"
return
end
discord_id = event.options["user_discord_id"]
member = $server.member(discord_id)
if member.nil?
event.respond content: "No member with that ID"
end
current_puzzle = Puzzle.current.first
# This is a copy-paste of in /solve, but it's simple for now
member.add_role $puzzle_solver_role
# Create "solve" instance
Solve.create discord_id: event.user.id, puzzle_id: current_puzzle.id
# TODO: Update "solves" count role
event.respond content: "Added solved info for user"
end
# /edit-puzzle-message <public_id> <find_regex> <new_text>
define_command "Edit Puzzle", [
{ type: :string, name: "puzzle_public_id", description: "The public id of the puzzle to edit", required: true },
{ type: :string, name: "find_regex", description: "The regex to match", required: true },
{ type: :string, name: "new_text", description: "The text to replace with", required: true },
] do |event|
if event.user != $me
event.respond content: "This is an admin command for editing a puzzle"
return
end
puzzle = Puzzle.where(public_id: event.options["puzzle_public_id"]).first
if puzzle.nil?
event.respond content: "No puzzle found"
return
end
text = puzzle.text
# TODO: This errors for some reason and I don't know why
new_text = text.gsub(Regexp.compile(event.options["find_regex"]), new_text)
puzzle.text = new_text
puzzle.save
if puzzle.message_id
# TODO:
event.respond content: "Updated DB; Editing the message isn't supported yet"
else
event.respond content: "Updated content for puzzle #{puzzle.display_id}."
end
end
# /preview-puzzle <public_id>
define_command "Preview Puzzle", [
{type: :string, name: "public_id", description: "The puzzle you want to see must have been assigned a public_id.", require: true}
] do |event|
if event.user != $me
event.respond content: "No", ephemeral: false
break
end
# find the puzzle
puzzle = Puzzle.where(public_id: event.options["public_id"]).first
if !puzzle.present?
event.channel.send "No puzzle found with that id"
break
end
puzzle.get_message_text.each { |t| event.channel.send t }
event.respond content: "Puzzle text sent", ephemeral: true
end
# /irb-discord
$irb_sessions = []
class DiscordIRBSession
attr_accessor :user
attr_accessor :channel
attr_accessor :enviroment
end
define_command("IRB Discord", []) do |event|
if event.user == $me
if $irb_sessions.filter { |s| s.user == event.user and s.channel == event.channel }.present?
event.respond content: "You already had a live session, but it doesn't hurt to make sure."
next
end
event.respond content: "Started an IRB-Discord session. Use `exit` to exit."
session = DiscordIRBSession.new
session.user = event.user
session.channel = event.channel
session.enviroment = binding
$irb_sessions.push session
else
event.respond content: "You don't have permission to use this command"
end
end
$bot.message(from: $me) do |event|
session = $irb_sessions.filter { |s| s.user == event.user and s.channel == event.channel }
if session.length == 1
session = session.first
code = event.message.content
if code.strip == "exit"
$irb_sessions.delete session
event.channel.send "Thanks for irb-ing ;)"
else
begin
res = eval code, session.enviroment
rescue => err
res = err
ensure
event.channel.send "```rb\n#{PP.pp(res, "")}\n```"
end
end
end
end
# Kick off regular jobs
SendPuzzleJob.enqueue
UpdateStatusJob.enqueue
# Wait forever for the bot to exit
$bot.join