chatx 0.0.0.pre.pre3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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