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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +23 -0
- data/context7.json +4 -0
- data/lib/telegrama/client.rb +55 -10
- data/lib/telegrama/configuration.rb +14 -0
- data/lib/telegrama/formatter.rb +50 -5
- data/lib/telegrama/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d31e0ac18148fac53c29577602d858f326781575376c6fd42c1e984fac1adc2
|
|
4
|
+
data.tar.gz: a41b7301c11863797a284bb4f1284d45e146f4c74126bd3b3e52d334945687d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/telegrama/client.rb
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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."
|
data/lib/telegrama/formatter.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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)
|
data/lib/telegrama/version.rb
CHANGED
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.
|
|
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-
|
|
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
|