telegrama 0.2.0 → 0.3.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: dc97c0b840468fc67d8be828329aa6b99df3f3a785c9b24ccdd5f508777ea4aa
4
- data.tar.gz: 3e68d1a677c64f60efdef48c93d14925ba16286388031b6585c0a328f7466f8b
3
+ metadata.gz: 3ae6c6b2bb6c3da545107d0f91b63e490fbd6e7e5a6e6be633291346c9f6ebcb
4
+ data.tar.gz: 42b7590b77f6497a8b972a97a1bb8cd371f89bf5db322bcd779b7d0a7ae9a331
5
5
  SHA512:
6
- metadata.gz: 85b746d44608935c125176f96c36216963d196b7665d3f5ecbd5b0855fd168d7979ed17f772d4a64ad728cf2a935373a7009a1a9c982d7d5de8bae90b4271e62
7
- data.tar.gz: 3e0df23462571c09f43df2adaa38d68dcfd70476b87d02d5f7eb68a0163d180bc592f08869c092f3936922fea7542b0e8be2fe512ec485e7cef521e9f79f5d9b
6
+ metadata.gz: 596ad2ca19bb16c3c50d83f0ee7891e4a0ffe44218f34de187497ddb23a58862695564183402abe3b13e290801dc3bcaf033e00330e26b5faf5da2d97eb6e3bd
7
+ data.tar.gz: 810fe478ac180741ca83d2210eb179776ad815e647297cb037ad1d33a49cf0a81630bc27ac11a4393309ef4be23b23f330f0b75754c60cacaf37b78668adb658
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.0] - 2026-05-22
4
+
5
+ - Added support for sending messages to Telegram forum topics with `message_thread_id`
6
+ - Added `config.message_thread_id` as an optional default forum topic for all messages
7
+ - Added per-message `message_thread_id:` overrides, including `message_thread_id: nil`
8
+ to bypass a configured default topic for one send
9
+ - Preserved `message_thread_id` through async ActiveJob delivery, string-key serialized
10
+ options, and Markdown-to-HTML-to-plain-text fallback retries
11
+ - Added validation so `message_thread_id` must be a positive integer when present
12
+ - Fixed `parse_mode: nil` to omit the `parse_mode` parameter from Telegram API
13
+ requests instead of sending `null`
14
+ - Fixed plain-text sends so `parse_mode: nil` does not inherit MarkdownV2 or HTML
15
+ escaping from configured formatting defaults
16
+ - Fixed HTML sends so they do not inherit MarkdownV2 escaping from configured
17
+ formatting defaults
18
+ - Fixed MarkdownV2 escaping for underscores inside identifiers such as `is_bot`,
19
+ `message_thread_id`, and `send_message`
20
+
1
21
  ## [0.2.0] - 2026-01-17
2
22
 
3
23
  - 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:**
@@ -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."
@@ -179,8 +179,12 @@ module Telegrama
179
179
  @result += '*'
180
180
  advance
181
181
  elsif char == '_' && !escaped?
182
- enter_state(:italic)
183
- @result += '_'
182
+ if italic_opening_delimiter?
183
+ enter_state(:italic)
184
+ @result += '_'
185
+ else
186
+ @result += '\\_'
187
+ end
184
188
  advance
185
189
  elsif char == '[' && !escaped?
186
190
  if looking_at_markdown_link?
@@ -256,8 +260,12 @@ module Telegrama
256
260
  char = current_char
257
261
 
258
262
  if char == '_' && !escaped?
259
- exit_state
260
- @result += '_'
263
+ if italic_closing_delimiter?
264
+ exit_state
265
+ @result += '_'
266
+ else
267
+ @result += '\\_'
268
+ end
261
269
  advance
262
270
  elsif char == '\\' && !escaped?
263
271
  handle_escape_sequence
@@ -389,6 +397,34 @@ module Telegrama
389
397
  @chars[@position + 1]
390
398
  end
391
399
 
400
+ # Get the previous character
401
+ def previous_char
402
+ @position.positive? ? @chars[@position - 1] : nil
403
+ end
404
+
405
+ # Underscores inside identifiers should render literally, not start italics.
406
+ def italic_opening_delimiter?
407
+ following = next_char
408
+ return false if following.nil? || whitespace?(following)
409
+
410
+ !word_char?(previous_char)
411
+ end
412
+
413
+ def italic_closing_delimiter?
414
+ previous = previous_char
415
+ return false if previous.nil? || whitespace?(previous)
416
+
417
+ !word_char?(next_char)
418
+ end
419
+
420
+ def word_char?(char)
421
+ !!(char =~ /[[:alnum:]]/)
422
+ end
423
+
424
+ def whitespace?(char)
425
+ !!(char =~ /\s/)
426
+ end
427
+
392
428
  # Check if next character is one of the given characters
393
429
  def next_char_is?(*chars)
394
430
  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.0"
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.0
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-05-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails