xasin-telegram 0.2.3 → 0.3.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 +4 -4
- data/README.md +24 -1
- data/lib/xasin/bad_config.yml +7 -0
- data/lib/xasin/bad_test.rb +38 -0
- data/lib/xasin/telegram.rb +1 -0
- data/lib/xasin/telegram/Chat.rb +87 -0
- data/lib/xasin/telegram/GroupingAdapter.rb +273 -0
- data/lib/xasin/telegram/HTTPCore.rb +75 -22
- data/lib/xasin/telegram/Handler.rb +301 -0
- data/lib/xasin/telegram/KeyboardLayout.rb +67 -0
- data/lib/xasin/telegram/MQTT_Adapter.rb +21 -188
- data/lib/xasin/telegram/Message.rb +126 -0
- data/lib/xasin/telegram/OnCommand.rb +34 -0
- data/lib/xasin/telegram/OnMessage.rb +29 -0
- data/lib/xasin/telegram/SingleUser.rb +12 -7
- data/lib/xasin/telegram/TestHTTPCore.rb +30 -8
- data/lib/xasin/telegram/User.rb +142 -0
- metadata +14 -3
@@ -1,64 +1,116 @@
|
|
1
1
|
|
2
2
|
require 'net/http'
|
3
3
|
require 'json'
|
4
|
+
require 'shellwords'
|
4
5
|
|
5
6
|
module Xasin
|
6
7
|
module Telegram
|
8
|
+
# This class handles the direct connection to the Telegram API
|
9
|
+
# All it does is handle a receive loop with the fitting update IDs, as well
|
10
|
+
# as a rescue'd "perform post" message.
|
7
11
|
class HTTPCore
|
8
12
|
def initialize(apikey)
|
9
13
|
@apikey = apikey;
|
10
14
|
|
11
15
|
@lastUpdateID = 0;
|
12
|
-
|
16
|
+
# Start the receive loop. Shouldn't crash, but if it does we
|
17
|
+
# want to know.
|
18
|
+
@receiveThread = Thread.new do
|
13
19
|
receive_loop();
|
14
|
-
end
|
20
|
+
end
|
21
|
+
@receiveThread.abort_on_exception = true
|
15
22
|
|
23
|
+
# Receptors are class instances that will receive updates
|
24
|
+
# from the HTTP update connection
|
16
25
|
@receptors = Array.new();
|
17
26
|
end
|
18
27
|
|
19
|
-
def
|
20
|
-
|
21
|
-
|
22
|
-
begin
|
23
|
-
if data
|
24
|
-
outData = Hash.new();
|
25
|
-
data.each do |key, val|
|
26
|
-
if(val.is_a? Hash or val.is_a? Array)
|
27
|
-
outData[key] = val.to_json
|
28
|
-
else
|
29
|
-
outData[key] = val;
|
30
|
-
end
|
31
|
-
end
|
28
|
+
private def _double_json_data(data)
|
29
|
+
return {} unless data
|
32
30
|
|
33
|
-
|
31
|
+
out_data = {}
|
32
|
+
# JSON-Ify nested Hashes and Arrays to a String before the main
|
33
|
+
# POST request is performed. Needed by Telegram, it seems.
|
34
|
+
data.each do |key, val|
|
35
|
+
if(val.is_a? Hash or val.is_a? Array)
|
36
|
+
out_data[key] = val.to_json
|
34
37
|
else
|
35
|
-
|
38
|
+
out_data[key] = val;
|
36
39
|
end
|
40
|
+
end
|
41
|
+
|
42
|
+
out_data
|
43
|
+
end
|
44
|
+
|
45
|
+
private def _raw_post(addr, data)
|
46
|
+
d_str = Shellwords.escape(data.to_json)
|
47
|
+
|
48
|
+
response = `curl -s -X POST -H "Content-Type: application/json" -d #{d_str} "#{addr}"`
|
37
49
|
|
38
|
-
|
50
|
+
JSON.parse(response, symbolize_names: true)
|
51
|
+
end
|
52
|
+
# Perform a POST request.
|
53
|
+
# @param method [String] The Telegram bot API method that should be called
|
54
|
+
# @param data [nil, Hash] The data to be sent with the command.
|
55
|
+
# Caution, any nested Hashes and Arrays will be converted to JSON
|
56
|
+
# BEFORE the Hash itself is also JSON-ified. Telegram apparently
|
57
|
+
# needs this to work.
|
58
|
+
def perform_post(method, data = nil)
|
59
|
+
call_address = "https://api.telegram.org/bot#{@apikey}/#{method}"
|
60
|
+
|
61
|
+
# Rescue-construct to prevent a HTTP error from
|
62
|
+
# crashing our system.
|
63
|
+
timeoutLen = data[:timeout] if data.is_a? Hash
|
64
|
+
timeoutLen ||= 4;
|
65
|
+
retryCount = 0;
|
66
|
+
begin
|
67
|
+
Timeout.timeout(timeoutLen) do
|
68
|
+
return _raw_post(call_address, _double_json_data(data));
|
69
|
+
end
|
39
70
|
rescue
|
71
|
+
retryCount += 1;
|
72
|
+
return {} if retryCount >= 3;
|
73
|
+
|
40
74
|
sleep 0.5;
|
41
75
|
retry
|
42
76
|
end
|
77
|
+
end
|
43
78
|
|
44
|
-
|
79
|
+
def feed_receptors(data)
|
80
|
+
# Hand it out to the receptors
|
81
|
+
@receptors.each do |r|
|
82
|
+
begin
|
83
|
+
r.handle_packet(data);
|
84
|
+
rescue => e
|
85
|
+
warn "Error in repector: #{e}"
|
86
|
+
warn e.backtrace.join("\n");
|
87
|
+
end
|
88
|
+
end
|
45
89
|
end
|
46
90
|
|
91
|
+
# Handle receiving of the data from Telegram API
|
92
|
+
# This is done via the "getUpdates" HTTP Request, with a timeout
|
93
|
+
# of 20s
|
94
|
+
# Update-ID offset is handled automagically by the system, so you needn't
|
95
|
+
# worry about it.
|
47
96
|
def receive_loop()
|
48
97
|
loop do
|
49
98
|
begin
|
99
|
+
# Perform the update request
|
50
100
|
packet = perform_post("getUpdates", {timeout: 20, offset: @lastUpdateID + 1})
|
51
101
|
|
52
102
|
next unless packet[:ok];
|
103
|
+
# Check if there even was a message sent by Telegram
|
104
|
+
# Due to the 20s timeout, a zero-length reply can happen
|
53
105
|
next if packet[:result].length == 0;
|
54
106
|
|
107
|
+
# Handle each result individually.
|
55
108
|
packet[:result].each do |data|
|
56
109
|
hUpdateID = data[:update_id].to_i
|
110
|
+
# Calculate the maximum Update ID (for the offset "getUpdates" parameter)
|
57
111
|
@lastUpdateID = [hUpdateID, @lastUpdateID].max
|
58
112
|
|
59
|
-
|
60
|
-
r.handle_packet(data);
|
61
|
-
end
|
113
|
+
feed_receptors data
|
62
114
|
end
|
63
115
|
rescue
|
64
116
|
sleep 1
|
@@ -67,6 +119,7 @@ module Telegram
|
|
67
119
|
end
|
68
120
|
end
|
69
121
|
|
122
|
+
# TODO check if the class supports handle_packet
|
70
123
|
def attach_receptor(receptorClass)
|
71
124
|
@receptors << receptorClass;
|
72
125
|
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
|
2
|
+
require_relative 'HTTPCore.rb'
|
3
|
+
require_relative 'Message.rb'
|
4
|
+
require_relative 'User.rb'
|
5
|
+
|
6
|
+
module Xasin
|
7
|
+
module Telegram
|
8
|
+
# Dummy class to provide sensible defaults for most things.
|
9
|
+
# Only really matters for priority, so that we can sort
|
10
|
+
# after that.
|
11
|
+
#
|
12
|
+
# @todo Test if the sorting is the right way around
|
13
|
+
class OnTelegramEvent
|
14
|
+
attr_accessor :priority
|
15
|
+
|
16
|
+
def initialize()
|
17
|
+
@priority = 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def nomp_message(message) end
|
21
|
+
|
22
|
+
def <=>(other)
|
23
|
+
self.priority <=> other.priority
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Main handler class, provides interfaces.
|
28
|
+
# It gives the user a way to send messages, set callbacks
|
29
|
+
# and retrieve chats with detailed data.
|
30
|
+
# More in-depth functions for messages are provided in the
|
31
|
+
# message class itself.
|
32
|
+
class Handler
|
33
|
+
# Returns the {HTTPCore} used to communicate with
|
34
|
+
# Telegram.
|
35
|
+
attr_reader :core
|
36
|
+
|
37
|
+
# List of permissions.
|
38
|
+
# Set this to a Hash containing arrays to
|
39
|
+
# included permissions, i.e. the following:
|
40
|
+
# {
|
41
|
+
# "admin" => ["default", "advanced"]
|
42
|
+
# }
|
43
|
+
#
|
44
|
+
# A user with "admin" would now also have "default" and "advanced"
|
45
|
+
# permissions for command execution.
|
46
|
+
#
|
47
|
+
# @note The :sudo permission will always overwrite everything, use
|
48
|
+
# only for developer access!
|
49
|
+
attr_accessor :permissions_list
|
50
|
+
|
51
|
+
def self.from_options(options)
|
52
|
+
out_handler = Handler.new(options['Key']);
|
53
|
+
|
54
|
+
if perms = options['Permissions']
|
55
|
+
raise ArgumentError, 'Permission list must be a hash!' unless perms.is_a? Hash
|
56
|
+
out_handler.permissions_list = perms
|
57
|
+
end
|
58
|
+
|
59
|
+
if u_list = options['Users']
|
60
|
+
raise ArgumentError, 'Userlist must be a hash!' unless u_list.is_a? Hash
|
61
|
+
|
62
|
+
u_list.each do |key, extra_opts|
|
63
|
+
u = out_handler[key];
|
64
|
+
|
65
|
+
unless u
|
66
|
+
warn "User #{key} could not be found."
|
67
|
+
next
|
68
|
+
end
|
69
|
+
|
70
|
+
if u_perms = extra_opts['Permissions']
|
71
|
+
u.add_permissions u_perms
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
out_handler
|
77
|
+
end
|
78
|
+
|
79
|
+
# initialize a new Handler.
|
80
|
+
#
|
81
|
+
# @param [String, HTTPCore] http_core The core to use, either
|
82
|
+
# a String representing the API Key, or an initialized Telegram Core
|
83
|
+
def initialize(http_core)
|
84
|
+
@core = if(http_core.is_a? Telegram::HTTPCore)
|
85
|
+
http_core;
|
86
|
+
elsif http_core.is_a? String
|
87
|
+
Telegram::HTTPCore.new(http_core);
|
88
|
+
else
|
89
|
+
raise ArgumentError, "Could not make a valid HTTPCore from string!"
|
90
|
+
end
|
91
|
+
|
92
|
+
@core.attach_receptor(self);
|
93
|
+
|
94
|
+
@chats = {}
|
95
|
+
|
96
|
+
@on_telegram_event = []
|
97
|
+
|
98
|
+
@permissions_list = {}
|
99
|
+
end
|
100
|
+
|
101
|
+
private def handle_message(message)
|
102
|
+
message = Message.new(self, message);
|
103
|
+
|
104
|
+
return unless message.valid
|
105
|
+
|
106
|
+
(@on_telegram_event + message.chat&.on_telegram_event).sort.each do |evt|
|
107
|
+
evt.nomp_message message
|
108
|
+
|
109
|
+
break if message.handled
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Handle an incoming callback query (inline keyboard button press)
|
114
|
+
# as received from the HTTP Core
|
115
|
+
private def handle_callback_query(cbq)
|
116
|
+
# Generate a fake message to feed into the message
|
117
|
+
# handling system ;)
|
118
|
+
|
119
|
+
fake_msg = {
|
120
|
+
from: cbq[:from],
|
121
|
+
text: cbq[:data],
|
122
|
+
chat: cbq[:message][:chat],
|
123
|
+
message_id: cbq[:message][:message_id]
|
124
|
+
}
|
125
|
+
|
126
|
+
handle_message fake_msg
|
127
|
+
|
128
|
+
# Send out a callback query reply (i.e. Telegram now knows we saw it)
|
129
|
+
@core.perform_post("answerCallbackQuery", {callback_query_id: cbq[:id]})
|
130
|
+
end
|
131
|
+
|
132
|
+
# Internal function, called from the Telegram Core.
|
133
|
+
# This is meant to be fed a Hash representing one update object
|
134
|
+
# from Telegram's /getUpdate function
|
135
|
+
def handle_packet(packet)
|
136
|
+
if m = packet[:message]
|
137
|
+
handle_message m
|
138
|
+
end
|
139
|
+
|
140
|
+
if cbq = packet[:callback_query]
|
141
|
+
handle_callback_query cbq
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Return a {Chat} object directly constructed from the
|
146
|
+
# passed Hash.
|
147
|
+
#
|
148
|
+
# Pass a Hash here to either fetch a chat with matching ID, or
|
149
|
+
# else constuct a new chat from the provided data. Can be used
|
150
|
+
# for getting a chat from a Message object, or adding a chat from
|
151
|
+
# stored chat configs.
|
152
|
+
def chat_from_object(object)
|
153
|
+
chat_id = object[:id];
|
154
|
+
|
155
|
+
if c = @chats[chat_id]
|
156
|
+
return c
|
157
|
+
end
|
158
|
+
|
159
|
+
c = nil;
|
160
|
+
if object[:username]
|
161
|
+
c = User.new(self, object);
|
162
|
+
else
|
163
|
+
c = Chat.new(self, object);
|
164
|
+
end
|
165
|
+
|
166
|
+
@chats[chat_id] = c;
|
167
|
+
|
168
|
+
c
|
169
|
+
end
|
170
|
+
|
171
|
+
# Try to find a chat with matching string ID.
|
172
|
+
# Useful when trying to find a chat by username, or a channel.
|
173
|
+
def chat_from_string(str)
|
174
|
+
@chats.each do |_, chat|
|
175
|
+
if chat.str_id == str
|
176
|
+
return chat
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
nil
|
181
|
+
end
|
182
|
+
|
183
|
+
# Return the {Chat} with given ID.
|
184
|
+
# This will either return a known chat with the wanted ID, or else
|
185
|
+
# will call getChat to fetch details on the wanted chat and
|
186
|
+
# construct a new {Chat} object. May also return nil if the wanted
|
187
|
+
# chat does not exist!
|
188
|
+
def chat_from_id(num)
|
189
|
+
if c = @chats[num]
|
190
|
+
return c
|
191
|
+
end
|
192
|
+
|
193
|
+
chat_obj = @core.perform_post('getChat', { chat_id: num });
|
194
|
+
|
195
|
+
return nil unless chat_obj[:ok]
|
196
|
+
|
197
|
+
chat_from_object chat_obj[:result]
|
198
|
+
end
|
199
|
+
|
200
|
+
# Convenience function to get a chat by any means.
|
201
|
+
# Pass a Number (interpreted as Chat ID), String (username)
|
202
|
+
# or Hash into here to try and fetch a Chat based on the parameter.
|
203
|
+
# Chat ID is preferred as it will let the system fetch the Chat from
|
204
|
+
# Telegram's API.
|
205
|
+
def get_chat(object)
|
206
|
+
if object.is_a? Chat
|
207
|
+
object
|
208
|
+
elsif object.is_a? Hash
|
209
|
+
chat_from_object object;
|
210
|
+
elsif object.is_a? Numeric
|
211
|
+
chat_from_id object
|
212
|
+
elsif object.is_a? String
|
213
|
+
if object =~ /^@/
|
214
|
+
chat_from_id object
|
215
|
+
else
|
216
|
+
chat_from_string object
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
alias [] get_chat
|
221
|
+
|
222
|
+
# Send a message to a given chat.
|
223
|
+
# The following options are supported when sending:
|
224
|
+
#
|
225
|
+
# - silent: true/false, whether to enable or disable notification
|
226
|
+
# - reply_to: {Message}/nil, try to reply to the given message ID.
|
227
|
+
# - inline_keyboard: Hash or Array of Hashes, to set the inline keyboard
|
228
|
+
# with fitting commands.
|
229
|
+
#
|
230
|
+
# @note When a Inline Keyboard is used, the button presses are
|
231
|
+
# internally interpreted as messages. This way, they can feed into
|
232
|
+
# the /command syntax.
|
233
|
+
def send_message(chat, text, **options)
|
234
|
+
raise ArgumentError, "Text needs to be a string" unless text.is_a? String
|
235
|
+
|
236
|
+
if text.length > 900
|
237
|
+
text = text[0..900] + "..."
|
238
|
+
end
|
239
|
+
|
240
|
+
out_data = {
|
241
|
+
chat_id: get_chat(chat).chat_id,
|
242
|
+
parse_mode: 'HTML',
|
243
|
+
text: text
|
244
|
+
}
|
245
|
+
|
246
|
+
if r = options[:reply_to]
|
247
|
+
out_data[:reply_to_message_id] = r.to_i
|
248
|
+
end
|
249
|
+
|
250
|
+
if options[:silent]
|
251
|
+
out_data[:disable_notification] = true;
|
252
|
+
end
|
253
|
+
|
254
|
+
if layout = options[:inline_keyboard]
|
255
|
+
out_data[:reply_markup] = KeyboardLayout.new(layout).ilk_reply_markup
|
256
|
+
end
|
257
|
+
|
258
|
+
reply = @core.perform_post('sendMessage', out_data);
|
259
|
+
|
260
|
+
Message.new(self, reply[:result]);
|
261
|
+
end
|
262
|
+
|
263
|
+
# Add a new callback on any generic message.
|
264
|
+
# The provided block will be called with the Message as parameter
|
265
|
+
# when something arrives. May additionally specify a RegExp to match
|
266
|
+
# against, in which case the match is a second parameter to the
|
267
|
+
# block.
|
268
|
+
def on_message(regexp = nil, &block)
|
269
|
+
raise ArgumentError, 'Block must be given!' unless block_given?
|
270
|
+
|
271
|
+
out_evt = OnMessage.new({ block: block, regexp: regexp });
|
272
|
+
@on_telegram_event << out_evt
|
273
|
+
|
274
|
+
out_evt
|
275
|
+
end
|
276
|
+
|
277
|
+
# Add a new callback on any /command message.
|
278
|
+
# The provided block will be called whenever the fitting /command
|
279
|
+
# is called. Additionally, by setting a list of priorities
|
280
|
+
# (options[:priorities] = []), only certain users may be allowed
|
281
|
+
# to execute a command.
|
282
|
+
#
|
283
|
+
# The block will be passed the message as argument.
|
284
|
+
def on_command(command, **options, &block)
|
285
|
+
raise ArgumentError, 'Block must be given!' unless block_given?
|
286
|
+
|
287
|
+
options[:block] = block
|
288
|
+
options[:command] = command
|
289
|
+
|
290
|
+
out_evt = OnCommand.new(options);
|
291
|
+
@on_telegram_event << out_evt
|
292
|
+
|
293
|
+
out_evt
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
require_relative 'OnMessage.rb'
|
300
|
+
require_relative 'OnCommand.rb'
|
301
|
+
require_relative 'KeyboardLayout.rb'
|
@@ -0,0 +1,67 @@
|
|
1
|
+
|
2
|
+
module Xasin
|
3
|
+
module Telegram
|
4
|
+
class KeyboardLayout
|
5
|
+
def initialize(start_config = nil)
|
6
|
+
@rows = Array.new()
|
7
|
+
|
8
|
+
if start_config.is_a? Hash
|
9
|
+
start_config = [start_config]
|
10
|
+
end
|
11
|
+
|
12
|
+
if start_config.is_a? Array
|
13
|
+
start_config.each_index do |i|
|
14
|
+
j = 0
|
15
|
+
start_config[i].each do |k, v|
|
16
|
+
set_button(k, v, r: i, c: j)
|
17
|
+
j += 1
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def [](i)
|
24
|
+
@rows[i]
|
25
|
+
end
|
26
|
+
|
27
|
+
def set_button(name, command = nil, c: 0, r: 0)
|
28
|
+
command ||= name
|
29
|
+
|
30
|
+
@rows[r] ||= [];
|
31
|
+
out_button = {text: name}
|
32
|
+
if command =~ /^(http|tg)/
|
33
|
+
out_button[:url] = command
|
34
|
+
else
|
35
|
+
out_button[:callback_data] = command
|
36
|
+
end
|
37
|
+
|
38
|
+
@rows[r][c] = out_button
|
39
|
+
end
|
40
|
+
|
41
|
+
def ilk_reply_markup()
|
42
|
+
return { inline_keyboard: to_ilk_layout }
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_ilk_layout()
|
46
|
+
out_data = [];
|
47
|
+
|
48
|
+
@rows.each do |row|
|
49
|
+
next if row.nil?
|
50
|
+
next if row.empty?
|
51
|
+
|
52
|
+
formatted_row = []
|
53
|
+
|
54
|
+
row.each do |button|
|
55
|
+
next if button.nil?
|
56
|
+
|
57
|
+
formatted_row << button
|
58
|
+
end
|
59
|
+
|
60
|
+
out_data << formatted_row unless formatted_row.empty?
|
61
|
+
end
|
62
|
+
|
63
|
+
out_data
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|