summaryrefslogtreecommitdiff
path: root/crystal/bungmobott.cr
diff options
context:
space:
mode:
authorJoe Rayhawk <jrayhawk@fairlystable.org>2024-02-27 03:23:30 -0800
committerJoe Rayhawk <jrayhawk@fairlystable.org>2024-02-27 03:23:30 -0800
commit77b74b745aaa84fe342d56f396bc0491cea0859b (patch)
tree80faee6a10d6e3b5118464380edb0564afc283ec /crystal/bungmobott.cr
parent061b5e956e422d585f8733e8079c60a625c6d27f (diff)
downloadtwitchtools-77b74b745aaa84fe342d56f396bc0491cea0859b.tar.gz
twitchtools-77b74b745aaa84fe342d56f396bc0491cea0859b.zip
crystal/tcpsocket: promote new codebase to bungmobott
Diffstat (limited to 'crystal/bungmobott.cr')
-rw-r--r--crystal/bungmobott.cr1832
1 files changed, 1832 insertions, 0 deletions
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 "},
+ { /</, " 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) %}
+ t2sifc.send( { voice, "#{namesub} #{text}" } )
+ puts( "#{Fiber.current.name} tx t2sifc: #{voice}, #{namesub} #{text}" )
+ #{% else %}
+ # t2smsg( config, "#{voice} #{namesub} #{text}" )
+ #{% end %}
+ return( voice )
+ else
+ return( nil )
+ end
+end
+
+spawn name: "command_dispatch" do
+ fiberifc.send( Fiber.current )
+ loop do
+ while commandmsg : Tuple( String, FastIRC::Message ) = commandifc.receive
+ spawn name: "command_dispatch ifc rx" do
+ service = String.new
+ local_commandmsg = commandmsg
+ puts( "#{Fiber.current.name}: #{local_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.
+ #bbssrvifc.send( "#{message.to_s}" )
+ #puts( "#{Fiber.current.name} tx bbssrvifc: #{message.to_s}" )
+ if channelsubs[ { service, ircchannel } ]?
+ channelsubs[ { service, ircchannel } ].each do |channelsub|
+ if channelsub.is_a? OpenSSL::SSL::Socket::Server
+ channelsub.puts( "msg #{service} #{message.to_s}" )
+ end
+ end
+ end
+ end
+
+ next unless ( userlogreturn = userlog( config, service, message ) )
+ chatuser, uid, userdir, lastseen, oldname = userlogreturn
+ pp userlogreturn
+ # Have we seen this user lately?
+ if ( ( Time.utc.to_unix - lastseen ) >= 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
+