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
@@ -0,0 +1,49 @@
1
+ module GrubY
2
+ class Keyboard
3
+ class << self
4
+ def inline(rows)
5
+ { inline_keyboard: rows }
6
+ end
7
+
8
+ def reply(rows, resize_keyboard: true, one_time_keyboard: false, is_persistent: false)
9
+ {
10
+ keyboard: rows,
11
+ resize_keyboard: resize_keyboard,
12
+ one_time_keyboard: one_time_keyboard,
13
+ is_persistent: is_persistent
14
+ }
15
+ end
16
+
17
+ def button(text, data = nil)
18
+ {
19
+ text: text.to_s,
20
+ callback_data: data.to_s
21
+ }
22
+ end
23
+
24
+ def url_button(text, url)
25
+ {
26
+ text: text.to_s,
27
+ url: url.to_s
28
+ }
29
+ end
30
+
31
+ def web_app_button(text, url)
32
+ {
33
+ text: text.to_s,
34
+ web_app: {
35
+ url: url.to_s
36
+ }
37
+ }
38
+ end
39
+
40
+ def switch_inline_button(text, query:, in_current_chat: false)
41
+ key = in_current_chat ? :switch_inline_query_current_chat : :switch_inline_query
42
+ {
43
+ text: text.to_s,
44
+ key => query.to_s
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ require "net/http"
2
+
3
+ module GrubY
4
+ class Media
5
+ def initialize(token)
6
+ @base = "https://api.telegram.org/bot#{token}/"
7
+ end
8
+
9
+ def send_photo(chat_id, file)
10
+ post_file("sendPhoto", "photo", chat_id, file)
11
+ end
12
+
13
+ def send_video(chat_id, file)
14
+ post_file("sendVideo", "video", chat_id, file)
15
+ end
16
+
17
+ def send_audio(chat_id, file)
18
+ post_file("sendAudio", "audio", chat_id, file)
19
+ end
20
+
21
+ def post_file(method, field_name, chat_id, file)
22
+ uri = URI(@base + method)
23
+
24
+ File.open(file, "rb") do |io|
25
+ req = Net::HTTP::Post.new(uri)
26
+ form_data = [
27
+ ["chat_id", chat_id],
28
+ [field_name, io]
29
+ ]
30
+
31
+ req.set_form form_data, "multipart/form-data"
32
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
33
+ http.request(req)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ module GrubY
2
+ class Middleware
3
+ def initialize
4
+ @stack = []
5
+ end
6
+
7
+ def use(&block)
8
+ @stack << block
9
+ end
10
+
11
+ def run(ctx, &final)
12
+ chain = @stack.reverse.inject(final) do |nxt, mw|
13
+ proc { mw.call(ctx, nxt) }
14
+ end
15
+ chain.call
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,100 @@
1
+ require "ffi"
2
+
3
+ module GrubY
4
+ module NTgCalls
5
+ module Native
6
+ extend FFI::Library
7
+
8
+ class LibraryNotFoundError < StandardError; end
9
+
10
+ callback :ntg_async_callback, [:pointer], :void
11
+
12
+ class NtgAsyncStruct < FFI::Struct
13
+ layout :userData, :pointer,
14
+ :errorCode, :pointer,
15
+ :errorMessage, :pointer,
16
+ :promise, :ntg_async_callback
17
+ end
18
+
19
+ class NtgAudioDescriptionStruct < FFI::Struct
20
+ layout :mediaSource, :int,
21
+ :input, :string,
22
+ :sampleRate, :uint32,
23
+ :channelCount, :uint8,
24
+ :keepOpen, :bool
25
+ end
26
+
27
+ class NtgMediaDescriptionStruct < FFI::Struct
28
+ layout :microphone, :pointer,
29
+ :speaker, :pointer,
30
+ :camera, :pointer,
31
+ :screen, :pointer
32
+ end
33
+
34
+ class << self
35
+ def load!(path = nil)
36
+ return if @loaded
37
+
38
+ ffi_lib(resolve_library_path(path))
39
+ attach_function :ntg_init, [], :uintptr_t
40
+ attach_function :ntg_destroy, [:uintptr_t], :int
41
+ attach_function :ntg_get_version, [:pointer], :int
42
+ attach_function :ntg_create, [:uintptr_t, :int64, :pointer, NtgAsyncStruct.by_value], :int
43
+ attach_function :ntg_connect, [:uintptr_t, :int64, :string, :bool, NtgAsyncStruct.by_value], :int
44
+ attach_function :ntg_set_stream_sources, [:uintptr_t, :int64, :int, NtgMediaDescriptionStruct.by_value, NtgAsyncStruct.by_value], :int
45
+ attach_function :ntg_pause, [:uintptr_t, :int64, NtgAsyncStruct.by_value], :int
46
+ attach_function :ntg_resume, [:uintptr_t, :int64, NtgAsyncStruct.by_value], :int
47
+ attach_function :ntg_mute, [:uintptr_t, :int64, NtgAsyncStruct.by_value], :int
48
+ attach_function :ntg_unmute, [:uintptr_t, :int64, NtgAsyncStruct.by_value], :int
49
+ attach_function :ntg_stop, [:uintptr_t, :int64, NtgAsyncStruct.by_value], :int
50
+ attach_function :ntg_time, [:uintptr_t, :int64, :int, :pointer, NtgAsyncStruct.by_value], :int
51
+ attach_function :ntg_cpu_usage, [:uintptr_t, :pointer, NtgAsyncStruct.by_value], :int
52
+ @loaded = true
53
+ end
54
+
55
+ private
56
+
57
+ def resolve_library_path(custom_path)
58
+ return custom_path if custom_path && File.exist?(custom_path)
59
+
60
+ env_path = ENV["NTGCALLS_PATH"]
61
+ return env_path if env_path && File.exist?(env_path)
62
+
63
+ found = default_candidates.find { |path| File.exist?(path) }
64
+ return found if found
65
+
66
+ raise LibraryNotFoundError,
67
+ "NTgCalls shared library not found. Set NTGCALLS_PATH or pass lib_path."
68
+ end
69
+
70
+ def default_candidates
71
+ if Gem.win_platform?
72
+ [
73
+ "ntgcalls.dll",
74
+ "vendor/ntgcalls/windows/ntgcalls.dll",
75
+ "vendor/ntgcalls/ntgcalls.dll",
76
+ "C:/ntgcalls/bin/ntgcalls.dll"
77
+ ]
78
+ elsif RUBY_PLATFORM.include?("darwin")
79
+ [
80
+ "libntgcalls.dylib",
81
+ "vendor/ntgcalls/macos/libntgcalls.dylib",
82
+ "vendor/ntgcalls/darwin/libntgcalls.dylib",
83
+ "vendor/ntgcalls/libntgcalls.dylib",
84
+ "/usr/local/lib/libntgcalls.dylib",
85
+ "/opt/homebrew/lib/libntgcalls.dylib"
86
+ ]
87
+ else
88
+ [
89
+ "libntgcalls.so",
90
+ "vendor/ntgcalls/linux/libntgcalls.so",
91
+ "vendor/ntgcalls/libntgcalls.so",
92
+ "/usr/lib/libntgcalls.so",
93
+ "/usr/local/lib/libntgcalls.so"
94
+ ]
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,478 @@
1
+ require "timeout"
2
+ require "thread"
3
+ require "securerandom"
4
+ require_relative "ntgcalls/native"
5
+ require_relative "raw"
6
+ require_relative "tdlib/client"
7
+ require_relative "tdlib/user_session"
8
+ require_relative "raw_types"
9
+
10
+ module GrubY
11
+ module NTgCalls
12
+ class Error < StandardError; end
13
+
14
+ module StreamMode
15
+ CAPTURE = 0
16
+ PLAYBACK = 1
17
+ end
18
+
19
+ module MediaSource
20
+ FILE = 1 << 0
21
+ SHELL = 1 << 1
22
+ FFMPEG = 1 << 2
23
+ DEVICE = 1 << 3
24
+ DESKTOP = 1 << 4
25
+ EXTERNAL = 1 << 5
26
+ end
27
+
28
+ class Client
29
+ DEFAULT_TIMEOUT = 20
30
+ attr_reader :handle
31
+
32
+ def initialize(lib_path: nil)
33
+ Native.load!(lib_path)
34
+ @handle = Native.ntg_init
35
+ raise Error, "ntgcalls init failed" if @handle.nil? || @handle.zero?
36
+ end
37
+
38
+ def self.version(lib_path: nil)
39
+ Native.load!(lib_path)
40
+ version_ptr = FFI::MemoryPointer.new(:pointer)
41
+ code = Native.ntg_get_version(version_ptr)
42
+ raise Error, "ntg_get_version failed with code=#{code}" if code.negative?
43
+
44
+ read_c_string_ptr(version_ptr)
45
+ end
46
+
47
+ def close
48
+ return if @handle.nil? || @handle.zero?
49
+
50
+ code = Native.ntg_destroy(@handle)
51
+ raise Error, "ntg_destroy failed with code=#{code}" if code.negative?
52
+
53
+ @handle = nil
54
+ end
55
+
56
+ alias stop close
57
+
58
+ def create(chat_id:, timeout: DEFAULT_TIMEOUT)
59
+ call_async_string(:ntg_create, [handle!, chat_id.to_i], timeout: timeout)
60
+ end
61
+
62
+ def connect(chat_id:, params:, is_presentation: false, timeout: DEFAULT_TIMEOUT)
63
+ call_async(
64
+ :ntg_connect,
65
+ [handle!, chat_id.to_i, params.to_s, !!is_presentation],
66
+ timeout: timeout
67
+ )
68
+ end
69
+
70
+ def play(chat_id:, stream:, sample_rate: 48_000, channels: 2, keep_open: false, timeout: DEFAULT_TIMEOUT)
71
+ audio = Native::NtgAudioDescriptionStruct.new
72
+ audio[:mediaSource] = MediaSource::FILE
73
+ audio[:input] = stream.to_s
74
+ audio[:sampleRate] = sample_rate.to_i
75
+ audio[:channelCount] = channels.to_i
76
+ audio[:keepOpen] = !!keep_open
77
+
78
+ media = Native::NtgMediaDescriptionStruct.new
79
+ media[:microphone] = audio.to_ptr
80
+ media[:speaker] = FFI::Pointer::NULL
81
+ media[:camera] = FFI::Pointer::NULL
82
+ media[:screen] = FFI::Pointer::NULL
83
+
84
+ call_async(
85
+ :ntg_set_stream_sources,
86
+ [handle!, chat_id.to_i, StreamMode::CAPTURE, media],
87
+ timeout: timeout
88
+ )
89
+ end
90
+
91
+ def pause(chat_id:, timeout: DEFAULT_TIMEOUT)
92
+ call_async(:ntg_pause, [handle!, chat_id.to_i], timeout: timeout)
93
+ end
94
+
95
+ def resume(chat_id:, timeout: DEFAULT_TIMEOUT)
96
+ call_async(:ntg_resume, [handle!, chat_id.to_i], timeout: timeout)
97
+ end
98
+
99
+ def mute(chat_id:, timeout: DEFAULT_TIMEOUT)
100
+ call_async(:ntg_mute, [handle!, chat_id.to_i], timeout: timeout)
101
+ end
102
+
103
+ def unmute(chat_id:, timeout: DEFAULT_TIMEOUT)
104
+ call_async(:ntg_unmute, [handle!, chat_id.to_i], timeout: timeout)
105
+ end
106
+
107
+ def stop_call(chat_id:, timeout: DEFAULT_TIMEOUT)
108
+ call_async(:ntg_stop, [handle!, chat_id.to_i], timeout: timeout)
109
+ end
110
+
111
+ def cpu_usage(timeout: DEFAULT_TIMEOUT)
112
+ out = FFI::MemoryPointer.new(:double)
113
+ call_async(:ntg_cpu_usage, [handle!, out], timeout: timeout)
114
+ out.read_double
115
+ end
116
+
117
+ def time(chat_id:, mode: StreamMode::CAPTURE, timeout: DEFAULT_TIMEOUT)
118
+ out = FFI::MemoryPointer.new(:int64)
119
+ call_async(:ntg_time, [handle!, chat_id.to_i, mode.to_i, out], timeout: timeout)
120
+ out.read_int64
121
+ end
122
+
123
+ private
124
+
125
+ def handle!
126
+ raise Error, "ntgcalls client is closed" if @handle.nil? || @handle.zero?
127
+
128
+ @handle
129
+ end
130
+
131
+ def call_async_string(function_name, args, timeout:)
132
+ out_ptr = FFI::MemoryPointer.new(:pointer)
133
+ call_async(function_name, args + [out_ptr], timeout: timeout)
134
+ self.class.read_c_string_ptr(out_ptr)
135
+ end
136
+
137
+ def call_async(function_name, args, timeout:)
138
+ done = Queue.new
139
+ error_code = FFI::MemoryPointer.new(:int)
140
+ error_code.write_int(0)
141
+ error_message = FFI::MemoryPointer.new(:pointer)
142
+ error_message.write_pointer(FFI::Pointer::NULL)
143
+
144
+ callback = proc do |_user_data|
145
+ done << true
146
+ end
147
+
148
+ future = Native::NtgAsyncStruct.new
149
+ future[:userData] = FFI::Pointer::NULL
150
+ future[:errorCode] = error_code
151
+ future[:errorMessage] = error_message
152
+ future[:promise] = callback
153
+
154
+ code = Native.public_send(function_name, *args, future)
155
+ raise_native_error(code, error_message) if code.negative?
156
+
157
+ begin
158
+ Timeout.timeout(timeout) { done.pop }
159
+ rescue Timeout::Error
160
+ raise Error, "ntgcalls timeout while waiting for #{function_name}"
161
+ end
162
+
163
+ async_code = error_code.read_int
164
+ raise_native_error(async_code, error_message) if async_code.negative?
165
+ true
166
+ end
167
+
168
+ def raise_native_error(code, error_message_ptr)
169
+ message_ptr = error_message_ptr.read_pointer
170
+ native_message = message_ptr.null? ? nil : message_ptr.read_string
171
+ suffix = native_message.to_s.empty? ? "" : " (#{native_message})"
172
+ raise Error, "ntgcalls error code=#{code}#{suffix}"
173
+ end
174
+
175
+ class << self
176
+ def read_c_string_ptr(pointer_to_pointer)
177
+ ptr = pointer_to_pointer.read_pointer
178
+ return "" if ptr.null?
179
+
180
+ ptr.read_string
181
+ end
182
+ end
183
+ end
184
+
185
+ class MusicBot
186
+ DEFAULT_AUTH_TIMEOUT = 180
187
+
188
+ def initialize(
189
+ td_user_session:,
190
+ tdjson_path: nil,
191
+ ntgcalls_path: nil,
192
+ phone_number: nil,
193
+ td_verbosity: 1
194
+ )
195
+ td_cfg = GrubY::TDLib::UserSession.client_kwargs(td_user_session)
196
+ @td = GrubY::TDLib::Client.new(
197
+ **td_cfg,
198
+ phone_number: phone_number,
199
+ tdjson_path: tdjson_path,
200
+ td_verbosity: td_verbosity,
201
+ workers: 2
202
+ )
203
+ @ntg = Client.new(lib_path: ntgcalls_path)
204
+ @joined_chat_id = nil
205
+ @phone_number = phone_number
206
+ @td.on("updateAuthorizationState") { |update| handle_auth_state(update) }
207
+ end
208
+
209
+ def start(timeout: DEFAULT_AUTH_TIMEOUT)
210
+ @td.start
211
+ wait_until_authorized!(timeout: timeout)
212
+ self
213
+ end
214
+
215
+ def stop
216
+ @ntg.close
217
+ @td.stop
218
+ end
219
+
220
+ def join_and_play(chat:, audio:, invite_hash: "")
221
+ chat_id = resolve_chat_id(chat)
222
+ input_group_call = fetch_input_group_call(chat_id)
223
+ local_offer = @ntg.create(chat_id: chat_id)
224
+ audio_source_id = deterministic_audio_source_id(chat_id)
225
+
226
+ join_response = try_join_group_call(
227
+ input_group_call: input_group_call,
228
+ local_offer: local_offer,
229
+ audio_source_id: audio_source_id,
230
+ invite_hash: invite_hash
231
+ )
232
+ remote_params = extract_join_payload(join_response)
233
+ raise Error, "joinGroupCall response did not contain tgcalls params" if remote_params.to_s.empty?
234
+
235
+ @ntg.connect(chat_id: chat_id, params: remote_params)
236
+ @ntg.play(chat_id: chat_id, stream: audio)
237
+ @joined_chat_id = chat_id
238
+
239
+ {
240
+ chat_id: chat_id,
241
+ offer: local_offer,
242
+ remote_params: remote_params
243
+ }
244
+ end
245
+
246
+ def input_group_call(chat:)
247
+ chat_id = resolve_chat_id(chat)
248
+ fetch_input_group_call(chat_id)
249
+ end
250
+
251
+ def input_peer_self
252
+ GrubY::RawTypes.input_peer_self
253
+ end
254
+
255
+ def data_json(value)
256
+ GrubY::RawTypes.to_data_json(value)
257
+ end
258
+
259
+ def join_group_call(chat:, payload:, audio_source_id:, muted: false, video_stopped: true, invite_hash: nil)
260
+ call = input_group_call(chat: chat)
261
+ params = data_json(payload)
262
+ query = GrubY::RawTypes.join_group_call(
263
+ call: call,
264
+ params: params,
265
+ muted: muted,
266
+ video_stopped: video_stopped,
267
+ invite_hash: invite_hash,
268
+ join_as: input_peer_self
269
+ )
270
+ GrubY::Raw.td_call!(@td, mtproto_to_td_join_query(query, audio_source_id: audio_source_id))
271
+ end
272
+
273
+ def leave_group_call(chat:, source:)
274
+ call = input_group_call(chat: chat)
275
+ query = GrubY::RawTypes.leave_group_call(call: call, source: source)
276
+ GrubY::Raw.td_call!(@td, mtproto_to_td_leave_query(query))
277
+ end
278
+
279
+ def update_group_call(payload)
280
+ GrubY::RawTypes.update_group_call(payload)
281
+ end
282
+
283
+ def pause
284
+ @ntg.pause(chat_id: joined_chat_id!)
285
+ end
286
+
287
+ def resume
288
+ @ntg.resume(chat_id: joined_chat_id!)
289
+ end
290
+
291
+ def mute
292
+ @ntg.mute(chat_id: joined_chat_id!)
293
+ end
294
+
295
+ def unmute
296
+ @ntg.unmute(chat_id: joined_chat_id!)
297
+ end
298
+
299
+ def stop_call
300
+ @ntg.stop_call(chat_id: joined_chat_id!)
301
+ end
302
+
303
+ def cpu_usage
304
+ @ntg.cpu_usage
305
+ end
306
+
307
+ def time
308
+ @ntg.time(chat_id: joined_chat_id!)
309
+ end
310
+
311
+ private
312
+
313
+ def joined_chat_id!
314
+ raise Error, "no active group call in this bot instance" if @joined_chat_id.nil?
315
+
316
+ @joined_chat_id
317
+ end
318
+
319
+ def wait_until_authorized!(timeout:)
320
+ deadline = Time.now + timeout
321
+ loop do
322
+ return true if @td.authorized?
323
+ raise Error, "TDLib authorization timeout" if Time.now > deadline
324
+ sleep 0.25
325
+ end
326
+ end
327
+
328
+ def handle_auth_state(update)
329
+ state = update.dig("authorization_state", "@type")
330
+ case state
331
+ when "authorizationStateWaitPhoneNumber"
332
+ phone = @phone_number.to_s.strip
333
+ phone = prompt("Enter phone number (+countrycode...): ") if phone.empty?
334
+ @td.send_query("@type": "setAuthenticationPhoneNumber", phone_number: phone)
335
+ when "authorizationStateWaitCode"
336
+ code = prompt("Enter Telegram login code: ")
337
+ @td.check_authentication_code(code)
338
+ when "authorizationStateWaitPassword"
339
+ password = prompt("Enter 2FA password: ")
340
+ @td.check_authentication_password(password)
341
+ when "authorizationStateWaitOtherDeviceConfirmation"
342
+ link = update.dig("authorization_state", "link")
343
+ warn "[NTgCalls] Open Telegram > Settings > Devices > Link Desktop Device"
344
+ warn "[NTgCalls] #{link}" if link
345
+ end
346
+ end
347
+
348
+ def prompt(label)
349
+ print label
350
+ STDIN.gets.to_s.strip
351
+ end
352
+
353
+ def resolve_chat_id(chat)
354
+ text = chat.to_s.strip
355
+ raise Error, "chat is required" if text.empty?
356
+
357
+ return text.to_i if text.match?(/\A-?\d+\z/)
358
+
359
+ username = text.start_with?("@") ? text : "@#{text}"
360
+ result = @td.raw!("@type": "searchPublicChat", username: username.delete_prefix("@"))
361
+ chat_id = result["id"].to_i
362
+ raise Error, "unable to resolve chat '#{chat}'" if chat_id.zero?
363
+
364
+ chat_id
365
+ end
366
+
367
+ def fetch_input_group_call(chat_id)
368
+ chat = @td.raw!("@type": "getChat", chat_id: chat_id)
369
+ video_chat = chat["video_chat"] || {}
370
+ group_call_id = video_chat["group_call_id"] || dig_group_call_id(video_chat)
371
+ raise Error, "no active voice chat found for chat_id=#{chat_id}" if group_call_id.to_i.zero?
372
+
373
+ access_hash = video_chat["group_call_access_hash"] || dig_group_call_access_hash(video_chat)
374
+ {
375
+ "@type" => "inputGroupCall",
376
+ "id" => group_call_id.to_i,
377
+ "access_hash" => access_hash.to_i
378
+ }
379
+ end
380
+
381
+ def try_join_group_call(input_group_call:, local_offer:, audio_source_id:, invite_hash:)
382
+ candidates = [
383
+ {
384
+ "@type": "joinGroupCall",
385
+ "group_call_id": input_group_call["id"],
386
+ "participant_id": nil,
387
+ "audio_source_id": audio_source_id,
388
+ "payload": local_offer,
389
+ "is_muted": false,
390
+ "is_my_video_enabled": false,
391
+ "invite_hash": invite_hash.to_s
392
+ },
393
+ {
394
+ "@type": "joinGroupCall",
395
+ "input_group_call": input_group_call,
396
+ "join_parameters": {
397
+ "@type": "groupCallJoinParameters",
398
+ "payload": local_offer,
399
+ "audio_source_id": audio_source_id,
400
+ "is_muted": false,
401
+ "is_my_video_enabled": false,
402
+ "invite_hash": invite_hash.to_s
403
+ }
404
+ }
405
+ ]
406
+
407
+ last_error = nil
408
+ candidates.each do |query|
409
+ response = @td.raw(query)
410
+ return response unless response.is_a?(Hash) && response["@type"] == "error"
411
+
412
+ last_error = response["message"].to_s
413
+ end
414
+ raise Error, "joinGroupCall failed: #{last_error}"
415
+ end
416
+
417
+ def mtproto_to_td_join_query(mtproto_query, audio_source_id:)
418
+ {
419
+ "@type": "joinGroupCall",
420
+ input_group_call: mtproto_query["call"],
421
+ join_parameters: {
422
+ "@type": "groupCallJoinParameters",
423
+ payload: mtproto_query.dig("params", "data"),
424
+ audio_source_id: audio_source_id.to_i,
425
+ is_muted: !!mtproto_query["muted"],
426
+ is_my_video_enabled: !mtproto_query["video_stopped"],
427
+ invite_hash: mtproto_query["invite_hash"].to_s
428
+ }
429
+ }
430
+ end
431
+
432
+ def mtproto_to_td_leave_query(mtproto_query)
433
+ {
434
+ "@type": "leaveGroupCall",
435
+ input_group_call: mtproto_query["call"]
436
+ }
437
+ end
438
+
439
+ def extract_join_payload(response)
440
+ return response["text"] if response["text"].is_a?(String)
441
+ return response["payload"] if response["payload"].is_a?(String)
442
+
443
+ group_call_info = response["group_call_info"] || response["groupCallInfo"] || {}
444
+ return group_call_info["payload"] if group_call_info["payload"].is_a?(String)
445
+ return group_call_info["params"] if group_call_info["params"].is_a?(String)
446
+
447
+ params = response["params"] || response["connection_params"]
448
+ return params if params.is_a?(String)
449
+
450
+ ""
451
+ end
452
+
453
+ def deterministic_audio_source_id(chat_id)
454
+ seed = "#{chat_id}:#{Process.pid}:#{SecureRandom.hex(4)}"
455
+ value = seed.each_byte.reduce(0) { |acc, b| ((acc * 131) + b) & 0x7fffffff }
456
+ value.zero? ? 1 : value
457
+ end
458
+
459
+ def dig_group_call_id(video_chat)
460
+ group_call = video_chat["group_call"] || {}
461
+ group_call["id"]
462
+ end
463
+
464
+ def dig_group_call_access_hash(video_chat)
465
+ group_call = video_chat["group_call"] || {}
466
+ group_call["access_hash"]
467
+ end
468
+ end
469
+
470
+ class Bridge
471
+ def initialize(*_args, **_kwargs)
472
+ raise Error, "Use GrubY::NTgCalls::Client (pure Ruby and ntgcalls C bindings)."
473
+ end
474
+ end
475
+
476
+ class RBtgCalls < Bridge; end
477
+ end
478
+ end
@@ -0,0 +1,14 @@
1
+ module GrubY
2
+ class Plugin
3
+ def self.load(bot, path)
4
+ mod = Module.new
5
+ mod.module_eval(File.read(path), path)
6
+
7
+ if mod.respond_to?(:register)
8
+ mod.register(bot)
9
+ else
10
+ raise "Plugin must define register(bot)"
11
+ end
12
+ end
13
+ end
14
+ end