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,81 @@
1
+ # lib/grammerb/conversation.rb
2
+ require "set"
3
+
4
+ module Grammerb
5
+ class Conversation
6
+ class TimeoutError < Errors::Error; end
7
+
8
+ attr_reader :chat_id
9
+
10
+ def initialize(client, chat_id, timeout: 60)
11
+ @client = client
12
+ @chat_id = chat_id
13
+ @default_timeout = timeout
14
+ @queue = Queue.new
15
+ @last_outgoing_id = nil
16
+ @outgoing_ids = Set.new
17
+ end
18
+
19
+ # Send a message in this conversation.
20
+ def send_message(text, **opts)
21
+ msg = @client.send_message(@chat_id, text, **opts)
22
+ @last_outgoing_id = msg.id
23
+ @outgoing_ids.add(msg.id)
24
+ msg
25
+ end
26
+
27
+ # Wait for the next incoming message.
28
+ def get_response(timeout: nil)
29
+ wait_event(Events::NewMessage, timeout: timeout)
30
+ end
31
+
32
+ # Wait for a message edit.
33
+ def get_edit(timeout: nil)
34
+ wait_event(Events::MessageEdited, timeout: timeout)
35
+ end
36
+
37
+ # Wait for a callback query (button press).
38
+ def get_callback(timeout: nil)
39
+ wait_event(Events::CallbackQuery, timeout: timeout)
40
+ end
41
+
42
+ # Wait for any event matching the given class.
43
+ # Automatically skips messages sent by this conversation.
44
+ def wait_event(event_class, timeout: nil)
45
+ t = timeout || @default_timeout
46
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + t
47
+
48
+ loop do
49
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ raise TimeoutError, "Conversation timed out after #{t}s" if remaining <= 0
51
+
52
+ event = @queue.pop(timeout: remaining)
53
+ raise TimeoutError, "Conversation timed out after #{t}s" if event.nil?
54
+
55
+ next unless event.is_a?(event_class)
56
+
57
+ # Skip our own outgoing messages
58
+ if event.respond_to?(:message_id) && @outgoing_ids.include?(event.message_id)
59
+ next
60
+ end
61
+
62
+ return event
63
+ end
64
+ end
65
+
66
+ # Mark conversation as read up to last received message.
67
+ def mark_read
68
+ @client.mark_as_read(@chat_id)
69
+ end
70
+
71
+ # Cancel the conversation early (drains the queue).
72
+ def cancel
73
+ @queue.close
74
+ end
75
+
76
+ # @api private — called by the event dispatch system.
77
+ def _push(event)
78
+ @queue.push(event) unless @queue.closed?
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,108 @@
1
+ # lib/grammerb/errors.rb
2
+ module Grammerb
3
+ module Errors
4
+ class Error < StandardError; end
5
+ class ConnectionError < Error; end
6
+ class AuthError < Error; end
7
+ class NotConnectedError < Error; end
8
+
9
+ # Telegram-specific errors
10
+ class RPCError < Error
11
+ attr_reader :code, :message_text, :value
12
+
13
+ def initialize(code, message_text = nil, value: nil)
14
+ @code = code
15
+ @message_text = message_text
16
+ @value = value
17
+ super("RPCError #{code}: #{message_text}")
18
+ end
19
+ end
20
+
21
+ class FloodWait < RPCError
22
+ attr_reader :seconds
23
+
24
+ def initialize(seconds)
25
+ @seconds = seconds
26
+ super(420, "FLOOD_WAIT", value: seconds)
27
+ end
28
+ end
29
+
30
+ class UserNotFound < RPCError
31
+ def initialize
32
+ super(400, "USER_NOT_FOUND")
33
+ end
34
+ end
35
+
36
+ class ChatWriteForbidden < RPCError
37
+ def initialize
38
+ super(403, "CHAT_WRITE_FORBIDDEN")
39
+ end
40
+ end
41
+
42
+ class PhoneNumberInvalid < RPCError
43
+ def initialize
44
+ super(400, "PHONE_NUMBER_INVALID")
45
+ end
46
+ end
47
+
48
+ class SessionPasswordNeeded < RPCError
49
+ def initialize
50
+ super(401, "SESSION_PASSWORD_NEEDED")
51
+ end
52
+ end
53
+
54
+ # Map RPC error name strings to classes
55
+ RPC_MAP = {
56
+ "FLOOD_WAIT" => FloodWait,
57
+ "USER_NOT_FOUND" => UserNotFound,
58
+ "CHAT_WRITE_FORBIDDEN" => ChatWriteForbidden,
59
+ "PHONE_NUMBER_INVALID" => PhoneNumberInvalid,
60
+ "SESSION_PASSWORD_NEEDED" => SessionPasswordNeeded,
61
+ }.freeze
62
+
63
+ # Map kind strings to exception classes
64
+ KIND_MAP = {
65
+ "flood_wait" => FloodWait,
66
+ "connection" => ConnectionError,
67
+ "auth" => AuthError,
68
+ "not_connected" => NotConnectedError,
69
+ "rpc" => RPCError,
70
+ }.freeze
71
+
72
+ # Parse a structured error string from the native extension.
73
+ # Format: "kind:code:name:value|Human-readable message"
74
+ def self.from_native(msg)
75
+ if msg =~ /\A(\w+):(\d+):([^:]*):([^|]*)\|(.+)\z/m
76
+ kind, code, name, value_str, human = $1, $2.to_i, $3, $4, $5
77
+
78
+ case kind
79
+ when "flood_wait"
80
+ FloodWait.new(value_str.to_i)
81
+ when "connection"
82
+ ConnectionError.new(human)
83
+ when "auth"
84
+ AuthError.new(human)
85
+ when "not_connected"
86
+ NotConnectedError.new(human)
87
+ when "rpc"
88
+ # Check if we have a specific class for this RPC error name
89
+ if (klass = RPC_MAP[name])
90
+ if klass == FloodWait
91
+ klass.new(value_str.to_i)
92
+ else
93
+ klass.new
94
+ end
95
+ else
96
+ RPCError.new(code, name, value: value_str.empty? ? nil : value_str.to_i)
97
+ end
98
+ else
99
+ Error.new(human)
100
+ end
101
+ else
102
+ # Unstructured message — wrap in base error
103
+ Error.new(msg)
104
+ end
105
+ end
106
+
107
+ end
108
+ end
@@ -0,0 +1,438 @@
1
+ # lib/grammerb/events.rb
2
+ module Grammerb
3
+ module Events
4
+ class Event
5
+ attr_reader :raw
6
+ attr_accessor :client
7
+
8
+ def initialize(raw)
9
+ @raw = raw
10
+ end
11
+ end
12
+
13
+ # Shared logic for message-like events (NewMessage, MessageEdited).
14
+ module MessageEvent
15
+ attr_reader :message_id, :text, :chat_id, :chat_name,
16
+ :sender_id, :sender_name, :date, :sender_is_bot, :chat_type,
17
+ :grouped_id, :reply_to_message_id,
18
+ :media_type, :media_id, :media_file_name, :media_mime_type, :media_size,
19
+ :buttons
20
+
21
+ def init_message_fields(raw)
22
+ @message_id = raw[:message_id]
23
+ @text = raw[:message_text]
24
+ @chat_id = raw[:chat_id]
25
+ @chat_name = raw[:chat_name]
26
+ @sender_id = raw[:sender_id]
27
+ @sender_name = raw[:sender_name]
28
+ @date = raw[:date] ? Time.at(raw[:date]) : nil
29
+ @sender_is_bot = raw[:sender_is_bot]
30
+ @chat_type = raw[:chat_type]
31
+ @grouped_id = raw[:grouped_id]
32
+ @reply_to_message_id = raw[:reply_to_message_id]
33
+ @media_type = raw[:media_type]
34
+ @media_id = raw[:media_id]
35
+ @media_file_name = raw[:media_file_name]
36
+ @media_mime_type = raw[:media_mime_type]
37
+ @media_size = raw[:media_size]
38
+ @buttons = raw[:buttons]&.map { |row| row.map { |b| Types::Button.new(b) } }
39
+ end
40
+
41
+ def album?
42
+ !@grouped_id.nil?
43
+ end
44
+
45
+ def has_media?
46
+ !@media_type.nil?
47
+ end
48
+
49
+ def photo?
50
+ @media_type == "photo"
51
+ end
52
+
53
+ def document?
54
+ @media_type == "document"
55
+ end
56
+
57
+ def sticker?
58
+ @media_type == "sticker"
59
+ end
60
+
61
+ def has_buttons?
62
+ @buttons && !@buttons.empty?
63
+ end
64
+
65
+ def edit(text, parse_mode: nil, buttons: nil, reply_markup: nil)
66
+ @client&.edit_message(chat_id || chat_name, message_id, text, parse_mode: parse_mode, buttons: buttons, reply_markup: reply_markup)
67
+ end
68
+
69
+ def delete
70
+ @client&.delete_messages(chat_id || chat_name, message_id)
71
+ end
72
+
73
+ def bot?
74
+ @sender_is_bot == true
75
+ end
76
+
77
+ def private?
78
+ @chat_type == "user"
79
+ end
80
+
81
+ def group?
82
+ @chat_type == "chat"
83
+ end
84
+
85
+ def channel?
86
+ @chat_type == "channel"
87
+ end
88
+ end
89
+
90
+ class NewMessage < Event
91
+ include MessageEvent
92
+
93
+ def initialize(raw)
94
+ super
95
+ init_message_fields(raw)
96
+ end
97
+
98
+ def reply(text, **opts)
99
+ @client&.send_message(chat_id || chat_name, text, reply_to: message_id, **opts)
100
+ end
101
+
102
+ def respond(text, **opts)
103
+ @client&.send_message(chat_id || chat_name, text, **opts)
104
+ end
105
+
106
+ def forward(to_chat)
107
+ @client&.forward_messages(chat_id || chat_name, [message_id], to_chat)
108
+ end
109
+ end
110
+
111
+ class MessageEdited < Event
112
+ include MessageEvent
113
+
114
+ def initialize(raw)
115
+ super
116
+ init_message_fields(raw)
117
+ end
118
+ end
119
+
120
+ class MessageDeleted < Event
121
+ attr_reader :deleted_ids, :chat_id
122
+
123
+ def initialize(raw)
124
+ super
125
+ @deleted_ids = raw[:deleted_message_ids] || []
126
+ @chat_id = raw[:chat_id]
127
+ end
128
+ end
129
+
130
+ class CallbackQuery < Event
131
+ attr_reader :query_id, :data, :chat_id, :sender_id, :message_id
132
+
133
+ def initialize(raw)
134
+ super
135
+ @query_id = raw[:callback_query_id]
136
+ @data = raw[:callback_data]
137
+ @chat_id = raw[:callback_chat_id]
138
+ @sender_id = raw[:sender_id]
139
+ @message_id = raw[:message_id]
140
+ end
141
+
142
+ def data_str
143
+ @data_str_cache ||= @data&.pack("C*")&.force_encoding("UTF-8")
144
+ end
145
+
146
+ def answer(text: nil, alert: false, cache_time: nil)
147
+ @client&.answer_callback_query(query_id, text: text, alert: alert, cache_time: cache_time)
148
+ end
149
+
150
+ def edit(text, parse_mode: nil, buttons: nil, reply_markup: nil)
151
+ @client&.edit_message(chat_id, message_id, text, parse_mode: parse_mode, buttons: buttons, reply_markup: reply_markup)
152
+ end
153
+
154
+ def delete
155
+ @client&.delete_messages(chat_id, message_id)
156
+ end
157
+
158
+ def reply(text, **opts)
159
+ @client&.send_message(chat_id, text, reply_to: message_id, **opts)
160
+ end
161
+
162
+ def respond(text, **opts)
163
+ @client&.send_message(chat_id, text, **opts)
164
+ end
165
+ end
166
+
167
+ class InlineQuery < Event
168
+ attr_reader :query_id, :query, :offset, :sender_id
169
+
170
+ def initialize(raw)
171
+ super
172
+ @query_id = raw[:inline_query_id]
173
+ @query = raw[:inline_query_text]
174
+ @offset = raw[:inline_query_offset]
175
+ @sender_id = raw[:sender_id]
176
+ end
177
+
178
+ def answer(results, cache_time: nil, is_personal: false, next_offset: nil)
179
+ @client&.answer_inline_query(query_id, results, cache_time: cache_time, is_personal: is_personal, next_offset: next_offset)
180
+ end
181
+ end
182
+
183
+ class UpdateError < Event
184
+ attr_reader :message
185
+
186
+ def initialize(raw)
187
+ super
188
+ @message = raw[:message_text]
189
+ end
190
+ end
191
+
192
+ class InlineSend < Event
193
+ attr_reader :sender_id, :query, :result_id
194
+
195
+ def initialize(raw)
196
+ super
197
+ @sender_id = raw[:sender_id]
198
+ @query = raw[:inline_send_query]
199
+ @result_id = raw[:inline_send_result_id]
200
+ end
201
+ end
202
+
203
+ class PreCheckoutQuery < Event
204
+ attr_reader :query_id, :sender_id, :currency, :total_amount, :invoice_payload
205
+
206
+ def initialize(raw)
207
+ super
208
+ @query_id = raw[:pre_checkout_query_id]
209
+ @sender_id = raw[:sender_id]
210
+ @currency = raw[:pre_checkout_currency]
211
+ @total_amount = raw[:pre_checkout_amount]
212
+ @invoice_payload = raw[:pre_checkout_payload]
213
+ end
214
+
215
+ def approve
216
+ @client&.answer_pre_checkout_query(query_id, ok: true)
217
+ end
218
+
219
+ def reject(error_message = "Payment rejected")
220
+ @client&.answer_pre_checkout_query(query_id, ok: false, error_message: error_message)
221
+ end
222
+ end
223
+
224
+ class SuccessfulPayment < Event
225
+ attr_reader :sender_id, :currency, :total_amount, :invoice_payload, :charge_id
226
+
227
+ def initialize(raw)
228
+ super
229
+ @sender_id = raw[:sender_id]
230
+ @currency = raw[:payment_currency]
231
+ @total_amount = raw[:payment_amount]
232
+ @invoice_payload = raw[:payment_payload]
233
+ @charge_id = raw[:payment_charge_id]
234
+ end
235
+ end
236
+
237
+ class Album < Event
238
+ attr_reader :messages, :grouped_id, :chat_id, :chat_name
239
+
240
+ def initialize(messages)
241
+ super(messages.first.raw)
242
+ @messages = messages
243
+ @grouped_id = messages.first.grouped_id
244
+ @chat_id = messages.first.chat_id
245
+ @chat_name = messages.first.chat_name
246
+ end
247
+
248
+ def texts
249
+ @messages.map(&:text)
250
+ end
251
+
252
+ def reply(text, **opts)
253
+ @client&.send_message(chat_id || chat_name, text, reply_to: messages.first.message_id, **opts)
254
+ end
255
+
256
+ def respond(text, **opts)
257
+ @client&.send_message(chat_id || chat_name, text, **opts)
258
+ end
259
+ end
260
+
261
+ class ChatAction < Event
262
+ attr_reader :action_type, :message_id, :chat_id, :chat_name, :chat_type,
263
+ :sender_id, :actor_id, :user_ids, :new_title,
264
+ :pinned_message_id, :game_score, :date
265
+
266
+ def initialize(raw)
267
+ super
268
+ @action_type = raw[:action_type]
269
+ @message_id = raw[:message_id]
270
+ @chat_id = raw[:chat_id]
271
+ @chat_name = raw[:chat_name]
272
+ @chat_type = raw[:chat_type]
273
+ @sender_id = raw[:sender_id]
274
+ @actor_id = raw[:actor_id]
275
+ @user_ids = raw[:action_user_ids] || []
276
+ @new_title = raw[:new_title]
277
+ @pinned_message_id = raw[:pinned_message_id]
278
+ @game_score = raw[:game_score]
279
+ @date = raw[:date] ? Time.at(raw[:date]) : nil
280
+ end
281
+
282
+ def user_joined?; @action_type == "user_joined" end
283
+ def user_added?; @action_type == "user_added" end
284
+ def user_kicked?; @action_type == "user_kicked" end
285
+ def user_left?; @action_type == "user_left" end
286
+ def title_changed?; @action_type == "title_changed" end
287
+ def photo_changed?; @action_type == "photo_changed" end
288
+ def photo_deleted?; @action_type == "photo_deleted" end
289
+ def pin_message?; @action_type == "pin_message" end
290
+ def unpin?; @action_type == "unpin" end
291
+ def chat_created?; @action_type == "chat_created" end
292
+ def channel_created?; @action_type == "channel_created" end
293
+ def user_promoted?; @action_type == "user_promoted" end
294
+ def user_demoted?; @action_type == "user_demoted" end
295
+ def new_score?; @action_type == "new_score" end
296
+
297
+ def user_id; @user_ids&.first end
298
+ def added_by; @actor_id if user_added? end
299
+ def kicked_by; @actor_id if user_kicked? end
300
+
301
+ def reply(text, **opts)
302
+ @client&.send_message(chat_id || chat_name, text, reply_to: message_id, **opts)
303
+ end
304
+
305
+ def respond(text, **opts)
306
+ @client&.send_message(chat_id || chat_name, text, **opts)
307
+ end
308
+
309
+ def delete
310
+ @client&.delete_messages(chat_id || chat_name, message_id) if message_id
311
+ end
312
+ end
313
+
314
+ class UserUpdate < Event
315
+ attr_reader :sender_id, :chat_id, :user_status,
316
+ :last_seen, :online_until, :typing_action
317
+
318
+ def initialize(raw)
319
+ super
320
+ @sender_id = raw[:sender_id]
321
+ @chat_id = raw[:chat_id]
322
+ @user_status = raw[:user_status]
323
+ @last_seen = raw[:last_seen] ? Time.at(raw[:last_seen]) : nil
324
+ @online_until = raw[:online_until] ? Time.at(raw[:online_until]) : nil
325
+ @typing_action = raw[:typing_action]
326
+ end
327
+
328
+ def online?; @user_status == "online" end
329
+ def offline?; @user_status == "offline" end
330
+ def recently?; @user_status == "recently" end
331
+ def within_weeks?; @user_status == "last_week" end
332
+ def within_months?; @user_status == "last_month" end
333
+
334
+ def typing?; @typing_action == "typing" end
335
+ def recording?; @typing_action&.start_with?("recording") end
336
+ def uploading?; @typing_action&.start_with?("uploading") end
337
+ def playing?; @typing_action == "playing_game" end
338
+ def cancel?; @typing_action == "cancel" end
339
+ def audio?; @typing_action&.include?("audio") end
340
+ def video?; @typing_action&.include?("video") && !round? end
341
+ def round?; @typing_action&.include?("round") end
342
+ def document?; @typing_action == "uploading_document" end
343
+ def photo?; @typing_action == "uploading_photo" end
344
+ def sticker?; @typing_action == "choosing_sticker" end
345
+ def contact?; @typing_action == "choosing_contact" end
346
+ def geo?; @typing_action == "geo_location" end
347
+
348
+ alias_method :user_id, :sender_id
349
+ end
350
+
351
+ class MessageRead < Event
352
+ attr_reader :chat_id, :max_id, :outbox, :unread_count, :message_ids
353
+
354
+ def initialize(raw)
355
+ super
356
+ @chat_id = raw[:chat_id]
357
+ @max_id = raw[:max_id]
358
+ @outbox = raw[:is_outbox] || false
359
+ @unread_count = raw[:unread_count]
360
+ @message_ids = raw[:read_message_ids] || []
361
+ end
362
+
363
+ def inbox?; !@outbox end
364
+ def outbox?; @outbox end
365
+ def contents?; !@message_ids.empty? end
366
+
367
+ def is_read?(message_id)
368
+ if contents?
369
+ @message_ids.include?(message_id)
370
+ elsif @max_id
371
+ message_id <= @max_id
372
+ else
373
+ false
374
+ end
375
+ end
376
+
377
+ def get_messages
378
+ return [] unless @client && @chat_id
379
+ ids = contents? ? @message_ids : (@max_id ? [@max_id] : [])
380
+ return [] if ids.empty?
381
+ @client.get_messages_by_id(@chat_id, *ids).compact
382
+ end
383
+ end
384
+
385
+ class RawUpdate < Event; end
386
+
387
+ # Maps update type strings from Rust to event classes
388
+ TYPE_MAP = {
389
+ "new_message" => NewMessage,
390
+ "message_edited" => MessageEdited,
391
+ "message_deleted" => MessageDeleted,
392
+ "callback_query" => CallbackQuery,
393
+ "inline_query" => InlineQuery,
394
+ "inline_send" => InlineSend,
395
+ "pre_checkout_query" => PreCheckoutQuery,
396
+ "successful_payment" => SuccessfulPayment,
397
+ "chat_action" => ChatAction,
398
+ "user_update" => UserUpdate,
399
+ "message_read" => MessageRead,
400
+ "raw" => RawUpdate,
401
+ "error" => UpdateError,
402
+ }.freeze
403
+
404
+ def self.from_raw(raw_hash)
405
+ type_str = raw_hash[:type]
406
+ klass = TYPE_MAP[type_str]
407
+ return nil unless klass
408
+ klass.new(raw_hash)
409
+ end
410
+
411
+ # Handler registration
412
+ class Handler
413
+ attr_reader :event_class, :pattern, :chats, :block
414
+
415
+ def initialize(event_class, pattern: nil, chats: nil, &block)
416
+ @event_class = event_class
417
+ @pattern = pattern
418
+ @chats = chats
419
+ @block = block
420
+ end
421
+
422
+ def matches?(event)
423
+ return false unless event.is_a?(@event_class)
424
+ if @pattern && event.respond_to?(:text)
425
+ text = event.text
426
+ # Skip pattern check for media-only messages (nil text) instead of rejecting them
427
+ return false if text && !text.match?(@pattern)
428
+ end
429
+ if @chats
430
+ chat_id = event.respond_to?(:chat_id) ? event.chat_id : nil
431
+ chat_name = event.respond_to?(:chat_name) ? event.chat_name : nil
432
+ return false unless (chat_name && @chats.include?(chat_name)) || (chat_id && @chats.include?(chat_id))
433
+ end
434
+ true
435
+ end
436
+ end
437
+ end
438
+ end
Binary file