summaryrefslogtreecommitdiff
path: root/crystal/bungmobott-old.cr
diff options
context:
space:
mode:
authorJoe Rayhawk <jrayhawk@fairlystable.org>2024-02-27 03:22:55 -0800
committerJoe Rayhawk <jrayhawk@fairlystable.org>2024-02-27 03:22:55 -0800
commit061b5e956e422d585f8733e8079c60a625c6d27f (patch)
treed19dee66731ca7bfcb79ff11ce10c3976190d139 /crystal/bungmobott-old.cr
parent545d60e306e29b39a58f4467b799d55f1b82b854 (diff)
downloadtwitchtools-061b5e956e422d585f8733e8079c60a625c6d27f.tar.gz
twitchtools-061b5e956e422d585f8733e8079c60a625c6d27f.zip
crystal/bungmobott: deprecate old codebase
Diffstat (limited to 'crystal/bungmobott-old.cr')
-rwxr-xr-xcrystal/bungmobott-old.cr991
1 files changed, 991 insertions, 0 deletions
diff --git a/crystal/bungmobott-old.cr b/crystal/bungmobott-old.cr
new file mode 100755
index 0000000..642d0b3
--- /dev/null
+++ b/crystal/bungmobott-old.cr
@@ -0,0 +1,991 @@
+require "twitch/irc"
+require "http"
+require "uri"
+require "twitcr"
+require "json"
+require "crystal_mpd"
+require "obswebsocket"
+
+STDOUT.sync = true
+STDOUT.flush_on_newline = true
+
+struct Nil
+ def as_s?
+ self
+ end
+end
+
+settings = Hash(String, String).new
+
+settings["configdir"] = Path.home./("/.config/bungmobott/").to_s
+settings["statedir"] = Path.home./("/.local/state/bungmobott/").to_s
+
+if ENV["LOCALAPPDATA"]?
+ settings["configdir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\" ).to_s
+ settings["statedir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\state\\" ).to_s
+end
+
+settings["tempdir"] = "/tmp/bungmobott/"
+ENV["TEMP"]? && ( settings["tempdir"] = "#{ENV["TEMP"]}\\bungmobott\\" )
+
+ENV["XDG_CONFIG_HOME"]? && ( settings["configdir"] = ENV["XDG_CONFIG_HOME"] + "/bungmobott/" )
+#ENV["XDG_DATA_HOME"]? && ( settings["datadir"] = ENV["XDG_DATA_HOME"] ) # unused?
+ENV["XDG_STATE_HOME"]? && ( settings["statedir"] = ENV["XDG_STATE_HOME"] + "/bungmobott/" )
+
+Dir.mkdir_p( settings["configdir"] )
+Dir.mkdir_p( settings["statedir"] )
+Dir.mkdir_p( settings["tempdir"] )
+
+settings["home"] = Path.home.to_s
+regextwitchuser = /^[0-9a-zA-Z_]+$/
+
+error = false
+[ "access_token", "client_id" ].each do |key|
+ begin
+ settings[key] = File.read( settings["configdir"] + key ).chomp
+ rescue IO::Error
+ STDERR.puts "ERROR: Missing " + settings["configdir"] + key
+ error = true
+ end
+end
+
+# enable gcloud?
+if File.exists?( settings["configdir"] + "gcloud_token" ) && ( settings["gcloud_token"] = File.read( settings["configdir"] + "gcloud_token" ).chomp )
+ gcloud = true
+else
+ gcloud = false
+ STDERR.puts "Warning: #{settings["configdir"]}gcloud_token is missing; GCS voices disabled."
+end
+
+# enable aws?
+if ! File.exists?( Path.home./("/.aws/credentials") )
+ STDERR.puts "Warning: #{Path.home}/.aws/credentials is missing; AWS voices disabled."
+ aws = false
+elsif ! Process.find_executable( "aws.exe" )
+ STDERR.puts "Warning: aws.exe is missing; AWS voices disabled."
+ aws = false
+else
+ aws = true
+end
+
+# enable effects?
+effects = Hash( String, String ).new
+if File.exists?( settings["configdir"] + "/effects.txt" )
+ File.each_line( settings["configdir"] + "/effects.txt" ) do |line|
+ effects[ line.downcase ] = line
+ end
+end
+
+client = Twitcr::Client.new( settings )
+
+# derive channel_id from channel or vice versa
+[ "channel", "channel_id" ].each do |key|
+ File.exists?( settings["configdir"] + key ) && ( settings[key] = File.read( settings["configdir"] + key ).chomp )
+end
+if ( settings["channel"]? =~ regextwitchuser ) && ( settings["channel_id"]? =~ /^[0-9]+$/ )
+elsif ! ( settings["channel"]? =~ regextwitchuser ) && ( settings["channel_id"]? =~ /^[0-9]+$/ )
+ settings["channel"] = client.user( settings["channel_id"].to_u64 ).login
+elsif ( settings["channel"]? =~ regextwitchuser ) && ! ( settings["channel_id"]? =~ /^[0-9]+$/ )
+ settings["channel_id"] = client.user( settings["channel"] ).id.to_s
+else
+ STDERR.puts "ERROR: Missing #{settings["configdir"]}channel and channel_id configuration keys."
+ error = true
+ # exit 2
+end
+if error == true
+ {% if flag?(:windows) %}
+ STDERR.puts "press enter to end program"
+ gets
+ {% end %}
+ exit 1
+end
+
+if File.exists?( settings["configdir"] + "chatuser" )
+ File.read( settings["configdir"] + "chat_user" ).chomp
+else
+ STDERR.puts "Warning: " + settings["configdir"] + "chat_user is missing; using configured channel instead."
+ settings["chat_user"] = settings["channel"]
+end
+
+# fiber communication channels
+ircipc = Channel( Tuple( String, String ) ).new
+t2sipc = Channel( Tuple( String, String ) ).new
+twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new
+
+obs = OBS::WebSocket.new( "ws://127.0.0.1:4455/" )
+
+# OBS event thread
+spawn do
+ evchan = Channel( JSON::Any ).new
+ 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"
+ ircipc.send( { "#" + settings["channel"], "| 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", UUID.random.to_s, 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/
+ ircipc.send( { "##{settings["channel"]}", "| 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"]
+ ircipc.send( { "##{settings["channel"]}", "| obs: source #{edata["sourceName"].as_s} filter #{edata["filterName"].as_s} visibility is currently #{edata["filterEnabled"].as_bool}" } )
+ end
+ end
+ Fiber.yield
+ end
+end
+
+# Twitch API request handling thread
+spawn do
+ loop do
+ begin
+ while twitchtuple = twitchipc.receive
+ cmd, arg = [ *twitchtuple ]
+ case cmd
+ when "get_user"
+ userinfo = JSON.parse( client.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( client.get_user_follows( to: arg.to_u64 ) )["total"].as_i64
+ if followers > 500
+ puts "\033[38;5;2m#{followers} followers\033[0m"
+ end
+ end
+ end
+ end
+ end
+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", UUID.random.to_s, JSON.parse( { "sceneName" => sname, "sceneItemId" => siid, "sceneItemTransform" => { "positionX" => goodtransform.to_h["positionX"], "positionY" => goodtransform.to_h["positionY"] } }.to_json ) ) )
+ end
+end
+
+def urbandef( term : String )
+ 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
+
+# Currently only used in flag?(:unix)
+def t2smsg( settings : Hash(String, String), msg : String)
+ if File.exists?( settings["statedir"] + "/.t2s.sock" )
+ sock = Socket.unix
+ sock.connect Socket::UNIXAddress.new( settings["statedir"] + "/.t2s.sock" )
+ sock.puts( msg )
+ sock.close
+ end
+rescue ex
+ puts ex
+end
+
+def effectsmsg( settings : Hash(String, String), msg : String )
+ if File.exists?( settings["statedir"] + "/.effects.sock" )
+ sock = Socket.unix
+ sock.connect Socket::UNIXAddress.new( settings["statedir"] + "/.effects.sock" )
+ sock.puts( msg )
+ sock.close
+ end
+rescue ex
+ puts ex
+end
+
+def userlog( ircipc : Channel, settings : Hash(String, String), message : FastIRC::Message )
+ unless ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) && ( uid = message.tags["user-id"]? ) )
+ return nil
+ end
+ basedir = settings["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( { "##{settings["channel"]}", "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] == "##{settings["channel"]}" )
+ return nil
+ end
+ return ( { chatuser, uid, userdir, lastseen } )
+rescue ex
+ puts ex
+end
+
+def getvoice( settings : Hash(String, 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( settings["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
+
+# text2speech
+def t2s( t2sipc : Channel, settings : Hash(String, String), userdir : String, chatuser : String, text : String )
+ if ( text !~ /^ *(!|\|)/ )
+ namesub, voice_setting, voice = getvoice( settings, 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( settings, "#{voice} #{namesub} #{text}" )
+ {% end %}
+ return( voice )
+ else
+ return( nil )
+ end
+end
+
+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 regeneratevoicelist( settings : Hash( String, String ), aws : Bool, gcloud : Bool )
+ voices = Hash(String, String).new
+ if aws
+ JSON.parse( `aws polly describe-voices` )["Voices"].as_a.each do | v |
+ voices[ v["Id"].as_s.downcase ] = v["Id"].as_s
+ end
+ end
+ if gcloud
+ ssl_context = OpenSSL::SSL::Context::Client.new
+ {% if flag?(:windows) %}
+ ssl_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE
+ {% end %}
+ headers = HTTP::Headers.new
+ headers["Content-Type"] = "application/json; charset=utf-8"
+
+ response = HTTP::Client.exec( "GET", "https://texttospeech.googleapis.com/v1/voices?key=#{settings["gcloud_token"]}", headers, nil, tls: ssl_context )
+
+ JSON.parse( response.body )["voices"].as_a.each do | v |
+ voices[ v["name"].as_s.downcase ] = v["name"].as_s
+ end
+ end
+ {% if flag?(:windows) %}
+ 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[ v.downcase ] = v
+ end
+ end
+ {% end %}
+
+ File.write( settings["configdir"] + "/voicelist.txt", voices.values.sort.join("\r\n") )
+ return voices
+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
+
+voices = Hash(String, String).new
+if File.exists?( settings["configdir"] + "/voicelist.txt" )
+ File.each_line( settings["configdir"] + "/voicelist.txt" ) do |line|
+ voices[ line.downcase ] = line
+ end
+else
+ voices = regeneratevoicelist( settings, aws, gcloud )
+end
+
+lastvoice = Array(String).new
+
+#streamstatus = Hash(String, Union( Bool, Float64, Int64, UInt64, String ) ).new
+streamstatus = Hash(String, JSON::Any).new
+outputstatus = Hash(String, JSON::Any).new
+
+# Put tts stuff into the same thread so each playback blocks the next
+spawn do
+ 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=#{settings["gcloud_token"]}", headers, request.to_json, tls: ssl_context )
+
+ response.body
+
+ filepath="#{settings["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="#{settings["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
+end
+
+# main thread: IRC
+loop do
+ begin
+
+ bot = Twitch::IRC::Client.new( nick: settings["chat_user"], token: "oauth:" + settings["access_token"], log_mode: true )
+ bot.tags = [ "membership", "tags", "commands" ]
+
+ # Outgoing IRC message thread
+ # "Most IRC servers limit messages to 512 bytes in length, including the trailing CR-LF characters."
+ # PRIVMSG #channel message\r\n
+ spawn do
+ while msgtuple = ircipc.receive # why does this need to be a tuple?
+ sizelimit=( 512 - ( msgtuple[0].size + 12 ) )
+ bot.message( msgtuple[0], msgtuple[1][0..sizelimit] ) # limit size
+ end
+ end
+
+ # Create a handler to process incoming messages
+ bot.on_message do |message|
+ spawn do
+ next unless ( userlogreturn = userlog( ircipc, settings, message ) )
+ chatuser, uid, userdir, lastseen = userlogreturn
+ if ( t2sreturn = t2s( t2sipc, settings, userdir, chatuser, message.params[1] ) )
+ lastvoice.insert( 0, t2sreturn )
+ lastvoice = lastvoice[0..4]
+ end
+ # Have we seen this user lately?
+ if ( ( Time.utc.to_unix - lastseen ) >= 14400 )
+ twitchipc.send( { "get_user", uid.to_u64 } )
+ twitchipc.send( { "get_followers", uid.to_u64 } )
+ prevnames = Array( String ).new
+ if ( prevnames = Dir.children( settings["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( settings, "fanfare #{uid} #{chatuser}" )
+ {% end %}
+ end
+ 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" )
+ [ { 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
+ case message.params[1]
+ when /pl(ease|z).+nerf/i
+ obsrandommediaenable( obs, "media-youdied-nerf" )
+ when /you.+died/i
+ obsrandommediaenable( obs, "media-youdied" )
+ when /did +nothing +wrong/i
+ obsrandommediaenable( obs, "media-youdied-tiggs" )
+ when /thanks.+obama/i
+ obsrandommediaenable( obs, "media-youdied-thanksobama" )
+ 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 =~ /^(commands|help)$/ )
+ bot.message( "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/txt/commands.txt" )
+ elsif ( cmd =~ /^(dexem)$/ )
+ ircipc.send( { "##{settings["channel"]}", "| You're doing great work, Dexem!" } )
+ 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 =~ /^(voices|voice(s|)list|listvoice(s|))$/ ) && ( mod || sub ) )
+ bot.message( "##{settings["channel"]}", "| https://bungmonkey.omgwallhack.org/voicelist.txt" )
+ elsif ( ( cmd =~ /^(setvoice|voice)$/ ) && ( mod || sub ) )
+ case message.params[1].split( " " ).size
+ when 1
+ namesub, voice_setting, voice_output = getvoice( settings, 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
+ ircipc.send( { "##{settings["channel"]}", "An error occurred! " + ex.message.to_s } )
+ end
+ end
+
+ rooms = Array( String ).new
+ rooms = [ "##{settings["channel"]}" ]
+
+ # 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
+
+
+# FastIRC::Message.to_s
+# @badge-info=;badges=;color=;display-name=BungMonkey;emotes=;flags=;id=20fcc358-4fc3-4919-8229-f1034743d18f;mod=0;room-id=46694819;subscriber=0;tmi-sent-ts=1587876383907;turbo=0;user-id=59895482;user-type= :bungmonkey!bungmonkey@bungmonkey.tmi.twitch.tv PRIVMSG #kr3wzz test
+
+# FastIRC::Message.tags
+# {"badge-info" => "subscriber/34",
+# "badges" => "broadcaster/1,subscriber/12",
+# "color" => "",
+# "display-name" => "BungMonkey",
+# "emote-only" => "1",
+# "emotes" => "300780134:0-9",
+# "flags" => "",
+# "id" => "c5c08c05-6e39-483f-b426-488dfc477a6c",
+# "mod" => "0",
+# "room-id" => "59895482",
+# "subscriber" => "1",
+# "tmi-sent-ts" => "1587871386678",
+# "turbo" => "0",
+# "user-id" => "59895482",
+# "user-type" => ""}
+
+#command = "PRIVMSG"
+#prefix = Prefix(@source="bungmonkey", @user="bungmonkey", @host="bungmonkey.tmi.twitch.tv")
+#params = ["#bungmonkey", "test test test test"]