chatx 0.0.0.pre.pre3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,111 @@
1
+ class ChatBot
2
+ # The immediate exit point when a message is recieved from a websocket. It
3
+ # grabs the relevant hooks, creates the event, and passes the event to the
4
+ # hooks.
5
+ #
6
+ # It also spawns a new thread for every hook. This could lead to errors later,
7
+ # but it prevents 409 errors which shut the bot up for a while.
8
+ #
9
+ # @note This method is strictly internal.
10
+ # @param data [Hash] It's the JSON passed by the websocket
11
+ def handle(data, server: @default_server)
12
+ data.each do |room, evt|
13
+ next if evt.keys.first != 'e'
14
+ evt['e'].each do |e|
15
+ event_type = e['event_type'].to_i - 1
16
+ room_id = room[1..-1].to_i
17
+ event = ChatX::Event.new e, server, self
18
+ @ws_json_logger.info "#{event.type_long}: #{event.hash} #{event.inspect}"
19
+ @ws_json_logger.info "Currently in rooms #{@rooms.keys} / #{current_rooms}"
20
+ break if @rooms[room_id].nil?
21
+ @rooms[room_id.to_i][:events].push(event)
22
+ next if @hooks[event_type.to_i].nil?
23
+ @hooks[event_type.to_i].each do |rm_id, hook|
24
+ Thread.new do
25
+ @hook.current_room = room_id
26
+ hook.call(event) if rm_id == room_id || rm_id == '*'
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # A convinant way to hook into an event.
34
+ # @param room_id [#to_i] The ID of th room to listen in.
35
+ # @param event [String] The [EVENT_SHORTHAND] for the event ID.
36
+ # @param action [Proc] This is a block which will run when the hook
37
+ # is triggered. It is passed one parameter, which is the event.
38
+ # It is important to note that it will NOT be passed an {Event},
39
+ # rather, it will be passed a sub event designated in {Event::EVENT_CLASSES}.
40
+ def add_hook(room_id, event, &action)
41
+ @hooks[EVENT_SHORTHAND.index(event)] ||= []
42
+ @hooks[EVENT_SHORTHAND.index(event)].push [room_id, action]
43
+ end
44
+
45
+ # A simpler syntax for creating {add_hook} to the "Message Posted" event.
46
+ # @param room_id [#to_i] The room to listen in
47
+ # @see #add_hook
48
+ def on_message(room_id)
49
+ add_hook(room_id, 'Message Posted') { |e| yield(e.hash['content']) }
50
+ end
51
+
52
+ # This opens up the DSL created by {Hook}.
53
+ def gen_hooks(&block)
54
+ @hook ||= Hook.new(self)
55
+ @hook.instance_eval(&block)
56
+ end
57
+ end
58
+
59
+ class Hook
60
+ attr_reader :bot
61
+ attr_accessor :current_room
62
+
63
+ def initialize(bot)
64
+ @bot = bot
65
+ end
66
+
67
+ def say(message, room_id = @current_room)
68
+ @bot.say(message, room_id)
69
+ end
70
+
71
+ def room(room_id, &block)
72
+ room = Room.new(room_id, self)
73
+ room.instance_eval(&block)
74
+ end
75
+
76
+ def on(event, &block)
77
+ @bot.hooks[EVENT_SHORTHAND.index(event)] ||= []
78
+ @bot.hooks[EVENT_SHORTHAND.index(event)].push ['*', block]
79
+ end
80
+
81
+ def command(prefix, &block)
82
+ on "message" do |e|
83
+ msg = HTMLEntities.new.decode(e.content)
84
+ if msg.downcase == prefix || msg.downcase.start_with?("#{prefix} ")
85
+ args = msg.scan(%r{\"(.*)\"|\'(.*)\'|([^\s]*)}).flatten.reject { |a| a.to_s.empty? }[1..-1]
86
+ block.call(*args)
87
+ end
88
+ end
89
+ end
90
+
91
+ class Room < Hook
92
+ def initialize(room_id, hook)
93
+ @hook = hook
94
+ @bot = hook.bot
95
+ @room_id = room_id
96
+ end
97
+
98
+ def say(msg)
99
+ @bot.say(msg, @room_id)
100
+ end
101
+
102
+ def reply_to(msg, reply)
103
+ msg.reply(@bot, reply)
104
+ end
105
+
106
+ def on(event, &block)
107
+ @bot.hooks[EVENT_SHORTHAND.index(event)] ||= []
108
+ @bot.hooks[EVENT_SHORTHAND.index(event)].push [@room_id, block]
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,36 @@
1
+ module ChatX
2
+ class Helpers
3
+ @cache = {}
4
+
5
+ def self.symbolize_hash_keys(hash)
6
+ hash.each_key do |key|
7
+ # rubocop:disable Lint/RescueWithoutErrorClass
8
+ hash[(begin
9
+ key.to_sym
10
+ rescue
11
+ key
12
+ end) || key] = hash.delete(key)
13
+ # rubocop:enable: Lint/RescueWithoutErrorClass
14
+ end
15
+ hash
16
+ end
17
+
18
+ def self.cached(key, scope = nil)
19
+ @cache ||= {}
20
+ if !scope.nil?
21
+ @cache[scope] ||= {}
22
+ @cache[scope][key] = yield if @cache[scope][key].nil?
23
+ @cache[scope][key]
24
+ else
25
+ @cache[key] = yield if @cache[key].nil?
26
+ @cache[key]
27
+ end
28
+ end
29
+ end
30
+
31
+ class InitializationDataException < RuntimeError
32
+ def initialize(msg)
33
+ super(msg)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ require 'date'
2
+ require_relative 'helpers'
3
+ require_relative 'room'
4
+ require_relative 'user'
5
+
6
+ module ChatX
7
+ class Message
8
+ attr_reader :server, :timestamp, :content, :room, :user, :id, :parent_id
9
+
10
+ def initialize(server, **opts)
11
+ if opts.values_at(:time_stamp, :content, :room_id, :user_id, :message_id).any?(&:nil?)
12
+ raise ChatX::InitializationDataException, 'Got nil for an expected message property'
13
+ end
14
+
15
+ @server = server
16
+
17
+ @id = opts[:message_id]
18
+ @timestamp = Time.at(opts[:time_stamp]).utc.to_datetime
19
+ @content = opts[:content]
20
+ @room = ChatX::Helpers.cached opts[:room_id].to_i, :rooms do
21
+ ChatX::Room.new server, room_id: opts[:room_id].to_i
22
+ end
23
+ @user = ChatX::Helpers.cached opts[:user_id].to_i, :users do
24
+ ChatX::User.new server, user_id: opts[:user_id].to_i
25
+ end
26
+
27
+ @parent_id = opts[:parent_id]
28
+ end
29
+
30
+ def reply(bot, content)
31
+ bot.say ":#{id} #{content}", @room.id, server: @server
32
+ end
33
+
34
+ def reply?
35
+ @content =~ /^:\d+\s/
36
+ end
37
+
38
+ def pings
39
+ @pings = @content.scan(/@(\w+)/).flatten if @pings.nil?
40
+ @pings
41
+ end
42
+
43
+ def pinged?(username)
44
+ @pings.map(&:downcase).map { |x| username.downcase.start_with? x }.any?
45
+ end
46
+
47
+ %i[
48
+ toggle_star
49
+ star_count
50
+ star
51
+ unstar
52
+ starred?
53
+ cancel_stars
54
+ delete
55
+ edit
56
+ toggle_pin
57
+ pin
58
+ unpin
59
+ pinned?
60
+ ].each do |name|
61
+ define_method(name) { |bot| bot.send(name, @id, @server) }
62
+ end
63
+
64
+ def self.from_hash(server, hash)
65
+ new server, **ChatX::Helpers.symbolize_hash_keys(hash)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,56 @@
1
+ require_relative 'helpers'
2
+
3
+ module ChatX
4
+ class Room
5
+ attr_reader :users, :name, :id, :server, :stars, :owners, :description
6
+
7
+ def initialize(server, **opts)
8
+ if opts.values_at(:room_id).any?(&:nil?)
9
+ raise ArgumentError, 'Got nil for an expected room property'
10
+ end
11
+
12
+ @server = server
13
+
14
+ @id = opts[:room_id]
15
+ track_users if opts[:track_users]
16
+ track_starred_messages if opts[:track_starred_messages]
17
+ metadata if opts[:metadata]
18
+ end
19
+
20
+ private
21
+
22
+ def track_users
23
+ room_page = Nokogiri::HTML open("https://chat.#{@server}.com/rooms/#{@id}")
24
+ @users = room_page.css('#room-usercards-container').children.map do |e|
25
+ e = e.css('.user-header > a').attr('href').split('/')
26
+ User.new(server,
27
+ user_name: e[-1],
28
+ user_id: e[-2])
29
+ end
30
+ end
31
+
32
+ def track_starred_messages
33
+ star_page = Nokogiri::HTML open("https://chat.#{@server}.com/rooms/starred/#{@id}")
34
+ @stars = star_page.css('entry').map do |e|
35
+ Message.new @server,
36
+ time_stamp: Time.parse(e.css('published').first.text).utc.to_i,
37
+ content: e.css('summary').first.text,
38
+ user_id: e.css('author uri').first.text.split('/').last,
39
+ message_id: e.css('id').first.text.split('-').last,
40
+ room_id: @id
41
+ end
42
+ end
43
+
44
+ def metadata
45
+ metadata_card = Nokogiri::HTML(open("https://chat.#{@server}.com/rooms/info/#{@id}")).css('.roomcard-xxl')
46
+ @name = metadata_card.css('h1').first.text
47
+ @description = metadata_card.css('p').first.text
48
+
49
+ owner_cards = Nokogiri::HTML(open("https://chat.#{@server}.com/rooms/info/#{@id}")).css('.room-ownercards')
50
+ @owners = owner_cards.each do |e|
51
+ id, name = e.css('a:first-child').attr('href').split('/')
52
+ User.new(@server, user_id: id, user_name: name)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ require_relative 'helpers'
2
+
3
+ module ChatX
4
+ class User
5
+ attr_reader :owns_rooms, :in_rooms, :description, :id, :username, :server
6
+
7
+ def initialize(server, **opts)
8
+ if opts.values_at(:user_id).any?(&:nil?)
9
+ raise ArgumentError, 'Got nil for an expected user property'
10
+ end
11
+
12
+ @server = server
13
+ @id = opts[:user_id]
14
+
15
+ metadata
16
+ end
17
+
18
+ private
19
+
20
+ def metadata
21
+ metadata_page = Nokogiri::HTML open("https://chat.#{@server}.com/users/#{@id}")
22
+ @username = metadata_page.css('.usercard-xxl .user-status').first.text
23
+ @description = metadata_page.css('.user-stats tr:nth-child(4) td:last-child').text
24
+
25
+ in_room_cards = metadata_page.css('#user-roomcards-container').children
26
+ @in_rooms = if !in_room_cards.nil?
27
+ in_room_cards.reject { |e| e.is_a? Nokogiri::XML::Text }.map do |e|
28
+ rid = e.attr('id').split('-').last.to_i
29
+ ChatX::Helpers.cached rid, :rooms do
30
+ Room.new @server, room_id: rid
31
+ end
32
+ end
33
+ else
34
+ []
35
+ end
36
+
37
+ owns_room_cards = metadata_page.css('#user-owningcards').children[-1..1]
38
+ @owns_rooms = if !owns_room_cards.nil?
39
+ owns_room_cards.reject { |e| e.is_a? Nokogiri::XML::Text }.map do |e|
40
+ puts e
41
+ rid = e.attr('id').split('-').last.to_i
42
+ ChatX::Helpers.cached rid, :rooms do
43
+ Room.new @server, room_id: rid
44
+ end
45
+ end
46
+ else
47
+ []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ class AnonClient
2
+ attr_accessor :rooms
3
+ attr_reader :server
4
+ def initialize(bot, server, room_id, poll_frequency: 2)
5
+ @poll_frequency = poll_frequency
6
+ @bot = bot
7
+ @dead = false
8
+ @server = server
9
+ join_room room_id
10
+ start_loop
11
+ end
12
+
13
+ def join_room(room_id)
14
+ @fkey = Nokogiri::HTML(open("https://chat.#{server}.com/rooms/#{room_id}").read).search("//input[@name='fkey']").attribute("value")
15
+ events_json = Net::HTTP.post_form(URI("https://chat.#{@server}.com/chats/#{room_id}/events"),
16
+ fkey: @fkey,
17
+ since: 0,
18
+ mode: "Messages",
19
+ msgCount: 100).body
20
+
21
+ events = JSON.parse(events_json)["events"]
22
+ last_event_time = events.max_by { |event| event['time_stamp'] }['time_stamp']
23
+ @rooms = {"r#{room_id}" => last_event_time}
24
+ end
25
+
26
+ def kill
27
+ @dead = true
28
+ @thread.join
29
+ end
30
+
31
+ private
32
+
33
+ def start_loop
34
+ @thread = Thread.new do
35
+ until @dead
36
+ response_json = Net::HTTP.post_form(URI("https://chat.#{@server}.com/events"), {fkey: @fkey}.merge(@rooms))
37
+ response = JSON.parse(response_json.body)
38
+ response.each do |room, data|
39
+ @rooms[room] = data["t"] unless data["t"].nil?
40
+ end
41
+ @bot.handle(response)
42
+ sleep @poll_frequency
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,3 @@
1
+ class ChatBot
2
+ VERSION = '0.0.0-pre3'.freeze
3
+ end
@@ -0,0 +1,76 @@
1
+ class WSClient
2
+ attr_reader :url, :thread, :driver, :in_rooms
3
+
4
+ # Opens a websocket. This was really annoying. To open a websocket, you've got
5
+ # to grab a weird uri (see {#join_room}) and pass some cookies and set the origin
6
+ # header. Anywho, it opens a new socket in a new thread.
7
+ # @note This method is internal. Do not use.
8
+ # @param uri [String] See {#join_room}
9
+ # @param cookies [String] See {#join_room}
10
+ # @param server [String] The chat server to use.
11
+ def initialize(url, cookies, bot, server)
12
+ bot.logger.info "Opening on #{url}"
13
+ @logger = Logger.new 'websocket_raw.log'
14
+ @url = url
15
+ @bot = bot
16
+ @in_rooms = {rooms: [], last_updated: Time.now}
17
+ @uri = URI.parse(url)
18
+ @driver = WebSocket::Driver.client(self)
19
+ @tcp = TCPSocket.new(@uri.host, 80) # (@uri.scheme == "wss" || @uri.scheme == "ws" ? 80 : 443))
20
+ @restart = true
21
+ @driver.add_extension(PermessageDeflate)
22
+ @driver.set_header("Cookie", cookies)
23
+ @driver.set_header("Origin", "https://chat.#{server}.com")
24
+
25
+ @driver.on :connect, ->(_e) {}
26
+
27
+ @driver.on :open, ->(_e) { @bot.logger.info "WebSocket is open!" }
28
+
29
+ @driver.on :message do |e|
30
+ @logger.info "Read: #{e.data}"
31
+ @bot.handle(JSON.parse(e.data))
32
+ @in_rooms = {rooms: JSON.parse(e.data).keys.map { |k| k[1..-1].to_i }, last_updated: Time.now}
33
+ end
34
+
35
+ @driver.on :close, ->(_e) do
36
+ @bot.logger.error "The websocket is closing."
37
+ if @restart
38
+ @bot.logger.info "Attempting to reopen websocket..."
39
+ @driver.start
40
+ end
41
+ end
42
+
43
+ @driver.on :error, ->(e) { @bot.logger.error e }
44
+
45
+ @driver.start
46
+
47
+ @thread = Thread.new do
48
+ trap("SIGINT") do
49
+ @restart = false
50
+ close
51
+ Thread.exit
52
+ end
53
+ begin
54
+ loop { @driver.parse(@tcp.recv(1)) }
55
+ rescue IOError, SystemCallError => e
56
+ @bot.logger.debug "Recieved #{e} closing TCP socket. You shouldn't be worried :)"
57
+ end
58
+ end
59
+ end
60
+
61
+ def send(message)
62
+ @logger.info "Write: #{message}"
63
+ @driver.text(message)
64
+ end
65
+
66
+ def write(data)
67
+ @tcp.write(data)
68
+ end
69
+
70
+ def close
71
+ @driver.close
72
+ @tcp.shutdown
73
+ rescue IOError, Errno::ENOTCONN => e
74
+ @bot.logger.error "Recieved #{e.class} trying to close websocket. Ignoring..."
75
+ end
76
+ end