344 lines
12 KiB
Ruby
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
|