require "twitch/irc" require "http" require "uri" require "twitcr" require "json" require "crystal_mpd" STDOUT.sync = true struct Nil def as_s? self end end settings = Hash(String, String).new settings["home"] = Path.home.to_s ["access_token", "channel", "channel_id", "client_id", "client_id_twitch"].each do |key| begin settings[key] = File.read( settings["home"] + "/.config/twitch/" + key ).chomp rescue IO::Error STDERR.puts "Warning: Missing " + settings["home"] + "/.config/twitch/" + key end end obsipc = Channel( String ).new ircipc = Channel( Tuple( String, String ) ).new snifflast = Int64.new(0) client = Twitcr::Client.new( settings ) def urbandef( term : String ) #http://api.urbandictionary.com/v0/define?term=waifu client = HTTP::Client.new( "api.urbandictionary.com" ) response = client.exec( "GET", "/v0/define?term=" + URI.encode_www_form( term ) ) puts response.status_code json = JSON.parse( response.body ) return json["list"][0]["definition"].to_s.gsub( /[\[\]]/, "" ).gsub( /\n/, "" ) end def ircmsg( home : String, channel : String, msg : String) if msg !~ /(\r|\n)/ sock = Socket.unix # sock.connect Socket::UNIXAddress.new( home + "/.irssi/twitch-socket".to_s, type: Socket::Type::DGRAM ) sock.connect Socket::UNIXAddress.new( home + "/.irssi/twitch-socket".to_s ) sock.puts( "#" + channel + " " + msg ) sock.close end end def t2smsg( msg : String) sock = Socket.unix sock.connect Socket::UNIXAddress.new( ENV["HOME"] + "/.t2s.sock" ) sock.puts( msg ) sock.close rescue ex puts ex end def effectsmsg( msg : String ) sock = Socket.unix sock.connect Socket::UNIXAddress.new( ENV["HOME"] + "/.effects.sock" ) sock.puts( msg ) sock.close rescue ex puts ex end def userlog( settings : Hash(String, String), message : FastIRC::Message ) unless ( ( prefix = message.prefix ) && ( chatuser = prefix.source ) && ( uid = message.tags["user-id"]? ) ) return nil end basedir = settings["home"] + "/.cache/twitchtools" userdir = basedir + "/uids/" + uid unless File.directory?( userdir ) Dir.mkdir( userdir ) end unless File.directory?( userdir + "/names" ) Dir.mkdir( userdir + "/names" ) end unless File.symlink?( userdir + "/names/" + chatuser ) namelatest = "" datelatest = Time::UNIX_EPOCH Dir.each_child( userdir + "/names/" ) do |name| namedate = File.info( userdir + "/names/" + name ).modification_time if namedate > datelatest namelatest = name datelatest = namedate end end unless namelatest.empty? ircmsg( settings["home"], settings["channel"], "Rename detected: #{uid}: #{namelatest} -> #{chatuser}" ) end File.symlink( "../", userdir + "/names/" + chatuser ) end unless File.symlink?( basedir + "/names/" + chatuser ) File.symlink( "../uids/" + uid, basedir + "/names/" + chatuser ) end unless ( message.params[0] == "#bungmonkey" ) return nil end return ( [ chatuser, uid, userdir ] ) rescue ex puts ex end def getvoice( settings : Hash(String, String), userdir : String, chatuser : String ) if File.exists?( userdir + "/voice" ) voice_output = File.read( userdir + "/voice" ).chomp voice_setting = voice_output else voice_output = File.read( settings["home"] + "/voicelist.txt" ).chomp.split( "\n" ).sample( 1 )[0].chomp voice_setting = "random" end if File.exists?( userdir + "/voicesub" ) namesub = File.read( userdir + "/voicesub" ).chomp else namesub = chatuser end return( [namesub, voice_setting, voice_output ] ) end # text2speech def t2s( settings : Hash(String, String), userdir : String, chatuser : String, text : String ) if ( text !~ /^ *(!|\|)/ ) namesub, voice_setting, voice = getvoice( settings, userdir, chatuser ) t2stext = text.gsub( /http(s|):\/\/([a-z0-9.]+)\/[a-zA-Z0-9\/&=%-_]+/, "link to ${2}" ) t2stext = t2stext.gsub( /rrr.+/, "rr" ) t2smsg( "#{voice} #{namesub} #{t2stext}" ) return( voice ) else return( nil ) end end voices = Hash(String, String).new File.each_line( settings["home"] + "/voicelist.txt" ) do |line| voices[ line.downcase ] = line end lastvoice = Array(String).new # obs-websocket spawn do loop do begin obs_pubsub = HTTP::WebSocket.new( URI.parse( "ws://127.0.0.1:4444/" ), HTTP::Headers{"Cookie" => "SESSIONID=1234"} ) # Outgoing spawn do while msg = obsipc.receive pp msg obs_pubsub.send( msg ) end end # Incoming obs_pubsub.on_message do | message | json = JSON.parse( message ) if json["error"]? puts json["error"] ircipc.send( { "#" + settings["channel"], "| obs: #{json["error"]}" } ) next end case json["update-type"]? when "StreamStatus" # these are a bit noisy, next # so skip them when "SwitchScenes" ircipc.send( { "#" + settings["channel"], "| obs: switched scene to " + ( json["scene-name"]?.as_s? || "unknown" ) } ) when "MediaEnded" request = Hash( String, String | Bool ){ "request-type" => "SetSceneItemProperties", "message-id" => "SetSceneItemProperties", "item" => json["sourceName"].as_s, "visible" => false } #ircipc.send( { "##{settings["channel"]}", "Disabling #{json["sourceName"].as_s}" } ) obsipc.send( request.to_json ) when "SourceFilterVisibilityChanged" ircipc.send( { "##{settings["channel"]}", "| obs: source #{json["sourceName"]} filter #{json["filterName"]} visibility is now #{json["filterEnabled"]}" } ) when "SceneItemVisibilityChanged" ircipc.send( { "##{settings["channel"]}", "| obs: source #{json["item-name"]} visibility is now #{json["item-visible"].as_bool}" } ) end print( "RECEIVED: ") print( json.to_pretty_json ) print( "\n") case json["message-id"]? when "GetCurrentScene" ircipc.send( { "#" + settings["channel"], "| obs: " + json["sources"].as_a.map{ |source| source["name"] }.join(", ") } ) when "GetSourceFilters" ircipc.send( { "#" + settings["channel"], "| obs: " + json["filters"].as_a.map{ |filter| filter["name"] }.join(", ") } ) when "GetSceneList" ircipc.send( { "#" + settings["channel"], "| obs: " + json["scenes"].as_a.map{ |scene| scene["name"] }.join(", ") } ) when "toggle-filter" name = json["filters"][0]["name"].as_s visible = ( json["filters"][0]["enabled"].as_bool == false ) ircipc.send( { "#" + settings["channel"], "| obs: Setting visibility of filter #{name} to #{visible}" } ) obsipc.send( "{ \"request-type\": \"SetSourceFilterVisibility\", \"message-id\": \"SetSourceFilterVisibility\", \"sourceName\": \"#{name}\", \"visible\": #{visible} }" ) #This is a dumb hack to toggle SetSceneItemProperty visibility when "toggle-source" name = json["name"].as_s visible = ( json["visible"].as_bool == false ) ircipc.send( { "#" + settings["channel"], "| obs: Setting visibility of source #{name} to #{visible}" } ) obsipc.send( "{ \"request-type\": \"SetSceneItemProperties\", \"message-id\": \"SetSceneItemProperties\", \"item\": \"#{name}\", \"visible\": #{visible} }" ) end end obs_pubsub.run rescue ex : IO::Error pp ex ircipc.send( { "#" + settings["channel"], "obs.cr:obs: " + ex.to_s.gsub(/\r|\n/, ' ') + ": Maybe try again in 10 seconds?" } ) rescue ex : Socket::ConnectError # these are a bit noisy pp ex obs_pubsub && obs_pubsub.close rescue ex ircmsg( settings["home"], settings["channel"], "irc.cr:obs: " + ex.to_s.gsub(/\r|\n/, ' ') ) puts ex obs_pubsub && obs_pubsub.close end sleep 10 next end end # IRC loop do begin bot = Twitch::IRC::Client.new( nick: settings["channel"], token: "oauth:" + settings["access_token"], log_mode: true ) bot.tags = [ "membership", "tags", "commands" ] # Outgoing spawn do while msgtuple = ircipc.receive bot.message( msgtuple[0], msgtuple[1][0..480] ) end end # Create a handler to process incoming messages bot.on_message do |message| spawn do next unless ( userlogreturn = userlog( settings, message ) ) chatuser, uid, userdir = userlogreturn if ( chatuser == "pipne" ) && ( ( Time.utc.to_unix - snifflast ) >= 14400 ) snifflast = Time.utc.to_unix t2smsg( chatuser + " sniff null" ) end if ( t2sreturn = t2s( settings, userdir, chatuser, message.params[1] ) ) lastvoice.insert( 0, t2sreturn ) lastvoice = lastvoice[0..4] 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] 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" ) if ( ( cmd =~ /^(substitute|voicesub)$/ ) && ( mod || sub ) ) case message.params[1].split( " " ).size when 1 if File.exists?( userdir + "/voicesub" ) bot.message( "#bungmonkey", "| Current name substitution is \"#{File.read( userdir + "/voicesub" )}\"." ) else bot.message( "#bungmonkey", "| Current name substitution is disabled." ) end else if match[2]? voicesub = match[2].downcase if voicesub =~ /^(disabled|null|disable|none)$/ if File.exists?( userdir + "/voicesub" ) File.delete( userdir + "/voicesub" ) bot.message( "#bungmonkey", "| Name substitution for #{chatuser} is now disabled." ) else bot.message( "#bungmonkey", "| Name substitution for #{chatuser} is already disabled." ) end else File.directory?( userdir ) || Dir.mkdir( userdir ) File.write( userdir + "/voicesub", voicesub ) bot.message( "#bungmonkey", "| Name substitution for #{chatuser} is now \"#{File.read( userdir + "/voicesub" )}\"." ) end end end elsif ( cmd =~ /^(commands|help)$/ ) bot.message( "#bungmonkey", "| https://bungmonkey.omgwallhack.org/txt/commands.txt" ) elsif ( cmd =~ /^(dexem)$/ ) ircipc.send( { "#bungmonkey", "| You're doing great work, Dexem!" } ) elsif ( ( cmd == "lastvoice" ) && ( mod || sub ) ) unless lastvoice.empty? bot.message( "#bungmonkey", "| Last voices were " + lastvoice.join( ", " ) ) else bot.message( "#bungmonkey", "| No voices used so far." ) end elsif ( ( cmd =~ /^(voices|voicelist)$/ ) && ( mod || sub ) ) bot.message( "#bungmonkey", "| https://bungmonkey.omgwallhack.org/voicelist.txt" ) elsif ( ( cmd =~ /^(setvoice|voice)$/ ) && ( mod || sub ) ) case message.params[1].split( " " ).size when 1 namesub, voice_setting, voice_output = getvoice( settings, userdir, chatuser ) bot.message( "#bungmonkey", "| Current voice is #{voice_setting}" ) when 2 if match[2]? voice = match[2].downcase if voice =~ /disabled|null|disable|none|random/ if File.exists?( userdir + "/voice" ) File.delete( userdir + "/voice" ) bot.message( "#bungmonkey", "| Voice for #{chatuser} is now random." ) else bot.message( "#bungmonkey", "| Voice for #{chatuser} is already random." ) end elsif voices.has_key?( voice ) csvoice = voices[voice] File.directory?( userdir ) || Dir.mkdir( userdir ) File.write( userdir + "/voice", csvoice ) pp userdir bot.message( "#bungmonkey", "| Voice for #{chatuser} is now #{File.read( userdir + "/voice" )}." ) # TODO: make separate script to print streamelements URLs to client machine else pp ( match ) bot.message( "#bungmonkey", "| Invalid voice. To see list, use !voices" ) end end when 3 else end elsif ( ( cmd =~ /^scene$/ ) && ( mod || own ) ) if ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) obsipc.send( "{ \"request-type\": \"SetCurrentScene\", \"message-id\": \"SetCurrentScene\", \"scene-name\": \"" + match[2] + "\" }" ) else obsipc.send( "{ \"request-type\": \"GetSceneList\", \"message-id\": \"GetSceneList\" }" ) end elsif ( ( cmd =~ /^source$/ ) && ( mod || own ) ) request = Hash( String, String | Bool ).new if ( match[2]? ) && ( sourceargs = match[2].match( /^([a-zA-Z0-9-_]+) +(true|false)/ ) ) request["request-type"] = "SetSceneItemProperties" request["message-id"] = "SetSceneItemProperties" request["item"] = sourceargs[1] sourceargs[2] == "true" && ( request["visible"] = true ) sourceargs[2] == "false" && ( request["visible"] = false ) ircipc.send( { "##{settings["channel"]}", "Setting source #{sourceargs[1]} visibility to #{sourceargs[2]}" } ) obsipc.send( request.to_json ) elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) request["request-type"] = "GetSceneItemProperties" request["message-id"] = "toggle-source" request["item"] = match[2] obsipc.send( request.to_json ) else request["request-type"] = "GetCurrentScene" request["message-id"] = "GetCurrentScene" obsipc.send( request.to_json ) end elsif ( ( cmd =~ /^filter$/ ) && ( mod || own ) ) request = Hash( String, String | Bool ){ "request-type" => "GetSourceFilters" } if ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+) (true|false)/ ) ) request["request-type"] = "SetSourceFilterVisibility" request["message-id"] = "SetSourceFilterVisibility" request["sourceName"] = filterargs[1] request["filterName"] = filterargs[2] #request["filterEnabled"] = filterargs[3] filterargs[3] == "true" && ( request["filterEnabled"] = true ) filterargs[3] == "false" && ( request["filterEnabled"] = false ) obsipc.send( request.to_json ) elsif ( match[2]? ) && ( filterargs = match[2].match( /^([a-zA-Z0-9-_]+) +([a-zA-Z0-9-_]+)/ ) ) request["message-id"] = "toggle-filter" request["sourceName"] = filterargs[1] request["filterName"] = filterargs[2] obsipc.send( request.to_json ) elsif ( match[2]? && match[2] =~ /^[a-zA-Z0-9-_]+$/ ) request["sourceName"] = match[2] request["message-id"] = "GetSourceFilters" obsipc.send( request.to_json ) else ircipc.send( { "##{settings["channel"]}", "Must provide at least one source name as argument, and optionally one filter name." } ) end elsif ( cmd =~ /^(metaminute|youdied)$/ ) request = Hash( String, String | Bool ).new request["request-type"] = "SetSceneItemProperties" request["message-id"] = "SetSceneItemProperties" request["item"] = "media-" + cmd # This breaks playback (???) #request["visible"] = false #obsipc.send( request.to_json ) #sleep 0.5 # I should probably set this up to add the source, and then delete the source at the end of playback. request["visible"] = true ircipc.send( { "##{settings["channel"]}", "Playing media-#{cmd}" } ) obsipc.send( request.to_json ) elsif ( ( cmd =~ /^(status|title)$/ ) && ( mod || own ) ) begin 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 ) ) ircmsg( settings["home"], settings["channel"], "Title is now \"#{ json["data"][0]["title"] }\"") else json = JSON.parse( client.get_channel( settings["channel_id"].to_u64 ) ) ircmsg( settings["home"], settings["channel"], "| Title is currently \"#{ json["data"][0]["title"] }\"") end rescue ex ircmsg( settings["home"], settings["channel"], "| An error occurred! " + ex.message.to_s ) end elsif ( ( cmd =~ /^(game|category)$/ ) && ( mod || own ) ) begin 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 ) ) ircmsg( settings["home"], 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 ) ) ircmsg( settings["home"], settings["channel"], "| Game is currently \"#{ json["data"][0]["game_name"] }\"") end rescue ex ircmsg( settings["home"], settings["channel"], "| An error occurred! " + ex.message.to_s ) end elsif ( ( cmd == "urban" ) && ( mod || own || sub || vip ) ) if match[2]? && match[2] =~ /^([a-zA-Z0-9 -])+$/ definition = urbandef( match[2] ) ircmsg( settings["home"], settings["channel"], definition[0,400] ) else ircmsg( settings["home"], settings["channel"], "| Urban Dictionary search term should consist of letters, numbers, spaces, and/or hyphens." ) end elsif ( cmd == "matrix" ) effectsmsg( "overlay glmatrix" ) elsif ( cmd =~ /juggle|juggler/ ) effectsmsg( "overlay juggler3d" ) elsif ( cmd =~ /fireworks|firework/ ) effectsmsg( "overlay fireworkx" ) elsif ( cmd == "pipes" ) if match[2]? && match[2] =~ /^(fast|faster)$/ effectsmsg( "overlay pipes " + match[2] ) else effectsmsg( "overlay pipes" ) end elsif ( cmd == "jellyfish" ) effectsmsg( "overlay hydrostat" ) elsif ( cmd == "gluten" ) effectsmsg( "overlay flyingtoasters" ) elsif ( cmd =~ /^(glsnake|glmatrix|gibson|xmatrix|flyingtoasters|moebiusgears|fireworkx|hydrostat|hypertorus|jigsaw|juggler3d|kaleidocycle|kumppa|molecule|noof|polyhedra)$/ ) effectsmsg( "overlay " + cmd ) elsif ( cmd =~ /gltext|cowsay|xcowsay/ ) if ( match[2]? ) && ( gltextargs = match[2].match( /^([0-9]+) +(.+)$/ ) ) seconds=UInt64.new( 1 ) seconds=gltextargs[1].to_u64 if ( own || mod || vip ) effectsmsg( "overlay #{cmd} #{match[2]}" ) puts "matched #{cmd} #{match[2]}" elsif ( sub ) if ( seconds > 20 ) seconds=20 end effectsmsg( "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) puts "matched #{cmd} #{seconds} #{gltextargs[2]}" else if ( seconds > 5 ) seconds=5 end effectsmsg( "overlay #{cmd} #{seconds} #{gltextargs[2]}" ) puts "matched #{cmd} #{seconds} #{gltextargs[2]}" end elsif match[2]? && match[2] =~ /^.+$/ effectsmsg( "overlay #{cmd} #{match[2]}" ) puts "matched #{cmd} #{match[2]}" else effectsmsg( "overlay #{cmd}" ) puts "failed to match gltext" end elsif ( cmd =~ /cow|cowabunga|holycow/ ) if match[2]? && match[2] =~ /^([0-9])+$/ effectsmsg( "overlay bouncingcow #{match[2]}" ) else effectsmsg( "overlay bouncingcow" ) end elsif ( cmd == "overlay" ) if match[2]? && match[2] =~ /^[a-z]+$/ ircmsg( settings["home"], settings["channel"], "| overlay requires an argument consisting wholly of lower case characters.") effectsmsg( "overlay #{match[2]}" ) else end elsif ( cmd == "hackerman" ) ircmsg( settings["home"], settings["channel"], "| https://bungmonkey.omgwallhack.org/img/hackerman.jpg" ) elsif ( cmd =~ /^(songrequest|sr)$/ ) && match[2]? puts ("song detected: #{match[2]}") if ( ( match[2] =~ /list=/ ) && ( match[2] !~ /v=/ ) ) ircmsg( settings["home"], 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 ) ircmsg( settings["home"], settings["channel"], "| " + playlistinfo[ status["playlistlength"].to_i - 1 ]["file"].to_s + " added in position " + status["playlistlength"].to_s ) else ircmsg( settings["home"], settings["channel"], "| A failure occured." ) end m.disconnect else end elsif ( cmd =~ /^current(|song)$/ ) m = MPD::Client.new if ( currentsong = m.currentsong ) ircmsg( settings["home"], settings["channel"], "| Currently playing: " + currentsong["file"].to_s ) else ircmsg( settings["home"], 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 ircmsg( settings["home"], settings["channel"], "| " + sprintf( "2%d:2%d", min, sec ) ) else ircmsg( settings["home"], settings["channel"], "| An error occurred. " ) end m.disconnect else ircmsg( settings["home"], 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 ) ircmsg( settings["home"], settings["channel"], "| Currently playing: " + currentsong["file"].to_s ) else ircmsg( settings["home"], settings["channel"], "| A failure occured." ) end else ircmsg( settings["home"], settings["channel"], "| Playlist is now empty." ) end m.disconnect elsif ( cmd == "followage" ) begin 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 ircmsg( settings["home"], 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 ircmsg( settings["home"], 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 ircmsg( settings["home"], settings["channel"], "| " + json["data"][0]["followed_at"].to_s ) end rescue ex ircmsg( settings["home"], settings["channel"], "An error occurred! " + ex.message.to_s ) end end end end rooms = Array( String ).new rooms = [ "#bungmonkey", "kr3wzz" ] # 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 exit 1 end end # FastIRC::Message.to_s # @badge-info=;badges=;color=;display-name=BungMonkey;emotes=;flags=;id=20fcc358-4fc3-4919-8229-f1034743d18f;mod=0;room-id=46694819;subscriber=0;tmi-sent-ts=1587876383907;turbo=0;user-id=59895482;user-type= :bungmonkey!bungmonkey@bungmonkey.tmi.twitch.tv PRIVMSG #kr3wzz test # FastIRC::Message.tags # {"badge-info" => "subscriber/34", # "badges" => "broadcaster/1,subscriber/12", # "color" => "", # "display-name" => "BungMonkey", # "emote-only" => "1", # "emotes" => "300780134:0-9", # "flags" => "", # "id" => "c5c08c05-6e39-483f-b426-488dfc477a6c", # "mod" => "0", # "room-id" => "59895482", # "subscriber" => "1", # "tmi-sent-ts" => "1587871386678", # "turbo" => "0", # "user-id" => "59895482", # "user-type" => ""} #command = "PRIVMSG" #prefix = Prefix(@source="bungmonkey", @user="bungmonkey", @host="bungmonkey.tmi.twitch.tv") #params = ["#bungmonkey", "test test test test"]