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" require "file_utils" require "option_parser" STDOUT.sync = true STDOUT.flush_on_newline = true # Convenience mixins 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 send_and_log( ifc, value ) puts( "#{Fiber.current.name} tx {{ifc}}: #{{{value}}}" ) {{ifc}}.send( {{value}} ) end # IRC say macro say( service, channel, text ) case {{service}} when "twitch" send_and_log( twitchircifc, { "#" + {{channel}}, {{text}} } ) when "gamesurge" send_and_log( gamesurgeircifc, { "#" + {{channel}}, {{text}} } ) when "twitch_remote" send_and_log( bbscliifc, "say twitch " + {{text}} ) when "gamesurge_remote" send_and_log( bbscliifc, "say gamesurge " + {{text}} ) end end # Say() in all available primary channels macro say_all_self_chan( text ) if fibers["Twitch::IRC"]? say( "twitch", config.chat_user.not_nil!.twitch.not_nil!, {{text}} ) elsif fibers["BungmoBott::Socket client"]? && say( "twitch_remote", config.chat_user.not_nil!.twitch.not_nil!, {{text}} ) end # FIXME: Maybe use config.join_channels.gamesurge[0]? think about this later if fibers["GameSurge::IRC"]? say( "gamesurge", config.chat_user.not_nil!.gamesurge.not_nil!, {{text}} ) elsif fibers["BungmoBott::Socket client"]? say( "gamesurge_remote", config.chat_user.not_nil!.gamesurge.not_nil!, {{text}} ) 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 File.exists?( File.expand_path( "config.txt" ) ) configfile = Path[File.expand_path( "config.txt" )].normalize end secretsfile = Path[ configdir + "secrets.txt" ].normalize if File.exists?( File.expand_path( "secrets.txt" ) ) secretsfile = Path[File.expand_path( "secrets.txt" )].normalize end OptionParser.parse do |parser| parser.banner = "Usage: #{PROGRAM_NAME} (arguments...)" parser.on("-c FILE", "--config=FILE", "YAML configuration file") { |file| configfile = Path[file].normalize } parser.on("-s FILE", "--secrets=FILE", "YAML secrets file") { |file| secretsfile = Path[file].normalize } {% if flag?(:windows) %} parser.on("--install-mss-voices", "Download and install Microsoft Speech Services voices") do t2s_mss_voice_install() end {% end %} parser.on("-h", "--help", "Show help") do puts( parser ) exit end parser.invalid_option do |flag| STDERR.puts "ERROR: #{flag} is not a valid option." STDERR.puts parser end 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 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 # FIXME: start with ?sceneItemEnabled false to give us time to SetSceneItemTransform 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; direct AWS voices disabled." aws = false elsif ! Process.find_executable( "aws.exe" ) && ! Process.find_executable( "aws" ) STDERR.puts "Warning: aws CLI executable is missing; direct 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 %} voices = Hash( String, String ).new if ( mss || gcloud || aws ) && config.voice_list text2speech = true if File.exists?( config.voice_list.not_nil! ) File.read( config.voice_list.not_nil! ).strip.split( "\n" ).each do |voice| voices[voice.strip.downcase] = voice.strip end #else #regeneratevoicelist() end else text2speech = false end lastvoice = Array(String).new # Inter-Fiber Communication Channels # BungmoBott::Socket IRC channel subscriptions: { { service, chan } => [ client, client ] } 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 gamesurgeircifc = Channel( Tuple( String, String ) ).new # commandifc: serv, chan, user, msg commandifc = Channel( Tuple( String, FastIRC::Message ) ).new # t2sifc: voice, text t2sifc = Channel( Tuple( String, String ) ).new twitchircifc = Channel( Tuple( String, String ) ).new twitchapiifc = Channel( Tuple( String, String | UInt64 ) ).new bbscliifc = Channel( String ).new # currently unused # bbssrvifc = Channel( String ).new # FIXME: need to channel up subscriptions between command_dispatch/bbssrv and twitchapi #twitchapieventsubs = Hash( Channel( String ) ) waitgroup = Channel( String ).new fiberifc = 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}/", secrets.obs_password ) # OBS event fiber spawn name: "OBS::WebSocket" do fiberifc.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 name: "OBS::WebSocket event" do d = json # Copy *immediately* case d["eventType"].as_s when "CurrentProgramSceneChanged" say_all_self_chan( "| 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_all_self_chan( "| 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_all_self_chan( "| 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 pp ex ensure waitgroup.send( Fiber.current.name.not_nil! ) puts( "#{Fiber.current.name} tx waitgroup: #{Fiber.current.name}" ) end fiber = fiberifc.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 # FIXME: maybe break this out into separate functions later def getvoice( voicelist : String, userdir : Path, chatuser : String ) voicefile = Path.new( userdir, "voice" ).normalize voicenamesubfile = Path.new( userdir, "voicesub" ).normalize if File.exists?( voicefile ) voice_output = File.read( voicefile ).strip voice_setting = voice_output else voice_output = File.read( voicelist ).strip.split( "\n" ).sample( 1 )[0].strip voice_setting = "random" end if File.exists?( voicenamesubfile ) namesub = File.read( voicenamesubfile ).strip 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.strip ) 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 | STDERR.puts( "#{v["naturalSampleRateHertz"]} #{v["languageCodes"]} #{v["name"]}" ) voices.push( v["name"].as_s.strip ) 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.strip ) end end if ( voices.size < 10 ) puts( "WARNING: Microsoft Speech Service voice count is suspiciously low." ) puts( "You may want to visit https://www.microsoft.com/en-us/download/details.aspx?id=27224" ) puts( "or run #{PROGRAM_NAME} --install-mss-voices" ) end return voices end {% end %} macro writevoices() File.write( config.voice_list.not_nil!, voices.values.sort.join("\r\n") ) say_all_self_chan( "| Wrote voice_list file." ) end macro regeneratevoicelist() if aws generatevoicelistaws().each do | voice | voices[ voice.downcase ] = voice end elsif fibers["BungmoBott::Socket client"]? bbscliifc.send( "awsvoicelist" ) puts( "#{Fiber.current.name} tx bbscliifc: awsvoicelist" ) end if secrets.gcloud_token generatevoicelistgcs( secrets.gcloud_token.not_nil! ).each do | voice | voices[ voice.downcase ] = voice end elsif fibers["BungmoBott::Socket client"]? bbscliifc.send( "gcsvoicelist" ) puts( "#{Fiber.current.name} tx bbscliifc: gcsvoicelist" ) end {% if flag?(:windows) %} generatevoicelistwin().each do | voice | voices[ voice.downcase ] = voice end {% end %} writevoices() end def file_list( path : String ) : Array( String ) if File.file?( path ) return Array( String ){ File.realpath( path ) } elsif File.directory?( path ) dir = Dir.new( path ) return dir.children.map{ | child | dir.path + child } else raise Exception.new("InvalidPath") end end # TODO: add piping into mpv on POSIX def playaudiodata( tempdir : Path | String, data : Bytes ) filepath=Path.new( tempdir, "#{Time.utc.to_unix_ms}.mp3" ).normalize File.write( filepath, data ) playaudiofile( filepath ) File.delete( filepath ) end def playaudiofile( filepath : Path ) {% 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, Path, 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 = Path.new( config.statedir, "twitch" ).normalize userdir = Path.new( basedir, "uids", uid ).normalize if File.directory?( userdir ) lastseen = File.info( userdir ).modification_time.to_unix else lastseen = 0 end Dir.mkdir_p( Path.new( basedir, "names" ).normalize ) Dir.mkdir_p( Path.new( userdir, "names" ).normalize ) File.touch( userdir ) unless testrefuser2uid( Path.new( userdir, "names", chatuser ).normalize ) oldname = nil datelatest = Time::UNIX_EPOCH Dir.each_child( Path.new( userdir, "names" ).normalize ) do |name| namedate = File.info( Path.new( userdir, "names", name ).normalize ).modification_time if namedate > datelatest oldname = name datelatest = namedate end end genrefuser2uid( Path.new( userdir, "names", chatuser ).normalize, uid, 3 ) end unless testrefuser2uid( Path.new( basedir, "names", chatuser ).normalize ) genrefuser2uid( Path.new( basedir, "names", chatuser ).normalize, 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 = Path.new( config.statedir, "gamesurge" ).normalize userdir = Path.new( basedir, "names", chatuser ).normalize 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 pp ex return nil end {% if flag?(:windows) %} def t2s_mss_voice_install() puts "Downloading Microsoft Speech Services voice index" puts "https://www.microsoft.com/en-us/download/details.aspx?id=27224" ENV["TEMP"]? || ( STDERR.puts "TEMP environment variable undefined" && exit 2 ) ssl_context = OpenSSL::SSL::Context::Client.new response = HTTP::Client.exec( "GET", "https://www.microsoft.com/en-us/download/details.aspx?id=27224", tls: ssl_context ) if ( response.status_code == 200 ) response.body.split("\"").select(/^https.+MSSpeech.+\.msi$/).each do | msi_url | puts "Downloading: #{msi_url}" ssl_context = OpenSSL::SSL::Context::Client.new response = HTTP::Client.exec( "GET", msi_url, tls: ssl_context ) if ( ( response.status_code == 200 ) && ( response.content_type == "application/octet-stream" ) && ( match = /^.+\/(.+msi)$/.match(msi_url) ) ) msi_path = Path.new( Path[ENV["LOCALAPPDATA"]].normalize, "Temp", match[1] ) puts "Writing : #{msi_path}" File.write( msi_path, response.body ) puts "Installing : #{msi_path}" p = Process.new( "msiexec", [ "/i", msi_path.normalize.to_s ], output: STDOUT, error: STDERR, ) p.wait puts "Deleting : #{msi_path}" File.delete( msi_path ) else end end else puts response.status_code pp response.headers puts response.body exit 1 end exit end {% 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 pp ex end def t2s( t2sifc : Channel, config : BungmoBott::Config, userdir : Path, 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 "}, { /\x{0003}[0-9]*,[0-9]*/, "" }, # IRC terminal color codes { /\x{0003}[0-9]+/, "" }, { /\|/, " vertical bar "}, { /\`/, " grave accent "}, { /\+/, " plus "}, { /×/, " multiplied by "}, { /=/, " equals "}, { /\//, " slash "}, { /\\/, " backslash "}, { /@/, " at "}, { /&/, " and "}, { />/, " greater than "}, { /= 14400 ) if service =~ /^twitch/ && uid.is_a?( UInt64 ) if twitchapi send_and_log( twitchapiifc, { "get_user", uid.to_u64 } ) send_and_log( twitchapiifc, { "get_followers", uid.to_u64 } ) elsif fibers["BungmoBott::Socket client"]? send_and_log( bbscliifc, "twitchapigetuser id:#{uid}" ) end prevnames = Array( String ).new if ( prevnames = Dir.children( Path.new( userdir, "names" ).normalize ) ) && ( 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?( Path.new( userdir, "fanfare" ).normalize ) playaudiofile( Path.new( userdir, "fanfare", Dir.children( Path.new( userdir, "fanfare" ).normalize ).sample ).normalize ) 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.match && config.match.not_nil!.each do | networkregex, channelhash | Regex.new( networkregex ).match( service ) || next channelhash.each do | channelregex, texthash | Regex.new( channelregex ).match( ircchannel ) || next texthash.each do | textregex, command | # Hash( String, Array ) Regex.new( textregex ).match( message.params[1] ) || next command.each do |exec| # Array if ( ( botowner || ( exec.perm && exec.perm.not_nil!.includes?("any") ) ) || ( chanowner && ( 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. if service =~ /^twitch/ if ( exec.func == "detect_rename" && uid.is_a?( UInt64 ) ) if oldname.is_a?( String ) say( service, config.chat_user.not_nil!.twitch.not_nil!, "Rename detected: #{uid}: #{oldname} -> #{chatuser}" ) end next end end if ( exec.func == "twitchapi_get_user" ) if ( match = / ([a-zA-Z0-9_]+)/.match( message.params[1] ) ) if twitchapi send_and_log( twitchapiifc, { "get_user", match[1] } ) elsif fibers["BungmoBott::Socket client"]? send_and_log( bbscliifc, "twitchapigetuser name:#{match[1]}" ) else say( service, ircchannel, "| No Twitch API access configured." ) end else say( service, ircchannel, "| Requires twitch username as argument." ) 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 if ( exec.func == "obs_stats_get" ) puts "Exec-ing obs_stats_get" stats = obs.stats.to_h ostatus = obs.outputs["adv_stream"].status.to_h say( service, ircchannel, "| #{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)}" ) next end if ( exec.func == "obs_scene_list_get" ) puts "Exec-ing obs_scene_list_get" say( service, ircchannel, "| scenes: #{obs.scenes.to_h.keys.join(" ")}" ) next end if ( exec.func == "obs_scene_get" ) puts "Exec-ing obs_scene_get" say( service, ircchannel, "| Current scene: #{obs.scenes.program.name}" ) next end if ( exec.func == "obs_scene_set" ) puts "Exec-ing obs_scene_set" if ( match = / ([a-zA-Z0-9-_ ]+)/.match( message.params[1] ) ) obs.scenes[match[1]].program! else say( service, ircchannel, "| Could not find scene name." ) end next end if ( exec.func == "obs_input_list_get" ) puts "Exec-ing obs_input_list_get" say( service, ircchannel, "| inputs: #{obs.inputs.to_h.keys.join(" ")}" ) next end if ( exec.func == "obs_source_list_get" ) puts "Exec-ing obs_source_list_get" say( service, ircchannel, "| current sources: #{obs.scenes.current.metascene.keys.join(" ")}" ) next end if ( exec.func == "obs_source_toggle" ) puts "Exec-ing obs_source_toggle" if ( match = / ([a-zA-Z0-9-_ ]+)/.match( message.params[1] ) ) obs.scenes.current.metascene[match[1]][0].toggle! # in studio mode, direct Scene->SceneItem toggles require a transition obs.scenes.current.preview! obs.transition! else say( service, ircchannel, "| Could not find source name." ) end next end if ( exec.func == "obs_source_filters_list_get" ) puts "Exec-ing obs_source_filters_list_get" if ( match = / ([a-zA-Z0-9-_]+)/.match( message.params[1] ) ) say( service, ircchannel, "| #{match[1]} filters: #{obs.sources.to_h[match[1]].filters.to_h.keys.join(" ")}" ) else say( service, ircchannel, "| Could not match source name." ) end next end if ( exec.func == "obs_source_filter_toggle" ) puts "Exec-ing obs_source_filter_toggle" if ( match = / ([a-zA-Z0-9-_]+) ([a-zA-Z0-9-_]+)/.match( message.params[1] ) ) obs.sources.to_h[match[1]].filters[match[2]].toggle! else say( service, ircchannel, "| Could not match source and filter name to toggle." ) end next end if ( exec.func == "obs_create_ephemeral_media_sources_from_path" ) puts "Exec-ing obs_create_ephemeral_media_sources_from_path" count = 1 sources = file_list( exec.arg.not_nil![1].not_nil! ) if ( match = / ([0-9]+)/.match( message.params[1] ) ) count = match[1].to_i if ( ( count < 1 ) || ( count > sources.size ) ) say( service, ircchannel, "| Ephemeral OBS source count must be between 1 and #{sources.size}" ) next end end sources.sample( count ).each do |path| obstemporarymediacreate( obs, exec.arg.not_nil![0].not_nil!, path.gsub(/[\/ ]/, '_').downcase, path ) end next end end if text2speech voicenamesubfile = Path.new( userdir, "voicesub" ).normalize voicefile = Path.new( userdir, "voice" ).normalize if ( exec.func == "text_to_speech" ) puts "Exec-ing text_to_speech" if ( t2sreturn = t2s( t2sifc, config, userdir, chatuser, message.params[1] ) ) lastvoice.insert( 0, t2sreturn.strip ) lastvoice = lastvoice[0..4] end next end if ( exec.func == "tts_voice_list_generate" ) puts "Exec-ing " + exec.func regeneratevoicelist() say( service, ircchannel, "| Regenerated voicelist." ) next end if ( exec.func == "tts_last_voice" ) unless lastvoice.empty? say( service, ircchannel, "| Last voices were " + lastvoice.join( ", " ) ) else say( service, ircchannel, "| No voices used so far." ) end next end if ( exec.func == "tts_voice_get" ) puts "Exec-ing tts_voice_get" namesub, voice_setting, voice_output = getvoice( config.voice_list.not_nil!, userdir, chatuser ) say( service, config.chat_user.not_nil!.twitch.not_nil!, "| Current voice is #{voice_setting}" ) next end if ( exec.func == "tts_voice_set" ) puts "Exec-ing tts_voice_set" if ( match = / ([a-zA-Z0-9-_]+)/.match( message.params[1] ) ) voice = match[1].downcase if voice =~ /disabled|null|disable|none|random/ if File.exists?( voicefile ) File.delete( voicefile ) say( service, ircchannel, "| Voice for #{chatuser} is now random." ) else say( service, ircchannel, "| Voice for #{chatuser} is already random." ) end elsif voices.has_key?( voice.downcase ) csvoice = voices[voice] Dir.mkdir_p( userdir ) File.write( voicefile, csvoice ) pp userdir say( service, ircchannel, "| Voice for #{chatuser} is now #{File.read( voicefile ).strip}." ) else pp ( match ) say( service, ircchannel, "| Invalid voice. To see list, use !voices" ) end else say( service, ircchannel, "| tts_voice_set failed generic string match ") end next end if ( exec.func == "tts_name_get" ) puts "Exec-ing " + exec.func if File.exists?( voicenamesubfile ) say( service, ircchannel, "| Current name substitution is \"#{File.read( voicenamesubfile ).strip}\"." ) else say( service, ircchannel, "| Current name substitution is disabled." ) end end if ( exec.func == "tts_name_set" ) puts "Exec-ing tts_name_set" if ( match = / ([\sa-zA-Z0-9-]+)$/.match( message.params[1] ) ) pp match[1] voicesub = match[1].downcase if voicesub =~ /^(disabled|null|disable|none)$/ if File.exists?( voicenamesubfile ) File.delete( voicenamesubfile ) say( service, ircchannel, "| Name substitution for #{chatuser} is now disabled." ) else say( service, ircchannel, "| Name substitution for #{chatuser} is already disabled." ) end else Dir.mkdir_p( userdir ) File.write( voicenamesubfile, voicesub ) say( service, ircchannel, "| Name substitution for #{chatuser} is now \"#{File.read( voicenamesubfile ).strip}\"." ) end end next end end if ( exec.func == "say" ) say( service, config.chat_user.not_nil!.twitch.not_nil!, exec.arg.not_nil![0].not_nil! ) 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 next end STDERR.puts "WARNING: unhandled function for /#{textregex}/: #{exec.func}" else STDOUT.print "DENIED: " ppe command end 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] # # 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-_]+$/ ) # gamesurgeircifc.send( { "##{settings["channel"]}", "| #{obs.inputs[match[2]].settings.to_h.to_s} " } ) # 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 # gamesurgeircifc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument." } ) # end # # FIXME: This is only half-implemented # 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 ) ) # gamesurgeircifc.send( { "##{settings["channel"]}", "Title is now \"#{ json["data"][0]["title"] }\""} ) # else # json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) # gamesurgeircifc.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 ) ) # gamesurgeircifc.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 ) ) # gamesurgeircifc.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] ) # gamesurgeircifc.send( { "##{settings["channel"]}", definition[0,400] } ) # t2s( t2sifc, settings, userdir, chatuser, definition[0,400] ) # else # gamesurgeircifc.send( { "##{settings["channel"]}", "| Urban Dictionary search term should consist of letters, numbers, spaces, and/or hyphens." } ) # end # elsif ( cmd =~ /^(shout|shoutout)$/ ) # if match[2]? && match[2] =~ /^[a-z]+$/ # gamesurgeircifc.send( { "##{settings["channel"]}", "| Go check out twitch.tv/#{match[2]}"} ) # effectsmsg( settings, "overlay gltext 5 Go follow #{match[2]}" ) # else # gamesurgeircifc.send( { "##{settings["channel"]}", "| Missing argument."} ) # end # elsif ( cmd =~ /^(songrequest|sr)$/ ) && match[2]? # puts ("song detected: #{match[2]}") # if ( ( match[2] =~ /list=/ ) && ( match[2] !~ /v=/ ) ) # gamesurgeircifc.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 ) # gamesurgeircifc.send( { "##{settings["channel"]}", "| " + playlistinfo[ status["playlistlength"].to_i - 1 ]["file"].to_s + " added in position " + status["playlistlength"].to_s } ) # else # gamesurgeircifc.send( { "##{settings["channel"]}", "| A failure occured." } ) # end # m.disconnect # else # end # elsif ( cmd =~ /^current(|song)$/ ) # m = MPD::Client.new # if ( currentsong = m.currentsong ) # gamesurgeircifc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) # else # gamesurgeircifc.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 # gamesurgeircifc.send( { "##{settings["channel"]}", "| " + sprintf( "2%d:2%d", min, sec ) } ) # else # gamesurgeircifc.send( { "##{settings["channel"]}", "| An error occurred. " } ) # end # m.disconnect # else # gamesurgeircifc.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 ) # gamesurgeircifc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) # else # gamesurgeircifc.send( { "##{settings["channel"]}", "| A failure occured." } ) # end # else # gamesurgeircifc.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 # gamesurgeircifc.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 # gamesurgeircifc.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 # gamesurgeircifc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) # end # end rescue ex pp ex puts ex.backtrace say_all_self_chan( "An error occurred! " + ex.message.to_s ) end end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberifc.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 : Path, voice : String, text : String ) p = Process.new( "aws", [ "polly", "synthesize-speech", "--output-format", "mp3", "--voice-id", voice, "--text", text, filepath.to_s ], output: STDOUT, error: STDERR ) p.wait end # Put tts stuff into the same fiber so each playback blocks the next spawn name: "text2speech" do fiberifc.send( Fiber.current ) loop do begin while t2stuple = t2sifc.receive puts( "#{Fiber.current.name}: #{t2stuple}" ) 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"]? bbscliifc.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=Path.new( config.tempdir, "#{Time.utc.to_unix_ms}.mp3" ).normalize ttsaws( filepath, voice, text ) playaudiofile( filepath ) File.delete( filepath ) elsif fibers["BungmoBott::Socket client"]? bbscliifc.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 pp ex end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberifc.receive fibers[fiber.name.not_nil!] = fiber # Twitch API request handling fiber # FIXME: Implement ratelimiting here. if twitchclient.is_a?( Twitcr::Client ) spawn name: "Twitcr::Client" do fiberifc.send( Fiber.current ) loop do begin while twitchtuple = twitchapiifc.receive puts( "#{Fiber.current.name}: #{twitchtuple}" ) cmd, arg = [ *twitchtuple ] case cmd when "get_user" userinfo = JSON.parse( twitchclient.get_user( arg ) )["data"][0] 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 = fiberifc.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 fiber spawn name: "Twitch::IRC" do fiberifc.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 fiber # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters." # PRIVMSG #channel message\r\n spawn name: "Twitch::IRC ifc rx" do while tuple = twitchircifc.receive # why does this need to be a tuple? puts( "#{Fiber.current.name}: #{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 name: "Twitch::IRC irc rx" 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 commandifc.send( { "twitch", message } ) pp message pp message.params rescue ex pp ex #twitchircifc.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 = fiberifc.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 fiber spawn name: "GameSurge::IRC" do fiberifc.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 fiber # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters." # PRIVMSG #channel message\r\n spawn name: "GameSurge::IRC ifc rx" do while tuple = gamesurgeircifc.receive # why does this need to be a tuple? puts( "#{Fiber.current.name}: #{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 name: "GameSurge::IRC irc rx" do commandifc.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 # gamesurgeircifc.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 # gamesurgeircifc.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 # gamesurgeircifc.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 # gamesurgeircifc.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 # gamesurgeircifc.send( { "##{settings["channel"]}", "| current sources: #{obs.scenes["meta-bullshit"].to_h.keys.map{ | s | s.sub( /^medialoop-bullshit-/, "") } .join(" ")}" } ) # end # end rescue ex pp ex gamesurgeircifc.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 = fiberifc.receive fibers[fiber.name.not_nil!] = fiber end # BungmoBott::Socket client fiber if config.bungmobott_connect bbscli_host, bbscli_port = config.bungmobott_connect.not_nil!.split(":") spawn name: "BungmoBott::Socket client" do fiberifc.send( Fiber.current ) loop do puts "#{Fiber.current.name} connecting #{config.bungmobott_connect}" 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 name: "BungmoBott::Socket client ssl rx" do while message = ssl_socket.gets puts "#{Fiber.current.name}: " + 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)/ ) ) commandifc.send( { "#{match[1]}_remote", 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 ) elsif ( match = message.match( /^awsvoicelist (.+)$/ ) ) match[1].split(" ").each do | voice | voices[voice.downcase] = voice end writevoices() elsif ( match = message.match( /^gcsvoicelist (.+)$/ ) ) match[1].split(" ").each do | voice | voices[voice.downcase] = voice end writevoices() end end end ssl_socket.puts( "auth #{user} #{bungmobott_key}" ) while input = bbscliifc.receive puts( "#{Fiber.current.name} ssl tx: #{input}" ) # ssl_socket gets redefined in the event of I/O errors, so we deal with it here. ssl_socket.puts( input ) end end rescue ex pp ex ensure waitgroup.send( Fiber.current.name.not_nil! ) sleep 2 end fiber = fiberifc.receive fibers[fiber.name.not_nil!] = fiber end # BungmoBott::Socket fiber if config.bungmobott_listen spawn name: "BungmoBott::Socket server" do fiberifc.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 name: "BungmoBott::Socket server tcp rx" 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}" twitchircifc.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}" gamesurgeircifc.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=Path.new( config.tempdir, "#{Time.utc.to_unix_ms}.mp3" ).normalize 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-Z0-9-]+) (.+)/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|gamesurge) (.+)/i ) ) say( match[1], connections[client]["user"], match[2] ) elsif ( message =~ /^twitchapi/i) if ( twitchapi ) if ( match = message.match( /^twitchapigetuser id:(\d+)/i ) ) send_and_log( twitchapiifc, { "get_user", match[1].to_u64 } ) elsif ( match = message.match( /^twitchapigetuser name:(\w+)/i ) ) send_and_log( twitchapiifc, { "get_user", match[1] } ) end end elsif ( message =~ /testchannelsubs/ ) #commandifc.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| if ( localkey == remotekey ) connections[client]["authed"] = "true" puts "authed #{ remoteuser }" client.puts "authed #{ remoteuser }" connections[client]["user"] = remoteuser connections[client]["key"] = remotekey break else puts "WARNING: auth failure: #{localkey} did not match #{remotekey}" # maybe quiet this down once users start using multiple keys 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 pp ex next rescue ex pp 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 pp ex ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberifc.receive fibers[fiber.name.not_nil!] = fiber end if config.join_channels spawn name: "join_channels" do fiberifc.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| gamesurgeircifc.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"]? && config.join_channels.not_nil!.gamesurge ircservice = "gamesurge" config.join_channels.not_nil!.gamesurge.not_nil!.each do |ircchannel| bbscliifc.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| twitchircifc.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"]? && config.join_channels.not_nil!.twitch ircservice = "twitch" config.join_channels.not_nil!.twitch.not_nil!.each do |ircchannel| bbscliifc.send( "irc twitch JOIN \#" + ircchannel ) end end ensure waitgroup.send( Fiber.current.name.not_nil! ) end fiber = fiberifc.receive fibers[fiber.name.not_nil!] = fiber end puts "Spawned fibers:" pp fibers.keys fibers.size.times do fiber = waitgroup.receive fibers.delete( fiber ) puts "Fiber ended: " + fiber end