tg_error_notifier 0.1.2 → 0.4.0

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: 785aa28f5a62476e0f827430090ff0dad300edd8277b54672b88f3b89a7ee730
4
- data.tar.gz: 680dea5d7f8440e07b41e5df415483e35a0aed5b8adbf0b5f9169302c1306422
3
+ metadata.gz: 03b6d8e79dd258e7487a99925017dc62d9fd7d66255af5e80c62aecff7b6638b
4
+ data.tar.gz: 062f6f0253004feb248c55e9cf2962244a7683c6d6771dac55f9a762a1c97bec
5
5
  SHA512:
6
- metadata.gz: 001f1b8fb2fb679ea77acb1d1761f6fe67f3736b08be8be1fa5c9541e45818b137a37b9c39ea4ce7d59ede8cc654bdd9689be76959a4364253add6fccb4b0535
7
- data.tar.gz: 58ccd0356a6699a0fafff07bd90d461a8a3654db0d00813faa5a3fc7b826593f737924756127fa809d7bf1be65eb1420460b7731cb9cf46349d19160f10291cc
6
+ metadata.gz: 8caa97214339f4f0af2683a40f7d3a705a80fd0ecb9dd65f7a242d97efb38b2cab5e284b5b4f2d2cff3bb0121034190b09f8ad87793529937e89a2624bf1bc3c
7
+ data.tar.gz: 8e546d58081bd0e50abae5bf375a7d9adac0ada19032fb44fbfe089fedb62fbaf55e0abe4b35489857716fa21352466c90224d36592faa43926d6fd578330b5b
data/README.md CHANGED
@@ -70,6 +70,42 @@ Examples:
70
70
  - Ensure bot has permission to send messages.
71
71
  - Put value into `TELEGRAM_ERRORS_CHAT_ID` and run a smoke test.
72
72
 
73
+ ## Error grouping
74
+
75
+ Group identical errors to avoid flooding. When the same exception repeats within a time window, only the first message is sent — subsequent occurrences are suppressed and reported as a count in the next message.
76
+
77
+ ```ruby
78
+ TgErrorNotifier.configure do |config|
79
+ config.grouping_enabled = true
80
+ config.grouping_window = 60 # seconds (default)
81
+ end
82
+ ```
83
+
84
+ Errors are grouped by exception class + normalized message (IDs and UUIDs are replaced with placeholders for better deduplication).
85
+
86
+ ## Forum topics (threads)
87
+
88
+ Automatically create a Telegram Forum topic (thread) per unique error type. Each error gets its own topic in a supergroup with Forum Topics enabled.
89
+
90
+ ```ruby
91
+ TgErrorNotifier.configure do |config|
92
+ config.topics_enabled = true
93
+ config.topic_icon_color = 0xFB6F5F # red (default), optional
94
+ end
95
+ ```
96
+
97
+ **Requirements:** The chat must be a supergroup with Forum Topics enabled. The bot must have `can_manage_topics` admin permission.
98
+
99
+ You can combine both features — errors will be grouped within their respective topics:
100
+
101
+ ```ruby
102
+ TgErrorNotifier.configure do |config|
103
+ config.grouping_enabled = true
104
+ config.grouping_window = 60
105
+ config.topics_enabled = true
106
+ end
107
+ ```
108
+
73
109
  ## Manual notification
74
110
  ```ruby
75
111
  begin
@@ -102,3 +138,50 @@ TgErrorNotifier.capture_message(
102
138
  ```
103
139
 
104
140
  `capture_message` returns the same diagnostic hash format as `capture_exception`.
