require "socket" require "openssl" require "gamesurge/irc" require "twitch/irc" require "http" require "uri" require "twitcr" require "json" require "crystal_mpd" require "obswebsocket" require "yaml" require "bungmobott" STDOUT.sync = true STDOUT.flush_on_newline = true struct Nil def as_s? self end def []?( v : String | Int64 | Int32 | Range( Int32, Nil ) ) self end end class OpenSSL::SSL::Socket::Client def fill_read( slice : Bytes ) datasize = slice.size datarcvdtotal = UInt32.new( 0 ) data = Bytes.new( 0 ) while datarcvdtotal < datasize # OpenSSL only unbuffered_read's TLS records of max size 16384, so we may have to reassemble data_buffer = Bytes.new( datasize ) datarcvd = self.unbuffered_read( data_buffer ) datarcvdtotal = ( datarcvdtotal + datarcvd ) data = data + data_buffer[0..datarcvd-1] data_buffer = Bytes.new( datasize - datarcvdtotal ) end slice.copy_from( data ) end 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 def ppe(object) PrettyPrint.format( object, STDERR, 79 ) STDERR.puts object end EXE = "bungmobott" regexservice = /(twitch|gamesurge)/ regexuser = /[0-9a-zA-Z_]+/ regexb64 = /[0-9a-fA-F]+/ regexvoice = /[0-9a-zA-Z-]+/ configdir = Path.home./("/.config/#{EXE}/").to_s ENV["XDG_CONFIG_HOME"]? && ( configdir = ENV["XDG_CONFIG_HOME"] + "/#{EXE}/" ) ENV["LOCALAPPDATA"]? && ( configdir = Path.windows( ENV["LOCALAPPDATA"] + "\\#{EXE}\\" ).to_s ) # cygwin will have both, but should probably use LOCALAPPDATA def writeconfig( filepath : Path, contents : String ) Dir.mkdir_p( filepath.parent ) File.write( filepath, contents ) rescue exio : IO::Error puts "ERROR: Unable to write #{ filepath }: #{exio.message}" exit 7 end configfile = Path[configdir + "config.txt"].normalize if ARGV.size >= 1 configfile = Path[ ARGV[0] ].normalize end puts configfile if File.exists?( configfile ) config = BungmoBott::Config.from_yaml( File.read( configfile ) ) else config = BungmoBott::Config.from_yaml("---") STDERR.puts "WARNING: #{configfile} not found. Writing new one." writeconfig( configfile, config.to_yaml ) end puts config.to_yaml # FIXME: maybe do this in the after_initialize method? unless config.yaml_unmapped.empty? STDERR.puts "WARNING: #{configfile} has unknown properties:" ppe config.yaml_unmapped end if ( config.chat_user && ! config.chat_user.not_nil!.yaml_unmapped.empty? ) STDERR.puts "WARNING: #{configfile} chat_user has unknown properties:" ppe config.chat_user.not_nil!.yaml_unmapped end if ( config.join_channels && ! config.join_channels.not_nil!.yaml_unmapped.empty? ) STDERR.puts "WARNING: #{configfile} join_channels has unknown properties:" ppe config.join_channels.not_nil!.yaml_unmapped end secretsfile = Path[ configdir + "secrets.txt" ].normalize if ARGV.size >= 2 secretsfile = Path[ ARGV[1] ].normalize end if File.exists?( secretsfile ) secrets = BungmoBott::Secrets.from_yaml( File.read( secretsfile ) ) else secrets = BungmoBott::Secrets.from_yaml( "---" ) STDERR.puts "WARNING: #{secretsfile} not found. Writing new one." writeconfig( secretsfile, secrets.to_yaml ) end unless secrets.yaml_unmapped.empty? STDERR.puts "WARNING: #{secretsfile} has unknown properties:" ppe secrets.yaml_unmapped.keys end Dir.mkdir_p( config.tempdir ) 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", JSON.parse( { "sceneName" => sname, "sceneItemId" => siid, "sceneItemTransform" => { "positionX" => goodtransform.to_h["positionX"], "positionY" => goodtransform.to_h["positionY"] } }.to_json ) ) ) end end regextwitchuser = /^[0-9a-zA-Z_]+$/ # enable direct twitch api? if ( secrets.twitch_access_token && secrets.twitch_client_id ) twitchapi = true twitchclient = Twitcr::Client.new( Hash( String, String ){ "client_id" => secrets.twitch_client_id.not_nil!, "access_token" => secrets.twitch_access_token.not_nil! } ) # derive twitch_channel_id from channel or vice versa unless config.twitch_user_id config.twitch_user_id = twitchclient.user( config.chat_user.not_nil!.twitch.not_nil! ).id.to_u32 end else twitchapi = false secrets.twitch_access_token || STDERR.puts "Warning: #{secretsfile} 'twitch_access_token' is missing; direct Twitch API access disabled." secrets.twitch_client_id || STDERR.puts "Warning: #{secretsfile} 'twitch_client_id' is missing; direct Twitch API access disabled." unless chat_user = ( config.chat_user && config.chat_user.not_nil!.twitch ) if chat_user = ( config.join_channels && config.join_channels.not_nil!.twitch[0]? ) STDERR.puts "Warning: #{configfile} 'chat_user: {twitch}' value is missing; using first configured 'join_channels: {twitch}' array value instead: #{chat_user}" if ( config.chat_user ) config.chat_user.not_nil!.twitch = chat_user end else STDERR.puts "ERROR: 'chat_user: {twitch}' string value and 'join_channels: {twitch}' array value missing." exit 3 end end end # enable direct gcloud api? if secrets.gcloud_token gcloud = true else gcloud = false STDERR.puts "Warning: #{secretsfile} gcloud_token is missing; direct GCS voices disabled." end # enable aws? if ! File.exists?( Path.home./("/.aws/credentials") ) # FIXME: work out where this is on Windows STDERR.puts "Warning: #{Path.home}/.aws/credentials is missing; AWS voices disabled." aws = false elsif ! Process.find_executable( "aws.exe" ) && ! Process.find_executable( "aws" ) STDERR.puts "Warning: aws CLI executable is missing; AWS voices disabled." aws = false else aws = true end # enable microsoft speech services? # TODO: download and msiexec /i https://www.microsoft.com/en-us/download/details.aspx?id=27224 mss : Bool {% if flag?(:windows) %} mss = true {% else %} mss = false {% end %} if ( mss || gcloud || aws ) && config.voice_list text2speech = true else text2speech = false end lastvoice = Array(String).new # channel message subscriptions: { service, chan } => [ client, client ] #channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server | Channel( Tuple( String, FastIRC::Message ) ) ) ).new channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server) ).new # Unencrypted #connections = Hash(TCPSocket, Hash(String, String)).new # Encrypted connections = Hash(OpenSSL::SSL::Socket::Server, Hash(String, String)).new ircipc = Channel( Tuple( String, String ) ).new # commandircipc: serv, chan, user, msg commandircipc = Channel( Tuple( String, FastIRC::Message ) ).new commandipc = Channel( String ).new # t2sipc: voice, text t2sipc = Channel( Tuple( String, String ) ).new gamesurgeircipc = Channel( Tuple( String, String ) ).new twitchircipc = Channel( Tuple( String, String ) ).new twitchapiipc = Channel( Tuple( String, String | UInt64 ) ).new bbscliipc = Channel( String ).new bbssrvipc = Channel( String ).new waitgroup = Channel( String ).new fiberipc = Channel( Fiber ).new fibers = Hash( String, Fiber ).new evchan = Channel( JSON::Any ).new obs : Nil | OBS::WebSocket = nil if config.obs_connect obs = OBS::WebSocket.new( "ws://#{config.obs_connect}/" ) # OBS event thread spawn name: "obs_event_thread" do fiberipc.send( Fiber.current ) 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" say( twitchircipc, config.chat_user.not_nil!.twitch.not_nil!, "| 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", 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/ say( twitchircipc, config.chat_user.not_nil!.twitch.not_nil!, "| 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"] say( twitchircipc, config.chat_user.not_nil!.twitch.not_nil!, "| obs: source #{edata["sourceName"].as_s} filter #{edata["filterName"].as_s} visibility is currently #{edata["filterEnabled"].as_bool}" ) end end Fiber.yield end rescue ex puts ex ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end # enable effects? #effects = Hash( String, String ).new #if File.exists?( config.configdir + "/effects.txt" ) # File.each_line( config.configdir + "/effects.txt" ) do |line| # effects[ line.downcase ] = line # end #end def urbandef( term : String ) #{ # "list": [ # { # "definition": "An overactive, small-proportioned homosexual [gentleman] who will [launch] at anything in [sight].", # "permalink": "http://bungmonkey.urbanup.com/83517", # "thumbs_up": 6, # "author": "fishbear", # "word": "bungmonkey", # "defid": 83517, # "current_vote": "", # "written_on": "2003-04-04T11:55:12.000Z", # "example": "\"That [chap] [in the corner] is a [proper] little bungmonkey. Look at him go!\"", # "thumbs_down": 1 # } # ] #} 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 def getvoice( voicelist : 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( voicelist ).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 def generatevoicelistaws( ) voices = Array(String).new JSON.parse( `aws polly describe-voices` )["Voices"].as_a.each do | v | voices.push( v["Id"].as_s ) end return voices end def generatevoicelistgcs( gcloud_token : String ) voices = Array(String).new ssl_context = OpenSSL::SSL::Context::Client.new headers = HTTP::Headers.new headers["Content-Type"] = "application/json; charset=utf-8" response = HTTP::Client.exec( "GET", "https://texttospeech.googleapis.com/v1/voices?key=#{gcloud_token}", headers, nil, tls: ssl_context ) JSON.parse( response.body )["voices"].as_a.each do | v | voices.push( v["name"].as_s ) end return voices end {% if flag?(:windows) %} def generatevoicelistwin() voices = Array(String).new 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.push( v ) end end return voices end {% end %} def regeneratevoicelist( defaultsettings : Hash( String, String ), aws : Bool, gcloud : Bool ) voices = Hash(String, String).new if aws generatevoicelistaws() do | voice | voices[ voice.downcase ] = voice end end if gcloud generatevoicelistgcs( secrets.gcloud_token ).each do | voice | voices[ voice.downcase ] = voice end end {% if flag?(:windows) %} generatevoicelistwin().each do {% end %} File.write( config.configdir + "/voicelist.txt", voices.values.sort.join("\r\n") ) return voices end # TODO: add piping into mpv on POSIX def playaudiodata( tempdir, data : Bytes ) filepath="#{tempdir}#{Time.utc.to_unix_ms}.mp3" File.write( filepath, data ) playaudiofile( filepath ) File.delete( filepath ) end def playaudiofile( filepath : String ) {% if flag?(:windows) %} 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 {% else %} p = Process.new( "ompv", # FIXME: switch this over to xdg-open at some point? [ "#{filepath}" ], output: STDOUT, error: STDERR ) p.wait {% end %} end # user, uid, userdir,lastseen, oldname def userlog( config : BungmoBott::Config, service : String, message : FastIRC::Message ) : Tuple( String, UInt64 | Nil, String, Int32 | Int64, String | Nil ) | Nil unless ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) ) return nil end if service == "twitch" if ( uid = message.tags["user-id"]? ) basedir = config.statedir + "/" + service 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 ) oldname = nil datelatest = Time::UNIX_EPOCH Dir.each_child( userdir + "/names/" ) do |name| namedate = File.info( userdir + "/names/" + name ).modification_time if namedate > datelatest oldname = name datelatest = namedate end end genrefuser2uid( userdir + "/names/" + chatuser, uid, 3 ) end unless testrefuser2uid( basedir + "/names/" + chatuser ) genrefuser2uid( basedir + "/names/" + chatuser, uid, 1 ) end uid = uid.to_u64 else # No uid? STDERR.puts( "WARNING: userlog unexpectedly found message.prefix.source with no UID tag." ) return( nil ) end elsif( service == "gamesurge" ) basedir = config.statedir + "/" + service userdir = basedir + "/names/" + chatuser if File.directory?( userdir ) lastseen = File.info( userdir ).modification_time.to_unix File.touch( userdir ) else Dir.mkdir_p( userdir ) lastseen = 0 end oldname = nil else STDERR.puts( "WARNING: invalid service used with userlog()" ) return( nil ) end return ( { chatuser, uid, userdir, lastseen, oldname } ) rescue ex puts ex end # IRC say # does this function have a reason to exist? def say( ipc : Channel( Tuple( String, String ) ), channel : String, text : String ) ipc.send( { "#" + channel, text } ) end # Currently only used in flag?(:unix) def t2smsg( config : BungmoBott::Config, msg : String) if File.exists?( config.rundir + "/.t2s.sock" ) sock = Socket.unix sock.connect Socket::UNIXAddress.new( config.rundir + "/.t2s.sock" ) sock.puts( msg ) sock.close end rescue ex puts ex end def t2s( t2sipc : Channel, config : BungmoBott::Config, userdir : String, chatuser : String, text : String ) if ( text !~ /^ *(!|\|)/ ) namesub, voice_setting, voice = getvoice( config.voice_list.not_nil!, 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 "}, { /= 14400 ) if service == "twitch" && uid.is_a?( UInt64 ) twitchapiipc.send( { "get_user", uid.to_s } ) twitchapiipc.send( { "get_followers", uid.to_s } ) prevnames = Array( String ).new if ( prevnames = Dir.children( "#{userdir}/names" ) ) && ( prevnames.size > 1 ) prevnames.delete( chatuser ) puts "\033[38;5;14m#{chatuser} previous names: #{prevnames.join(", ")}\033[0m" end end # play random fanfare if available. # 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 end # FIXME: Generalize this across Twitch/IRC? Mods are +o? vips are +v? # FIXME: Add configuration interface for this # FIXME: There's a distinction between channel owner and bot owner; do we care? # channel owner # botowner chanowner = ( chatuser == ircchannel ) if service == "twitch" #chanowner = ( message.tags["room-id"] == message.tags["user-id"] ) botowner = ( chatuser == config.chat_user.not_nil!.twitch ) vip = ( message.tags["badges"].to_s.matches?( /(^|,)vip\// ) ) mod = ( message.tags["mod"] == "1" ) sub = ( message.tags["subscriber"] == "1" ) elsif service == "gamesurge" botowner = ( chatuser == config.chat_user.not_nil!.gamesurge ) vip = false # FIXME: Maybe +v? FastIRC lets us see MODE changes, but does not itself track them mod = false # FIXME: Probably +o sub = false # No idea. Maybe check user registration? end # Emote-triggered effects: # [ { 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 config.commands && config.commands.not_nil!.each do | commandregex, command | # Hash( String, Array ) if Regex.new( commandregex ).match( message.params[1] ) command.each do |exec| # Array if ( ( chanowner || ( exec.perm && exec.perm.not_nil!.includes?("any") ) ) || ( botowner && ( exec.perm && exec.perm.not_nil!.includes?("owner") ) ) || ( mod && ( exec.perm && exec.perm.not_nil!.includes?("mod") ) ) || ( sub && ( exec.perm && exec.perm.not_nil!.includes?("sub") ) ) || ( vip && ( exec.perm && exec.perm.not_nil!.includes?("vip") ) ) ) # As a matter of policy: # BungmoBott::Socket clients can only say things in their own authenticated channel # Direct IRC clients can say things whereever. # FIXME: this needs to be service-specific if ( service == "twitch" && exec.func == "detect_rename" && uid.is_a?( UInt64 ) ) unless oldname.is_a?( String ) ircipc.send( { "##{config.chat_user.not_nil!.twitch}", "Rename detected: #{uid}: #{oldname} -> #{chatuser}" } ) end next end if obs # FIXME: better validate args ( exec.func == "obs_random_source_enable" ) && obsrandommediaenable( obs, exec.arg.not_nil![0].not_nil! ) && next # ( exec.func == "obs_stats" ) && obs_stats( obs ) end if text2speech if ( exec.func == "text_to_speech" ) puts "Exec-ing text_to_speech" if ( t2sreturn = t2s( t2sipc, config, userdir, chatuser, message.params[1] ) ) lastvoice.insert( 0, t2sreturn ) lastvoice = lastvoice[0..4] end next end end # FIXME: add gamesurge support if ( exec.func == "say" ) if ( service == "twitch" ) say( twitchircipc, config.chat_user.not_nil!.twitch.not_nil!, exec.arg.not_nil![0].not_nil! ) elsif ( service == "gamesurge" ) say( ircipc, config.chat_user.not_nil!.gamesurge.not_nil!, exec.arg.not_nil![0].not_nil! ) end next end if ( exec.func == "run" ) if ( cmd = exec.arg[0]? ) && File.executable?( cmd ) p = Process.new( cmd, exec.arg[1..]?, output: STDOUT, error: STDERR, env: { "TEXT" => message.params[1], "CHATUSER" => chatuser } ) else STDERR.puts( "WARNING: exec.func \"run\" called without argument" ) end end if ( exec.func == "run_shell" ) if ( cmd = exec.arg[0]? ) p = Process.new( cmd, exec.arg[1..]?, output: STDOUT, error: STDERR, shell: true, env: { "TEXT" => message.params[1], "CHATUSER" => chatuser } ) else STDERR.puts( "WARNING: exec.func \"run_shell\" called without argument" ) end end STDERR.puts "WARNING: unhandled function for /#{commandregex}/: #{exec.func}" else STDOUT.print "DENIED: " ppe command end end end end end else # String case local_commandmsg when "testchannelsubs" pp channelsubs 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] # 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 == "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 =~ /^(setvoice|voice)$/ ) && ( mod || sub ) ) # case message.params[1].split( " " ).size # when 1 # namesub, voice_setting, voice_output = getvoice( config.voice_list_file, 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_]+/ # twitchapiipc.send( { "get_user", match[2] } ) # elsif match[2]? && match[2] =~ /[0-9]+/ # twitchapiipc.send( { "get_user", match[2].to_u64 } ) # else # twitchapiipc.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 puts ex.backtrace if service == "twitch" say( twitchircipc, config.chat_user.not_nil!.twitch.not_nil!, "An error occurred! " + ex.message.to_s ) elsif service == "gamesurge" say( ircipc, config.chat_user.not_nil!.gamesurge.not_nil!, "An error occurred! " + ex.message.to_s ) else puts "...also, the chat service is undefined." end end end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber def ttsgcs( languagecode : String, voice : String, text : String, gcskey : String ) : Bytes request = Hash( String, Hash( String, String ) ){ "input" => { "text" => text }, "audioConfig" => { "audioEncoding" => "MP3" }, "voice" => { "name" => voice, "languageCode" => languagecode, }, } 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=#{gcskey}", headers, request.to_json, tls: ssl_context ) json=JSON.parse(response.body) return Base64.decode( json["audioContent"].as_s ) end def ttsaws( filepath : String, voice : String, text : String ) p = Process.new( "aws", [ "polly", "synthesize-speech", "--output-format", "mp3", "--voice-id", voice, "--text", text, filepath ], output: STDOUT, error: STDERR ) p.wait end # Put tts stuff into the same thread so each playback blocks the next spawn name: "text2speech" do fiberipc.send( Fiber.current ) loop do begin while t2stuple = t2sipc.receive voice, text = [ *t2stuple ] if ( match = voice.match( /^Microsoft-([A-Za-z]+)/ ) ) {% if flag?(:windows) %} 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 {% else %} STDERR.puts( "WARNING: Microsoft speech services voice called on non-Windows platform." ) {% end %} elsif ( match = voice.match( /^([a-zA-Z]{2,3}-[a-zA-Z]{2})/ ) ) if ( gcloud_token = secrets.gcloud_token ).is_a?( String ) # Google cloud voice mp3data = ttsgcs( match[1], voice, text, gcloud_token ) playaudiodata( config.tempdir, mp3data ) elsif fibers["BungmoBott::Socket client"]? bbscliipc.send( "gcst2s #{voice} #{text}" ) # The rest of this is dealt with in the BungmoBott::Socket client else STDERR.puts( "ERROR: google cloud voice requested, but no gcloud_token or BungmoBott::Socket client is available" ) end else if aws # AWS polly voices filepath="#{config.tempdir}#{Time.utc.to_unix_ms}.mp3" ttsaws( filepath, voice, text ) playaudiofile( filepath ) File.delete( filepath ) elsif fibers["BungmoBott::Socket client"]? bbscliipc.send( "awst2s #{voice} #{text}" ) # The rest of this is dealt with in the BungmoBott::Socket client else STDERR.puts( "ERROR: aws polly voice requested, but no aws CLI executable or BungmoBott::Socket client is available" ) end #else # unknown # STDERR.puts "Voice not recognized or available." end end rescue ex puts ex end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber # Twitch API request handling thread # FIXME: Implement ratelimiting here. if twitchclient.is_a?( Twitcr::Client ) spawn name: "Twitcr::Client" do fiberipc.send( Fiber.current ) loop do begin while twitchtuple = twitchapiipc.receive cmd, arg = [ *twitchtuple ] case cmd when "get_user" userinfo = JSON.parse( twitchclient.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( twitchclient.get_channel_followers( to: arg.to_u64 ) )["total"].as_i64 if followers > 500 puts "\033[38;5;2m#{followers} followers\033[0m" end end end rescue ex pp ex end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.join_channels.not_nil!.twitch ) # Twitch::IRC thread spawn name: "Twitch::IRC" do fiberipc.send( Fiber.current ) loop do begin bot = Twitch::IRC::Client.new( nick: config.chat_user.not_nil!.twitch.not_nil!, token: "oauth:" + secrets.twitch_access_token.not_nil!, log_mode: true ) bot.tags = [ "membership", "tags", "commands" ] # Outgoing IRC message thread # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters." # PRIVMSG #channel message\r\n spawn do while tuple = twitchircipc.receive # why does this need to be a tuple? sizelimit=( 512 - ( tuple[0].size + 12 ) ) if ( tuple[0] == "JOIN" ) if ( tuple[0] =~ regextwitchuser ) bot.join_channel( tuple[1] ) else STDERR.puts "Invalid channel name #{ tuple[1] }" end elsif ( tuple[0] =~ /^#/ ) bot.message( tuple[0], tuple[1][0..sizelimit] ) # limit size end end end # Create a handler to process incoming messages bot.on_message do |message| spawn do #FastIRC::Message( # @tags={ # "badge-info" => "", # "badges" => "moderator/1,bits/100", # "color" => "", # "display-name" => "BungMonkey", # "emotes" => "", # "first-msg" => "0", # "flags" => "", # "id" => "3170330a-66dd-4163-a5cb-5a380abef366", # "mod" => "1", # "returning-chatter" => "0", # "room-id" => "22579666", # "subscriber" => "0", # "tmi-sent-ts" => "1692002718705", # "turbo" => "0", # "user-id" => "59895482", # "user-type" => "mod" # }, # @prefix=Prefix( # @source="bungmonkey", # @user="bungmonkey", # @host="bungmonkey.tmi.twitch.tv" # ), # @command="PRIVMSG", # @params=[ # "#tenichi", # "test" # ] #) # channelsubs[{ "twitch", message.params[0] }].each do | client | # if ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) ) # && ( uid = message.tags["user-id"]? ) # if client.is_a? OpenSSL::SSL::Socket::Server # client.puts( "msg twitch #{message.params[0]} #{chatuser} #{message.params[1]}" ) # else # client.is_a? Channel( Tuple( String, FastIRC::Message ) ) # client.send( { "twitch", message } ) # end # end # end commandircipc.send( { "twitch", message } ) pp message pp message.params rescue ex puts ex #twitchircipc.send( { "##{config.channel}", "An error occurred! " + ex.message.to_s } ) # Maybe send all error messages out through the API? Have to do channel->client mappings, though. end end rooms = Array( String ).new #rooms = config.join_channels.not_nil!.twitch.not_nil! # 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 ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end if ( secrets.gamesurge_password && config.chat_user.not_nil!.gamesurge && config.join_channels.not_nil!.gamesurge ) # GameSurge::IRC thread spawn name: "GameSurge::IRC" do fiberipc.send( Fiber.current ) loop do begin bot = GameSurge::IRC::Client.new( nick: config.chat_user.not_nil!.gamesurge.not_nil!, token: secrets.gamesurge_password.not_nil!, 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 tuple = ircipc.receive # why does this need to be a tuple? STDERR.puts( "#{Fiber.current} irc rx #{tuple}" ) sizelimit=( 512 - ( tuple[0].size + 12 ) ) if ( tuple[0] == "JOIN" ) # FIXME: Do validation on this bot.join_channel( tuple[1] ) elsif ( tuple[0] =~ /^#/ ) bot.message( tuple[0], tuple[1][0..sizelimit] ) # limit size end end end # Create a handler to process incoming messages bot.on_message do |message| spawn do commandircipc.send( { "gamesurge", message } ) pp message pp message.params # elsif ( cmd =~ /^(create|addsource)/ ) # if ( match[2]? ) && ( match[2] =~ /^([\/a-zA-Z0-9-_]+)$/ ) # obstemporarymediacreate( obs, "meta-foreground", match[2], "C:/cygwin64/home/user/effects/#{match[2]}.webm" ) # elsif ( match[2]? ) && ( match[2] =~ /http(|s):\/\// ) # newargs = Array( String ).new # args = match[2].split( / +/ ) # if uri = URI.parse( args.shift ) # newargs.push( uri.to_s ) # else # ircipc.send( { "##{settings["channel"]}", "Unable to parse URL." } ) # next # end # colormask : UInt32 | Nil = nil # direction : UInt16 | Nil = nil # args.each do |arg| # case arg # when "black" # colormask = 0xFF000000 # when "red" # colormask = 0xFF0000FF # when "green" # colormask = 0xFF00FF00 # when "blue" # colormask = 0xFFFF0000 # when "white" # colormask = 0xFFFFFFFF # when /^#[0-9]{6}$/ # colormask = "0x#{arg.sub( "#([0-9][0-9])([0-9][0-9])([0-9][0-9])", "#FF\3\2\1" )}".to_u32 # when /^[0-9]+$/ # direction = arg.to_u16%360 # else # ircipc.send( { "##{settings["channel"]}", "Unable to parse color argument." } ) # end # end # colormask && newargs.push( "colormask=#{colormask}" ) # direction && newargs.push( "direction=#{direction}" ) # t2smsg( settings, "download #{newargs.join(" ")}" ) # else # ircipc.send( { "##{settings["channel"]}", "Must provide at least one URL as argument." } ) # end # elsif ( cmd =~ /^(delete|remove)/ ) # if ( match[2]? ) && ( match[2] =~ /^([\/a-zA-Z0-9-_]+)$/ ) # obs.inputs["medialoop-bullshit-#{match[2]}"].delete! # else # ircipc.send( { "##{settings["channel"]}", "Must provide at least one bullshit source as argument." } ) # end # elsif ( cmd == "bullshit" ) # request = Hash( String, String | Bool ).new # if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) # obs.scenes["meta-meta-foreground"]["meta-bullshit"].toggle! # else # ircipc.send( { "##{settings["channel"]}", "| current sources: #{obs.scenes["meta-bullshit"].to_h.keys.map{ | s | s.sub( /^medialoop-bullshit-/, "") } .join(" ")}" } ) # end # elsif ( cmd =~ /^(system|dxdiag|computer)$/ ) && ( Crystal::DESCRIPTION =~ /linux/ ) # ircipc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/tmp/DxDiag.txt" } ) # end rescue ex puts ex ircipc.send( { "##{config.chat_user.not_nil!.gamesurge.not_nil!}", "An error occurred! " + ex.message.to_s } ) end end rooms = Array( String ).new #rooms = [ "##{settings["channel"]}" ] rooms = config.join_channels.not_nil!.gamesurge.not_nil! # Connect to Gamesurge bot.run( rooms.map{ | room | room.sub( /^#/, "") } ) rescue ex : IO::Error pp ex sleep 1 # loop to reconnect rescue ex pp ex exit 1 end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end # BungmoBott::Socket client thread if config.bungmobott_connect bbscli_host, bbscli_port = config.bungmobott_connect.not_nil!.split(":") puts "spawning BungmoBott::Socket client" spawn name: "BungmoBott::Socket client" do fiberipc.send( Fiber.current ) loop do puts "looping BungmoBott::Socket client" user = config.chat_user.not_nil!.twitch.not_nil! bungmobott_key = secrets.bungmobott_key.not_nil! ssl_socket = OpenSSL::SSL::Socket::Client.new( TCPSocket.new( bbscli_host, bbscli_port.to_u16 ), OpenSSL::SSL::Context::Client.new ) ssl_socket.sync = true negotiated = false spawn do while message = ssl_socket.gets puts "BungmoBott::Socket cli recv: " + message.gsub( bungmobott_key, "CENSORED" ) if message =~ /^error/i raise Exception.new("BungmoBott::Socket Error: #{message}") elsif message =~ /^authed/ negotiated = true #ssl_socket.puts( "say twitch #{user} test" ) elsif ( match = message.match( /^msg (twitch|gamesurge)/ ) ) commandircipc.send( { match[1], FastIRC.parse_line( message.split(" ")[2..].join(" ") ) } ) elsif ( match = message.match( /^awst2s ([0-9]+)/ ) ) datasize = match[1].to_u32 audiodata = Bytes.new( datasize ) ssl_socket.fill_read( audiodata ) playaudiodata( config.tempdir, audiodata ) elsif ( match = message.match( /^gcst2s ([0-9]+)/ ) ) datasize = match[1].to_u32 audiodata = Bytes.new( datasize ) ssl_socket.fill_read( audiodata ) playaudiodata( config.tempdir, audiodata ) end end end ssl_socket.puts( "auth #{user} #{bungmobott_key}" ) while input = bbscliipc.receive # ssl_socket gets redefined in the event of I/O errors, so we deal with it here. #if ( climsg = input.match( /^irc +twitch +JOIN \#+([a-zA-Z0-9_]+) *$/ ) ) #end puts( "bbscli tx: " + input ) ssl_socket.puts( input ) end end rescue ex pp ex ensure waitgroup.send( Fiber.current.name.not_nil! ) sleep 2 end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end # BungmoBott::Socket thread if config.bungmobott_listen spawn name: "BungmoBott::Socket server" do fiberipc.send( Fiber.current ) ip, port = config.bungmobott_listen.not_nil!.split(":") tcp_server = TCPServer.new( ip, port.to_i ) ssl_context = OpenSSL::SSL::Context::Server.new ssl_context.private_key=(configdir + "/privkey.pem" ) ssl_context.certificate_chain=(configdir + "/fullchain.pem" ) # unauthenticated config; seems to be broken in crystal v1.9.2 # ssl_context = OpenSSL::SSL::Context::Server.insecure() # ssl_context.add_options(OpenSSL::SSL::Options::ALL) # ssl_context.security_level=0 # ssl_context.ciphers=("ADH@SECLEVEL=0") # ssl_context.ciphers=("ADH-AES256-GCM-SHA384:@SECLEVEL=0") # bungmobott protocol server #while tcp_server.accept? do | clientsocket | while clientsocket = tcp_server.accept? spawn do client = OpenSSL::SSL::Socket::Server.new(clientsocket, ssl_context) client.flush_on_newline=true puts "Connected: #{clientsocket.remote_address}" connections[client] = Hash(String, String).new connections[client]["remote_address"] = clientsocket.remote_address.to_s connections[client]["authed"] = "false" while message = client.gets if ( match = message.match( /^auth (#{regexuser}) (#{regexb64})$/i ) ) puts "auth #{match[1]} CENSORED" else puts message end client.puts "RECEIVED " + message if ( connections[client]["authed"] == "true" ) # irc if ( match = message.match( /^irc (#{regexservice}) JOIN \#(#{regexuser}) *$/i ) ) ircservice = match[2] # regexservice has some parens, too ircchannel = match[3] if ircservice == "twitch" client.puts "joining #{ircservice} \##{ircchannel}" twitchircipc.send( { "JOIN", ircchannel } ) unless channelsubs[ { ircservice, "#" + ircchannel } ]? channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new end channelsubs[ { ircservice, "#" + ircchannel } ].push( client ) pp channelsubs elsif ircservice == "gamesurge" client.puts "joining #{ircservice} \##{ircchannel}" ircipc.send( { "JOIN", ircchannel } ) unless channelsubs[ { ircservice, "#" + ircchannel } ]? channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new end channelsubs[ { ircservice, "#" + ircchannel } ].push( client ) pp channelsubs end elsif ( message =~ /^aws/i) if aws if ( match = message.match( /^awsvoicelist$/i ) ) client.puts "awsvoicelist " + generatevoicelistaws().join(" ") elsif ( match = message.match( /^awst2s ([a-zA-Z-]+) (.+)/i ) ) filepath="#{config.tempdir}#{Time.utc.to_unix_ms}.mp3" ttsaws( filepath, match[1], match[2] ) mp3datasize = File.size( filepath ) mp3data = Bytes.new( mp3datasize ) content = File.open( filepath ) do |file| file.read( mp3data ) end client.puts "awst2s #{mp3data.size}" STDOUT.puts "SENT: awst2s #{mp3data.size}" client.unbuffered_write( mp3data ) # Normal writes create TLS records of 4608 bytes. Unbuffered_writes create maximum 16384 byte records that need to be reassembled on the read end. STDOUT.puts "SENT: mp3data" end end elsif ( message =~ /^gcs/i) if ( gcloud_token = secrets.gcloud_token ).is_a?( String ) if ( match = message.match( /^gcsvoicelist$/i ) ) client.puts "gcsvoicelist " + generatevoicelistgcs( ( gcloud_token ) ).join(" ") elsif ( match = message.match( /^gcst2s (([a-zA-Z]{2,3}-[a-zA-Z]{2})[a-zA-Z-]+) (.+)/i ) ) mp3data = ttsgcs( match[2], match[1], match[3], gcloud_token ) client.puts "gcst2s #{mp3data.size}" STDOUT.puts "SENT: gcst2s #{mp3data.size}" client.unbuffered_write( mp3data ) STDOUT.puts "SENT: mp3data" end else client.puts "ERROR: gcloud_token missing, gcs commands disabled." end elsif ( match = message.match( /^say twitch (.+)/i ) ) say( twitchircipc, connections[client]["user"], match[1] ) elsif ( message =~ /testchannelsubs/ ) #commandircipc.send( "testchannelsubs" ) end else # auth if ( match = message.match( /^auth (#{regexuser}) (#{regexb64})$/i ) ) remoteuser = match[1] remotekey = match[2] if File.exists?( config.statedir + "apikeys/" + remoteuser ) File.each_line( config.statedir + "apikeys/" + remoteuser ) do |localkey| puts "comparing #{localkey} to #{remotekey}" if ( localkey == remotekey ) connections[client]["authed"] = "true" puts "authed #{ remoteuser }" client.puts "authed #{ remoteuser }" connections[client]["user"] = remoteuser connections[client]["key"] = remotekey break end end end if ( connections[client]["authed"] == "false" ) client.puts "error: auth failure" end else client.puts "must auth [user] [key]" end end end rescue ex : IO::Error puts ex next rescue ex puts ex next ensure connections.delete( client ) channelsubs.each_key do |key| channelsubs[key].delete( client ) end puts "Disconnected: #{clientsocket.remote_address}" end end rescue ex puts ex ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end if config.join_channels spawn name: "join_channels" do fiberipc.send( Fiber.current ) if fibers["GameSurge::IRC"]? && config.join_channels.not_nil!.gamesurge ircservice = "gamesurge" config.join_channels.not_nil!.gamesurge.not_nil!.each do |ircchannel| ircipc.send( { "JOIN", ircchannel } ) unless channelsubs[ { ircservice, ircchannel } ]? channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new # Do we ever care about this? #channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server | Channel( Tuple( String, FastIRC::Message ) ) ).new end end elsif fibers["BungmoBott::Socket client"]? ircservice = "gamesurge" config.join_channels.not_nil!.gamesurge.not_nil!.each do |ircchannel| bbscliipc.send( "irc gamesurge JOIN \#" + ircchannel ) end end if fibers["Twitch::IRC"]? && config.join_channels.not_nil!.twitch ircservice = "twitch" config.join_channels.not_nil!.twitch.not_nil!.each do |ircchannel| twitchircipc.send( { "JOIN", ircchannel } ) unless channelsubs[ { ircservice, ircchannel } ]? channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new # Do we ever care about this? #channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server | Channel( Tuple( String, FastIRC::Message ) ) ).new end end elsif fibers["BungmoBott::Socket client"]? ircservice = "twitch" config.join_channels.not_nil!.twitch.not_nil!.each do |ircchannel| bbscliipc.send( "irc twitch JOIN \#" + ircchannel ) end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberipc.receive fibers[fiber.name.not_nil!] = fiber end puts "Spawned fibers:" pp fibers fibers.size.times do fiber = waitgroup.receive fibers.delete( fiber ) puts "Fiber ended: " + fiber end