summaryrefslogtreecommitdiff
path: root/twitchpubsub.rb
blob: 49ebe6db9e82a7a3207bb22c8e480aba2761405c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#!/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 || true
channel      = IO.read( confdir + 'channel'      ).chomp
channel_id   = IO.read( confdir + 'channel_id'   ).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

# kraken broke
#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['context'] == 'subgift' then # Gifted subscription
    unix_socket.print( message['recipient_user_name'] + ' has subscribed for ' + (message['cumulative_months'].to_i).to_s + ' months at sub level #' + message['sub_plan'] + 'thanks to a gift from ' + message['user_name'] + ': "' + message['sub_message']['message'] + "\"\n" )
  elsif message['sub_plan']             then # Subscription
    unix_socket.print( message['user_name'] + ' has ' + message['context'] + 'scribed for ' + (message['cumulative_months'].to_i).to_s + ' months at sub level #' + 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", "nonce": "fart", "data": { "topics": ["channel-bits-events-v1.' + channel_id + '", "channel-subscribe-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, EOFError, OpenSSL::SSL::SSLError
    IO.select([tcp_socket])
    retry
  end
}

# sub_frame = <<-'EOF'
#{"type"=>"MESSAGE","data"=>{"topic"=>"channel-subscribe-events-v1.59895482","message"=>"{\"user_name\":\"totalsunny\",\"display_name\":\"TotalSunny\",\"channel_name\":\"bungmonkey\",\"user_id\":\"47871268\",\"channel_id\":\"59895482\",\"time\":\"2019-04-27T12:47:26.023643965Z\",\"sub_message\":{\"message\":\"Greetings\",\"emotes\":null},\"sub_plan\":\"1000\",\"sub_plan_name\":\"5\",\"months\":0,\"cumulative_months\":7,\"context\":\"resub\"}"}}
# EOF
#
# sub_frame = <<-'EOF'
#{"type":"MESSAGE","data":{"topic":"channel-subscribe-events-v1.59895482","message":"{\"user_name\":\"sash_isynergy\",\"display_name\":\"sasH_iSynergy\",\"channel_name\":\"bungmonkey\",\"user_id\":\"101100602\",\"channel_id\":\"59895482\",\"recipient_id\":\"152875613\",\"recipient_user_name\":\"twixgun\",\"recipient_display_name\":\"twixgun\",\"time\":\"2018-06-14T14:42:01.772426724Z\",\"sub_message\":{\"message\":\"\",\"emotes\":null},\"sub_plan\":\"1000\",\"sub_plan_name\":\"5\",\"months\":0,\"context\":\"subgift\"}"}}
#
# 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 = ?