gruubY 0.2.0

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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +6 -0
  4. data/Rakefile +51 -0
  5. data/config/config.rb +5 -0
  6. data/example/bot.rb +160 -0
  7. data/example/generate_user_session.rb +81 -0
  8. data/example/ntgcalls.rb +80 -0
  9. data/example/tdlib.rb +77 -0
  10. data/example/userbot.rb +133 -0
  11. data/lib/grubY/api.rb +363 -0
  12. data/lib/grubY/async.rb +13 -0
  13. data/lib/grubY/bot.rb +90 -0
  14. data/lib/grubY/bound.rb +14 -0
  15. data/lib/grubY/client.rb +296 -0
  16. data/lib/grubY/context.rb +242 -0
  17. data/lib/grubY/dispatcher.rb +60 -0
  18. data/lib/grubY/enums.rb +62 -0
  19. data/lib/grubY/exception.rb +5 -0
  20. data/lib/grubY/file_stream.rb +17 -0
  21. data/lib/grubY/filters.rb +292 -0
  22. data/lib/grubY/group_manager.rb +213 -0
  23. data/lib/grubY/handlers.rb +170 -0
  24. data/lib/grubY/keyboard.rb +49 -0
  25. data/lib/grubY/media.rb +38 -0
  26. data/lib/grubY/middleware.rb +18 -0
  27. data/lib/grubY/ntgcalls/native.rb +100 -0
  28. data/lib/grubY/ntgcalls.rb +478 -0
  29. data/lib/grubY/plugin.rb +14 -0
  30. data/lib/grubY/raw.rb +25 -0
  31. data/lib/grubY/raw_types.rb +57 -0
  32. data/lib/grubY/retry.rb +14 -0
  33. data/lib/grubY/server.rb +18 -0
  34. data/lib/grubY/session.rb +30 -0
  35. data/lib/grubY/tdlib/client.rb +565 -0
  36. data/lib/grubY/tdlib/client_manager.rb +37 -0
  37. data/lib/grubY/tdlib/decorators.rb +17 -0
  38. data/lib/grubY/tdlib/errors.rb +6 -0
  39. data/lib/grubY/tdlib/group_manager.rb +58 -0
  40. data/lib/grubY/tdlib/native.rb +72 -0
  41. data/lib/grubY/tdlib/schema_builder.rb +237 -0
  42. data/lib/grubY/tdlib/tdjson.rb +49 -0
  43. data/lib/grubY/tdlib/user_session.rb +69 -0
  44. data/lib/grubY/types/base_object.rb +72 -0
  45. data/lib/grubY/types/bound_entities.rb +245 -0
  46. data/lib/grubY/types/chat.rb +93 -0
  47. data/lib/grubY/types/chat_info.rb +31 -0
  48. data/lib/grubY/types/extra.rb +251 -0
  49. data/lib/grubY/types/message.rb +295 -0
  50. data/lib/grubY/types/message_entity.rb +14 -0
  51. data/lib/grubY/types/registry.rb +67 -0
  52. data/lib/grubY/types/user.rb +31 -0
  53. data/lib/grubY/webapp.rb +22 -0
  54. data/lib/grubY.rb +41 -0
  55. data/plugins/logger.rb +7 -0
  56. data/plugins/media_tools.rb +13 -0
  57. metadata +110 -0
