#!/usr/bin/env ruby # IRC/OBS-websocket scene management bridge require 'json' require 'socket' require 'openssl' require 'websocket' require 'net/http' require 'pp' require 'uri' require 'mpd' $VERBOSE = nil # mpd.rb is a bit noisy about versions, though. $m = MPD.new confdir = Dir.home + '/.config/twitch/' ENV['APPDATA'] && confdir = ENV['APPDATA'] + "\\twitch\\" ENV['XDG_CONFIG_HOME'] && confdir = ENV['XDG_CONFIG_HOME'] + '/twitch/' # returning 'true' on each line to conceal results when sourcing in irb ( $ACCESS_TOKEN = IO.read( confdir + 'access_token' ).chomp ) && true ( $CHANNEL = IO.read( confdir + 'channel' ).chomp ) && true ( $CLIENT_ID = IO.read( confdir + 'client_id' ).chomp ) && true ( $UNIX_SOCKET_PATH = confdir + 'chat_socket' ) && true ( $OBS_PASSWORD = IO.read( confdir + 'obs_password' ).chomp ) && true ( $TRN_API_KEY = IO.read( confdir + '../trn-api-key' ).chomp ) && true def ircsocketwrite( text ) unix_socket = UNIXSocket.new( $UNIX_SOCKET_PATH ) unix_socket.print( text ) rescue ensure unix_socket.close end def websocketwrite( json ) $tcp_socket.write( WebSocket::Frame::Outgoing::Client.new( data: json, type: :text ).to_s ) rescue IOError, EOFError, Errno::ECONNRESET $tcp_socket.close websocket_create() retry end # the obsreader thread specially interprets unusual message-id-keyed responses; '1' will be a safe default. def SetSourceRender( source, render = 'true', id = '1' ) websocketwrite( '{ "request-type": "SetSourceRender", "message-id" : "' + id.to_s + '", "source" : "' + source + '", "render" : ' + render.to_s + ' }' ) end def GetCurrentScene( id = '1' ) websocketwrite( '{ "request-type": "GetCurrentScene", "message-id" : "' + id.to_s + '" }' ) end def GetSceneList( id = '1' ) websocketwrite( '{ "request-type": "GetSceneList", "message-id" : "' + id.to_s + '" }' ) end def SetCurrentScene( scene, id = '1' ) websocketwrite( '{ "request-type": "SetCurrentScene", "message-id" : "' + id.to_s + '", "scene-name": "' + scene + '" }' ) end def GetStreamingStatus( id = '1' ) websocketwrite( '{ "request-type": "GetStreamingStatus", "message-id" : "' + id.to_s + '" }' ) end def pubgstats( name ) tries = 2 http = Net::HTTP.new( 'pubgtracker.com', 443 ) http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_NONE http.read_timeout = 500 req = Net::HTTP::Get.new( "/api/profile/pc/#{name}" ) ( req["TRN-Api-Key"] = $TRN_API_KEY ) && true begin response = JSON.parse( http.request( req ).body ) rescue sleep 2 retry unless( tries-=1 ).zero? end if response['error'] then ircsocketwrite( "| pubgstats: #{response['message']} (maybe bad name?)\n" ) return end totalwins = 0 totallosses = 0 totalkills = 0 totaldeaths = 0 totalrounds = 0 bestrank = 9999999 bestrating = 0 bestroundkills = 0 bestrangekill = 0 solowins = 0 sololosses = 0 duowins = 0 duolosses = 0 squadwins = 0 squadlosses = 0 #response['Stats'].each { |matchstats| matchstats['Stats'].each { |stats| printf( "%9s %3s %5s %25s %10s\n", matchstats['Season'], matchstats['Region'], matchstats['Match'], stats['field'], stats['value'] ) } } && true# debug #response['Stats'].each { |matchstats| matchstats['Stats'].each { |stats| printf( "%9s %3s %5s %23s %20s %12s %10s %10s %10s %10s\n", matchstats['Season'], matchstats['Region'], matchstats['Match'], stats['label'], stats['field'], stats['category'], stats['value'], stats['rank'], stats['percentile'], stats['displayValue'] ) } } && true # debug begin response['Stats'].each do |matchstats| kdr = 0.0 matchstats['Stats'].each do |stats| if stats['field'] == 'KillDeathRatio' then kdr = stats['value'].to_f end #matchstats['Match'] #solo #duo #squad #solo-fpp #duo-fpp #squad-fpp if stats['field'] == 'Wins' then case matchstats['Match'] when /^solo/ solowins += ( stats['value'].to_i ) when /^duo/ duowins += ( stats['value'].to_i ) when /^squad/ squadwins += ( stats['value'].to_i ) end end if stats['field'] == 'Losses' then case matchstats['Match'] when /^solo/ sololosses += stats['value'].to_i when /^duo/ duolosses += stats['value'].to_i when /^squad/ squadlosses += stats['value'].to_i end end stats['field'] == 'RoundsPlayed' && totalrounds += stats['value'].to_i if stats['field'] == 'Kills' && kdr != 0 then totalkills += stats['value'].to_i totaldeaths += stats['value'].to_i / kdr end ( stats['field'] == 'BestRating' && stats['value'].to_i > bestrating ) && bestrating = stats['value'].to_i ( stats['field'] == 'RoundMostKills' && stats['value'].to_i > bestroundkills ) && bestroundkills = stats['value'].to_i ( stats['field'] == 'LongestKill' && stats['value'].to_i > bestrangekill ) && bestrangekill = stats['value'].to_i ( stats['field'] == 'BestRank' && 0 < stats['value'].to_i && stats['value'].to_i < bestrank ) && bestrank = stats['value'].to_i end end && true rescue NoMethodError => e ircsocketwrite( e.message ) end #bestrank #bestrating #bestroundkills totalkills /= 2 totaldeaths /= 2 #totalwins # we cancel out the aggregate earlier totallosses /= 2 sololosses /= 2 duolosses /= 2 squadlosses /= 2 #bestrangekill winratio = totalwins.to_f/totallosses.to_f solowinratio = solowins.to_f/sololosses.to_f*100 duowinratio = duowins.to_f/duolosses.to_f*50 squadwinratio = squadwins.to_f/squadlosses.to_f*25 killratio = totalkills.to_f/totaldeaths.to_f ircsocketwrite( sprintf( "| %s BEST: rating: %i, rank: %i, roundkills: %i, rangekill: %i; TOTAL: kills/deaths: %i / %i = %02.03f, normalized win likelihood: solo: %i/%i*100 = %01.03f, duo: %i/%i*50 = %01.03f, squad: %i/%i*25 = %01.03f", response['PlayerName'], bestrating, bestrank, bestroundkills, bestrangekill, totalkills, totaldeaths, killratio, solowins, sololosses, solowinratio, duowins, duolosses, duowinratio, squadwins, squadlosses, squadwinratio ) ) #ircsocketwrite( "| #{response['PlayerName']} PUBG Stats: BestRating: #{bestrating}, BestRank: #{bestrank}, HighestRoundKills: #{bestroundkills}, TotalKills: #{totalkills}, TotalWins/Losses #{totalwins}/#{totallosses} = #{winratio}, HighestRange: #{bestrangekill}\n" ) # optimally: # highest 'Kills' 'KillDeathRatio' & percentile 'Wins'/'WinPoints'/'WinRatio'/percentile 'RoundMostKills' 'RoundsPlayed' 'BestRating' 'BestRank' 'LongestKill' # realistically: # BestRank BestRating RoundsPlayed Wins/Losses WinRatio Kills RoundMostKills LongestKill end def transientsource( source ) Thread.new { SetSourceRender( 'media-' + source , false ) sleep 0.1 SetSourceRender( 'media-' + source , true ) sleep 60 SetSourceRender( 'media-' + source , false ) } end #json["subscriptions"][0]["user"]["name"] def websocket_create() begin $tcp_socket = TCPSocket.new( '127.0.0.1', 4444 ) $tcp_socket.write( WebSocket::Handshake::Client.new( url: 'ws://localhost/' ).to_s ) IO.select( [ $tcp_socket ] ) print $tcp_socket.read_nonblock( 4096 ) websocketwrite( '{ "request-type": "GetAuthRequired", "message-id" : "1" }' ) # Let the obsreader thread do the actual authentication. rescue IOError, EOFError, Errno::ECONNREFUSED, Errno::ECONNRESET $tcp_socket && $tcp_socket.close sleep 5 retry end end $lastused = Hash.new $globallastused = 0 def command_dispatch( mode, user, command, arg1 ) # arg1 may be empty String safe = /^[-0-9a-zA-Z_]+$/ # '@'-style user mode reporting is super flaky on the Twitch IRC gateway. # FIXME: Should probably dynamically poll and cache .mods/.vips output. if user == $CHANNEL mode = '@' end if mode == '@' && command == 'scene' if arg1 =~ safe SetCurrentScene( arg1 ) return else GetSceneList( ) return end elsif mode == '@' && command == 'source' if arg1 =~ safe # GetCurrentScene for current visibility state GetCurrentScene( arg1 ) # SetSourceRender to toggle return else GetCurrentScene( ) return end elsif mode == '@' && ( command == 'delete' || command == 'delsong' ) if arg1 =~ /^[1-9][0-9]*$/ begin file = $m.playlistinfo( arg1.to_i - 1 )['file'] $m.delete( arg1.to_i - 1 ) ircsocketwrite( '| deleting song' + file + "\n" ) rescue RuntimeError ircsocketwrite( '| problem deleting song ' + arg1 + "\n" ) ensure return end end elsif mode == '@' && ( command == 'volume' || command == 'vol' ) if arg1 =~ /^[0-9]+$/ ircsocketwrite( "| volume adjusted: " + $m.setvol( arg1 ) + "\n" ) return else ircsocketwrite( "| volume: " + $m.status['volume'] + "\n" ) return end elsif mode == '@' && ( command == 'nextsong' || command == 'next' ) ircsocketwrite( "| " + $m.next["file"] + "\n" ) return elsif ( command == 'uptime' ) ircsocketwrite( "| " + $status["stream-timecode"] + "\n" ) return elsif ( command == 'currentsong' ) || ( command == 'current' ) ircsocketwrite( "| " + $m.currentsong["file"] + "\n" ) return elsif ( command == 'songlist' ) || ( command == 'list' ) ircsocketwrite( "| song list: http://action.omgwallhack.org/music\n" ) return elsif( ( command == 'songrequest' ) || ( command == 'sr' ) ) && arg1.size > 1 if( system( 'sraddsong.sh', arg1 ) ) if( $m.playlistinfo[0].file == 'http://music/music.ogg' ) $m.next end $m.playlistinfo.each do | song | if( song.file == 'http://music/music.ogg' ) $m.deleteid( song.dbid ) end end ircsocketwrite( '| ' + $m.playlistinfo[ $m.status['playlistlength'].to_i - 1 ]['file'] + ' added in position ' + $m.status['playlistlength'] + "\n" ) $m.add( 'http://music/music.ogg' ) return else ircsocketwrite( '| adding song ' + arg1 + " FAILED\n" ) return end # popen to get output someday? elsif mode == '@' && command == 'commands' ircsocketwrite( "| !uptime !songrequest !currentsong !next !volume !delsong !source !scene !pubg !metaminute !nopgl1 !nopgl2 !design\n" ) return end # ratelimited commands after +o commands if command =~ /^(dexem)$/ if ( ! $lastused[ command ] || $lastused[ command ] <= ( Time.now.to_i - 5 ) ) # || mode == '@' $lastused[ command ] = Time.now.to_i ircsocketwrite( "| You're doing great work, Dexem!\n" ) return end end if command =~ /^(design)$/ if ( ! $lastused[ command ] || $lastused[ command ] <= ( Time.now.to_i - 5 ) ) # || mode == '@' $lastused[ command ] = Time.now.to_i ircsocketwrite( "| http://i.imgur.com/s29rvaw.jpg http://i.imgur.com/OjMkp6I.jpg\n" ) return end end if command =~ /^(mattscott)$/ if ( ! $lastused[ command ] || $lastused[ command ] <= ( Time.now.to_i - 5 ) ) # || mode == '@' $lastused[ command ] = Time.now.to_i ircsocketwrite( "\"We're not looking to [...] change the monetization.\" https://forums-old.gamersfirst.com/topic/408317-/page-5#entry4522545\n" ) return end end if command =~ /^(pubg)$/ if ( ! $lastused[ command ] || $lastused[ command ] <= ( Time.now.to_i - 5 ) ) # || mode == '@' $lastused[ command ] = Time.now.to_i pubgstats( arg1 ) return end end if ( ! $lastused[ command ] || $lastused[ command ] <= ( Time.now.to_i - 120 ) ) && $globallastused <= ( Time.now.to_i - 60 ) # || mode == '@' $globallastused = Time.now.to_i $lastused[ command ] = Time.now.to_i if command =~ /^(metaminute|nopgl1|nopgl2)$/ transientsource( command ) return elsif command == 'commands' ircsocketwrite( "| !design !uptime !songrequest !currentsong !pubg !metaminute !nopgl1 !nopgl2\n" ) return end end end def frame_dispatch( frame ) if frame["update-type"] == "StreamStatus" $status = frame elsif frame['current-scene'] && frame['scenes'] # GetSceneList text = '| Current scene: ' + frame['current-scene'] + ' | Available: ' + frame['scenes'].map{|v| v['name'] }.sort.join(' ') + "\n" print text ircsocketwrite( text ) elsif frame['name'] && frame['sources'] # GetCurrentScene pp frame text = '| Sources: ' + frame['sources'].map{|v| v['name'] }.sort.join(' ') + "\n" print text if frame['message-id'] == '1' ircsocketwrite( text ) else # other message-ids are a source we want to toggle frame['sources'].each do |v| if v['name'] == frame['message-id'] if v['render'] == true SetSourceRender( v['name'], 'false', '2' ) ircsocketwrite( '| Source ' + v['name'] + " is now unrendered\n" ) elsif v['render'] == false SetSourceRender( v['name'], 'true', '2' ) ircsocketwrite( '| Source ' + v['name'] + " is now visible\n" ) end end end end elsif frame['update-type'] == 'SwitchScenes' ircsocketwrite( '| Scene is now ' + frame['scene-name'] + "\n" ) #elsif frame['update-type'] == 'SceneItemVisibilityChanged' # ircsocketwrite( '| Source ' + frame['item-name'] + ' visibility is now ' + frame['item-visible'].to_s + "\n" ) elsif frame['authRequired'] == true # GetAuthRequired websocketwrite( '{ "request-type": "Authenticate", "message-id" : "2", "auth" : "' + Digest::SHA256.base64digest(Digest::SHA256.base64digest($OBS_PASSWORD + frame['salt'])+frame['challenge']) + '" }' ) else print frame end end # irclog read loop event thread ircreader = Thread.new { begin irclog = IO.popen('logtail ' + ENV['HOME'] + '/irclogs/twitch/#' + $CHANNEL + '/' ) irclog.each_line do |line| if ( match = line.match(/^\d\d:\d\d <([@ +])(.+)?> *!([-0-9a-zA-Z]+) *(.*)/) ) command_dispatch( *match.to_a[1..4] ) end end rescue ensure irclog.close end } websocket_create() frame = WebSocket::Frame::Incoming::Client.new frame_decoded = String.new $status = Hash.new # websocket read loop event thread obsreader = Thread.new { begin while true frame << $tcp_socket.read_nonblock( 4096 ) while ( frame_decoded = frame.next.to_s ) != "" frame_parsed = JSON.parse(frame_decoded) frame_dispatch( frame_parsed ) end #pp JSON.parse(frame_decoded) end rescue IO::WaitReadable, IO::EAGAINWaitReadable, JSON::ParserError IO.select([$tcp_socket]) retry rescue IOError, EOFError, Errno::ECONNRESET $tcp_socket.close websocket_create() retry end }