141
+
142
+ ## Named topics for messages
143
+
144
+ Send messages to a dedicated Forum topic by name. The topic is created on first use (blue icon) and reused afterwards:
145
+
146
+ ```ruby
147
+ TgErrorNotifier.capture_message("New account: #{account.name}", topic: "Registrations")
148
+ ```
149
+
150
+ Since the topic → thread_id mapping is cached in memory per process, configure a persistent store so restarts and multiple processes (Puma workers, Sidekiq) don't create duplicate topics:
151
+
152
+ ```ruby
153
+ TgErrorNotifier.configure do |config|
154
+ config.topic_store_read = ->(name) { MyKeyValueStore.get("tg_topic:#{name}") }
155
+ config.topic_store_write = ->(name, thread_id) { MyKeyValueStore.set("tg_topic:#{name}", thread_id) }
156
+ end
157
+ ```
158
+
159
+ Without a store everything still works, but each process creates its own topic. Named topics work independently of `topics_enabled` (which controls per-exception topics). Requirements are the same: forum supergroup + `can_manage_topics` bot permission.
160
+
161
+ ## Buttons under the message
162
+
163
+ Attach an inline keyboard to error notifications — e.g. a link that files the error as a task on your board. The gem does not interpret the buttons: whatever the callable returns is passed to Telegram as is, so `url`, `callback_data` and `web_app` buttons all work.
164
+
165
+ ```ruby
166
+ TgErrorNotifier.configure do |config|
167
+ config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
168
+ next nil unless kind == :exception
169
+
170
+ token = ErrorTaskLink.issue(exception: exception, source: source, fingerprint: fingerprint)
171
+ [[{ text: "\u2795 To the board", url: "https://example.com/e/#{token}" }]]
172
+ end
173
+ end
174
+ ```
175
+
176
+ Arguments:
177
+
178
+ | key | value |
179
+ | --- | --- |
180
+ | `kind` | `:exception` for `capture_exception`/rescued errors, `:message` for `capture_message` |
181
+ | `exception` | the exception (nil for `:message`) |
182
+ | `message` | the message text (nil for `:exception`) |
183
+ | `source` | where it came from (`"Sidekiq: MyWorker"`, `"manual"`, ...) |
184
+ | `context` | the context hash passed to the notifier |
185
+ | `fingerprint` | grouping key of the exception — stable across repeats, use it to deduplicate whatever the button creates (nil for `:message`) |
186
+
187
+ Return an array of rows (`[[button, button], [button]]`); a flat array becomes a single row; `nil` or `[]` means no keyboard. Exceptions raised inside the callable are logged and the notification is still delivered — without the keyboard.
@@ -19,7 +19,14 @@ module TgErrorNotifier
19
19
  :proxy_addr,
20
20
  :proxy_port,
21
21
  :proxy_user,
22
- :proxy_pass
22
+ :proxy_pass,
23
+ :grouping_enabled,
24
+ :grouping_window,
25
+ :topics_enabled,
26
+ :topic_icon_color,
27
+ :topic_store_read,
28
+ :topic_store_write,
29
+ :buttons
23
30
 
24
31
  def initialize
25
32
  @enabled = true
@@ -44,6 +51,29 @@ module TgErrorNotifier
44
51
  @proxy_port = nil
45
52
  @proxy_user = nil
46
53
  @proxy_pass = nil
54
+ @grouping_enabled = false
55
+ @grouping_window = 60
56
+ @topics_enabled = false
57
+ @topic_icon_color = nil
58
+ # Persistent storage for named topics (created via capture_message topic:).
59
+ # Without a store each process/restart would create a duplicate forum topic.
60
+ # topic_store_read = ->(name) { ... } # returns thread_id or nil
61
+ # topic_store_write = ->(name, thread_id) { ... }
62
+ @topic_store_read = nil
63
+ @topic_store_write = nil
64
+ # Inline keyboard attached under the message. The gem does not interpret
65
+ # the buttons: whatever the host returns goes to Telegram as is, so the
66
+ # host is free to use url buttons, callback_data or web_app.
67
+ #
68
+ # config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
69
+ # [[{ text: "To the board", url: "https://example.com/e/#{token}" }]]
70
+ # end
71
+ #
72
+ # Return value: array of rows, each row an array of button hashes.
73
+ # A flat array of buttons is accepted too and becomes a single row.
74
+ # Return nil or [] for no keyboard. Errors raised inside are logged and
75
+ # the message is still delivered, without the keyboard.
76
+ @buttons = nil
47
77
  end
48
78
 
