telegram-support-bot 0.1.06 → 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: 443be16da398a9055e09a98aa7a828d887baa3828916f0afa2a509e9e228d1a3
4
- data.tar.gz: 959d8dbe4eaeb3660bd688dafa26712575af0b0269d94acbea1e294ce9901af9
3
+ metadata.gz: d522d11e8602df8c2e6ab13df25de60eb2a376bb4983299092b277ad8ca81c15
4
+ data.tar.gz: 99739fa0654123c2b2052f9cdd09c78e2336c7cf1bfc4ac45983486e69a47c40
5
5
  SHA512:
6
- metadata.gz: adfa333fb11bd3f68551a68a7b25f17d09a3840c5326ae65a0872741c326c7956e9d7ccdfe74f33d2e7f017f8e307f9fccd804a744df81004a05a6b460da0f7a
7
- data.tar.gz: 75a42d5e219572496a5d3011a1c66373878a1cba685fbf7ff8574e9a00b451002df6b082f998e6d6be77c7b3dd8972d9d9e7faaaa3c486c83feece51724c8294
6
+ metadata.gz: 799182e98806ac1d0e550a89f37f81d867879b00221bf919f1c3665f20d3bb3a6e0c092dad0c1717d1dce3bc0aa10869be56589b7478e967c58a9dea40885018
7
+ data.tar.gz: f39e7efa2db2221af245b8641477a03936aa0ef285bcaf40cc21d3de79ece871fbc79c03321aef58602afbf701133b42b310279968f8cb6d07a0768a4de80388
data/.idea/workspace.xml CHANGED
@@ -6,9 +6,7 @@
6
6
  <component name="ChangeListManager">
7
7
  <list default="true" id="edf498b0-8552-42f1-846d-0c79d29ff991" name="Changes" comment="">
8
8
  <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
9
- <change beforePath="$PROJECT_DIR$/Gemfile" beforeDir="false" afterPath="$PROJECT_DIR$/Gemfile" afterDir="false" />
10
- <change beforePath="$PROJECT_DIR$/lib/telegram_support_bot/adapters/telegram_bot_adapter.rb" beforeDir="false" afterPath="$PROJECT_DIR$/lib/telegram_support_bot/adapters/telegram_bot_adapter.rb" afterDir="false" />
11
- <change beforePath="$PROJECT_DIR$/spec/telegram_support_bot/adapters/telegram_bot_adapter_spec.rb" beforeDir="false" afterPath="$PROJECT_DIR$/spec/telegram_support_bot/adapters/telegram_bot_adapter_spec.rb" afterDir="false" />
9
+ <change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
12
10
  </list>
13
11
  <option name="SHOW_DIALOG" value="false" />
14
12
  <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -39,30 +37,30 @@
39
37
  <option name="hideEmptyMiddlePackages" value="true" />
40
38
  <option name="showLibraryContents" value="true" />
41
39
  </component>
