diff options
author | Joe Rayhawk <jrayhawk+git@omgwallhack.org> | 2024-01-11 17:37:20 -0800 |
---|---|---|
committer | Joe Rayhawk <jrayhawk+git@omgwallhack.org> | 2024-01-11 17:37:20 -0800 |
commit | 24197ba7c6709498e930b6e25ec4d7d30a4e33e9 (patch) | |
tree | ec59798af8114a6ea5b845dde38ded3de6eb4426 /crystal | |
parent | 11b8b62fd544479c07f34c57aedd807de53308c4 (diff) | |
download | twitchtools-24197ba7c6709498e930b6e25ec4d7d30a4e33e9.tar.gz twitchtools-24197ba7c6709498e930b6e25ec4d7d30a4e33e9.zip |
crystal/tcpsocket.cr: Get irc message transport working
Diffstat (limited to 'crystal')
-rw-r--r-- | crystal/lib/bungmobott/src/bungmobott.cr | 39 | ||||
-rw-r--r-- | crystal/tcpsocket.cr | 1058 |
2 files changed, 1052 insertions, 45 deletions
diff --git a/crystal/lib/bungmobott/src/bungmobott.cr b/crystal/lib/bungmobott/src/bungmobott.cr index d54eaad..8c7f755 100644 --- a/crystal/lib/bungmobott/src/bungmobott.cr +++ b/crystal/lib/bungmobott/src/bungmobott.cr @@ -24,7 +24,25 @@ module BungmoBott property twitch : String? property gamesurge : String? end - + +# Do we turn this into a class? Maybe an Enum? +# class Permissions +# include YAML::Serializable +# include YAML::Serializable::Unmapped +# property any +# property sub +# property mod +# property vip +# end + + class Commands + include YAML::Serializable + include YAML::Serializable::Unmapped + property perm : Array( String )? = nil + property func : String + property arg : Array( String? )? = nil + end + class JoinChannels include YAML::Serializable include YAML::Serializable::Unmapped @@ -50,10 +68,23 @@ module BungmoBott ) # FIXME: do sockets and such even work on Windows? # Should probably warn about that somewhere around here. #@[YAML::Field(emit_null: true)] - property listen : String? = nil + property bungmobott_listen : String? = nil + property bungmobott_connect : String? = nil + #@[YAML::Field(emit_null: true)] + property obs_connect : String? = nil property chat_user : ChatUser? = ChatUser.from_yaml("---") property join_channels : JoinChannels? = JoinChannels.from_yaml("---") + property commands : Hash( String, Array( Commands ) )? = nil + @[YAML::Field(emit_null: true)] property twitch_user_id : UInt32? = nil + @[YAML::Field(emit_null: true)] + property voice_list : String? = nil + +# def after_initialize() +# if @voice_list? +# # @voice_list = @statedir + "/voices.txt" +# end +# end end class Secrets include YAML::Serializable @@ -66,6 +97,10 @@ module BungmoBott property gcloud_token : String? @[YAML::Field(emit_null: true)] property gamesurge_password : String? + @[YAML::Field(emit_null: true)] + property obs_password : String? + @[YAML::Field(emit_null: true)] + property bungmobott_key : String? end end diff --git a/crystal/tcpsocket.cr b/crystal/tcpsocket.cr index b4ee6a8..f97169e 100644 --- a/crystal/tcpsocket.cr +++ b/crystal/tcpsocket.cr @@ -22,6 +22,22 @@ struct Nil 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 @@ -40,25 +56,31 @@ ENV["XDG_CONFIG_HOME"]? && ( configdir = ENV["XDG_CONFIG_HOME"] + ENV["LOCALAPPDATA"]? && ( configdir = Path.windows( ENV["LOCALAPPDATA"] + "\\#{EXE}\\" ).to_s ) # cygwin will have both, but should probably use LOCALAPPDATA -def writeconfig( configdir : String, file : String, contents : String ) - Dir.mkdir_p( configdir ) - File.write( configdir + file, contents ) +def writeconfig( filepath : Path, contents : String ) + Dir.mkdir_p( filepath.parent ) + File.write( filepath, contents ) rescue exio : IO::Error - puts "ERROR: Unable to write #{ configdir + file }.txt: #{exio.message}" + puts "ERROR: Unable to write #{ filepath }: #{exio.message}" exit 7 end -configfile = configdir + "config.txt" +configfile = Path[configdir + "config.txt"].normalize +if ARGV.size >= 1 + configfile = Path[ ARGV[0] ].normalize +end +puts configfile + if File.exists?( configfile ) - config = BungmoBott::Config.from_yaml( File.read( configdir + "config.txt" ) ) + config = BungmoBott::Config.from_yaml( File.read( configfile ) ) else config = BungmoBott::Config.from_yaml("---") STDERR.puts "WARNING: #{configfile} not found. Writing new one." - writeconfig( configdir, "config.txt", config.to_yaml ) + 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 @@ -74,14 +96,17 @@ if ( config.join_channels && ! config.join_channels.not_nil!.yaml_unmapped.empty ppe config.join_channels.not_nil!.yaml_unmapped end -secretsfile = configdir + "secrets.txt" +secretsfile = Path[ configdir + "secrets.txt" ].normalize +if ARGV.size >= 2 + secretsfile = Path[ ARGV[1] ].normalize +end if File.exists?( secretsfile ) secrets = BungmoBott::Secrets.from_yaml( File.read( secretsfile ) ) else secrets = BungmoBott::Secrets.from_yaml( "---" ) STDERR.puts "WARNING: #{secretsfile} not found. Writing new one." - writeconfig( configdir, "secrets.txt", secrets.to_yaml ) + writeconfig( secretsfile, secrets.to_yaml ) end unless secrets.yaml_unmapped.empty? @@ -89,6 +114,35 @@ unless secrets.yaml_unmapped.empty? ppe secrets.yaml_unmapped.keys end +def obsrandommediaenable( obs : OBS::WebSocket, siname : String ) + if ( Random.rand(3) < 2 ) + obs.scenes.current.metascene[siname][0].enable! + else + randsiname = obs.scenes.current.metascene.keys.select( /^#{siname}/ ).sample( 1 )[0] + obs.scenes.current.metascene[randsiname][0].enable! + end +end + +def obstemporarymediacreate( obs : OBS::WebSocket, sname : String, iname, path : String ) + iname = "media-temporary-effect-#{iname}" + isettings = Hash( String, String | Bool | Int64 | Float64 ){ + "advanced" => true, + "clear_on_media_end" => true, + "color_range" => 0.to_i64, + "is_local_file" => true, + "looping" => false, + "restart_on_activate" => true, + "local_file" => path, + } + response = obs.scenes[sname].createinput( iname, "ffmpeg_source", isettings ) + # Skip ORM stuff and configure the SceneItem as fast as we possibly can + if ( rdata = response["responseData"]? ) && ( goodtransform = obs.sources.last_known_real_transform?( iname ) ) + siid = rdata["sceneItemId"].as_i64 + obs.send( OBS.req( "SetSceneItemTransform", JSON.parse( { "sceneName" => sname, "sceneItemId" => siid, "sceneItemTransform" => { "positionX" => goodtransform.to_h["positionX"], "positionY" => goodtransform.to_h["positionY"] } }.to_json ) ) ) + end +end + + regextwitchuser = /^[0-9a-zA-Z_]+$/ # enable direct twitch api? @@ -132,6 +186,110 @@ else aws = true end +# enable microsoft speech services? +mss : Bool +{% if flag?(:windows) %} + mss = true +{% else %} + mss = false +{% end %} + +if ( mss || gcloud || aws ) && config.voice_list + text2speech = true +else + text2speech = false +end + +lastvoice = Array(String).new + +# channel message subscriptions: { service, chan } => [ client, client ] +#channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server | Channel( Tuple( String, FastIRC::Message ) ) ) ).new +channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server) ).new +# Unencrypted +#connections = Hash(TCPSocket, Hash(String, String)).new +# Encrypted +connections = Hash(OpenSSL::SSL::Socket::Server, Hash(String, String)).new +ircipc = Channel( Tuple( String, String ) ).new +# commandircipc: serv, chan, user, msg +commandircipc = Channel( Tuple( String, FastIRC::Message ) ).new +commandipc = Channel( String ).new +# t2sipc: voice, text +t2sipc = Channel( Tuple( String, String ) ).new +twitchipc = Channel( Tuple( String, String ) ).new +bbscliipc = Channel( String ).new +bbssrvipc = Channel( String ).new +waitgroup = Channel( String ).new + + +fiberipc = Channel( Fiber ).new +fibers = Hash( String, Fiber ).new + +evchan = Channel( JSON::Any ).new +obs : Nil | OBS::WebSocket = nil +if config.obs_connect + obs = OBS::WebSocket.new( "ws://#{config.obs_connect}/" ) + # OBS event thread + spawn name: "obs_event_thread" do + fiberipc.send( Fiber.current ) + obs.scenes["meta-foreground"].to_h.each_key do | key | + if key =~ /^media-temporary-/ + obs.inputs[key].delete! + end + end + obs.eventsub_add( evchan ) + while json = evchan.receive + # A Fiber.yield occurs after this to make sure "json" doesn't get overwritten before we can use it. + spawn do + d = json # Copy *immediately* + case d["eventType"].as_s + when "CurrentProgramSceneChanged" + say( twitchipc, config.chat_user.not_nil!.twitch.not_nil!, "| obs: switched scene to " + ( d["eventData"]["sceneName"]?.as_s? || "unknown" ) ) + when "MediaInputPlaybackEnded" + if d["eventData"]["inputName"].as_s =~ /^media-temporary-/ + obs.send( OBS.req( "RemoveInput", JSON.parse({ "inputName" => d["eventData"]["inputName"].as_s }.to_json) ) ) + elsif d["eventData"]["inputName"].as_s =~ /^media-/ + obs.scenes.current.metascene[d["eventData"]["inputName"].as_s][0].disable! + end + when "SceneItemEnableStateChanged" + edata = d["eventData"] + name = obs.scenes[ edata["sceneName"].as_s ][ edata["sceneItemId"].as_i64 ].name + if name !~ /media-temporary/ + say( twitchipc, config.chat_user.not_nil!.twitch.not_nil!, "| obs: source #{name} visibility is now #{edata["sceneItemEnabled"].as_bool}" ) + end + when "SceneItemTransformChanged" + edata = d["eventData"] + sceneitem = obs.scenes[edata["sceneName"].as_s][edata["sceneItemId"].as_i64] + t = edata["sceneItemTransform"] + if ( sceneitem.name =~ /^media-temporary-/ ) + spx = t["positionX" ].as_f.to_i64 + spy = t["positionY" ].as_f.to_i64 + sdx = t["sourceHeight"].as_f.to_i64 + sdy = t["sourceWidth" ].as_f.to_i64 + if ( spx == 0 && spy == 0 && sdx != 0 && sdy != 0 ) + # source position randomizer + bx = obs.video.to_h["baseWidth" ].as(Int64 | Float64).to_i64 + by = obs.video.to_h["baseHeight"].as(Int64 | Float64).to_i64 + spx = ( rand(bx) - (sdx / 2) ) + spy = ( rand(by) - (sdy / 2) ) + sceneitem.transform( { "positionX" => spx, "positionY" => spy } ) + end + end + when "SourceFilterEnableStateChanged" + edata = d["eventData"] + say( twitchipc, config.chat_user.not_nil!.twitch.not_nil!, "| obs: source #{edata["sourceName"].as_s} filter #{edata["filterName"].as_s} visibility is currently #{edata["filterEnabled"].as_bool}" ) + end + end + Fiber.yield + end + rescue ex + puts ex + ensure + waitgroup.send( Fiber.current.name.not_nil! ) + end + fiber = fiberipc.receive + fibers[fiber.name.not_nil!] = fiber +end + # enable effects? #effects = Hash( String, String ).new #if File.exists?( config.configdir + "/effects.txt" ) @@ -166,6 +324,50 @@ end # end #end +def urbandef( term : String ) +#{ +# "list": [ +# { +# "definition": "An overactive, small-proportioned homosexual [gentleman] who will [launch] at anything in [sight].", +# "permalink": "http://bungmonkey.urbanup.com/83517", +# "thumbs_up": 6, +# "author": "fishbear", +# "word": "bungmonkey", +# "defid": 83517, +# "current_vote": "", +# "written_on": "2003-04-04T11:55:12.000Z", +# "example": "\"That [chap] [in the corner] is a [proper] little bungmonkey. Look at him go!\"", +# "thumbs_down": 1 +# } +# ] +#} + ssl_context = OpenSSL::SSL::Context::Client.new + #{% if flag?(:windows) %} + #ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE + #{% end %} + #https://api.urbandictionary.com/v0/define?term=waifu + response = HTTP::Client.exec( "GET", "https://api.urbandictionary.com/v0/define?term=#{term}", tls: ssl_context ) + puts response.status_code + json = JSON.parse( response.body ) + return json["list"][0]["definition"].to_s.gsub( /[\[\]]/, "" ).gsub( /(\r|\n)/, " " ) +end + +def getvoice( configdir : String, userdir : String, chatuser : String ) + if File.exists?( userdir + "/voice" ) + voice_output = File.read( userdir + "/voice" ).chomp + voice_setting = voice_output + else + voice_output = File.read( configdir + "/voicelist.txt" ).chomp.split( "\n" ).sample( 1 )[0].chomp + voice_setting = "random" + end + if File.exists?( userdir + "/voicesub" ) + namesub = File.read( userdir + "/voicesub" ).chomp + else + namesub = chatuser + end + return( [namesub, voice_setting, voice_output ] ) +end + def generatevoicelistaws( ) voices = Array(String).new JSON.parse( `aws polly describe-voices` )["Voices"].as_a.each do | v | @@ -230,16 +432,676 @@ def regeneratevoicelist( defaultsettings : Hash( String, String ), aws : Bool, g return voices end -# channel message 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 +def playaudiofile( filepath : String ) + 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 +end + +def userlog( ircipc : Channel, config : BungmoBott::Config, message : FastIRC::Message ) + unless ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) && ( uid = message.tags["user-id"]? ) ) + return nil + end + basedir = config.statedir + userdir = basedir + "/uids/" + uid + if File.directory?( userdir ) + lastseen = File.info( userdir ).modification_time.to_unix + else + lastseen = 0 + end + Dir.mkdir_p( basedir + "/names" ) + Dir.mkdir_p( userdir + "/names" ) + File.touch( userdir ) + unless testrefuser2uid( userdir + "/names/" + chatuser ) + namelatest = "" + datelatest = Time::UNIX_EPOCH + Dir.each_child( userdir + "/names/" ) do |name| + namedate = File.info( userdir + "/names/" + name ).modification_time + if namedate > datelatest + namelatest = name + datelatest = namedate + end + end + unless namelatest.empty? + ircipc.send( { "##{config.chat_user.not_nil!.twitch}", "Rename detected: #{uid}: #{namelatest} -> #{chatuser}" } ) + end + genrefuser2uid( userdir + "/names/" + chatuser, uid, 3 ) + end + unless testrefuser2uid( basedir + "/names/" + chatuser ) + genrefuser2uid( basedir + "/names/" + chatuser, uid, 1 ) + end + unless ( message.params[0] == "##{config.chat_user.not_nil!.twitch}" ) + return nil + end + return ( { chatuser, uid, userdir, lastseen } ) +rescue ex + puts ex +end + +# IRC say +# does this function have a reason to exist? +def say( ipc : Channel( Tuple( String, String ) ), channel : String, text : String ) + ipc.send( { "#" + channel, text } ) +end + +# Currently only used in flag?(:unix) +def t2smsg( config : BungmoBott::Config, msg : String) + if File.exists?( config.rundir + "/.t2s.sock" ) + sock = Socket.unix + sock.connect Socket::UNIXAddress.new( config.rundir + "/.t2s.sock" ) + sock.puts( msg ) + sock.close + end +rescue ex + puts ex +end + +def t2s( t2sipc : Channel, config : BungmoBott::Config, userdir : String, chatuser : String, text : String ) + if ( text !~ /^ *(!|\|)/ ) + namesub, voice_setting, voice = getvoice( config.voice_list.not_nil!, userdir, chatuser ) + subs = Array( Tuple( Regex, String ) ){ + { /http(s|):\/\/([a-z0-9.-]+)\/[a-zA-Z0-9\/&=%-_]+/, "link to \\2" }, + { /([^a-zA-Z0-9])-/, "\\1 dash "}, + { /\|/, " vertical bar "}, + { /\`/, " grave accent "}, + { /\+/, " plus "}, + { /×/, " multiplied by "}, + { /=/, " equals "}, + { /\//, " slash "}, + { /\\/, " backslash "}, + { /@/, " at "}, + { /&/, " and "}, + { />/, " greater than "}, + { /</, " less than "}, + { /_/, " underscore "}, + { /\.\.\./, " dot dot dot "}, + { /\^/, " circumflex accent "}, + { /\#/, " octothorpe "}, + { /:([^ ])/, " colon \\1"}, + { /;([^ ])/, " semicolon \\1"}, + { /\.([^ ])/, " dot \\1"}, + { /\%([^ ])/, " percent sign \\1"}, + { /!([^ ])/, " tchik \\1"}, + { /([^ ])\$/, "\\1 dollar sign "}, + { /\(/, " open paren "}, + { /\)/, " close paren "}, + { /\{/, " open curly bracket "}, + { /\}/, " close curly bracket "}, + { /\[/, " open square bracket "}, + { /\]/, " close square bracket "}, + { /0/, " zero " }, + { /1/, " one " }, + { /2/, " two " }, + { /3/, " three " }, + { /4/, " four " }, + { /5/, " five " }, + { /6/, " six " }, + { /7/, " seven " }, + { /8/, " eight " }, + { /9/, " nine " }, + { /rrr.+/, "rr" }, + }.each do | subtuple | + text = text.gsub( subtuple[0], subtuple[1] ) + end + {% if flag?(:windows) %} + t2sipc.send( { voice, "#{namesub} #{text}" } ) + {% else %} + t2smsg( config, "#{voice} #{namesub} #{text}" ) + {% end %} + return( voice ) + else + return( nil ) + end +end + +spawn name: "command_dispatch" do + fiberipc.send( Fiber.current ) + loop do + while commandmsg : Tuple( String, FastIRC::Message ) = commandircipc.receive + spawn do + service = String.new + local_commandmsg = commandmsg + if local_commandmsg.is_a?( Tuple( String, FastIRC::Message ) ) && local_commandmsg[0].is_a?( String ) + service = local_commandmsg[0] + message : FastIRC::Message = local_commandmsg[1] + ircchannel = message.params[0] + unless service.empty? + if config.bungmobott_listen && ! ircchannel.empty? + # Do we send to the bungmobott_listen fiber? Probably no need. + #bbssrvipc.send( "#{message.to_s}" ) + if channelsubs[ { service, ircchannel } ]? + channelsubs[ { service, ircchannel } ].each do |channelsub| + if channelsub.is_a? OpenSSL::SSL::Socket::Server + channelsub.puts( "msg twitch #{message.to_s}" ) + # don't think this is ever useful + #else # client.is_a? Channel( Tuple( String, FastIRC::Message ) ) + # client.send( { "twitch", message } ) + end + end + end + end + + next unless ( userlogreturn = userlog( twitchipc, config, message ) ) + chatuser, uid, userdir, lastseen = userlogreturn + if config.voice_list + if ( t2sreturn = t2s( t2sipc, config, userdir, chatuser, message.params[1] ) ) + lastvoice.insert( 0, t2sreturn ) + lastvoice = lastvoice[0..4] + end + else + STDERR.puts "WARNING: config.voice_list empty" + end + # Have we seen this user lately? + if ( ( Time.utc.to_unix - lastseen ) >= 14400 ) + twitchipc.send( { "get_user", uid.to_s } ) + twitchipc.send( { "get_followers", uid.to_s } ) + prevnames = Array( String ).new + if ( prevnames = Dir.children( config.statedir + "/uids/" + uid + "/names" ) ) && ( prevnames.size > 1 ) + prevnames.delete( chatuser ) + puts "\033[38;5;14m#{chatuser} previous names: #{prevnames.join(", ")}\033[0m" + end + # play random fanfare if available. + {% if flag?(:windows) %} + # This file hierarchy gets manually set up for now. + # Maybe someday let mods do something like: + # !fanfare add pipne https://pip.ne/sniff.mp3 + if File.exists?( userdir + "/fanfare/" ) + playaudiofile( userdir + "/fanfare/" + Dir.children( userdir + "/fanfare/" ).sample ) + end + {% else %} + t2smsg( config, "fanfare #{uid} #{chatuser}" ) + {% end %} + end + # FIXME: Generalize this across Twitch/IRC? Mods are +o? vips are +v? + # FIXME: Add configuration interface for this + own = ( message.tags["room-id"] == message.tags["user-id"] ) + vip = ( message.tags["badges"].to_s.matches?( /(^|,)vip\// ) ) + mod = ( message.tags["mod"] == "1" ) + sub = ( message.tags["subscriber"] == "1" ) + # Emote-triggered effects: + # [ { 301501910, "farts" }, + # { 322820, "explosions" } ].each do | fx | + # emoteid = fx[0] + # fxname = fx[1] + # if ( message.tags["emotes"]? ) && + # ( message.tags["emotes"] ) && + # ( fxemotes = message.tags["emotes"].not_nil!.split("/").select( /^#{emoteid}[:_]/ ).join(",").split(",") ) && + # ( ! fxemotes[0].empty? ) && + # ( effects.values.select(/^#{fxname}/).size > 0 ) + # effects.values.select(/^#{fxname}/).sample( fxemotes.size ).each do | filepath | + # obstemporarymediacreate( obs, "meta-foreground", filepath.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{filepath}" ) + # end + # end + # end + config.commands && config.commands.not_nil!.each do | commandregex, command | # Hash( String, Array ) + if Regex.new( commandregex ).match( message.params[1] ) + command.each do |exec| # Array + if ( + ( own || ( exec.perm && exec.perm.not_nil!.includes?("any") ) ) || + ( 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") ) ) + ) + if obs + # FIXME: better validate args + ( exec.func == "obs_random_source_enable" ) && obsrandommediaenable( obs, exec.arg.not_nil![0].not_nil! ) && next + # ( exec.func == "obs_stats" ) && obs_stats( obs ) + end + if text2speech + if ( exec.func == "text_to_speech" ) + if ( t2sreturn = t2s( t2sipc, config, userdir, chatuser, message.params[1] ) ) + lastvoice.insert( 0, t2sreturn ) + lastvoice = lastvoice[0..4] + end + next + end + end + # FIXME: add gamesurge support + ( exec.func == "say" ) && say( twitchipc, config.chat_user.not_nil!.twitch.not_nil!, exec.arg.not_nil![0].not_nil! ) && next + STDERR.puts "WARNING: unhandled function for /#{commandregex}/: #{exec.func}" + else + STDOUT.print "DENIED: " + ppe command + end + end + end + end + end + else # String + case local_commandmsg + when "testchannelsubs" + pp channelsubs + end + end +# next unless ( ( match = message.params[1].match(/^ *!([A-Za-z]+) (([a-zA-Z0-9= _\:,.&'\/?;\\\(\)\[\]+\-]|!)+)/) || message.params[1].match(/^ *!([A-Za-z]+)/) ) ) +# cmd = match[1] +# if ( ( cmd =~ /^(substitute|voicesub)$/ ) && ( mod || sub ) ) +# case message.params[1].split( " " ).size +# when 1 +# if File.exists?( userdir + "/voicesub" ) +# bot.message( "##{settings["channel"]}", "| Current name substitution is \"#{File.read( userdir + "/voicesub" )}\"." ) +# else +# bot.message( "##{settings["channel"]}", "| Current name substitution is disabled." ) +# end +# else +# if match[2]? +# voicesub = match[2].downcase +# if voicesub =~ /^(disabled|null|disable|none)$/ +# if File.exists?( userdir + "/voicesub" ) +# File.delete( userdir + "/voicesub" ) +# bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is now disabled." ) +# else +# bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is already disabled." ) +# end +# else +# Dir.mkdir_p( userdir ) +# File.write( userdir + "/voicesub", voicesub ) +# bot.message( "##{settings["channel"]}", "| Name substitution for #{chatuser} is now \"#{File.read( userdir + "/voicesub" )}\"." ) +# end +# end +# end +# elsif ( ( cmd == "lastvoice" ) && ( mod || sub ) ) +# unless lastvoice.empty? +# bot.message( "##{settings["channel"]}", "| Last voices were " + lastvoice.join( ", " ) ) +# else +# bot.message( "##{settings["channel"]}", "| No voices used so far." ) +# end +# elsif ( ( cmd == "regeneratevoicelist" ) && ( own ) ) +# voices = regeneratevoicelist( settings, aws, gcloud ) +# ircipc.send( { "##{settings["channel"]}", "| Regenerated voicelist." } ) +# elsif ( ( cmd =~ /^(setvoice|voice)$/ ) && ( mod || sub ) ) +# case message.params[1].split( " " ).size +# when 1 +# namesub, voice_setting, voice_output = getvoice( config.voice_list_file, userdir, chatuser ) +# bot.message( "##{settings["channel"]}", "| Current voice is #{voice_setting}" ) +# when 2 +# if match[2]? +# voice = match[2].downcase +# if voice =~ /disabled|null|disable|none|random/ +# if File.exists?( userdir + "/voice" ) +# File.delete( userdir + "/voice" ) +# bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is now random." ) +# else +# bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is already random." ) +# end +# elsif voices.has_key?( voice ) +# csvoice = voices[voice] +# Dir.mkdir_p( userdir ) +# File.write( userdir + "/voice", csvoice ) +# pp userdir +# bot.message( "##{settings["channel"]}", "| Voice for #{chatuser} is now #{File.read( userdir + "/voice" )}." ) +# else +# pp ( match ) +# bot.message( "##{settings["channel"]}", "| Invalid voice. To see list, use !voices" ) +# end +# end +# when 3 +# else +# end +# elsif ( cmd =~ /^uptime$/ ) +# stats = obs.stats.to_h +# ostatus = obs.outputs["adv_stream"].status.to_h +# ircipc.send( { "##{settings["channel"]}", "| #{ostatus["outputTimecode"].to_s[0..7]} #{stats["activeFps"].to_s[0,2]}fps Usage: #{stats["cpuUsage"].as(Float64).to_i64}% #{stats["memoryUsage"].as(Float64).to_i64}MiB Frame losses: #{stats["outputSkippedFrames"].as(Int64)} #{stats["renderSkippedFrames"].as(Int64)}" } ) +# # !newimage [name] [url] (random|top|bottom|left|right|center) +# # download url, verify mimetype with libmagic, duplicate existing medialoop source, change "file" SourceSetting +# elsif ( ( cmd =~ /^inputsetting(|s)$/ ) && ( mod || own || vip ) ) +# if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) +# ircipc.send( { "##{settings["channel"]}", "| #{obs.inputs[match[2]].settings.to_h.to_s} " } ) +# end +# elsif ( ( cmd =~ /^input(|s)$/ ) && ( mod || own || vip ) ) +# ircipc.send( { "##{settings["channel"]}", "| inputs: #{obs.inputs.to_h.keys.join(" ")}" } ) +# elsif ( ( cmd =~ /^scene(|s)$/ ) && ( mod || own || vip ) ) +# if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) +# obs.scenes[match[2]].program! +# else +# ircipc.send( { "##{settings["channel"]}", "| scenes: #{obs.scenes.to_h.keys.join(" ")}" } ) +# end +# elsif ( ( cmd =~ /^source(|s)$/ ) && ( mod || own || vip ) ) +# request = Hash( String, String | Bool ).new +# if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) +# obs.scenes.current.metascene[match[2]][0].toggle! +# # in studio mode, direct Scene->SceneItem toggles require a transition +# obs.scenes.current.preview! +# obs.transition! +# else +# ircipc.send( { "##{settings["channel"]}", "| current sources: #{obs.scenes.current.metascene.keys.join(" ")}" } ) +# end +# elsif ( ( cmd =~ /^filter(|s)$/ ) && ( mod || own ) ) +# if ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+)/ ) ) +# obs.sources.to_h[filterargs[1]].filters[filterargs[2]].toggle! +# elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) +# ircipc.send( { "##{settings["channel"]}", "| #{match[2]} filters: #{obs.sources.to_h[match[2]].filters.to_h.keys.join(" ")}" } ) +# else +# ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument, and optionally one filter name to toggle on or off." } ) +# end +# elsif ( cmd == "create" && ( mod || own || vip ) ) +# if ( match[2]? ) && ( match[2] =~ /^([\/a-zA-Z0-9-_]+)/ ) +# obstemporarymediacreate( obs, "meta-foreground", match[2], "C:/cygwin64/home/user/effects/#{match[2]}.webm" ) +# else +# ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument." } ) +# end +# elsif ( cmd =~ /^(metaminute|youdied)$/ ) +# obsrandommediaenable( obs, "media-#{cmd}" ) +# elsif ( cmd =~ /^fart(s|)$/ ) && ( sub || mod || own || vip ) +# if effects.values.select( /^farts/ ).size > 0 +# if ( match[2]? ) && ( match[2].match( /^([0-9]+)/ ) && ( match[2].to_i > 1 ) && ( match[2].to_i <= 32 ) ) +# count = match[2].to_i +# else +# count = 1 +# end +# effects.values.select( /^farts/ ).sample( count ).each do | effect | +# obstemporarymediacreate( obs, "meta-foreground", effect.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{effect}" ) +# end +# else +# puts "farts undefined" +# end +# elsif ( cmd =~ /^explosion(s|)$/ ) && ( sub || mod || own || vip ) +# if effects.values.select( /^explosions/ ).size > 0 +# if ( match[2]? ) && ( match[2].match( /^([0-9]+)/ ) && ( match[2].to_i > 1 ) && ( match[2].to_i <= 32 ) ) +# count = match[2].to_i +# else +# count = 1 +# end +# effects.values.select( /^explosions/ ).sample( count ).each do | effect | +# obstemporarymediacreate( obs, "meta-foreground", effect.gsub(/[\/ ]/, '_').downcase, "C:/cygwin64/home/user/effects/#{effect}" ) +# end +# else +# puts "explosions undefined" +# end +# # FIXME: This is only half-implemented +# elsif ( ( cmd =~ /^(user)$/ ) && ( mod || own ) ) +# if match[2]? && match[2] =~ /[a-z][a-z0-9_]+/ +# twitchipc.send( { "get_user", match[2] } ) +# elsif match[2]? && match[2] =~ /[0-9]+/ +# twitchipc.send( { "get_user", match[2].to_u64 } ) +# else +# twitchipc.send( { "get_user", settings["channel_id"].to_u64 } ) +# end +# elsif ( ( cmd =~ /^(status|title)$/ ) && ( mod || own ) ) +# if match[2]? +# client.put_channel!( settings["channel_id"].to_u64, title: match[2] ) +# json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) +# ircipc.send( { "##{settings["channel"]}", "Title is now \"#{ json["data"][0]["title"] }\""} ) +# else +# json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) +# ircipc.send( { "##{settings["channel"]}", "| Title is currently \"#{ json["data"][0]["title"] }\""} ) +# end +# elsif ( ( cmd =~ /^(game|category)$/ ) && ( mod || own ) ) +# if match[2]? +# puts "2 matches" +# client.put_channel!( settings["channel_id"].to_u64, game: match[2] ) +# json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) +# ircipc.send( { "##{settings["channel"]}", "Game is now \"#{ json["data"][0]["game_name"] }\""} ) +# else +# puts "1 matches" +# json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) +# ircipc.send( { "##{settings["channel"]}", "| Game is currently \"#{ json["data"][0]["game_name"] }\""} ) +# end +# elsif ( ( cmd == "urban" ) && ( mod || own || sub || vip ) ) +# if match[2]? && match[2] =~ /^([a-zA-Z0-9 -])+$/ +# definition = urbandef( match[2] ) +# ircipc.send( { "##{settings["channel"]}", definition[0,400] } ) +# t2s( t2sipc, settings, userdir, chatuser, definition[0,400] ) +# else +# ircipc.send( { "##{settings["channel"]}", "| Urban Dictionary search term should consist of letters, numbers, spaces, and/or hyphens." } ) +# end +# elsif ( cmd == "matrix" ) +# effectsmsg( settings, "overlay glmatrix" ) +# elsif ( cmd =~ /juggle|juggler/ ) +# effectsmsg( settings, "overlay juggler3d" ) +# elsif ( cmd =~ /fireworks|firework/ ) +# effectsmsg( settings, "overlay fireworkx" ) +# elsif ( cmd == "pipes" ) +# if match[2]? && match[2] =~ /^(fast|faster)$/ +# effectsmsg( settings, "overlay pipes " + match[2] ) +# else +# effectsmsg( settings, "overlay pipes" ) +# end +# elsif ( cmd == "jellyfish" ) +# effectsmsg( settings, "overlay hydrostat" ) +# elsif ( cmd == "gluten" ) +# effectsmsg( settings, "overlay flyingtoasters" ) +# elsif ( cmd =~ /^(glsnake|glmatrix|gibson|xmatrix|flyingtoasters|moebiusgears|fireworkx|hydrostat|hypertorus|jigsaw|juggler3d|kaleidocycle|kumppa|molecule|noof|polyhedra)$/ ) +# effectsmsg( settings, "overlay " + cmd ) +# elsif ( cmd =~ /gltext|cowsay|xcowsay|cowfuscious/ ) +# ( cmd == "cowfuscious" ) && ( cmd = "xcowsay" ) +# if ( match[2]? ) && ( gltextargs = match[2].match( /^([0-9]+) +(.+)$/ ) ) +# seconds=UInt64.new( 1 ) +# seconds=gltextargs[1].to_u64 +# if ( own || mod || vip ) +# effectsmsg( settings, "overlay #{cmd} #{match[2]}" ) +# puts "matched #{cmd} #{match[2]}" +# elsif ( sub ) +# if ( seconds > 20 ) +# seconds=20 +# end +# effectsmsg( settings, "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) +# puts "matched #{cmd} #{seconds} #{gltextargs[2]}" +# else +# if ( seconds > 5 ) +# seconds=5 +# end +# effectsmsg( settings, "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) +# puts "matched #{cmd} #{seconds} #{gltextargs[2]}" +# end +# elsif match[2]? && match[2] =~ /^.+$/ +# effectsmsg( settings, "overlay #{cmd} #{match[2]}" ) +# puts "matched #{cmd} #{match[2]}" +# else +# effectsmsg( settings, "overlay #{cmd}" ) +# puts "failed to match gltext" +# end +# elsif ( cmd =~ /^(cow(s|)|cowabunga|holycow$)/ ) +# if match[2]? && match[2] =~ /^([0-9])+$/ +# effectsmsg( settings, "overlay bouncingcow #{match[2]}" ) +# else +# effectsmsg( settings, "overlay bouncingcow" ) +# end +# elsif ( cmd == "overlay" ) +# if match[2]? && match[2] =~ /^[a-z]+$/ +# ircipc.send( { "##{settings["channel"]}", "| overlay requires an argument consisting wholly of lower case characters."} ) +# effectsmsg( settings, "overlay #{match[2]}" ) +# end +# elsif ( cmd =~ /^(shout|shoutout)$/ ) +# if match[2]? && match[2] =~ /^[a-z]+$/ +# ircipc.send( { "##{settings["channel"]}", "| Go check out twitch.tv/#{match[2]}"} ) +# effectsmsg( settings, "overlay gltext 5 Go follow #{match[2]}" ) +# else +# ircipc.send( { "##{settings["channel"]}", "| Missing argument."} ) +# end +# elsif ( cmd =~ /^(system|dxdiag|computer)$/ ) && ( Crystal::DESCRIPTION =~ /linux/ ) +# ircipc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/tmp/DxDiag.txt" } ) +# elsif ( cmd == "hackerman" ) +# ircipc.send( { "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/img/hackerman.jpg" } ) +# elsif ( cmd =~ /^(songrequest|sr)$/ ) && match[2]? +# puts ("song detected: #{match[2]}") +# if ( ( match[2] =~ /list=/ ) && ( match[2] !~ /v=/ ) ) +# ircipc.send( { "##{settings["channel"]}", "| Lists are not accepted.\n" } ) +# elsif Process.run( "sraddsong.sh", {match[2]} ) +# m = MPD::Client.new +# currentsong = m.currentsong +# if ( currentsong ) && ( currentsong["file"].to_s == "http://music/music.ogg" ) +# m.next +# end +# if ( status = m.status ) && ( playlistinfo = m.playlistinfo ) +# ircipc.send( { "##{settings["channel"]}", "| " + playlistinfo[ status["playlistlength"].to_i - 1 ]["file"].to_s + " added in position " + status["playlistlength"].to_s } ) +# else +# ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) +# end +# m.disconnect +# else +# end +# elsif ( cmd =~ /^current(|song)$/ ) +# m = MPD::Client.new +# if ( currentsong = m.currentsong ) +# ircipc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) +# else +# ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) +# end +# m.disconnect +# elsif ( cmd =~ /^seek$/ ) +# if ( ( match[2] ) && ( match[2] =~ /([+-]|)[0-9]/ ) ) +# m = MPD::Client.new +# m.seekcur( match[2] ) +# if ( ( musicstatus = m.status ) && ( pos = musicstatus["elapsed"].to_f.to_u64 ) ) +# min=( pos / 60 ).to_u64 +# sec=( pos % 60 ).to_u64 +# ircipc.send( { "##{settings["channel"]}", "| " + sprintf( "2%d:2%d", min, sec ) } ) +# else +# ircipc.send( { "##{settings["channel"]}", "| An error occurred. " } ) +# end +# m.disconnect +# else +# ircipc.send( { "##{settings["channel"]}", "| Seek requires an argument of an absolute position in integer number of seconds, or a relative position in in signed integer number of seconds such as +60" } ) +# end +# elsif ( cmd =~ /^next(|song)$/ ) +# m = MPD::Client.new +# m.next +# if ( status = m.status ) && ( status["playlistlength"].to_i > 0 ) +# if ( currentsong = m.nextsong ) +# ircipc.send( { "##{settings["channel"]}", "| Currently playing: " + currentsong["file"].to_s } ) +# else +# ircipc.send( { "##{settings["channel"]}", "| A failure occured." } ) +# end +# else +# ircipc.send( { "##{settings["channel"]}", "| Playlist is now empty." } ) +# end +# m.disconnect +# elsif ( cmd == "followage" ) +# if match[2]? +# args = match[2].split(/\s/) +# if args[1]? +# json = JSON.parse( client.get_user_follows( from: client.user_id( args[0] ).to_u64 , to: client.user_id( args[1] ).to_u64 ) ) +# puts client.user_id( args[0] ).to_s +# puts client.user_id( args[1] ).to_s +# puts json +# ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) +# elsif args[0]? +# json = JSON.parse( client.get_user_follows( from: client.user_id( args[0] ).to_u64 , to: settings["channel_id"].to_u64 ) ) +# puts client.user_id( args[0] ).to_s +# puts json +# ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) +# end +# else +# json = JSON.parse( client.get_user_follows( from: uid.to_u64 , to: settings["channel_id"].to_u64 ) ) +# puts json +# ircipc.send( { "##{settings["channel"]}", "| " + json["data"][0]["followed_at"].to_s } ) +# end +# end + rescue ex + puts ex + puts ex.backtrace + if service == "twitch" + say( twitchipc, config.chat_user.not_nil!.twitch.not_nil!, "An error occurred! " + ex.message.to_s ) + elsif service == "gamesurge" + say( ircipc, config.chat_user.not_nil!.gamesurge.not_nil!, "An error occurred! " + ex.message.to_s ) + else + puts "...also, the chat service is undefined." + end + end + + end + end +ensure + waitgroup.send( Fiber.current.name.not_nil! ) +end +fiber = fiberipc.receive +fibers[fiber.name.not_nil!] = fiber + + +# Put tts stuff into the same thread so each playback blocks the next +spawn name: "text2speech" do + fiberipc.send( Fiber.current ) + loop do + begin + while t2stuple = t2sipc.receive + voice, text = [ *t2stuple ] + if ( match = voice.match( /^Microsoft-([A-Za-z]+)/ ) ) + if ( 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 + elsif gcloud && ( match = voice.match( /^([a-zA-Z]{2,3}-[a-zA-Z]{2})/ ) ) # Google cloud voice + request = Hash( String, Hash( String, String ) ){ + "input" => { "text" => text }, + "audioConfig" => { "audioEncoding" => "MP3" }, + "voice" => { + "name" => voice, + "languageCode" => match[1], + }, + } + 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=#{secrets.gcloud_token}", headers, request.to_json, tls: ssl_context ) + + response.body + + filepath="#{config.tempdir}#{Time.utc.to_unix_ms}.mp3" + json=JSON.parse(response.body) + File.write( filepath, Base64.decode_string( json["audioContent"].as_s ) ) + playaudiofile( filepath ) + File.delete( filepath ) + elsif aws # AWS polly voices + filepath="#{config.tempdir}#{Time.utc.to_unix_ms}.mp3" + p = Process.new( + "aws.exe", [ + "polly", "synthesize-speech", + "--output-format", "mp3", + "--voice-id", voice, + "--text", text, + filepath + ], output: STDOUT, error: STDERR + ) + p.wait + playaudiofile( filepath ) + File.delete( filepath ) + else # unknown + STDERR.puts "Voice not recognized or available." + end + end + rescue ex + puts ex + end + end +ensure + waitgroup.send( Fiber.current.name.not_nil! ) +end +fiber = fiberipc.receive +fibers[fiber.name.not_nil!] = fiber + -ircipc = Channel( Tuple( String, String ) ).new -t2sipc = Channel( Tuple( String, String ) ).new -twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new # Twitch API request handling thread #spawn do @@ -271,8 +1133,9 @@ twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new # if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.join_channels.not_nil!.twitch ) - # IRC thread - spawn do + # Twitch::IRC thread + spawn name: "Twitch::IRC" do + fiberipc.send( Fiber.current ) loop do begin bot = Twitch::IRC::Client.new( nick: config.chat_user.not_nil!.twitch.not_nil!, token: "oauth:" + secrets.twitch_access_token.not_nil!, log_mode: true ) @@ -282,7 +1145,7 @@ if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.j # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters." # PRIVMSG #channel message\r\n spawn do - while tuple = ircipc.receive # why does this need to be a tuple? + while tuple = twitchipc.receive # why does this need to be a tuple? sizelimit=( 512 - ( tuple[0].size + 12 ) ) if ( tuple[0] == "JOIN" ) if ( tuple[0] =~ regextwitchuser ) @@ -299,7 +1162,7 @@ if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.j # Create a handler to process incoming messages bot.on_message do |message| spawn do - #Message( + #FastIRC::Message( # @tags={ # "badge-info" => "", # "badges" => "moderator/1,bits/100", @@ -330,11 +1193,17 @@ if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.j # ] #) - channelsubs[{ "twitch", message.params[0] }].each do | client | - if ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) ) # && ( uid = message.tags["user-id"]? ) - client.puts "msg twitch #{message.params[0]} #{chatuser} #{message.params[1]}" - end - end +# channelsubs[{ "twitch", message.params[0] }].each do | client | +# if ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) ) # && ( uid = message.tags["user-id"]? ) +# if client.is_a? OpenSSL::SSL::Socket::Server +# client.puts( "msg twitch #{message.params[0]} #{chatuser} #{message.params[1]}" ) +# else # client.is_a? Channel( Tuple( String, FastIRC::Message ) ) +# client.send( { "twitch", message } ) +# end +# end +# end + + commandircipc.send( { "twitch", message } ) pp message pp message.params own = ( message.tags["room-id"] == message.tags["user-id"] ) @@ -343,13 +1212,13 @@ if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.j sub = ( message.tags["subscriber"] == "1" ) rescue ex puts ex - #ircipc.send( { "##{config.channel}", "An error occurred! " + ex.message.to_s } ) + #twitchipc.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! + #rooms = config.join_channels.not_nil!.twitch.not_nil! # Connect to Twitch bot.run( rooms.map{ | room | room.sub( /^#/, "") } ) @@ -366,12 +1235,65 @@ if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.j exit 1 end end + ensure + waitgroup.send( Fiber.current.name.not_nil! ) end + fiber = fiberipc.receive + fibers[fiber.name.not_nil!] = fiber end -if config.listen - begin - ip, port = config.listen.not_nil!.split(":") +# BungmoBott::Socket client thread +if config.bungmobott_connect + bbscli_host, bbscli_port = config.bungmobott_connect.not_nil!.split(":") + puts "spawning BungmoBott::Socket client" + spawn name: "BungmoBott::Socket client" do + loop do + puts "looping BungmoBott::Socket client" + user = config.chat_user.not_nil!.twitch.not_nil! + bungmobott_key = secrets.bungmobott_key.not_nil! + fiberipc.send( Fiber.current ) + socket = TCPSocket.new( bbscli_host, bbscli_port.to_u16 ) + context = OpenSSL::SSL::Context::Client.new + ssl_socket = OpenSSL::SSL::Socket::Client.new( socket, context ) + ssl_socket.sync = true + negotiated = false + spawn do + while input = bbscliipc.receive + # ssl_socket gets redefined in the event of I/O errors, so we deal with it here. + #if ( climsg = input.match( /^irc +twitch +JOIN \#+([a-zA-Z0-9_]+) *$/ ) ) + #end + ssl_socket.puts( input ) + end + end + ssl_socket.puts( "auth #{user} #{bungmobott_key}" ) + while message = ssl_socket.gets + puts "BungmoBott::Socket cli recv: " + message.gsub( bungmobott_key, "CENSORED" ) + case message + when /^error/ + raise Exception.new("BungmoBott::Socket Error: #{message}") + when /^authed/ + negotiated = true + ssl_socket.puts( "say twitch #{user} test" ) + when /^msg twitch$/ + commandircipc.send( { "twitch", FastIRC.parse_line( message.split(" ")[2..].join(" ") ) } ) + end + end + end + rescue ex + pp ex + ensure + waitgroup.send( Fiber.current.name.not_nil! ) + sleep 2 + end + fiber = fiberipc.receive + fibers[fiber.name.not_nil!] = fiber +end + +# BungmoBott::Socket thread +if config.bungmobott_listen + spawn name: "BungmoBott::Socket server" do + fiberipc.send( Fiber.current ) + ip, port = config.bungmobott_listen.not_nil!.split(":") tcp_server = TCPServer.new( ip, port.to_i ) ssl_context = OpenSSL::SSL::Context::Server.new ssl_context.private_key=(configdir + "/privkey.pem" ) @@ -385,6 +1307,7 @@ if config.listen # bungmobott protocol server #while tcp_server.accept? do | clientsocket | + while clientsocket = tcp_server.accept? spawn do client = OpenSSL::SSL::Socket::Server.new(clientsocket, ssl_context) @@ -396,20 +1319,26 @@ if config.listen connections[client]["authed"] = "false" while message = client.gets - puts message - client.puts message + 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}) (#{regexuser})$/i ) ) - ircservice = match[1] + if ( match = message.match( /^irc (#{regexservice}) JOIN \#(#{regexuser}) *$/i ) ) + ircservice = match[2] # regexservice has some parens, too ircchannel = match[3] - client.puts "joining #{ircservice} #{ircchannel}" - ircipc.send( { "JOIN", ircchannel } ) - unless channelsubs[ { ircservice, ircchannel } ]? - channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new + if ircservice == "twitch" + client.puts "joining #{ircservice} \##{ircchannel}" + twitchipc.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 - channelsubs[ { ircservice, "#" + ircchannel } ].push( client ) - pp channelsubs elsif ( match = message.match( /^awst2s (#{regexvoice}) (.+)/i ) ) # FIXME # powershell doesn't take bytestreams; maybe use http file hosting for this? elsif ( match = message.match( /^gcst2s (#{regexvoice}) (.+)/i ) ) @@ -418,12 +1347,15 @@ if config.listen client.puts "awsvoicelist " + generatevoicelistaws().join(" ") elsif ( match = message.match( /^gcsvoicelist$/i ) ) client.puts "gcsvoicelist " + generatevoicelistgcs( secrets.gcloud_token ).join(" ") + elsif ( match = message.match( /^say twitch (.+)/i ) ) + say( twitchipc, connections[client]["user"], match[1] ) + elsif ( message =~ /testchannelsubs/ ) + #commandircipc.send( "testchannelsubs" ) end else # auth if ( match = message.match( /^auth (#{regexuser}) (#{regexb64})$/i ) ) - pp match remoteuser = match[1] remotekey = match[2] if File.exists?( config.statedir + "apikeys/" + remoteuser ) @@ -431,6 +1363,7 @@ if config.listen puts "comparing #{localkey} to #{remotekey}" if ( localkey == remotekey ) connections[client]["authed"] = "true" + puts "authed #{ remoteuser }" client.puts "authed #{ remoteuser }" connections[client]["user"] = remoteuser connections[client]["key"] = remotekey @@ -463,6 +1396,45 @@ if config.listen end rescue ex puts ex + ensure + waitgroup.send( Fiber.current.name.not_nil! ) end + fiber = fiberipc.receive + fibers[fiber.name.not_nil!] = fiber +end + +if config.join_channels && config.join_channels.not_nil!.twitch + spawn name: "join_channels" do + fiberipc.send( Fiber.current ) + if fibers["Twitch::IRC"]? + ircservice = "twitch" + config.join_channels.not_nil!.twitch.not_nil!.each do |ircchannel| + twitchipc.send( { "JOIN", ircchannel } ) + unless channelsubs[ { ircservice, ircchannel } ]? + channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new + # Do we ever care about this? + #channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server | Channel( Tuple( String, FastIRC::Message ) ) ).new + end + end + elsif fibers["BungmoBott::Socket client"]? + ircservice = "twitch" + config.join_channels.not_nil!.twitch.not_nil!.each do |ircchannel| + bbscliipc.send( "irc twitch JOIN \#" + ircchannel ) + end + end + ensure + waitgroup.send( Fiber.current.name.not_nil! ) + end + fiber = fiberipc.receive + fibers[fiber.name.not_nil!] = fiber +end + +puts "Spawned fibers:" +pp fibers + +fibers.size.times do + fiber = waitgroup.receive + fibers.delete( fiber ) + puts "Fiber ended: " + fiber end |