telegram-support-bot 0.1.07 → 0.1.08
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 +15 -0
- data/README.md +48 -16
- data/lib/telegram_support_bot/configuration.rb +16 -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 +164 -56
- 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: d522d11e8602df8c2e6ab13df25de60eb2a376bb4983299092b277ad8ca81c15
|
|
4
|
+
data.tar.gz: 99739fa0654123c2b2052f9cdd09c78e2336c7cf1bfc4ac45983486e69a47c40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 799182e98806ac1d0e550a89f37f81d867879b00221bf919f1c3665f20d3bb3a6e0c092dad0c1717d1dce3bc0aa10869be56589b7478e967c58a9dea40885018
|
|
7
|
+
data.tar.gz: f39e7efa2db2221af245b8641477a03936aa0ef285bcaf40cc21d3de79ece871fbc79c03321aef58602afbf701133b42b310279968f8cb6d07a0768a4de80388
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.08] - 2026-02-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Optional one-time contact sharing flow for user identification:
|
|
9
|
+
`request_contact_on_start`, `require_contact_for_support`, custom contact messages, and
|
|
10
|
+
`on_contact_received` callback.
|
|
11
|
+
- In-memory user profile storage and lookup via `TelegramSupportBot.user_profile(chat_id)`.
|
|
12
|
+
- Configurable state-store backend with Redis support for multi-pod deployments
|
|
13
|
+
(`state_store`, `state_store_options`, and state TTL settings).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Support reply routing now uses internal message mapping first, with `forward_from` as fallback.
|
|
17
|
+
This removes the dependency on user forwarding privacy settings for normal reply flows.
|
|
18
|
+
- Message processing no longer relies on shared `@message_chat_id`, reducing thread-safety risks.
|
|
19
|
+
|
|
5
20
|
## [0.1.07] - 2026-02-13
|
|
6
21
|
|
|
7
22
|
### Added
|
data/README.md
CHANGED
|
@@ -43,6 +43,15 @@ 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
|
+
# Optional: ask users to share their phone once for account lookup.
|
|
47
|
+
config.request_contact_on_start = true
|
|
48
|
+
# Optional: block forwarding until contact is shared.
|
|
49
|
+
config.require_contact_for_support = false
|
|
50
|
+
# Optional callback to persist/lookup user profile in your app.
|
|
51
|
+
config.on_contact_received = ->(profile) { YourUserMatcher.sync_from_telegram(profile) }
|
|
52
|
+
# Recommended in Kubernetes/multi-pod setup:
|
|
53
|
+
# config.state_store = :redis
|
|
54
|
+
# config.state_store_options = { url: ENV.fetch('REDIS_URL'), namespace: 'telegram_support_bot' }
|
|
46
55
|
end
|
|
47
56
|
```
|
|
48
57
|
|
|
@@ -115,28 +124,51 @@ end
|
|
|
115
124
|
Implement custom adapters by inheriting from `TelegramSupportBot::Adapter::Base` and defining
|
|
116
125
|
message sending and forwarding methods.
|
|
117
126
|
|
|
118
|
-
##
|
|
127
|
+
## User Identification With Phone Sharing
|
|
119
128
|
|
|
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:
|
|
129
|
+
If you want support agents to identify users quickly in your CRM, you can request phone sharing once:
|
|
125
130
|
|
|
131
|
+
```ruby
|
|
132
|
+
TelegramSupportBot.configure do |config|
|
|
133
|
+
config.request_contact_on_start = true
|
|
134
|
+
config.require_contact_for_support = false
|
|
135
|
+
config.contact_request_message = 'Please share your phone number so we can identify your account.'
|
|
136
|
+
config.contact_received_message = 'Thanks! We have saved your phone number.'
|
|
137
|
+
config.on_contact_received = ->(profile) do
|
|
138
|
+
# profile keys:
|
|
139
|
+
# :chat_id, :user_id, :phone_number, :first_name, :last_name, :username, :language_code
|
|
140
|
+
UserIdentitySync.call(profile)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
126
143
|
```
|
|
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
144
|
|
|
134
|
-
|
|
145
|
+
If you set `require_contact_for_support = true`, the bot will ask for contact and will not forward
|
|
146
|
+
other user messages until contact is shared.
|
|
135
147
|
|
|
136
|
-
|
|
148
|
+
Support replies are routed by internal message mapping, so users do not need to change Telegram
|
|
149
|
+
forwarding privacy settings to receive replies.
|
|
150
|
+
|
|
151
|
+
## State Storage (Single Pod vs Multi-Pod)
|
|
137
152
|
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
By default, runtime state is stored in-memory (`state_store = :memory`). This is fine for local
|
|
154
|
+
development or a single process.
|
|
155
|
+
|
|
156
|
+
For Kubernetes / multiple pods, configure Redis so message mappings, reaction state, and user
|
|
157
|
+
profiles are shared:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
TelegramSupportBot.configure do |config|
|
|
161
|
+
config.state_store = :redis
|
|
162
|
+
config.state_store_options = {
|
|
163
|
+
url: ENV.fetch('REDIS_URL'),
|
|
164
|
+
namespace: 'telegram_support_bot'
|
|
165
|
+
}
|
|
166
|
+
# Optional TTL tuning:
|
|
167
|
+
# config.mapping_ttl_seconds = 30 * 24 * 60 * 60
|
|
168
|
+
# config.reaction_count_ttl_seconds = 7 * 24 * 60 * 60
|
|
169
|
+
# config.user_profile_ttl_seconds = nil
|
|
170
|
+
end
|
|
171
|
+
```
|
|
140
172
|
|
|
141
173
|
## Development
|
|
142
174
|
|
|
@@ -3,7 +3,11 @@
|
|
|
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
|
+
:request_contact_on_start, :require_contact_for_support, :contact_request_message,
|
|
8
|
+
:contact_received_message, :contact_invalid_message, :on_contact_received,
|
|
9
|
+
:state_store, :state_store_options, :mapping_ttl_seconds,
|
|
10
|
+
:reaction_count_ttl_seconds, :user_profile_ttl_seconds
|
|
7
11
|
|
|
8
12
|
def initialize
|
|
9
13
|
@adapter = :telegram_bot
|
|
@@ -12,6 +16,17 @@ module TelegramSupportBot
|
|
|
12
16
|
@ignore_unknown_commands = true
|
|
13
17
|
@auto_away_interval = 10 # seconds
|
|
14
18
|
@auto_away_message = 'We are sorry, all operators are busy at the moment. Please wait'
|
|
19
|
+
@request_contact_on_start = false
|
|
20
|
+
@require_contact_for_support = false
|
|
21
|
+
@contact_request_message = 'Please share your phone number so we can quickly identify your account.'
|
|
22
|
+
@contact_received_message = 'Thanks! We have saved your phone number.'
|
|
23
|
+
@contact_invalid_message = 'Please use the button below to share your own phone number.'
|
|
24
|
+
@on_contact_received = nil
|
|
25
|
+
@state_store = :memory
|
|
26
|
+
@state_store_options = {}
|
|
27
|
+
@mapping_ttl_seconds = 30 * 24 * 60 * 60
|
|
28
|
+
@reaction_count_ttl_seconds = 7 * 24 * 60 * 60
|
|
29
|
+
@user_profile_ttl_seconds = nil
|
|
15
30
|
end
|
|
16
31
|
end
|
|
17
32
|
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,129 @@ 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
133
|
# For non-command messages, you might want to handle differently or just ignore
|
|
89
134
|
# For now, let's just acknowledge the message
|
|
90
|
-
acknowledge_non_command_message(message)
|
|
135
|
+
acknowledge_non_command_message(message, chat_id: chat_id)
|
|
91
136
|
end
|
|
92
137
|
end
|
|
93
138
|
|
|
94
|
-
def acknowledge_non_command_message(message)
|
|
139
|
+
def acknowledge_non_command_message(message, chat_id:)
|
|
95
140
|
reply_message = 'I received your message, but I only respond to commands. Please use /start to get started.'
|
|
96
141
|
adapter.send_message(
|
|
97
|
-
chat_id:
|
|
142
|
+
chat_id: chat_id,
|
|
98
143
|
text: reply_message,
|
|
99
144
|
reply_to_message_id: message['message_id']
|
|
100
145
|
)
|
|
101
146
|
end
|
|
102
147
|
|
|
103
|
-
def process_command(message)
|
|
148
|
+
def process_command(message, chat_id:)
|
|
104
149
|
command = message['text'].split(/[ \@]/).first.downcase # Extract the command, normalize to lowercase
|
|
105
150
|
|
|
106
151
|
case command
|
|
107
152
|
when '/start'
|
|
108
|
-
send_welcome_message(chat_id:
|
|
153
|
+
send_welcome_message(chat_id: chat_id)
|
|
109
154
|
else
|
|
110
155
|
# Respond to unknown commands
|
|
111
156
|
unless configuration.ignore_unknown_commands
|
|
112
157
|
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:
|
|
158
|
+
adapter.send_message(chat_id: chat_id, text: unknown_command_response)
|
|
114
159
|
end
|
|
115
160
|
end
|
|
116
161
|
end
|
|
117
162
|
|
|
118
163
|
def process_reply_in_support_chat(message)
|
|
119
164
|
reply_to_message = message['reply_to_message']
|
|
165
|
+
reply_to_message_id = reply_to_message['message_id']
|
|
166
|
+
mapping = find_message_mapping(reply_to_message_id)
|
|
120
167
|
|
|
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
|
-
|
|
168
|
+
original_user_id = mapping && mapping[:chat_id]
|
|
169
|
+
original_user_id ||= reply_to_message.dig('forward_from', 'id')
|
|
170
|
+
return unless original_user_id
|
|
171
|
+
|
|
172
|
+
caption = message['caption'] if message.key?('caption')
|
|
173
|
+
|
|
174
|
+
# Determine the type of media and prepare the content and options
|
|
175
|
+
type, media, options = extract_media_info(message)
|
|
176
|
+
options[:caption] = caption if caption
|
|
177
|
+
|
|
178
|
+
message_id = message['message_id']
|
|
179
|
+
if :unknown == type
|
|
180
|
+
# Handle other types of messages or default case
|
|
181
|
+
warning_message = "Warning: The message type received from the user is not supported by the bot. Please assist the user directly."
|
|
182
|
+
adapter.send_message(
|
|
183
|
+
chat_id: configuration.support_chat_id,
|
|
184
|
+
text: warning_message,
|
|
185
|
+
reply_to_message_id: message_id
|
|
186
|
+
)
|
|
187
|
+
else
|
|
188
|
+
result = adapter.send_media(chat_id: original_user_id, type: type, media: media, **options)
|
|
189
|
+
if result
|
|
190
|
+
user_message_id = extract_message_id(result)
|
|
191
|
+
if user_message_id
|
|
192
|
+
support_message_id = message['message_id']
|
|
193
|
+
store_message_mapping(
|
|
194
|
+
support_message_id: support_message_id,
|
|
195
|
+
user_chat_id: original_user_id,
|
|
196
|
+
user_message_id: user_message_id
|
|
197
|
+
)
|
|
151
198
|
end
|
|
152
|
-
# scheduler.cancel_scheduled_task(message_id)
|
|
153
199
|
end
|
|
200
|
+
# scheduler.cancel_scheduled_task(message_id)
|
|
154
201
|
end
|
|
155
202
|
end
|
|
156
203
|
|
|
@@ -181,10 +228,10 @@ module TelegramSupportBot
|
|
|
181
228
|
end
|
|
182
229
|
end
|
|
183
230
|
|
|
184
|
-
def forward_message_to_support_chat(message)
|
|
231
|
+
def forward_message_to_support_chat(message, chat_id:)
|
|
185
232
|
message_id = message['message_id']
|
|
186
233
|
result = adapter.forward_message(
|
|
187
|
-
from_chat_id:
|
|
234
|
+
from_chat_id: chat_id,
|
|
188
235
|
message_id: message_id,
|
|
189
236
|
chat_id: configuration.support_chat_id)
|
|
190
237
|
|
|
@@ -193,7 +240,7 @@ module TelegramSupportBot
|
|
|
193
240
|
if support_message_id
|
|
194
241
|
store_message_mapping(
|
|
195
242
|
support_message_id: support_message_id,
|
|
196
|
-
user_chat_id:
|
|
243
|
+
user_chat_id: chat_id,
|
|
197
244
|
user_message_id: message_id
|
|
198
245
|
)
|
|
199
246
|
end
|
|
@@ -312,6 +359,63 @@ module TelegramSupportBot
|
|
|
312
359
|
warn "Failed to mirror reaction to chat_id=#{chat_id} message_id=#{message_id}: #{error.class}: #{error.message}"
|
|
313
360
|
end
|
|
314
361
|
|
|
362
|
+
def request_contact_from_user(chat_id:, text: configuration.contact_request_message)
|
|
363
|
+
adapter.send_message(chat_id: chat_id, text: text, reply_markup: contact_request_keyboard)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def contact_request_keyboard
|
|
367
|
+
{
|
|
368
|
+
keyboard: [[{ text: 'Share phone number', request_contact: true }]],
|
|
369
|
+
resize_keyboard: true,
|
|
370
|
+
one_time_keyboard: true
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def remove_keyboard_markup
|
|
375
|
+
{ remove_keyboard: true }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def should_request_contact?(chat_id)
|
|
379
|
+
configuration.request_contact_on_start && !contact_known_for_user?(chat_id)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def contact_known_for_user?(chat_id)
|
|
383
|
+
!user_profile(chat_id).nil?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def valid_contact_for_chat?(contact:, chat_id:)
|
|
387
|
+
contact_user_id = contact['user_id'] || contact[:user_id]
|
|
388
|
+
return false if contact_user_id.nil?
|
|
389
|
+
|
|
390
|
+
same_chat_id?(contact_user_id, chat_id)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def build_contact_profile(chat_id:, message:, contact:)
|
|
394
|
+
sender = message['from'] || {}
|
|
395
|
+
{
|
|
396
|
+
chat_id: chat_id,
|
|
397
|
+
user_id: contact['user_id'] || contact[:user_id],
|
|
398
|
+
phone_number: contact['phone_number'] || contact[:phone_number],
|
|
399
|
+
first_name: contact['first_name'] || contact[:first_name] || sender['first_name'],
|
|
400
|
+
last_name: contact['last_name'] || contact[:last_name] || sender['last_name'],
|
|
401
|
+
username: sender['username'],
|
|
402
|
+
language_code: sender['language_code']
|
|
403
|
+
}
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def store_user_profile(chat_id:, profile:)
|
|
407
|
+
user_profiles[chat_id] = profile
|
|
408
|
+
user_profiles[chat_id.to_s] = profile
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def notify_contact_received(profile)
|
|
412
|
+
return unless configuration.on_contact_received.respond_to?(:call)
|
|
413
|
+
|
|
414
|
+
configuration.on_contact_received.call(profile)
|
|
415
|
+
rescue StandardError => error
|
|
416
|
+
warn "Failed to run on_contact_received callback: #{error.class}: #{error.message}"
|
|
417
|
+
end
|
|
418
|
+
|
|
315
419
|
def extract_reaction_counts(reactions)
|
|
316
420
|
counts = {}
|
|
317
421
|
reaction_types = {}
|
|
@@ -414,4 +518,8 @@ module TelegramSupportBot
|
|
|
414
518
|
def self.reset_adapter!
|
|
415
519
|
@adapter = nil
|
|
416
520
|
end
|
|
521
|
+
|
|
522
|
+
def self.reset_state_store!
|
|
523
|
+
@state_store = nil
|
|
524
|
+
end
|
|
417
525
|
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.08
|
|
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
|