class ChatBot
Constants
- VERSION
Attributes
@!attribute [r] rooms
@return [Hash<Hash<Array>>] a hash of the rooms. Each key is a room ID, and each value is futher hash with an :events key which contains an array of {Event}s from that room.
@!attribute [r] websocket
@return [Hash<room_id, Thread>] a hash of websockets. Each key is a room ID and each value is a websocket thread. Each websocket gets it's own thead because EventMachine blocks the main thread when I run it there.
@!attribute [rw] default_server
@return [String] The default server to connect to. It's good to pass this to the default_server keyword argument of the {initialize} method if you're only sticking to one server. Otherwise, with every {#say} you'll have to specify the server. This defaults to stackexchange. The options for this are "meta.stackexchange", "stackexchange", and "stackoverflow". The rest of the URL is managed internally.
@!attribute [rw] hooks
@return [Array] An array of the {Hook}s for the bot.
@!attribute [r] rooms
@return [Hash<Hash<Array>>] a hash of the rooms. Each key is a room ID, and each value is futher hash with an :events key which contains an array of {Event}s from that room.
@!attribute [r] websocket
@return [Hash<room_id, Thread>] a hash of websockets. Each key is a room ID and each value is a websocket thread. Each websocket gets it's own thead because EventMachine blocks the main thread when I run it there.
@!attribute [rw] default_server
@return [String] The default server to connect to. It's good to pass this to the default_server keyword argument of the {initialize} method if you're only sticking to one server. Otherwise, with every {#say} you'll have to specify the server. This defaults to stackexchange. The options for this are "meta.stackexchange", "stackexchange", and "stackoverflow". The rest of the URL is managed internally.
@!attribute [rw] hooks
@return [Array] An array of the {Hook}s for the bot.
@!attribute [r] rooms
@return [Hash<Hash<Array>>] a hash of the rooms. Each key is a room ID, and each value is futher hash with an :events key which contains an array of {Event}s from that room.
@!attribute [r] websocket
@return [Hash<room_id, Thread>] a hash of websockets. Each key is a room ID and each value is a websocket thread. Each websocket gets it's own thead because EventMachine blocks the main thread when I run it there.
@!attribute [rw] default_server
@return [String] The default server to connect to. It's good to pass this to the default_server keyword argument of the {initialize} method if you're only sticking to one server. Otherwise, with every {#say} you'll have to specify the server. This defaults to stackexchange. The options for this are "meta.stackexchange", "stackexchange", and "stackoverflow". The rest of the URL is managed internally.
@!attribute [rw] hooks
@return [Array] An array of the {Hook}s for the bot.
@!attribute [r] rooms
@return [Hash<Hash<Array>>] a hash of the rooms. Each key is a room ID, and each value is futher hash with an :events key which contains an array of {Event}s from that room.
@!attribute [r] websocket
@return [Hash<room_id, Thread>] a hash of websockets. Each key is a room ID and each value is a websocket thread. Each websocket gets it's own thead because EventMachine blocks the main thread when I run it there.
@!attribute [rw] default_server
@return [String] The default server to connect to. It's good to pass this to the default_server keyword argument of the {initialize} method if you're only sticking to one server. Otherwise, with every {#say} you'll have to specify the server. This defaults to stackexchange. The options for this are "meta.stackexchange", "stackexchange", and "stackoverflow". The rest of the URL is managed internally.
@!attribute [rw] hooks
@return [Array] An array of the {Hook}s for the bot.
Public Class Methods
Creates a bot.
These must be stack exchange openid credentials, and the user must have spoken in a room on that server first.
For further details on authentication, see authenticate
@param [String] email The Stack Exchange OpenID email @param [String] password The Stack Exchange OpenID password @return [ChatBot] A new bot instance. Not sure what happens if
you try and run more than one at a time...
# File lib/chatx.rb, line 50 def initialize(email, password, **opts) opts[:default_server] ||= 'stackexchange' opts[:log_location] ||= STDOUT opts[:log_level] ||= Logger::DEBUG @logger = Logger.new opts[:log_location] @logger.level = opts[:log_level] @ws_json_logger = Logger.new 'websockets_json.log' # Both of these can be overriden in #login with cookie: @email = email @password = password @agent = Mechanize.new @rooms = {} # room_id => {events} @default_server = opts[:default_server] @hooks = {'*' => []} @websockets = {} at_exit { rejoin } end
Public Instance Methods
A convinant way to hook into an event. @param room_id [#to_i] The ID of th room to listen in. @param event [String] The [EVENT_SHORTHAND] for the event ID. @param action [Proc] This is a block which will run when the hook
is triggered. It is passed one parameter, which is the event. It is important to note that it will NOT be passed an {Event}, rather, it will be passed a sub event designated in {Event::EVENT_CLASSES}.
# File lib/chatx/hooks.rb, line 40 def add_hook(room_id, event, server: @default_server, &action) @hooks[server] ||= {} @hook ||= Hook.new(self) if event == '*' @hooks[server]['*'] ||= [] @hooks[server]['*'].push [room_id, action] else @hooks[server][EVENT_SHORTHAND.index(event)] ||= [] @hooks[server][EVENT_SHORTHAND.index(event)].push [room_id, action] end end
# File lib/chatx/auth.rb, line 2 def authenticate(sites = ["stackexchange"]) if sites.is_a? Hash sites.each do |site, cookie_str| cookie = Mechanize::Cookie.new("acct", cookie_str) cookie.domain = ".#{site}.com" cookie.path = "/" @agent.cookie_jar.add!(cookie) end true else sites = [sites] unless sites.is_a?(Array) openid = @agent.get "https://openid.stackexchange.com/account/login" fkey_input = openid.search "//input[@name='fkey']" fkey = fkey_input.empty? ? "" : fkey_input.attribute("value") @agent.post("https://openid.stackexchange.com/account/login/submit", fkey: fkey, email: @email, password: @password) auth_results = sites.map { |s| site_auth(s) } failed = auth_results.any?(&:!) !failed end end
# File lib/chatx.rb, line 213 def cancel_stars(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/unstar", fkey: fkey) end
# File lib/chatx.rb, line 270 def current_rooms @websockets.map do |server, ws| [server, ws.in_rooms[:rooms]] end.to_h end
# File lib/chatx.rb, line 218 def delete(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/delete", fkey: fkey) end
# File lib/chatx.rb, line 223 def edit(message_id, new_message, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}", fkey: fkey, text: new_message) end
This opens up the DSL created by {Hook}.
# File lib/chatx/hooks.rb, line 60 def gen_hooks(&block) @hook ||= Hook.new(self) @hook.instance_eval(&block) end
The immediate exit point when a message is recieved from a websocket. It grabs the relevant hooks, creates the event, and passes the event to the hooks.
It also spawns a new thread for every hook. This could lead to errors later, but it prevents 409 errors which shut the bot up for a while.
@note This method is strictly internal. @param data [Hash] It's the JSON passed by the websocket
# File lib/chatx/hooks.rb, line 11 def handle(data, server:) data.each do |room, evt| next if evt.keys.first != 'e' evt['e'].each do |e| event_type = e['event_type'].to_i - 1 room_id = room[1..-1].to_i event = ChatX::Event.new e, server, self @ws_json_logger.info "#{event.type_long}: #{event.hash}" @ws_json_logger.info "Currently in rooms #{@rooms.keys} / #{current_rooms}" next if @rooms[room_id].nil? @rooms[room_id.to_i][:events].push(event) @hooks[server] ||= {} (Array(@hooks[server][event_type.to_i])+Array(@hooks[server]['*'])+Array(@hooks['*'][event_type.to_i])).each do |rm_id, hook| Thread.new do @hook.current_room = room_id hook.call(event, room_id) if rm_id == room_id || rm_id == '*' end end end end end
Attempts to join a room, and for every room joined opens a websocket. Websockets seem to be the way to show your presence in a room. It's weird that way.
Each websocket is added to the @websockets instance variable which can be read but not written to.
@param room_id [#to_i] A valid room ID on the server designated by the
server param.
@keyword server [String] A string referring to the relevant server. The
default value is set by the @default_server instance variable.
@return [Hash] The hash of currently active websockets.
# File lib/chatx.rb, line 93 def join_room(room_id, server: @default_server) @logger.info "Joining #{room_id} on server #{server}" fkey = get_fkey(server, "rooms/#{room_id}") @agent.get("https://chat.#{server}.com/rooms/#{room_id}", fkey: fkey) events_json = @agent.post("https://chat.#{server}.com/chats/#{room_id}/events", fkey: fkey, since: 0, mode: "Messages", msgCount: 100).body events = JSON.parse(events_json)["events"] @logger.info "Retrieved events (length #{events.length})" ws_auth_data = @agent.post("https://chat.#{server}.com/ws-auth", roomid: room_id, fkey: fkey) @logger.info "Began room auth for room id: #{room_id}" @rooms[room_id.to_i] = {} @rooms[room_id.to_i][:events] = events.map do |e| begin ChatX::Event.new e, server, self rescue ChatX::InitializationDataException => e @logger.warn "Caught InitializationDataException during events population (#{e}); skipping event" nil end end.compact @logger.info "Rooms: #{@rooms.keys}" unless @websockets[server].nil? || @websockets[server].dead @logger.info "Websocket #{@websockets[server]} already open; clearing." @websockets[server].close @websockets[server] = nil end @logger.info "SOOOOOOOOOOOOOOOOOOOOOOOOOOOO" ws_uri = JSON.parse(ws_auth_data.body)["url"] last_event_time = events.max_by { |event| event['time_stamp'] }['time_stamp'] cookies = (@agent.cookies.map { |cookie| "#{cookie.name}=#{cookie.value}" if cookie.domain == "chat.#{server}.com" || cookie.domain == "#{server}.com" } - [nil]).join("; ") @logger.info "Launching new WSCLient" @websockets[server] = WSClient.new("#{ws_uri}?l=#{last_event_time}", cookies, self, server) @logger.info "New websocket open (#{@websockets[server]}" end
# File lib/chatx.rb, line 142 def join_rooms(*rooms, server: @default_server) rooms.flatten.each { |rid| join_room(rid) } end
Kills all active websockets for the bot.
# File lib/chatx.rb, line 250 def kill @websockets.values.each(&:close) end
# File lib/chatx.rb, line 264 def leave_all_rooms @rooms.each_key do |room_id| leave_room(room_id) end end
Leaves the room. Not much else to say… @param room_id [#to_i] The ID of the room to leave @keyword server [String] The chat server that room is on. @return A meaningless value
# File lib/chatx.rb, line 150 def leave_room(room_id, server: @default_server) fkey = get_fkey("stackexchange", "/rooms/#{room_id}") @rooms.delete(room_id) unless @rooms[room_id].nil? @agent.post("https://chat.#{server}.com/chats/leave/#{room_id}", fkey: fkey, quiet: "true") end
Logs the bot into the three SE chat servers.
@return [Boolean] A bool indicating the result of authentication: true if all three servers were authenticated successfully, false otherwise.
# File lib/chatx.rb, line 75 def login(servers = @default_server) servers = [servers] unless servers.is_a?(Array) || servers.is_a?(Hash) return if authenticate(servers) throw "Login failed; Exiting." end
A simpler syntax for creating {add_hook} to the “Message Posted” event. @param room_id [#to_i] The room to listen in @see add_hook
# File lib/chatx/hooks.rb, line 55 def on_message(room_id) add_hook(room_id, 'Message Posted') { |e| yield(e.hash['content']) } end
# File lib/chatx.rb, line 233 def pin(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey) end
# File lib/chatx.rb, line 243 def pinned?(message_id, server: @default_server) page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}") return false if page.css("#message-#{message_id} .flash .stars")[0].nil? page.css("#message-#{message_id} .flash .stars")[0].attr("class").include?("owner-star") end
# File lib/chatx.rb, line 254 def rejoin ThreadsWait.all_waits @websockets.values.map(&:thread) leave_all_rooms end
Speaks in a room! Not much to say here, but much to say in the room that is passed!
If you're trying to reply to a message, please use the {Message#reply} method. @param room_id [#to_i] The ID of the room to be spoken in @param content [String] The text of message to send @keyword server [String] The server to send the messon on. @return A meaningless value
# File lib/chatx.rb, line 165 def say(content, room_id, server: @default_server) fkey = get_fkey(server, "/rooms/#{room_id}") if content.to_s.empty? @logger.warn "Message is empty, not posting: '#{content}'" return end @logger.warn "Message too long, truncating each line to 500 chars: #{content}" if content.to_s.split("\n").any? { |line| line.length > 500 } resp ||= nil loop do begin 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")) rescue Mechanize::ResponseCodeError @logger.error "Posting message to room #{room_id} failed. Retrying... #{content.to_s.split("\n").map { |line| line[0...500] }.join("\n")}" sleep 0.3 # A magic number I just chose for no reason retry end break unless JSON.parse(resp.content)["id"].nil? content = " #{content}" end return JSON.parse(resp.content)["id"].to_i end
# File lib/chatx/auth.rb, line 29 def site_auth(site) # Get fkey login_page = @agent.get "https://#{site}.com/users/login" fkey_input = login_page.search "//input[@name='fkey']" fkey = fkey_input.attribute('value') @agent.post("https://#{site}.com/users/authenticate", fkey: fkey, openid_identifier: 'https://openid.stackexchange.com') home = @agent.get "https://chat.#{site}.com" if home.search(".topbar-links span.topbar-menu-links a").first.text.casecmp('log in').zero? @logger.warn "Login to #{site} failed :(" false else @logger.info "Login to #{site} successful!" true end end
# File lib/chatx.rb, line 197 def star(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey) unless starred?(message_id) end
# File lib/chatx.rb, line 192 def star_count(message_id, server: @default_server) page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}") page.css("#message-#{message_id} .flash .star .times")[0].content.to_i end
# File lib/chatx.rb, line 207 def starred?(message_id, server: @default_server) page = @agent.get("https://chat.#{server}.com/transcript/message/#{message_id}") return false if page.css("#message-#{message_id} .flash .stars")[0].nil? page.css("#message-#{message_id} .flash .stars")[0].attr("class").include?("user-star") end
# File lib/chatx.rb, line 259 def stop leave_all_rooms @websockets.values.map(&:thread).each(&:kill) end
# File lib/chatx.rb, line 228 def toggle_pin(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey) end
# File lib/chatx.rb, line 187 def toggle_star(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey) end
# File lib/chatx.rb, line 238 def unpin(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/owner-star", fkey: fkey) end
# File lib/chatx.rb, line 202 def unstar(message_id, server: @default_server) fkey = get_fkey("stackexchange") @agent.post("https://chat.#{server}.com/messages/#{message_id}/star", fkey: fkey) if starred?(message_id) end
Private Instance Methods
# File lib/chatx.rb, line 278 def get_fkey(server, uri = "") @agent.get("https://chat.#{server}.com/#{uri}").search("//input[@name='fkey']").attribute("value") rescue Mechanize::ResponseCodeError @logger.error "Getting fkey failed for uri https://chat.#{server}.com/#{uri}. Retrying..." sleep 1 # A magic number I just chose for no reason retry end