tg_error_notifier 0.4.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 +4 -4
- data/README.md +45 -0
- data/lib/tg_error_notifier/configuration.rb +12 -1
- data/lib/tg_error_notifier/middleware.rb +33 -2
- data/lib/tg_error_notifier/notifier.rb +89 -17
- data/lib/tg_error_notifier/version.rb +1 -1
- data/lib/tg_error_notifier.rb +6 -0
- 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: 71739ce66aa4580137b5c1781b0a6ae388713379297c26895883b77b4b8ba7a4
|
|
4
|
+
data.tar.gz: 0c0303ccbdc01d133029ee4ffcd842e7fc665223c804fd125bcdd82e1fb53761
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dd5dd9a02ec60173ff81eeb6b30f84831b351d9ac12f07d4d6ead4e1fb22eee0cadefcffc7acf85638ba4e4e3aa344873091e111bb9ddd2f228f67f1bb652ee
|
|
7
|
+
data.tar.gz: 53f7ffc99e4672c9bbde0b52897a7da2c05ae5327bf69fcdfcacdbe6fdfb06038b36696d5fe691f5b50091393d7a1f4458099d52cb53f1f19ec924f70c948141
|
data/README.md
CHANGED
|
@@ -158,6 +158,19 @@ end
|
|
|
158
158
|
|
|
159
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
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
|
+
|
|
161
174
|
## Buttons under the message
|
|
162
175
|
|
|
163
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.
|
|
@@ -185,3 +198,35 @@ Arguments:
|
|
|
185
198
|
| `fingerprint` | grouping key of the exception — stable across repeats, use it to deduplicate whatever the button creates (nil for `:message`) |
|
|
186
199
|
|
|
187
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.
|
|
@@ -26,7 +26,9 @@ module TgErrorNotifier
|
|
|
26
26
|
:topic_icon_color,
|
|
27
27
|
:topic_store_read,
|
|
28
28
|
:topic_store_write,
|
|
29
|
-
:buttons
|
|
29
|
+
:buttons,
|
|
30
|
+
:filter_parameters,
|
|
31
|
+
:sensitive_path_prefixes
|
|
30
32
|
|
|
31
33
|
def initialize
|
|
32
34
|
@enabled = true
|
|
@@ -74,6 +76,15 @@ module TgErrorNotifier
|
|
|
74
76
|
# Return nil or [] for no keyboard. Errors raised inside are logged and
|
|
75
77
|
# the message is still delivered, without the keyboard.
|
|
76
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 = []
|
|
77
88
|
end
|
|
78
89
|
|
|
79
90
|
def proxy?
|
|
@@ -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
|
|
@@ -90,6 +90,27 @@ module TgErrorNotifier
|
|
|
90
90
|
{ sent: false, status: :failed, reason: e.class.name, error: e.message }
|
|
91
91
|
end
|
|
92
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
|
+
|
|
93
114
|
private
|
|
94
115
|
|
|
95
116
|
attr_reader :config
|
|
@@ -122,8 +143,43 @@ module TgErrorNotifier
|
|
|
122
143
|
end
|
|
123
144
|
|
|
124
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)
|
|
125
181
|
token = resolve(config.bot_token)
|
|
126
|
-
uri = URI("#{resolve(config.api_base)}/bot#{token}
|
|
182
|
+
uri = URI("#{resolve(config.api_base)}/bot#{token}/#{method_name}")
|
|
127
183
|
|
|
128
184
|
request = Net::HTTP::Post.new(uri)
|
|
129
185
|
request["Content-Type"] = "application/json"
|
|
@@ -138,23 +194,35 @@ module TgErrorNotifier
|
|
|
138
194
|
http.open_timeout = config.open_timeout
|
|
139
195
|
http.read_timeout = config.read_timeout
|
|
140
196
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return { sent: true, status: :sent, code: response.code.to_i }
|
|
144
|
-
end
|
|
197
|
+
http.request(request)
|
|
198
|
+
end
|
|
145
199
|
|
|
146
|
-
|
|
200
|
+
def edit_result(response)
|
|
201
|
+
return { edited: true, status: :edited } if response.is_a?(Net::HTTPSuccess)
|
|
147
202
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
plain[:text] = truncate(strip_html(payload[:text]))
|
|
154
|
-
return send_payload(plain, retry_plain: false)
|
|
203
|
+
body = response.body.to_s
|
|
204
|
+
description = begin
|
|
205
|
+
JSON.parse(body)["description"].to_s
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
body
|
|
155
208
|
end
|
|
156
209
|
|
|
157
|
-
|
|
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
|
|
158
226
|
end
|
|
159
227
|
|
|
160
228
|
def parse_entities_error?(response)
|
|
@@ -222,15 +290,19 @@ module TgErrorNotifier
|
|
|
222
290
|
context: context,
|
|
223
291
|
fingerprint: fingerprint
|
|
224
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)
|
|
225
300
|
return nil if rows.nil? || rows.empty?
|
|
226
301
|
|
|
227
302
|
# Плоский список кнопок принимаем как одну строку клавиатуры.
|
|
228
303
|
rows = [rows] unless rows.first.is_a?(Array)
|
|
229
304
|
|
|
230
305
|
{ inline_keyboard: rows }
|
|
231
|
-
rescue StandardError => e
|
|
232
|
-
log("buttons failed: #{e.class}: #{e.message}")
|
|
233
|
-
nil
|
|
234
306
|
end
|
|
235
307
|
|
|
236
308
|
def context_block(context)
|
data/lib/tg_error_notifier.rb
CHANGED
|
@@ -36,6 +36,12 @@ module TgErrorNotifier
|
|
|
36
36
|
notifier.notify_message(message: message, level: level, source: source, context: context, topic: topic)
|
|
37
37
|
end
|
|
38
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)
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
def reset!
|
|
40
46
|
@configuration = nil
|
|
41
47
|
@notifier = nil
|