From 11b8b62fd544479c07f34c57aedd807de53308c4 Mon Sep 17 00:00:00 2001 From: Joe Rayhawk Date: Wed, 27 Dec 2023 12:21:38 -0800 Subject: crystal bungmobott: add YAML::Serializable configuration management system as module --- crystal/lib/bungmobott/src/bungmobott.cr | 71 ++++++++++ crystal/tcpsocket.cr | 214 +++++++++++-------------------- 2 files changed, 148 insertions(+), 137 deletions(-) create mode 100644 crystal/lib/bungmobott/src/bungmobott.cr (limited to 'crystal') diff --git a/crystal/lib/bungmobott/src/bungmobott.cr b/crystal/lib/bungmobott/src/bungmobott.cr new file mode 100644 index 0000000..d54eaad --- /dev/null +++ b/crystal/lib/bungmobott/src/bungmobott.cr @@ -0,0 +1,71 @@ +#--- +#statedir: /home/jrayhawk/.local/state/bungmobott/ +#tempdir: /tmp/bungmobott/ +#listen: 0.0.0.0:5555 +#join_channels: +# twitch: +# - bungmonkey +# - dopefish +# # - yackamov +# gamesurge: +# - bungmonkey +#twitch_user_id: 59895482 # saves a lookup +#chat_user: +# twitch: bungmonkey +# gamesurge: bungmoBott + +module BungmoBott + + EXE = "bungmobott" + + class ChatUser + include YAML::Serializable + include YAML::Serializable::Unmapped + property twitch : String? + property gamesurge : String? + end + + class JoinChannels + include YAML::Serializable + include YAML::Serializable::Unmapped + property twitch : Array(String)? + property gamesurge : Array(String)? + end + + class Config + include YAML::Serializable + include YAML::Serializable::Unmapped + property statedir : String = ( + ENV["LOCALAPPDATA"]? && Path.windows( ENV["LOCALAPPDATA"] + "\\#{EXE}\\state\\" ).to_s || + ENV["XDG_STATE_HOME"]? && ENV["XDG_STATE_HOME"] + "/#{EXE}/" || + Path.home./(".local/state/#{EXE}").to_s + ) + property tempdir : String = ( + ENV["TEMP"]? && ENV["TEMP"] + "\\#{EXE}\\" || + "/tmp/#{EXE}/" + ) + property rundir : String = ( + ENV["XDG_RUNTIME_DIR"]? && ENV["XDG_RUNTIME_DIR"] + "/#{EXE}/" || + "/tmp/#{EXE}/" + ) # FIXME: do sockets and such even work on Windows? + # Should probably warn about that somewhere around here. + #@[YAML::Field(emit_null: true)] + property listen : String? = nil + property chat_user : ChatUser? = ChatUser.from_yaml("---") + property join_channels : JoinChannels? = JoinChannels.from_yaml("---") + property twitch_user_id : UInt32? = nil + end + class Secrets + include YAML::Serializable + include YAML::Serializable::Unmapped + @[YAML::Field(emit_null: true)] + property twitch_access_token : String? + @[YAML::Field(emit_null: true)] + property twitch_client_id : String? + @[YAML::Field(emit_null: true)] + property gcloud_token : String? + @[YAML::Field(emit_null: true)] + property gamesurge_password : String? + end +end + diff --git a/crystal/tcpsocket.cr b/crystal/tcpsocket.cr index 8d26a68..b4ee6a8 100644 --- a/crystal/tcpsocket.cr +++ b/crystal/tcpsocket.cr @@ -8,6 +8,7 @@ require "json" require "crystal_mpd" require "obswebsocket" require "yaml" +require "bungmobott" STDOUT.sync = true STDOUT.flush_on_newline = true @@ -21,150 +22,88 @@ struct Nil end end +def ppe(object) + PrettyPrint.format( object, STDERR, 79 ) + STDERR.puts + object +end + +EXE = "bungmobott" + regexservice = /(twitch|gamesurge)/ regexuser = /[0-9a-zA-Z_]+/ regexb64 = /[0-9a-fA-F]+/ regexvoice = /[0-9a-zA-Z-]+/ -module BungmoBott - class Config - # YAML: - #--- - #statedir: /home/jrayhawk/.local/state/bungmobott/ - #tempdir: /tmp/bungmobott/ - #listen: 0.0.0.0:5555 - #join_channels: - # twitch: - # - bungmonkey - # - dopefish - # # - yackamov - # gamesurge: - # - bungmonkey - #twitch_channel_id: 59895482 # saves a lookup - #chat_user: - # twitch: bungmonkey - # gamesurge: bungmoBott - - getter configdir : String - getter statedir : String - getter tempdir : String - getter listen : String | Nil - getter join_channels : Hash( String, Array( String ) ) | Nil - getter twitch_channel_id : UInt64 | Nil - getter twitch_client_id : String | Nil - getter twitch_access_token : String | Nil - getter gcloud_token : String | Nil - getter chat_user : Hash( String, String ) | Nil - getter metaconfig : Hash( String, Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ) ) - - def initialize( ) - - @metaconfig = Hash( String, Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ) ).new - @metaconfig["default"] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).new - @metaconfig["default"]["home"] = Path.home.to_s - @configdir = Path.home./("/.config/bungmobott/").to_s - @metaconfig["default"]["statedir"] = Path.home./("/.local/state/bungmobott/").to_s - - if ENV["LOCALAPPDATA"]? - @configdir = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\" ).to_s - @metaconfig["default"]["statedir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\state\\" ).to_s - end +configdir = Path.home./("/.config/#{EXE}/").to_s +ENV["XDG_CONFIG_HOME"]? && ( configdir = ENV["XDG_CONFIG_HOME"] + "/#{EXE}/" ) +ENV["LOCALAPPDATA"]? && ( configdir = Path.windows( ENV["LOCALAPPDATA"] + "\\#{EXE}\\" ).to_s ) +# cygwin will have both, but should probably use LOCALAPPDATA + +def writeconfig( configdir : String, file : String, contents : String ) + Dir.mkdir_p( configdir ) + File.write( configdir + file, contents ) +rescue exio : IO::Error + puts "ERROR: Unable to write #{ configdir + file }.txt: #{exio.message}" + exit 7 +end - @metaconfig["default"]["tempdir"] = "/tmp/bungmobott/" - - ENV["TEMP"]? && ( @metaconfig["default"]["tempdir"] = "#{ENV["TEMP"]}\\bungmobott\\" ) - - ENV["XDG_CONFIG_HOME"]? && ( @configdir = ENV["XDG_CONFIG_HOME"] + "/bungmobott/" ) - #ENV["XDG_DATA_HOME"]? && ( @metaconfig["default"]["datadir"] = ENV["XDG_DATA_HOME"] ) # unused? - ENV["XDG_STATE_HOME"]? && ( @metaconfig["default"]["statedir"] = ENV["XDG_STATE_HOME"] + "/bungmobott/" ) - - populate( "config" ) - populate( "secret" ) - @metaconfig["default"].merge!( - { - "chat_user" => nil, - "twitch_channel_id" => nil, - "join_channels" => nil, - "listen" => nil, - "twitch_client_id" => nil, - "twitch_access_token" => nil, - "gcloud_token" => nil, - } - ) - - # FIXME: Maybe add more explicit validation on these...? - # FIXME: Might need to do explicit maps on these to get them to typecheck - @chat_user = @metaconfig["config"]["chat_user"]?.as( Hash( String, String ) | Nil ) - @twitch_channel_id = @metaconfig["config"]["twitch_channel_id"]?.as( UInt64 ) - @join_channels = @metaconfig["config"]["join_channels"]?.as( Hash( String, Array( String ) ) | Nil ) - @twitch_client_id = @metaconfig["secret"]["twitch_client_id"]?.as( String ) - @twitch_access_token = @metaconfig["secret"]["twitch_access_token"]?.as( String ) - @gcloud_token = @metaconfig["secret"]["gcloud_token"]?.as( String ) - - @listen = ( @metaconfig["config"]["listen"]?.as( String | Nil ) ) - @statedir = ( - if @metaconfig["config"]["statedir"]? - @metaconfig["config"]["statedir"].as( String ) - else - @metaconfig["default"]["statedir"].as( String ) - end - ) - @tempdir = ( - if @metaconfig["config"]["tempdir"]? - @metaconfig["config"]["tempdir"].as( String ) - else - @metaconfig["default"]["tempdir"].as( String ) - end - ) +configfile = configdir + "config.txt" +if File.exists?( configfile ) + config = BungmoBott::Config.from_yaml( File.read( configdir + "config.txt" ) ) +else + config = BungmoBott::Config.from_yaml("---") + STDERR.puts "WARNING: #{configfile} not found. Writing new one." + writeconfig( configdir, "config.txt", config.to_yaml ) +end - Dir.mkdir_p( @statedir ) - Dir.mkdir_p( @tempdir ) +puts config.to_yaml - end - def populate( configname : String ) - begin - @metaconfig[configname] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).from_yaml( File.read( configdir + configname + ".txt" ) ) - rescue exyaml : YAML::ParseException - puts "Failed to parse #{configname}.txt: #{exyaml.message}" - exit 4 - rescue exio : IO::Error - puts "#{configname}.txt missing, writing new #{configname}.txt" - if configname == "secrets" - write( configname, @metaconfig["default"].select{ |k, v| k =~ /twitch_(client_id|access_token)|gcloud_token/ }.to_yaml ) - elsif configname == "config" - write( configname, @metaconfig["default"].reject{ |k, v| k =~ /configdir|home|twitch_(client_id|access_token)|gcloud_token/ }.to_yaml ) - end - # verify we can read it back - @metaconfig[configname] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).from_yaml( File.read( configdir + configname + ".txt" ) ) - end - end - def write( configname : String, content : String ) - File.write( configdir + "#{configname}.txt", content ) - Dir.mkdir_p( @configdir ) - rescue exio : IO::Error - puts "ERROR: Unable to write #{configname}.txt: #{exio.message}" - exit 7 - end - end +unless config.yaml_unmapped.empty? + STDERR.puts "WARNING: #{configfile} has unknown properties:" + ppe config.yaml_unmapped end -regextwitchuser = /^[0-9a-zA-Z_]+$/ +if ( config.chat_user && ! config.chat_user.not_nil!.yaml_unmapped.empty? ) + STDERR.puts "WARNING: #{configfile} chat_user has unknown properties:" + ppe config.chat_user.not_nil!.yaml_unmapped +end + +if ( config.join_channels && ! config.join_channels.not_nil!.yaml_unmapped.empty? ) + STDERR.puts "WARNING: #{configfile} join_channels has unknown properties:" + ppe config.join_channels.not_nil!.yaml_unmapped +end + +secretsfile = configdir + "secrets.txt" -config = BungmoBott::Config.new +if File.exists?( secretsfile ) + secrets = BungmoBott::Secrets.from_yaml( File.read( secretsfile ) ) +else + secrets = BungmoBott::Secrets.from_yaml( "---" ) + STDERR.puts "WARNING: #{secretsfile} not found. Writing new one." + writeconfig( configdir, "secrets.txt", secrets.to_yaml ) +end + +unless secrets.yaml_unmapped.empty? + STDERR.puts "WARNING: #{secretsfile} has unknown properties:" + ppe secrets.yaml_unmapped.keys +end + +regextwitchuser = /^[0-9a-zA-Z_]+$/ # enable direct twitch api? -if ( config.twitch_access_token && config.twitch_client_id ) +if ( secrets.twitch_access_token && secrets.twitch_client_id ) twitchapi = true else twitchapi = false - config.twitch_access_token || STDERR.puts "Warning: #{config.configdir}secret.txt 'twitch_access_token' is missing; direct Twitch API access disabled." - config.twitch_client_id || STDERR.puts "Warning: #{config.configdir}secret.txt 'twitch_client_id' is missing; direct Twitch API access disabled." - unless chat_user = ( config.chat_user && config.chat_user.not_nil!["twitch"]? ) - if chat_user = ( config.join_channels && config.join_channels.not_nil!["twitch"]?[0]? ) - STDERR.puts "Warning: #{config.configdir}config.txt 'chat_user: {twitch}' value is missing; using first configured 'join_channels: {twitch}' array value instead: #{chat_user}" + secrets.twitch_access_token || STDERR.puts "Warning: #{secretsfile} 'twitch_access_token' is missing; direct Twitch API access disabled." + secrets.twitch_client_id || STDERR.puts "Warning: #{secretsfile} 'twitch_client_id' is missing; direct Twitch API access disabled." + unless chat_user = ( config.chat_user && config.chat_user.not_nil!.twitch ) + if chat_user = ( config.join_channels && config.join_channels.not_nil!.twitch[0]? ) + STDERR.puts "Warning: #{configfile} 'chat_user: {twitch}' value is missing; using first configured 'join_channels: {twitch}' array value instead: #{chat_user}" if ( config.chat_user ) - config.chat_user.not_nil!["twitch"] = chat_user + config.chat_user.not_nil!.twitch = chat_user end else STDERR.puts "ERROR: 'chat_user: {twitch}' string value and 'join_channels: {twitch}' array value missing." @@ -174,15 +113,16 @@ else end # enable direct gcloud api? -if config.gcloud_token +if secrets.gcloud_token gcloud = true else gcloud = false - STDERR.puts "Warning: #{config.configdir}secret.txt gcloud_token is missing; direct GCS voices disabled." + STDERR.puts "Warning: #{secretsfile} gcloud_token is missing; direct GCS voices disabled." end # enable aws? if ! File.exists?( Path.home./("/.aws/credentials") ) + # FIXME: work out where this is on Windows STDERR.puts "Warning: #{Path.home}/.aws/credentials is missing; AWS voices disabled." aws = false elsif ! Process.find_executable( "aws.exe" ) @@ -278,7 +218,7 @@ def regeneratevoicelist( defaultsettings : Hash( String, String ), aws : Bool, g end end if gcloud - generatevoicelistgcs( config.gcloud_token ).each do | voice | + generatevoicelistgcs( secrets.gcloud_token ).each do | voice | voices[ voice.downcase ] = voice end end @@ -297,8 +237,8 @@ channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server # Encrypted connections = Hash(OpenSSL::SSL::Socket::Server, Hash(String, String)).new -ircipc = Channel( Tuple( String, String ) ).new -t2sipc = Channel( Tuple( String, String ) ).new +ircipc = Channel( Tuple( String, String ) ).new +t2sipc = Channel( Tuple( String, String ) ).new twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new # Twitch API request handling thread @@ -330,12 +270,12 @@ twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new #end # -if ( config.twitch_access_token && config.chat_user.not_nil!["twitch"]? && config.join_channels.not_nil!["twitch"]? ) +if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.join_channels.not_nil!.twitch ) # IRC thread spawn do loop do begin - bot = Twitch::IRC::Client.new( nick: config.chat_user.not_nil!["twitch"], token: "oauth:" + config.twitch_access_token.not_nil!, log_mode: true ) + bot = Twitch::IRC::Client.new( nick: config.chat_user.not_nil!.twitch.not_nil!, token: "oauth:" + secrets.twitch_access_token.not_nil!, log_mode: true ) bot.tags = [ "membership", "tags", "commands" ] # Outgoing IRC message thread @@ -409,7 +349,7 @@ if ( config.twitch_access_token && config.chat_user.not_nil!["twitch"]? && confi end rooms = Array( String ).new - rooms = config.join_channels.not_nil!["twitch"] + rooms = config.join_channels.not_nil!.twitch.not_nil! # Connect to Twitch bot.run( rooms.map{ | room | room.sub( /^#/, "") } ) @@ -434,8 +374,8 @@ if config.listen ip, port = config.listen.not_nil!.split(":") tcp_server = TCPServer.new( ip, port.to_i ) ssl_context = OpenSSL::SSL::Context::Server.new - ssl_context.private_key=(config.configdir + "/privkey.pem" ) - ssl_context.certificate_chain=(config.configdir + "/fullchain.pem" ) + ssl_context.private_key=(configdir + "/privkey.pem" ) + ssl_context.certificate_chain=(configdir + "/fullchain.pem" ) # unauthenticated config; seems to be broken in crystal v1.9.2 # ssl_context = OpenSSL::SSL::Context::Server.insecure() # ssl_context.add_options(OpenSSL::SSL::Options::ALL) @@ -477,7 +417,7 @@ if config.listen elsif ( match = message.match( /^awsvoicelist$/i ) ) client.puts "awsvoicelist " + generatevoicelistaws().join(" ") elsif ( match = message.match( /^gcsvoicelist$/i ) ) - client.puts "gcsvoicelist " + generatevoicelistgcs( config.gcloud_token ).join(" ") + client.puts "gcsvoicelist " + generatevoicelistgcs( secrets.gcloud_token ).join(" ") end else -- cgit v1.2.3