Class: ChatBot

Inherits:
Object
  • Object
show all
Defined in:
lib/chatx.rb,
lib/chatx/auth.rb,
lib/chatx/hooks.rb,
lib/chatx/version.rb

Constant Summary collapse

VERSION =
'0.0.1'.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(email, password, **opts) ⇒ ChatBot

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

Parameters:

  • email (String)

    The Stack Exchange OpenID email

  • password (String)

    The Stack Exchange OpenID password



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 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

Instance Attribute Details

#agentObject (readonly)

Returns the value of attribute agent.



36
37
38
# File 'lib/chatx.rb', line 36

def agent
  @agent
end

#default_serverString

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

Returns:

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



36
# File 'lib/chatx.rb', line 36

attr_reader :rooms, :websockets, :logger, :agent

#hooksArray

Returns An array of the Hooks for the bot.

Returns:

  • (Array)

    An array of the Hooks for the bot.



36
# File 'lib/chatx.rb', line 36

attr_reader :rooms, :websockets, :logger, :agent

#loggerObject (readonly)

Returns the value of attribute logger.



36
37
38
# File 'lib/chatx.rb', line 36

def logger
  @logger
end

#roomsHash<Hash<Array>> (readonly)

Returns 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 Events from that room.

Returns:

  • (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 Events from that room.



36
37
38
# File 'lib/chatx.rb', line 36

def rooms
  @rooms
end

#websocketHash<room_id, Thread> (readonly)

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

Returns:

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



36
# File 'lib/chatx.rb', line 36

attr_reader :rooms, :websockets, :logger, :agent

#websocketsObject (readonly)

Returns the value of attribute websockets.



36
37
38
# File 'lib/chatx.rb', line 36

def websockets
  @websockets
end

Instance Method Details

#add_hook(room_id, event, server: @default_server, &action) ⇒ Object

A convinant way to hook into an event.

Parameters:

  • room_id (#to_i)

    The ID of th room to listen in.

  • event (String)

    The [EVENT_SHORTHAND] for the event ID.

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



40
41
42
43
44
45
46
47
48
49
50
# 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"]) ⇒ Object



2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 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) ⇒ Object



213
214
215
216
# 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_roomsObject



270
271
272
273
274
# 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) ⇒ Object



218
219
220
221
# 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) ⇒ Object



223
224
225
226
# 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) ⇒ Object

This opens up the DSL created by Hook.



60
61
62
63
# File 'lib/chatx/hooks.rb', line 60

def gen_hooks(&block)
  @hook ||= Hook.new(self)
  @hook.instance_eval(&block)
end

#handle(data, server:) ⇒ Object

Note:

This method is strictly internal.

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.

Parameters:

  • data (Hash)

    It’s the JSON passed by the websocket



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 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) ⇒ Hash

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.

Parameters:

  • room_id (#to_i)

    A valid room ID on the server designated by the server param.

Returns:

  • (Hash)

    The hash of currently active websockets.

Keyword Arguments:

  • server (String)

    A string referring to the relevant server. The default value is set by the @default_server instance variable.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# 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) ⇒ Object



142
143
144
# File 'lib/chatx.rb', line 142

def join_rooms(*rooms, server: @default_server)
  rooms.flatten.each { |rid| join_room(rid) }
end

#killObject

Kills all active websockets for the bot.



250
251
252
# File 'lib/chatx.rb', line 250

def kill
  @websockets.values.each(&:close)
end

#leave_all_roomsObject



264
265
266
267
268
# 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) ⇒ Object

Leaves the room. Not much else to say…

Parameters:

  • room_id (#to_i)

    The ID of the room to leave

Returns:

  • A meaningless value

Keyword Arguments:

  • server (String)

    The chat server that room is on.



150
151
152
153
154
# 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) ⇒ Boolean

Logs the bot into the three SE chat servers.

servers were authenticated successfully, false otherwise.

Returns:

  • (Boolean)

    A bool indicating the result of authentication: true if all three



75
76
77
78
79
# File 'lib/chatx.rb', line 75

def (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) ⇒ Object

A simpler syntax for creating #add_hook to the “Message Posted” event.

Parameters:

  • room_id (#to_i)

    The room to listen in

See Also:



55
56
57
# 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) ⇒ Object



233
234
235
236
# 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) ⇒ Boolean

Returns:

  • (Boolean)


243
244
245
246
247
# 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

#rejoinObject



254
255
256
257
# 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) ⇒ Object

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.

Parameters:

  • room_id (#to_i)

    The ID of the room to be spoken in

  • content (String)

    The text of message to send

Returns:

  • A meaningless value

Keyword Arguments:

  • server (String)

    The server to send the messon on.



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# 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) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/chatx/auth.rb', line 29

def site_auth(site)
  # Get fkey
   = @agent.get "https://#{site}.com/users/login"
  fkey_input = .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) ⇒ Object



197
198
199
200
# 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) ⇒ Object



192
193
194
195
# 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) ⇒ Boolean

Returns:

  • (Boolean)


207
208
209
210
211
# 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

#stopObject



259
260
261
262
# 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) ⇒ Object



228
229
230
231
# 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) ⇒ Object



187
188
189
190
# 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) ⇒ Object



238
239
240
241
# 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) ⇒ Object



202
203
204
205
# 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