chatx 0.0.0.pre.pre3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.gitlab-ci.yml +27 -0
- data/.rubocop.yml +38 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +3 -0
- data/Guardfile +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/chatx.gemspec +52 -0
- data/lib/chatx.rb +271 -0
- data/lib/chatx/auth.rb +38 -0
- data/lib/chatx/events.rb +195 -0
- data/lib/chatx/hooks.rb +111 -0
- data/lib/chatx/models/helpers.rb +36 -0
- data/lib/chatx/models/message.rb +68 -0
- data/lib/chatx/models/room.rb +56 -0
- data/lib/chatx/models/user.rb +51 -0
- data/lib/chatx/polling.rb +46 -0
- data/lib/chatx/version.rb +3 -0
- data/lib/chatx/websocket.rb +76 -0
- metadata +252 -0
data/lib/chatx.rb
ADDED
@@ -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
|
data/lib/chatx/auth.rb
ADDED
@@ -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
|
data/lib/chatx/events.rb
ADDED
@@ -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
|