Switch from ActiveRecord to Sequel; /add-puzzle

This commit is contained in:
Matthias Portzel 2024-03-09 15:55:36 -05:00
parent 76dfc2fa0f
commit 1aa7447ae6
9 changed files with 218 additions and 75 deletions

View File

@ -2,7 +2,6 @@ source "https://rubygems.org"
gem "dotenv"
gem "activerecord"
gem "sqlite3"
gem "activesupport"
@ -12,3 +11,8 @@ gem "activejob"
gem "discordrb"
gem "pry"
gem "toml-rb"
# ORM
gem "sequel"

View File

@ -4,12 +4,6 @@ GEM
activejob (7.1.3.2)
activesupport (= 7.1.3.2)
globalid (>= 0.3.6)
activemodel (7.1.3.2)
activesupport (= 7.1.3.2)
activerecord (7.1.3.2)
activemodel (= 7.1.3.2)
activesupport (= 7.1.3.2)
timeout (>= 0.4.0)
activesupport (7.1.3.2)
base64
bigdecimal
@ -22,6 +16,7 @@ GEM
tzinfo (~> 2.0)
base64 (0.2.0)
bigdecimal (3.1.6)
citrus (3.0.2)
coderay (1.1.3)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
@ -58,14 +53,19 @@ GEM
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
racc (1.7.3)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
ruby2_keywords (0.0.5)
sequel (5.78.0)
bigdecimal
sqlite3 (1.7.2-x86_64-darwin)
timeout (0.4.1)
toml-rb (3.0.1)
citrus (~> 3.0, > 3.0)
racc (~> 1.7)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
websocket (1.2.10)
@ -79,12 +79,13 @@ PLATFORMS
DEPENDENCIES
activejob
activerecord
activesupport
discordrb
dotenv
pry
sequel
sqlite3
toml-rb
BUNDLED WITH
2.4.13

View File

@ -1,5 +1,7 @@
# Puzzle Format
This is out of date, see db.rb or 0001_create_puzzles
text, a string with the text of the puzzle
solution, a string to compare
check_solution, a string; a ruby expression that will evaluate to true or false. `discord_username` and `submission` are in scope.

79
db.rb
View File

@ -1,32 +1,67 @@
require "active_record"
require "sequel"
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: 'db.sqlite3'
)
# :created_at and :updated_at on all tables
Sequel::Model.plugin :timestamps, update_on_create: true
DB = Sequel.connect("sqlite://db.sqlite3")
# Migrations Boilerplate
Dir["migrations/*.rb"].each {|f| require_relative f}
unless ActiveRecord::Base.connection.table_exists? :puzzles
CreatePuzzleTable.migrate(:up)
end
# Puzzle model
class Puzzle < ActiveRecord::Base
# .string :text, The markdown text of the message
# Sequel creates getters and setters corresponding to DB columns. DB is the source of truth.
# Sequel tracks which migrations have been run, and you can use it to run migrations
# We need to write migrations manually
class Puzzle < Sequel::Model
# Fields:
# primary_key :id
# One of these must be present
# table.string :solution, null: true # A string to compare against
# table.string :check_solution, null: true # a ruby expression that will evaluate to true or false. `discord_username` and `submission` are in scope.
# # The markdown text of the message
# String :text, null: false
# # Public_id or title, used to identify the puzzle when upserting toml
# table.string :public_id
# # One of these must be present
# String :solution, null: true # A string to compare against
# String :check_solution, null: true # a ruby expression that will evaluate to true or false. `discord_username` and `submission` are in scope.
# # used / current / ready / draft
# table.integer :state, default: 0
enum :state, %i[draft ready current used]
# # We need a public_id or title that we can use to identify the puzzle, for example when upserting toml
# # This is publicly displayed in hex, but stored as an integer so that we can sort
# # This is the order that the puzzles are released
# # Unreleased puzzles may have a null public_id
# Integer :public_id, null: true
def validate
return solution.present? || check_solution.present
# # To facilitate editing puzzle messages, we want to store the Discord message id
# # Obviously null if it hasn't been sent yet
# Integer :message_id, null: true
# # draft / ready / current / used
# String :state, default: "draft", null: false
# # Timestamps
# DateTime :created_at, null: false
# DateTime :updated_at, null: false
# Scopes
class << self
def draft
where state: "draft"
end
def ready
where state: "ready"
end
def current
where state: "current"
end
def used
where state: "used"
end
end
# Validation
plugin :validation_helpers
def validate
errors.add(:solution, "must be present") if solution.blank? and check_solution.blank?
validates_presence :text
end
# TODO: getter for getting the Discord message ID
# $puzzle_channel.load_message message_id
end

107
main.rb
View File

