telegem 0.1.6 → 0.2.5
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 +4 -4
- data/lib/api/client.rb +158 -151
- data/lib/core/bot.rb +44 -38
- data/lib/telegem.rb +1 -1
- data/telegem.gemspec +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc3dc81c673fa1128b108e02c3401f4bac483d7173759b4c901c3db625fa8dd8
|
|
4
|
+
data.tar.gz: 4cb6de9fa5d9b8c5babbbd83ff86b5b7f6c0161628e74dbb7416ee5eeaa10e70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32211bca88485aa5d19b41c8bc400b1af8b790de8ec4cf9761537a2f59de299e4ee6c5dca2de3e5561f58fe0280238c1452cff10429aa876d2fad2426556819d
|
|
7
|
+
data.tar.gz: fef73c4f8d976614ec815cee3c230409bf471564f38a46acb5fc7093c80d2154186a621f68068098cc1072cdb144184c92a683be83e42107f7ea409ea3175b5e
|
data/lib/api/client.rb
CHANGED
|
@@ -1,156 +1,163 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
1
|
+
require 'async'
|
|
2
|
+
require 'async/http'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'logger'
|
|
5
|
+
require 'mime/types'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'ostruct'
|
|
8
|
+
|
|
9
|
+
module Telegem
|
|
10
|
+
module API
|
|
11
|
+
class Client
|
|
12
|
+
BASE_URL = 'https://api.telegram.org'
|
|
13
|
+
|
|
14
|
+
attr_reader :token, :logger
|
|
15
|
+
|
|
16
|
+
def initialize(token, endpoint: nil, logger: nil)
|
|
17
|
+
@token = token
|
|
18
|
+
@logger = logger || Logger.new($stdout)
|
|
19
|
+
@endpoint = endpoint || Async::HTTP::Endpoint.parse("#{BASE_URL}/bot#{token}")
|
|
20
|
+
@client = nil
|
|
21
|
+
@semaphore = Async::Semaphore.new(30)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(method, params = {})
|
|
25
|
+
Async do |task|
|
|
26
|
+
@semaphore.async do
|
|
27
|
+
make_request(method, clean_params(params))
|
|
145
28
|
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def upload(method, params)
|
|
33
|
+
Async do |task|
|
|
34
|
+
@semaphore.async do
|
|
35
|
+
make_multipart_request(method, params)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_updates(offset: nil, timeout: 30, limit: 100)
|
|
41
|
+
params = { timeout: timeout, limit: limit }
|
|
42
|
+
params[:offset] = offset if offset
|
|
43
|
+
call('getUpdates', params)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@client&.close
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
146
51
|
|
|
147
|
-
|
|
148
|
-
|
|
52
|
+
def make_request(method, params)
|
|
53
|
+
with_client do |client|
|
|
54
|
+
headers = { 'content-type' => 'application/json' }
|
|
55
|
+
body = params.to_json
|
|
149
56
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
57
|
+
response = client.post("/bot#{@token}/#{method}", headers, body)
|
|
58
|
+
handle_response(response)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def make_multipart_request(method, params)
|
|
63
|
+
with_client do |client|
|
|
64
|
+
form = build_multipart(params)
|
|
65
|
+
headers = form.headers
|
|
66
|
+
|
|
67
|
+
response = client.post("/bot#{@token}/#{method}", headers, form.body)
|
|
68
|
+
handle_response(response)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_client(&block)
|
|
73
|
+
@client ||= Async::HTTP::Client.new(@endpoint)
|
|
74
|
+
yield @client
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clean_params(params)
|
|
78
|
+
params.reject { |_, v| v.nil? }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_multipart(params)
|
|
82
|
+
boundary = SecureRandom.hex(16)
|
|
83
|
+
parts = []
|
|
84
|
+
|
|
85
|
+
params.each do |key, value|
|
|
86
|
+
if file?(value)
|
|
87
|
+
parts << part_from_file(key, value, boundary)
|
|
88
|
+
else
|
|
89
|
+
parts << part_from_field(key, value, boundary)
|
|
154
90
|
end
|
|
155
91
|
end
|
|
156
|
-
|
|
92
|
+
|
|
93
|
+
parts << "--#{boundary}--\r\n"
|
|
94
|
+
|
|
95
|
+
body = parts.join
|
|
96
|
+
headers = {
|
|
97
|
+
'content-type' => "multipart/form-data; boundary=#{boundary}",
|
|
98
|
+
'content-length' => body.bytesize.to_s
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
OpenStruct.new(headers: headers, body: body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def file?(value)
|
|
105
|
+
value.is_a?(File) ||
|
|
106
|
+
value.is_a?(StringIO) ||
|
|
107
|
+
(value.is_a?(String) && File.exist?(value))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def part_from_file(key, file, boundary)
|
|
111
|
+
filename = File.basename(file.path) if file.respond_to?(:path)
|
|
112
|
+
filename ||= "file"
|
|
113
|
+
|
|
114
|
+
mime_type = MIME::Types.type_for(filename).first || 'application/octet-stream'
|
|
115
|
+
|
|
116
|
+
content = if file.is_a?(String)
|
|
117
|
+
File.read(file)
|
|
118
|
+
else
|
|
119
|
+
file.read
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
<<~PART
|
|
123
|
+
--#{boundary}\r
|
|
124
|
+
Content-Disposition: form-data; name="#{key}"; filename="#{filename}"\r
|
|
125
|
+
Content-Type: #{mime_type}\r
|
|
126
|
+
\r
|
|
127
|
+
#{content}\r
|
|
128
|
+
PART
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def part_from_field(key, value, boundary)
|
|
132
|
+
<<~PART
|
|
133
|
+
--#{boundary}\r
|
|
134
|
+
Content-Disposition: form-data; name="#{key}"\r
|
|
135
|
+
\r
|
|
136
|
+
#{value}\r
|
|
137
|
+
PART
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_response(response)
|
|
141
|
+
body = response.read
|
|
142
|
+
json = JSON.parse(body)
|
|
143
|
+
|
|
144
|
+
if json['ok']
|
|
145
|
+
json['result']
|
|
146
|
+
else
|
|
147
|
+
raise APIError.new(json['description'], json['error_code'])
|
|
148
|
+
end
|
|
149
|
+
rescue JSON::ParserError
|
|
150
|
+
raise APIError, "Invalid JSON response: #{body[0..100]}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class APIError < StandardError
|
|
155
|
+
attr_reader :code
|
|
156
|
+
|
|
157
|
+
def initialize(message, code = nil)
|
|
158
|
+
super(message)
|
|
159
|
+
@code = code
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/core/bot.rb
CHANGED
|
@@ -22,9 +22,10 @@ module Telegem
|
|
|
22
22
|
@session_store = options[:session_store] || Session::MemoryStore.new
|
|
23
23
|
@concurrency = options[:concurrency] || 10
|
|
24
24
|
@semaphore = Async::Semaphore.new(@concurrency)
|
|
25
|
+
@polling_task = nil
|
|
26
|
+
@running = false
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
# DSL Methods
|
|
28
29
|
def command(name, **options, &block)
|
|
29
30
|
pattern = /^\/#{Regexp.escape(name)}(?:@\w+)?(?:\s+(.+))?$/i
|
|
30
31
|
|
|
@@ -59,35 +60,43 @@ module Telegem
|
|
|
59
60
|
@scenes[id] = Scene.new(id, &block)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
# Async Polling
|
|
63
63
|
def start_polling(**options)
|
|
64
|
-
|
|
64
|
+
return @polling_task if @running
|
|
65
|
+
|
|
66
|
+
@polling_task = Async do |parent|
|
|
67
|
+
@running = true
|
|
65
68
|
@logger.info "Starting async polling..."
|
|
66
69
|
offset = nil
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
begin
|
|
72
|
+
loop do
|
|
73
|
+
updates_task = fetch_updates(offset, **options)
|
|
74
|
+
updates_data = updates_task.wait
|
|
75
|
+
updates = updates_data.map { |data| Types::Update.new(data) }
|
|
76
|
+
|
|
77
|
+
updates.each do |update|
|
|
78
|
+
parent.async do |child|
|
|
79
|
+
@semaphore.async do
|
|
80
|
+
process_update(update)
|
|
81
|
+
end
|
|
76
82
|
end
|
|
77
83
|
end
|
|
78
|
-
end
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
offset = updates.last&.update_id.to_i + 1 if updates.any?
|
|
86
|
+
end
|
|
87
|
+
rescue => e
|
|
88
|
+
handle_error(e)
|
|
89
|
+
raise
|
|
90
|
+
ensure
|
|
91
|
+
@running = false
|
|
81
92
|
end
|
|
82
|
-
rescue => e
|
|
83
|
-
handle_error(e)
|
|
84
|
-
raise
|
|
85
93
|
end
|
|
94
|
+
|
|
95
|
+
@polling_task
|
|
86
96
|
end
|
|
87
97
|
|
|
88
|
-
# Webhook Support
|
|
89
98
|
def webhook(app = nil, &block)
|
|
90
|
-
|
|
99
|
+
require_relative '../../webhook/server'
|
|
91
100
|
|
|
92
101
|
if block_given?
|
|
93
102
|
Webhook::Server.new(self, &block)
|
|
@@ -117,7 +126,6 @@ module Telegem
|
|
|
117
126
|
end
|
|
118
127
|
end
|
|
119
128
|
|
|
120
|
-
# Core Processing
|
|
121
129
|
def process(update_data)
|
|
122
130
|
Async do
|
|
123
131
|
update = Types::Update.new(update_data)
|
|
@@ -127,24 +135,29 @@ module Telegem
|
|
|
127
135
|
|
|
128
136
|
def shutdown
|
|
129
137
|
@logger.info "Shutting down..."
|
|
138
|
+
|
|
139
|
+
if @polling_task && @polling_task.running?
|
|
140
|
+
@polling_task.stop
|
|
141
|
+
@polling_task = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
130
144
|
@api.close
|
|
145
|
+
@running = false
|
|
131
146
|
@logger.info "Bot stopped"
|
|
132
147
|
end
|
|
133
148
|
|
|
149
|
+
def running?
|
|
150
|
+
@running
|
|
151
|
+
end
|
|
152
|
+
|
|
134
153
|
private
|
|
135
154
|
|
|
136
155
|
def fetch_updates(offset, timeout: 30, limit: 100, allowed_updates: nil)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
156
|
+
params = { timeout: timeout, limit: limit }
|
|
157
|
+
params[:offset] = offset if offset
|
|
158
|
+
params[:allowed_updates] = allowed_updates if allowed_updates
|
|
159
|
+
|
|
160
|
+
@api.get_updates(**params)
|
|
148
161
|
end
|
|
149
162
|
|
|
150
163
|
def process_update(update)
|
|
@@ -169,7 +182,6 @@ module Telegem
|
|
|
169
182
|
def build_middleware_chain
|
|
170
183
|
chain = Composer.new
|
|
171
184
|
|
|
172
|
-
# Add user middleware
|
|
173
185
|
@middleware.each do |middleware_class, args, block|
|
|
174
186
|
if middleware_class.respond_to?(:new)
|
|
175
187
|
middleware = middleware_class.new(*args, &block)
|
|
@@ -179,7 +191,6 @@ module Telegem
|
|
|
179
191
|
end
|
|
180
192
|
end
|
|
181
193
|
|
|
182
|
-
# Add session middleware if not already added
|
|
183
194
|
unless @middleware.any? { |m, _, _| m.is_a?(Session::Middleware) }
|
|
184
195
|
chain.use(Session::Middleware.new(@session_store))
|
|
185
196
|
end
|
|
@@ -196,7 +207,7 @@ module Telegem
|
|
|
196
207
|
if matches_filters?(ctx, handler[:filters])
|
|
197
208
|
result = handler[:handler].call(ctx)
|
|
198
209
|
result = await(result) if result.is_a?(Async::Task)
|
|
199
|
-
break
|
|
210
|
+
break
|
|
200
211
|
end
|
|
201
212
|
end
|
|
202
213
|
end
|
|
@@ -251,11 +262,6 @@ module Telegem
|
|
|
251
262
|
ctx.chat.type == type.to_s
|
|
252
263
|
end
|
|
253
264
|
|
|
254
|
-
def webhook_server(**options)
|
|
255
|
-
require_relative '../webhook/server'
|
|
256
|
-
Webhook::Server.new(self, **options)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
265
|
def matches_command_filter(ctx, command_name)
|
|
260
266
|
return false unless ctx.message&.command?
|
|
261
267
|
ctx.message.command_name == command_name.to_s
|
data/lib/telegem.rb
CHANGED
data/telegem.gemspec
CHANGED
|
@@ -4,7 +4,7 @@ Gem::Specification.new do |spec|
|
|
|
4
4
|
|
|
5
5
|
# Read version from lib/telegem.rb
|
|
6
6
|
version_file = File.read('lib/telegem.rb').match(/VERSION\s*=\s*['"]([^'"]+)['"]/)
|
|
7
|
-
spec.version = version_file ? version_file[1] : "0.
|
|
7
|
+
spec.version = version_file ? version_file[1] : "0.2.5"
|
|
8
8
|
|
|
9
9
|
spec.authors = ["Phantom"]
|
|
10
10
|
spec.email = ["ynghosted@icloud.com"]
|