diff options
Diffstat (limited to 'crystal/bungmobott-old.cr')
-rwxr-xr-x | crystal/bungmobott-old.cr | 991 |
1 files changed, 991 insertions, 0 deletions
diff --git a/crystal/bungmobott-old.cr b/crystal/bungmobott-old.cr new file mode 100755 index 0000000..642d0b3 --- /dev/null +++ b/crystal/bungmobott-old.cr @@ -0,0 +1,991 @@ +require "twitch/irc" +require "http" +require "uri" +require "twitcr" +require "json" +require "crystal_mpd" +require "obswebsocket" + +STDOUT.sync = true +STDOUT.flush_on_newline = true + +struct Nil + def as_s? + self + end +end + +settings = Hash(String, String).new + +settings["configdir"] = Path.home./("/.config/bungmobott/").to_s +settings["statedir"] = Path.home./("/.local/state/bungmobott/").to_s + +if ENV["LOCALAPPDATA"]? + settings["configdir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\" ).to_s + settings["statedir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\state\\" ).to_s +end + +settings["tempdir"] = "/tmp/bungmobott/" +ENV["TEMP"]? && ( settings["tempdir"] = "#{ENV["TEMP"]}\\bungmobott\\" ) + +ENV["XDG_CONFIG_HOME"]? && ( settings["configdir"] = ENV["XDG_CONFIG_HOME"] + "/bungmobott/" ) +#ENV["XDG_DATA_HOME"]? && ( settings["datadir"] = ENV["XDG_DATA_HOME"] ) # unused? +ENV["XDG_STATE_HOME"]? && ( settings["statedir"] = ENV["XDG_STATE_HOME"] + "/bungmobott/" ) + +Dir.mkdir_p( settings["configdir"] ) +Dir.mkdir_p( settings["statedir"] ) +Dir.mkdir_p( settings["tempdir"] ) + +settings["home"] = Path.home.to_s +regextwitchuser = /^[0-9a-zA-Z_]+$/ + +error = false +[ "access_token", "client_id" ].each do |key| + begin + settings[key] = File.read( settings["configdir"] + key ).chomp + rescue IO::Error + STDERR.puts "ERROR: Missing " + settings["configdir"] + key + error = true + end +end + +# enable gcloud? +if File.exists?( settings["configdir"] + "gcloud_token" ) && ( settings["gcloud_token"] = File.read( settings["configdir"] + "gcloud_token" ).chomp ) + gcloud = true +else + gcloud = false + STDERR.puts "Warning: #{settings["configdir"]}gcloud_token is missing; GCS voices disabled." +end + +# enable aws? +if ! File.exists?( Path.home./("/.aws/credentials") ) + STDERR.puts "Warning: #{Path.home}/.aws/credentials is missing; AWS voices disabled." + aws = false +elsif ! Process.find_executable( "aws.exe" ) + STDERR.puts "Warning: aws.exe is missing; AWS voices disabled." + aws = false +else + aws = true +end + +# enable effects? +effects = Hash( String, String ).new +if File.exists?( settings["configdir"] + "/effects.txt" ) + File.each_line( settings["configdir"] + "/effects.txt" ) do |line| + effects[ line.downcase ] = line + end +end + +client = Twitcr::Client.new( settings ) + +# derive channel_id from channel or vice versa +[ "channel", "channel_id" ].each do |key| + File.exists?( settings["configdir"] + key ) && ( settings[key] = File.read( settings["configdir"] + key ).chomp ) +end +if ( settings["channel"]? =~ regextwitchuser ) && ( settings["channel_id"]? =~ /^[0-9]+$/ ) +elsif ! ( settings["channel"]? =~ regextwitchuser ) && ( settings["channel_id"]? =~ /^[0-9]+$/ ) + settings["channel"] = client.user( settings["channel_id"].to_u64 ).login +elsif ( settings["channel"]? =~ regextwitchuser ) && ! ( settings["channel_id"]? =~ /^[0-9]+$/ ) + settings["channel_id"] = client.user( settings["channel"] ).id.to_s +else + STDERR.puts "ERROR: Missing #{settings["configdir"]}channel and channel_id configuration keys." + error = true + # exit 2 +end +if error == true + {% if flag?(:windows) %} + STDERR.puts "press enter to end program" + gets + {% end %} + exit 1 +end + +if File.exists?( settings["configdir"] + "chatuser" ) + File.read( settings["configdir"] + "chat_user" ).chomp +else + STDERR.puts "Warning: " + settings["configdir"] + "chat_user is missing; using configured channel instead." + settings["chat_user"] = settings["channel"] +end + +# fiber communication channels +ircipc = Channel( Tuple( String, String ) ).new +t2sipc = Channel( Tuple( String, String ) ).new +twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new + +obs = OBS::WebSocket.new( "ws://127.0.0.1:4455/" ) + +# OBS event thread +spawn do + evchan = Channel( JSON::Any ).new + obs.scenes["meta-foreground"].to_h.each_key do | key | + if key =~ /^media-temporary-/ + obs.inputs[key].delete! + end + end + obs.eventsub_add( evchan ) + while json = evchan.receive + # A Fiber.yield occurs after this to make sure "json" doesn't get overwritten before we can use it. + spawn do + d = json # Copy *immediately* + case d["eventType"].as_s + when "CurrentProgramSceneChanged" + ircipc.send( { "#" + settings["channel"], "| obs: switched scene to " + ( d["eventData"]["sceneName"]?.as_s? || "unknown" ) } ) + when "MediaInputPlaybackEnded" + if d["eventData"]["inputName"].as_s =~ /^media-temporary-/ + obs.send( OBS.req( "RemoveInput", UUID.random.to_s, JSON.parse({ "inputName" => d["eventData"]["inputName"].as_s }.to_json) ) ) + elsif d["eventData"]["inputName"].as_s =~ /^media-/ + obs.scenes.current.metascene[d["eventData"]["inputName"].as_s][0].disable! + end + when "SceneItemEnableStateChanged" + edata = d["eventData"] + name = obs.scenes[ edata["sceneName"].as_s ][ edata["sceneItemId"].as_i64 ].name + if name !~ /media-temporary/ + ircipc.send( { "##{settings["channel"]}", "| obs: source #{name} visibility is now #{edata["sceneItemEnabled"].as_bool}" } ) + end + when "SceneItemTransformChanged" + edata = d["eventData"] + sceneitem = obs.scenes[edata["sceneName"].as_s][edata["sceneItemId"].as_i64] + t = edata["sceneItemTransform"] + if ( sceneitem.name =~ /^media-temporary-/ ) + spx = t["positionX" ].as_f.to_i64 + spy = t["positionY" ].as_f.to_i64 + sdx = t["sourceHeight"].as_f.to_i64 + sdy = t["sourceWidth" ].as_f.to_i64 + if ( spx == 0 && spy == 0 && sdx != 0 && sdy != 0 ) + # source position randomizer + bx = obs.video.to_h["baseWidth" ].as(Int64 | Float64).to_i64 + by = obs.video.to_h["baseHeight"].as(Int64 | Float64).to_i64 + spx = ( rand(bx) - (sdx / 2) ) + spy = ( rand(by) - (sdy / 2) ) + sceneitem.transform( { "positionX" => spx, "positionY" => spy } ) + end + end + when "SourceFilterEnableStateChanged" + edata = d["eventData"] + ircipc.send( { "##{settings["channel"]}", "| obs: source #{edata["sourceName"].as_s} filter #{edata["filterName"].as_s} visibility is currently #{edata["filterEnabled"].as_bool}" } ) + end + end + Fiber.yield + end +end + +# Twitch API request handling thread +spawn do + loop do + begin + while twitchtuple = twitchipc.receive + cmd, arg = [ *twitchtuple ] + case cmd + when "get_user" + userinfo = JSON.parse( client.get_user( arg ) )["data"][0] + pp userinfo + unless userinfo["broadcaster_type"].as_s.blank? + puts "\033[38;5;12m#{userinfo["login"]} is #{userinfo["broadcaster_type"]}\033[0m" + end + userage = ( Time.utc.to_unix - Time::Format::RFC_3339.parse( userinfo["created_at"].as_s ).to_unix ) + if ( userage - 172800 ) < 0 + puts "\033[38;5;1m#{userinfo["login"]}'s account is #{((172800 - userage)/60/60).to_i64} hours old.\033[0m" + end + when "get_followers" + followers = JSON.parse( client.get_user_follows( to: arg.to_u64 ) )["total"].as_i64 + if followers > 500 + puts "\033[38;5;2m#{followers} followers\033[0m" + end + end + end + end + end +end + +def obsrandommediaenable( obs : OBS::WebSocket, siname : String ) + if ( Random.rand(3) < 2 ) + obs.scenes.current.metascene[siname][0].enable! + else + randsiname = obs.scenes.current.metascene.keys.select( /^#{siname}/ ).sample( 1 )[0] + obs.scenes.current.metascene[randsiname][0].enable! + end +end + +def obstemporarymediacreate( obs : OBS::WebSocket, sname : String, iname, path : String ) + iname = "media-temporary-effect-#{iname}" + isettings = Hash( String, String | Bool | Int64 | Float64 ){ + "advanced" => true, + "clear_on_media_end" => true, + "color_range" => 0.to_i64, + "is_local_file" => true, + "looping" => false, + "restart_on_activate" => true, + "local_file" => path, + } + response = obs.scenes[sname].createinput( iname, "ffmpeg_source", isettings ) + # Skip ORM stuff and configure the SceneItem as fast as we possibly can + if ( rdata = response["responseData"]? ) && ( goodtransform = obs.sources.last_known_real_transform?( iname ) ) + siid = rdata["sceneItemId"].as_i64 + obs.send( OBS.req( "SetSceneItemTransform", UUID.random.to_s, JSON.parse( { "sceneName" => sname, "sceneItemId" => siid, "sceneItemTransform" => { "positionX" => goodtransform.to_h["positionX"], "positionY" => goodtransform.to_h["positionY"] } }.to_json ) ) ) + end +end + +def urbandef( term : String ) + ssl_context = OpenSSL::SSL::Context::Client.new + {% if flag?(:windows) %} + ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE + {% end %} + #https://api.urbandictionary.com/v0/define?term=waifu + response = HTTP::Client.exec( "GET", "https://api.urbandictionary.com/v0/define?term=#{term}", tls: ssl_context ) + puts response.status_code + json = JSON.parse( response.body ) + return json["list"][0]["definition"].to_s.gsub( /[\[\]]/, "" ).gsub( /(\r|\n)/, " " ) +end + +# Currently only used in flag?(:unix) +def t2smsg( settings : Hash(String, String), msg : String) + if File.exists?( settings["statedir"] + "/.t2s.sock" ) + sock = Socket.unix + sock.connect Socket::UNIXAddress.new( settings["statedir"] + "/.t2s.sock" ) + sock.puts( msg ) + sock.close + end +rescue ex + puts ex +end + +def effectsmsg( settings : Hash(String, String), msg : String ) + if File.exists?( settings["statedir"] + "/.effects.sock" ) + sock = Socket.unix + sock.connect Socket::UNIXAddress.new( settings["statedir"] + "/.effects.sock" ) + sock.puts( msg ) + sock.close + end +rescue ex + puts ex +end + +def userlog( ircipc : Channel, settings : Hash(String, String), message : FastIRC::Message ) + unless ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) && ( uid = message.tags["user-id"]? ) ) + return nil + end + basedir = settings["statedir"] + userdir = basedir + "/uids/" + uid + if File.directory?( userdir ) + lastseen = File.info( userdir ).modification_time.to_unix + else + lastseen = 0 + end + Dir.mkdir_p( basedir + "/names" ) + Dir.mkdir_p( userdir + "/names" ) + File.touch( userdir ) + unless testrefuser2uid( userdir + "/names/" + chatuser ) + namelatest = "" + datelatest = Time::UNIX_EPOCH + Dir.each_child( userdir + "/names/" ) do |name| + namedate = File.info( userdir + "/names/" + name ).modification_time + if namedate > datelatest + namelatest = name + datelatest = namedate + end + end + unless namelatest.empty? + ircipc.send( { "##{settings["channel"]}", "Rename detected: #{uid}: #{namelatest} -> #{chatuser}" } ) + end + genrefuser2uid( userdir + "/names/" + chatuser, uid, 3 ) + end + unless testrefuser2uid( basedir + "/names/" + chatuser ) + genrefuser2uid( basedir + "/names/" + chatuser, uid, 1 ) + end + unless ( message.params[0] == "##{settings["channel"]}" ) + return nil + end + return ( { chatuser, uid, userdir, lastseen } ) +rescue ex + puts ex +end + +def getvoice( settings : Hash(String, String), userdir : String, chatuser : String ) + if File.exists?( userdir + "/voice" ) + voice_output = File.read( userdir + "/voice" ).chomp + voice_setting = voice_output + else + voice_output = File.read( settings["configdir"] + "/voicelist.txt" ).chomp.split( "\n" ).sample( 1 )[0].chomp + voice_setting = "random" + end + if File.exists?( userdir + "/voicesub" ) + namesub = File.read( userdir + "/voicesub" ).chomp + else + namesub = chatuser + end + return( [namesub, voice_setting, voice_output ] ) +end + +# text2speech +def t2s( t2sipc : Channel, settings : Hash(String, String), userdir : String, chatuser : String, text : String ) + if ( text !~ /^ *(!|\|)/ ) + namesub, voice_setting, voice = getvoice( settings, userdir, chatuser ) + subs = Array( Tuple( Regex, String ) ){ + { /http(s|):\/\/([a-z0-9.-]+)\/[a-zA-Z0-9\/&=%-_]+/, "link to \\2" }, + { /([^a-zA-Z0-9])-/, "\\1 dash "}, + { /\|/, " vertical bar "}, + { /\`/, " grave accent "}, + { /\+/, " plus "}, + { /×/, " multiplied by "}, + { /=/, " equals "}, + { /\//, " slash "}, + { /\\/, " backslash "}, + { /@/, " at "}, + { /&/, " and "}, + { />/, " greater than "}, + { /</, " less than "}, + { /_/, " underscore "}, + { /\.\.\./, " dot dot dot "}, + { /\^/, " circumflex accent "}, + { /\#/, " octothorpe "}, + { /:([^ ])/, " colon \\1"}, + { /;([^ ])/, " semicolon \\1"}, + { /\.([^ ])/, " dot \\1"}, + { /\%([^ ])/, " percent sign \\1"}, + { /!([^ ])/, " tchik \\1"}, + { /([^ ])\$/, "\\1 dollar sign "}, + { /\(/, " open paren "}, + { /\)/, " close paren "}, + { /\{/, " open curly bracket "}, + { /\}/, " close curly bracket "}, + { /\[/, " open square bracket "}, + { /\]/, " close square bracket "}, + { /0/, " zero " }, + { /1/, " one " }, + { /2/, " two " }, + { /3/, " three " }, + { /4/, " four " }, + { /5/, " five " }, + { /6/, " six " }, + { /7/, " seven " }, + { /8/, " eight " }, + { /9/, " nine " }, + { /rrr.+/, "rr" }, + }.each do | subtuple | + text = text.gsub( subtuple[0], subtuple[1] ) + end + {% if flag?(:windows) %} + t2sipc.send( { voice, "#{namesub} #{text}" } ) + {% else %} + t2smsg( settings, "#{voice} #{namesub} #{text}" ) + {% end %} + return( voice ) + else + return( nil ) + end +end + +def playaudiofile( filepath : String ) + p = Process.new( + "powershell.exe", + [ "-Command", "#Set-PSDebug -Trace 1; + Add-Type -AssemblyName presentationCore; + $player = New-Object system.windows.media.mediaplayer; + $player.open(\"#{filepath}\"); + $player.volume = .99; + $player.play(); + Start-Sleep -Milliseconds 1000; + $duration = $player.NaturalDuration.TimeSpan.TotalMilliseconds; + Start-Sleep -Milliseconds ($duration - 1000 ); + "], + output: STDOUT, error: STDERR + ) + # https://geekeefy.wordpress.com/2016/07/19/powershellmediaplayer/ has some ideas + p.wait +end + +def regeneratevoicelist( settings : Hash( String, String ), aws : Bool, gcloud : Bool ) + voices = Hash(String, String).new + if aws + JSON.parse( `aws polly describe-voices` )["Voices"].as_a.each do | v | + voices[ v["Id"].as_s.downcase ] = v["Id"].as_s + end + end + if gcloud + ssl_context = OpenSSL::SSL::Context::Client.new + {% if flag?(:windows) %} + ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE + {% end %} + headers = HTTP::Headers.new + headers["Content-Type"] = "application/json; charset=utf-8" + + response = HTTP::Client.exec( "GET", "https://texttospeech.googleapis.com/v1/voices?key=#{settings["gcloud_token"]}", headers, nil, tls: ssl_context ) + + JSON.parse( response.body )["voices"].as_a.each do | v | + voices[ v["name"].as_s.downcase ] = v["name"].as_s + end + end + {% if flag?(:windows) %} + p = Process.new( + "powershell.exe", + [ "-Command", " + Add-Type -AssemblyName System.Speech; + $speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; + $speak.GetInstalledVoices().VoiceInfo | Select-Object Name + "], + output: Process::Redirect::Pipe + ) + p.output.each_line do | v | + v = v.gsub(/ +$/, "") + v = v.gsub(/ Desktop$/, "") + v = v.gsub( " ", "-" ) + if v =~ /[A-Za-z0-9]-[A-Za-z0-9]/ + voices[ v.downcase ] = v + end + end + {% end %} + + File.write( settings["configdir"] + "/voicelist.txt", voices.values.sort.join("\r\n") ) + return voices +end + +macro testrefuser2uid( path ) + {% if flag?(:windows) %} + File.exists?( {{path}} ) && ( File.read( {{ path }} ) =~ /^[0-9]+$/ ) + {% else %} + File.symlink?( {{path}} ) + {% end %} +end + +macro genrefuser2uid( path, uid, depth ) + {% if flag?(:windows) %} + File.write( {{path}}, {{uid}}.to_s ) + {% else %} + File.symlink( "../"*{{depth}} + "uids/#{{{uid}}}", {{path}} ) + {% end %} +end + +voices = Hash(String, String).new +if File.exists?( settings["configdir"] + "/voicelist.txt" ) + File.each_line( settings["configdir"] + "/voicelist.txt" ) do |line| + voices[ line.downcase ] = line + end +else + voices = regeneratevoicelist( settings, aws, gcloud ) +end + +lastvoice = Array(String).new + +#streamstatus = Hash(String, Union( Bool, Float64, Int64, UInt64, String ) ).new +streamstatus = Hash(String, JSON::Any).new +outputstatus = Hash(String, JSON::Any).new + +# Put tts stuff into the same thread so each playback blocks the next +spawn do + loop do + begin + while t2stuple = t2sipc.receive + voice, text = [ *t2stuple ] + if ( match = voice.match( /^Microsoft-([A-Za-z]+)/ ) ) + if ( match[1] =~ /Lili|Mary|Mike|Sam|Anna/ ) + msttsvoice="Microsoft #{match[1]}" + else + msttsvoice="Microsoft #{match[1]} Desktop" + end + p = Process.new( + "powershell.exe", + [ "-Command", " + Add-Type -AssemblyName System.Speech; + $speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; + $speak.SelectVoice(\"#{msttsvoice}\"); + $speak.Speak($Input); + $speak.Finalize; + "], + input: Process::Redirect::Pipe, output: STDOUT + ) + p.input.puts text + p.input.close + p.wait + elsif gcloud && ( match = voice.match( /^([a-zA-Z]{2,3}-[a-zA-Z]{2})/ ) ) # Google cloud voice + request = Hash( String, Hash( String, String ) ){ + "input" => { "text" => text }, + "audioConfig" => { "audioEncoding" => "MP3" }, + "voice" => { + "name" => voice, + "languageCode" => match[1], + }, + } + ssl_context = OpenSSL::SSL::Context::Client.new + ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE + + headers = HTTP::Headers.new + headers["Content-Type"] = "application/json; charset=utf-8" + + response = HTTP::Client.exec( "POST", "https://texttospeech.googleapis.com/v1/text:synthesize?key=#{settings["gcloud_token"]}", headers, request.to_json, tls: ssl_context ) + + response.body + + filepath="#{settings["tempdir"]}#{Time.utc.to_unix_ms}.mp3" + json=JSON.parse(response.body) + File.write( filepath, Base64.decode_string( json["audioContent"].as_s ) ) + playaudiofile( filepath ) + File.delete( filepath ) + elsif aws # AWS polly voices + filepath="#{settings["tempdir"]}#{Time.utc.to_unix_ms}.mp3" + p = Process.new( + "aws.exe", [ + "polly", "synthesize-speech", + "--output-format", "mp3", + "--voice-id", voice, + "--text", text, + filepath + ], output: STDOUT, error: STDERR + ) + p.wait + playaudiofile( filepath ) + File.delete( filepath ) + else # unknown + STDERR.puts "Voice not recognized or available." + end + end + rescue ex + puts ex + end + end +end + +# main thread: IRC +loop do + begin + + bot = Twitch::IRC::Client.new( nick: settings["chat_user"], token: "oauth:" + settings["access_token"], log_mode: true ) + bot.tags = [ "membership", "tags", "commands" ] + + # Outgoing IRC message thread + # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters." + # PRIVMSG #channel message\r\n + spawn do + while msgtuple = ircipc.receive # why does this need to be a tuple? + sizelimit=( 512 - ( msgtuple[0].size + 12 ) ) + bot.message( msgtuple[0], msgtuple[1][0..sizelimit] ) # limit size + end + end + + # Create a handler to process incoming messages + bot.on_message do |message| + spawn do + next unless ( userlogreturn = userlog( ircipc, settings, message ) ) + chatuser, uid, userdir, lastseen = userlogreturn + if ( t2sreturn = t2s( t2sipc, settings, userdir, chatuser, message.params[1] ) ) + lastvoice.insert( 0, t2sreturn ) + lastvoice = lastvoice[0..4] + end + # Have we seen this user lately? + if ( ( Time.utc.to_unix - lastseen ) >= 14400 ) + twitchipc.send( { "get_user", uid.to_u64 } ) + twitchipc.send( { "get_followers", uid.to_u64 } ) + prevnames = Array( String ).new + if ( prevnames = Dir.children( settings["statedir"] + "/uids/" + uid + "/names" ) ) && ( prevnames.size > 1 ) + prevnames.delete( chatuser ) + puts "\033[38;5;14m#{chatuser} previous names: #{prevnames.join(", ")}\033[0m" + end + # play random fanfare if available. + {% if flag?(:windows) %} + # This file hierarchy gets manually set up for now. + # Maybe someday let mods do something like: + # !fanfare add pipne https://pip.ne/sniff.mp3 + if File.exists?( userdir + "/fanfare/" ) + playaudiofile( userdir + "/fanfare/" + Dir.children( userdir + "/fanfare/" ).sample ) + end + {% else %} + t2smsg( settings, "fanfare #{uid} #{chatuser}" ) + {% end %} + end + own = ( message.tags["room-id"] == message.tags["user-id"] ) + vip = ( message.tags["badges"].to_s.matches?( /(^|,)vip\// ) ) + mod = ( message.tags["mod"] == "1" ) + sub = ( message.tags["subscriber"] == "1" ) + [ { 301501910, "farts" }, + { 322820, "explosions" } ].each do | fx | + emoteid = fx[0] + fxname = fx[1] + if ( message.tags["emotes"]? ) && + ( message.tags["emotes"] ) && + ( fxemotes = message.tags["emotes"].not_nil!.split("/").select( /^#{emoteid}[:_]/ ).join(",").split(",") ) && + ( ! fxemotes[0].empty? ) && + ( effects.values.select(/^#{fxname}/).size > 0 ) + effects.values.select(/^#{fxname}/).sample( fxemotes.size ).each do | filepath | + obstemporarymediacreate( obs, "meta-foreground", filepath.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{filepath}" ) + end + end + end + case message.params[1] + when /pl(ease|z).+nerf/i + obsrandommediaenable( obs, "media-youdied-nerf" ) + when /you.+died/i + obsrandommediaenable( obs, "media-youdied" ) + when /did +nothing +wrong/i + obsrandommediaenable( obs, "media-youdied-tiggs" ) + when /thanks.+obama/i + obsrandommediaenable( obs, "media-youdied-thanksobama" ) + end + next unless ( ( match = message.params[1].match(/^ *!([A-Za-z]+) (([a-zA-Z0-9= _\:,.&'\/?;\\\(\)\[\]+\-]|!)+)/) || message.params[1].match(/^ *!([A-Za-z]+)/) ) ) + cmd = match[1] + if ( ( cmd =~ /^(substitute|voicesub)$/ ) && ( mod || sub ) ) + case message.params[1].split( " " ).size + when 1 + if File.exists?( userdir + "/voicesub" ) + bot.message( "##{settings["channel"]}", "| Current name substitution is \"#{File.read( userdir + "/voicesub" )}\"." ) + else + bot.message( "##{settings["channel"]}", "| Current name substitution is disabled." ) + end + else + if match[2]? + voicesub = match[2].downcase + if voicesub =~ /^(disabled|null|disable|none)$/ + if File.exists?( userdir + "/voicesub" ) + File.delete( userdir + "/voicesub" ) + bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is now disabled." ) + else + bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is already disabled." ) + end + else + Dir.mkdir_p( userdir ) + File.write( userdir + "/voicesub", voicesub ) + bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is now \"#{File.read( userdir + "/voicesub" )}\"." ) + end + end + end + elsif ( cmd =~ /^(commands|help)$/ ) + bot.message( "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/txt/commands.txt" ) + elsif ( cmd =~ /^(dexem)$/ ) + ircipc.send( { "##{settings["channel"]}", "| You're doing great work, Dexem!" } ) + elsif ( ( cmd == "lastvoice" ) && ( mod || sub ) ) + unless lastvoice.empty? + bot.message( "##{settings["channel"]}", "| Last voices were " + lastvoice.join( ", " ) ) + else + bot.message( "##{settings["channel"]}", "| No voices used so far." ) + end + elsif ( ( cmd == "regeneratevoicelist" ) && ( own ) ) + voices = regeneratevoicelist( settings, aws, gcloud ) + ircipc.send( { "##{settings["channel"]}", "| Regenerated voicelist." } ) + elsif ( ( cmd =~ /^(voices|voice(s|)list|listvoice(s|))$/ ) && ( mod || sub ) ) + bot.message( "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/voicelist.txt" ) + elsif ( ( cmd =~ /^(setvoice|voice)$/ ) && ( mod || sub ) ) + case message.params[1].split( " " ).size + when 1 + namesub, voice_setting, voice_output = getvoice( settings, userdir, chatuser ) + bot.message( "##{settings["channel"]}", "| Current voice is #{voice_setting}" ) + when 2 + if match[2]? + voice = match[2].downcase + if voice =~ /disabled|null|disable|none|random/ + if File.exists?( userdir + "/voice" ) + File.delete( userdir + "/voice" ) + bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is now random." ) + else + bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is already random." ) + end + elsif voices.has_key?( voice ) + csvoice = voices[voice] + Dir.mkdir_p( userdir ) + File.write( userdir + "/voice", csvoice ) + pp userdir + bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is now #{File.read( userdir + "/voice" )}." ) + else + pp ( match ) + bot.message( "##{settings["channel"]}", "| Invalid voice. To see list, use !voices" ) + end + end + when 3 + else + end + elsif ( cmd =~ /^uptime$/ ) + stats = obs.stats.to_h + ostatus = obs.outputs["adv_stream"].status.to_h + ircipc.send( { "##{settings["channel"]}", "| #{ostatus["outputTimecode"].to_s[0..7]} #{stats["activeFps"].to_s[0,2]}fps Usage: #{stats["cpuUsage"].as(Float64).to_i64}% #{stats["memoryUsage"].as(Float64).to_i64}MiB Frame losses: #{stats["outputSkippedFrames"].as(Int64)} #{stats["renderSkippedFrames"].as(Int64)}" } ) + # !newimage [name] [url] (random|top|bottom|left|right|center) + # download url, verify mimetype with libmagic, duplicate existing medialoop source, change "file" SourceSetting + elsif ( ( cmd =~ /^inputsetting(|s)$/ ) && ( mod || own || vip ) ) + if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) + ircipc.send( { "##{settings["channel"]}", "| #{obs.inputs[match[2]].settings.to_h.to_s} " } ) + end + elsif ( ( cmd =~ /^input(|s)$/ ) && ( mod || own || vip ) ) + ircipc.send( { "##{settings["channel"]}", "| inputs: #{obs.inputs.to_h.keys.join(" ")}" } ) + elsif ( ( cmd =~ /^scene(|s)$/ ) && ( mod || own || vip ) ) + if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) + obs.scenes[match[2]].program! + else + ircipc.send( { "##{settings["channel"]}", "| scenes: #{obs.scenes.to_h.keys.join(" ")}" } ) + end + elsif ( ( cmd =~ /^source(|s)$/ ) && ( mod || own || vip ) ) + request = Hash( String, String | Bool ).new + if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) + obs.scenes.current.metascene[match[2]][0].toggle! + # in studio mode, direct Scene->SceneItem toggles require a transition + obs.scenes.current.preview! + obs.transition! + else + ircipc.send( { "##{settings["channel"]}", "| current sources: #{obs.scenes.current.metascene.keys.join(" ")}" } ) + end + elsif ( ( cmd =~ /^filter(|s)$/ ) && ( mod || own ) ) + if ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+)/ ) ) + obs.sources.to_h[filterargs[1]].filters[filterargs[2]].toggle! + elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) + ircipc.send( { "##{settings["channel"]}", "| #{match[2]} filters: #{obs.sources.to_h[match[2]].filters.to_h.keys.join(" ")}" } ) + else + ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument, and optionally one filter name to toggle on or off." } ) + end + elsif ( cmd == "create" && ( mod || own || vip ) ) + if ( match[2]? ) && ( match[2] =~ /^([\/a-zA-Z0-9-_]+)/ ) + obstemporarymediacreate( obs, "meta-foreground", match[2], "C:/cygwin64/home/user/effects/#{match[2]}.webm" ) + else + ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument." } ) + end + elsif ( cmd =~ /^(metaminute|youdied)$/ ) + obsrandommediaenable( obs, "media-#{cmd}" ) + elsif ( cmd =~ /^fart(s|)$/ ) && ( sub || mod || own || vip ) + if effects.values.select( /^farts/ ).size > 0 + if ( match[2]? ) && ( match[2].match( /^([0-9]+)/ ) && ( match[2].to_i > 1 ) && ( match[2].to_i <= 32 ) ) + count = match[2].to_i + else + count = 1 + end + effects.values.select( /^farts/ ).sample( count ).each do | effect | + obstemporarymediacreate( obs, "meta-foreground", effect.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{effect}" ) + end + else + puts "farts undefined" + end + elsif ( cmd =~ /^explosion(s|)$/ ) && ( sub || mod || own || vip ) + if effects.values.select( /^explosions/ ).size > 0 + if ( match[2]? ) && ( match[2].match( /^([0-9]+)/ ) && ( match[2].to_i > 1 ) && ( match[2].to_i <= 32 ) ) + count = match[2].to_i + else + count = 1 + end + effects.values.select( /^explosions/ ).sample( count ).each do | effect | + obstemporarymediacreate( obs, "meta-foreground", effect.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{effect}" ) + end + else + puts "explosions undefined" + end + # FIXME: This is only half-implemented + elsif ( ( cmd =~ /^(user)$/ ) && ( mod || own ) ) + if match[2]? && match[2] =~ /[a-z][a-z0-9_]+/ + twitchipc.send( { "get_user", match[2] } ) + elsif match[2]? && match[2] =~ /[0-9]+/ + twitchipc.send( { "get_user", match[2].to_u64 } ) + else + twitchipc.send( { "get_user", settings["channel_id"].to_u64 } ) + end + elsif ( ( cmd =~ /^(status|title)$/ ) && ( mod || own ) ) + if match[2]? + client.put_channel!( settings["channel_id"].to_u64, title: match[2] ) + json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) + ircipc.send( { "##{settings["channel"]}", "Title is now \"#{ json["data"][0]["title"] }\""} ) + else + json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) + ircipc.send( { "##{settings["channel"]}", "| Title is currently \"#{ json["data"][0]["title"] }\""} ) + end + elsif ( ( cmd =~ /^(game|category)$/ ) && ( mod || own ) ) + if match[2]? + puts "2 matches" + client.put_channel!( settings["channel_id"].to_u64, game: match[2] ) + json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) + ircipc.send( { "##{settings["channel"]}", "Game is now \"#{ json["data"][0]["game_name"] }\""} ) + else + puts "1 matches" + json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) + ircipc.send( { "##{settings["channel"]}", "| Game is currently \"#{ json["data"][0]["game_name"] }\""} ) + end + elsif ( ( cmd == "urban" ) && ( mod || own || sub || vip ) ) + if match[2]? && match[2] =~ /^([a-zA-Z0-9 -])+$/ + definition = urbandef( match[2] ) + ircipc.send( { "##{settings["channel"]}", definition[0,400] } ) + t2s( t2sipc, settings, userdir, chatuser, definition[0,400] ) + else + ircipc.send( { "##{settings["channel"]}", "| Urban Dictionary search term should consist of letters, numbers, spaces, and/or hyphens." } ) + end + elsif ( cmd == "matrix" ) + effectsmsg( settings, "overlay glmatrix" ) + elsif ( cmd =~ /juggle|juggler/ ) + effectsmsg( settings, "overlay juggler3d" ) + elsif ( cmd =~ /fireworks|firework/ ) + effectsmsg( settings, "overlay fireworkx" ) + elsif ( cmd == "pipes" ) + if match[2]? && match[2] =~ /^(fast|faster)$/ + effectsmsg( settings, "overlay pipes " + match[2] ) + else + effectsmsg( settings, "overlay pipes" ) + end + elsif ( cmd == "jellyfish" ) + effectsmsg( settings, "overlay hydrostat" ) + elsif ( cmd == "gluten" ) + effectsmsg( settings, "overlay flyingtoasters" ) + elsif ( cmd =~ /^(glsnake|glmatrix|gibson|xmatrix|flyingtoasters|moebiusgears|fireworkx|hydrostat|hypertorus|jigsaw|juggler3d|kaleidocycle|kumppa|molecule|noof|polyhedra)$/ ) + effectsmsg( settings, "overlay " + cmd ) + elsif ( cmd =~ /gltext|cowsay|xcowsay|cowfuscious/ ) + ( cmd == "cowfuscious" ) && ( cmd = "xcowsay" ) + if ( match[2]? ) && ( gltextargs = match[2].match( /^([0-9]+) +(.+)$/ ) ) + seconds=UInt64.new( 1 ) + seconds=gltextargs[1].to_u64 + if ( own || mod || vip ) + effectsmsg( settings, "overlay #{cmd} #{match[2]}" ) + puts "matched #{cmd} #{match[2]}" + elsif ( sub ) + if ( seconds > 20 ) + seconds=20 + end + effectsmsg( settings, "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) + puts "matched #{cmd} #{seconds} #{gltextargs[2]}" + else + if ( seconds > 5 ) + seconds=5 + end + effectsmsg( settings, "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) + puts "matched #{cmd} #{seconds} #{gltextargs[2]}" + end + elsif match[2]? && match[2] =~ /^.+$/ + effectsmsg( settings, "overlay #{cmd} #{match[2]}" ) + puts "matched #{cmd} #{match[2]}" + else + effectsmsg( settings, "overlay #{cmd}" ) + puts "failed to match gltext" + end + elsif ( cmd =~ /^(cow(s|)|cowabunga|holycow$)/ ) + if match[2]? && match[2] =~ /^([0-9])+$/ + effectsmsg( settings, "overlay bouncingcow #{match[2]}" ) + else + effectsmsg( settings, "overlay bouncingcow" ) + end + elsif ( cmd == "overlay" ) + if match[2]? && match[2] =~ /^[a-z]+$/ + ircipc.send( { "##{settings["channel"]}", "| overlay requires an argument consisting wholly of lower case characters."} ) + effectsmsg( settings, "overlay #{match[2]}" ) + end + elsif ( cmd =~ /^(shout|shoutout)$/ ) + if match[2]? && match[2] =~ /^[a-z]+$/ + ircipc.send( { "##{settings["channel"]}", "| Go check out twitch.tv/#{match[2]}"} ) + effectsmsg( settings, "overlay gltext 5 Go follow #{match[2]}" ) + else + ircipc.send( { "##{settings["channel"]}", "| Missing argument."} ) + end + elsif ( cmd =~ /^(system|dxdiag|computer)$/ ) && ( Crystal::DESCRIPTION =~ /linux/ ) + ircipc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/tmp/DxDiag.txt" } ) + elsif ( cmd == "hackerman" ) + ircipc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/img/hackerman.jpg" } ) + elsif ( cmd =~ /^(songrequest|sr)$/ ) && match[2]? + puts ("song detected: #{match[2]}") + if ( ( match[2] =~ /list=/ ) && ( match[2] !~ /v=/ ) ) + ircipc.send( { "##{settings["channel"]}", "| Lists are not accepted.\n" } ) + elsif Process.run( "sraddsong.sh", {match[2]} ) + m = MPD::Client.new + currentsong = m.currentsong + if ( currentsong ) && ( currentsong["file"].to_s == "http://music/music.ogg" ) + m.next + end + if ( status = m.status ) && ( playlistinfo = m.playlistinfo ) + ircipc.send( { "##{settings["channel"]}", "| " + playlistinfo[ status["playlistlength"].to_i - 1 ]["file"].to_s + " added in position " + status["playlistlength"].to_s } ) + else + ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) + end + m.disconnect + else + end + elsif ( cmd =~ /^current(|song)$/ ) + m = MPD::Client.new + if ( currentsong = m.currentsong ) + ircipc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) + else + ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) + end + m.disconnect + elsif ( cmd =~ /^seek$/ ) + if ( ( match[2] ) && ( match[2] =~ /([+-]|)[0-9]/ ) ) + m = MPD::Client.new + m.seekcur( match[2] ) + if ( ( musicstatus = m.status ) && ( pos = musicstatus["elapsed"].to_f.to_u64 ) ) + min=( pos / 60 ).to_u64 + sec=( pos % 60 ).to_u64 + ircipc.send( { "##{settings["channel"]}", "| " + sprintf( "2%d:2%d", min, sec ) } ) + else + ircipc.send( { "##{settings["channel"]}", "| An error occurred. " } ) + end + m.disconnect + else + ircipc.send( { "##{settings["channel"]}", "| Seek requires an argument of an absolute position in integer number of seconds, or a relative position in in signed integer number of seconds such as +60" } ) + end + elsif ( cmd =~ /^next(|song)$/ ) + m = MPD::Client.new + m.next + if ( status = m.status ) && ( status["playlistlength"].to_i > 0 ) + if ( currentsong = m.nextsong ) + ircipc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) + else + ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) + end + else + ircipc.send( { "##{settings["channel"]}", "| Playlist is now empty." } ) + end + m.disconnect + elsif ( cmd == "followage" ) + if match[2]? + args = match[2].split(/\s/) + if args[1]? + json = JSON.parse( client.get_user_follows( from: client.user_id( args[0] ).to_u64 , to: client.user_id( args[1] ).to_u64 ) ) + puts client.user_id( args[0] ).to_s + puts client.user_id( args[1] ).to_s + puts json + ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) + elsif args[0]? + json = JSON.parse( client.get_user_follows( from: client.user_id( args[0] ).to_u64 , to: settings["channel_id"].to_u64 ) ) + puts client.user_id( args[0] ).to_s + puts json + ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) + end + else + json = JSON.parse( client.get_user_follows( from: uid.to_u64 , to: settings["channel_id"].to_u64 ) ) + puts json + ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) + end + end + rescue ex + puts ex + ircipc.send( { "##{settings["channel"]}", "An error occurred! " + ex.message.to_s } ) + end + end + + rooms = Array( String ).new + rooms = [ "##{settings["channel"]}" ] + + # Connect to Twitch + bot.run( rooms.map{ | room | room.sub( /^#/, "") } ) + rescue ex : IO::Error + pp ex + sleep 1 + # loop to reconnect + rescue ex + pp ex + {% if flag?(:windows) %} + puts "press enter to end program" + gets + {% end %} + exit 1 + end +end + + +# FastIRC::Message.to_s +# @badge-info=;badges=;color=;display-name=BungMonkey;emotes=;flags=;id=20fcc358-4fc3-4919-8229-f1034743d18f;mod=0;room-id=46694819;subscriber=0;tmi-sent-ts=1587876383907;turbo=0;user-id=59895482;user-type= :bungmonkey!bungmonkey@bungmonkey.tmi.twitch.tv PRIVMSG #kr3wzz test + +# FastIRC::Message.tags +# {"badge-info" => "subscriber/34", +# "badges" => "broadcaster/1,subscriber/12", +# "color" => "", +# "display-name" => "BungMonkey", +# "emote-only" => "1", +# "emotes" => "300780134:0-9", +# "flags" => "", +# "id" => "c5c08c05-6e39-483f-b426-488dfc477a6c", +# "mod" => "0", +# "room-id" => "59895482", +# "subscriber" => "1", +# "tmi-sent-ts" => "1587871386678", +# "turbo" => "0", +# "user-id" => "59895482", +# "user-type" => ""} + +#command = "PRIVMSG" +#prefix = Prefix(@source="bungmonkey", @user="bungmonkey", @host="bungmonkey.tmi.twitch.tv") +#params = ["#bungmonkey", "test test test test"] |