From 79e94489db56e5e5c0fdf0ff648a6ad71381dc85 Mon Sep 17 00:00:00 2001 From: Joe Rayhawk Date: Wed, 12 Jul 2017 19:55:33 -0700 Subject: Twitch PubSub->AF_UNIX bridge --- twitchpubsub.rb | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100755 twitchpubsub.rb (limited to 'twitchpubsub.rb') diff --git a/twitchpubsub.rb b/twitchpubsub.rb new file mode 100755 index 0000000..521f73a --- /dev/null +++ b/twitchpubsub.rb @@ -0,0 +1,121 @@ +#!/usr/bin/env ruby + +# Twitch pubsub->AF_UNIX bridge. + +require 'json' +require 'socket' +require 'openssl' +require 'websocket' +require 'net/http' +require 'pp' + +confdir = Dir.home + '/.config/twitch/' +ENV['APPDATA'] && confdir = ENV['APPDATA'] + "\\twitch\\" +ENV['XDG_CONFIG_HOME'] && confdir = ENV['XDG_CONFIG_HOME'] + '/twitch/' + +access_token = IO.read( confdir + 'access_token' ).chomp +channel = IO.read( confdir + 'channel' ).chomp +client_id = IO.read( confdir + 'client_id' ).chomp +unix_socket_path = confdir + 'chat_socket' + +http = Net::HTTP.new('api.twitch.tv', 443) +http.use_ssl = true +http.read_timeout = 10 + +channel_id = JSON.parse(http.request(Net::HTTP::Get.new("/kraken/channels/#{channel}?client_id=#{client_id}")).body)['_id'].to_s + +def normalize_twitch_pubsub_bullshit_data_because_seriously_fuck_twitch( parsed ) + # Twitch may or may not have JSON-encoded this JSON because Twitch. + begin + message = JSON.parse( parsed['data']['message'] ) + rescue JSON::ParserError + message = parsed['data']['message'] + end + # Twitch may or may not deliver subdata within the 'data' hash that is in a subhash called 'data' that is, additionally, JSON-encoded within the JSON-encoded JSON because Twitch. + # Inexplicably, this is redundant data; in this case, Twitch, additionally, provides 'data' subdata in a subhash called 'data_object'. + if message['data_object'].class == Hash then + message = message['data_object'] + end + if message['data'].class == Hash then + message = message['data'] + end + # This is a dadaist parody of an API. + return message +end + +def dispatch_message( message, unix_socket_path ) + unix_socket = UNIXSocket.new( unix_socket_path ) + if message['sub_plan'] then # Subscription + unix_socket.print( message['user_name'] + ' ' + message['context'] + 'cribed with ' + message['sub_plan'] + ': "' + message['sub_message']['message'] + "\"\n" ) + elsif message['item_description'] then # commerce + unix_socket.print( message['user_name'] + ' has purchased ' + message['item_description'] + ': "' + message['purchase_message']['message'] + "\"\n" ) + elsif message['bits_used'] then # bits + unix_socket.print( message['user_name'] + ' sends ' + message['bits_used'].to_s + 'bits: "' + message['chat_message'] + "\"\n" ) + elsif message['tags'] then # whisper + puts message['tags']['login'] + ' whispers: "' + message['body'] + '"' + end + unix_socket.close +end + +tcp_socket = TCPSocket.open( 'pubsub-edge.twitch.tv',443 ) +ssl_context = OpenSSL::SSL::SSLContext.new() +ssl_socket = OpenSSL::SSL::SSLSocket.new( tcp_socket, ssl_context ) +ssl_socket.connect + +ssl_socket.write( WebSocket::Handshake::Client.new( url: 'wss://pubsub-edge.twitch.tv/', headers: { 'Cookie' => 'SESSIONID=1234' } ).to_s ) + +IO.select( [ tcp_socket ] ) +# We can only select() on the TCPSocket rather than the SSLSocket +# Here we pray that this is encapsulated data and not SSL negotiation noise. +print ssl_socket.read_nonblock( 4096 ) + +# Twitch calls it "auth_token" here rather than the usual "access_token" because Twitch. +ssl_socket.write( WebSocket::Frame::Outgoing::Client.new( data: '{ "type": "LISTEN", "data": { "topics": ["channel-bits-events-v1.' + channel_id + '", "channel-subscribe-events-v1.' + channel_id + '", "channel-commerce-events-v1.' + channel_id + '", "whispers.' + channel_id + '" ], "auth_token": "'+access_token+'" } }', type: :text ).to_s ) + +# Keepalive thread +t2 = Thread.new { + while sleep 180 + ssl_socket.write( WebSocket::Frame::Outgoing::Client.new( data: '{ "type": "PING" }', type: :text ).to_s ) + end +} + +frame = WebSocket::Frame::Incoming::Client.new +frame << ssl_socket.gets +print frame.next.to_s + +frame_decoded = String.new +frame_parsed = Hash.new + +# event loop thread +t3 = Thread.new { + begin + while true + frame << ssl_socket.read_nonblock( 4096 ) + frame_decoded = frame.next.to_s + print frame_decoded + frame_parsed = JSON.parse(frame_decoded) + pp frame_parsed + if frame_parsed['type'] == 'MESSAGE' then + message = normalize_twitch_pubsub_bullshit_data_because_seriously_fuck_twitch( frame_parsed ) + dispatch_message( message, unix_socket_path ) + end + end + rescue IO::WaitReadable, OpenSSL::SSL::SSLErrorWaitReadable, JSON::ParserError + IO.select([tcp_socket]) + retry + end +} + +# sub_frame = <<-'EOF' +# {"type":"MESSAGE","data":{"topic":"channel-subscribe-events-v1.59895482","message":"{\"user_name\":\"tobiidk\",\"display_name\":\"TobiiDK\",\"channel_name\":\"bungmonkey\",\"user_id\":\"22413536\",\"channel_id\":\"59895482\",\"time\":\"2017-07-12T11:21:07.118989503Z\",\"sub_message\":{\"message\":\"\",\"emotes\":null},\"sub_plan\":\"1000\",\"sub_plan_name\":\"Throw money at Bung\",\"months\":0,\"context\":\"sub\"}"}} +# EOF +# +# bits_frame = <<-'EOF' +# {"type":"MESSAGE","data":{"topic":"channel-bits-events-v1.59895482","message":"{\"data\":{\"user_name\":\"tobiidk\",\"channel_name\":\"bungmonkey\",\"user_id\":\"22413536\",\"channel_id\":\"59895482\",\"time\":\"2017-07-12T11:17:37.432Z\",\"chat_message\":\"cheer100 cheer100 From a bro! cheer100 cheer100\",\"bits_used\":400,\"total_bits_used\":400,\"context\":\"cheer\",\"badge_entitlement\":{\"new_version\":100,\"previous_version\":0}},\"version\":\"1.0\",\"message_type\":\"bits_event\",\"message_id\":\"6e7e92bb-8b97-5c00-872c-256ed59a920c\"}"}} +# EOF +# +# whisper_frame = <<-'EOF' +# {"type":"MESSAGE","data":{"topic":"whispers.59895482","message":"{\"type\":\"whisper_received\",\"data\":\"{\\\"message_id\\\":\\\"e7bb5308-e46b-44b2-9fe0-b70117998ed1\\\",\\\"id\\\":1,\\\"thread_id\\\":\\\"59895482_110143127\\\",\\\"body\\\":\\\"poop\\\",\\\"sent_ts\\\":1499771385,\\\"from_id\\\":110143127,\\\"tags\\\":{\\\"login\\\":\\\"jortlet\\\",\\\"display_name\\\":\\\"jortlet\\\",\\\"color\\\":\\\"#00FF7F\\\",\\\"user_type\\\":\\\"\\\",\\\"turbo\\\":false,\\\"emotes\\\":[],\\\"badges\\\":[]},\\\"recipient\\\":{\\\"id\\\":59895482,\\\"username\\\":\\\"bungmonkey\\\",\\\"display_name\\\":\\\"BungMonkey\\\",\\\"color\\\":\\\"\\\",\\\"user_type\\\":\\\"\\\",\\\"turbo\\\":false,\\\"badges\\\":[],\\\"profile_image\\\":null},\\\"nonce\\\":\\\"fi0YVDT5F7Faj34episa5jHVXIkIFy\\\"}\",\"data_object\":{\"message_id\":\"e7bb5308-e46b-44b2-9fe0-b70117998ed1\",\"id\":1,\"thread_id\":\"59895482_110143127\",\"body\":\"poop\",\"sent_ts\":1499771385,\"from_id\":110143127,\"tags\":{\"login\":\"jortlet\",\"display_name\":\"jortlet\",\"color\":\"#00FF7F\",\"user_type\":\"\",\"turbo\":false,\"emotes\":[],\"badges\":[]},\"recipient\":{\"id\":59895482,\"username\":\"bungmonkey\",\"display_name\":\"BungMonkey\",\"color\":\"\",\"user_type\":\"\",\"turbo\":false,\"badges\":[],\"profile_image\":null},\"nonce\":\"fi0YVDT5F7Faj34episa5jHVXIkIFy\"}}"}} +# EOF +# +# commerce_frame = ? -- cgit v1.2.3