42
- <component name="PropertiesComponent"><![CDATA[{
43
- "keyToString": {
44
- "DefaultRubyCreateTestTemplate": "Minitest Spec",
45
- "RSpec.Unnamed.executor": "Run",
46
- "Ruby.scratch_61.executor": "Run",
47
- "RunOnceActivity.OpenProjectViewOnStart": "true",
48
- "RunOnceActivity.ShowReadmeOnStart": "true",
49
- "git-widget-placeholder": "main",
50
- "last_opened_file_path": "/home/max/code/gems/telegram_support_bot",
51
- "node.js.detected.package.eslint": "true",
52
- "node.js.detected.package.tslint": "true",
53
- "node.js.selected.package.eslint": "(autodetect)",
54
- "node.js.selected.package.tslint": "(autodetect)",
55
- "nodejs_package_manager_path": "npm",
56
- "ruby.structure.view.model.defaults.configured": "true",
57
- "settings.editor.selected.configurable": "org.jetbrains.plugins.ruby.settings.RubyActiveModuleSdkConfigurable",
58
- "vue.rearranger.settings.migration": "true"
40
+ <component name="PropertiesComponent">{
41
+ &quot;keyToString&quot;: {
42
+ &quot;DefaultRubyCreateTestTemplate&quot;: &quot;Minitest Spec&quot;,
43
+ &quot;RSpec.Unnamed.executor&quot;: &quot;Run&quot;,
44
+ &quot;Ruby.scratch_61.executor&quot;: &quot;Run&quot;,
45
+ &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
46
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
47
+ &quot;git-widget-placeholder&quot;: &quot;main&quot;,
48
+ &quot;last_opened_file_path&quot;: &quot;/home/max/code/tg-sample&quot;,
49
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
50
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
51
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
52
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
53
+ &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
54
+ &quot;ruby.structure.view.model.defaults.configured&quot;: &quot;true&quot;,
55
+ &quot;settings.editor.selected.configurable&quot;: &quot;org.jetbrains.plugins.ruby.settings.RubyActiveModuleSdkConfigurable&quot;,
56
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
59
57
  },
60
- "keyToStringList": {
61
- "com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
62
- "ruby"
58
+ &quot;keyToStringList&quot;: {
59
+ &quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
60
+ &quot;ruby&quot;
63
61
  ]
64
62
  }
65
- }]]></component>
63
+ }</component>
66
64
  <component name="RecentsManager">
67
65
  <key name="CopyFile.RECENT_KEYS">
68
66
  <recent name="$PROJECT_DIR$/spec/telegram_support_bot/adapters" />
@@ -139,6 +137,10 @@
139
137
  <updated>1708600824931</updated>
140
138
  <workItem from="1708600827139" duration="10157000" />
141
139
  <workItem from="1708784534887" duration="682000" />
140
+ <workItem from="1708944130416" duration="99000" />
141
+ <workItem from="1708944249622" duration="627000" />
142
+ <workItem from="1708949675518" duration="80000" />
143
+ <workItem from="1708951183744" duration="310000" />
142
144
  </task>
143
145
  <servers />
144
146
  </component>
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
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
+
20
+ ## [0.1.07] - 2026-02-13
21
+
22
+ ### Added
23
+ - Mirroring of `message_reaction` updates between support chat and user chats.
24
+ - Message mapping storage (`message_map` and `reverse_message_map`) to correlate forwarded/replied messages for reaction sync.
25
+ - Adapter API method `set_message_reaction` with implementations for both supported adapters.
26
+ - Test coverage for reaction handling and adapter reaction calls.
27
+ - Local polling helper script for development testing without Rails (`script/dev_poll.rb`).
28
+ - README documentation for local development testing workflow.
29
+
30
+ ### Fixed
31
+ - Local polling script now uses the proper polling call for `telegram-bot` gem (`get_updates`) and supports multiple client styles.
32
+ - Reaction mapping now correctly handles wrapped Telegram API responses (`{ "ok": true, "result": ... }`).
33
+ - Reaction mirroring now gracefully handles `REACTIONS_TOO_MANY` by retrying with a single reaction instead of crashing.
34
+ - Reaction mapping lookup now tolerates chat/message ID type mismatches (string vs integer).
35
+ - Added optional reaction-flow debug logs via `TSB_DEBUG=1`.
36
+ - Support-chat reaction mirroring now also handles `message_reaction_count` updates (anonymous reaction counts).
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,13 +124,107 @@ end
115
124
  Implement custom adapters by inheriting from `TelegramSupportBot::Adapter::Base` and defining
116
125
  message sending and forwarding methods.
117
126
 
127
+ ## User Identification With Phone Sharing
128
+
129
+ If you want support agents to identify users quickly in your CRM, you can request phone sharing once:
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
143
+ ```
144
+
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.
147
+
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)
152
+
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
+ ```
118
172
 
119
173
  ## Development
120
174
 
121
175
  - Run `bin/setup` to install dependencies.
122
176
  - Use `rake spec` for tests and `bin/console` for an interactive prompt.
123
177
  - To install locally, use `bundle exec rake install`.
124
- - For releases, update `version.rb`, and run `bundle exec rake release`.
178
+ - For releases, update `lib/telegram_support_bot/version.rb` and `CHANGELOG.md`, then run `bundle exec rake release`.
179
+
180
+ ### Local Testing Without Rails (Polling)
181
+
182
+ You can run the bot locally without a Rails app by using the included script:
183
+
184
+ Prerequisites:
185
+ - Add the bot as an **administrator** in the support chat if you want support-side reactions to be delivered as updates.
186
+ - Bots can set only one reaction per message via Bot API, so only one mirrored reaction is applied when multiple are present.
187
+
188
+ 1. Export required environment variables:
189
+
190
+ ```bash
191
+ export TELEGRAM_BOT_TOKEN=your_bot_token
192
+ export SUPPORT_CHAT_ID=your_support_chat_id
193
+ # optional; defaults to telegram_bot
194
+ export TSB_ADAPTER=telegram_bot
195
+ # optional; used by telegram_bot adapter
196
+ export TELEGRAM_BOT_USERNAME=your_bot_username
197
+ ```
198
+
199
+ 2. Disable webhook mode for that bot token (polling and webhooks cannot be used together):
200
+
201
+ ```bash
202
+ curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/deleteWebhook" > /dev/null
203
+ ```
204
+
205
+ 3. Start the local poller:
206
+
207
+ ```bash
208
+ bundle exec ruby script/dev_poll.rb
209
+ ```
210
+
211
+ 4. In Telegram, verify:
212
+ - user message is forwarded to support chat
213
+ - support reply is sent back to user
214
+ - reactions are mirrored in both directions
215
+
216
+ If you want to test with `telegram_bot_ruby` adapter, set `TSB_ADAPTER=telegram_bot_ruby` and add
217
+ the `telegram-bot-ruby` gem in your environment.
218
+
219
+ ### Switch Back To Webhook Mode
220
+
221
+ After polling tests, set your webhook again:
222
+
223
+ ```bash
224
+ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
225
+ -H "Content-Type: application/json" \
226
+ -d '{"url":"https://YOUR_PUBLIC_HOST/telegram/webhook","allowed_updates":["message","message_reaction","message_reaction_count","my_chat_member"]}'
227
+ ```
125
228
 
126
229
  ## Contributing
127
230
 
@@ -8,15 +8,24 @@ module TelegramSupportBot
8
8
  }.freeze
