tg_error_notifier 0.2.0 → 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 +4 -4
- data/README.md +47 -0
- data/lib/tg_error_notifier/configuration.rb +23 -1
- data/lib/tg_error_notifier/grouper.rb +6 -0
- data/lib/tg_error_notifier/notifier.rb +117 -13
- data/lib/tg_error_notifier/topic_manager.rb +96 -8
- data/lib/tg_error_notifier/version.rb +1 -1
- data/lib/tg_error_notifier.rb +4 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03b6d8e79dd258e7487a99925017dc62d9fd7d66255af5e80c62aecff7b6638b
|
|
4
|
+
data.tar.gz: 062f6f0253004feb248c55e9cf2962244a7683c6d6771dac55f9a762a1c97bec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8caa97214339f4f0af2683a40f7d3a705a80fd0ecb9dd65f7a242d97efb38b2cab5e284b5b4f2d2cff3bb0121034190b09f8ad87793529937e89a2624bf1bc3c
|
|
7
|
+
data.tar.gz: 8e546d58081bd0e50abae5bf375a7d9adac0ada19032fb44fbfe089fedb62fbaf55e0abe4b35489857716fa21352466c90224d36592faa43926d6fd578330b5b
|
data/README.md
CHANGED
|
@@ -138,3 +138,50 @@ 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
|
+
## 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.
|
|
@@ -23,7 +23,10 @@ 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
|
|
27
30
|
|
|
28
31
|
def initialize
|
|
29
32
|
@enabled = true
|
|
@@ -52,6 +55,25 @@ module TgErrorNotifier
|
|
|
52
55
|
@grouping_window = 60
|
|
53
56
|
@topics_enabled = false
|
|
54
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
|
|
55
77
|
end
|
|
56
78
|
|
|
57
79
|
def proxy?
|
|
@@ -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,
|
|
@@ -108,7 +121,7 @@ module TgErrorNotifier
|
|
|
108
121
|
ignored.include?(exception.class.name)
|
|
109
122
|
end
|
|
110
123
|
|
|
111
|
-
def send_payload(payload)
|
|
124
|
+
def send_payload(payload, retry_plain: true)
|
|
112
125
|
token = resolve(config.bot_token)
|
|
113
126
|
uri = URI("#{resolve(config.api_base)}/bot#{token}/sendMessage")
|
|
114
127
|
|
|
@@ -131,15 +144,29 @@ module TgErrorNotifier
|
|
|
131
144
|
end
|
|
132
145
|
|
|
133
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
|
+
|
|
134
157
|
{ sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
def
|
|
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)
|
|
138
165
|
parts = [
|
|
139
166
|
"<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
140
|
-
"<b>Source:</b> #{escape(source
|
|
167
|
+
"<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
|
|
141
168
|
"<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
|
|
142
|
-
"<b>Message:</b> #{escape(exception.message
|
|
169
|
+
"<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
|
|
143
170
|
]
|
|
144
171
|
|
|
145
172
|
if suppressed_count > 0
|
|
@@ -148,12 +175,19 @@ module TgErrorNotifier
|
|
|
148
175
|
|
|
149
176
|
parts << context_block(context)
|
|
150
177
|
|
|
151
|
-
text = parts.compact.join("\n")
|
|
178
|
+
text = truncate(parts.compact.join("\n"))
|
|
152
179
|
|
|
153
180
|
if config.include_backtrace && exception.backtrace
|
|
154
181
|
lines = exception.backtrace.first(config.max_backtrace_lines)
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
157
191
|
end
|
|
158
192
|
|
|
159
193
|
payload = {
|
|
@@ -163,13 +197,46 @@ module TgErrorNotifier
|
|
|
163
197
|
disable_web_page_preview: true
|
|
164
198
|
}
|
|
165
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
|
|
166
208
|
payload
|
|
167
209
|
end
|
|
168
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
|
|
234
|
+
end
|
|
235
|
+
|
|
169
236
|
def context_block(context)
|
|
170
237
|
return nil if context.nil? || context.empty?
|
|
171
238
|
|
|
172
|
-
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v
|
|
239
|
+
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
|
|
173
240
|
formatted.join("\n")
|
|
174
241
|
end
|
|
175
242
|
|
|
@@ -189,13 +256,50 @@ module TgErrorNotifier
|
|
|
189
256
|
disable_web_page_preview: true
|
|
190
257
|
}
|
|
191
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
|
|
192
267
|
payload
|
|
193
268
|
end
|
|
194
269
|
|
|
270
|
+
def clamp(value, limit)
|
|
271
|
+
text = value.to_s
|
|
272
|
+
text.length > limit ? "#{text[0...limit]}…" : text
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Обрезка уже экранированного текста: не оставляем хвост от «&»
|
|
276
|
+
def cut_escaped(text, limit)
|
|
277
|
+
return text if text.length <= limit
|
|
278
|
+
|
|
279
|
+
text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
|
|
280
|
+
end
|
|
281
|
+
|
|
195
282
|
def truncate(text)
|
|
196
283
|
return text if text.length <= MAX_MESSAGE_LENGTH
|
|
197
284
|
|
|
198
|
-
text
|
|
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(/<[^>]*>/, ""))
|
|
199
303
|
end
|
|
200
304
|
|
|
201
305
|
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(
|
|
46
|
+
thread_id = create_topic(name)
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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)
|
data/lib/tg_error_notifier.rb
CHANGED
|
@@ -30,8 +30,10 @@ module TgErrorNotifier
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# API similar to Sentry.capture_message("text")
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def reset!
|