From 77b74b745aaa84fe342d56f396bc0491cea0859b Mon Sep 17 00:00:00 2001 From: Joe Rayhawk Date: Tue, 27 Feb 2024 03:23:30 -0800 Subject: crystal/tcpsocket: promote new codebase to bungmobott --- crystal/bungmobott.cr | 1832 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1832 insertions(+) create mode 100644 crystal/bungmobott.cr (limited to 'crystal/bungmobott.cr') diff --git a/crystal/bungmobott.cr b/crystal/bungmobott.cr new file mode 100644 index 0000000..8194eb6 --- /dev/null +++ b/crystal/bungmobott.cr @@ -0,0 +1,1832 @@ +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; 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 %} + +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 +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" ) + 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 "}, + { /\|/, " vertical bar "}, + { /\`/, " grave accent "}, + { /\+/, " plus "}, + { /×/, " multiplied by "}, + { /=/, " equals "}, + { /\//, " slash "}, + { /\\/, " backslash "}, + { /@/, " at "}, + { /&/, " and "}, + { />/, " greater than "}, + { /= 14400 ) + if service =~ /^twitch/ && uid.is_a?( UInt64 ) + if twitchapi + puts( "#{Fiber.current.name} tx twitchapiifc: get_user, #{uid}" ) + twitchapiifc.send( { "get_user", uid.to_s } ) + puts( "#{Fiber.current.name} tx twitchapiifc: get_followers, #{uid}" ) + twitchapiifc.send( { "get_followers", uid.to_s } ) + 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/ && 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 + 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 =~ /^(user)$/ ) && ( mod || own ) ) +# if match[2]? && match[2] =~ /[a-z][a-z0-9_]+/ +# twitchapiifc.send( { "get_user", match[2] } ) +# elsif match[2]? && match[2] =~ /[0-9]+/ +# twitchapiifc.send( { "get_user", match[2].to_u64 } ) +# else +# twitchapiifc.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 ) ) +# 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] + 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 = 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 +# elsif ( cmd =~ /^(system|dxdiag|computer)$/ ) && ( Crystal::DESCRIPTION =~ /linux/ ) +# gamesurgeircifc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/tmp/DxDiag.txt" } ) +# 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 =~ /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 + -- cgit v1.2.3