require "socket" require "openssl" require "twitch/irc" require "http" require "uri" require "twitcr" require "json" require "crystal_mpd" require "obswebsocket" require "yaml" require "bungmobott" STDOUT.sync = true STDOUT.flush_on_newline = true struct Nil def as_s? self end def []?( v : String | Int ) self 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( configdir : String, file : String, contents : String ) Dir.mkdir_p( configdir ) File.write( configdir + file, contents ) rescue exio : IO::Error puts "ERROR: Unable to write #{ configdir + file }.txt: #{exio.message}" exit 7 end configfile = configdir + "config.txt" if File.exists?( configfile ) config = BungmoBott::Config.from_yaml( File.read( configdir + "config.txt" ) ) else config = BungmoBott::Config.from_yaml("---") STDERR.puts "WARNING: #{configfile} not found. Writing new one." writeconfig( configdir, "config.txt", config.to_yaml ) end puts config.to_yaml 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 secretsfile = configdir + "secrets.txt" if File.exists?( secretsfile ) secrets = BungmoBott::Secrets.from_yaml( File.read( secretsfile ) ) else secrets = BungmoBott::Secrets.from_yaml( "---" ) STDERR.puts "WARNING: #{secretsfile} not found. Writing new one." writeconfig( configdir, "secrets.txt", secrets.to_yaml ) end unless secrets.yaml_unmapped.empty? STDERR.puts "WARNING: #{secretsfile} has unknown properties:" ppe secrets.yaml_unmapped.keys end regextwitchuser = /^[0-9a-zA-Z_]+$/ # enable direct twitch api? if ( secrets.twitch_access_token && secrets.twitch_client_id ) twitchapi = true 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" ) 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?( config.configdir + "/effects.txt" ) # File.each_line( config.configdir + "/effects.txt" ) do |line| # effects[ line.downcase ] = line # end #end # FIXME: might need twitch_channel_id eventually ...? #if twitchapi # twitchclient = Twitcr::Client.new( defaultsettings ) # # derive twitch_channel_id from channel or vice versa # [ "channel", "twitch_channel_id" ].each do |key| # File.exists?( config.configdir + key ) && ( settings[key] = File.read( settings["config.configdir"] + key ).chomp ) # end # if ( config.channel? =~ regextwitchuser ) && ( settings["twitch_channel_id"]? =~ /^[0-9]+$/ ) # elsif ! ( config.channel? =~ regextwitchuser ) && ( settings["twitch_channel_id"]? =~ /^[0-9]+$/ ) # config.channel = twitchclient.user( settings["twitch_channel_id"].to_u64 ).login # elsif ( config.channel? =~ regextwitchuser ) && ! ( settings["twitch_channel_id"]? =~ /^[0-9]+$/ ) # config.twitch_channel_id = twitchclient.user( settings["channel"] ).id.to_s # else # STDERR.puts "ERROR: Missing #{config.configdir}channel and twitch_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 #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 ) end return voices end def generatevoicelistgcs( gcloud_token ) 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 | voices.push( v["name"].as_s ) 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 ) end end return voices end {% end %} def regeneratevoicelist( defaultsettings : Hash( String, String ), aws : Bool, gcloud : Bool ) voices = Hash(String, String).new if aws generatevoicelistaws() do | voice | voices[ voice.downcase ] = voice end end if gcloud generatevoicelistgcs( secrets.gcloud_token ).each do | voice | voices[ voice.downcase ] = voice end end {% if flag?(:windows) %} generatevoicelistwin().each do {% end %} File.write( config.configdir + "/voicelist.txt", voices.values.sort.join("\r\n") ) return voices end # channel message subscriptions: { service, chan } => [ client, client ] channelsubs = Hash( Tuple( String, String ), Array( OpenSSL::SSL::Socket::Server ) ).new # Unencrypted #connections = Hash(TCPSocket, Hash(String, String)).new # Encrypted connections = Hash(OpenSSL::SSL::Socket::Server, Hash(String, String)).new ircipc = Channel( Tuple( String, String ) ).new t2sipc = Channel( Tuple( String, String ) ).new twitchipc = Channel( Tuple( String, Union( String, UInt64 ) ) ).new # Twitch API request handling thread #spawn do # loop do # begin # while twitchtuple = twitchipc.receive # 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_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 # if ( secrets.twitch_access_token && config.chat_user.not_nil!.twitch && config.join_channels.not_nil!.twitch ) # IRC thread spawn do 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 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 tuple = ircipc.receive # why does this need to be a 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 do #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"]? ) client.puts "msg twitch #{message.params[0]} #{chatuser} #{message.params[1]}" end end pp message pp message.params 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" ) rescue ex puts ex #ircipc.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 end end if config.listen begin ip, port = config.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 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 puts message client.puts message if ( connections[client]["authed"] == "true" ) # irc if ( match = message.match( /^irc (#{regexservice}) (#{regexuser})$/i ) ) ircservice = match[1] ircchannel = match[3] client.puts "joining #{ircservice} #{ircchannel}" ircipc.send( { "JOIN", ircchannel } ) unless channelsubs[ { ircservice, ircchannel } ]? channelsubs[ { ircservice, "#" + ircchannel } ] = Array( OpenSSL::SSL::Socket::Server ).new end channelsubs[ { ircservice, "#" + ircchannel } ].push( client ) pp channelsubs elsif ( match = message.match( /^awst2s (#{regexvoice}) (.+)/i ) ) # FIXME # powershell doesn't take bytestreams; maybe use http file hosting for this? elsif ( match = message.match( /^gcst2s (#{regexvoice}) (.+)/i ) ) # FIXME elsif ( match = message.match( /^awsvoicelist$/i ) ) client.puts "awsvoicelist " + generatevoicelistaws().join(" ") elsif ( match = message.match( /^gcsvoicelist$/i ) ) client.puts "gcsvoicelist " + generatevoicelistgcs( secrets.gcloud_token ).join(" ") end else # auth if ( match = message.match( /^auth (#{regexuser}) (#{regexb64})$/i ) ) pp match remoteuser = match[1] remotekey = match[2] if File.exists?( config.statedir + "apikeys/" + remoteuser ) File.each_line( config.statedir + "apikeys/" + remoteuser ) do |localkey| puts "comparing #{localkey} to #{remotekey}" if ( localkey == remotekey ) connections[client]["authed"] = "true" client.puts "authed #{ remoteuser }" connections[client]["user"] = remoteuser connections[client]["key"] = remotekey break end end end if ( connections[client]["authed"] == "false" ) client.puts "error: auth failure" end else client.puts "must auth [user] [key]" end end end connections.delete( client ) channelsubs.each_key do |key| channelsubs[key].delete( client ) end puts "Disconnected: #{clientsocket.remote_address}" pp channelsubs pp connections rescue ex : IO::Error puts ex next rescue ex puts ex next end end rescue ex puts ex end end