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 +7 -0
- data/lib/api/client.rb +156 -0
- data/lib/api/types.rb +190 -0
- data/lib/core/bot.rb +275 -0
- data/lib/core/composer.rb +40 -0
- data/lib/core/context.rb +395 -0
- data/lib/core/scene.rb +81 -0
- data/lib/markup/keyboard.rb +294 -0
- data/lib/session/memory_store.rb +49 -0
- data/lib/session/middleware.rb +53 -0
- data/telegem.rb +55 -0
- data/version.rb +4 -0
- data/webhook/server.rb +86 -0
- metadata +123 -0
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
|