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