9
9
 
10
10
  def self.build(adapter_specification, adapter_options = {})
11
+ adapter_options ||= {}
11
12
  case adapter_specification
12
13
  when Symbol
13
- adapter_class = ADAPTERS[adapter_specification].constantize
14
- adapter_class.new(adapter_options)
14
+ class_name = ADAPTERS[adapter_specification]
15
+ raise ArgumentError, "Unsupported adapter specification: #{adapter_specification}" unless class_name
16
+
17
+ adapter_class = constantize(class_name)
18
+ adapter_class.new(**adapter_options)
15
19
  when Class
16
- adapter_specification.new(adapter_options)
20
+ adapter_specification.new(**adapter_options)
17
21
  else
18
22
  raise ArgumentError, "Unsupported adapter specification: #{adapter_specification}"
19
23
  end
20
24
  end
25
+
26
+ def self.constantize(class_name)
27
+ class_name.split('::').reject(&:empty?).inject(Object) { |namespace, constant| namespace.const_get(constant) }
28
+ end
29
+ private_class_method :constantize
21
30
  end
22
31
  end
@@ -35,6 +35,10 @@ module TelegramSupportBot
35
35
  # forward messages to the support chat
36
36
  end
37
37
 
38
+ def set_message_reaction(chat_id:, message_id:, reaction:, **options)
39
+ # set reaction to a message
40
+ end
41
+
38
42
  def on_message(&block)
39
43
  # Implementation to register a block to be called on new messages
40
44
  end
@@ -50,6 +50,15 @@ module TelegramSupportBot
50
50
  message_id: message_id
51
51
  )
52
52
  end
53
+
54
+ def set_message_reaction(chat_id:, message_id:, reaction:, **options)
55
+ @bot.set_message_reaction(
56
+ chat_id: chat_id,
57
+ message_id: message_id,
58
+ reaction: reaction,
59
+ **options
60
+ )
61
+ end
53
62
  end
54
63
  end
55
64
  end
@@ -43,6 +43,10 @@ module TelegramSupportBot
43
43
  def forward_message(from_chat_id:, chat_id:, message_id:)
44
44
  bot.api.forward_message(chat_id: chat_id, from_chat_id: from_chat_id, message_id: message_id)
45
45
  end
46
+
47
+ def set_message_reaction(chat_id:, message_id:, reaction:, **options)
48
+ bot.api.set_message_reaction(chat_id: chat_id, message_id: message_id, reaction: reaction, **options)
49
+ end
46
50
  end
47
51
  end
48
52
  end
@@ -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