telegem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 62c9d35475ba9320251a3a59954a34a0d0a0cf88e73787c03e493376704c76d5
4
+ data.tar.gz: 3a0bc3965894c05cc915d687fd546a18dcd958e034e2a674bf28677b8601b1d3
5
+ SHA512:
6
+ metadata.gz: 46885ed06aba5022c292c213413784d620743400da0ef9220126234a3bb12e83867bf069e7279bdae161ae8fec5eca9cba7936a3826f666505dde516a34b8de8
7
+ data.tar.gz: 3a1cd8545eb8b7c5b5c8ff4b92e7423e7c1a9e542e5d2200941d7cae9aecb890055a1d9d1371850bd6b0ba816d50c1c929acdebf52aff05fa466f238dec5e002
data/lib/api/client.rb ADDED
@@ -0,0 +1,156 @@
1
+ module Telegem
2
+ module API
3
+ class Client
4
+ BASE_URL = 'https://api.telegram.org'
5
+
6
+ attr_reader :token, :logger
7
+
8
+ def initialize(token, endpoint: nil, logger: nil)
9
+ @token = token
10
+ @logger = logger || Logger.new($stdout)
11
+ @endpoint = endpoint || Async::HTTP::Endpoint.parse("#{BASE_URL}/bot#{token}")
12
+ @client = nil
13
+ @semaphore = Async::Semaphore.new(30)
14
+ end
15
+
16
+ def call(method, params = {})
17
+ Async do |task|
18
+ @semaphore.async do
19
+ make_request(method, clean_params(params))
20
+ end
21
+ end
22
+ end
23
+
24
+ def upload(method, params)
25
+ Async do |task|
26
+ @semaphore.async do
27
+ make_multipart_request(method, params)
28
+ end
29
+ end
30
+ end
31
+
32
+ def get_updates(offset: nil, timeout: 30, limit: 100)
33
+ params = { timeout: timeout, limit: limit }
34
+ params[:offset] = offset if offset
35
+ call('getUpdates', params)
36
+ end
37
+
38
+ def close
39
+ @client&.close
40
+ end
41
+
42
+ private
43
+
44
+ def make_request(method, params)
45
+ with_client do |client|
46
+ headers = { 'content-type' => 'application/json' }
47
+ body = params.to_json
48
+
49
+ response = client.post("/bot#{@token}/#{method}", headers, body)
50
+ handle_response(response)
51
+ end
52
+ end
53
+
54
+ def make_multipart_request(method, params)
55
+ with_client do |client|
56
+ form = build_multipart(params)
57
+ headers = form.headers
58
+
59
+ response = client.post("/bot#{@token}/#{method}", headers, form.body)
60
+ handle_response(response)
61
+ end
62
+ end
63
+
64
+ def with_client(&block)
65
+ @client ||= Async::HTTP::Client.new(@endpoint)
66
+ yield @client
67
+ end
68
+
69
+ def clean_params(params)
70
+ params.reject { |_, v| v.nil? }
71
+ end
72
+
73
+ def build_multipart(params)
74
+ # Build multipart form data for file uploads
75
+ boundary = SecureRandom.hex(16)
76
+ parts = []
77
+
78
+ params.each do |key, value|
79
+ if file?(value)
80
+ parts << part_from_file(key, value, boundary)
81
+ else
82
+ parts << part_from_field(key, value, boundary)
83
+ end
84
+ end
85
+
86
+ parts << "--#{boundary}--\r\n"
87
+
88
+ body = parts.join
89
+ headers = {
90
+ 'content-type' => "multipart/form-data; boundary=#{boundary}",
91
+ 'content-length' => body.bytesize.to_s
92
+ }
93
+
94
+ OpenStruct.new(headers: headers, body: body)
95
+ end
96
+
97
+ def file?(value)
98
+ value.is_a?(File) ||
99
+ value.is_a?(StringIO) ||
100
+ (value.is_a?(String) && File.exist?(value))
101
+ end
102
+
103
+ def part_from_file(key, file, boundary)
104
+ filename = File.basename(file.path) if file.respond_to?(:path)
105
+ filename ||= "file"
106
+
107
+ mime_type = MIME::Types.type_for(filename).first || 'application/octet-stream'
108
+
109
+ content = if file.is_a?(String)
110
+ File.read(file)
111
+ else
112
+ file.read
113
+ end
114
+
115
+ <<~PART
116
+ --#{boundary}\r
117
+ Content-Disposition: form-data; name="#{key}"; filename="#{filename}"\r
118
+ Content-Type: #{mime_type}\r
119
+ \r
120
+ #{content}\r
121
+ PART
122
+ end
123
+
124
+ def part_from_field(key, value, boundary)
125
+ <<~PART
126
+ --#{boundary}\r
127
+ Content-Disposition: form-data; name="#{key}"\r
128
+ \r
129
+ #{value}\r
130
+ PART
131
+ end
132
+
133
+ def handle_response(response)
134
+ body = response.read
135
+ json = JSON.parse(body)
136
+
137
+ if json['ok']
138
+ json['result']
139
+ else
140
+ raise APIError.new(json['description'], json['error_code'])
141
+ end
142
+ rescue JSON::ParserError
143
+ raise APIError, "Invalid JSON response: #{body[0..100]}"
144
+ end
145
+ end
146
+
147
+ class APIError < StandardError
148
+ attr_reader :code
149
+
150
+ def initialize(message, code = nil)
151
+ super(message)
152
+ @code = code
153
+ end
154
+ end
155
+ end
156
+ end
data/lib/api/types.rb ADDED
@@ -0,0 +1,190 @@
1
+ module Telegem
2
+ module Types
3
+ class BaseType
4
+ def initialize(data)
5
+ @_raw_data = data || {}
6
+ end
7
+
8
+ def method_missing(name, *args)
9
+ key = name.to_s
10
+ return @_raw_data[key] if @_raw_data.key?(key)
11
+
12
+ camel_key = snake_to_camel(key)
13
+ return @_raw_data[camel_key] if @_raw_data.key?(camel_key)
14
+
15
+ super
16
+ end
17
+
18
+ def respond_to_missing?(name, include_private = false)
19
+ key = name.to_s
20
+ camel_key = snake_to_camel(key)
21
+ @_raw_data.key?(key) || @_raw_data.key?(camel_key) || super
22
+ end
23
+
24
+ attr_reader :_raw_data
25
+
26
+ private
27
+
28
+ def snake_to_camel(str)
29
+ str.gsub(/_([a-z])/) { $1.upcase }
30
+ end
31
+ end
32
+
33
+ class User < BaseType
34
+ attr_reader :id, :is_bot, :first_name, :last_name, :username,
35
+ :can_join_groups, :can_read_all_group_messages, :supports_inline_queries
36
+
37
+ def initialize(data)
38
+ super(data)
39
+
40
+ @id = data['id']
41
+ @is_bot = data['is_bot']
42
+ @first_name = data['first_name']
43
+ @last_name = data['last_name']
44
+ @username = data['username']
45
+ @can_join_groups = data['can_join_groups']
46
+ @can_read_all_group_messages = data['can_read_all_group_messages']
47
+ @supports_inline_queries = data['supports_inline_queries']
48
+ end
49
+
50
+ def full_name
51
+ [first_name, last_name].compact.join(' ')
52
+ end
53
+
54
+ def mention
55
+ username ? "@#{username}" : first_name
56
+ end
57
+ end
58
+
59
+ class Chat < BaseType
60
+ attr_reader :id, :type, :username, :title
61
+
62
+ def initialize(data)
63
+ super(data)
64
+
65
+ @id = data['id']
66
+ @type = data['type']
67
+ @username = data['username']
68
+ @title = data['title']
69
+ end
70
+
71
+ def private?
72
+ type == 'private'
73
+ end
74
+
75
+ def group?
76
+ type == 'group'
77
+ end
78
+
79
+ def supergroup?
80
+ type == 'supergroup'
81
+ end
82
+
83
+ def channel?
84
+ type == 'channel'
85
+ end
86
+ end
87
+
88
+ class MessageEntity < BaseType
89
+ attr_reader :type, :offset, :length, :url, :user, :language
90
+
91
+ def initialize(data)
92
+ super(data)
93
+
94
+ @type = data['type']
95
+ @offset = data['offset']
96
+ @length = data['length']
97
+ @url = data['url']
98
+ @user = User.new(data['user']) if data['user']
99
+ @language = data['language']
100
+ end
101
+ end
102
+
103
+ class Message < BaseType
104
+ attr_reader :message_id, :from, :chat, :date, :text, :entities,
105
+ :reply_markup, :via_bot, :forward_from, :forward_from_chat
106
+
107
+ def initialize(data)
108
+ super(data)
109
+
110
+ @message_id = data['message_id']
111
+ @from = User.new(data['from']) if data['from']
112
+ @chat = Chat.new(data['chat']) if data['chat']
113
+ @date = Time.at(data['date']) if data['date']
114
+ @text = data['text']
115
+
116
+ if data['entities']
117
+ @entities = data['entities'].map { |e| MessageEntity.new(e) }
118
+ end
119
+
120
+ @reply_markup = data['reply_markup']
121
+ @via_bot = User.new(data['via_bot']) if data['via_bot']
122
+ @forward_from = User.new(data['forward_from']) if data['forward_from']
123
+ @forward_from_chat = Chat.new(data['forward_from_chat']) if data['forward_from_chat']
124
+ end
125
+
126
+ # FIXED: Proper command detection
127
+ def command?
128
+ return false unless text
129
+ return false unless entities
130
+
131
+ # Find a "bot_command" entity
132
+ command_entity = entities.find { |e| e.type == 'bot_command' }
133
+ return false unless command_entity
134
+
135
+ # Extract the command text
136
+ command_text = text[command_entity.offset, command_entity.length]
137
+ command_text&.start_with?('/')
138
+ end
139
+
140
+ def command_name
141
+ return nil unless command?
142
+
143
+ command_entity = entities.find { |e| e.type == 'bot_command' }
144
+ return nil unless command_entity
145
+
146
+ cmd = text[command_entity.offset, command_entity.length]
147
+ cmd = cmd[1..-1] # Remove "/"
148
+ cmd = cmd.split('@').first # Remove bot username
149
+ cmd
150
+ end
151
+
152
+ def command_args
153
+ return nil unless command?
154
+
155
+ command_entity = entities.find { |e| e.type == 'bot_command' }
156
+ return nil unless command_entity
157
+
158
+ # Text after the command entity
159
+ args_start = command_entity.offset + command_entity.length
160
+ text[args_start..-1]&.strip
161
+ end
162
+ end
163
+
164
+ class CallbackQuery < BaseType
165
+ attr_reader :id, :from, :message, :data, :chat_instance
166
+
167
+ def initialize(data)
168
+ super(data)
169
+
170
+ @id = data['id']
171
+ @from = User.new(data['from']) if data['from']
172
+ @message = Message.new(data['message']) if data['message']
173
+ @data = data['data']
174
+ @chat_instance = data['chat_instance']
175
+ end
176
+ end
177
+
178
+ class Update < BaseType
179
+ attr_reader :update_id, :message, :callback_query
180
+
181
+ def initialize(data)
182
+ super(data)
183
+
184
+ @update_id = data['update_id']
185
+ @message = Message.new(data['message']) if data['message']
186
+ @callback_query = CallbackQuery.new(data['callback_query']) if data['callback_query']
187
+ end
188
+ end
189
+ end
190
+ end
data/lib/core/bot.rb ADDED
@@ -0,0 +1,275 @@
1
+ module Telegem
2
+ module Core
3
+ class Bot
4
+ attr_reader :token, :api, :handlers, :middleware, :logger, :scenes
5
+
6
+ def initialize(token, **options)
7
+ @token = token
8
+ @api = API::Client.new(token, **options.slice(:endpoint, :logger))
9
+ @handlers = {
10
+ message: [],
11
+ callback_query: [],
12
+ inline_query: [],
13
+ chat_member: [],
14
+ poll: [],
15
+ pre_checkout_query: [],
16
+ shipping_query: []
17
+ }
18
+ @middleware = []
19
+ @scenes = {}
20
+ @logger = options[:logger] || Logger.new($stdout)
21
+ @error_handler = nil
22
+ @session_store = options[:session_store] || Session::MemoryStore.new
23
+ @concurrency = options[:concurrency] || 10
24
+ @semaphore = Async::Semaphore.new(@concurrency)
25
+ end
26
+
27
+ # DSL Methods
28
+ def command(name, **options, &block)
29
+ pattern = /^\/#{Regexp.escape(name)}(?:@\w+)?(?:\s+(.+))?$/i
30
+
31
+ on(:message, text: pattern) do |ctx|
32
+ ctx.match = ctx.message.text.match(pattern)
33
+ ctx.state[:command_args] = ctx.match[1] if ctx.match
34
+ block.call(ctx)
35
+ end
36
+ end
37
+
38
+ def hears(pattern, **options, &block)
39
+ on(:message, text: pattern) do |ctx|
40
+ ctx.match = ctx.message.text.match(pattern)
41
+ block.call(ctx)
42
+ end
43
+ end
44
+
45
+ def on(type, filters = {}, &block)
46
+ @handlers[type] << { filters: filters, handler: block }
47
+ end
48
+
49
+ def use(middleware, *args, &block)
50
+ @middleware << [middleware, args, block]
51
+ self
52
+ end
53
+
54
+ def error(&block)
55
+ @error_handler = block
56
+ end
57
+
58
+ def scene(id, &block)
59
+ @scenes[id] = Scene.new(id, &block)
60
+ end
61
+
62
+ # Async Polling
63
+ def start_polling(**options)
64
+ Async do |parent|
65
+ @logger.info "Starting async polling..."
66
+ offset = nil
67
+
68
+ loop do
69
+ updates = await fetch_updates(offset, **options)
70
+
71
+ # Process updates concurrently with limits
72
+ updates.each do |update|
73
+ parent.async do |child|
74
+ @semaphore.async do
75
+ await process_update(update)
76
+ end
77
+ end
78
+ end
79
+
80
+ offset = updates.last&.update_id.to_i + 1 if updates.any?
81
+ end
82
+ rescue => e
83
+ handle_error(e)
84
+ raise
85
+ end
86
+ end
87
+
88
+ # Webhook Support
89
+ def webhook(app = nil, &block)
90
+ require 'telegem/webhook'
91
+
92
+ if block_given?
93
+ Webhook::Server.new(self, &block)
94
+ elsif app
95
+ Webhook::Middleware.new(self, app)
96
+ else
97
+ Webhook::Server.new(self)
98
+ end
99
+ end
100
+
101
+ def set_webhook(url, **options)
102
+ Async do
103
+ params = { url: url }.merge(options)
104
+ await @api.call('setWebhook', params)
105
+ end
106
+ end
107
+
108
+ def delete_webhook
109
+ Async do
110
+ await @api.call('deleteWebhook', {})
111
+ end
112
+ end
113
+
114
+ def get_webhook_info
115
+ Async do
116
+ await @api.call('getWebhookInfo', {})
117
+ end
118
+ end
119
+
120
+ # Core Processing
121
+ def process(update_data)
122
+ Async do
123
+ update = Types::Update.new(update_data)
124
+ await process_update(update)
125
+ end
126
+ end
127
+
128
+ def shutdown
129
+ @logger.info "Shutting down..."
130
+ @api.close
131
+ @logger.info "Bot stopped"
132
+ end
133
+
134
+ private
135
+
136
+ def fetch_updates(offset, timeout: 30, limit: 100, allowed_updates: nil)
137
+ Async do
138
+ params = { timeout: timeout, limit: limit }
139
+ params[:offset] = offset if offset
140
+ params[:allowed_updates] = allowed_updates if allowed_updates
141
+
142
+ updates = await @api.get_updates(**params)
143
+ updates.map { |data| Types::Update.new(data) }
144
+ rescue API::APIError => e
145
+ @logger.error "Failed to fetch updates: #{e.message}"
146
+ []
147
+ end
148
+ end
149
+
150
+ def process_update(update)
151
+ Async do
152
+ ctx = Context.new(update, self)
153
+
154
+ begin
155
+ await run_middleware_chain(ctx) do |context|
156
+ await dispatch_to_handlers(context)
157
+ end
158
+ rescue => e
159
+ await handle_error(e, ctx)
160
+ end
161
+ end
162
+ end
163
+
164
+ def run_middleware_chain(ctx, &final)
165
+ chain = build_middleware_chain
166
+ chain.call(ctx, &final)
167
+ end
168
+
169
+ def build_middleware_chain
170
+ chain = Composer.new
171
+
172
+ # Add user middleware
173
+ @middleware.each do |middleware_class, args, block|
174
+ if middleware_class.respond_to?(:new)
175
+ middleware = middleware_class.new(*args, &block)
176
+ chain.use(middleware)
177
+ else
178
+ chain.use(middleware_class)
179
+ end
180
+ end
181
+
182
+ # Add session middleware if not already added
183
+ unless @middleware.any? { |m, _, _| m.is_a?(Session::Middleware) }
184
+ chain.use(Session::Middleware.new(@session_store))
185
+ end
186
+
187
+ chain
188
+ end
189
+
190
+ def dispatch_to_handlers(ctx)
191
+ Async do
192
+ update_type = detect_update_type(ctx.update)
193
+ handlers = @handlers[update_type] || []
194
+
195
+ handlers.each do |handler|
196
+ if matches_filters?(ctx, handler[:filters])
197
+ result = handler[:handler].call(ctx)
198
+ result = await(result) if result.is_a?(Async::Task)
199
+ break # First matching handler wins
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ def detect_update_type(update)
206
+ return :message if update.message
207
+ return :callback_query if update.callback_query
208
+ return :inline_query if update.inline_query
209
+ return :chat_member if update.chat_member
210
+ return :poll if update.poll
211
+ return :pre_checkout_query if update.pre_checkout_query
212
+ return :shipping_query if update.shipping_query
213
+ :unknown
214
+ end
215
+
216
+ def matches_filters?(ctx, filters)
217
+ return true if filters.empty?
218
+
219
+ filters.all? do |key, value|
220
+ case key
221
+ when :text
222
+ matches_text_filter(ctx, value)
223
+ when :chat_type
224
+ matches_chat_type_filter(ctx, value)
225
+ when :command
226
+ matches_command_filter(ctx, value)
227
+ else
228
+ if value.respond_to?(:call)
229
+ result = value.call(ctx)
230
+ result = await(result) if result.is_a?(Async::Task)
231
+ result
232
+ else
233
+ ctx.update.send(key) == value
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ def matches_text_filter(ctx, pattern)
240
+ return false unless ctx.message&.text
241
+
242
+ if pattern.is_a?(Regexp)
243
+ ctx.message.text.match?(pattern)
244
+ else
245
+ ctx.message.text.include?(pattern.to_s)
246
+ end
247
+ end
248
+
249
+ def matches_chat_type_filter(ctx, type)
250
+ return false unless ctx.chat
251
+ ctx.chat.type == type.to_s
252
+ end
253
+
254
+ def webhook_server(**options)
255
+ require_relative '../webhook/server'
256
+ Webhook::Server.new(self, **options)
257
+ end
258
+
259
+ def matches_command_filter(ctx, command_name)
260
+ return false unless ctx.message&.command?
261
+ ctx.message.command_name == command_name.to_s
262
+ end
263
+
264
+ def handle_error(error, ctx = nil)
265
+ if @error_handler
266
+ result = @error_handler.call(error, ctx)
267
+ await(result) if result.is_a?(Async::Task)
268
+ else
269
+ @logger.error("Unhandled error: #{error.class}: #{error.message}")
270
+ @logger.error(error.backtrace.join("\n")) if error.backtrace
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,40 @@
1
+ module Telegem
2
+ module Core
3
+ class Composer
4
+ def initialize
5
+ @middleware = []
6
+ end
7
+
8
+ def use(middleware)
9
+ @middleware << middleware
10
+ self
11
+ end
12
+
13
+ def call(ctx, &final)
14
+ return final.call(ctx) if @middleware.empty?
15
+
16
+ # Build async-aware chain
17
+ chain = final
18
+
19
+ @middleware.reverse_each do |middleware|
20
+ chain = ->(context) do
21
+ if middleware.respond_to?(:call)
22
+ result = middleware.call(context, chain)
23
+ result.is_a?(Async::Task) ? result : Async::Task.new(result)
24
+ elsif middleware.is_a?(Class)
25
+ instance = middleware.new
26
+ result = instance.call(context, chain)
27
+ result.is_a?(Async::Task) ? result : Async::Task.new(result)
28
+ else
29
+ raise "Invalid middleware: #{middleware.class}"
30
+ end
31
+ end
32
+ end
33
+
34
+ # Execute the chain
35
+ chain.call(ctx)
36
+ end
37
+
38
+ def empty?
39
+ @middleware.empty?
40
+ end