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,37 @@
1
+ require "thread"
2
+ require_relative "tdjson"
3
+
4
+ module GrubY
5
+ module TDLib
6
+ class ClientManager
7
+ attr_reader :tdjson
8
+
9
+ def initialize(lib_path: nil, verbosity: 1)
10
+ @tdjson = TdJson.new(lib_path: lib_path, verbosity: verbosity)
11
+ @clients = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def add_client(client)
16
+ key = client.native_client_key
17
+ @mutex.synchronize { @clients[key] = client }
18
+ client
19
+ end
20
+
21
+ def remove_client(client)
22
+ key = client.native_client_key
23
+ @mutex.synchronize { @clients.delete(key) }
24
+ end
25
+
26
+ def each_client(&block)
27
+ snapshot = @mutex.synchronize { @clients.values.dup }
28
+ snapshot.each(&block)
29
+ end
30
+
31
+ def close
32
+ each_client(&:stop)
33
+ true
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module GrubY
2
+ module TDLib
3
+ module Decorators
4
+ def initializer(filter: nil, position: nil, timeout: nil, &block)
5
+ add_handler("initializer", filter: filter, position: position, timeout: timeout, &block)
6
+ end
7
+
8
+ def finalizer(filter: nil, position: nil, timeout: nil, &block)
9
+ add_handler("finalizer", filter: filter, position: position, timeout: timeout, &block)
10
+ end
11
+
12
+ def on_message(filter: nil, position: nil, timeout: nil, &block)
13
+ add_handler("updateNewMessage", filter: filter, position: position, timeout: timeout, inner_object: true, &block)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ module GrubY
2
+ module TDLib
3
+ class StopHandlers < StandardError; end
4
+ class AuthorizationError < StandardError; end
5
+ end
6
+ end
@@ -0,0 +1,58 @@
1
+ module GrubY
2
+ module TDLib
3
+ class GroupManager
4
+ def initialize(client)
5
+ @client = client
6
+ end
7
+
8
+ def ban_chat_member(chat_id:, member_id:, banned_until_date: 0, revoke_messages: true)
9
+ @client.banChatMember(
10
+ chat_id: chat_id,
11
+ member_id: member_id,
12
+ banned_until_date: banned_until_date,
13
+ revoke_messages: revoke_messages
14
+ )
15
+ end
16
+
17
+ def unban_chat_member(chat_id:, member_id:)
18
+ @client.unbanChatMember(chat_id: chat_id, member_id: member_id)
19
+ end
20
+
21
+ def set_chat_title(chat_id:, title:)
22
+ @client.setChatTitle(chat_id: chat_id, title: title)
23
+ end
24
+
25
+ def set_chat_description(chat_id:, description:)
26
+ @client.setChatDescription(chat_id: chat_id, description: description)
27
+ end
28
+
29
+ def set_slow_mode(chat_id:, delay:)
30
+ @client.setChatSlowModeDelay(chat_id: chat_id, slow_mode_delay: delay)
31
+ end
32
+
33
+ def pin_chat_message(chat_id:, message_id:, disable_notification: false)
34
+ @client.pinChatMessage(
35
+ chat_id: chat_id,
36
+ message_id: message_id,
37
+ disable_notification: disable_notification
38
+ )
39
+ end
40
+
41
+ def unpin_chat_message(chat_id:, message_id: nil)
42
+ @client.unpinChatMessage(chat_id: chat_id, message_id: message_id)
43
+ end
44
+
45
+ def leave_chat(chat_id:)
46
+ @client.leaveChat(chat_id: chat_id)
47
+ end
48
+
49
+ def get_chat_member(chat_id:, member_id:)
50
+ @client.getChatMember(chat_id: chat_id, member_id: member_id)
51
+ end
52
+
53
+ def get_chat(chat_id:)
54
+ @client.getChat(chat_id: chat_id)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,72 @@
1
+ require "ffi"
2
+
3
+ module GrubY
4
+ module TDLib
5
+ module Native
6
+ extend FFI::Library
7
+
8
+ class LibraryNotFoundError < StandardError; end
9
+
10
+ class << self
11
+ def load!(path = nil)
12
+ return if @loaded
13
+
14
+ ffi_lib(resolve_library_path(path))
15
+ attach_function :td_json_client_create, [], :pointer
16
+ attach_function :td_json_client_send, [:pointer, :string], :void
17
+ attach_function :td_json_client_receive, [:pointer, :double], :string
18
+ attach_function :td_json_client_execute, [:pointer, :string], :string
19
+ attach_function :td_json_client_destroy, [:pointer], :void
20
+ attach_function :td_set_log_verbosity_level, [:int], :void
21
+ @loaded = true
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_library_path(custom_path)
27
+ return custom_path if custom_path && File.exist?(custom_path)
28
+
29
+ env_path = ENV["TDJSON_PATH"]
30
+ return env_path if env_path && File.exist?(env_path)
31
+
32
+ candidates = default_candidates
33
+ found = candidates.find { |path| File.exist?(path) }
34
+ return found if found
35
+
36
+ raise LibraryNotFoundError,
37
+ "TDLib (tdjson) not found. Set TDJSON_PATH or pass tdjson_path."
38
+ end
39
+
40
+ def default_candidates
41
+ if Gem.win_platform?
42
+ [
43
+ "tdjson.dll",
44
+ "vendor/tdlib/windows/tdjson.dll",
45
+ "vendor/tdlib/win/tdjson.dll",
46
+ "vendor/tdlib/tdjson.dll",
47
+ "C:/tdlib/bin/tdjson.dll",
48
+ "C:/Program Files/TDLib/bin/tdjson.dll"
49
+ ]
50
+ elsif RUBY_PLATFORM.include?("darwin")
51
+ [
52
+ "libtdjson.dylib",
53
+ "vendor/tdlib/macos/libtdjson.dylib",
54
+ "vendor/tdlib/darwin/libtdjson.dylib",
55
+ "vendor/tdlib/libtdjson.dylib",
56
+ "/usr/local/lib/libtdjson.dylib",
57
+ "/opt/homebrew/lib/libtdjson.dylib"
58
+ ]
59
+ else
60
+ [
61
+ "libtdjson.so",
62
+ "vendor/tdlib/linux/libtdjson.so",
63
+ "vendor/tdlib/libtdjson.so",
64
+ "/usr/lib/libtdjson.so",
65
+ "/usr/local/lib/libtdjson.so"
66
+ ]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,237 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "cgi"
5
+ require "stringio"
6
+
7
+ module GrubY
8
+ module TDLib
9
+ class SchemaBuilder
10
+ TL_DEF_REGEX = /^(?<name>\w+)(?:#[0-9a-f]+)?\s+(?<params>.*)=\s+(?<type>\w+);$/
11
+ PARAM_DETAIL_REGEX = /(?<name>\w+):(?<type>[^\s]+)/
12
+ ROW_REGEX = /<tr>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<\/tr>/m
13
+ TAG_REGEX = /<[^>]*>/
14
+
15
+ def initialize(src:, version: "", commit: "", out: "tdlib.json")
16
+ @src = src
17
+ @version = version
18
+ @commit = commit
19
+ @out = out
20
+ end
21
+
22
+ def call
23
+ data = if @src.start_with?("http://", "https://")
24
+ fetch_and_parse_tl(@src)
25
+ else
26
+ parse_tl_from_file(@src)
27
+ end
28
+
29
+ data["version"] = @version
30
+ data["commit"] = @commit
31
+
32
+ begin
33
+ data["options"] = fetch_options
34
+ rescue StandardError => e
35
+ warn "Warning: failed to fetch options: #{e.message}"
36
+ end
37
+
38
+ save_json(data, @out)
39
+ end
40
+
41
+ private
42
+
43
+ def parse_tl_from_file(path)
44
+ File.open(path, "r:utf-8") do |file|
45
+ parse_tl_from_reader(file)
46
+ end
47
+ end
48
+
49
+ def fetch_and_parse_tl(url)
50
+ uri = URI.parse(url)
51
+ request = Net::HTTP::Get.new(uri)
52
+ request["User-Agent"] = "gruby-tdlib-schema-builder/1.0"
53
+
54
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 30, open_timeout: 30) do |http|
55
+ http.request(request)
56
+ end
57
+ raise "failed to fetch #{url}: status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
58
+
59
+ parse_tl_from_reader(StringIO.new(response.body))
60
+ end
61
+
62
+ def parse_tl_from_reader(reader)
63
+ data = {
64
+ "name" => "Auto-generated JSON TDLib API",
65
+ "version" => "",
66
+ "commit" => "",
67
+ "classes" => {},
68
+ "types" => {},
69
+ "updates" => {},
70
+ "functions" => {},
71
+ "options" => {}
72
+ }
73
+
74
+ current_description = ""
75
+ current_params = {}
76
+ is_functions_section = false
77
+ start = false
78
+
79
+ reader.each_line do |raw_line|
80
+ line = raw_line.strip
81
+
82
+ if line.include?("---functions---")
83
+ is_functions_section = true
84
+ next
85
+ end
86
+
87
+ if line.start_with?("//")
88
+ start = true
89
+ if line.start_with?("//@")
90
+ parse_doc_tags(data, line.sub("//@", ""), current_params) do |description|
91
+ current_description = [current_description, description].reject(&:empty?).join(" ").strip
92
+ end
93
+ end
94
+ next
95
+ end
96
+
97
+ next if line.empty? || !start
98
+
99
+ match = TL_DEF_REGEX.match(line)
100
+ next unless match
101
+
102
+ name = match[:name]
103
+ params_str = match[:params]
104
+ result_type = match[:type]
105
+
106
+ type_def = {
107
+ "description" => current_description,
108
+ "args" => {},
109
+ "type" => result_type
110
+ }
111
+
112
+ params_str.scan(PARAM_DETAIL_REGEX) do |p_name, p_type|
113
+ p_desc = current_params[p_name] || ""
114
+ type_def["args"][p_name] = {
115
+ "description" => p_desc,
116
+ "is_optional" => optional_arg?(p_desc, p_type),
117
+ "type" => p_type
118
+ }
119
+ end
120
+
121
+ if is_functions_section
122
+ data["functions"][name] = type_def
123
+ (data["classes"][result_type] ||= default_class)["functions"] << name
124
+ elsif name.start_with?("update")
125
+ data["updates"][name] = type_def
126
+ (data["classes"][result_type] ||= default_class)["types"] << name
127
+ else
128
+ data["types"][name] = type_def
129
+ (data["classes"][result_type] ||= default_class)["types"] << name
130
+ end
131
+
132
+ current_description = ""
133
+ current_params = {}
134
+ end
135
+
136
+ data
137
+ end
138
+
139
+ def parse_doc_tags(data, tag_line, current_params)
140
+ parts = (" " + tag_line).split(" @")
141
+ current_class = nil
142
+
143
+ parts.each do |part|
144
+ part = part.strip
145
+ next if part.empty?
146
+
147
+ tag_name, tag_text = part.split(" ", 2)
148
+ tag_text = (tag_text || "").strip
149
+
150
+ case tag_name
151
+ when "class"
152
+ current_class = tag_text
153
+ when "description"
154
+ if current_class
155
+ data["classes"][current_class] = {
156
+ "description" => tag_text,
157
+ "types" => [],
158
+ "functions" => []
159
+ }
160
+ current_class = nil
161
+ else
162
+ yield(tag_text)
163
+ end
164
+ else
165
+ clean_name = tag_name.sub(/^param_/, "")
166
+ current_params[clean_name] = [current_params[clean_name], tag_text].compact.join(" ").strip
167
+ end
168
+ end
169
+ end
170
+
171
+ def default_class
172
+ { "description" => "", "types" => [], "functions" => [] }
173
+ end
174
+
175
+ def optional_arg?(description, type)
176
+ text = description.to_s.downcase
177
+ text.include?("may be null") ||
178
+ text.include?("pass null") ||
179
+ text.include?("may be empty") ||
180
+ description.to_s.include?("If non-empty,") ||
181
+ type.to_s.include?("?")
182
+ end
183
+
184
+ def fetch_options
185
+ url = URI.parse("https://core.telegram.org/tdlib/options")
186
+ request = Net::HTTP::Get.new(url)
187
+ request["User-Agent"] = "gruby-tdlib-schema-builder/1.0"
188
+
189
+ response = Net::HTTP.start(url.host, url.port, use_ssl: true, read_timeout: 30, open_timeout: 30) do |http|
190
+ http.request(request)
191
+ end
192
+ raise "failed to fetch options: status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
193
+
194
+ content = response.body
195
+ marker_index = content.index("list-of-options-supported-by-tdlib")
196
+ raise "could not find options list in HTML" unless marker_index
197
+
198
+ options = {}
199
+ content[marker_index..].scan(ROW_REGEX) do |name, type_name, writable, description|
200
+ clean_name = clean_html(name)
201
+ next if clean_name.empty? || clean_name.casecmp("name").zero?
202
+
203
+ options[clean_name] = {
204
+ "type" => map_type(clean_html(type_name)),
205
+ "writable" => clean_html(writable).casecmp("yes").zero?,
206
+ "description" => clean_html(description)
207
+ }
208
+ end
209
+
210
+ raise "no options were parsed from HTML" if options.empty?
211
+
212
+ options
213
+ end
214
+
215
+ def clean_html(text)
216
+ CGI.unescapeHTML(text.gsub(TAG_REGEX, "").strip)
217
+ end
218
+
219
+ def map_type(type_name)
220
+ case type_name
221
+ when "Integer"
222
+ "int64"
223
+ when "Boolean"
224
+ "Bool"
225
+ when "String"
226
+ "string"
227
+ else
228
+ type_name
229
+ end
230
+ end
231
+
232
+ def save_json(data, path)
233
+ File.write(path, JSON.pretty_generate(data) + "\n")
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+ require_relative "native"
3
+
4
+ module GrubY
5
+ module TDLib
6
+ class TdJson
7
+ attr_reader :version
8
+
9
+ def initialize(lib_path: nil, verbosity: 1)
10
+ Native.load!(lib_path)
11
+ Native.td_set_log_verbosity_level(verbosity.to_i)
12
+ @version = fetch_version
13
+ end
14
+
15
+ def create_client_id
16
+ Native.td_json_client_create
17
+ end
18
+
19
+ def send(client_id, request)
20
+ Native.td_json_client_send(client_id, JSON.generate(request))
21
+ end
22
+
23
+ def receive(client_id, timeout:)
24
+ raw = Native.td_json_client_receive(client_id, timeout.to_f)
25
+ raw ? JSON.parse(raw) : nil
26
+ end
27
+
28
+ def execute(client_id, request)
29
+ raw = Native.td_json_client_execute(client_id, JSON.generate(request))
30
+ raw ? JSON.parse(raw) : nil
31
+ end
32
+
33
+ def destroy(client_id)
34
+ Native.td_json_client_destroy(client_id)
35
+ end
36
+
37
+ private
38
+
39
+ def fetch_version
40
+ temp = create_client_id
41
+ response = execute(temp, { "@type" => "getOption", "name" => "version" })
42
+ destroy(temp)
43
+ response&.dig("value", "value")
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,69 @@
1
+ require "base64"
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module GrubY
6
+ module TDLib
7
+ module UserSession
8
+ module_function
9
+
10
+ VERSION = 1
11
+
12
+ def encode(
13
+ api_id:,
14
+ api_hash:,
15
+ database_directory:,
16
+ files_directory:,
17
+ database_encryption_key: "",
18
+ session_name: nil
19
+ )
20
+ payload = {
21
+ "v" => VERSION,
22
+ "api_id" => api_id.to_i,
23
+ "api_hash" => api_hash.to_s,
24
+ "database_directory" => database_directory.to_s,
25
+ "files_directory" => files_directory.to_s,
26
+ "database_encryption_key" => database_encryption_key.to_s,
27
+ "session_name" => session_name.to_s
28
+ }
29
+ Base64.urlsafe_encode64(payload.to_json)
30
+ end
31
+
32
+ def decode(session_string)
33
+ raise ArgumentError, "session_string is empty" if session_string.to_s.strip.empty?
34
+
35
+ payload = JSON.parse(Base64.urlsafe_decode64(session_string.to_s.strip))
36
+ unless payload.is_a?(Hash) && payload["v"].to_i == VERSION
37
+ raise ArgumentError, "invalid session string version"
38
+ end
39
+
40
+ payload
41
+ rescue ArgumentError, JSON::ParserError => e
42
+ raise ArgumentError, "invalid session string: #{e.message}"
43
+ end
44
+
45
+ def build(session_name: "default", root: "storage/tdlib-sessions")
46
+ normalized = session_name.to_s.strip
47
+ normalized = "default" if normalized.empty?
48
+
49
+ {
50
+ session_name: normalized,
51
+ database_directory: File.join(root, normalized, "db"),
52
+ files_directory: File.join(root, normalized, "files"),
53
+ database_encryption_key: SecureRandom.hex(24)
54
+ }
55
+ end
56
+
57
+ def client_kwargs(session_string)
58
+ payload = decode(session_string)
59
+ {
60
+ api_id: payload.fetch("api_id").to_i,
61
+ api_hash: payload.fetch("api_hash").to_s,
62
+ database_directory: payload.fetch("database_directory").to_s,
63
+ files_directory: payload.fetch("files_directory").to_s,
64
+ database_encryption_key: payload.fetch("database_encryption_key").to_s
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,72 @@
1
+ module GrubY
2
+ class BaseObject
3
+ class << self
4
+ def fields(*names)
5
+ @field_names ||= []
6
+ @field_names.concat(names.map(&:to_sym))
7
+ attr_reader(*names)
8
+ end
9
+
10
+ def field_names
11
+ @field_names || []
12
+ end
13
+ end
14
+
15
+ def initialize(data = {}, api: nil, client: nil)
16
+ @data = normalize_hash(data)
17
+ @api = api
18
+ @client = client
19
+ self.class.field_names.each do |field|
20
+ instance_variable_set("@#{field}", @data[field.to_s])
21
+ end
22
+ end
23
+
24
+ attr_reader :api, :client
25
+
26
+ def [](key)
27
+ @data[key.to_s]
28
+ end
29
+
30
+ def dig(*keys)
31
+ normalized = keys.map(&:to_s)
32
+ @data.dig(*normalized)
33
+ end
34
+
35
+ def to_h
36
+ @data.dup
37
+ end
38
+
39
+ def method_missing(name, *args, &block)
40
+ return super unless args.empty? && block.nil?
41
+ return @data[name.to_s] if @data.key?(name.to_s)
42
+
43
+ super
44
+ end
45
+
46
+ def respond_to_missing?(name, include_private = false)
47
+ @data.key?(name.to_s) || super
48
+ end
49
+
50
+ private
51
+
52
+ def call_api(method, params = {})
53
+ raise ArgumentError, "API is not bound to this object" unless @api
54
+
55
+ @api.request(method, params)
56
+ end
57
+
58
+ def call_raw_api(method, params = {})
59
+ raise ArgumentError, "API is not bound to this object" unless @api
60
+
61
+ @api.raw(method, params)
62
+ end
63
+
64
+ def normalize_hash(data)
65
+ return {} unless data.is_a?(Hash)
66
+
67
+ data.each_with_object({}) do |(k, v), out|
68
+ out[k.to_s] = v
69
+ end
70
+ end
71
+ end
72
+ end