49
79
  def proxy?
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TgErrorNotifier
4
+ class Grouper
5
+ Entry = Struct.new(:count, :first_at, :last_sent_at, :thread_id, keyword_init: true)
6
+
7
+ CLEANUP_INTERVAL = 100
8
+
9
+ def initialize(window:)
10
+ @window = window
11
+ @mutex = Mutex.new
12
+ @entries = {}
13
+ @call_count = 0
14
+ end
15
+
16
+ # Returns:
17
+ # { action: :send, count: N, thread_id: id_or_nil }
18
+ # { action: :suppress }
19
+ def process(key:, thread_id: nil)
20
+ now = Time.now
21
+
22
+ @mutex.synchronize do
23
+ @call_count += 1
24
+ lazy_cleanup!(now) if (@call_count % CLEANUP_INTERVAL).zero?
25
+
26
+ entry = @entries[key]
27
+
28
+ if entry.nil?
29
+ @entries[key] = Entry.new(count: 0, first_at: now, last_sent_at: now, thread_id: thread_id)
30
+ return { action: :send, count: 0, thread_id: thread_id }
31
+ end
32
+
33
+ entry.thread_id = thread_id if entry.thread_id.nil? && thread_id
34
+
35
+ elapsed = now - entry.last_sent_at
36
+
37
+ if elapsed >= @window
38
+ accumulated = entry.count
39
+ entry.count = 0
40
+ entry.last_sent_at = now
41
+ { action: :send, count: accumulated, thread_id: entry.thread_id }
42
+ else
43
+ entry.count += 1
44
+ { action: :suppress }
45
+ end
46
+ end
47
+ end
48
+
49
+ def rollback(key)
50
+ @mutex.synchronize do
51
+ @entries.delete(key)
52
+ end
53
+ end
54
+
55
+ def grouping_key(exception)
56
+ "#{exception.class.name}:#{normalize_message(exception.message)}"
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_message(message)
62
+ msg = message.to_s
63
+ msg = msg.gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<UUID>")
64
+ msg = msg.gsub(/\b\d{4,}\b/, "<ID>")
65
+ msg = msg.gsub(/#<\w+:0x[0-9a-f]+>/i, "#<Object>")
66
+ msg.strip
67
+ end
68
+
69
+ def lazy_cleanup!(now)
70
+ cutoff = now - 3600
71
+ @entries.delete_if { |_, e| e.last_sent_at < cutoff }
72
+ end
73
+ end
74
+ end
@@ -7,6 +7,10 @@ require "cgi"
7
7
  module TgErrorNotifier
8
8
  class Notifier
9
9
  MAX_MESSAGE_LENGTH = 3800
10
+ # Отдельные поля (сообщение исключения, значения контекста) режем заранее,
11
+ # чтобы гарантированно осталось место под бэктрейс и закрывающие теги
12
+ MAX_FIELD_LENGTH = 900
13
+ HTML_TAGS = %w[pre code b i u s].freeze
10
14
 
11
15
  def initialize(config)
12
16
  @config = config
@@ -23,21 +27,63 @@ module TgErrorNotifier
23
27
  return { sent: false, status: :skipped, reason: "ignored_exception" }
24
28
  end
25
29
 
26
- payload = build_payload(exception: exception, source: source, context: context)
27
- send_payload(payload)
30
+ key = nil
31
+ thread_id = nil
32
+ suppressed_count = 0
33
+
34
+ if config.topics_enabled || config.grouping_enabled || config.buttons
35
+ key = grouper.grouping_key(exception)
36
+ end
37
+
38
+ if config.topics_enabled
39
+ thread_id = topic_manager.thread_id_for(key, exception)
40
+ end
41
+
42
+ if config.grouping_enabled
43
+ result = grouper.process(key: key, thread_id: thread_id)
44
+ if result[:action] == :suppress
45
+ return { sent: false, status: :suppressed }
46
+ end
47
+ suppressed_count = result[:count]
48
+ thread_id = result[:thread_id] || thread_id
49
+ end
50
+
51
+ payload = build_payload(
52
+ exception: exception,
53
+ source: source,
54
+ context: context,
55
+ thread_id: thread_id,
56
+ suppressed_count: suppressed_count,
57
+ fingerprint: key
58
+ )
59
+ response = send_payload(payload)
60
+
61
+ if !response[:sent] && config.grouping_enabled && key
62
+ grouper.rollback(key)
63
+ end
64
+
65
+ response
28
66
  rescue StandardError => e
29
67
  log("notify failed: #{e.class}: #{e.message}")
30
68
  { sent: false, status: :failed, reason: e.class.name, error: e.message }
31
69
  end
32
70
 
33
- def notify_message(message:, level:, source:, context: {})
71
+ def notify_message(message:, level:, source:, context: {}, topic: nil, thread_id: nil)
34
72
  enabled_check = enabled_status
35
73
  unless enabled_check[:enabled]
36
74
  log("skipped: #{enabled_check[:reason]}")
37
75
  return { sent: false, status: :skipped, reason: enabled_check[:reason] }
38
76
  end
39
77
 
40
- payload = build_message_payload(message: message, level: level, source: source, context: context)
78
+ thread_id ||= topic_manager.thread_id_for_name(topic) if topic
79
+
80
+ payload = build_message_payload(
81
+ message: message,
82
+ level: level,
83
+ source: source,
84
+ context: context,
85
+ thread_id: thread_id
86
+ )
41
87
  send_payload(payload)
42
88
  rescue StandardError => e
43
89
  log("notify_message failed: #{e.class}: #{e.message}")
@@ -48,6 +94,14 @@ module TgErrorNotifier
48
94
 
49
95
  attr_reader :config
50
96
 
97
+ def grouper
98
+ @grouper ||= Grouper.new(window: config.grouping_window)
99
+ end
100
+
101
+ def topic_manager
102
+ @topic_manager ||= TopicManager.new(config)
103
+ end
104
+
51
105
  def enabled_status
52
106
  return { enabled: false, reason: "disabled" } unless resolve(config.enabled)
53
107
  if config.ignored_environments.include?(resolve(config.environment).to_s)
@@ -67,7 +121,7 @@ module TgErrorNotifier
67
121
  ignored.include?(exception.class.name)
68
122
  end
69
123
 
70
- def send_payload(payload)
124
+ def send_payload(payload, retry_plain: true)
71
125
  token = resolve(config.bot_token)
72
126
  uri = URI("#{resolve(config.api_base)}/bot#{token}/sendMessage")
73
127
 
@@ -90,40 +144,103 @@ module TgErrorNotifier
90
144
  end
91
145
 
92
146
  log("telegram api error: HTTP #{response.code} #{response.body}")
147
+
148
+ # Последний рубеж: разметка не распарсилась — шлём то же самое
149
+ # обычным текстом, чтобы тело сообщения дошло в любом случае
150
+ if retry_plain && parse_entities_error?(response)
151
+ plain = payload.dup
152
+ plain.delete(:parse_mode)
153
+ plain[:text] = truncate(strip_html(payload[:text]))
154
+ return send_payload(plain, retry_plain: false)
155
+ end
156
+
93
157
  { sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
94
158
  end
95
159
 
96
- def build_payload(exception:, source:, context: {})
97
- text = [
160
+ def parse_entities_error?(response)
161
+ response.code.to_i == 400 && response.body.to_s.include?("can't parse entities")
162
+ end
163
+
164
+ def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0, fingerprint: nil)
165
+ parts = [
98
166
  "<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
99
- "<b>Source:</b> #{escape(source.to_s)}",
167
+ "<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
100
168
  "<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
101
- "<b>Message:</b> #{escape(exception.message.to_s)}",
102
- context_block(context)
103
- ].compact.join("\n")
169
+ "<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
170
+ ]
171
+
172
+ if suppressed_count > 0
173
+ parts << "<b>🔁 +#{suppressed_count} more in last #{config.grouping_window}s</b>"
174
+ end
175
+
176
+ parts << context_block(context)
177
+
178
+ text = truncate(parts.compact.join("\n"))
104
179
 
105
180
  if config.include_backtrace && exception.backtrace
106
181
  lines = exception.backtrace.first(config.max_backtrace_lines)
107
- bt = escape(lines.join("\n"))
108
- text = "#{text}\n<b>Backtrace:</b>\n<pre>#{bt}</pre>"
182
+ header = "\n<b>Backtrace:</b>\n"
183
+ # Бэктрейс режем по остатку бюджета ДО оборачивания в <pre>,
184
+ # иначе финальный truncate обрезал бы текст внутри тега и Telegram
185
+ # отвечал 400 "can't find end tag corresponding to start tag pre"
186
+ budget = MAX_MESSAGE_LENGTH - text.length - header.length - "<pre></pre>".length
187
+ if budget > 100
188
+ bt = cut_escaped(escape(lines.join("\n")), budget)
189
+ text = "#{text}#{header}<pre>#{bt}</pre>"
190
+ end
109
191
  end
110
192
 
111
- {
193
+ payload = {
112
194
  chat_id: resolve(config.chat_id),
113
195
  text: truncate(text),
114
196
  parse_mode: "HTML",
115
197
  disable_web_page_preview: true
116
198
  }
199
+ payload[:message_thread_id] = thread_id if thread_id
200
+ keyboard = build_keyboard(
201
+ kind: :exception,
202
+ exception: exception,
203
+ source: source,
204
+ context: context,
205
+ fingerprint: fingerprint
206
+ )
207
+ payload[:reply_markup] = keyboard if keyboard
208
+ payload
209
+ end
210
+
211
+ # Кнопки строит хост (config.buttons) — гем не знает, куда они ведут.
212
+ # Сбой в колбэке не должен отменять доставку самого уведомления: об ошибке
213
+ # надо узнать в любом случае, кнопка — дополнение.
214
+ def build_keyboard(kind:, source:, context:, fingerprint:, exception: nil, message: nil)
215
+ return nil if config.buttons.nil?
216
+
217
+ rows = config.buttons.call(
218
+ kind: kind,
219
+ exception: exception,
220
+ message: message,
221
+ source: source,
222
+ context: context,
223
+ fingerprint: fingerprint
224
+ )
225
+ return nil if rows.nil? || rows.empty?
226
+
227
+ # Плоский список кнопок принимаем как одну строку клавиатуры.
228
+ rows = [rows] unless rows.first.is_a?(Array)
229
+
230
+ { inline_keyboard: rows }
231
+ rescue StandardError => e
232
+ log("buttons failed: #{e.class}: #{e.message}")
233
+ nil
117
234
  end
118
235
 
119
236
  def context_block(context)
120
237
  return nil if context.nil? || context.empty?
121
238
 
122
- formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v.to_s)}" }
239
+ formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
123
240
  formatted.join("\n")
