diligense 0.0.1
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/CHANGELOG.md +17 -0
- data/README.md +19 -0
- data/Rakefile +8 -0
- data/lib/diligense/api_client.rb +66 -0
- data/lib/diligense/application.rb +59 -0
- data/lib/diligense/message_controller.rb +31 -0
- data/lib/diligense/route.rb +102 -0
- data/lib/diligense/router.rb +62 -0
- data/lib/diligense/types/message.rb +35 -0
- data/lib/diligense/types/updates/message_update.rb +26 -0
- data/lib/diligense/version.rb +5 -0
- data/lib/diligense.rb +12 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fee0b40f03c5b52e6cb0683a3586a66cb92e8a9708d72a9facda56b9a3b661ec
|
|
4
|
+
data.tar.gz: 1e4e59ca8ec828f4188dcd52536f472f9c51e13b41b5c4bba0e6dc67a4216fcd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a1da038a14a705b7424317995d45ef3dd3484f4756f60fd589525afae89f612fbdac5e0b3b7f387717b95ee355426fc974bed630df2f7c45314ca32a6d724527
|
|
7
|
+
data.tar.gz: d2e2fc4002332fbde439116abf9058c0b3371457d9e85ed9aff0be50405ff17ebbe7a4c163711b56214f62755a82b4461bf4b157c828b627d33a95c311eeb0a0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2025-10-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Rails-inspired routing system for Telegram bots
|
|
15
|
+
- Controller-based message handling
|
|
16
|
+
- Basic bot functionality with HTTParty integration
|
|
17
|
+
- Zeitwerk for autoloading
|
data/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Ruby Telegram Bot
|
|
2
|
+
|
|
3
|
+
A Ruby application for Telegram bot with Rails-like structure.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Install dependencies:
|
|
8
|
+
```bash
|
|
9
|
+
bundle install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
2. Run the application:
|
|
13
|
+
```bash
|
|
14
|
+
./bin/bot
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Ruby Version
|
|
18
|
+
|
|
19
|
+
This project uses Ruby 3.4.4
|
data/Rakefile
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diligense
|
|
4
|
+
class ApiClient
|
|
5
|
+
BASE_URL = 'https://api.telegram.org/bot'
|
|
6
|
+
|
|
7
|
+
attr_reader :bot_info
|
|
8
|
+
|
|
9
|
+
def initialize(token)
|
|
10
|
+
@token = token
|
|
11
|
+
@bot_info = fetch_bot_info
|
|
12
|
+
print_bot_info
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_updates(offset: nil, timeout: 30)
|
|
16
|
+
params = { timeout: timeout }
|
|
17
|
+
params[:offset] = offset if offset
|
|
18
|
+
|
|
19
|
+
response = HTTParty.get("#{BASE_URL}#{@token}/getUpdates", query: params)
|
|
20
|
+
|
|
21
|
+
return [] unless response.success?
|
|
22
|
+
|
|
23
|
+
response.parsed_response['result']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def send_message(chat_id:, text:, **options)
|
|
27
|
+
params = {
|
|
28
|
+
chat_id: chat_id,
|
|
29
|
+
text: text
|
|
30
|
+
}.merge(options)
|
|
31
|
+
|
|
32
|
+
response = HTTParty.post("#{BASE_URL}#{@token}/sendMessage", body: params)
|
|
33
|
+
|
|
34
|
+
unless response.success?
|
|
35
|
+
raise "Failed to send message: #{response.code} - #{response.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
response.parsed_response['result']
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fetch_bot_info
|
|
44
|
+
response = HTTParty.get("#{BASE_URL}#{@token}/getMe")
|
|
45
|
+
|
|
46
|
+
unless response.success?
|
|
47
|
+
raise "Failed to fetch bot info: #{response.code} - #{response.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
response.parsed_response['result']
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_bot_info
|
|
54
|
+
return unless @bot_info
|
|
55
|
+
|
|
56
|
+
puts "\n🤖 Bot Information:"
|
|
57
|
+
puts " ID: #{@bot_info['id']}"
|
|
58
|
+
puts " Name: #{@bot_info['first_name']}"
|
|
59
|
+
puts " Username: @#{@bot_info['username']}" if @bot_info['username']
|
|
60
|
+
puts " Can join groups: #{@bot_info['can_join_groups']}"
|
|
61
|
+
puts " Can read all group messages: #{@bot_info['can_read_all_group_messages']}"
|
|
62
|
+
puts " Supports inline queries: #{@bot_info['supports_inline_queries']}"
|
|
63
|
+
puts
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Diligense
|
|
2
|
+
class Application
|
|
3
|
+
def initialize(root_path)
|
|
4
|
+
@root_path = root_path
|
|
5
|
+
|
|
6
|
+
@loader = Zeitwerk::Loader.new
|
|
7
|
+
@loader.push_dir(File.join(@root_path, 'app'))
|
|
8
|
+
@loader.enable_reloading
|
|
9
|
+
@loader.setup
|
|
10
|
+
|
|
11
|
+
token = ENV['TG_TOKEN']
|
|
12
|
+
raise 'TG_TOKEN environment variable is not set' unless token
|
|
13
|
+
|
|
14
|
+
@api_client = ApiClient.new(token)
|
|
15
|
+
@offset = nil
|
|
16
|
+
@router = load_routes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
loop do
|
|
21
|
+
updates = @api_client.get_updates(offset: @offset)
|
|
22
|
+
|
|
23
|
+
updates.each do |update|
|
|
24
|
+
if update.include?('message')
|
|
25
|
+
message_update = Types::Updates::MessageUpdate.new(update)
|
|
26
|
+
dispatch_message(message_update)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@offset = update['update_id'] + 1
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def load_routes
|
|
37
|
+
routes_path = File.join(@root_path, 'config', 'routes.rb')
|
|
38
|
+
router = eval(File.read(routes_path))
|
|
39
|
+
puts "Loaded #{router.routes.size} route(s)"
|
|
40
|
+
router
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dispatch_message(message_update)
|
|
44
|
+
handler_class_name = @router.match(message_update)
|
|
45
|
+
|
|
46
|
+
unless handler_class_name
|
|
47
|
+
puts "No route matched for message #{message_update.id}"
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
handler_class = Object.const_get(handler_class_name)
|
|
52
|
+
handler = handler_class.new(message_update: message_update, api_client: @api_client)
|
|
53
|
+
handler.call
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
puts "Error handling message: #{e.message}"
|
|
56
|
+
puts e.backtrace.first(5)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diligense
|
|
4
|
+
class MessageController
|
|
5
|
+
attr_reader :message
|
|
6
|
+
|
|
7
|
+
def initialize(message_update:, api_client:)
|
|
8
|
+
@message = message_update.message
|
|
9
|
+
@api_client = api_client
|
|
10
|
+
@message_update = message_update
|
|
11
|
+
|
|
12
|
+
@message&.instance_variable_set(:@api_client, @api_client)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return unless should_call_plain_message?
|
|
17
|
+
|
|
18
|
+
plain_message if respond_to?(:plain_message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def should_call_plain_message?
|
|
24
|
+
return false unless @message_update.type == :message
|
|
25
|
+
return false unless @message
|
|
26
|
+
return false if @message.has_attachments?
|
|
27
|
+
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diligense
|
|
4
|
+
class Route
|
|
5
|
+
attr_reader :type, :matcher, :handler
|
|
6
|
+
|
|
7
|
+
def initialize(type:, matcher:, handler:)
|
|
8
|
+
@type = type
|
|
9
|
+
@matcher = matcher
|
|
10
|
+
@handler = handler
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def match(message_update)
|
|
14
|
+
result = (
|
|
15
|
+
case @type
|
|
16
|
+
when :chat_id
|
|
17
|
+
match_chat_id?(message_update)
|
|
18
|
+
when :user_id
|
|
19
|
+
match_user_id?(message_update)
|
|
20
|
+
when :chat_type
|
|
21
|
+
match_chat_type?(message_update)
|
|
22
|
+
when :message_type
|
|
23
|
+
match_message_type?(message_update)
|
|
24
|
+
when :text_pattern
|
|
25
|
+
match_text_pattern?(message_update)
|
|
26
|
+
when :default
|
|
27
|
+
true
|
|
28
|
+
else
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
result ? @handler : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def match_chat_id?(message_update)
|
|
39
|
+
chat_id = extract_chat_id(message_update)
|
|
40
|
+
return false unless chat_id
|
|
41
|
+
|
|
42
|
+
@matcher == chat_id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def match_user_id?(message_update)
|
|
46
|
+
user_id = extract_user_id(message_update)
|
|
47
|
+
return false unless user_id
|
|
48
|
+
|
|
49
|
+
@matcher == user_id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def match_chat_type?(message_update)
|
|
53
|
+
chat_type = extract_chat_type(message_update)
|
|
54
|
+
return false unless chat_type
|
|
55
|
+
|
|
56
|
+
if @matcher.is_a?(Array)
|
|
57
|
+
@matcher.include?(chat_type)
|
|
58
|
+
else
|
|
59
|
+
@matcher == chat_type
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def match_message_type?(message_update)
|
|
64
|
+
raw_message = message_update.instance_variable_get(:@raw)['message']
|
|
65
|
+
return false unless raw_message
|
|
66
|
+
|
|
67
|
+
raw_message.key?(@matcher.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def match_text_pattern?(message_update)
|
|
71
|
+
raw_message = message_update.instance_variable_get(:@raw)['message']
|
|
72
|
+
return false unless raw_message
|
|
73
|
+
|
|
74
|
+
text = raw_message['text']
|
|
75
|
+
return false unless text
|
|
76
|
+
|
|
77
|
+
case @matcher
|
|
78
|
+
when Regexp
|
|
79
|
+
@matcher.match?(text)
|
|
80
|
+
when String
|
|
81
|
+
text == @matcher
|
|
82
|
+
else
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extract_chat_id(message_update)
|
|
88
|
+
raw_message = message_update.instance_variable_get(:@raw)['message']
|
|
89
|
+
raw_message&.dig('chat', 'id')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def extract_user_id(message_update)
|
|
93
|
+
raw_message = message_update.instance_variable_get(:@raw)['message']
|
|
94
|
+
raw_message&.dig('from', 'id')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def extract_chat_type(message_update)
|
|
98
|
+
raw_message = message_update.instance_variable_get(:@raw)['message']
|
|
99
|
+
raw_message&.dig('chat', 'type')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diligense
|
|
4
|
+
class Router
|
|
5
|
+
attr_reader :routes
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@routes = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def draw(&block)
|
|
12
|
+
instance_eval(&block)
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chat(chat_id, to:)
|
|
17
|
+
add_route(type: :chat_id, matcher: chat_id, handler: to)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def user(user_id, to:)
|
|
21
|
+
add_route(type: :user_id, matcher: user_id, handler: to)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dialog(to:)
|
|
25
|
+
add_route(type: :chat_type, matcher: 'private', handler: to)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def group(to:)
|
|
29
|
+
add_route(type: :chat_type, matcher: %w[group supergroup], handler: to)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def channel(to:)
|
|
33
|
+
add_route(type: :chat_type, matcher: 'channel', handler: to)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def message_type(type, to:)
|
|
37
|
+
add_route(type: :message_type, matcher: type, handler: to)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def text_matches(pattern, to:)
|
|
41
|
+
add_route(type: :text_pattern, matcher: pattern, handler: to)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default(to:)
|
|
45
|
+
add_route(type: :default, matcher: nil, handler: to)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def match(message_update)
|
|
49
|
+
@routes.each do |route|
|
|
50
|
+
handler = route.match(message_update)
|
|
51
|
+
return handler if handler
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def add_route(type:, matcher:, handler:)
|
|
59
|
+
@routes << Route.new(type: type, matcher: matcher, handler: handler)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Diligense
|
|
6
|
+
module Types
|
|
7
|
+
class Message
|
|
8
|
+
attr_reader :id, :content, :created_at
|
|
9
|
+
|
|
10
|
+
def initialize(raw, api_client: nil)
|
|
11
|
+
@raw = raw
|
|
12
|
+
@api_client = api_client
|
|
13
|
+
|
|
14
|
+
@id = raw.fetch('message_id')
|
|
15
|
+
@created_at = Time.at(raw.fetch('date')).to_datetime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def text
|
|
19
|
+
@raw['text']
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def has_attachments?
|
|
23
|
+
attachment_keys = %w[photo video document audio voice video_note sticker animation]
|
|
24
|
+
attachment_keys.any? { |key| @raw.key?(key) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reply(text)
|
|
28
|
+
raise 'API client not set' unless @api_client
|
|
29
|
+
|
|
30
|
+
chat_id = @raw.dig('chat', 'id')
|
|
31
|
+
@api_client.send_message(chat_id: chat_id, text: text)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Diligense
|
|
6
|
+
module Types
|
|
7
|
+
module Updates
|
|
8
|
+
class MessageUpdate
|
|
9
|
+
attr_reader :id, :message
|
|
10
|
+
|
|
11
|
+
def initialize(raw)
|
|
12
|
+
@raw = raw
|
|
13
|
+
|
|
14
|
+
@id = raw.fetch('update_id')
|
|
15
|
+
@message = Types::Message.new(raw.fetch('message')) if raw['message']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def type
|
|
19
|
+
return :message if @raw['message']
|
|
20
|
+
|
|
21
|
+
:unknown
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/diligense.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: diligense
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Artem Levenkov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: httparty
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.21'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.21'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: zeitwerk
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.6'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.6'
|
|
40
|
+
description: A Ruby framework for building Telegram bots with Rails-like conventions
|
|
41
|
+
including routing, controllers, and message handling
|
|
42
|
+
email:
|
|
43
|
+
- me@alev.pro
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- README.md
|
|
50
|
+
- Rakefile
|
|
51
|
+
- lib/diligense.rb
|
|
52
|
+
- lib/diligense/api_client.rb
|
|
53
|
+
- lib/diligense/application.rb
|
|
54
|
+
- lib/diligense/message_controller.rb
|
|
55
|
+
- lib/diligense/route.rb
|
|
56
|
+
- lib/diligense/router.rb
|
|
57
|
+
- lib/diligense/types/message.rb
|
|
58
|
+
- lib/diligense/types/updates/message_update.rb
|
|
59
|
+
- lib/diligense/version.rb
|
|
60
|
+
homepage: https://github.com/alev-pro/diligense
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
homepage_uri: https://github.com/alev-pro/diligense
|
|
65
|
+
source_code_uri: https://github.com/alev-pro/diligense
|
|
66
|
+
changelog_uri: https://github.com/alev-pro/diligense/blob/master/CHANGELOG.md
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 3.4.4
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.6.7
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Rails-inspired framework for building Telegram bots
|
|
85
|
+
test_files: []
|