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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +6 -0
- data/Rakefile +51 -0
- data/config/config.rb +5 -0
- data/example/bot.rb +160 -0
- data/example/generate_user_session.rb +81 -0
- data/example/ntgcalls.rb +80 -0
- data/example/tdlib.rb +77 -0
- data/example/userbot.rb +133 -0
- data/lib/grubY/api.rb +363 -0
- data/lib/grubY/async.rb +13 -0
- data/lib/grubY/bot.rb +90 -0
- data/lib/grubY/bound.rb +14 -0
- data/lib/grubY/client.rb +296 -0
- data/lib/grubY/context.rb +242 -0
- data/lib/grubY/dispatcher.rb +60 -0
- data/lib/grubY/enums.rb +62 -0
- data/lib/grubY/exception.rb +5 -0
- data/lib/grubY/file_stream.rb +17 -0
- data/lib/grubY/filters.rb +292 -0
- data/lib/grubY/group_manager.rb +213 -0
- data/lib/grubY/handlers.rb +170 -0
- data/lib/grubY/keyboard.rb +49 -0
- data/lib/grubY/media.rb +38 -0
- data/lib/grubY/middleware.rb +18 -0
- data/lib/grubY/ntgcalls/native.rb +100 -0
- data/lib/grubY/ntgcalls.rb +478 -0
- data/lib/grubY/plugin.rb +14 -0
- data/lib/grubY/raw.rb +25 -0
- data/lib/grubY/raw_types.rb +57 -0
- data/lib/grubY/retry.rb +14 -0
- data/lib/grubY/server.rb +18 -0
- data/lib/grubY/session.rb +30 -0
- data/lib/grubY/tdlib/client.rb +565 -0
- data/lib/grubY/tdlib/client_manager.rb +37 -0
- data/lib/grubY/tdlib/decorators.rb +17 -0
- data/lib/grubY/tdlib/errors.rb +6 -0
- data/lib/grubY/tdlib/group_manager.rb +58 -0
- data/lib/grubY/tdlib/native.rb +72 -0
- data/lib/grubY/tdlib/schema_builder.rb +237 -0
- data/lib/grubY/tdlib/tdjson.rb +49 -0
- data/lib/grubY/tdlib/user_session.rb +69 -0
- data/lib/grubY/types/base_object.rb +72 -0
- data/lib/grubY/types/bound_entities.rb +245 -0
- data/lib/grubY/types/chat.rb +93 -0
- data/lib/grubY/types/chat_info.rb +31 -0
- data/lib/grubY/types/extra.rb +251 -0
- data/lib/grubY/types/message.rb +295 -0
- data/lib/grubY/types/message_entity.rb +14 -0
- data/lib/grubY/types/registry.rb +67 -0
- data/lib/grubY/types/user.rb +31 -0
- data/lib/grubY/webapp.rb +22 -0
- data/lib/grubY.rb +41 -0
- data/plugins/logger.rb +7 -0
- data/plugins/media_tools.rb +13 -0
- 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
|
data/lib/grubY/media.rb
ADDED
|
@@ -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
|
data/lib/grubY/plugin.rb
ADDED