124
241
  end
125
242
 
126
- def build_message_payload(message:, level:, source:, context: {})
243
+ def build_message_payload(message:, level:, source:, context: {}, thread_id: nil)
127
244
  text = [
128
245
  "<b>ℹ️ #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
129
246
  "<b>Source:</b> #{escape(source.to_s)}",
@@ -132,18 +249,57 @@ module TgErrorNotifier
132
249
  context_block(context)
133
250
  ].compact.join("\n")
134
251
 
135
- {
252
+ payload = {
136
253
  chat_id: resolve(config.chat_id),
137
254
  text: truncate(text),
138
255
  parse_mode: "HTML",
139
256
  disable_web_page_preview: true
140
257
  }
258
+ payload[:message_thread_id] = thread_id if thread_id
259
+ keyboard = build_keyboard(
260
+ kind: :message,
261
+ message: message,
262
+ source: source,
263
+ context: context,
264
+ fingerprint: nil
265
+ )
266
+ payload[:reply_markup] = keyboard if keyboard
267
+ payload
268
+ end
269
+
270
+ def clamp(value, limit)
271
+ text = value.to_s
272
+ text.length > limit ? "#{text[0...limit]}…" : text
273
+ end
274
+
275
+ # Обрезка уже экранированного текста: не оставляем хвост от «&amp;»
276
+ def cut_escaped(text, limit)
277
+ return text if text.length <= limit
278
+
279
+ text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
141
280
  end
142
281
 
143
282
  def truncate(text)
144
283
  return text if text.length <= MAX_MESSAGE_LENGTH
145
284
 
146
- text[0...MAX_MESSAGE_LENGTH] + "\n...truncated"
285
+ close_tags(cut_escaped(text, MAX_MESSAGE_LENGTH - 20)) + "\n...truncated"
286
+ end
287
+
288
+ # Обрезка могла оставить незакрытый тег или его половину — Telegram
289
+ # на таком отвечает 400 и сообщение не доходит вовсе
290
+ def close_tags(text)
291
+ text = text.sub(/<[^>]*\z/, "")
292
+
293
+ HTML_TAGS.each do |tag|
294
+ unclosed = text.scan("<#{tag}>").size - text.scan("</#{tag}>").size
295
+ unclosed.times { text += "</#{tag}>" }
296
+ end
297
+
298
+ text
299
+ end
300
+
301
+ def strip_html(text)
302
+ CGI.unescapeHTML(text.to_s.gsub(/<[^>]*>/, ""))
147
303
  end
148
304
 
149
305
  def resolve(value)
@@ -22,6 +22,10 @@ module TgErrorNotifier
22
22
  config.logger = options.logger unless options.logger.nil?
23
23
  config.include_backtrace = options.include_backtrace unless options.include_backtrace.nil?
24
24
  config.active_job_enabled = options.active_job_enabled unless options.active_job_enabled.nil?
25
+ config.grouping_enabled = options.grouping_enabled unless options.grouping_enabled.nil?
26
+ config.grouping_window = options.grouping_window unless options.grouping_window.nil?
27
+ config.topics_enabled = options.topics_enabled unless options.topics_enabled.nil?
28
+ config.topic_icon_color = options.topic_icon_color unless options.topic_icon_color.nil?
25
29
  end
26
30
  end
27
31
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "set"
6
+
7
+ module TgErrorNotifier
8
+ class TopicManager
9
+ ICON_COLOR_RED = 0xFB6F5F
10
+ ICON_COLOR_BLUE = 0x6FB9F0
11
+ MAX_TOPIC_NAME = 128
12
+ MAX_STORE_KEY = 200
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ @mutex = Mutex.new
17
+ @condition = ConditionVariable.new
18
+ @topics = {} # grouping_key => message_thread_id
19
+ @creating = Set.new
20
+ end
21
+
22
+ def thread_id_for(key, exception)
23
+ name = nil
24
+ store_key = "exception:#{key}"[0...MAX_STORE_KEY]
25
+
26
+ @mutex.synchronize do
27
+ # Wait if another thread is already creating this topic
28
+ while @creating.include?(key)
29
+ @condition.wait(@mutex, 10)
30
+ end
31
+
32
+ return @topics[key] if @topics.key?(key)
33
+
34
+ # Тема ошибки должна пережить рестарт и быть общей для всех
35
+ # процессов: иначе каждый puma/sidekiq-воркер заводит свой дубль
36
+ stored = store_read(store_key)
37
+ if stored
38
+ @topics[key] = stored
39
+ return stored
40
+ end
41
+
42
+ @creating.add(key)
43
+ name = topic_name(exception)
44
+ end
45
+
46
+ thread_id = create_topic(name)
47
+
48
+ @mutex.synchronize do
49
+ if thread_id
50
+ @topics[key] = thread_id
51
+ store_write(store_key, thread_id)
52
+ end
53
+ @creating.delete(key)
54
+ @condition.broadcast
55
+ end
56
+
57
+ thread_id
58
+ end
59
+
60
+ # Resolve thread_id for an explicitly named topic (capture_message topic: "...").
61
+ # Unlike exception topics, named topics survive restarts via the configured
62
+ # topic_store_read/topic_store_write callbacks.
63
+ def thread_id_for_name(name)
64
+ key = "topic:#{name}"
65
+
66
+ @mutex.synchronize do
67
+ while @creating.include?(key)
68
+ @condition.wait(@mutex, 10)
69
+ end
70
+
71
+ return @topics[key] if @topics.key?(key)
72
+
73
+ stored = store_read(name)
74
+ if stored
75
+ @topics[key] = stored
76
+ return stored
77
+ end
78
+
79
+ @creating.add(key)
80
+ end
81
+
82
+ thread_id = create_topic(truncate_name(name), icon_color: @config.topic_icon_color || ICON_COLOR_BLUE)
83
+
84
+ @mutex.synchronize do
85
+ if thread_id
86
+ @topics[key] = thread_id
87
+ store_write(name, thread_id)
88
+ end
89
+ @creating.delete(key)
90
+ @condition.broadcast
91
+ end
92
+
93
+ thread_id
94
+ end
95
+
96
+ private
97
+
98
+ def store_read(name)
99
+ reader = @config.topic_store_read
100
+ return nil unless reader
101
+
102
+ value = reader.call(name)
103
+ value.to_s.empty? ? nil : value.to_i
104
+ rescue StandardError => e
105
+ log("topic_store_read failed: #{e.class}: #{e.message}")
106
+ nil
107
+ end
108
+
109
+ def store_write(name, thread_id)
110
+ writer = @config.topic_store_write
111
+ return unless writer
112
+
113
+ writer.call(name, thread_id)
114
+ rescue StandardError => e
115
+ log("topic_store_write failed: #{e.class}: #{e.message}")
116
+ end
117
+
118
+ def truncate_name(name)
119
+ name = name.to_s.gsub(/\s+/, " ").strip
120
+ name.length > MAX_TOPIC_NAME ? "#{name[0...MAX_TOPIC_NAME - 1]}…" : name
121
+ end
122
+
123
+ def topic_name(exception)
124
+ truncate_name("#{exception.class.name}: #{exception.message}")
125
+ end
126
+
127
+ def create_topic(name, icon_color: nil)
128
+ token = resolve(@config.bot_token)
129
+ chat_id = resolve(@config.chat_id)
130
+ uri = URI("#{resolve(@config.api_base)}/bot#{token}/createForumTopic")
131
+
132
+ payload = {
133
+ chat_id: chat_id,
134
+ name: name,
135
+ icon_color: icon_color || @config.topic_icon_color || ICON_COLOR_RED
136
+ }
137
+
138
+ request = Net::HTTP::Post.new(uri)
139
+ request["Content-Type"] = "application/json"
140
+ request.body = payload.to_json
141
+
142
+ http = build_http(uri)
143
+ response = http.request(request)
144
+
145
+ if response.is_a?(Net::HTTPSuccess)
146
+ data = JSON.parse(response.body)
147
+ data.dig("result", "message_thread_id")
148
+ else
149
+ log("createForumTopic failed: HTTP #{response.code} #{response.body}")
150
+ nil
151
+ end
152
+ rescue StandardError => e
153
+ log("createForumTopic error: #{e.class}: #{e.message}")
154
+ nil
155
+ end
156
+
157
+ def build_http(uri)
158
+ http = if @config.proxy?
159
+ Net::HTTP.new(uri.host, uri.port, resolve(@config.proxy_addr), resolve(@config.proxy_port).to_i, resolve(@config.proxy_user), resolve(@config.proxy_pass))
160
+ else
161
+ Net::HTTP.new(uri.host, uri.port)
162
+ end
163
+ http.use_ssl = uri.scheme == "https"
164
+ http.open_timeout = @config.open_timeout
165
+ http.read_timeout = @config.read_timeout
166
+ http
167
+ end
168
+
169
+ def resolve(value)
170
+ value.respond_to?(:call) ? value.call : value
171
+ end
172
+
173
+ def log(message)
174
+ return unless @config.logger
175
+ @config.logger.error("[TgErrorNotifier::TopicManager] #{message}")
176
+ end
177
+ end
178
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TgErrorNotifier
4
- VERSION = "0.1.2"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -3,6 +3,8 @@
3
3
  require "rails"
4
4
  require_relative "tg_error_notifier/version"
5
5
  require_relative "tg_error_notifier/configuration"
6
+ require_relative "tg_error_notifier/grouper"
7
+ require_relative "tg_error_notifier/topic_manager"
6
8
  require_relative "tg_error_notifier/notifier"
7
9
  require_relative "tg_error_notifier/middleware"
8
10
  require_relative "tg_error_notifier/subscriber"
@@ -28,8 +30,15 @@ module TgErrorNotifier
28
30
  end
29
31
 
30
32
  # API similar to Sentry.capture_message("text")
31
- def capture_message(message, level: :info, source: "manual", context: {})
32
- notifier.notify_message(message: message, level: level, source: source, context: context)
33
+ # topic: имя форум-топика сообщение уйдёт в отдельную тему группы
34
+ # (топик создаётся один раз и переиспользуется через topic_store_read/write)
35
+ def capture_message(message, level: :info, source: "manual", context: {}, topic: nil)
36
+ notifier.notify_message(message: message, level: level, source: source, context: context, topic: topic)
37
+ end
38
+
39
+ def reset!
40
+ @configuration = nil
41
+ @notifier = nil
33
42
  end
34
43
 
35
44
  private
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tg_error_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei Ustinov
@@ -35,10 +35,12 @@ files:
35
35
  - README.md
36
36
  - lib/tg_error_notifier.rb
37
37
  - lib/tg_error_notifier/configuration.rb
38
+ - lib/tg_error_notifier/grouper.rb
38
39
  - lib/tg_error_notifier/middleware.rb
39
40
  - lib/tg_error_notifier/notifier.rb
40
41
  - lib/tg_error_notifier/railtie.rb
41
42
  - lib/tg_error_notifier/subscriber.rb
43
+ - lib/tg_error_notifier/topic_manager.rb
42
44
  - lib/tg_error_notifier/version.rb
43
45
  homepage: https://github.com/sergeyustinov/TgErrorNotifier
44
46
  licenses: