diff options
author | Joe Rayhawk <jrayhawk+git@omgwallhack.org> | 2023-10-17 07:53:30 -0700 |
---|---|---|
committer | Joe Rayhawk <jrayhawk+git@omgwallhack.org> | 2023-12-27 12:33:49 -0800 |
commit | 0438391b4ec94404582b71b0fb4e12bc999c0021 (patch) | |
tree | f1b08d78929395b46c667f934d673d191d03e53e /crystal | |
parent | fe3ea5b7d7dc90f4bdbec4631a3b2f47e56dc2fa (diff) | |
download | twitchtools-0438391b4ec94404582b71b0fb4e12bc999c0021.tar.gz twitchtools-0438391b4ec94404582b71b0fb4e12bc999c0021.zip |
crystal/tcpsocket.cr: initial work on semi-public socket protocol
Diffstat (limited to 'crystal')
-rw-r--r-- | crystal/tcpsocket.cr | 528 |
1 files changed, 528 insertions, 0 deletions
diff --git a/crystal/tcpsocket.cr b/crystal/tcpsocket.cr new file mode 100644 index 0000000..8d26a68 --- /dev/null +++ b/crystal/tcpsocket.cr @@ -0,0 +1,528 @@ +require "socket" +require "openssl" +require "twitch/irc" +require "http" +require "uri" +require "twitcr" +require "json" +require "crystal_mpd" +require "obswebsocket" +require "yaml" + +STDOUT.sync = true +STDOUT.flush_on_newline = true + +struct Nil + def as_s? + self + end + def []?( v : String | Int ) + self + end +end + +regexservice = /(twitch|gamesurge)/ +regexuser = /[0-9a-zA-Z_]+/ +regexb64 = /[0-9a-fA-F]+/ +regexvoice = /[0-9a-zA-Z-]+/ + +module BungmoBott + class Config + # YAML: + #--- + #statedir: /home/jrayhawk/.local/state/bungmobott/ + #tempdir: /tmp/bungmobott/ + #listen: 0.0.0.0:5555 + #join_channels: + # twitch: + # - bungmonkey + # - dopefish + # # - yackamov + # gamesurge: + # - bungmonkey + #twitch_channel_id: 59895482 # saves a lookup + #chat_user: + # twitch: bungmonkey + # gamesurge: bungmoBott + + getter configdir : String + getter statedir : String + getter tempdir : String + getter listen : String | Nil + getter join_channels : Hash( String, Array( String ) ) | Nil + getter twitch_channel_id : UInt64 | Nil + getter twitch_client_id : String | Nil + getter twitch_access_token : String | Nil + getter gcloud_token : String | Nil + getter chat_user : Hash( String, String ) | Nil + getter metaconfig : Hash( String, Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ) ) + + def initialize( ) + + @metaconfig = Hash( String, Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ) ).new + @metaconfig["default"] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).new + @metaconfig["default"]["home"] = Path.home.to_s + @configdir = Path.home./("/.config/bungmobott/").to_s + @metaconfig["default"]["statedir"] = Path.home./("/.local/state/bungmobott/").to_s + + if ENV["LOCALAPPDATA"]? + @configdir = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\" ).to_s + @metaconfig["default"]["statedir"] = Path.windows( ENV["LOCALAPPDATA"] + "\\bungmobott\\state\\" ).to_s + end + + @metaconfig["default"]["tempdir"] = "/tmp/bungmobott/" + + ENV["TEMP"]? && ( @metaconfig["default"]["tempdir"] = "#{ENV["TEMP"]}\\bungmobott\\" ) + + ENV["XDG_CONFIG_HOME"]? && ( @configdir = ENV["XDG_CONFIG_HOME"] + "/bungmobott/" ) + #ENV["XDG_DATA_HOME"]? && ( @metaconfig["default"]["datadir"] = ENV["XDG_DATA_HOME"] ) # unused? + ENV["XDG_STATE_HOME"]? && ( @metaconfig["default"]["statedir"] = ENV["XDG_STATE_HOME"] + "/bungmobott/" ) + + populate( "config" ) + populate( "secret" ) + @metaconfig["default"].merge!( + { + "chat_user" => nil, + "twitch_channel_id" => nil, + "join_channels" => nil, + "listen" => nil, + "twitch_client_id" => nil, + "twitch_access_token" => nil, + "gcloud_token" => nil, + } + ) + + # FIXME: Maybe add more explicit validation on these...? + # FIXME: Might need to do explicit maps on these to get them to typecheck + @chat_user = @metaconfig["config"]["chat_user"]?.as( Hash( String, String ) | Nil ) + @twitch_channel_id = @metaconfig["config"]["twitch_channel_id"]?.as( UInt64 ) + @join_channels = @metaconfig["config"]["join_channels"]?.as( Hash( String, Array( String ) ) | Nil ) + @twitch_client_id = @metaconfig["secret"]["twitch_client_id"]?.as( String ) + @twitch_access_token = @metaconfig["secret"]["twitch_access_token"]?.as( String ) + @gcloud_token = @metaconfig["secret"]["gcloud_token"]?.as( String ) + + @listen = ( @metaconfig["config"]["listen"]?.as( String | Nil ) ) + @statedir = ( + if @metaconfig["config"]["statedir"]? + @metaconfig["config"]["statedir"].as( String ) + else + @metaconfig["default"]["statedir"].as( String ) + end + ) + @tempdir = ( + if @metaconfig["config"]["tempdir"]? + @metaconfig["config"]["tempdir"].as( String ) + else + @metaconfig["default"]["tempdir"].as( String ) + end + ) + + Dir.mkdir_p( @statedir ) + Dir.mkdir_p( @tempdir ) + + end + def populate( configname : String ) + begin + @metaconfig[configname] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).from_yaml( File.read( configdir + configname + ".txt" ) ) + rescue exyaml : YAML::ParseException + puts "Failed to parse #{configname}.txt: #{exyaml.message}" + exit 4 + rescue exio : IO::Error + puts "#{configname}.txt missing, writing new #{configname}.txt" + if configname == "secrets" + write( configname, @metaconfig["default"].select{ |k, v| k =~ /twitch_(client_id|access_token)|gcloud_token/ }.to_yaml ) + elsif configname == "config" + write( configname, @metaconfig["default"].reject{ |k, v| k =~ /configdir|home|twitch_(client_id|access_token)|gcloud_token/ }.to_yaml ) + end + # verify we can read it back + @metaconfig[configname] = Hash( String, String | UInt64 | Nil | Hash( String, String | Array( String ) ) ).from_yaml( File.read( configdir + configname + ".txt" ) ) + end + end + def write( configname : String, content : String ) + File.write( configdir + "#{configname}.txt", content ) + Dir.mkdir_p( @configdir ) + rescue exio : IO::Error + puts "ERROR: Unable to write #{configname}.txt: #{exio.message}" + exit 7 + end + end +end + +regextwitchuser = /^[0-9a-zA-Z_]+$/ + +config = BungmoBott::Config.new + +# enable direct twitch api? + +if ( config.twitch_access_token && config.twitch_client_id ) + twitchapi = true +else + twitchapi = false + config.twitch_access_token || STDERR.puts "Warning: #{config.configdir}secret.txt 'twitch_access_token' is missing; direct Twitch API access disabled." + config.twitch_client_id || STDERR.puts "Warning: #{config.configdir}secret.txt '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: #{config.configdir}config.txt '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 config.gcloud_token + gcloud = true +else + gcloud = false + STDERR.puts "Warning: #{config.configdir}secret.txt gcloud_token is missing; direct 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?( 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( config.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 ( config.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"], token: "oauth:" + config.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"] + + # 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=(config.configdir + "/privkey.pem" ) + ssl_context.certificate_chain=(config.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( config.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 + |