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.
- 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
|