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.
@@ -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