xasin-telegram 0.2.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- Thread.new do
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.abort_on_exception = true
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 perform_post(method, data = nil)
20
- callAddress = URI "https://api.telegram.org/bot#{@apikey}/#{method}"
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
- response = Net::HTTP.post_form(callAddress, outData);
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
- response = Net::HTTP.get callAddress
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
- response = JSON.parse(response.body, symbolize_names: true);
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
- return response;
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
- @receptors.each do |r|
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