tg_error_notifier 0.2.0 → 0.5.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: eb51823201c7e9fef5a9a3d3f1ddb9ca52390d75cf256c1488f3b3f72ebc6d2b
4
- data.tar.gz: d778ac6a5c07212e617729878ad8dc9d05d338377d9788f57a44577b9d739804
3
+ metadata.gz: 71739ce66aa4580137b5c1781b0a6ae388713379297c26895883b77b4b8ba7a4
4
+ data.tar.gz: 0c0303ccbdc01d133029ee4ffcd842e7fc665223c804fd125bcdd82e1fb53761
5
5
  SHA512:
6
- metadata.gz: c943e6433257b3d68fb0d4630156e3073084dfa82d90ef51c38d5db4b518f840abd2e211d0aacf893ed8734d70e893cb8872969ffbe284324893b54946882610
7
- data.tar.gz: 3f05a5989f3d5731eef6114b2e8acb3247ea97af1ee43d7ccc98d163f6496b5f22c55b6fed9fac1e31c5c3431dfe82919bb42c715f342c3b610552a4f6ed7849
6
+ metadata.gz: 1dd5dd9a02ec60173ff81eeb6b30f84831b351d9ac12f07d4d6ead4e1fb22eee0cadefcffc7acf85638ba4e4e3aa344873091e111bb9ddd2f228f67f1bb652ee
7
+ data.tar.gz: 53f7ffc99e4672c9bbde0b52897a7da2c05ae5327bf69fcdfcacdbe6fdfb06038b36696d5fe691f5b50091393d7a1f4458099d52cb53f1f19ec924f70c948141
data/README.md CHANGED
@@ -138,3 +138,95 @@ TgErrorNotifier.capture_message(
138
138
  ```
139
139
 
140
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
+ ## Filtering request data
162
+
163
+ The query string of a failed request is filtered with `Rails.application.config.filter_parameters` (the same rules as the Rails log) before it is sent: `?token=abc` arrives as `?token=[FILTERED]`. A query that cannot be parsed is not sent at all. Override the rules with `config.filter_parameters = [...]`.
164
+
165
+ Paths that carry a secret as a segment (one-time login links, invitations) are masked by prefix:
166
+
167
+ ```ruby
168
+ TgErrorNotifier.configure do |config|
169
+ config.sensitive_path_prefixes = %w[/tsd/ /invitations/]
170
+ end
171
+ # "/tsd/abc123/scan" is reported as "/tsd/[FILTERED]/scan"
172
+ ```
173
+
174
+ ## Buttons under the message
175
+
176
+ 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.
177
+
178
+ ```ruby
179
+ TgErrorNotifier.configure do |config|
180
+ config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
181
+ next nil unless kind == :exception
182
+
183
+ token = ErrorTaskLink.issue(exception: exception, source: source, fingerprint: fingerprint)
184
+ [[{ text: "\u2795 To the board", url: "https://example.com/e/#{token}" }]]
185
+ end
186
+ end
187
+ ```
188
+
189
+ Arguments:
190
+
191
+ | key | value |
192
+ | --- | --- |
193
+ | `kind` | `:exception` for `capture_exception`/rescued errors, `:message` for `capture_message` |
194
+ | `exception` | the exception (nil for `:message`) |
195
+ | `message` | the message text (nil for `:exception`) |
196
+ | `source` | where it came from (`"Sidekiq: MyWorker"`, `"manual"`, ...) |
197
+ | `context` | the context hash passed to the notifier |
198
+ | `fingerprint` | grouping key of the exception — stable across repeats, use it to deduplicate whatever the button creates (nil for `:message`) |
199
+
200
+ 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.
201
+
202
+ ## Updating buttons later
203
+
204
+ `notify`, `capture_exception` and `capture_message` return the ids of the delivered message:
205
+
206
+ ```ruby
207
+ result = TgErrorNotifier.capture_exception(error)
208
+ # => { sent: true, status: :sent, code: 200, message_id: 812, chat_id: -1001234567890, thread_id: 15 }
209
+ ```
210
+
211
+ Keep them if the notification should reflect what happened next — e.g. replace "To the board" with a link to the task once it exists:
212
+
213
+ ```ruby
214
+ TgErrorNotifier.edit_buttons(
215
+ chat_id: stored.chat_id,
216
+ message_id: stored.message_id,
217
+ buttons: [[{ text: "Task #42 · In progress", url: "https://example.com/tasks/42" }]]
218
+ )
219
+ ```
220
+
221
+ `buttons` takes rows in the same shape as `config.buttons`; `nil` or `[]` removes the keyboard. Only the keyboard changes, the text stays as it was. The result is a hash with `status`:
222
+
223
+ | status | meaning |
224
+ | --- | --- |
225
+ | `:edited` | keyboard replaced |
226
+ | `:not_modified` | keyboard is already the same |
227
+ | `:not_found` | the message was deleted — forget its ids |
228
+ | `:rate_limited` | Telegram asked to wait `retry_after` seconds |
229
+ | `:skipped` | notifier is disabled in this environment |
230
+ | `:failed` | any other API error, with `code` and `body` |
231
+
232
+ Unlike notifications, which never raise, `edit_buttons` lets network errors through: it is called by your code (usually a background job), which knows whether to retry. Telegram allows roughly 20 edits per minute in a group, so update the latest few messages of an error, not all of them.
@@ -23,7 +23,12 @@ module TgErrorNotifier
23
23
  :grouping_enabled,
24
24
  :grouping_window,
25
25
  :topics_enabled,
26
- :topic_icon_color
26
+ :topic_icon_color,
27
+ :topic_store_read,
28
+ :topic_store_write,
29
+ :buttons,
30
+ :filter_parameters,
31
+ :sensitive_path_prefixes
27
32
 
28
33
  def initialize
29
34
  @enabled = true
@@ -52,6 +57,34 @@ module TgErrorNotifier
52
57
  @grouping_window = 60
53
58
  @topics_enabled = false
54
59
  @topic_icon_color = nil
60
+ # Persistent storage for named topics (created via capture_message topic:).
61
+ # Without a store each process/restart would create a duplicate forum topic.
62
+ # topic_store_read = ->(name) { ... } # returns thread_id or nil
63
+ # topic_store_write = ->(name, thread_id) { ... }
64
+ @topic_store_read = nil
65
+ @topic_store_write = nil
66
+ # Inline keyboard attached under the message. The gem does not interpret
67
+ # the buttons: whatever the host returns goes to Telegram as is, so the
68
+ # host is free to use url buttons, callback_data or web_app.
69
+ #
70
+ # config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
71
+ # [[{ text: "To the board", url: "https://example.com/e/#{token}" }]]
72
+ # end
73
+ #
74
+ # Return value: array of rows, each row an array of button hashes.
75
+ # A flat array of buttons is accepted too and becomes a single row.
76
+ # Return nil or [] for no keyboard. Errors raised inside are logged and
77
+ # the message is still delivered, without the keyboard.
78
+ @buttons = nil
79
+ # Query string of a failed request is filtered before it leaves the app:
80
+ # tokens in links (?token=..., ?code=...) must not end up in a Telegram chat.
81
+ # nil means "use Rails.application.config.filter_parameters" — the same
82
+ # rules as the Rails log.
83
+ @filter_parameters = nil
84
+ # Paths whose next segment is a secret (one-time login links and the like):
85
+ # config.sensitive_path_prefixes = %w[/tsd/ /invitations/]
86
+ # "/tsd/abc123/x" is reported as "/tsd/[FILTERED]/x".
87
+ @sensitive_path_prefixes = []
55
88
  end
56
89
 
57
90
  def proxy?
@@ -46,6 +46,12 @@ module TgErrorNotifier
46
46
  end
47
47
  end
48
48
 
49
+ def rollback(key)
50
+ @mutex.synchronize do
51
+ @entries.delete(key)
52
+ end
53
+ end
54
+
49
55
  def grouping_key(exception)
50
56
  "#{exception.class.name}:#{normalize_message(exception.message)}"
51
57
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module TgErrorNotifier
4
4
  class Middleware
5
+ FILTERED = "[FILTERED]"
6
+
5
7
  def initialize(app)
6
8
  @app = app
7
9
  end
@@ -14,12 +16,41 @@ module TgErrorNotifier
14
16
  source: "rack",
15
17
  context: {
16
18
  method: env["REQUEST_METHOD"],
17
- path: env["PATH_INFO"],
18
- query: env["QUERY_STRING"],
19
+ path: self.class.filter_path(env["PATH_INFO"]),
20
+ query: self.class.filter_query(env["QUERY_STRING"]),
19
21
  request_id: env["action_dispatch.request_id"]
20
22
  }
21
23
  )
22
24
  raise
23
25
  end
26
+
27
+ class << self
28
+ def filter_query(query)
29
+ return query if query.nil? || query.empty?
30
+
31
+ params = Rack::Utils.parse_nested_query(query)
32
+ Rack::Utils.build_nested_query(parameter_filter.filter(params))
33
+ .gsub(Rack::Utils.escape(FILTERED), FILTERED)
34
+ rescue StandardError
35
+ # Unparseable query (invalid encoding, broken nesting): it cannot be
36
+ # filtered key by key, so it is not reported at all.
37
+ FILTERED
38
+ end
39
+
40
+ def filter_path(path)
41
+ return path if path.nil? || path.empty?
42
+
43
+ TgErrorNotifier.configuration.sensitive_path_prefixes.reduce(path) do |result, prefix|
44
+ result.gsub(%r{(#{Regexp.escape(prefix)})[^/?]+}) { "#{Regexp.last_match(1)}#{FILTERED}" }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def parameter_filter
51
+ filters = TgErrorNotifier.configuration.filter_parameters || Rails.application.config.filter_parameters
52
+ ActiveSupport::ParameterFilter.new(filters, mask: FILTERED)
53
+ end
54
+ end
24
55
  end
25
56
  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
@@ -27,7 +31,7 @@ module TgErrorNotifier
27
31
  thread_id = nil
28
32
  suppressed_count = 0
29
33
 
30
- if config.topics_enabled || config.grouping_enabled
34
+ if config.topics_enabled || config.grouping_enabled || config.buttons
31
35
  key = grouper.grouping_key(exception)
32
36
  end
33
37
 
@@ -49,21 +53,30 @@ module TgErrorNotifier
49
53
  source: source,
50
54
  context: context,
51
55
  thread_id: thread_id,
52
- suppressed_count: suppressed_count
56
+ suppressed_count: suppressed_count,
57
+ fingerprint: key
53
58
  )
54
- send_payload(payload)
59
+ response = send_payload(payload)
60
+
61
+ if !response[:sent] && config.grouping_enabled && key
62
+ grouper.rollback(key)
63
+ end
64
+
65
+ response
55
66
  rescue StandardError => e
56
67
  log("notify failed: #{e.class}: #{e.message}")
57
68
  { sent: false, status: :failed, reason: e.class.name, error: e.message }
58
69
  end
59
70
 
60
- def notify_message(message:, level:, source:, context: {}, thread_id: nil)
71
+ def notify_message(message:, level:, source:, context: {}, topic: nil, thread_id: nil)
61
72
  enabled_check = enabled_status
62
73
  unless enabled_check[:enabled]
63
74
  log("skipped: #{enabled_check[:reason]}")
64
75
  return { sent: false, status: :skipped, reason: enabled_check[:reason] }
65
76
  end
66
77
 
78
+ thread_id ||= topic_manager.thread_id_for_name(topic) if topic
79
+
67
80
  payload = build_message_payload(
68
81
  message: message,
69
82
  level: level,
@@ -77,6 +90,27 @@ module TgErrorNotifier
77
90
  { sent: false, status: :failed, reason: e.class.name, error: e.message }
78
91
  end
79
92
 
93
+ # Replaces the inline keyboard of an already delivered notification —
94
+ # e.g. "To the board" becomes "Task #42 · In progress" once somebody picks
95
+ # the error up. Message ids come from the notify/capture_* result.
96
+ # buttons: rows as in config.buttons; nil or [] removes the keyboard.
97
+ #
98
+ # Returns { edited: true, status: :edited } or { edited: false, status: }
99
+ # where status is :not_modified (keyboard is already the same),
100
+ # :not_found (message was deleted), :rate_limited (with retry_after
101
+ # seconds), :skipped or :failed. Network errors are raised: unlike a
102
+ # notification, an edit is called by the host, which decides whether to retry.
103
+ def edit_buttons(chat_id:, message_id:, buttons:)
104
+ enabled_check = enabled_status
105
+ return { edited: false, status: :skipped, reason: enabled_check[:reason] } unless enabled_check[:enabled]
106
+
107
+ payload = { chat_id: chat_id, message_id: message_id }
108
+ keyboard = keyboard_from(buttons)
109
+ payload[:reply_markup] = keyboard if keyboard
110
+
111
+ edit_result(post_api("editMessageReplyMarkup", payload))
112
+ end
113
+
80
114
  private
81
115
 
82
116
  attr_reader :config
@@ -108,9 +142,44 @@ module TgErrorNotifier
108
142
  ignored.include?(exception.class.name)
109
143
  end
110
144
 
111
- def send_payload(payload)
145
+ def send_payload(payload, retry_plain: true)
146
+ response = post_api("sendMessage", payload)
147
+ if response.is_a?(Net::HTTPSuccess)
148
+ return { sent: true, status: :sent, code: response.code.to_i }.merge(sent_message_ids(response))
149
+ end
150
+
151
+ log("telegram api error: HTTP #{response.code} #{response.body}")
152
+
153
+ # Последний рубеж: разметка не распарсилась — шлём то же самое
154
+ # обычным текстом, чтобы тело сообщения дошло в любом случае
155
+ if retry_plain && parse_entities_error?(response)
156
+ plain = payload.dup
157
+ plain.delete(:parse_mode)
158
+ plain[:text] = truncate(strip_html(payload[:text]))
159
+ return send_payload(plain, retry_plain: false)
160
+ end
161
+
162
+ { sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
163
+ end
164
+
165
+ # Ids of the delivered message, so the host can edit it later
166
+ # (TgErrorNotifier.edit_buttons). A success response that cannot be read
167
+ # still means the message was delivered: report it as sent, without ids.
168
+ def sent_message_ids(response)
169
+ result = JSON.parse(response.body.to_s).fetch("result")
170
+ {
171
+ message_id: result.fetch("message_id"),
172
+ chat_id: result.fetch("chat").fetch("id"),
173
+ thread_id: result["message_thread_id"]
174
+ }
175
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError => e
176
+ log("sendMessage response without message ids: #{e.class}: #{e.message}")
177
+ {}
178
+ end
179
+
180
+ def post_api(method_name, payload)
112
181
  token = resolve(config.bot_token)
113
- uri = URI("#{resolve(config.api_base)}/bot#{token}/sendMessage")
182
+ uri = URI("#{resolve(config.api_base)}/bot#{token}/#{method_name}")
114
183
 
115
184
  request = Net::HTTP::Post.new(uri)
116
185
  request["Content-Type"] = "application/json"
@@ -125,21 +194,47 @@ module TgErrorNotifier
125
194
  http.open_timeout = config.open_timeout
126
195
  http.read_timeout = config.read_timeout
127
196
 
128
- response = http.request(request)
129
- if response.is_a?(Net::HTTPSuccess)
130
- return { sent: true, status: :sent, code: response.code.to_i }
197
+ http.request(request)
198
+ end
199
+
200
+ def edit_result(response)
201
+ return { edited: true, status: :edited } if response.is_a?(Net::HTTPSuccess)
202
+
203
+ body = response.body.to_s
204
+ description = begin
205
+ JSON.parse(body)["description"].to_s
206
+ rescue JSON::ParserError
207
+ body
131
208
  end
132
209
 
133
- log("telegram api error: HTTP #{response.code} #{response.body}")
134
- { sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
210
+ if response.code.to_i == 400 && description.include?("message is not modified")
211
+ { edited: false, status: :not_modified }
212
+ elsif response.code.to_i == 400 && description.include?("message to edit not found")
213
+ { edited: false, status: :not_found }
214
+ elsif response.code.to_i == 429
215
+ { edited: false, status: :rate_limited, retry_after: retry_after(body) }
216
+ else
217
+ log("telegram api error on edit: HTTP #{response.code} #{body}")
218
+ { edited: false, status: :failed, code: response.code.to_i, body: body }
219
+ end
220
+ end
221
+
222
+ def retry_after(body)
223
+ JSON.parse(body).dig("parameters", "retry_after")
224
+ rescue JSON::ParserError
225
+ nil
226
+ end
227
+
228
+ def parse_entities_error?(response)
229
+ response.code.to_i == 400 && response.body.to_s.include?("can't parse entities")
135
230
  end
136
231
 
137
- def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0)
232
+ def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0, fingerprint: nil)
138
233
  parts = [
139
234
  "<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
140
- "<b>Source:</b> #{escape(source.to_s)}",
235
+ "<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
141
236
  "<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
142
- "<b>Message:</b> #{escape(exception.message.to_s)}"
237
+ "<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
143
238
  ]
144
239
 
145
240
  if suppressed_count > 0
@@ -148,12 +243,19 @@ module TgErrorNotifier
148
243
 
149
244
  parts << context_block(context)
150
245
 
151
- text = parts.compact.join("\n")
246
+ text = truncate(parts.compact.join("\n"))
152
247
 
153
248
  if config.include_backtrace && exception.backtrace
154
249
  lines = exception.backtrace.first(config.max_backtrace_lines)
155
- bt = escape(lines.join("\n"))
156
- text = "#{text}\n<b>Backtrace:</b>\n<pre>#{bt}</pre>"
250
+ header = "\n<b>Backtrace:</b>\n"
251
+ # Бэктрейс режем по остатку бюджета ДО оборачивания в <pre>,
252
+ # иначе финальный truncate обрезал бы текст внутри тега и Telegram
253
+ # отвечал 400 "can't find end tag corresponding to start tag pre"
254
+ budget = MAX_MESSAGE_LENGTH - text.length - header.length - "<pre></pre>".length
255
+ if budget > 100
256
+ bt = cut_escaped(escape(lines.join("\n")), budget)
257
+ text = "#{text}#{header}<pre>#{bt}</pre>"
258
+ end
157
259
  end
158
260
 
159
261
  payload = {
@@ -163,13 +265,50 @@ module TgErrorNotifier
163
265
  disable_web_page_preview: true
164
266
  }
165
267
  payload[:message_thread_id] = thread_id if thread_id
268
+ keyboard = build_keyboard(
269
+ kind: :exception,
270
+ exception: exception,
271
+ source: source,
272
+ context: context,
273
+ fingerprint: fingerprint
274
+ )
275
+ payload[:reply_markup] = keyboard if keyboard
166
276
  payload
167
277
  end
168
278
 
279
+ # Кнопки строит хост (config.buttons) — гем не знает, куда они ведут.
280
+ # Сбой в колбэке не должен отменять доставку самого уведомления: об ошибке
281
+ # надо узнать в любом случае, кнопка — дополнение.
282
+ def build_keyboard(kind:, source:, context:, fingerprint:, exception: nil, message: nil)
283
+ return nil if config.buttons.nil?
284
+
285
+ rows = config.buttons.call(
286
+ kind: kind,
287
+ exception: exception,
288
+ message: message,
289
+ source: source,
290
+ context: context,
291
+ fingerprint: fingerprint
292
+ )
293
+ keyboard_from(rows)
294
+ rescue StandardError => e
295
+ log("buttons failed: #{e.class}: #{e.message}")
296
+ nil
297
+ end
298
+
299
+ def keyboard_from(rows)
300
+ return nil if rows.nil? || rows.empty?
301
+
302
+ # Плоский список кнопок принимаем как одну строку клавиатуры.
303
+ rows = [rows] unless rows.first.is_a?(Array)
304
+
305
+ { inline_keyboard: rows }
306
+ end
307
+
169
308
  def context_block(context)
170
309
  return nil if context.nil? || context.empty?
171
310
 
172
- formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v.to_s)}" }
311
+ formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
173
312
  formatted.join("\n")
174
313
  end
175
314
 
@@ -189,13 +328,50 @@ module TgErrorNotifier
189
328
  disable_web_page_preview: true
190
329
  }
191
330
  payload[:message_thread_id] = thread_id if thread_id
331
+ keyboard = build_keyboard(
332
+ kind: :message,
333
+ message: message,
334
+ source: source,
335
+ context: context,
336
+ fingerprint: nil
337
+ )
338
+ payload[:reply_markup] = keyboard if keyboard
192
339
  payload
193
340
  end
194
341
 
342
+ def clamp(value, limit)
343
+ text = value.to_s
344
+ text.length > limit ? "#{text[0...limit]}…" : text
345
+ end
346
+
347
+ # Обрезка уже экранированного текста: не оставляем хвост от «&amp;»
348
+ def cut_escaped(text, limit)
349
+ return text if text.length <= limit
350
+
351
+ text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
352
+ end
353
+
195
354
  def truncate(text)
196
355
  return text if text.length <= MAX_MESSAGE_LENGTH
197
356
 
198
- text[0...MAX_MESSAGE_LENGTH] + "\n...truncated"
357
+ close_tags(cut_escaped(text, MAX_MESSAGE_LENGTH - 20)) + "\n...truncated"
358
+ end
359
+
360
+ # Обрезка могла оставить незакрытый тег или его половину — Telegram
361
+ # на таком отвечает 400 и сообщение не доходит вовсе
362
+ def close_tags(text)
363
+ text = text.sub(/<[^>]*\z/, "")
364
+
365
+ HTML_TAGS.each do |tag|
366
+ unclosed = text.scan("<#{tag}>").size - text.scan("</#{tag}>").size
367
+ unclosed.times { text += "</#{tag}>" }
368
+ end
369
+
370
+ text
371
+ end
372
+
373
+ def strip_html(text)
374
+ CGI.unescapeHTML(text.to_s.gsub(/<[^>]*>/, ""))
199
375
  end
200
376
 
201
377
  def resolve(value)
@@ -2,27 +2,92 @@
2
2
 
3
3
  require "net/http"
4
4
  require "json"
5
+ require "set"
5
6
 
6
7
  module TgErrorNotifier
7
8
  class TopicManager
8
9
  ICON_COLOR_RED = 0xFB6F5F
10
+ ICON_COLOR_BLUE = 0x6FB9F0
9
11
  MAX_TOPIC_NAME = 128
12
+ MAX_STORE_KEY = 200
10
13
 
11
14
  def initialize(config)
12
15
  @config = config
13
16
  @mutex = Mutex.new
17
+ @condition = ConditionVariable.new
14
18
  @topics = {} # grouping_key => message_thread_id
19
+ @creating = Set.new
15
20
  end
16
21
 
17
22
  def thread_id_for(key, exception)
23
+ name = nil
24
+ store_key = "exception:#{key}"[0...MAX_STORE_KEY]
25
+
18
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
+
19
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)
20
44
  end
21
45
 
22
- thread_id = create_topic(topic_name(exception))
46
+ thread_id = create_topic(name)
23
47
 
24
- if thread_id
25
- @mutex.synchronize { @topics[key] = thread_id }
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
26
91
  end
27
92
 
28
93
  thread_id
@@ -30,13 +95,36 @@ module TgErrorNotifier
30
95
 
31
96
  private
32
97
 
33
- def topic_name(exception)
34
- name = "#{exception.class.name}: #{exception.message}"
35
- name = name.gsub(/\s+/, " ").strip
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
36
120
  name.length > MAX_TOPIC_NAME ? "#{name[0...MAX_TOPIC_NAME - 1]}…" : name
37
121
  end
38
122
 
39
- def create_topic(name)
123
+ def topic_name(exception)
124
+ truncate_name("#{exception.class.name}: #{exception.message}")
125
+ end
126
+
127
+ def create_topic(name, icon_color: nil)
40
128
  token = resolve(@config.bot_token)
41
129
  chat_id = resolve(@config.chat_id)
42
130
  uri = URI("#{resolve(@config.api_base)}/bot#{token}/createForumTopic")
@@ -44,7 +132,7 @@ module TgErrorNotifier
44
132
  payload = {
45
133
  chat_id: chat_id,
46
134
  name: name,
47
- icon_color: @config.topic_icon_color || ICON_COLOR_RED
135
+ icon_color: icon_color || @config.topic_icon_color || ICON_COLOR_RED
48
136
  }
49
137
 
50
138
  request = Net::HTTP::Post.new(uri)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TgErrorNotifier
4
- VERSION = "0.2.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -30,8 +30,16 @@ module TgErrorNotifier
30
30
  end
31
31
 
32
32
  # API similar to Sentry.capture_message("text")
33
- def capture_message(message, level: :info, source: "manual", context: {})
34
- 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
+ # Replace the buttons under a delivered notification. Ids come from the
40
+ # result of notify/capture_exception/capture_message (message_id, chat_id).
41
+ def edit_buttons(chat_id:, message_id:, buttons:)
42
+ notifier.edit_buttons(chat_id: chat_id, message_id: message_id, buttons: buttons)
35
43
  end
36
44
 
37
45
  def reset!
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.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei Ustinov