require "twitch/irc" require "http" require "uri" require "twitcr" require "json" require "crystal_mpd" STDOUT.sync = 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 explosions? if File.exists?( settings["configdir"] + "/explosionlist.txt" ) explosions = Hash(String, String).new File.each_line( settings["configdir"] + "/explosionlist.txt" ) do |line| explosions[ 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 obs4ipc = Channel( String ).new obs5ipc = Channel( 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 spawn do loop do begin while twitchtuple = twitchipc.receive cmd, arg = [ *twitchtuple ] case cmd when "get_user" userinfo = JSON.parse( client.get_user( arg.to_u64 ) )["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 snifflast = Int64.new(0) def urbandef( term : String ) ssl_context = OpenSSL::SSL::Context::Client.new {% if flag?(:windows) %} ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE {% end %} #http://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 "}, { / { # "vidcap-c920" => { # "render" => true, "type" => "dshow_input", "x" => 0.0, "y" => 0.0 # }, # "gst-bungfront" => { # "type" => "gstreamer-source", "x" => 0.0, "y" => 0.0 # } # } # # scenes[currentscene][source]["type"] => "scene" sourcemap = Hash( String, String ).new scenes[currentscene].each do | source, sourcedata | # maybe make this recurse later sourcemap[source.to_s] = currentscene if ( ( ! sourcedata.has_key?("type") ) || ( sourcedata["type"] == "scene" ) ) scenes[source.to_s].each do | metasource, metasourcedata | sourcemap[metasource.to_s] = source.to_s end end end return sourcemap 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 def obsrequest( type : String, id : String, data : ( String | Nil | JSON::Any ) = nil ) request = JSON.build do |json| json.object do json.field "op", 6 json.field "d" do json.object do json.field "requestType", type json.field "requestId", id if data json.field "requestData", data end end end end end return request.to_s end # obs-websocket-5 thread spawn do loop do begin obs5_pubsub = HTTP::WebSocket.new( URI.parse( "ws://127.0.0.1:4455/" ), HTTP::Headers{"Cookie" => "SESSIONID=1235", "Sec-WebSocket-Protocol" => "obswebsocket.json"} ) # Outgoing # state currentscene = "unknown" canvas = { 1280, 720 } # inputname => { property => value } inputs = Hash( String, Hash( String, String | Int64 | Float64 | Bool ) ) # GetInputList # GetInputSettings # scenename => { sceneitemid => { property => value } scenes = Hash( String, Hash( String, Hash( String, Int64 | Float64 | Bool | String ) ) ).new # GetSceneList # GetSceneItemList # GetSceneItemTransform spawn do while msg = obs5ipc.receive pp msg if ( match = msg.match( /^\x10source(-notransition|) ([a-z0-9-_]+)/ ) ) transition = ( match[1] != "-notransition" ) source = match[2] sources = reversesource( scenes, currentscene ) pp sources unless ( scene = sources[source]? ) ircipc.send( { "#" + settings["channel"], "| obs5: source not found: #{source}" } ) next end obs5_pubsub.send( obsrequest( "GetSceneItemEnabled", "GetSceneItemTransform", JSON.parse( { "sceneName" => scene, "sceneItemId" => scenes[scene][source]["id"] }.to_json ) ) ) if ( match = msg.match( /^\x10source[-a-zA-Z0-9_]* ([a-z0-9-_]+) (true|false)/ ) ) obs5_pubsub.send( obsrequest( "SetSceneItemEnabled", "SetSceneItemEnabled", JSON.parse( { "sceneName" => scene, "sceneItemId" => scenes[scene][source]["id"], "sceneItemEnabled" => ( match[2] == "true" ) }.to_json ) ) ) elsif ( match = msg.match( /^\x10source[-a-zA-Z0-9_]* ([a-z0-9-_]+)/ ) ) obs5_pubsub.send( obsrequest( "SetSceneItemEnabled", "SetSceneItemEnabled", JSON.parse( { "sceneName" => scene, "sceneItemId" => scenes[scene][source]["id"], "sceneItemEnabled" => ! scenes[scene][source]["render"] }.to_json ) ) ) end # if transition # request = Hash( String, String | Bool ).new # request["request-type"] = "SetPreviewScene" # request["message-id"] = "SetPreviewScene" # request["scene-name"] = currentscene # obs4_pubsub.send( request.to_json ) # sleep 0.1 # somehow these get out-of-order otherwise? >:( # request = Hash( String, String ).new # request["request-type"] = "TransitionToProgram" # request["message-id"] = "TransitionToProgram" # obs4_pubsub.send( request.to_json ) # end elsif ( match = msg.match( /^\x10source/ ) ) pp scenes pp currentscene sources = reversesource( scenes, currentscene ) #ircipc.send( { "#" + settings["channel"], "| obs: " + sources.keys.join(", ") } ) obs5_pubsub.send( obsrequest( "GetSceneItemList", "GetSceneItemList", JSON.parse( { "sceneName" => currentscene }.to_json ) ) ) else obs5_pubsub.send( msg ) end end end # Incoming obs5_pubsub.on_message do | message | json = JSON.parse( message ) if json["error"]? puts json["error"] ircipc.send( { "#" + settings["channel"], "| obs5: #{json["error"]}" } ) next end case json["op"].as_i when 0 # hello json_text = %({"op":1,"d":{"rpcVersion":1}}) obs5_pubsub.send( json_text ) when 5 # event edata = json["d"]["eventData"] case json["d"]["eventType"].as_s when "CurrentProgramSceneChanged" ircipc.send( { "#" + settings["channel"], "| obs5: switched scene to " + ( edata["sceneName"]?.as_s? || "unknown" ) } ) puts "SwitchScenes: currentscene is " + currentscene currentscene = edata["sceneName"].as_s puts "SwitchScenes: currentscene is " + currentscene when "SceneItemEnableStateChanged" # FIXME: No sceneItemName? need to convert scenes/sceneitems into a proper object interface or build a new index so we can lookup name by id edata["sceneItemEnabled"].as_bool edata["sceneItemId"].as_i edata["sceneName"].as_s end when 7 # requestResponse case json["d"]["requestType"].as_s when "GetStats" # FIXME: RequestBatch this with GetOutputStatus? streamstatus = json["d"]["responseData"].as_h obs5_pubsub.send( obsrequest( "GetOutputList", "GetOutputList" )) when "GetOutputList" output_name = String.new json["d"]["responseData"]["outputs"].as_a.each do | output | if output["outputKind"].as_s == "rtmp_output" output_name = output["outputName"].as_s end end unless output_name.empty? obs5_pubsub.send( obsrequest( "GetOutputStatus", "GetOutputStatus", JSON.parse( { "outputName" => output_name }.to_json ) )) end when "GetOutputStatus" streamstatus = streamstatus.merge( json["d"]["responseData"].as_h ) ircipc.send( { "##{settings["channel"]}", "| #{streamstatus["outputTimecode"].as_s[0..7]} #{streamstatus["activeFps"].as_f.to_s[0,2]}fps Usage: #{streamstatus["cpuUsage"].as_f.to_i64}% #{streamstatus["memoryUsage"].as_f.to_i64}MiB Frame losses: #{streamstatus["outputSkippedFrames"].as_i64} #{streamstatus["outputSkippedFrames"].as_i64}" } ) when /^GetSceneItemList/ pp json rdata = json["d"]["responseData"] ( match = json["d"]["requestId"].as_s.match( /^GetSceneItemList-([A-Za-z0-9-_\/]+)/ ) ) || next # scenename => { sourcename => { property => value } # scenes = Hash( String, Hash( String, Hash( String, Int64 | Float64 | Bool | String ) ) ).new rdata["sceneItems"].as_a.each do | source | scenes[match[1]][source["sourceName"]]? || ( scenes[match[1]][source["sourceName"].as_s] = Hash( String, Int64 | Float64 | Bool | String ).new ) source["sceneItemEnabled"].as_bool != Nil && ( scenes[match[1]][source["sourceName"].as_s]["render"] = source["sceneItemEnabled"].as_bool ) source["sceneItemId"].as_i64? && ( scenes[match[1]][source["sourceName"].as_s]["id"] = source["sceneItemId"].as_i64 ) source["inputKind"].as_s? && ( scenes[match[1]][source["sourceName"].as_s]["type"] = source["inputKind"].as_s ) source["sceneItemTransform"]["positionX"].as_f? && ( scenes[match[1]][source["sourceName"].as_s]["x"] = source["sceneItemTransform"]["positionX"].as_f ) source["sceneItemTransform"]["positionY"].as_f? && ( scenes[match[1]][source["sourceName"].as_s]["y"] = source["sceneItemTransform"]["positionY"].as_f ) source["sceneItemTransform"]["sourceWidth"].as_f? && ( scenes[match[1]][source["sourceName"].as_s]["source_cx"] = source["sceneItemTransform"]["sourceWidth"].as_f ) source["sceneItemTransform"]["sourceHeight"].as_f? && ( scenes[match[1]][source["sourceName"].as_s]["source_cy"] = source["sceneItemTransform"]["sourceHeight"].as_f ) if source["sceneItemTransform"]["width"].as_i64? scenes[match[1]][source["sourceName"].as_s]["cx"] = source["sceneItemTransform"]["cx"].as_i64 else scenes[match[1]][source["sourceName"].as_s]["cx"] = 0.to_i64 end if source["sceneItemTransform"]["height"].as_i64? scenes[match[1]][source["sourceName"].as_s]["cy"] = source["sceneItemTransform"]["cy"].as_i64 else scenes[match[1]][source["sourceName"].as_s]["cy"] = 0.to_i64 end end when "GetSceneList" rdata = json["d"]["responseData"] currentscene = rdata["currentProgramSceneName"].as_s pp rdata["scenes"] rdata["scenes"].as_a.each do |scene| scenes[scene["sceneName"].as_s] = Hash( String, Hash( String, Int64 | Float64 | Bool | String ) ).new end if json["d"]["requestId"].as_s =~ /Quietly/ # We need to populate the scenes table once at startup scenes.keys.each do |scene| obs5_pubsub.send( obsrequest( "GetSceneItemList", "GetSceneItemList-#{scene}", JSON.parse( { "sceneName" => scene }.to_json ) )) end else # Loudly ircipc.send( { "#" + settings["channel"], "| obs5: " + rdata["scenes"].as_a.map{ |scene| scene["sceneName"] }.join(", ") } ) end when "GetVideoSettings" rdata = json["d"]["responseData"] canvas = { rdata["baseWidth"].as_i.to_u64 , rdata["baseHeight"].as_i.to_u64 } pp canvas end end print( "5 RECEIVED: ") print( json.to_pretty_json ) print( "\n") end spawn do # authentication sleep 1 obs5_pubsub.send( obsrequest( "GetVideoSettings", "GetVideoSettings" )) obs5_pubsub.send( obsrequest( "GetStats", "GetStats" )) sleep 1 obs5_pubsub.send( obsrequest( "GetSceneList", "GetSceneList Quietly" )) end obs5_pubsub.run rescue ex : Socket::ConnectError # these are a bit noisy #pp ex obs5_pubsub && obs5_pubsub.close rescue ex : IO::Error pp ex ircipc.send( { "#" + settings["channel"], "obs.cr:obs: " + ex.to_s.gsub(/\r|\n/, ' ') + ": Maybe try again in 10 seconds?" } ) rescue ex pp ex pp ex.backtrace? ircipc.send( { "#" + settings["channel"], "irc.cr:obs: " + ex.to_s.gsub(/\r|\n/, ' ') } ) obs5_pubsub && obs5_pubsub.close end sleep 10 next end end # obs-websocket-4 thread spawn do loop do begin obs4_pubsub = HTTP::WebSocket.new( URI.parse( "ws://127.0.0.1:4444/" ), HTTP::Headers{"Cookie" => "SESSIONID=1234"} ) # Outgoing # state currentscene = "unknown" canvas = { 1280, 720 } # scenename => { sourcename => { property => value } scenes = Hash( String, Hash( String, Hash( String, Int64 | Float64 | Bool | String ) ) ).new spawn do while msg = obs4ipc.receive pp msg if ( match = msg.match( /^\x10source(-notransition|) ([a-z0-9-_]+)/ ) ) transition = ( match[1] != "-notransition" ) source = match[2] sources = reversesource( scenes, currentscene ) pp sources request = Hash( String, String | Bool ).new request["request-type"] = "SetSceneItemProperties" request["message-id"] = "SetSceneItemProperties" unless ( scene = sources[source]? ) ircipc.send( { "#" + settings["channel"], "| obs4: source not found: #{source}" } ) next end request["scene-name"] = scene request["item"] = source if ( match = msg.match( /^\x10source[-a-zA-Z0-9_]* ([a-z0-9-_]+) (true|false)/ ) ) request["visible"] = ( match[2] == "true" ) elsif ( match = msg.match( /^\x10source[-a-zA-Z0-9_]* ([a-z0-9-_]+)/ ) ) pp scenes[scene][source]["render"] request["visible"] = ( scenes[scene][source]["render"]? != true ) # use a random similarly named source instead? randsources = sources.keys.select!{ |x| x =~ /^#{source}/} if ( randsources.size > 1 ) #if Random.new.next_bool # 50/50 if Random.new.rand(5) == 0 # 20/80 source = randsources.sample scene = sources[source] request["scene-name"] = scene request["item"] = source end end end obs4_pubsub.send( request.to_json ) puts "x10source: currentscene is " + currentscene if transition request = Hash( String, String | Bool ).new request["request-type"] = "SetPreviewScene" request["message-id"] = "SetPreviewScene" request["scene-name"] = currentscene obs4_pubsub.send( request.to_json ) sleep 0.1 # somehow these get out-of-order otherwise? >:( request = Hash( String, String ).new request["request-type"] = "TransitionToProgram" request["message-id"] = "TransitionToProgram" obs4_pubsub.send( request.to_json ) end elsif ( match = msg.match( /^\x10sceneitem ([a-z0-9-_]+)/ ) ) source = match[1] sources = reversesource( scenes, currentscene ) pp sources request = Hash( String, String | Bool ).new request["request-type"] = "GetSceneItemProperties" request["message-id"] = "GetSceneItemProperties" unless ( scene = sources[source]? ) ircipc.send( { "#" + settings["channel"], "| obs4: source not found: #{source}" } ) next end request["scene-name"] = scene request["item"] = source obs4_pubsub.send( request.to_json ) elsif ( match = msg.match( /^\x10source/ ) ) request = Hash( String, String ){ "request-type" => "GetCurrentScene", "message-id" => "GetCurrentScene", } obs4_pubsub.send( request.to_json ) elsif ( match = msg.match( /^\x10newsource(-temporary|) ([a-zA-Z0-9-_]+)/ ) ) temporary = ( match[1]? ) sourcename = "media#{temporary}-#{match[2]}" request = Hash( String, String | Hash( String, String | Bool ) ){ "request-type" => "CreateSource", "message-id" => "CreateSource", "sourceName" => sourcename, "sourceKind" => "ffmpeg_source", "sceneName" => "meta-foreground", #"setVisible" => "false", # this somehow triggers one or more MediaEnded events? "sourceSettings" => { "clear_on_media_end"=> true, "close_when_inactive"=> false, "is_local_file"=> true, "local_file"=> "c:/cygwin64/home/user/effects/explosions/#{match[2]}.webm", "looping"=> false }, } ircipc.send( { "##{settings["channel"]}", "Creating #{sourcename}" } ) obs4_pubsub.send( request.to_json ) else obs4_pubsub.send( msg ) end end end # Incoming obs4_pubsub.on_message do | message | json = JSON.parse( message ) if json["error"]? puts json["error"] ircipc.send( { "#" + settings["channel"], "| obs4: #{json["error"]}" } ) next end case json["update-type"]? when "StreamStatus" # these are a bit noisy, streamstatus = json.as_h next # so skip them when "SwitchScenes" ircipc.send( { "#" + settings["channel"], "| obs4: switched scene to " + ( json["scene-name"]?.as_s? || "unknown" ) } ) puts "SwitchScenes: currentscene is " + currentscene currentscene = json["scene-name"].as_s puts "SwitchScenes: currentscene is " + currentscene when "MediaEnded" pp json source = json["sourceName"].as_s sources = reversesource( scenes, currentscene ) if scene = sources[source]? if ( scenes[scene][source]["render"] == true ) && ( source =~ /^media-/ ) && ( source !~ /media-temporary/ ) request = Hash( String, String | Bool ){ "request-type" => "SetSceneItemProperties", "message-id" => "SetSceneItemProperties", "scene-name" => scene, "item" => source, "visible" => false } ircipc.send( { "##{settings["channel"]}", "| obs4: Media ended; disabling #{source}" } ) obs4ipc.send( request.to_json ) end end if ( source =~ /^media-temporary-/ ) request = Hash( String, String | Hash( String, String ) ){ "scene" => "meta-foreground", "request-type" => "DeleteSceneItem", "message-id" => "DeleteSceneItem", "item" => { "name" => json["sourceName"].as_s }, } ircipc.send( { "##{settings["channel"]}", "Deleting #{source}" } ) obs4ipc.send( request.to_json ) end when "SourceFilterVisibilityChanged" ircipc.send( { "##{settings["channel"]}", "| obs4: source #{json["sourceName"]} filter #{json["filterName"]} visibility is currently #{json["filterEnabled"]}" } ) when "SceneItemAdded" request = Hash( String, String ){ "request-type" => "GetCurrentScene", "message-id" => "GetCurrentScene Quietly", } obs4ipc.send( request.to_json) request = Hash( String, String ){ "request-type" => "GetSceneList", "message-id" => "GetSceneList Quietly", } obs4ipc.send( request.to_json ) if json["item-name"].as_s =~ /media-temporary/ request = Hash( String, String | Bool ).new request["request-type"] = "GetSceneItemProperties" request["message-id"] = "GetSceneItemProperties" request["scene-name"] = json["scene-name"].as_s request["item"] = json["item-name"].as_s obs4_pubsub.send( request.to_json ) end #scenes[json["scene-name"].as_s][json["item-name"].as_s] when "SourceRenamed" sourceprev = json["previousName"].as_s sourcenew = json["newName"].as_s # does not tell us the scenes involved? # Will have to iterate through them. scenes.keys.each{ | scene | if scenes[scene][sourceprev]? scenes[scene][sourcenew] = scenes[scene][sourceprev] scenes[scene].delete(sourceprev) end } when "SceneItemVisibilityChanged" print( json.to_pretty_json ) if json["item-name"].as_s !~ /media-temporary/ scenes[json["scene-name"].as_s][json["item-name"].as_s]["render"] = json["item-visible"].as_bool ircipc.send( { "##{settings["channel"]}", "| obs4: source #{json["item-name"]} visibility is now #{json["item-visible"].as_bool}" } ) end end print( "4 RECEIVED: ") print( json.to_pretty_json ) print( "\n") case json["message-id"]?.as_s? when "GetSceneItemProperties" if json["name"].as_s =~ /media-temporary/ size = { json["sourceWidth"].as_i, json["sourceHeight"].as_i } request = Hash( String, String | Bool | Hash( String, Float64) ).new request["request-type"] = "SetSceneItemProperties" request["message-id"] = "SetSceneItemProperties" request["scene-name"] = "meta-foreground" # why isn't this returned? request["item"] = json["name"].as_s # FIXME we're not getting real values for sourceWidth and sourceHeight until playback begins, but dropping frames would be horrible. # We should probably just preprocess these values into explosionslist :/ # ffprobe -show_entries stream=width,height big_explosion_1.webm request["position"] = { "x" => (rand(canvas[0]) - ( size[0] / 2 ) ).to_f, "y" => (rand(canvas[1]) - ( size[1] / 2 ) ).to_f, } obs4ipc.send( request.to_json ) #ircipc.send( { "##{settings["channel"]}", "| obs4: #{request.to_json}" } ) end when /^GetVideoInfo/ canvas = { json["baseWidth"].as_i64, json["baseHeight"].as_i64 } pp canvas when /^GetCurrentScene/ currentscene = json["name"].as_s puts "GetCurrentScene: currentscene is " + currentscene if json["message-id"].as_s? !~ /Quietly/ sources = reversesource( scenes, currentscene ) ircipc.send( { "#" + settings["channel"], "| obs4: " + sources.keys.join(", ") } ) end when "GetSourceFilters" ircipc.send( { "#" + settings["channel"], "| obs4: " + json["filters"].as_a.map{ |filter| "#{filter["name"].as_s}: #{filter["enabled"].as_bool}" }.join(", ") } ) when /^GetSceneList/ if json["message-id"].as_s? !~ /Quietly/ ircipc.send( { "#" + settings["channel"], "| obs4: " + json["scenes"].as_a.map{ |scene| scene["name"] }.join(", ") } ) end json["scenes"].as_a.each{ |scene| scenes[scene["name"].as_s]? || ( scenes[scene["name"].as_s] = Hash( String, Hash( String, Int64 | Float64 | Bool | String ) ).new ) scene["sources"].as_a.each{ |source| scenes[scene["name"].as_s][source["name"]]? || ( scenes[scene["name"].as_s][source["name"].as_s] = Hash( String, Int64 | Float64 | Bool | String ).new ) source["render"].as_bool != Nil && ( scenes[scene["name"].as_s][source["name"].as_s]["render"] = source["render"].as_bool ) source["type"].as_s? && ( scenes[scene["name"].as_s][source["name"].as_s]["type"] = source["type"].as_s ) source["x"].as_f? && ( scenes[scene["name"].as_s][source["name"].as_s]["x"] = source["x"].as_f ) source["y"].as_f? && ( scenes[scene["name"].as_s][source["name"].as_s]["y"] = source["y"].as_f ) source["source_cx"].as_f? && ( scenes[scene["name"].as_s][source["name"].as_s]["source_cx"] = source["source_cx"].as_f ) source["source_cy"].as_f? && ( scenes[scene["name"].as_s][source["name"].as_s]["source_cy"] = source["source_cy"].as_f ) if source["cx"].as_i64? scenes[scene["name"].as_s][source["name"].as_s]["cx"] = source["cx"].as_i64 else scenes[scene["name"].as_s][source["name"].as_s]["cx"] = 0.to_i64 end if source["cy"].as_i64? scenes[scene["name"].as_s][source["name"].as_s]["cy"] = source["cy"].as_i64 else scenes[scene["name"].as_s][source["name"].as_s]["cy"] = 0.to_i64 end } } pp scenes end end spawn do sleep 1 request = Hash( String, String ){ "request-type" => "GetCurrentScene", "message-id" => "GetCurrentScene Quietly", } obs4ipc.send( request.to_json ) request = Hash( String, String ){ "request-type" => "GetSceneList", "message-id" => "GetSceneList Quietly", } obs4ipc.send( request.to_json) sleep 1 request = Hash( String, String ){ "request-type" => "GetVideoInfo", "message-id" => "GetVideoInfo Quietly", } obs4ipc.send( request.to_json) end obs4_pubsub.run rescue ex : Socket::ConnectError # these are a bit noisy #pp ex obs4_pubsub && obs4_pubsub.close rescue ex : IO::Error pp ex ircipc.send( { "#" + settings["channel"], "obs.cr:obs4: " + ex.to_s.gsub(/\r|\n/, ' ') + ": Maybe try again in 10 seconds?" } ) rescue ex pp ex pp ex.backtrace? ircipc.send( { "#" + settings["channel"], "irc.cr:obs4: " + ex.to_s.gsub(/\r|\n/, ' ') } ) obs4_pubsub && obs4_pubsub.close end sleep 10 next 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 next unless ( ( match = message.params[1].match(/^ *!([A-Za-z]+) (([a-zA-Z0-9= _\:,.&'\/?;\\\(\)\[\]+\-]|!)+)/) || message.params[1].match(/^ *!([A-Za-z]+)/) ) ) cmd = match[1] 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" ) 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" )}." ) # TODO: make separate script to print streamelements URLs to client machine else pp ( match ) bot.message( "##{settings["channel"]}", "| Invalid voice. To see list, use !voices" ) end end when 3 else end elsif ( cmd =~ /^uptime$/ ) obs5ipc.send( obsrequest( "GetStats", "GetStats" )) # !newimage [name] [url] (random|top|bottom|left|right|center) # download url, verify mimetype with libmagic, duplicate existing medialoop source, change "file" SourceSetting elsif ( ( cmd =~ /^sourcesettings$/ ) && ( mod || own || vip ) ) if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) obs5ipc.send( obsrequest( "GetInputSettings", "GetInputSettings", JSON.parse( { "inputName" => match[2] }.to_json ) )) end elsif ( ( cmd =~ /^inputlist$/ ) && ( mod || own || vip ) ) obs5ipc.send( obsrequest( "GetInputList", "GetInputList" ) ) elsif ( ( cmd =~ /^scene$/ ) && ( mod || own || vip ) ) if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) obs5ipc.send( obsrequest( "SetCurrentProgramScene", "SetCurrentProgramScene", JSON.parse( { "sceneName" => match[2] }.to_json ) )) else obs5ipc.send( obsrequest( "GetSceneList", "GetSceneList" ) ) end elsif ( ( cmd =~ /^source$/ ) && ( mod || own || vip ) ) request = Hash( String, String | Bool ).new if ( match[2]? ) && ( sourceargs = match[2].match( /^([a-zA-Z0-9-_]+) +(true|false)$/ ) ) obs5ipc.send( "\x10source #{match[2]}" ) elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) obs5ipc.send( "\x10source #{match[2]}" ) else obs5ipc.send( "\x10source" ) end elsif ( ( cmd =~ /^filter$/ ) && ( mod || own ) ) request = Hash( String, String | Bool ){ "request-type" => "GetSourceFilters" } if ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+) (true|false)/ ) ) request["request-type"] = "SetSourceFilterVisibility" request["message-id"] = "SetSourceFilterVisibility" request["sourceName"] = filterargs[1] request["filterName"] = filterargs[2] #request["filterEnabled"] = filterargs[3] filterargs[3] == "true" && ( request["filterEnabled"] = true ) filterargs[3] == "false" && ( request["filterEnabled"] = false ) obs4ipc.send( request.to_json ) elsif ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+)/ ) ) ircipc.send( { "##{settings["channel"]}", "Must provide provide a value of true or false as the third argument." } ) elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) request["sourceName"] = match[2] request["message-id"] = "GetSourceFilters" obs4ipc.send( request.to_json ) else ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument, and optionally one filter name and a value of true or false." } ) end elsif ( cmd == "create" && ( mod || own || vip ) ) if ( match[2]? ) && ( match[2] =~ /^([a-zA-Z0-9-_]+)/ ) obs4ipc.send( "\x10newsource-temporary #{match[2]}" ) else ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument." } ) end elsif ( cmd =~ /^(metaminute|youdied)$/ ) obs4ipc.send( "\x10source-notransition media-#{cmd}" ) elsif ( cmd =~ /^explosion$/ ) if explosions explosion = explosions.sample( 1 )[0][1] puts explosion obs4ipc.send( "\x10newsource-temporary #{explosion}" ) else puts "explosions undefined" end # FIXME: This is only half-implemented elsif ( ( cmd =~ /^(user)$/ ) && ( mod || own ) ) if 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 ) ) begin 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 rescue ex ircipc.send( { "##{settings["channel"]}", "| An error occurred! " + ex.message.to_s } ) end elsif ( ( cmd =~ /^(game|category)$/ ) && ( mod || own ) ) begin 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 rescue ex ircipc.send( { "##{settings["channel"]}", "| An error occurred! " + ex.message.to_s } ) 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] } ) 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" ) begin 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 rescue ex ircipc.send( { "##{settings["channel"]}", "An error occurred! " + ex.message.to_s } ) end end 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"]