grammerb 0.1.0-x86_64-linux
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/README.md +554 -0
- data/lib/grammerb/client.rb +802 -0
- data/lib/grammerb/conversation.rb +81 -0
- data/lib/grammerb/errors.rb +108 -0
- data/lib/grammerb/events.rb +438 -0
- data/lib/grammerb/grammerb.so +0 -0
- data/lib/grammerb/types.rb +530 -0
- data/lib/grammerb/version.rb +4 -0
- data/lib/grammerb.rb +19 -0
- data/sig/grammerb/client.rbs +145 -0
- data/sig/grammerb/conversation.rbs +20 -0
- data/sig/grammerb/errors.rbs +50 -0
- data/sig/grammerb/events.rbs +238 -0
- data/sig/grammerb/types.rbs +246 -0
- data/sig/grammerb.rbs +13 -0
- metadata +118 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
# lib/grammerb/client.rb
|
|
2
|
+
require "logger"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Grammerb
|
|
7
|
+
class HandlerPool
|
|
8
|
+
def initialize(size)
|
|
9
|
+
@queue = Queue.new
|
|
10
|
+
@workers = size.times.map do
|
|
11
|
+
Thread.new do
|
|
12
|
+
loop do
|
|
13
|
+
task = @queue.pop
|
|
14
|
+
break if task == :shutdown
|
|
15
|
+
task.call
|
|
16
|
+
rescue => e
|
|
17
|
+
# Error handling is inside the task block itself
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schedule(&block)
|
|
24
|
+
@queue.push(block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def shutdown
|
|
28
|
+
@workers.size.times { @queue.push(:shutdown) }
|
|
29
|
+
@workers.each { |t| t.join(5) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Client
|
|
34
|
+
attr_reader :native, :logger
|
|
35
|
+
|
|
36
|
+
def initialize(api_id:, api_hash:, session: "grammerb.session", proxy: nil, logger: nil, pool_size: 20)
|
|
37
|
+
@session_path = session
|
|
38
|
+
@native = Grammerb::Native::Client.new(api_id, api_hash, session, proxy)
|
|
39
|
+
@handlers = []
|
|
40
|
+
@handlers_mu = Mutex.new
|
|
41
|
+
@handlers_snapshot = [].freeze
|
|
42
|
+
@running = false
|
|
43
|
+
@running_mu = Mutex.new
|
|
44
|
+
@handler_pool = HandlerPool.new(pool_size)
|
|
45
|
+
@conversations = {}
|
|
46
|
+
@conversations_mu = Mutex.new
|
|
47
|
+
@logger = logger || default_logger
|
|
48
|
+
@entity_cache = {}
|
|
49
|
+
@entity_cache_mu = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Connection ---
|
|
53
|
+
|
|
54
|
+
def connect
|
|
55
|
+
@logger.debug("Connecting...")
|
|
56
|
+
safe_native { @native.connect }
|
|
57
|
+
@logger.info("Connected")
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def disconnect
|
|
62
|
+
@logger.info("Disconnecting...")
|
|
63
|
+
safe_native { @native.disconnect }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def authorized?
|
|
67
|
+
safe_native { @native.is_authorized }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# --- Auth ---
|
|
71
|
+
|
|
72
|
+
def start(phone:, code_callback: nil, password_callback: nil)
|
|
73
|
+
connect
|
|
74
|
+
|
|
75
|
+
return self if authorized?
|
|
76
|
+
|
|
77
|
+
safe_native { @native.request_login_code(phone) }
|
|
78
|
+
|
|
79
|
+
code = if code_callback
|
|
80
|
+
code_callback.call
|
|
81
|
+
else
|
|
82
|
+
print "Enter the code you received: "
|
|
83
|
+
$stdin.gets&.chomp || (raise Errors::AuthError, "No input available for code")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
result = safe_native { @native.sign_in(code) }
|
|
87
|
+
|
|
88
|
+
case result
|
|
89
|
+
when "ok"
|
|
90
|
+
# Signed in successfully
|
|
91
|
+
when "2fa_required"
|
|
92
|
+
password = if password_callback
|
|
93
|
+
password_callback.call
|
|
94
|
+
else
|
|
95
|
+
print "Enter your 2FA password: "
|
|
96
|
+
$stdin.gets&.chomp || (raise Errors::AuthError, "No input available for password")
|
|
97
|
+
end
|
|
98
|
+
safe_native { @native.check_password(password) }
|
|
99
|
+
when "sign_up_required"
|
|
100
|
+
raise Errors::AuthError, "This phone number is not registered with Telegram"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def bot_start(token:)
|
|
107
|
+
connect
|
|
108
|
+
unless authorized?
|
|
109
|
+
safe_native { @native.bot_sign_in(token) }
|
|
110
|
+
end
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sign_out
|
|
115
|
+
safe_native { @native.sign_out }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- Messages ---
|
|
119
|
+
|
|
120
|
+
def send_message(chat, text, reply_to: nil, parse_mode: nil, silent: false, link_preview: true, schedule: nil, comment_to: nil, send_as: nil, buttons: nil, reply_markup: nil)
|
|
121
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
122
|
+
schedule_ts = schedule&.to_i
|
|
123
|
+
sa = send_as ? peer_id(send_as) : nil
|
|
124
|
+
h = safe_native { @native.send_message_advanced(peer_id(chat), text.to_s, reply_to, parse_mode, silent, link_preview, schedule_ts, comment_to, sa, markup) }
|
|
125
|
+
wrap_message(h)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def edit_message(chat, message_id, text, parse_mode: nil, link_preview: true, buttons: nil, reply_markup: nil)
|
|
129
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
130
|
+
safe_native { @native.edit_message(peer_id(chat), message_id, text.to_s, parse_mode, link_preview, markup) }
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def delete_messages(chat, *message_ids, revoke: true)
|
|
135
|
+
ids = message_ids.flatten
|
|
136
|
+
safe_native { @native.delete_messages(peer_id(chat), ids, revoke) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def forward_messages(from_chat, message_ids, to_chat, silent: false, schedule: nil, drop_author: false, noforwards: false)
|
|
140
|
+
ids = Array(message_ids)
|
|
141
|
+
schedule_ts = schedule&.to_i
|
|
142
|
+
safe_native { @native.forward_messages(peer_id(from_chat), ids, peer_id(to_chat), silent, schedule_ts, drop_author, noforwards) }.map { |h| h ? wrap_message(h) : nil }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def get_messages(chat, limit: 20, offset_id: nil, offset_date: nil)
|
|
146
|
+
ts = offset_date.is_a?(Time) ? offset_date.to_i : offset_date
|
|
147
|
+
safe_native { @native.get_messages(peer_id(chat), limit, offset_id, ts) }.map { |h| wrap_message(h) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def get_messages_by_id(chat, *message_ids)
|
|
151
|
+
ids = message_ids.flatten
|
|
152
|
+
safe_native { @native.get_messages_by_id(peer_id(chat), ids) }.map { |h| h ? wrap_message(h) : nil }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def pin_message(chat, message_id, notify: false)
|
|
156
|
+
safe_native { @native.pin_message(peer_id(chat), message_id, notify) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def unpin_message(chat, message_id)
|
|
160
|
+
safe_native { @native.unpin_message(peer_id(chat), message_id) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def unpin_all_messages(chat)
|
|
164
|
+
safe_native { @native.unpin_all_messages(peer_id(chat)) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def get_pinned_message(chat)
|
|
168
|
+
h = safe_native { @native.get_pinned_message(peer_id(chat)) }
|
|
169
|
+
h ? wrap_message(h) : nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def send_reactions(chat, message_id, *reactions)
|
|
173
|
+
emojis = reactions.flatten
|
|
174
|
+
safe_native { @native.send_reactions(peer_id(chat), message_id, emojis) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def search_messages(chat, query: "", limit: 100, filter: nil, from_user: nil)
|
|
178
|
+
fu = from_user ? peer_id(from_user) : nil
|
|
179
|
+
safe_native { @native.search_messages(peer_id(chat), query, limit, filter, fu) }.map { |h| wrap_message(h) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def search_all_messages(query: "", limit: 100)
|
|
183
|
+
safe_native { @native.search_all_messages(query, limit) }.map { |h| wrap_message(h) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def get_reply_to_message(chat, message_id)
|
|
187
|
+
h = safe_native { @native.get_reply_to_message(peer_id(chat), message_id) }
|
|
188
|
+
h ? wrap_message(h) : nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def send_chat_action(chat, action)
|
|
192
|
+
safe_native { @native.send_chat_action(peer_id(chat), action.to_s) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def send_poll(chat, question, answers, quiz: false, correct_option: nil)
|
|
196
|
+
h = safe_native { @native.send_poll(peer_id(chat), question.to_s, Array(answers), quiz, correct_option) }
|
|
197
|
+
wrap_message(h)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def get_poll_results(chat, message_id)
|
|
201
|
+
h = safe_native { @native.get_poll_results(peer_id(chat), message_id) }
|
|
202
|
+
Types::PollResults.new(h)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def get_poll_votes(chat, message_id, option: nil, limit: 100)
|
|
206
|
+
h = safe_native { @native.get_poll_votes(peer_id(chat), message_id, option, limit) }
|
|
207
|
+
Types::PollVotes.new(h)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# --- Dialogs ---
|
|
211
|
+
|
|
212
|
+
def iter_dialogs(limit: 100)
|
|
213
|
+
safe_native { @native.iter_dialogs(limit) }.map { |h| Types::Dialog.new(h) }
|
|
214
|
+
end
|
|
215
|
+
alias_method :get_dialogs, :iter_dialogs
|
|
216
|
+
|
|
217
|
+
def delete_dialog(chat)
|
|
218
|
+
safe_native { @native.delete_dialog(peer_id(chat)) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def mark_as_read(chat)
|
|
222
|
+
safe_native { @native.mark_as_read(peer_id(chat)) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def send_read_acknowledge(chat, message = nil, max_id: nil)
|
|
226
|
+
mid = max_id || (message.respond_to?(:id) ? message.id : message&.to_i) || 0
|
|
227
|
+
safe_native { @native.send_read_acknowledge(peer_id(chat), mid) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def iter_drafts
|
|
231
|
+
safe_native { @native.iter_drafts }.map { |h| Types::Draft.new(h) }
|
|
232
|
+
end
|
|
233
|
+
alias_method :get_drafts, :iter_drafts
|
|
234
|
+
|
|
235
|
+
def clear_drafts
|
|
236
|
+
safe_native { @native.clear_all_drafts }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def clear_mentions(chat)
|
|
240
|
+
safe_native { @native.clear_mentions(peer_id(chat)) }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# --- Users / Chats ---
|
|
244
|
+
|
|
245
|
+
def get_me
|
|
246
|
+
Types::User.new(safe_native { @native.get_me })
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def get_entity(entity)
|
|
250
|
+
key = peer_id(entity)
|
|
251
|
+
cached = @entity_cache_mu.synchronize { @entity_cache[key] }
|
|
252
|
+
return cached if cached
|
|
253
|
+
|
|
254
|
+
h = safe_native { @native.get_entity(key) }
|
|
255
|
+
return nil unless h
|
|
256
|
+
peer = Types::Peer.new(h)
|
|
257
|
+
@entity_cache_mu.synchronize { @entity_cache[key] = peer }
|
|
258
|
+
peer
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def edit_permissions(chat, **rights)
|
|
262
|
+
rights_hash = rights.transform_keys(&:to_sym)
|
|
263
|
+
safe_native { @native.edit_permissions(peer_id(chat), rights_hash) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolve_username(username)
|
|
267
|
+
normalized = username.to_s.delete_prefix("@")
|
|
268
|
+
key = "@#{normalized}"
|
|
269
|
+
cached = @entity_cache_mu.synchronize { @entity_cache[key] }
|
|
270
|
+
return cached if cached
|
|
271
|
+
|
|
272
|
+
h = safe_native { @native.resolve_username(normalized) }
|
|
273
|
+
return nil unless h
|
|
274
|
+
peer = Types::Peer.new(h)
|
|
275
|
+
@entity_cache_mu.synchronize { @entity_cache[key] = peer }
|
|
276
|
+
peer
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def iter_participants(chat, limit: 200, filter: nil, search: nil, aggressive: false)
|
|
280
|
+
unless aggressive
|
|
281
|
+
return safe_native { @native.iter_participants(peer_id(chat), limit, filter, search) }.map { |h| Types::Participant.new(h) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Aggressive mode: query with different search strings to get all participants
|
|
285
|
+
seen = {}
|
|
286
|
+
result = []
|
|
287
|
+
# First get recent participants
|
|
288
|
+
safe_native { @native.iter_participants(peer_id(chat), limit, filter, nil) }.each do |h|
|
|
289
|
+
p = Types::Participant.new(h)
|
|
290
|
+
unless seen[p.id]
|
|
291
|
+
seen[p.id] = true
|
|
292
|
+
result << p
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
# Then search with each letter
|
|
296
|
+
("a".."z").each do |letter|
|
|
297
|
+
break if result.size >= limit
|
|
298
|
+
safe_native { @native.iter_participants(peer_id(chat), limit, filter, letter) }.each do |h|
|
|
299
|
+
p = Types::Participant.new(h)
|
|
300
|
+
unless seen[p.id]
|
|
301
|
+
seen[p.id] = true
|
|
302
|
+
result << p
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
result.first(limit)
|
|
307
|
+
end
|
|
308
|
+
alias_method :get_participants, :iter_participants
|
|
309
|
+
|
|
310
|
+
def kick_participant(chat, user)
|
|
311
|
+
safe_native { @native.kick_participant(peer_id(chat), peer_id(user)) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def set_admin_rights(chat, user, **rights)
|
|
315
|
+
rights_hash = rights.transform_keys(&:to_sym)
|
|
316
|
+
safe_native { @native.set_admin_rights(peer_id(chat), peer_id(user), rights_hash) }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def set_banned_rights(chat, user, **rights)
|
|
320
|
+
rights_hash = rights.transform_keys(&:to_sym)
|
|
321
|
+
safe_native { @native.set_banned_rights(peer_id(chat), peer_id(user), rights_hash) }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def get_permissions(chat, user)
|
|
325
|
+
Types::Permissions.new(safe_native { @native.get_permissions(peer_id(chat), peer_id(user)) })
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def iter_profile_photos(chat, limit: 100)
|
|
329
|
+
safe_native { @native.iter_profile_photos(peer_id(chat), limit) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def join_chat(chat)
|
|
333
|
+
link = chat.to_s
|
|
334
|
+
if link.match?(%r{(t\.me|telegram\.me|telegram\.dog)/(\+|joinchat/)}) || link.match?(%r{^https?://tg\.dev/})
|
|
335
|
+
return accept_invite_link(link)
|
|
336
|
+
end
|
|
337
|
+
safe_native { @native.join_chat(peer_id(chat)) }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def accept_invite_link(link)
|
|
341
|
+
result = safe_native { @native.accept_invite_link(link.to_s) }
|
|
342
|
+
result ? Types::Peer.new(result) : nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def download_profile_photo(entity, path)
|
|
346
|
+
safe_native { @native.download_profile_photo(peer_id(entity), path.to_s) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def iter_admin_log(chat, limit: 100, query: nil, filter: nil)
|
|
350
|
+
filter_hash = filter.is_a?(Hash) ? filter.transform_keys(&:to_sym) : filter
|
|
351
|
+
safe_native { @native.iter_admin_log(peer_id(chat), limit, query, filter_hash) }.map { |h| Types::AdminLogEvent.new(h) }
|
|
352
|
+
end
|
|
353
|
+
alias_method :get_admin_log, :iter_admin_log
|
|
354
|
+
|
|
355
|
+
def edit_title(chat, title)
|
|
356
|
+
safe_native { @native.edit_title(peer_id(chat), title.to_s) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def edit_photo(chat, path)
|
|
360
|
+
safe_native { @native.edit_photo(peer_id(chat), path.to_s) }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def get_stats(chat)
|
|
364
|
+
safe_native { @native.get_stats(peer_id(chat)) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def export_invite_link(chat)
|
|
368
|
+
safe_native { @native.export_invite_link(peer_id(chat)) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def block_user(user)
|
|
372
|
+
safe_native { @native.block_user(peer_id(user)) }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def unblock_user(user)
|
|
376
|
+
safe_native { @native.unblock_user(peer_id(user)) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def create_group(title, users)
|
|
380
|
+
user_ids = Array(users).map { |u| peer_id(u) }
|
|
381
|
+
Types::Peer.new(safe_native { @native.create_group(title.to_s, user_ids) })
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def create_channel(title, about: "")
|
|
385
|
+
Types::Peer.new(safe_native { @native.create_channel(title.to_s, about.to_s, false) })
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def create_supergroup(title, about: "")
|
|
389
|
+
Types::Peer.new(safe_native { @native.create_channel(title.to_s, about.to_s, true) })
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def edit_about(chat, about)
|
|
393
|
+
safe_native { @native.edit_about(peer_id(chat), about.to_s) }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def get_full_chat(chat)
|
|
397
|
+
Types::ChatFull.new(safe_native { @native.get_full_chat(peer_id(chat)) })
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# --- Keyboards ---
|
|
401
|
+
|
|
402
|
+
def send_reply_keyboard(chat, text, keys, parse_mode: nil, resize: true, single_use: false, placeholder: nil)
|
|
403
|
+
send_message(chat, text, parse_mode: parse_mode, reply_markup: {
|
|
404
|
+
type: :reply_keyboard, keys: build_keys(keys),
|
|
405
|
+
resize: resize, single_use: single_use, placeholder: placeholder
|
|
406
|
+
})
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def remove_keyboard(chat, text = "Keyboard removed.", parse_mode: nil)
|
|
410
|
+
send_message(chat, text, parse_mode: parse_mode, reply_markup: { type: :remove_keyboard })
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def force_reply(chat, text, parse_mode: nil)
|
|
414
|
+
send_message(chat, text, parse_mode: parse_mode, reply_markup: { type: :force_reply })
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# --- Files ---
|
|
418
|
+
|
|
419
|
+
def send_file(chat, path, caption: nil, parse_mode: nil, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
420
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
421
|
+
h = safe_native { @native.send_file(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, reply_to, silent, schedule&.to_i, markup) }
|
|
422
|
+
wrap_message(h)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def send_photo(chat, path, caption: nil, parse_mode: nil, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
426
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
427
|
+
h = safe_native { @native.send_photo(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, reply_to, silent, schedule&.to_i, markup) }
|
|
428
|
+
wrap_message(h)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def send_audio(chat, path, caption: nil, parse_mode: nil, duration: 0, title: nil, performer: nil, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
432
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
433
|
+
h = safe_native { @native.send_audio(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, duration, title, performer, reply_to, silent, schedule&.to_i, markup) }
|
|
434
|
+
wrap_message(h)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def send_video(chat, path, caption: nil, parse_mode: nil, duration: 0, width: 0, height: 0, supports_streaming: true, round_message: false, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
438
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
439
|
+
h = safe_native { @native.send_video(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, duration, width, height, supports_streaming, round_message, reply_to, silent, schedule&.to_i, markup) }
|
|
440
|
+
wrap_message(h)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def send_voice(chat, path, caption: nil, parse_mode: nil, duration: 0, waveform: nil, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
444
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
445
|
+
h = safe_native { @native.send_voice(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, duration, waveform, reply_to, silent, schedule&.to_i, markup) }
|
|
446
|
+
wrap_message(h)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def send_album(chat, *media_items, reply_to: nil, silent: false, buttons: nil, reply_markup: nil)
|
|
450
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
451
|
+
items = media_items.flatten.map do |item|
|
|
452
|
+
item.is_a?(String) ? { path: item } : item
|
|
453
|
+
end
|
|
454
|
+
safe_native { @native.send_album(peer_id(chat), items, reply_to, silent, markup) }.map { |h| h ? wrap_message(h) : nil }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def download_media(chat, message_id, path, &progress)
|
|
458
|
+
if progress
|
|
459
|
+
safe_native { @native.start_download_media(peer_id(chat), message_id, path.to_s) }
|
|
460
|
+
loop do
|
|
461
|
+
p = @native.poll_download_progress
|
|
462
|
+
if p
|
|
463
|
+
progress.call(p[:downloaded], p[:total])
|
|
464
|
+
break if p[:done]
|
|
465
|
+
else
|
|
466
|
+
sleep 0.05
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
else
|
|
470
|
+
safe_native { @native.download_media(peer_id(chat), message_id, path.to_s) }
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# --- Special Media ---
|
|
475
|
+
|
|
476
|
+
def send_location(chat, lat, long, accuracy_radius: nil, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
477
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
478
|
+
h = safe_native { @native.send_location(peer_id(chat), lat.to_f, long.to_f, accuracy_radius, reply_to, silent, schedule&.to_i, markup) }
|
|
479
|
+
wrap_message(h)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def send_venue(chat, lat, long, title:, address:, provider: "", venue_id: "", venue_type: "", reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
483
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
484
|
+
h = safe_native { @native.send_venue(peer_id(chat), lat.to_f, long.to_f, title.to_s, address.to_s, provider.to_s, venue_id.to_s, venue_type.to_s, reply_to, silent, schedule&.to_i, markup) }
|
|
485
|
+
wrap_message(h)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def send_contact(chat, phone_number:, first_name:, last_name: "", vcard: "", reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
489
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
490
|
+
h = safe_native { @native.send_contact(peer_id(chat), phone_number.to_s, first_name.to_s, last_name.to_s, vcard.to_s, reply_to, silent, schedule&.to_i, markup) }
|
|
491
|
+
wrap_message(h)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def send_dice(chat, emoticon: "\u{1F3B2}", reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
495
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
496
|
+
h = safe_native { @native.send_dice(peer_id(chat), emoticon.to_s, reply_to, silent, schedule&.to_i, markup) }
|
|
497
|
+
wrap_message(h)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def send_sticker(chat, path, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
501
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
502
|
+
h = safe_native { @native.send_sticker(peer_id(chat), path.to_s, reply_to, silent, schedule&.to_i, markup) }
|
|
503
|
+
wrap_message(h)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def send_animation(chat, path, caption: nil, parse_mode: nil, duration: 0, width: 0, height: 0, reply_to: nil, silent: false, schedule: nil, buttons: nil, reply_markup: nil)
|
|
507
|
+
markup = reply_markup || (buttons ? build_buttons(buttons) : nil)
|
|
508
|
+
h = safe_native { @native.send_animation(peer_id(chat), path.to_s, (caption || "").to_s, parse_mode, duration, width, height, reply_to, silent, schedule&.to_i, markup) }
|
|
509
|
+
wrap_message(h)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# --- Bots ---
|
|
513
|
+
|
|
514
|
+
def inline_query(bot, query, limit: 20)
|
|
515
|
+
safe_native { @native.inline_query(peer_id(bot), query.to_s, limit) }.map { |h| Types::InlineResult.new(h) }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def answer_callback_query(query_id, text: nil, alert: false, cache_time: nil)
|
|
519
|
+
safe_native { @native.answer_callback_query(query_id, text, alert, cache_time) }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def answer_inline_query(query_id, results, cache_time: nil, is_personal: false, next_offset: nil)
|
|
523
|
+
safe_native { @native.answer_inline_query(query_id, Array(results), cache_time, is_personal, next_offset) }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# --- Payments ---
|
|
527
|
+
|
|
528
|
+
def send_invoice(chat, title:, description:, payload:, currency: "XTR", amount:, photo_url: nil)
|
|
529
|
+
h = safe_native { @native.send_invoice(peer_id(chat), title, description, payload, currency, amount, photo_url) }
|
|
530
|
+
wrap_message(h) if h
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def create_invoice_link(title:, description:, payload:, currency: "XTR", amount:)
|
|
534
|
+
safe_native { @native.create_invoice_link(title, description, payload, currency, amount) }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def answer_pre_checkout_query(query_id, ok:, error_message: nil)
|
|
538
|
+
safe_native { @native.answer_pre_checkout_query(query_id, ok, error_message) }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def refund_star_payment(user_id, charge_id:)
|
|
542
|
+
safe_native { @native.refund_star_payment(peer_id(user_id), charge_id) }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def get_star_transactions(offset: "", limit: 100, inbound: false, outbound: false)
|
|
546
|
+
safe_native { @native.get_star_transactions(offset, limit, inbound, outbound) }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# --- Sessions ---
|
|
550
|
+
|
|
551
|
+
def export_session
|
|
552
|
+
raise Errors::Error, "Session file not found: #{@session_path}" unless File.exist?(@session_path)
|
|
553
|
+
Base64.strict_encode64(File.binread(@session_path))
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def self.import_session(session_string, path: "grammerb.session")
|
|
557
|
+
File.binwrite(path, Base64.strict_decode64(session_string))
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# --- Events ---
|
|
561
|
+
|
|
562
|
+
# Register an event handler. Returns the handler object (pass to off() to remove).
|
|
563
|
+
def on(event_class, pattern: nil, chats: nil, &block)
|
|
564
|
+
handler = Events::Handler.new(event_class, pattern: pattern, chats: chats && Set.new(Array(chats)), &block)
|
|
565
|
+
@handlers_mu.synchronize do
|
|
566
|
+
@handlers << handler
|
|
567
|
+
@handlers_snapshot = @handlers.dup.freeze
|
|
568
|
+
end
|
|
569
|
+
handler
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def off(handler)
|
|
573
|
+
@handlers_mu.synchronize do
|
|
574
|
+
@handlers.delete(handler)
|
|
575
|
+
@handlers_snapshot = @handlers.dup.freeze
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def run
|
|
580
|
+
safe_native { @native.start_update_loop }
|
|
581
|
+
@running_mu.synchronize { @running = true }
|
|
582
|
+
@logger.info("Update loop started")
|
|
583
|
+
|
|
584
|
+
# Install signal handlers for graceful shutdown
|
|
585
|
+
prev_int = trap("INT") { stop; disconnect }
|
|
586
|
+
prev_term = trap("TERM") { stop; disconnect }
|
|
587
|
+
|
|
588
|
+
album_buffer = {} # grouped_id => [event, ...]
|
|
589
|
+
album_timers = {} # grouped_id => Time
|
|
590
|
+
|
|
591
|
+
while @running_mu.synchronize { @running }
|
|
592
|
+
begin
|
|
593
|
+
# Flush expired album buffers (>300ms old)
|
|
594
|
+
unless album_timers.empty?
|
|
595
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
596
|
+
album_timers.keys.each do |gid|
|
|
597
|
+
if now - album_timers[gid] > 0.3
|
|
598
|
+
flush_album(album_buffer.delete(gid), album_timers)
|
|
599
|
+
album_timers.delete(gid)
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Blocking wait — releases GVL, wakes on update or timeout.
|
|
605
|
+
# Short timeout when buffering albums so we flush them promptly.
|
|
606
|
+
timeout = album_timers.empty? ? 300 : 50
|
|
607
|
+
raw = safe_native { @native.poll_update_blocking(timeout) }
|
|
608
|
+
next unless raw
|
|
609
|
+
|
|
610
|
+
event = Events.from_raw(raw)
|
|
611
|
+
next unless event
|
|
612
|
+
|
|
613
|
+
event.client = self
|
|
614
|
+
|
|
615
|
+
# Buffer album messages
|
|
616
|
+
if event.respond_to?(:grouped_id) && event.grouped_id && event.is_a?(Events::NewMessage)
|
|
617
|
+
gid = event.grouped_id
|
|
618
|
+
album_buffer[gid] ||= []
|
|
619
|
+
album_buffer[gid] << event
|
|
620
|
+
album_timers[gid] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
621
|
+
else
|
|
622
|
+
dispatch_event(event)
|
|
623
|
+
end
|
|
624
|
+
rescue Errors::Error => e
|
|
625
|
+
@logger.error("Event loop error: #{e.class}: #{e.message}")
|
|
626
|
+
rescue RuntimeError => e
|
|
627
|
+
@logger.error("Event loop error: #{e.message}")
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Flush remaining album buffers on stop
|
|
632
|
+
album_buffer.each_value { |events| flush_album(events, album_timers) }
|
|
633
|
+
@logger.info("Update loop ended")
|
|
634
|
+
ensure
|
|
635
|
+
# Restore previous signal handlers
|
|
636
|
+
trap("INT", prev_int || "DEFAULT")
|
|
637
|
+
trap("TERM", prev_term || "DEFAULT")
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def stop
|
|
641
|
+
@running_mu.synchronize { @running = false }
|
|
642
|
+
@logger.info("Update loop stopping, waiting for handlers...")
|
|
643
|
+
@handler_pool.shutdown
|
|
644
|
+
@logger.info("Stopped")
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def each_update(&block)
|
|
648
|
+
safe_native { @native.start_update_loop }
|
|
649
|
+
@running_mu.synchronize { @running = true }
|
|
650
|
+
|
|
651
|
+
prev_int = trap("INT") { stop; disconnect }
|
|
652
|
+
prev_term = trap("TERM") { stop; disconnect }
|
|
653
|
+
|
|
654
|
+
while @running_mu.synchronize { @running }
|
|
655
|
+
raw = safe_native { @native.poll_update_blocking(300) }
|
|
656
|
+
if raw
|
|
657
|
+
event = Events.from_raw(raw)
|
|
658
|
+
if event
|
|
659
|
+
event.client = self
|
|
660
|
+
yield event
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
ensure
|
|
665
|
+
trap("INT", prev_int || "DEFAULT")
|
|
666
|
+
trap("TERM", prev_term || "DEFAULT")
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# --- Conversations ---
|
|
670
|
+
|
|
671
|
+
# Open an exclusive conversation with a chat. Events for this chat are
|
|
672
|
+
# routed to the conversation and skip normal handlers while active.
|
|
673
|
+
#
|
|
674
|
+
# client.conversation(chat_id, timeout: 30) do |conv|
|
|
675
|
+
# conv.send_message("What's your name?")
|
|
676
|
+
# response = conv.get_response
|
|
677
|
+
# conv.send_message("Hello #{response.text}!")
|
|
678
|
+
# conv.send_message("Pick one:", buttons: [[{text: "A", callback_data: "a"}]])
|
|
679
|
+
# cb = conv.get_callback
|
|
680
|
+
# cb.answer(text: "You picked #{cb.data_str}")
|
|
681
|
+
# end
|
|
682
|
+
#
|
|
683
|
+
# Open an exclusive conversation with a chat. Spawns a dedicated thread
|
|
684
|
+
# so the handler pool is never blocked — thousands of concurrent
|
|
685
|
+
# conversations work fine (threads sleep on Queue#pop, releasing the GVL).
|
|
686
|
+
#
|
|
687
|
+
# Returns the Thread running the conversation block.
|
|
688
|
+
def conversation(chat, timeout: 60, exclusive: true, &block)
|
|
689
|
+
cid = peer_id(chat)
|
|
690
|
+
conv = Conversation.new(self, cid, timeout: timeout)
|
|
691
|
+
@conversations_mu.synchronize { @conversations[cid] = conv } if exclusive
|
|
692
|
+
|
|
693
|
+
Thread.new do
|
|
694
|
+
block.call(conv)
|
|
695
|
+
rescue Conversation::TimeoutError => e
|
|
696
|
+
@logger.debug("Conversation timeout for #{cid}: #{e.message}")
|
|
697
|
+
rescue Errors::Error => e
|
|
698
|
+
@logger.error("Conversation error for #{cid}: #{e.class}: #{e.message}")
|
|
699
|
+
rescue => e
|
|
700
|
+
@logger.error("Conversation error for #{cid}: #{e.message}")
|
|
701
|
+
ensure
|
|
702
|
+
@conversations_mu.synchronize { @conversations.delete(cid) } if exclusive
|
|
703
|
+
conv.cancel
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def save_session
|
|
708
|
+
@native.save_session
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# --- Raw TL ---
|
|
712
|
+
|
|
713
|
+
def invoke_raw(request_bytes)
|
|
714
|
+
safe_native { @native.invoke_raw(request_bytes) }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
private
|
|
718
|
+
|
|
719
|
+
# Wrap native calls: catch RuntimeError from Rust and re-raise as Grammerb::Errors.
|
|
720
|
+
def safe_native
|
|
721
|
+
yield
|
|
722
|
+
rescue RuntimeError => e
|
|
723
|
+
raise Errors.from_native(e.message)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Extract a usable peer identifier from any Grammerb type or raw value.
|
|
727
|
+
def peer_id(entity)
|
|
728
|
+
case entity
|
|
729
|
+
when Types::Message then entity.chat_id.to_s
|
|
730
|
+
when Types::Peer, Types::User, Types::Dialog, Types::Participant
|
|
731
|
+
entity.id.to_s
|
|
732
|
+
when Integer then entity.to_s
|
|
733
|
+
else entity.to_s
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def dispatch_event(event)
|
|
738
|
+
# Route to exclusive conversation if one is active for this chat
|
|
739
|
+
if event.respond_to?(:chat_id) && event.chat_id
|
|
740
|
+
conv = @conversations_mu.synchronize { @conversations[event.chat_id.to_s] }
|
|
741
|
+
if conv
|
|
742
|
+
conv._push(event)
|
|
743
|
+
return
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
@logger.debug("Event: #{event.class.name.split('::').last}")
|
|
748
|
+
@handlers_snapshot.each do |handler|
|
|
749
|
+
if handler.matches?(event)
|
|
750
|
+
logger = @logger
|
|
751
|
+
@handler_pool.schedule do
|
|
752
|
+
handler.block.call(event)
|
|
753
|
+
rescue Errors::FloodWait => ex
|
|
754
|
+
logger.warn("FloodWait in handler: sleeping #{ex.seconds}s")
|
|
755
|
+
sleep(ex.seconds)
|
|
756
|
+
rescue Errors::Error => ex
|
|
757
|
+
logger.error("Grammerb error in handler: #{ex.class}: #{ex.message}")
|
|
758
|
+
rescue => ex
|
|
759
|
+
logger.error("Handler error: #{ex.message}\n#{ex.backtrace&.first(5)&.join("\n")}")
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def flush_album(events, _timers)
|
|
766
|
+
return if events.nil? || events.empty?
|
|
767
|
+
album = Events::Album.new(events)
|
|
768
|
+
album.client = self
|
|
769
|
+
dispatch_event(album)
|
|
770
|
+
events.each { |e| dispatch_event(e) }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def wrap_message(hash)
|
|
774
|
+
msg = Types::Message.new(hash)
|
|
775
|
+
msg.client = self
|
|
776
|
+
msg
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def default_logger
|
|
780
|
+
l = Logger.new($stderr)
|
|
781
|
+
l.level = Logger::WARN
|
|
782
|
+
l.progname = "Grammerb"
|
|
783
|
+
l
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Convert Ruby button arrays to the format expected by native methods.
|
|
787
|
+
# Accepts: [[{text: "Btn", callback_data: "d"}, {text: "Link", url: "..."}]]
|
|
788
|
+
def build_buttons(buttons)
|
|
789
|
+
buttons.map do |row|
|
|
790
|
+
row.map { |btn| btn.transform_keys(&:to_sym) }
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Convert Ruby key arrays to the format expected by native methods.
|
|
795
|
+
# Accepts: [[{text: "Option A"}, {text: "Share Phone", request_phone: true}]]
|
|
796
|
+
def build_keys(keys)
|
|
797
|
+
keys.map do |row|
|
|
798
|
+
row.map { |key| key.is_a?(String) ? { text: key } : key.transform_keys(&:to_sym) }
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
end
|