data/lib/grubY/raw.rb ADDED
@@ -0,0 +1,25 @@
1
+ module GrubY
2
+ module Raw
3
+ module_function
4
+
5
+ def call(api, method, params = {})
6
+ api.raw(method, params)
7
+ end
8
+
9
+ def td_call(td_client, query, timeout: 30.0)
10
+ if td_client.respond_to?(:raw)
11
+ td_client.raw(query, timeout: timeout)
12
+ else
13
+ td_client.invoke(query, timeout: timeout)
14
+ end
15
+ end
16
+
17
+ def td_call!(td_client, query, timeout: 30.0)
18
+ response = td_call(td_client, query, timeout: timeout)
19
+ if response.is_a?(Hash) && response["@type"] == "error"
20
+ raise StandardError, "TDLib raw error: #{response['message']}"
21
+ end
22
+ response
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ require "json"
2
+ require_relative "types/base_object"
3
+
4
+ module GrubY
5
+ module RawTypes
6
+ class UpdateGroupCall < GrubY::BaseObject
7
+ fields :group_call
8
+ end
9
+
10
+ module_function
11
+
12
+ def to_data_json(data)
13
+ {
14
+ "_" => "DataJSON",
15
+ "data" => data.is_a?(String) ? data : JSON.generate(data)
16
+ }
17
+ end
18
+
19
+ def input_group_call(id:, access_hash:)
20
+ {
21
+ "_" => "InputGroupCall",
22
+ "id" => id.to_i,
23
+ "access_hash" => access_hash.to_i
24
+ }
25
+ end
26
+
27
+ def input_peer_self
28
+ {
29
+ "_" => "InputPeerSelf"
30
+ }
31
+ end
32
+
33
+ def join_group_call(call:, params:, muted: false, video_stopped: true, invite_hash: nil, join_as: nil)
34
+ {
35
+ "_" => "JoinGroupCall",
36
+ "call" => call,
37
+ "join_as" => join_as || input_peer_self,
38
+ "params" => params,
39
+ "muted" => !!muted,
40
+ "video_stopped" => !!video_stopped,
41
+ "invite_hash" => invite_hash
42
+ }.compact
43
+ end
44
+
45
+ def leave_group_call(call:, source:)
46
+ {
47
+ "_" => "LeaveGroupCall",
48
+ "call" => call,
49
+ "source" => source.to_i
50
+ }
51
+ end
52
+
53
+ def update_group_call(payload)
54
+ UpdateGroupCall.new(payload)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ module GrubY
2
+ class Retry
3
+ def self.call(times=3)
4
+ tries = 0
5
+ begin
6
+ yield
7
+ rescue
8
+ tries += 1
9
+ retry if tries < times
10
+ raise
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ require "sinatra/base"
2
+ require "json"
3
+ require_relative "../../config/config"
4
+
5
+ module GrubY
6
+ class Server < Sinatra::Base
7
+ def initialize(bot)
8
+ super()
9
+ @bot = bot
10
+ end
11
+
12
+ post Config::WEBHOOK_PATH do
13
+ data = JSON.parse(request.body.read)
14
+ @bot.process_update(data)
15
+ "ok"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ require "json"
2
+
3
+ module GrubY
4
+ class Session
5
+ FILE = "storage/sessions.json"
6
+
7
+ def initialize
8
+ Dir.mkdir("storage") unless Dir.exist?("storage")
9
+ File.write(FILE, "{}") unless File.exist?(FILE)
10
+ @data = JSON.parse(File.read(FILE))
11
+ end
12
+
13
+ def get(user_id)
14
+ @data[user_id.to_s] ||= {}
15
+ end
16
+
17
+ def set(user_id, value)
18
+ @data[user_id.to_s] = value
19
+ save
20
+ end
21
+
22
+ def save
23
+ File.write(FILE, JSON.pretty_generate(@data))
24
+ end
25
+
26
+ def to_h
27
+ @data.dup
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,565 @@
1
+ require "json"
2
+ require "logger"
3
+ require "timeout"
4
+ require "fileutils"
5
+ require "thread"
6
+ require_relative "decorators"
7
+ require_relative "errors"
8
+ require_relative "group_manager"
9
+ require_relative "tdjson"
10
+
11
+ module GrubY
12
+ module TDLib
13
+ class Client
14
+ include Decorators
15
+
16
+ Handler = Struct.new(
17
+ :id,
18
+ :update_type,
19
+ :block,
20
+ :filter,
21
+ :position,
22
+ :inner_object,
23
+ :timeout,
24
+ keyword_init: true
25
+ )
26
+
27
+ attr_reader :api_id, :api_hash, :database_directory, :files_directory, :group_manager
28
+ attr_reader :authorization_state, :td_options
29
+
30
+ def initialize(
31
+ api_id:,
32
+ api_hash:,
33
+ database_directory: "storage/tdlib",
34
+ files_directory: "storage/tdlib/files",
35
+ phone_number: nil,
36
+ bot_token: nil,
37
+ tdjson_path: nil,
38
+ database_encryption_key: "",
39
+ use_test_dc: false,
40
+ system_language_code: "en",
41
+ device_model: "GrubY",
42
+ system_version: RUBY_PLATFORM,
43
+ application_version: "0.2.0",
44
+ options: {},
45
+ workers: 4,
46
+ queue_size: 1_000,
47
+ default_handler_timeout: nil,
48
+ td_verbosity: 1
49
+ )
50
+ @api_id = api_id
51
+ @api_hash = api_hash
52
+ @database_directory = database_directory
53
+ @files_directory = files_directory
54
+ @phone_number = phone_number
55
+ @bot_token = bot_token
56
+ @database_encryption_key = database_encryption_key.to_s
57
+ @use_test_dc = use_test_dc
58
+ @system_language_code = system_language_code
59
+ @device_model = device_model
60
+ @system_version = system_version
61
+ @application_version = application_version
62
+ @td_options = options
63
+ @workers = workers
64
+ @queue_size = queue_size
65
+ @default_handler_timeout = default_handler_timeout
66
+ @logger = Logger.new($stdout)
67
+ @logger.level = Logger::INFO
68
+
69
+ @handlers = Hash.new { |h, k| h[k] = [] }
70
+ @next_handler_id = 1
71
+ @pending = {}
72
+ @pending_mutex = Mutex.new
73
+ @sequence = 0
74
+ @running = false
75
+ @authorized = false
76
+ @authorization_state = nil
77
+ @updates_thread = nil
78
+ @workers_threads = []
79
+ @queue = Queue.new
80
+ @group_manager = GroupManager.new(self)
81
+ @local_handlers = {
82
+ "updateAuthorizationState" => method(:process_authorization_update),
83
+ "updateOption" => method(:process_update_option)
84
+ }
85
+
86
+ @tdjson = TdJson.new(lib_path: tdjson_path, verbosity: td_verbosity)
87
+ @native_client = @tdjson.create_client_id
88
+ end
89
+
90
+ def start
91
+ return self if @running
92
+
93
+ ensure_native_client!
94
+ ensure_storage!
95
+ verify_arguments!
96
+ @running = true
97
+ set_options
98
+ boot_authorization_state
99
+ start_update_loop
100
+ start_workers
101
+ self
102
+ end
103
+
104
+ alias connect start
105
+
106
+ def stop
107
+ return self unless @running
108
+
109
+ @running = false
110
+ @updates_thread&.kill
111
+ @updates_thread = nil
112
+ @workers_threads.each(&:kill)
113
+ @workers_threads.clear
114
+ send_query("@type": "close")
115
+ @tdjson.destroy(@native_client) if @native_client
116
+ @native_client = nil
117
+ self
118
+ end
119
+
120
+ alias disconnect stop
121
+ alias terminate stop
122
+ alias initialize_client start
123
+
124
+ def restart
125
+ stop
126
+ start
127
+ end
128
+
129
+ def run
130
+ start
131
+ idle
132
+ stop
133
+ end
134
+
135
+ def idle
136
+ @idle = true
137
+ trap_signals
138
+ sleep 0.3 while @idle
139
+ ensure
140
+ @idle = false
141
+ end
142
+
143
+ def add_handler(
144
+ update_type = nil,
145
+ filter: nil,
146
+ position: nil,
147
+ inner_object: false,
148
+ timeout: nil,
149
+ &block
150
+ )
151
+ raise ArgumentError, "handler block is required" unless block
152
+
153
+ id = @next_handler_id
154
+ @next_handler_id += 1
155
+ type = update_type.to_s
156
+ handler = Handler.new(
157
+ id: id,
158
+ update_type: type,
159
+ block: block,
160
+ filter: filter,
161
+ position: position,
162
+ inner_object: inner_object,
163
+ timeout: timeout
164
+ )
165
+ @handlers[type] << handler
166
+ sort_handlers(type)
167
+ id
168
+ end
169
+
170
+ def on(update_type = nil, **options, &block)
171
+ add_handler(update_type, **options, &block)
172
+ end
173
+
174
+ def remove_handler(handler_id = nil, &block)
175
+ removed = false
176
+ @handlers.each_key do |type|
177
+ before = @handlers[type].length
178
+ @handlers[type].delete_if do |handler|
179
+ (handler_id && handler.id == handler_id) || (block && handler.block == block)
180
+ end
181
+ removed ||= before != @handlers[type].length
182
+ end
183
+ removed
184
+ end
185
+
186
+ def send_query(query)
187
+ @tdjson.send(@native_client, with_extra(query))
188
+ end
189
+
190
+ def execute(query)
191
+ @tdjson.execute(@native_client, stringify_keys(query))
192
+ end
193
+
194
+ def invoke(query, timeout: 30.0, poll_interval: 0.05)
195
+ tagged = with_extra(query)
196
+ tag = extra_id(tagged["@extra"])
197
+ @pending_mutex.synchronize { @pending[tag] = nil }
198
+ @tdjson.send(@native_client, tagged)
199
+
200
+ deadline = Time.now + timeout
201
+ while Time.now < deadline
202
+ consume_once(poll_interval)
203
+ value = @pending_mutex.synchronize { @pending[tag] }
204
+ return value if value
205
+ end
206
+ raise Timeout::Error, "TDLib response timeout for #{query[:@type] || query['@type']}"
207
+ ensure
208
+ @pending_mutex.synchronize { @pending.delete(tag) } if tag
209
+ end
210
+
211
+ def raw(query, timeout: 30.0, poll_interval: 0.05)
212
+ invoke(query, timeout: timeout, poll_interval: poll_interval)
213
+ end
214
+
215
+ def raw!(query, timeout: 30.0, poll_interval: 0.05)
216
+ result = raw(query, timeout: timeout, poll_interval: poll_interval)
217
+ if result.is_a?(Hash) && result["@type"] == "error"
218
+ message = result["message"] || "unknown TDLib error"
219
+ raise StandardError, "TDLib raw call failed: #{message}"
220
+ end
221
+ result
222
+ end
223
+
224
+ def authorized?
225
+ @authorized
226
+ end
227
+
228
+ def check_authentication_code(code)
229
+ send_query("@type": "checkAuthenticationCode", code: code)
230
+ end
231
+
232
+ alias sign_in check_authentication_code
233
+
234
+ def check_authentication_password(password)
235
+ send_query("@type": "checkAuthenticationPassword", password: password)
236
+ end
237
+
238
+ alias check_password check_authentication_password
239
+
240
+ def check_authentication_bot_token(token = @bot_token)
241
+ send_query("@type": "checkAuthenticationBotToken", token: token)
242
+ end
243
+
244
+ alias sign_in_bot check_authentication_bot_token
245
+
246
+ def send_phone_number_code(phone_number, settings: nil)
247
+ payload = { "@type": "setAuthenticationPhoneNumber", phone_number: phone_number }
248
+ payload[:settings] = settings if settings
249
+ send_query(payload)
250
+ end
251
+
252
+ def resend_phone_number_code
253
+ send_query("@type": "resendAuthenticationCode")
254
+ end
255
+
256
+ def send_recovery_code
257
+ send_query("@type": "requestAuthenticationPasswordRecovery")
258
+ end
259
+
260
+ def recover_password(recovery_code, new_password: nil, new_hint: nil)
261
+ send_query(
262
+ "@type": "recoverAuthenticationPassword",
263
+ recovery_code: recovery_code,
264
+ new_password: new_password,
265
+ new_hint: new_hint
266
+ )
267
+ end
268
+
269
+ def log_out
270
+ send_query("@type": "logOut")
271
+ end
272
+
273
+ def get_active_sessions
274
+ invoke("@type": "getActiveSessions")
275
+ end
276
+
277
+ def reset_session(session_hash)
278
+ send_query("@type": "terminateSession", session_id: session_hash)
279
+ end
280
+
281
+ def reset_sessions
282
+ send_query("@type": "terminateAllOtherSessions")
283
+ end
284
+
285
+ def set_log_verbosity(level)
286
+ Native.td_set_log_verbosity_level(level.to_i)
287
+ end
288
+
289
+ def method_missing(name, *args, **kwargs, &block)
290
+ return super if block
291
+ return super unless args.empty?
292
+
293
+ type = self.class.camelize_td_type(name)
294
+ invoke({ "@type": type }.merge(kwargs))
295
+ end
296
+
297
+ def respond_to_missing?(_name, _include_private = false)
298
+ true
299
+ end
300
+
301
+ def native_client_key
302
+ @native_client.to_i
303
+ end
304
+
305
+ def self.compose(*clients, &block)
306
+ clients.each(&:start)
307
+ block.call if block
308
+ clients.each(&:idle)
309
+ ensure
310
+ clients.each(&:stop)
311
+ end
312
+
313
+ def self.camelize_td_type(name)
314
+ parts = name.to_s.split("_")
315
+ parts.first + parts[1..].map(&:capitalize).join
316
+ end
317
+
318
+ private
319
+
320
+ def ensure_storage!
321
+ FileUtils.mkdir_p(@database_directory)
322
+ FileUtils.mkdir_p(@files_directory)
323
+ end
324
+
325
+ def ensure_native_client!
326
+ @native_client ||= @tdjson.create_client_id
327
+ end
328
+
329
+ def verify_arguments!
330
+ raise TypeError, "api_id must be an Integer" unless @api_id.is_a?(Integer)
331
+ raise TypeError, "api_hash must be a String" unless @api_hash.is_a?(String)
332
+ end
333
+
334
+ def boot_authorization_state
335
+ send_query("@type": "getAuthorizationState")
336
+ end
337
+
338
+ def set_options
339
+ return unless @td_options.is_a?(Hash)
340
+
341
+ @td_options.each do |name, value|
342
+ option_payload = case value
343
+ when String
344
+ { "@type" => "optionValueString", "value" => value }
345
+ when Integer
346
+ { "@type" => "optionValueInteger", "value" => value }
347
+ when TrueClass, FalseClass
348
+ { "@type" => "optionValueBoolean", "value" => value }
349
+ else
350
+ next
351
+ end
352
+ send_query("@type": "setOption", "name": name.to_s, "value": option_payload)
353
+ end
354
+ end
355
+
356
+ def start_update_loop
357
+ @updates_thread = Thread.new do
358
+ while @running
359
+ begin
360
+ consume_once(0.5)
361
+ rescue => e
362
+ warn "[TDLIB] update loop error: #{e.class}: #{e.message}"
363
+ sleep 0.2
364
+ end
365
+ end
366
+ end
367
+ end
368
+
369
+ def start_workers
370
+ worker_count = @workers.to_i
371
+ return if worker_count <= 0
372
+
373
+ @workers_threads = worker_count.times.map do
374
+ Thread.new do
375
+ while @running
376
+ begin
377
+ update = @queue.pop
378
+ run_update(update)
379
+ rescue StandardError => e
380
+ warn "[TDLIB] worker failed: #{e.class}: #{e.message}"
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ def consume_once(timeout)
388
+ update = @tdjson.receive(@native_client, timeout: timeout)
389
+ return unless update
390
+ handle_incoming(update)
391
+ end
392
+
393
+ def handle_incoming(update)
394
+ update_extra_id = extra_id(update["@extra"])
395
+ if update_extra_id && @pending_mutex.synchronize { @pending.key?(update_extra_id) }
396
+ @pending_mutex.synchronize { @pending[update_extra_id] = update }
397
+ return
398
+ end
399
+
400
+ local_handler = @local_handlers[update["@type"]]
401
+ local_handler.call(update) if local_handler
402
+
403
+ if @workers_threads.empty?
404
+ run_update(update)
405
+ return
406
+ end
407
+
408
+ if @queue.size > @queue_size
409
+ warn "[TDLIB] queue is full; dropping update #{update["@type"]}"
410
+ return
411
+ end
412
+
413
+ @queue << update
414
+ end
415
+
416
+ def process_authorization_update(update)
417
+ return unless update["@type"] == "updateAuthorizationState"
418
+
419
+ state = update.dig("authorization_state", "@type")
420
+ @authorization_state = state
421
+ case state
422
+ when "authorizationStateWaitTdlibParameters"
423
+ set_options
424
+ send_query(
425
+ "@type": "setTdlibParameters",
426
+ parameters: {
427
+ "@type": "tdlibParameters",
428
+ use_test_dc: @use_test_dc,
429
+ database_directory: @database_directory,
430
+ files_directory: @files_directory,
431
+ use_file_database: true,
432
+ use_chat_info_database: true,
433
+ use_message_database: true,
434
+ use_secret_chats: true,
435
+ api_id: @api_id,
436
+ api_hash: @api_hash,
437
+ system_language_code: @system_language_code,
438
+ device_model: @device_model,
439
+ system_version: @system_version,
440
+ application_version: @application_version
441
+ }
442
+ )
443
+ when "authorizationStateWaitEncryptionKey"
444
+ send_query(
445
+ "@type": "checkDatabaseEncryptionKey",
446
+ encryption_key: @database_encryption_key
447
+ )
448
+ when "authorizationStateWaitPhoneNumber"
449
+ if @bot_token
450
+ check_authentication_bot_token(@bot_token)
451
+ elsif @phone_number
452
+ send_query("@type": "setAuthenticationPhoneNumber", phone_number: @phone_number)
453
+ end
454
+ when "authorizationStateWaitCode"
455
+ run_update("@type" => "authCodeNeeded", "hint" => "Call check_authentication_code(code)")
456
+ when "authorizationStateWaitPassword"
457
+ run_update("@type" => "authPasswordNeeded", "hint" => "Call check_authentication_password(password)")
458
+ when "authorizationStateWaitRegistration"
459
+ run_update("@type" => "authRegistrationNeeded")
460
+ when "authorizationStateReady"
461
+ @authorized = true
462
+ run_update("@type" => "clientReady")
463
+ when "authorizationStateClosed"
464
+ @authorized = false
465
+ end
466
+ end
467
+
468
+ def process_update_option(update)
469
+ value = update["value"]
470
+ parsed = if value.is_a?(Hash)
471
+ value["value"]
472
+ else
473
+ value
474
+ end
475
+ @td_options[update["name"]] = parsed
476
+ end
477
+
478
+ def run_update(update)
479
+ run_handlers_for("initializer", update)
480
+ run_handlers_for(update["@type"].to_s, update)
481
+ rescue StopHandlers
482
+ ensure
483
+ run_handlers_for("finalizer", update)
484
+ end
485
+
486
+ def run_handlers_for(type, update)
487
+ handlers = @handlers[type]
488
+ return if handlers.nil? || handlers.empty?
489
+
490
+ handlers.each do |handler|
491
+ payload = handler.inner_object ? extract_inner_object(update) : update
492
+ next unless pass_filter?(handler, payload)
493
+
494
+ call_handler(handler, payload)
495
+ rescue StopHandlers
496
+ raise
497
+ rescue StandardError => e
498
+ warn "[TDLIB] handler failed: #{e.class}: #{e.message}"
499
+ end
500
+ end
501
+
502
+ def extract_inner_object(update)
503
+ return update["message"] if update["@type"] == "updateNewMessage"
504
+
505
+ update
506
+ end
507
+
508
+ def pass_filter?(handler, payload)
509
+ return true unless handler.filter.respond_to?(:call)
510
+
511
+ handler.filter.call(payload)
512
+ end
513
+
514
+ def call_handler(handler, payload)
515
+ timeout = handler.timeout || @default_handler_timeout
516
+ if timeout
517
+ Timeout.timeout(timeout) { handler.block.call(payload) }
518
+ else
519
+ handler.block.call(payload)
520
+ end
521
+ end
522
+
523
+ def sort_handlers(type)
524
+ @handlers[type].sort_by! do |h|
525
+ [h.position.nil? ? 1 : 0, h.position || 0]
526
+ end
527
+ end
528
+
529
+ def with_extra(query)
530
+ @sequence += 1
531
+ q = stringify_keys(query)
532
+ q["@extra"] ||= { "id" => "gruby-#{@sequence}" }
533
+ q
534
+ end
535
+
536
+ def extra_id(extra)
537
+ return nil if extra.nil?
538
+ return extra["id"] if extra.is_a?(Hash)
539
+
540
+ extra
541
+ end
542
+
543
+ def stringify_keys(hash)
544
+ hash.each_with_object({}) do |(k, v), out|
545
+ key = k.to_s
546
+ out[key] = case v
547
+ when Hash
548
+ stringify_keys(v)
549
+ when Array
550
+ v.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
551
+ else
552
+ v
553
+ end
554
+ end
555
+ end
556
+
557
+ def trap_signals
558
+ %w[INT TERM].each do |sig|
559
+ Signal.trap(sig) { @idle = false }
560
+ rescue ArgumentError
561
+ end
562
+ end
563
+ end
564
+ end
565
+ end