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 +++++++++++++++++++++++++++++++++++++++++++++++++ crystal/tcpsocket.cr | 1832 ------------------------------------------------- 2 files changed, 1832 insertions(+), 1832 deletions(-) create mode 100644 crystal/bungmobott.cr delete mode 100644 crystal/tcpsocket.cr (limited to 'crystal') 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 + diff --git a/crystal/tcpsocket.cr b/crystal/tcpsocket.cr deleted file mode 100644 index 8194eb6..0000000 --- a/crystal/tcpsocket.cr +++ /dev/null @@ -1,1832 +0,0 @@ -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