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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0dba7130ca446bc645510735687aa8501f31a6671cfb63fc29e7eb5e535c957
4
- data.tar.gz: 3fa484d6c00969af671bdce6e964fd1484fce7ff96496aac9e92c3cd7e694dba
3
+ metadata.gz: 15900469360f291d85f69a938dc8f25cc9826ec0fab4105c954e0f58aea48868
4
+ data.tar.gz: 441423cab220f879327d6474bafb069c8fa099966f7cc45c6ac2896944a52238
5
5
  SHA512:
6
- metadata.gz: c23fe383ec0166b10ab54edf9b536a83bc8973afbd9d373f04e9d281def477682406fd7b665f31c52b622b6e52ab33e47a7478c2193bc28e0a1a9139b06da840
7
- data.tar.gz: ede12ddc03a8cb4cf213507d5fa8e35faeed2bb78d5c9dc76dfa1978901164130842f055b8b66fa604b2e9d3cf3a43881752ece10a94dd2063f09a92ebce19cd
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
- ## Handling User Privacy Settings for Message Forwarding
132
+ ## User Identification With Phone Sharing
119
133
 
120
- Due to Telegram's privacy settings, users may have restricted the ability for bots to forward their
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
- This will allow the bot to send you back replies from the support team.
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
- Including such instructions can help in reducing the friction in user support interactions and
139
- ensure that your support team can effectively communicate with users through the bot.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TelegramSupportBot
4
- VERSION = "0.1.07"
4
+ VERSION = "0.1.09"
5
5
  end
@@ -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
- @message_map ||= {}
33
+ state_store.message_map
30
34
  end
31
35
 
32
36
  def reverse_message_map
33
- @reverse_message_map ||= {}
37
+ state_store.reverse_message_map
34
38
  end
35
39
 
36
40
  def reaction_count_state
37
- @reaction_count_state ||= {}
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
- @message_chat_id = message['chat']['id']
78
+ chat_id = message.dig('chat', 'id')
67
79
 
68
- if message_chat_id == configuration.support_chat_id
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
- # Message is from an individual user, forward it to the support chat
72
- if message['text'] == '/start'
73
- # Send welcome message to the user
74
- adapter.send_message(chat_id: message_chat_id, text: configuration.welcome_message)
75
- else
76
- forward_message_to_support_chat(message)
77
- end
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 process_support_chat_message(message)
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
- # For non-command messages, you might want to handle differently or just ignore
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: message_chat_id,
98
- text: reply_message,
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: 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: message_chat_id, text: unknown_command_response)
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
- if reply_to_message.key?('forward_from')
122
- # The reply is to a forwarded message
123
- original_user_id = reply_to_message['forward_from']['id']
124
- caption = message['caption'] if message.key?('caption')
125
-
126
- # Determine the type of media and prepare the content and options
127
- type, media, options = extract_media_info(message)
128
- options[:caption] = caption if caption
129
-
130
- message_id = message['message_id']
131
- if :unknown == type
132
- # Handle other types of messages or default case
133
- warning_message = "Warning: The message type received from the user is not supported by the bot. Please assist the user directly."
134
- adapter.send_message(
135
- chat_id: configuration.support_chat_id,
136
- text: warning_message,
137
- reply_to_message_id: message_id
138
- )
139
- else
140
- result = adapter.send_media(chat_id: original_user_id, type: type, media: media, **options)
141
- if result
142
- user_message_id = extract_message_id(result)
143
- if user_message_id
144
- support_message_id = message['message_id']
145
- store_message_mapping(
146
- support_message_id: support_message_id,
147
- user_chat_id: original_user_id,
148
- user_message_id: user_message_id
149
- )
150
- end
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: message_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: message_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.07
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