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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03b6d8e79dd258e7487a99925017dc62d9fd7d66255af5e80c62aecff7b6638b
4
- data.tar.gz: 062f6f0253004feb248c55e9cf2962244a7683c6d6771dac55f9a762a1c97bec
3
+ metadata.gz: 71739ce66aa4580137b5c1781b0a6ae388713379297c26895883b77b4b8ba7a4
4
+ data.tar.gz: 0c0303ccbdc01d133029ee4ffcd842e7fc665223c804fd125bcdd82e1fb53761
5
5
  SHA512:
6
- metadata.gz: 8caa97214339f4f0af2683a40f7d3a705a80fd0ecb9dd65f7a242d97efb38b2cab5e284b5b4f2d2cff3bb0121034190b09f8ad87793529937e89a2624bf1bc3c
7
- data.tar.gz: 8e546d58081bd0e50abae5bf375a7d9adac0ada19032fb44fbfe089fedb62fbaf55e0abe4b35489857716fa21352466c90224d36592faa43926d6fd578330b5b
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}/sendMessage")
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
- response = http.request(request)
142
- if response.is_a?(Net::HTTPSuccess)
143
- return { sent: true, status: :sent, code: response.code.to_i }
144
- end
197
+ http.request(request)
198
+ end
145
199
 
146
- log("telegram api error: HTTP #{response.code} #{response.body}")
200
+ def edit_result(response)
201
+ return { edited: true, status: :edited } if response.is_a?(Net::HTTPSuccess)
147
202
 
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)
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
- { 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
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TgErrorNotifier
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -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
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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei Ustinov