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,271 @@
1
+ require "mechanize"
2
+ require "nokogiri"
3
+ require "websocket/driver"
4
+ require "json"
5
+ require "permessage_deflate"
6
+ require "open-uri"
7
+ require_relative "chatx/events"
8
+ require_relative "chatx/auth"
9
+ require_relative "chatx/websocket"
10
+ require_relative "chatx/hooks"
11
+ require_relative "chatx/polling"
12
+
13
+ require "thwait"
14
+
15
+ Thread.abort_on_exception = true
16
+
17
+ class ChatBot
18
+ # @!attribute [r] rooms
19
+ # @return [Hash<Hash<Array>>] a hash of the rooms. Each key is a room ID, and
20
+ # each value is futher hash with an :events key which contains an array
21
+ # of {Event}s from that room.
22
+ # @!attribute [r] websocket
23
+ # @return [Hash<room_id, Thread>] a hash of websockets. Each key is a room ID and
24
+ # each value is a websocket thread. Each websocket gets it's own thead because
25
+ # EventMachine blocks the main thread when I run it there.
26
+ # @!attribute [rw] default_server
27
+ # @return [String] The default server to connect to. It's good to
28
+ # pass this to the default_server keyword argument of the {initialize} method
29
+ # if you're only sticking to one server. Otherwise, with every {#say}
30
+ # you'll have to specify the server. This defaults to stackexchange. The options for
31
+ # this are "meta.stackexchange", "stackexchange", and "stackoverflow". The rest of
32
+ # the URL is managed internally.
33
+ # @!attribute [rw] hooks
34
+ # @return [Array] An array of the {Hook}s for the bot.
35
+ attr_reader :rooms, :websockets, :logger
36
+ attr_accessor :default_server, :hooks
37
+
38
+ # Creates a bot.
39
+ #
40
+ # These must be stack exchange openid credentials, and the user
41
+ # must have spoken in a room on that server first.
42
+ #
43
+ # For further details on authentication, see #authenticate
44
+ #
45
+ # @param [String] email The Stack Exchange OpenID email
46
+ # @param [String] password The Stack Exchange OpenID password
47
+ # @return [ChatBot] A new bot instance. Not sure what happens if
48
+ # you try and run more than one at a time...
49
+ def initialize(email, password, **opts)
50
+ opts[:default_server] ||= 'stackexchange'
51
+ opts[:log_location] ||= STDOUT
52
+ opts[:log_level] ||= Logger::DEBUG
53
+
54
+ @logger = Logger.new opts[:log_location]
55
+ @logger.level = opts[:log_level]
56
+
57
+ @ws_json_logger = Logger.new 'websockets_json.log'
58
+
59
+ @email = email
60
+ @password = password
61
+ @agent = Mechanize.new
62
+ @rooms = {} # room_id => {events}
63
+ @default_server = opts[:default_server]
64
+ @hooks = {}
65
+ @websockets = {}
66
+ at_exit { rejoin }
67
+ end
68
+
69
+ # Logs the bot into the three SE chat servers.
70
+ #
71
+ # @return [Boolean] A bool indicating the result of authentication: true if all three
72
+ # servers were authenticated successfully, false otherwise.
73
+ def login(servers = @default_server)
74
+ servers = [servers] unless servers.is_a? Array
75
+ authenticate(servers)
76
+ end
77
+
78
+ # Attempts to join a room, and for every room joined opens a websocket.
79
+ # Websockets seem to be the way to show your presence in a room. It's weird
80
+ # that way.
81
+ #
82
+ # Each websocket is added to the @websockets instance variable which can be
83
+ # read but not written to.
84
+ #
85
+ # @param room_id [#to_i] A valid room ID on the server designated by the
86
+ # server param.
87
+ # @keyword server [String] A string referring to the relevant server. The
88
+ # default value is set by the @default_server instance variable.
89
+ # @return [Hash] The hash of currently active websockets.
90
+ def join_room(room_id, server: @default_server)
91
+ @logger.info "Joining #{room_id}"
92
+
93
+ fkey = get_fkey(server, "rooms/#{room_id}")
94
+
95
+ @agent.get("https://chat.#{server}.com/rooms/#{room_id}", fkey: fkey)
96
+
97
+ events_json = @agent.post("https://chat.#{server}.com/chats/#{room_id}/events",
98
+ fkey: fkey,
99
+ since: 0,
100
+ mode: "Messages",
101
+ msgCount: 100).body
102
+
103
+ events = JSON.parse(events_json)["events"]
104
+
105
+ @logger.info "Retrieved events (length #{events.length})"
106
+
107
+ ws_auth_data = @agent.post("https://chat.#{server}.com/ws-auth",
108
+ roomid: room_id,
109
+ fkey: fkey)
110
+
111
+ @logger.info "Began room auth for room id: #{room_id}"
112
+
113
+ @rooms[room_id.to_i] = {}
114
+ @rooms[room_id.to_i][:events] = events.map do |e|
115
+ begin
116
+ ChatX::Event.new e, server, self
117
+ rescue ChatX::InitializationDataException => e
118
+ @logger.warn "Caught InitializationDataException during events population (#{e}); skipping event"
119
+ nil
120
+ end
121
+ end.compact
122
+
123
+ @logger.info "Rooms: #{@rooms.keys}"
124
+
125
+ unless @websockets[server].nil?
126
+ @websockets[server].driver.close
127
+ @logger.info "Websocket #{@websockets[server]} already open; clearing."
128
+ @websockets[server] = nil
129
+ end
130
+ ws_uri = JSON.parse(ws_auth_data.body)["url"]
131
+ last_event_time = events.max_by { |event| event['time_stamp'] }['time_stamp']
132
+ cookies = (@agent.cookies.map { |cookie| "#{cookie.name}=#{cookie.value}" if cookie.domain == "chat.#{server}.com" || cookie.domain == "#{server}.com" } - [nil]).join("; ")
133
+ @websockets[server] = WSClient.new("#{ws_uri}?l=#{last_event_time}", cookies, self, server)
134
+ @logger.info "New websocket open (#{@websockets[server]}"
135
+ end
136
+
137
+ # Leaves the room. Not much else to say...
138
+ # @param room_id [#to_i] The ID of the room to leave
139
+ # @keyword server [String] The chat server that room is on.
140
+ # @return A meaningless value
141
+ def leave_room(room_id, server: @default_server)
142
+ fkey = get_fkey("stackexchange", "/rooms/#{room_id}")
143
+ @rooms.delete(room_id) unless @rooms[room_id].nil?
144
+ @agent.post("https://chat.#{server}.com/chats/leave/#{room_id}", fkey: fkey, quiet: "true")
145
+ end
146
+
147
+ # Speaks in a room! Not much to say here, but much to say in the room
148
+ # that is passed!
149
+ #
150
+ # If you're trying to reply to a message, please use the {Message#reply}
151
+ # method.
152
+ # @param room_id [#to_i] The ID of the room to be spoken in
153
+ # @param content [String] The text of message to send
154
+ # @keyword server [String] The server to send the messon on.
155
+ # @return A meaningless value
156
+ def say(content, room_id, server: @default_server)
157
+ fkey = get_fkey(server, "/rooms/#{room_id}")
158
+ @logger.warn "Message too long, truncating each line to 500 chars: #{content}" if content.to_s.split("\n").any? { |line| line.length > 500 }
159
+ if content.to_s.empty?
160
+ @logger.warn "Message is empty, not posting: '#{content}'"
161
+ return
162
+ end
163
+ begin
164
+ resp = @agent.post("https://chat.#{server}.com/chats/#{room_id}/messages/new", fkey: fkey, text: content.to_s.split("\n").map { |line| line[0...500] }.join("\n"))
165
+ rescue Mechanize::ResponseCodeError
166
+ @logger.error "Posting message to room #{room_id} failed. Retrying... #{content.to_s.split("\n").map { |line| line[0...500] }.join("\n")}"
167
+ sleep 0.3 # A magic number I just chose for no reason
168
+ retry
169
+ end
170
+ return JSON.parse(resp.content)["id"].to_i
171
+ end
172
+
173
+ def toggle_star(message_id, server: @default_server)
174
+ fkey = get_fkey("stackexchange")
175
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey)
176
+ end
177
+
178
+ def star_count(message_id, server: @default_server)
179
+ page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}")
180
+ page.css("#message-#{message_id} .flash .star .times")[0].content.to_i
181
+ end
182
+
183
+ def star(message_id, server: @default_server)
184
+ fkey = get_fkey("stackexchange")
185
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey) unless starred?(message_id)
186
+ end
187
+
188
+ def unstar(message_id, server: @default_server)
189
+ fkey = get_fkey("stackexchange")
190
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey) if starred?(message_id)
191
+ end
192
+
193
+ def starred?(message_id, server: @default_server)
194
+ page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}")
195
+ return false if page.css("#message-#{message_id} .flash .stars")[0].nil?
196
+ page.css("#message-#{message_id} .flash .stars")[0].attr("class").include?("user-star")
197
+ end
198
+
199
+ def cancel_stars(message_id, server: @default_server)
200
+ fkey = get_fkey("stackexchange")
201
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/unstar", fkey: fkey)
202
+ end
203
+
204
+ def delete(message_id, server: @default_server)
205
+ fkey = get_fkey("stackexchange")
206
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/delete", fkey: fkey)
207
+ end
208
+
209
+ def edit(message_id, new_message, server: @default_server)
210
+ fkey = get_fkey("stackexchange")
211
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}", fkey: fkey, text: new_message)
212
+ end
213
+
214
+ def toggle_pin(message_id, server: @default_server)
215
+ fkey = get_fkey("stackexchange")
216
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey)
217
+ end
218
+
219
+ def pin(message_id, server: @default_server)
220
+ fkey = get_fkey("stackexchange")
221
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey)
222
+ end
223
+
224
+ def unpin(message_id, server: @default_server)
225
+ fkey = get_fkey("stackexchange")
226
+ @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey)
227
+ end
228
+
229
+ def pinned?(message_id, server: @default_server)
230
+ page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}")
231
+ return false if page.css("#message-#{message_id} .flash .stars")[0].nil?
232
+ page.css("#message-#{message_id} .flash .stars")[0].attr("class").include?("owner-star")
233
+ end
234
+
235
+ # Kills all active websockets for the bot.
236
+ def kill
237
+ @websockets.values.each(&:close)
238
+ end
239
+
240
+ def rejoin
241
+ ThreadsWait.all_waits @websockets.values.map(&:thread)
242
+ leave_all_rooms
243
+ end
244
+
245
+ def stop
246
+ leave_all_rooms
247
+ @websockets.values.map(&:thread).each(&:kill)
248
+ end
249
+
250
+ def leave_all_rooms
251
+ @rooms.each_key do |room_id|
252
+ leave_room(room_id)
253
+ end
254
+ end
255
+
256
+ def current_rooms
257
+ @websockets.map do |server, ws|
258
+ [server, ws.in_rooms[:rooms]]
259
+ end.to_h
260
+ end
261
+
262
+ private
263
+
264
+ def get_fkey(server, uri = "")
265
+ @agent.get("https://chat.#{server}.com/#{uri}").search("//input[@name='fkey']").attribute("value")
266
+ rescue Mechanize::ResponseCodeError
267
+ @logger.error "Getting fkey failed for uri https://chat.#{server}.com/#{uri}. Retrying..."
268
+ sleep 1 # A magic number I just chose for no reason
269
+ retry
270
+ end
271
+ end
@@ -0,0 +1,38 @@
1
+ class ChatBot
2
+ def authenticate(sites = ["stackexchange"])
3
+ sites = [sites] unless sites.is_a?(Array)
4
+
5
+ openid = @agent.get "https://openid.stackexchange.com/account/login"
6
+ fkey_input = openid.search "//input[@name='fkey']"
7
+ fkey = fkey_input.empty? ? "" : fkey_input.attribute("value")
8
+
9
+ @agent.post("https://openid.stackexchange.com/account/login/submit",
10
+ fkey: fkey,
11
+ email: @email,
12
+ password: @password)
13
+
14
+ auth_results = sites.map { |s| site_auth(s) }
15
+ failed = auth_results.any?(&:!)
16
+ !failed
17
+ end
18
+
19
+ def site_auth(site)
20
+ # Get fkey
21
+ login_page = @agent.get "https://#{site}.com/users/login"
22
+ fkey_input = login_page.search "//input[@name='fkey']"
23
+ fkey = fkey_input.attribute('value')
24
+
25
+ @agent.post("https://#{site}.com/users/authenticate",
26
+ fkey: fkey,
27
+ openid_identifier: 'https://openid.stackexchange.com')
28
+
29
+ home = @agent.get "https://chat.#{site}.com"
30
+ if home.search(".topbar-links span.topbar-menu-links a").first.text.casecmp('log in').zero?
31
+ @logger.warn "Login to #{site} failed :("
32
+ false
33
+ else
34
+ @logger.info "Login to #{site} successful!"
35
+ true
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,195 @@
1
+ require_relative 'models/message'
2
+ require_relative 'models/room'
3
+ require_relative 'models/user'
4
+
5
+ EVENT = %w[
6
+ Message Posted
7
+ Message Edited
8
+ User Entered
9
+ User Left
10
+ Room Name Changed
11
+ Message Starred
12
+ Debug Message
13
+ User Mentioned
14
+ Message Flagged
15
+ Message Deleted
16
+ File Added
17
+ Moderator Flag
18
+ User Settings Changed
19
+ Global Notification
20
+ Access Level Changed
21
+ User Notification
22
+ Invitation
23
+ Message Reply
24
+ Message Moved Out
25
+ Message Moved In
26
+ Time Break
27
+ Feed Ticker
28
+ User Suspended
29
+ User Merged
30
+ ].freeze
31
+
32
+ EVENT_SHORTHAND = %w[
33
+ message
34
+ edit
35
+ entrance
36
+ exit
37
+ rename
38
+ star
39
+ debug
40
+ mention
41
+ flag
42
+ delete
43
+ file
44
+ mod-flag
45
+ settings
46
+ gnotif
47
+ level
48
+ lnotif
49
+ invite
50
+ reply
51
+ move-out
52
+ move-in
53
+ time
54
+ feed
55
+ suspended
56
+ merge
57
+ ].freeze
58
+
59
+ module ChatX
60
+ class Event
61
+ EVENT_CLASSES = {
62
+ 1 => {
63
+ ChatX::Message => {
64
+ time_stamp: 'time_stamp',
65
+ content: 'content',
66
+ room_id: 'room_id',
67
+ user_id: 'user_id',
68
+ message_id: 'message_id'
69
+ }
70
+ },
71
+ 2 => {
72
+ ChatX::Message => {
73
+ time_stamp: 'time_stamp',
74
+ content: 'content',
75
+ room_id: 'room_id',
76
+ user_id: 'user_id',
77
+ message_id: 'message_id'
78
+ }
79
+ },
80
+ 3 => {
81
+ ChatX::User => {
82
+ user_id: 'target_user_id'
83
+ },
84
+ ChatX::Room => {
85
+ room_id: 'room_id'
86
+ }
87
+ },
88
+ 4 => {
89
+ ChatX::User => {
90
+ user_id: 'target_user_id'
91
+ },
92
+ ChatX::Room => {
93
+ room_id: 'room_id'
94
+ }
95
+ },
96
+ 5 => {
97
+ ChatX::Room => {
98
+ room_id: 'room_id'
99
+ }
100
+ },
101
+ # Note: A star/unstar/pin/unpin event DOES NOT have a user_id, which throws an error in ChatX::Message intialization.
102
+ # 6 => {
103
+ # ChatX::Message => {
104
+ # time_stamp: 'time_stamp',
105
+ # content: 'content',
106
+ # room_id: 'room_id',
107
+ # user_id: 'user_id',
108
+ # message_id: 'message_id'
109
+ # }
110
+ # },
111
+ 8 => {
112
+ ChatX::Message => {
113
+ time_stamp: 'time_stamp',
114
+ content: 'content',
115
+ room_id: 'room_id',
116
+ user_id: 'user_id',
117
+ message_id: 'message_id'
118
+ },
119
+ [ChatX::User, 'source_user'] => {
120
+ user_id: 'user_id'
121
+ },
122
+ [ChatX::User, 'target_user'] => {
123
+ user_id: 'target_user_id'
124
+ }
125
+ },
126
+ 18 => {
127
+ ChatX::Message => {
128
+ time_stamp: 'time_stamp',
129
+ content: 'content',
130
+ room_id: 'room_id',
131
+ user_id: 'user_id',
132
+ message_id: 'message_id'
133
+ }
134
+ }
135
+ }.freeze
136
+
137
+ attr_reader :type, :type_long, :type_short, :hash
138
+
139
+ def initialize(event_hash, server, bot)
140
+ @type = event_hash['event_type'].to_i
141
+ @type_short = EVENT_SHORTHAND[@type - 1]
142
+ @type_long = EVENT[@type - 1]
143
+ @bot = bot
144
+
145
+ @etypes = []
146
+
147
+ @hash = event_hash # .delete("event_type")
148
+
149
+ # rubocop:disable Style/GuardClause
150
+ if EVENT_CLASSES.include? @type
151
+ classes = EVENT_CLASSES[@type]
152
+ classes.each do |c, i|
153
+ if c.class == Array
154
+ clazz = c[0]
155
+ method_name = c[1]
156
+ else
157
+ clazz = c
158
+ method_name = clazz.to_s.split('::').last.downcase
159
+ end
160
+ init_properties = i.map { |k, v| [k, event_hash[v]] }.to_h
161
+ # This bit is fun. It creates a ChatX::<event type> and uses the method_missing
162
+ # to make its methods all aliased to this ChatX::Event, and also defines an
163
+ # <event type> method on this ChatX::Event which returns the ChatX::<event type>
164
+ # object.
165
+ event_type_obj = clazz.new(server, **init_properties)
166
+ @etypes.push event_type_obj
167
+ instance_variable_set "@#{method_name}", event_type_obj
168
+ self.class.send(:define_method, method_name) do
169
+ instance_variable_get "@#{method_name}"
170
+ end
171
+ end
172
+ end
173
+ # rubocop:enable Style/GuardClause
174
+
175
+ def method_missing(m, *args, **opts, &block)
176
+ etypes = @etypes.select { |e| e.respond_to? m }
177
+ super if etypes.empty?
178
+ etype = etypes.first
179
+ if etype.respond_to? m
180
+ meth = etype.method(m)
181
+ # Because it treats **opts as 1 argument
182
+ if opts.empty?
183
+ meth.call(*args, &block)
184
+ else
185
+ meth.call(*args, **opts, &block)
186
+ end
187
+ end
188
+ end
189
+
190
+ def respond_to_missing?(m, *)
191
+ !@etypes.select { |e| e.respond_to? m }.empty? || super
192
+ end
193
+ end
194
+ end
195
+ end