@ -3,16 +3,16 @@ require "pp"
require "dotenv"
Dotenv.load("config/.env")
require "discordrb"
require "active_support/all"
require "discordrb"
require "toml-rb"
require_relative "send_puzzle_job"
require_relative "db"
$bot = Discordrb::Bot.new(token: ENV["DISCORD_TOKEN"], client_id: ENV["DISCORD_CLIENT_ID"])
## Setup
## Setup Discord Globals
$bot.run :async
at_exit { $bot.stop }
@ -22,13 +22,8 @@ $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" }
def remove_solver_role
p "remove solver role"
$puzzle_solver_role.members.each do |member|
member.remove_role $puzzle_solver_role
end
end
## Discord Command Helper
# Maps command name to snowflake
$command_name_cache = {}
def get_command_by_name command_name
@ -61,12 +56,18 @@ def define_command text, options
end
end
## Commands
define_command "Remove role", [] do |event|
remove_solver_role
event.respond content: "Removed the solved role from all solvers", ephemeral: false
end
# /remove-role (was only for testing and isn't checking auth!)
# define_command "Remove role", [] do |event|
# p "remove solver role"
# z$puzzle_solver_role.members.each do |member|
# member.remove_role $puzzle_solver_role
# end
# event.respond content: "Removed the solved role from all solvers", ephemeral: false
# end
# /remove-commands
# There's probably a better way to do this re-loading
define_command "Remove commands", [] do |event|
if event.user != $me
@ -84,6 +85,7 @@ define_command "Remove commands", [] do |event|
event.edit_response content: "Removed all commands!"
end
# /solve
define_command("Solve", [
{ type: :string, name: "solution", description: "Your solution to the puzzle", required: true }
]) do |event|
@ -118,6 +120,36 @@ define_command("Solve", [
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."
event.user.dm "After 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.edit_response content: "DM'd you instructions!"
end
# /irb-discord
$irb_sessions = []
class DiscordIRBSession
@ -153,17 +185,58 @@ $bot.message(from: $me) do |event|
rescue => err
res = err
ensure
# 5 x ` so we can use triple inside
event.channel.send "`````rb\n#{PP.pp(res, "")}\n`````"
event.channel.send "```rb\n#{PP.pp(res, "")}\n```"
end
end
end
end
$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?
message.channel.send "Invalid Puzzle. IDK why"
next
end
p.save
end
end
# Get ready to send the next puzzle at the next UTC sunday
SendPuzzleJob.new.enqueue(wait_until: Date.today.next_occurring(:sunday).beginning_of_day)
# binding.irb
# TODO: We need to run this every Sunday, not just the next one
# Wait forever for the bot to exit
$bot.join

7
migrate.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
(
cd $(dirname "$0")
sequel -m migrations/ "sqlite://db.sqlite3"
)

View File

@ -1,22 +0,0 @@
# TODO: finishing writing and migrated
class CreatePuzzleTable < ActiveRecord::Migration[7.1]
def change
create_table :puzzles do |table|
# The markdown text of the message
table.string :text
# One of these must be present
table.string :solution, null: true
table.string :check_solution, null: true
# We need a public_id or title that I can use to identify the puzzle when upserting toml
table.string :public_id
# used / current / ready / draft
table.integer :state, default: 0
table.timestamps
end
end
end

View File

@ -0,0 +1,31 @@
Sequel.migration do
change do
create_table :puzzles do
primary_key :id
# The markdown text of the message
String :text, null: false
# One of these must be present
String :solution, null: true # A string to compare against
String :check_solution, null: true # a ruby expression that will evaluate to true or false. `discord_username` and `submission` are in scope.
# We need a public_id or title that we can use to identify the puzzle, for example when upserting toml
# This is publicly displayed in hex, but stored as an integer so that we can sort
# This is the order that the puzzles are released
# Unreleased puzzles may have a null public_id
Integer :public_id, null: true
# To facilitate editing puzzle messages, we want to store the Discord message id
# Obviously null if it hasn't been sent yet
Integer :message_id, null: true
# draft / ready / current / used
String :state, default: "draft", null: false
# Timestamps
DateTime :created_at, null: false
DateTime :updated_at, null: false
end
end
end

View File

@ -7,19 +7,31 @@ class SendPuzzleJob < ActiveJob::Base
# Do something later
def perform
# Remove the puzzle command from everyone
remove_solver_role
$puzzle_solver_role.members.each do |member|
member.remove_role $puzzle_solver_role
end
# Set the previous puzzle as used (saves)
Puzzle.current.first.used!
# Set the previous puzzle as used
current_puzzle = Puzzle.current.first
current_puzzle.state = "used"
current_puzzle.save
# Get a random puzzle
puzzle = Puzzle.ready.order("RANDOM()").first
if puzzle.present?
# Send the new puzzle
$puzzle_channel.send puzzle.text
# TODO: puzzle public id to 0x + hex
msg = $puzzle_channel.send puzzle.text
# TODO: Submit your answer with /solve
# TODO: If you have an idea for a future puzzle, use /add-puzzle
# Save message id
puzzle.message_id = msg.id
# Mark it as current
puzzle.current!
puzzle.state = "current"
# Save
puzzle.save
else
# Panic
$study_channel.send "<@#{$me.id}> Help! No more puzzles! No more Club???"