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.
@@ -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