telegram-support-bot 0.1.07 → 0.1.09
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/CHANGELOG.md +24 -0
- data/README.md +53 -16
- data/lib/telegram_support_bot/configuration.rb +19 -1
- data/lib/telegram_support_bot/state_store.rb +57 -0
- data/lib/telegram_support_bot/state_stores/memory.rb +127 -0
- data/lib/telegram_support_bot/state_stores/redis.rb +202 -0
- data/lib/telegram_support_bot/version.rb +1 -1
- data/lib/telegram_support_bot.rb +165 -60
- data/script/dev_poll.rb +7 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15900469360f291d85f69a938dc8f25cc9826ec0fab4105c954e0f58aea48868
|
|
4
|
+
data.tar.gz: 441423cab220f879327d6474bafb069c8fa099966f7cc45c6ac2896944a52238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fa0913384b4a0ecc87dd7c37cc546fc6d8a5e937c9154b7bdeb91d80ed3070cb33d4f5bc6d9d051fe5d84b865bfc904638528e7259cb4a366f5e978e448ecf3
|
|
7
|
+
data.tar.gz: c844cfaf107d7bed62378b7dfd170d28ee8a4e81f17802c7924cc8629a0dc3485242b5966dbc0371bdddffa2bbd5e861068e0970ebb5d5561c7da1b3a663b06d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.09] - 2026-02-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Configurable support-chat non-command behavior:
|
|
11
|
+
`ignore_non_command_messages` (default `true`) and
|
|
12
|
+
`non_command_message_response` for optional acknowledgement text.
|
|
13
|
+
|
|
14
|
+
## [0.1.08] - 2026-02-13
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Optional one-time contact sharing flow for user identification:
|
|
18
|
+
`request_contact_on_start`, `require_contact_for_support`, custom contact messages, and
|
|
19
|
+
`on_contact_received` callback.
|
|
20
|
+
- In-memory user profile storage and lookup via `TelegramSupportBot.user_profile(chat_id)`.
|
|
21
|
+
- Configurable state-store backend with Redis support for multi-pod deployments
|
|
22
|
+
(`state_store`, `state_store_options`, and state TTL settings).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Support reply routing now uses internal message mapping first, with `forward_from` as fallback.
|
|
26
|
+
This removes the dependency on user forwarding privacy settings for normal reply flows.
|
|
27
|
+
- Message processing no longer relies on shared `@message_chat_id`, reducing thread-safety risks.
|
|
28
|
+
|
|
5
29
|
## [0.1.07] - 2026-02-13
|
|
6
30
|
|
|
7
31
|
### Added
|
data/README.md
CHANGED
|
@@ -43,6 +43,20 @@ TelegramSupportBot.configure do |config|
|
|
|
43
43
|
config.adapter_options = { token: 'YOUR_TELEGRAM_BOT_TOKEN' }
|
|
44
44
|
config.support_chat_id = 'YOUR_SUPPORT_CHAT_ID'
|
|
45
45
|
config.welcome_message = 'Hi! How can we help you?'
|
|
46
|
+
# Support-chat noise control:
|
|
47
|
+
# true (default) -> ignore non-command messages in support chat
|
|
48
|
+
# false -> reply with non_command_message_response
|
|
49
|
+
config.ignore_non_command_messages = true
|
|
50
|
+
config.non_command_message_response = 'I only respond to commands. Please use /start.'
|
|
51
|
+
# Optional: ask users to share their phone once for account lookup.
|
|
52
|
+
config.request_contact_on_start = true
|
|
53
|
+
# Optional: block forwarding until contact is shared.
|
|
54
|
+
config.require_contact_for_support = false
|
|
55
|
+
# Optional callback to persist/lookup user profile in your app.
|
|
56
|
+
config.on_contact_received = ->(profile) { YourUserMatcher.sync_from_telegram(profile) }
|
|
57
|
+
# Recommended in Kubernetes/multi-pod setup:
|
|
58
|
+
# config.state_store = :redis
|
|
59
|
+
# config.state_store_options = { url: ENV.fetch('REDIS_URL'), namespace: 'telegram_support_bot' }
|
|
46
60
|
end
|
|
47
61
|
```
|
|
48
62
|
|
|
@@ -115,28 +129,51 @@ end
|
|
|
115
129
|
Implement custom adapters by inheriting from `TelegramSupportBot::Adapter::Base` and defining
|
|
116
130
|
message sending and forwarding methods.
|
|
117
131
|
|
|
118
|
-
##
|
|
132
|
+
## User Identification With Phone Sharing
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
messages with identifiable information. This restriction impacts the `forward_from` key, necessary
|
|
122
|
-
for the bot to recognize and reply to users directly. To ensure seamless communication and support,
|
|
123
|
-
we recommend including instructions in your bot's welcome message, asking users to allow message
|
|
124
|
-
forwarding from your bot. Here's an example of how you can phrase this request:
|
|
134
|
+
If you want support agents to identify users quickly in your CRM, you can request phone sharing once:
|
|
125
135
|
|
|
136
|
+
```ruby
|
|
137
|
+
TelegramSupportBot.configure do |config|
|
|
138
|
+
config.request_contact_on_start = true
|
|
139
|
+
config.require_contact_for_support = false
|
|
140
|
+
config.contact_request_message = 'Please share your phone number so we can identify your account.'
|
|
141
|
+
config.contact_received_message = 'Thanks! We have saved your phone number.'
|
|
142
|
+
config.on_contact_received = ->(profile) do
|
|
143
|
+
# profile keys:
|
|
144
|
+
# :chat_id, :user_id, :phone_number, :first_name, :last_name, :username, :language_code
|
|
145
|
+
UserIdentitySync.call(profile)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
126
148
|
```
|
|
127
|
-
Please mind, that your privacy settings might prevent the bot from sending you the reply from the support team. Please consider adding this bot to your allow-list for forwarding. Here’s how you can do it:
|
|
128
|
-
|
|
129
|
-
1. Go to Settings in your Telegram app.
|
|
130
|
-
2. Tap on 'Privacy and Security'.
|
|
131
|
-
3. Scroll to 'Forwarded Messages'.
|
|
132
|
-
4. Add this bot to the list of exceptions by selecting 'Always Allow' for it.
|
|
133
149
|
|
|
134
|
-
|
|
150
|
+
If you set `require_contact_for_support = true`, the bot will ask for contact and will not forward
|
|
151
|
+
other user messages until contact is shared.
|
|
135
152
|
|
|
136
|
-
|
|
153
|
+
Support replies are routed by internal message mapping, so users do not need to change Telegram
|
|
154
|
+
forwarding privacy settings to receive replies.
|
|
155
|
+
|
|
156
|
+
## State Storage (Single Pod vs Multi-Pod)
|
|
137
157
|
|
|
138
|
-
|
|
139
|
-
|
|
158
|
+
By default, runtime state is stored in-memory (`state_store = :memory`). This is fine for local
|
|
159
|
+
development or a single process.
|
|
160
|
+
|
|
161
|
+
For Kubernetes / multiple pods, configure Redis so message mappings, reaction state, and user
|
|
162
|
+
profiles are shared:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
TelegramSupportBot.configure do |config|
|
|
166
|
+
config.state_store = :redis
|
|
167
|
+
config.state_store_options = {
|
|
168
|
+
url: ENV.fetch('REDIS_URL'),
|
|
169
|
+
namespace: 'telegram_support_bot'
|
|
170
|
+
}
|
|
171
|
+
# Optional TTL tuning:
|
|
172
|
+
# config.mapping_ttl_seconds = 30 * 24 * 60 * 60
|
|
173
|
+
# config.reaction_count_ttl_seconds = 7 * 24 * 60 * 60
|
|
174
|
+
# config.user_profile_ttl_seconds = nil
|
|
175
|
+
end
|
|
176
|
+
```
|
|
140
177
|
|
|
141
178
|
## Development
|
|
142
179
|
|
|
@@ -3,15 +3,33 @@
|
|
|
3
3
|
module TelegramSupportBot
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :adapter, :adapter_options, :support_chat_id, :welcome_message,
|
|
6
|
-
:auto_away_message, :auto_away_interval, :ignore_unknown_commands
|
|
6
|
+
:auto_away_message, :auto_away_interval, :ignore_unknown_commands,
|
|
7
|
+
:ignore_non_command_messages, :non_command_message_response,
|
|
8
|
+
:request_contact_on_start, :require_contact_for_support, :contact_request_message,
|
|
9
|
+
:contact_received_message, :contact_invalid_message, :on_contact_received,
|
|
10
|
+
:state_store, :state_store_options, :mapping_ttl_seconds,
|
|
11
|
+
:reaction_count_ttl_seconds, :user_profile_ttl_seconds
|
|
7
12
|
|
|
8
13
|
def initialize
|
|
9
14
|
@adapter = :telegram_bot
|
|
10
15
|
@adapter_options = {}
|
|
11
16
|
@welcome_message = 'Welcome! How can we help you?'
|
|
12
17
|
@ignore_unknown_commands = true
|
|
18
|
+
@ignore_non_command_messages = true
|
|
19
|
+
@non_command_message_response = 'I received your message, but I only respond to commands. Please use /start to get started.'
|
|
13
20
|
@auto_away_interval = 10 # seconds
|
|
14
21
|
@auto_away_message = 'We are sorry, all operators are busy at the moment. Please wait'
|
|
22
|
+
@request_contact_on_start = false
|
|
23
|
+
@require_contact_for_support = false
|
|
24
|
+
@contact_request_message = 'Please share your phone number so we can quickly identify your account.'
|
|
25
|
+
@contact_received_message = 'Thanks! We have saved your phone number.'
|
|
26
|
+
@contact_invalid_message = 'Please use the button below to share your own phone number.'
|
|
27
|
+
@on_contact_received = nil
|
|
28
|
+
@state_store = :memory
|
|
29
|
+
@state_store_options = {}
|
|
30
|
+
@mapping_ttl_seconds = 30 * 24 * 60 * 60
|
|
31
|
+
@reaction_count_ttl_seconds = 7 * 24 * 60 * 60
|
|
32
|
+
@user_profile_ttl_seconds = nil
|
|
15
33
|
end
|
|
16
34
|
end
|
|
17
35
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TelegramSupportBot
|
|
4
|
+
module StateStore
|
|
5
|
+
class MapProxy
|
|
6
|
+
def initialize(get_proc:, set_proc:, clear_proc:, size_proc:)
|
|
7
|
+
@get_proc = get_proc
|
|
8
|
+
@set_proc = set_proc
|
|
9
|
+
@clear_proc = clear_proc
|
|
10
|
+
@size_proc = size_proc
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def [](key)
|
|
14
|
+
@get_proc.call(key)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def []=(key, value)
|
|
18
|
+
@set_proc.call(key, value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clear
|
|
22
|
+
@clear_proc.call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size
|
|
26
|
+
@size_proc.call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build(configuration)
|
|
31
|
+
backend = configuration.state_store.to_sym
|
|
32
|
+
options = configuration.state_store_options || {}
|
|
33
|
+
|
|
34
|
+
case backend
|
|
35
|
+
when :memory
|
|
36
|
+
StateStores::Memory.new(
|
|
37
|
+
mapping_ttl_seconds: configuration.mapping_ttl_seconds,
|
|
38
|
+
reaction_count_ttl_seconds: configuration.reaction_count_ttl_seconds,
|
|
39
|
+
user_profile_ttl_seconds: configuration.user_profile_ttl_seconds,
|
|
40
|
+
**options
|
|
41
|
+
)
|
|
42
|
+
when :redis
|
|
43
|
+
require_relative 'state_stores/redis'
|
|
44
|
+
StateStores::Redis.new(
|
|
45
|
+
mapping_ttl_seconds: configuration.mapping_ttl_seconds,
|
|
46
|
+
reaction_count_ttl_seconds: configuration.reaction_count_ttl_seconds,
|
|
47
|
+
user_profile_ttl_seconds: configuration.user_profile_ttl_seconds,
|
|
48
|
+
**options
|
|
49
|
+
)
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "Unsupported state store backend: #{backend}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
require_relative 'state_stores/memory'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
5
|
+
module TelegramSupportBot
|
|
6
|
+
module StateStores
|
|
7
|
+
class Memory
|
|
8
|
+
def initialize(**_options)
|
|
9
|
+
@monitor = Monitor.new
|
|
10
|
+
@message_map = {}
|
|
11
|
+
@reverse_message_map = {}
|
|
12
|
+
@reaction_count_state = {}
|
|
13
|
+
@user_profiles = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def message_map
|
|
17
|
+
@message_map_proxy ||= StateStore::MapProxy.new(
|
|
18
|
+
get_proc: ->(key) { get_message_mapping(key) },
|
|
19
|
+
set_proc: ->(key, value) { set_message_mapping(key, value) },
|
|
20
|
+
clear_proc: -> { clear_message_mappings },
|
|
21
|
+
size_proc: -> { message_mappings_size }
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reverse_message_map
|
|
26
|
+
@reverse_map_proxy ||= StateStore::MapProxy.new(
|
|
27
|
+
get_proc: ->(key) { get_reverse_mapping(key) },
|
|
28
|
+
set_proc: ->(key, value) { set_reverse_mapping(key, value) },
|
|
29
|
+
clear_proc: -> { clear_reverse_mappings },
|
|
30
|
+
size_proc: -> { reverse_mappings_size }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reaction_count_state
|
|
35
|
+
@reaction_state_proxy ||= StateStore::MapProxy.new(
|
|
36
|
+
get_proc: ->(key) { get_reaction_count(key) },
|
|
37
|
+
set_proc: ->(key, value) { set_reaction_count(key, value) },
|
|
38
|
+
clear_proc: -> { clear_reaction_counts },
|
|
39
|
+
size_proc: -> { reaction_counts_size }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def user_profiles
|
|
44
|
+
@user_profiles_proxy ||= StateStore::MapProxy.new(
|
|
45
|
+
get_proc: ->(key) { get_user_profile(key) },
|
|
46
|
+
set_proc: ->(key, value) { set_user_profile(key, value) },
|
|
47
|
+
clear_proc: -> { clear_user_profiles },
|
|
48
|
+
size_proc: -> { user_profiles_size }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get_message_mapping(key)
|
|
53
|
+
synchronize { @message_map[normalize_key(key)] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_message_mapping(key, value)
|
|
57
|
+
synchronize { @message_map[normalize_key(key)] = value }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clear_message_mappings
|
|
61
|
+
synchronize { @message_map.clear }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def message_mappings_size
|
|
65
|
+
synchronize { @message_map.size }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get_reverse_mapping(key)
|
|
69
|
+
synchronize { @reverse_message_map[normalize_key(key)] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set_reverse_mapping(key, value)
|
|
73
|
+
synchronize { @reverse_message_map[normalize_key(key)] = value }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clear_reverse_mappings
|
|
77
|
+
synchronize { @reverse_message_map.clear }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reverse_mappings_size
|
|
81
|
+
synchronize { @reverse_message_map.size }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_reaction_count(key)
|
|
85
|
+
synchronize { @reaction_count_state[normalize_key(key)] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_reaction_count(key, value)
|
|
89
|
+
synchronize { @reaction_count_state[normalize_key(key)] = value }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clear_reaction_counts
|
|
93
|
+
synchronize { @reaction_count_state.clear }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reaction_counts_size
|
|
97
|
+
synchronize { @reaction_count_state.size }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def get_user_profile(key)
|
|
101
|
+
synchronize { @user_profiles[normalize_key(key)] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def set_user_profile(key, value)
|
|
105
|
+
synchronize { @user_profiles[normalize_key(key)] = value }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def clear_user_profiles
|
|
109
|
+
synchronize { @user_profiles.clear }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def user_profiles_size
|
|
113
|
+
synchronize { @user_profiles.size }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def synchronize(&block)
|
|
119
|
+
@monitor.synchronize(&block)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def normalize_key(key)
|
|
123
|
+
key.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module TelegramSupportBot
|
|
6
|
+
module StateStores
|
|
7
|
+
class Redis
|
|
8
|
+
DEFAULT_NAMESPACE = 'telegram_support_bot'
|
|
9
|
+
|
|
10
|
+
def initialize(url: nil, redis: nil, namespace: DEFAULT_NAMESPACE,
|
|
11
|
+
mapping_ttl_seconds: nil, reaction_count_ttl_seconds: nil, user_profile_ttl_seconds: nil, **_options)
|
|
12
|
+
@redis = redis || begin
|
|
13
|
+
require 'redis'
|
|
14
|
+
::Redis.new(url: url)
|
|
15
|
+
rescue LoadError => e
|
|
16
|
+
raise LoadError, "Redis backend requires the 'redis' gem. #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
@namespace = namespace
|
|
19
|
+
@mapping_ttl_seconds = mapping_ttl_seconds
|
|
20
|
+
@reaction_count_ttl_seconds = reaction_count_ttl_seconds
|
|
21
|
+
@user_profile_ttl_seconds = user_profile_ttl_seconds
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def message_map
|
|
25
|
+
@message_map_proxy ||= StateStore::MapProxy.new(
|
|
26
|
+
get_proc: ->(key) { get_message_mapping(key) },
|
|
27
|
+
set_proc: ->(key, value) { set_message_mapping(key, value) },
|
|
28
|
+
clear_proc: -> { clear_message_mappings },
|
|
29
|
+
size_proc: -> { message_mappings_size }
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reverse_message_map
|
|
34
|
+
@reverse_map_proxy ||= StateStore::MapProxy.new(
|
|
35
|
+
get_proc: ->(key) { get_reverse_mapping(key) },
|
|
36
|
+
set_proc: ->(key, value) { set_reverse_mapping(key, value) },
|
|
37
|
+
clear_proc: -> { clear_reverse_mappings },
|
|
38
|
+
size_proc: -> { reverse_mappings_size }
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reaction_count_state
|
|
43
|
+
@reaction_state_proxy ||= StateStore::MapProxy.new(
|
|
44
|
+
get_proc: ->(key) { get_reaction_count(key) },
|
|
45
|
+
set_proc: ->(key, value) { set_reaction_count(key, value) },
|
|
46
|
+
clear_proc: -> { clear_reaction_counts },
|
|
47
|
+
size_proc: -> { reaction_counts_size }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def user_profiles
|
|
52
|
+
@user_profiles_proxy ||= StateStore::MapProxy.new(
|
|
53
|
+
get_proc: ->(key) { get_user_profile(key) },
|
|
54
|
+
set_proc: ->(key, value) { set_user_profile(key, value) },
|
|
55
|
+
clear_proc: -> { clear_user_profiles },
|
|
56
|
+
size_proc: -> { user_profiles_size }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get_message_mapping(key)
|
|
61
|
+
payload = parse_json(@redis.get(map_key(:message_map, key)))
|
|
62
|
+
symbolize_hash(payload)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def set_message_mapping(key, value)
|
|
66
|
+
write_json(map_key(:message_map, key), value, @mapping_ttl_seconds)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear_message_mappings
|
|
70
|
+
delete_by_prefix(prefix(:message_map))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def message_mappings_size
|
|
74
|
+
count_by_prefix(prefix(:message_map))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get_reverse_mapping(key)
|
|
78
|
+
raw = @redis.get(map_key(:reverse_message_map, key))
|
|
79
|
+
coerce_scalar(raw)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def set_reverse_mapping(key, value)
|
|
83
|
+
write_scalar(map_key(:reverse_message_map, key), value, @mapping_ttl_seconds)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def clear_reverse_mappings
|
|
87
|
+
delete_by_prefix(prefix(:reverse_message_map))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reverse_mappings_size
|
|
91
|
+
count_by_prefix(prefix(:reverse_message_map))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def get_reaction_count(key)
|
|
95
|
+
parse_json(@redis.get(map_key(:reaction_count_state, key)))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def set_reaction_count(key, value)
|
|
99
|
+
write_json(map_key(:reaction_count_state, key), value, @reaction_count_ttl_seconds)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clear_reaction_counts
|
|
103
|
+
delete_by_prefix(prefix(:reaction_count_state))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reaction_counts_size
|
|
107
|
+
count_by_prefix(prefix(:reaction_count_state))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def get_user_profile(key)
|
|
111
|
+
payload = parse_json(@redis.get(map_key(:user_profiles, key)))
|
|
112
|
+
symbolize_hash(payload)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def set_user_profile(key, value)
|
|
116
|
+
write_json(map_key(:user_profiles, key), value, @user_profile_ttl_seconds)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def clear_user_profiles
|
|
120
|
+
delete_by_prefix(prefix(:user_profiles))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def user_profiles_size
|
|
124
|
+
count_by_prefix(prefix(:user_profiles))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def prefix(name)
|
|
130
|
+
"#{@namespace}:#{name}:"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def map_key(name, key)
|
|
134
|
+
"#{prefix(name)}#{key}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def write_json(key, value, ttl_seconds)
|
|
138
|
+
payload = JSON.generate(value)
|
|
139
|
+
write_raw(key, payload, ttl_seconds)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_scalar(key, value, ttl_seconds)
|
|
143
|
+
write_raw(key, value.to_s, ttl_seconds)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_raw(key, value, ttl_seconds)
|
|
147
|
+
@redis.set(key, value)
|
|
148
|
+
@redis.expire(key, ttl_seconds.to_i) if ttl_seconds && ttl_seconds.to_i.positive?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_json(raw)
|
|
152
|
+
return nil if raw.nil?
|
|
153
|
+
|
|
154
|
+
JSON.parse(raw)
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def symbolize_hash(obj)
|
|
160
|
+
case obj
|
|
161
|
+
when Hash
|
|
162
|
+
obj.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = symbolize_hash(v) }
|
|
163
|
+
when Array
|
|
164
|
+
obj.map { |item| symbolize_hash(item) }
|
|
165
|
+
else
|
|
166
|
+
obj
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def coerce_scalar(raw)
|
|
171
|
+
return nil if raw.nil?
|
|
172
|
+
|
|
173
|
+
return raw.to_i if /\A-?\d+\z/.match?(raw)
|
|
174
|
+
|
|
175
|
+
raw
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def delete_by_prefix(key_prefix)
|
|
179
|
+
scan_each(key_prefix) do |key|
|
|
180
|
+
@redis.del(key)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def count_by_prefix(key_prefix)
|
|
185
|
+
count = 0
|
|
186
|
+
scan_each(key_prefix) { count += 1 }
|
|
187
|
+
count
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def scan_each(key_prefix)
|
|
191
|
+
cursor = '0'
|
|
192
|
+
pattern = "#{key_prefix}*"
|
|
193
|
+
|
|
194
|
+
loop do
|
|
195
|
+
cursor, keys = @redis.scan(cursor, match: pattern, count: 1000)
|
|
196
|
+
keys.each { |key| yield key }
|
|
197
|
+
break if cursor == '0'
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/telegram_support_bot.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "telegram_support_bot/version"
|
|
|
4
4
|
require_relative 'telegram_support_bot/configuration'
|
|
5
5
|
require_relative 'telegram_support_bot/auto_away_scheduler'
|
|
6
6
|
require_relative 'telegram_support_bot/adapter_factory'
|
|
7
|
+
require_relative 'telegram_support_bot/state_store'
|
|
7
8
|
require_relative 'telegram_support_bot/adapters/base'
|
|
8
9
|
require_relative 'telegram_support_bot/adapters/telegram_bot'
|
|
9
10
|
require_relative 'telegram_support_bot/adapters/telegram_bot_ruby'
|
|
@@ -11,7 +12,6 @@ require_relative 'telegram_support_bot/adapters/telegram_bot_ruby'
|
|
|
11
12
|
module TelegramSupportBot
|
|
12
13
|
class << self
|
|
13
14
|
attr_accessor :configuration
|
|
14
|
-
attr_reader :message_chat_id
|
|
15
15
|
|
|
16
16
|
# Provides a method to configure the gem.
|
|
17
17
|
def configure
|
|
@@ -25,16 +25,28 @@ module TelegramSupportBot
|
|
|
25
25
|
@adapter ||= AdapterFactory.build(configuration.adapter, configuration.adapter_options)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def state_store
|
|
29
|
+
@state_store ||= StateStore.build(configuration)
|
|
30
|
+
end
|
|
31
|
+
|
|
28
32
|
def message_map
|
|
29
|
-
|
|
33
|
+
state_store.message_map
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def reverse_message_map
|
|
33
|
-
|
|
37
|
+
state_store.reverse_message_map
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def reaction_count_state
|
|
37
|
-
|
|
41
|
+
state_store.reaction_count_state
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def user_profiles
|
|
45
|
+
state_store.user_profiles
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def user_profile(chat_id)
|
|
49
|
+
user_profiles[chat_id] || user_profiles[chat_id.to_s] || user_profiles[chat_id.to_i]
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def scheduler
|
|
@@ -63,94 +75,126 @@ module TelegramSupportBot
|
|
|
63
75
|
private
|
|
64
76
|
|
|
65
77
|
def process_message(message)
|
|
66
|
-
|
|
78
|
+
chat_id = message.dig('chat', 'id')
|
|
67
79
|
|
|
68
|
-
if
|
|
69
|
-
process_support_chat_message(message)
|
|
80
|
+
if same_chat_id?(chat_id, configuration.support_chat_id)
|
|
81
|
+
process_support_chat_message(message, chat_id: chat_id)
|
|
70
82
|
else
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
process_user_chat_message(message, chat_id: chat_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def process_user_chat_message(message, chat_id:)
|
|
88
|
+
if message.key?('contact')
|
|
89
|
+
process_user_contact(message, chat_id: chat_id)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if message['text'] == '/start'
|
|
94
|
+
adapter.send_message(chat_id: chat_id, text: configuration.welcome_message)
|
|
95
|
+
request_contact_from_user(chat_id: chat_id) if should_request_contact?(chat_id)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if configuration.require_contact_for_support && !contact_known_for_user?(chat_id)
|
|
100
|
+
request_contact_from_user(chat_id: chat_id)
|
|
101
|
+
return
|
|
78
102
|
end
|
|
103
|
+
|
|
104
|
+
forward_message_to_support_chat(message, chat_id: chat_id)
|
|
79
105
|
end
|
|
80
106
|
|
|
81
|
-
def
|
|
107
|
+
def process_user_contact(message, chat_id:)
|
|
108
|
+
contact = message['contact'] || {}
|
|
109
|
+
|
|
110
|
+
unless valid_contact_for_chat?(contact: contact, chat_id: chat_id)
|
|
111
|
+
request_contact_from_user(chat_id: chat_id, text: configuration.contact_invalid_message)
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
profile = build_contact_profile(chat_id: chat_id, message: message, contact: contact)
|
|
116
|
+
store_user_profile(chat_id: chat_id, profile: profile)
|
|
117
|
+
notify_contact_received(profile)
|
|
118
|
+
|
|
119
|
+
adapter.send_message(
|
|
120
|
+
chat_id: chat_id,
|
|
121
|
+
text: configuration.contact_received_message,
|
|
122
|
+
reply_markup: remove_keyboard_markup
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def process_support_chat_message(message, chat_id:)
|
|
82
127
|
if message.key?('reply_to_message')
|
|
83
128
|
# It's a reply in the support chat
|
|
84
129
|
process_reply_in_support_chat(message)
|
|
85
130
|
elsif message['text']&.start_with?('/')
|
|
86
|
-
process_command(message)
|
|
131
|
+
process_command(message, chat_id: chat_id)
|
|
87
132
|
else
|
|
88
|
-
|
|
89
|
-
# For now, let's just acknowledge the message
|
|
90
|
-
acknowledge_non_command_message(message)
|
|
133
|
+
acknowledge_non_command_message(message, chat_id: chat_id) unless configuration.ignore_non_command_messages
|
|
91
134
|
end
|
|
92
135
|
end
|
|
93
136
|
|
|
94
|
-
def acknowledge_non_command_message(message)
|
|
95
|
-
reply_message = 'I received your message, but I only respond to commands. Please use /start to get started.'
|
|
137
|
+
def acknowledge_non_command_message(message, chat_id:)
|
|
96
138
|
adapter.send_message(
|
|
97
|
-
chat_id:
|
|
98
|
-
text:
|
|
139
|
+
chat_id: chat_id,
|
|
140
|
+
text: configuration.non_command_message_response,
|
|
99
141
|
reply_to_message_id: message['message_id']
|
|
100
142
|
)
|
|
101
143
|
end
|
|
102
144
|
|
|
103
|
-
def process_command(message)
|
|
145
|
+
def process_command(message, chat_id:)
|
|
104
146
|
command = message['text'].split(/[ \@]/).first.downcase # Extract the command, normalize to lowercase
|
|
105
147
|
|
|
106
148
|
case command
|
|
107
149
|
when '/start'
|
|
108
|
-
send_welcome_message(chat_id:
|
|
150
|
+
send_welcome_message(chat_id: chat_id)
|
|
109
151
|
else
|
|
110
152
|
# Respond to unknown commands
|
|
111
153
|
unless configuration.ignore_unknown_commands
|
|
112
154
|
unknown_command_response = "I don't know the command #{command}. Please use /start to begin or check the available commands."
|
|
113
|
-
adapter.send_message(chat_id:
|
|
155
|
+
adapter.send_message(chat_id: chat_id, text: unknown_command_response)
|
|
114
156
|
end
|
|
115
157
|
end
|
|
116
158
|
end
|
|
117
159
|
|
|
118
160
|
def process_reply_in_support_chat(message)
|
|
119
161
|
reply_to_message = message['reply_to_message']
|
|
162
|
+
reply_to_message_id = reply_to_message['message_id']
|
|
163
|
+
mapping = find_message_mapping(reply_to_message_id)
|
|
120
164
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
165
|
+
original_user_id = mapping && mapping[:chat_id]
|
|
166
|
+
original_user_id ||= reply_to_message.dig('forward_from', 'id')
|
|
167
|
+
return unless original_user_id
|
|
168
|
+
|
|
169
|
+
caption = message['caption'] if message.key?('caption')
|
|
170
|
+
|
|
171
|
+
# Determine the type of media and prepare the content and options
|
|
172
|
+
type, media, options = extract_media_info(message)
|
|
173
|
+
options[:caption] = caption if caption
|
|
174
|
+
|
|
175
|
+
message_id = message['message_id']
|
|
176
|
+
if :unknown == type
|
|
177
|
+
# Handle other types of messages or default case
|
|
178
|
+
warning_message = "Warning: The message type received from the user is not supported by the bot. Please assist the user directly."
|
|
179
|
+
adapter.send_message(
|
|
180
|
+
chat_id: configuration.support_chat_id,
|
|
181
|
+
text: warning_message,
|
|
182
|
+
reply_to_message_id: message_id
|
|
183
|
+
)
|
|
184
|
+
else
|
|
185
|
+
result = adapter.send_media(chat_id: original_user_id, type: type, media: media, **options)
|
|
186
|
+
if result
|
|
187
|
+
user_message_id = extract_message_id(result)
|
|
188
|
+
if user_message_id
|
|
189
|
+
support_message_id = message['message_id']
|
|
190
|
+
store_message_mapping(
|
|
191
|
+
support_message_id: support_message_id,
|
|
192
|
+
user_chat_id: original_user_id,
|
|
193
|
+
user_message_id: user_message_id
|
|
194
|
+
)
|
|
151
195
|
end
|
|
152
|
-
# scheduler.cancel_scheduled_task(message_id)
|
|
153
196
|
end
|
|
197
|
+
# scheduler.cancel_scheduled_task(message_id)
|
|
154
198
|
end
|
|
155
199
|
end
|
|
156
200
|
|
|
@@ -181,10 +225,10 @@ module TelegramSupportBot
|
|
|
181
225
|
end
|
|
182
226
|
end
|
|
183
227
|
|
|
184
|
-
def forward_message_to_support_chat(message)
|
|
228
|
+
def forward_message_to_support_chat(message, chat_id:)
|
|
185
229
|
message_id = message['message_id']
|
|
186
230
|
result = adapter.forward_message(
|
|
187
|
-
from_chat_id:
|
|
231
|
+
from_chat_id: chat_id,
|
|
188
232
|
message_id: message_id,
|
|
189
233
|
chat_id: configuration.support_chat_id)
|
|
190
234
|
|
|
@@ -193,7 +237,7 @@ module TelegramSupportBot
|
|
|
193
237
|
if support_message_id
|
|
194
238
|
store_message_mapping(
|
|
195
239
|
support_message_id: support_message_id,
|
|
196
|
-
user_chat_id:
|
|
240
|
+
user_chat_id: chat_id,
|
|
197
241
|
user_message_id: message_id
|
|
198
242
|
)
|
|
199
243
|
end
|
|
@@ -312,6 +356,63 @@ module TelegramSupportBot
|
|
|
312
356
|
warn "Failed to mirror reaction to chat_id=#{chat_id} message_id=#{message_id}: #{error.class}: #{error.message}"
|
|
313
357
|
end
|
|
314
358
|
|
|
359
|
+
def request_contact_from_user(chat_id:, text: configuration.contact_request_message)
|
|
360
|
+
adapter.send_message(chat_id: chat_id, text: text, reply_markup: contact_request_keyboard)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def contact_request_keyboard
|
|
364
|
+
{
|
|
365
|
+
keyboard: [[{ text: 'Share phone number', request_contact: true }]],
|
|
366
|
+
resize_keyboard: true,
|
|
367
|
+
one_time_keyboard: true
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def remove_keyboard_markup
|
|
372
|
+
{ remove_keyboard: true }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def should_request_contact?(chat_id)
|
|
376
|
+
configuration.request_contact_on_start && !contact_known_for_user?(chat_id)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def contact_known_for_user?(chat_id)
|
|
380
|
+
!user_profile(chat_id).nil?
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def valid_contact_for_chat?(contact:, chat_id:)
|
|
384
|
+
contact_user_id = contact['user_id'] || contact[:user_id]
|
|
385
|
+
return false if contact_user_id.nil?
|
|
386
|
+
|
|
387
|
+
same_chat_id?(contact_user_id, chat_id)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def build_contact_profile(chat_id:, message:, contact:)
|
|
391
|
+
sender = message['from'] || {}
|
|
392
|
+
{
|
|
393
|
+
chat_id: chat_id,
|
|
394
|
+
user_id: contact['user_id'] || contact[:user_id],
|
|
395
|
+
phone_number: contact['phone_number'] || contact[:phone_number],
|
|
396
|
+
first_name: contact['first_name'] || contact[:first_name] || sender['first_name'],
|
|
397
|
+
last_name: contact['last_name'] || contact[:last_name] || sender['last_name'],
|
|
398
|
+
username: sender['username'],
|
|
399
|
+
language_code: sender['language_code']
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def store_user_profile(chat_id:, profile:)
|
|
404
|
+
user_profiles[chat_id] = profile
|
|
405
|
+
user_profiles[chat_id.to_s] = profile
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def notify_contact_received(profile)
|
|
409
|
+
return unless configuration.on_contact_received.respond_to?(:call)
|
|
410
|
+
|
|
411
|
+
configuration.on_contact_received.call(profile)
|
|
412
|
+
rescue StandardError => error
|
|
413
|
+
warn "Failed to run on_contact_received callback: #{error.class}: #{error.message}"
|
|
414
|
+
end
|
|
415
|
+
|
|
315
416
|
def extract_reaction_counts(reactions)
|
|
316
417
|
counts = {}
|
|
317
418
|
reaction_types = {}
|
|
@@ -414,4 +515,8 @@ module TelegramSupportBot
|
|
|
414
515
|
def self.reset_adapter!
|
|
415
516
|
@adapter = nil
|
|
416
517
|
end
|
|
518
|
+
|
|
519
|
+
def self.reset_state_store!
|
|
520
|
+
@state_store = nil
|
|
521
|
+
end
|
|
417
522
|
end
|
data/script/dev_poll.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require 'telegram_support_bot'
|
|
5
5
|
require 'telegram/bot'
|
|
6
|
+
require 'json'
|
|
6
7
|
|
|
7
8
|
token = ENV.fetch('TELEGRAM_BOT_TOKEN')
|
|
8
9
|
support_chat_id = Integer(ENV.fetch('SUPPORT_CHAT_ID'))
|
|
@@ -16,7 +17,13 @@ TelegramSupportBot.configure do |config|
|
|
|
16
17
|
config.adapter = adapter.to_sym
|
|
17
18
|
config.adapter_options = adapter_options
|
|
18
19
|
config.support_chat_id = support_chat_id
|
|
20
|
+
config.request_contact_on_start = true
|
|
21
|
+
# config.require_contact_for_support = true
|
|
19
22
|
config.welcome_message = 'Hi! How can we help you?'
|
|
23
|
+
config.on_contact_received = lambda do |profile|
|
|
24
|
+
puts '[TSB CONTACT] Contact received profile:'
|
|
25
|
+
puts JSON.pretty_generate(profile)
|
|
26
|
+
end
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
client = Telegram::Bot::Client.new(token)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: telegram-support-bot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.09
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Max Buslaev
|
|
@@ -9,7 +9,21 @@ autorequire:
|
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 2026-02-13 00:00:00.000000000 Z
|
|
12
|
-
dependencies:
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: redis
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '4.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '4.8'
|
|
13
27
|
description: |-
|
|
14
28
|
The telegram_support_bot gem provides Rails applications with an
|
|
15
29
|
easy-to-integrate Telegram bot, designed to enhance customer support services.
|
|
@@ -41,6 +55,9 @@ files:
|
|
|
41
55
|
- lib/telegram_support_bot/adapters/telegram_bot_ruby.rb
|
|
42
56
|
- lib/telegram_support_bot/auto_away_scheduler.rb
|
|
43
57
|
- lib/telegram_support_bot/configuration.rb
|
|
58
|
+
- lib/telegram_support_bot/state_store.rb
|
|
59
|
+
- lib/telegram_support_bot/state_stores/memory.rb
|
|
60
|
+
- lib/telegram_support_bot/state_stores/redis.rb
|
|
44
61
|
- lib/telegram_support_bot/version.rb
|
|
45
62
|
- script/dev_poll.rb
|
|
46
63
|
- sig/telegram_support_bot.rbs
|