chatx 0.0.0.pre.pre3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.gitlab-ci.yml +27 -0
- data/.rubocop.yml +38 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +3 -0
- data/Guardfile +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/chatx.gemspec +52 -0
- data/lib/chatx.rb +271 -0
- data/lib/chatx/auth.rb +38 -0
- data/lib/chatx/events.rb +195 -0
- data/lib/chatx/hooks.rb +111 -0
- data/lib/chatx/models/helpers.rb +36 -0
- data/lib/chatx/models/message.rb +68 -0
- data/lib/chatx/models/room.rb +56 -0
- data/lib/chatx/models/user.rb +51 -0
- data/lib/chatx/polling.rb +46 -0
- data/lib/chatx/version.rb +3 -0
- data/lib/chatx/websocket.rb +76 -0
- metadata +252 -0
data/lib/chatx/hooks.rb
ADDED
@@ -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,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
|