class ChatBot

Constants

VERSION

Attributes

agent[R]

@!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.
default_server[RW]
hooks[RW]
logger[R]

@!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.
rooms[R]

@!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.
websockets[R]

@!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

new(email, password, **opts) click to toggle source

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

add_hook(room_id, event, server: @default_server, &action) click to toggle source

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
authenticate(sites = ["stackexchange"]) click to toggle source
# 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
cancel_stars(message_id, server: @default_server) click to toggle source
# 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
current_rooms() click to toggle source
# File lib/chatx.rb, line 270
def current_rooms
  @websockets.map do |server, ws|
    [server, ws.in_rooms[:rooms]]
  end.to_h
end
delete(message_id, server: @default_server) click to toggle source
# 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
edit(message_id, new_message, server: @default_server) click to toggle source
# 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
gen_hooks(&block) click to toggle source

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
handle(data, server:) click to toggle source

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
join_room(room_id, server: @default_server) click to toggle source

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
join_rooms(*rooms, server: @default_server) click to toggle source
# File lib/chatx.rb, line 142
def join_rooms(*rooms, server: @default_server)
  rooms.flatten.each { |rid| join_room(rid) }
end
kill() click to toggle source

Kills all active websockets for the bot.

# File lib/chatx.rb, line 250
def kill
  @websockets.values.each(&:close)
end
leave_all_rooms() click to toggle source
# File lib/chatx.rb, line 264
def leave_all_rooms
  @rooms.each_key do |room_id|
    leave_room(room_id)
  end
end
leave_room(room_id, server: @default_server) click to toggle source

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
login(servers = @default_server) click to toggle source

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
on_message(room_id) { |hash| ... } click to toggle source

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
pin(message_id, server: @default_server) click to toggle source
# 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
pinned?(message_id, server: @default_server) click to toggle source
# 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
rejoin() click to toggle source
# File lib/chatx.rb, line 254
def rejoin
  ThreadsWait.all_waits @websockets.values.map(&:thread)
  leave_all_rooms
end
say(content, room_id, server: @default_server) click to toggle source

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
site_auth(site) click to toggle source
# 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
star(message_id, server: @default_server) click to toggle source
# 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
star_count(message_id, server: @default_server) click to toggle source
# 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
starred?(message_id, server: @default_server) click to toggle source
# 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
stop() click to toggle source
# File lib/chatx.rb, line 259
def stop
  leave_all_rooms
  @websockets.values.map(&:thread).each(&:kill)
end
toggle_pin(message_id, server: @default_server) click to toggle source
# 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
toggle_star(message_id, server: @default_server) click to toggle source
# 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
unpin(message_id, server: @default_server) click to toggle source
# 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
unstar(message_id, server: @default_server) click to toggle source
# 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

get_fkey(server, uri = "") click to toggle source
# 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