telegrama 0.2.0 → 0.3.1

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: dc97c0b840468fc67d8be828329aa6b99df3f3a785c9b24ccdd5f508777ea4aa
4
- data.tar.gz: 3e68d1a677c64f60efdef48c93d14925ba16286388031b6585c0a328f7466f8b
3
+ metadata.gz: 1d31e0ac18148fac53c29577602d858f326781575376c6fd42c1e984fac1adc2
4
+ data.tar.gz: a41b7301c11863797a284bb4f1284d45e146f4c74126bd3b3e52d334945687d0
5
5
  SHA512:
6
- metadata.gz: 85b746d44608935c125176f96c36216963d196b7665d3f5ecbd5b0855fd168d7979ed17f772d4a64ad728cf2a935373a7009a1a9c982d7d5de8bae90b4271e62
7
- data.tar.gz: 3e0df23462571c09f43df2adaa38d68dcfd70476b87d02d5f7eb68a0163d180bc592f08869c092f3936922fea7542b0e8be2fe512ec485e7cef521e9f79f5d9b
6
+ metadata.gz: 9284e1584ca9874b51bc63ff968eb01822d9e5ac72fdfe36afaaf0fc995738e7c234f44dc52638216743f068819899fc596c38415efff64775ed21fa4e016f57
7
+ data.tar.gz: 1473e4fabcf750c0491f5a659b500190307f094158984d95b2f97a068ec9c90d60a85653af6efd2a0a6fbee706e48259bf2b61d0838872e094a7cc5b500a1567
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.1] - 2026-07-22
4
+
5
+ - Fixed MarkdownV2 escaping for `-` inside link text: the link-text escape
6
+ class was missing the hyphen, so a label like `[GPS-risk](url)` reached
7
+ Telegram unescaped — the API rejects the entire message ("Character '-' is
8
+ reserved and must be escaped") and delivery fell back to unformatted plain
9
+ text. The escape regex is now derived from `MARKDOWN_SPECIAL_CHARS` so the
10
+ two can never drift apart again.
11
+
12
+ ## [0.3.0] - 2026-05-22
13
+
14
+ - Added support for sending messages to Telegram forum topics with `message_thread_id`
15
+ - Added `config.message_thread_id` as an optional default forum topic for all messages
16
+ - Added per-message `message_thread_id:` overrides, including `message_thread_id: nil`
17
+ to bypass a configured default topic for one send
18
+ - Preserved `message_thread_id` through async ActiveJob delivery, string-key serialized
19
+ options, and Markdown-to-HTML-to-plain-text fallback retries
20
+ - Added validation so `message_thread_id` must be a positive integer when present
21
+ - Fixed `parse_mode: nil` to omit the `parse_mode` parameter from Telegram API
22
+ requests instead of sending `null`
23
+ - Fixed plain-text sends so `parse_mode: nil` does not inherit MarkdownV2 or HTML
24
+ escaping from configured formatting defaults
25
+ - Fixed HTML sends so they do not inherit MarkdownV2 escaping from configured
26
+ formatting defaults
27
+ - Fixed MarkdownV2 escaping for underscores inside identifiers such as `is_bot`,
28
+ `message_thread_id`, and `send_message`
29
+
1
30
  ## [0.2.0] - 2026-01-17
2
31
 
3
32
  - Added Minitest test suite
data/README.md CHANGED
@@ -53,6 +53,12 @@ Telegrama.send_message(a_general_message, chat_id: general_chat_id)
53
53
  Telegrama.send_message(marketing_message, chat_id: marketing_chat_id)
54
54
  ```
55
55
 
56
+ You can also send messages to a specific topic inside a forum-enabled Telegram group:
57
+
58
+ ```ruby
59
+ Telegrama.send_message(deploy_message, chat_id: engineering_group_id, message_thread_id: deployments_topic_id)
60
+ ```
61
+
56
62
  The goal with this gem is to provide a straightforward, no-frills, minimal API to send Telegram messages reliably for admin purposes, without you having to write your own wrapper over the Telegram API.
57
63
 
58
64
  ## Quick start
@@ -75,6 +81,7 @@ Then, create an initializer file under `config/initializers/telegrama.rb` and se
75
81
  Telegrama.configure do |config|
76
82
  config.bot_token = Rails.application.credentials.dig(Rails.env.to_sym, :telegram, :bot_token)
77
83
  config.chat_id = Rails.application.credentials.dig(Rails.env.to_sym, :telegram, :chat_id)
84
+ config.message_thread_id = nil # Optional default Telegram forum topic ID
78
85
  config.default_parse_mode = 'MarkdownV2'
79
86
 
80
87
  # Optional prefix/suffix for all messages (useful to identify messages from different apps or environments)
@@ -163,6 +170,22 @@ Both `message_prefix` and `message_suffix` are optional and can be used independ
163
170
  Telegrama.send_message("Hello, alternate group!", chat_id: alternate_chat_id)
164
171
  ```
165
172
 
173
+ - **`message_thread_id`**
174
+ *Send to a specific Telegram forum topic inside a group. Telegram's Bot API calls topic IDs `message_thread_id` values.*
175
+ **Usage Example:**
176
+ ```ruby
177
+ Telegrama.send_message("Deploy finished!", chat_id: engineering_group_id, message_thread_id: deployments_topic_id)
178
+ ```
179
+
180
+ You can get this ID from an incoming Telegram update sent inside the topic, or from the `ForumTopic` returned by Telegram's `createForumTopic` API.
181
+
182
+ You can also configure a default topic:
183
+ ```ruby
184
+ config.message_thread_id = deployments_topic_id
185
+ ```
186
+
187
+ To bypass a configured default topic for one message, pass `message_thread_id: nil`.
188
+
166
189
  - **`parse_mode`**
167
190
  *Override the default parse mode (default is `"MarkdownV2"`).*
168
191
  **Usage Example:**
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/telegrama",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
@@ -13,9 +13,20 @@ module Telegrama
13
13
 
14
14
  # Send a message with built-in error handling and fallbacks
15
15
  def send_message(message, options = {})
16
+ options = normalize_option_keys(options)
17
+
16
18
  # Allow chat ID override; fallback to config default
17
19
  chat_id = options.delete(:chat_id) || Telegrama.configuration.chat_id
18
20
 
21
+ # Allow topic/thread override; fallback to config default.
22
+ # Explicit nil means "send to the chat normally", even when a default topic is configured.
23
+ message_thread_id = if options.key?(:message_thread_id)
24
+ options.delete(:message_thread_id)
25
+ else
26
+ Telegrama.configuration.message_thread_id
27
+ end
28
+ validate_message_thread_id!(message_thread_id)
29
+
19
30
  # Get client options from config
20
31
  client_opts = Telegrama.configuration.client_options || {}
21
32
  client_opts = client_opts.merge(@config)
@@ -24,15 +35,11 @@ module Telegrama
24
35
  # Use key? to allow explicit nil override (for plain text without formatting)
25
36
  parse_mode = options.key?(:parse_mode) ? options[:parse_mode] : Telegrama.configuration.default_parse_mode
26
37
 
27
- # Allow runtime formatting options, merging with configured defaults
28
- formatting_opts = options.delete(:formatting) || {}
29
-
30
- # Add parse mode specific options
31
- if parse_mode == 'MarkdownV2'
32
- formatting_opts[:escape_markdown] = true unless formatting_opts.key?(:escape_markdown)
33
- elsif parse_mode == 'HTML'
34
- formatting_opts[:escape_html] = true unless formatting_opts.key?(:escape_html)
35
- end
38
+ # Allow runtime formatting options, merging with configured defaults.
39
+ # Escape behavior must match the outgoing Telegram parse mode; otherwise
40
+ # plain-text sends can display MarkdownV2 escape characters literally.
41
+ formatting_opts = normalize_option_keys(options.delete(:formatting) || {})
42
+ formatting_opts = formatting_options_for_parse_mode(parse_mode, formatting_opts)
36
43
 
37
44
  # Format the message text with our formatter
38
45
  formatted_message = Formatter.format(message, formatting_opts)
@@ -46,10 +53,11 @@ module Telegrama
46
53
  payload = {
47
54
  chat_id: chat_id,
48
55
  text: formatted_message,
49
- parse_mode: parse_mode,
50
56
  disable_web_page_preview: options.fetch(:disable_web_page_preview,
51
57
  Telegrama.configuration.disable_web_page_preview)
52
58
  }
59
+ payload[:parse_mode] = parse_mode unless parse_mode.nil?
60
+ payload[:message_thread_id] = message_thread_id unless message_thread_id.nil?
53
61
 
54
62
  # Additional options such as reply_markup can be added here
55
63
  payload.merge!(options.select { |k, _| [:reply_markup, :reply_to_message_id].include?(k) })
@@ -115,6 +123,43 @@ module Telegrama
115
123
 
116
124
  private
117
125
 
126
+ def normalize_option_keys(options)
127
+ return {} if options.nil?
128
+
129
+ options.to_h.each_with_object({}) do |(key, value), normalized|
130
+ normalized[key.respond_to?(:to_sym) ? key.to_sym : key] = value
131
+ end
132
+ end
133
+
134
+ def validate_message_thread_id!(message_thread_id)
135
+ return if message_thread_id.nil?
136
+
137
+ unless message_thread_id.is_a?(Integer) && message_thread_id.positive?
138
+ raise ArgumentError, "Telegrama send_message option error: message_thread_id must be a positive integer."
139
+ end
140
+ end
141
+
142
+ def formatting_options_for_parse_mode(parse_mode, formatting_opts)
143
+ case parse_mode
144
+ when 'MarkdownV2'
145
+ {
146
+ escape_html: false
147
+ }.merge(formatting_opts).tap do |opts|
148
+ opts[:escape_markdown] = true unless opts.key?(:escape_markdown)
149
+ end
150
+ when 'HTML'
151
+ {
152
+ escape_markdown: false
153
+ }.merge(formatting_opts).tap do |opts|
154
+ opts[:escape_html] = true unless opts.key?(:escape_html)
155
+ end
156
+ when nil
157
+ formatting_opts.merge(escape_markdown: false, escape_html: false)
158
+ else
159
+ formatting_opts
160
+ end
161
+ end
162
+
118
163
  def perform_request(payload, options = {})
119
164
  uri = URI("https://api.telegram.org/bot#{Telegrama.configuration.bot_token}/sendMessage")
120
165
  request = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
@@ -10,6 +10,10 @@ module Telegrama
10
10
  # You can override this on the fly when sending messages.
11
11
  attr_accessor :chat_id
12
12
 
13
+ # Default message thread ID for sending messages to a forum topic.
14
+ # You can override this on the fly when sending messages.
15
+ attr_accessor :message_thread_id
16
+
13
17
  # Default parse mode for messages (e.g. "MarkdownV2" or "HTML").
14
18
  attr_accessor :default_parse_mode
15
19
 
@@ -57,6 +61,7 @@ module Telegrama
57
61
  # Credentials (must be set via initializer)
58
62
  @bot_token = nil
59
63
  @chat_id = nil
64
+ @message_thread_id = nil
60
65
 
61
66
  # Defaults for message formatting
62
67
  @default_parse_mode = 'MarkdownV2'
@@ -90,6 +95,7 @@ module Telegrama
90
95
  def validate!
91
96
  validate_bot_token!
92
97
  validate_default_parse_mode!
98
+ validate_message_thread_id!
93
99
  validate_formatting_options!
94
100
  validate_client_options!
95
101
  true
@@ -110,6 +116,14 @@ module Telegrama
110
116
  end
111
117
  end
112
118
 
119
+ def validate_message_thread_id!
120
+ return if message_thread_id.nil?
121
+
122
+ unless message_thread_id.is_a?(Integer) && message_thread_id.positive?
123
+ raise ArgumentError, "Telegrama configuration error: message_thread_id must be a positive integer."
124
+ end
125
+ end
126
+
113
127
  def validate_formatting_options!
114
128
  unless formatting_options.is_a?(Hash)
115
129
  raise ArgumentError, "Telegrama configuration error: formatting_options must be a hash."
@@ -7,6 +7,15 @@ module Telegrama
7
7
  # Characters used for Markdown formatting that need special handling
8
8
  MARKDOWN_FORMAT_CHARS = %w[* _].freeze
9
9
 
10
+ # Escapes every MarkdownV2-reserved character (plus backslash) inside link
11
+ # TEXT. Derived from MARKDOWN_SPECIAL_CHARS so the two can never drift: a
12
+ # hand-typed copy of this class was missing '-', which let hyphenated link
13
+ # labels ("[GPS-risk](url)") reach Telegram unescaped — and the API rejects
14
+ # the ENTIRE message, not just the link, so delivery fell back to raw
15
+ # unformatted text. (URL parts keep their own narrower class below: per the
16
+ # MarkdownV2 spec, inside the (...) part only ')' and '\' MUST be escaped.)
17
+ LINK_TEXT_ESCAPE_REGEX = /([#{Regexp.escape((MARKDOWN_SPECIAL_CHARS + [ "\\" ]).join)}])/
18
+
10
19
  # Error class for Markdown formatting issues
11
20
  class MarkdownError < StandardError; end
12
21
 
@@ -100,7 +109,7 @@ module Telegrama
100
109
  url_part = $2
101
110
 
102
111
  # Handle escaping within link text
103
- text_part = text_part.gsub(/([_*\[\]()~`>#+=|{}.!\\])/) { |m| "\\#{m}" }
112
+ text_part = text_part.gsub(LINK_TEXT_ESCAPE_REGEX) { |m| "\\#{m}" }
104
113
 
105
114
  # Escape special characters in URL (except parentheses which define URL boundaries)
106
115
  url_part = url_part.gsub(/([_*\[\]~`>#+=|{}.!\\])/) { |m| "\\#{m}" }
@@ -179,8 +188,12 @@ module Telegrama
179
188
  @result += '*'
180
189
  advance
181
190
  elsif char == '_' && !escaped?
182
- enter_state(:italic)
183
- @result += '_'
191
+ if italic_opening_delimiter?
192
+ enter_state(:italic)
193
+ @result += '_'
194
+ else
195
+ @result += '\\_'
196
+ end
184
197
  advance
185
198
  elsif char == '[' && !escaped?
186
199
  if looking_at_markdown_link?
@@ -256,8 +269,12 @@ module Telegrama
256
269
  char = current_char
257
270
 
258
271
  if char == '_' && !escaped?
259
- exit_state
260
- @result += '_'
272
+ if italic_closing_delimiter?
273
+ exit_state
274
+ @result += '_'
275
+ else
276
+ @result += '\\_'
277
+ end
261
278
  advance
262
279
  elsif char == '\\' && !escaped?
263
280
  handle_escape_sequence
@@ -389,6 +406,34 @@ module Telegrama
389
406
  @chars[@position + 1]
390
407
  end
391
408
 
409
+ # Get the previous character
410
+ def previous_char
411
+ @position.positive? ? @chars[@position - 1] : nil
412
+ end
413
+
414
+ # Underscores inside identifiers should render literally, not start italics.
415
+ def italic_opening_delimiter?
416
+ following = next_char
417
+ return false if following.nil? || whitespace?(following)
418
+
419
+ !word_char?(previous_char)
420
+ end
421
+
422
+ def italic_closing_delimiter?
423
+ previous = previous_char
424
+ return false if previous.nil? || whitespace?(previous)
425
+
426
+ !word_char?(next_char)
427
+ end
428
+
429
+ def word_char?(char)
430
+ !!(char =~ /[[:alnum:]]/)
431
+ end
432
+
433
+ def whitespace?(char)
434
+ !!(char =~ /\s/)
435
+ end
436
+
392
437
  # Check if next character is one of the given characters
393
438
  def next_char_is?(*chars)
394
439
  has_chars_ahead?(1) && chars.include?(next_char)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Telegrama
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: telegrama
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Javi R
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-17 00:00:00.000000000 Z
10
+ date: 2026-07-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -56,6 +56,7 @@ files:
56
56
  - LICENSE.txt
57
57
  - README.md
58
58
  - Rakefile
59
+ - context7.json
59
60
  - gemfiles/rails_7.2.gemfile
60
61
  - gemfiles/rails_8.0.gemfile
61
62
  - gemfiles/rails_8.1.gemfile