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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0dba7130ca446bc645510735687aa8501f31a6671cfb63fc29e7eb5e535c957
4
- data.tar.gz: 3fa484d6c00969af671bdce6e964fd1484fce7ff96496aac9e92c3cd7e694dba
3
+ metadata.gz: d522d11e8602df8c2e6ab13df25de60eb2a376bb4983299092b277ad8ca81c15
4
+ data.tar.gz: 99739fa0654123c2b2052f9cdd09c78e2336c7cf1bfc4ac45983486e69a47c40
5
5
  SHA512:
6
- metadata.gz: c23fe383ec0166b10ab54edf9b536a83bc8973afbd9d373f04e9d281def477682406fd7b665f31c52b622b6e52ab33e47a7478c2193bc28e0a1a9139b06da840
7
- data.tar.gz: ede12ddc03a8cb4cf213507d5fa8e35faeed2bb78d5c9dc76dfa1978901164130842f055b8b66fa604b2e9d3cf3a43881752ece10a94dd2063f09a92ebce19cd
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
- ## Handling User Privacy Settings for Message Forwarding
127
+ ## User Identification With Phone Sharing
119
128
 
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:
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
- This will allow the bot to send you back replies from the support team.
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
- 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.
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
@@ -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.08"
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,129 @@ 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
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: message_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: 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: message_chat_id, text: unknown_command_response)
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
- 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
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: message_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: message_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.07
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