telegrama 0.3.0 → 0.4.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: 3ae6c6b2bb6c3da545107d0f91b63e490fbd6e7e5a6e6be633291346c9f6ebcb
4
- data.tar.gz: 42b7590b77f6497a8b972a97a1bb8cd371f89bf5db322bcd779b7d0a7ae9a331
3
+ metadata.gz: c449a525419c3f57a7349b9bd75427be2df8b8b685650aada9ed6cfae31d832b
4
+ data.tar.gz: fb840dd179f5ff1f1a06b0e1e210bffb1894470b4fe1d596ad5a8e71de7aab98
5
5
  SHA512:
6
- metadata.gz: 596ad2ca19bb16c3c50d83f0ee7891e4a0ffe44218f34de187497ddb23a58862695564183402abe3b13e290801dc3bcaf033e00330e26b5faf5da2d97eb6e3bd
7
- data.tar.gz: 810fe478ac180741ca83d2210eb179776ad815e647297cb037ad1d33a49cf0a81630bc27ac11a4393309ef4be23b23f330f0b75754c60cacaf37b78668adb658
6
+ metadata.gz: c39993629b42851f17e4edbcbc8731be446c920e5e58e4d66ae0b21aedabce7e9879def47f78e0008b0c5304365762d2c18358ed949ee3b27fa1e9efb07d2c05
7
+ data.tar.gz: 792c991f16affef2d5374e56ad788faba23d50d05ba08f7778be95761a4e337bfa655c949fcfc97fa937cc912750d31f89e32b0789daf09a8c5b242286f35e89
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-09-18
4
+
5
+ - Added `Telegrama::InlineKeyboard`, a builder for the buttons that hang under
6
+ a message: `Telegrama::InlineKeyboard.url("Open" => admin_url)` for the
7
+ common single-button case, or `.row(url: {...}).row(callback: {...})` chained
8
+ for several rows. Pass it straight to the existing `reply_markup:` option.
9
+ - Validates at the call site what Telegram would otherwise reject wholesale —
10
+ a malformed keyboard fails the ENTIRE sendMessage rather than degrading to a
11
+ message without buttons: `callback_data` over its 1-64 BYTE limit (bytes, not
12
+ characters, so one emoji costs four), relative URLs (the Rails `_path` vs
13
+ `_url` trap), blank labels, blank URLs, empty rows and empty keyboards.
14
+ - Documented `reply_markup:` and `reply_to_message_id:` in the README. Both
15
+ were already forwarded to the Telegram API but had never been documented or
16
+ covered by tests; hand-built `reply_markup` hashes keep working unchanged.
17
+ - Note on scope: `callback:` buttons build correctly, but RECEIVING a tap needs
18
+ a webhook or long polling handling `callback_query` updates, which this gem
19
+ deliberately does not do. It stays a send-only library.
20
+
21
+ ## [0.3.1] - 2026-07-22
22
+
23
+ - Fixed MarkdownV2 escaping for `-` inside link text: the link-text escape
24
+ class was missing the hyphen, so a label like `[GPS-risk](url)` reached
25
+ Telegram unescaped — the API rejects the entire message ("Character '-' is
26
+ reserved and must be escaped") and delivery fell back to unformatted plain
27
+ text. The escape regex is now derived from `MARKDOWN_SPECIAL_CHARS` so the
28
+ two can never drift apart again.
29
+
3
30
  ## [0.3.0] - 2026-05-22
4
31
 
5
32
  - Added support for sending messages to Telegram forum topics with `message_thread_id`
data/README.md CHANGED
@@ -212,6 +212,20 @@ Both `message_prefix` and `message_suffix` are optional and can be used independ
212
212
  Telegrama.send_message("Contact: john.doe@example.com", formatting: { obfuscate_emails: true })
213
213
  ```
214
214
 
215
+ - **`reply_markup`**
216
+ *Buttons under the message. See [Inline keyboards](#inline-keyboards) below.*
217
+ **Usage Example:**
218
+ ```ruby
219
+ Telegrama.send_message("Retirada retenida", reply_markup: Telegrama::InlineKeyboard.url("Open" => admin_url))
220
+ ```
221
+
222
+ - **`reply_to_message_id`**
223
+ *Make this message a reply to an earlier one in the same chat.*
224
+ **Usage Example:**
225
+ ```ruby
226
+ Telegrama.send_message("Resolved ✅", reply_to_message_id: alert_message_id)
227
+ ```
228
+
215
229
  - **`client_options`**
216
230
  *A hash that overrides the default HTTP client options for this specific request.*
217
231
  - `timeout` (Integer): Request timeout in seconds.
@@ -223,6 +237,62 @@ Both `message_prefix` and `message_suffix` are optional and can be used independ
223
237
  Telegrama.send_message("URGENT: Server alert!", client_options: { timeout: 5, retry_count: 5 })
224
238
  ```
225
239
 
240
+ ### Inline keyboards
241
+
242
+ Hang buttons under a message, so the person reading the alert at 2am can act on it with one tap instead of hunting for the right admin page:
243
+
244
+ ```ruby
245
+ Telegrama.send_message(
246
+ "Withdrawal held — needs a human",
247
+ reply_markup: Telegrama::InlineKeyboard.url("Open the withdrawal" => admin_withdrawal_url)
248
+ )
249
+ ```
250
+
251
+ Several rows — each `row` is one line of buttons on the phone:
252
+
253
+ ```ruby
254
+ keyboard = Telegrama::InlineKeyboard.new
255
+ .row(url: { "Open the withdrawal" => withdrawal_url })
256
+ .row(url: { "Payout queue" => queue_url, "User profile" => user_url })
257
+
258
+ Telegrama.send_message("Withdrawal held — needs a human", reply_markup: keyboard)
259
+ ```
260
+
261
+ There are two kinds of button, and the difference matters:
262
+
263
+ | | What it does | What you need |
264
+ |---|---|---|
265
+ | `url:` | Opens a link | Nothing — works with a send-only bot, which is what this gem is |
266
+ | `callback:` | Pings your bot with the data you set | A webhook (or long polling) handling `callback_query` updates, which this gem does **not** provide |
267
+
268
+ `callback:` builds a correct button either way, but without a receiver the tap just spins:
269
+
270
+ ```ruby
271
+ Telegrama::InlineKeyboard.callback("👀 I'm on it" => "claim:#{alert.id}")
272
+ ```
273
+
274
+ `callback_data` is limited by Telegram to **1–64 bytes** — bytes, not characters, so a single emoji costs four. It also travels back from the client, so treat it as untrusted: store the real payload server-side and put an opaque key in the button.
275
+
276
+ Build conditionally? Guard with `empty?`:
277
+
278
+ ```ruby
279
+ Telegrama.send_message(text, reply_markup: (keyboard unless keyboard.empty?))
280
+ ```
281
+
282
+ **Why a builder, when `reply_markup:` already accepts a plain Hash** (and still does — hand-built markup keeps working): a malformed keyboard does not degrade into a message without buttons. Telegram rejects the *entire* `sendMessage`, so the alert nobody could act on is also the alert nobody received. The builder turns those silent, total failures into an `ArgumentError` at the call site, naming the value that is wrong:
283
+
284
+ ```ruby
285
+ Telegrama::InlineKeyboard.callback("Ok" => "🚨" * 33)
286
+ # => ArgumentError: callback_data for button "Ok" is 132 bytes, over Telegram's 64-byte limit.
287
+ # (Emoji cost 4 bytes each — store the payload server-side and put an opaque key here.)
288
+
289
+ Telegrama::InlineKeyboard.url("Open" => admin_withdrawal_path(w))
290
+ # => ArgumentError: url button "Open" needs an absolute http(s):// or tg:// URL,
291
+ # got "/admin/withdrawals/42". (A Rails `_path` helper is a relative path — use `_url`.)
292
+ ```
293
+
294
+ Blank labels, blank URLs, empty rows and empty keyboards are refused the same way.
295
+
226
296
  ### Asynchronous message delivery
227
297
 
228
298
  For production environments or high-traffic applications, you might want to offload message delivery to a background job. Our gem supports asynchronous delivery via ActiveJob.
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/telegrama",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
@@ -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}" }
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Telegrama
6
+ # Inline keyboards: the buttons that hang under a message.
7
+ #
8
+ # Telegrama.send_message(
9
+ # "Retirada retenida — necesita una persona",
10
+ # reply_markup: Telegrama::InlineKeyboard.url("Abrir la retirada" => withdrawal_url)
11
+ # )
12
+ #
13
+ # Several rows, chained — each `row` is one line of buttons on the phone:
14
+ #
15
+ # keyboard = Telegrama::InlineKeyboard.new
16
+ # .row(url: { "Abrir la retirada" => withdrawal_url })
17
+ # .row(url: { "Cola de pagos" => queue_url, "Ficha" => user_url })
18
+ # .row(callback: { "👀 Lo veo yo" => "claim:#{id}" })
19
+ #
20
+ # Two kinds of button, and the difference matters:
21
+ #
22
+ # • `url:` opens a link. Nothing else is needed — this works with a
23
+ # send-only bot, which is what this gem is.
24
+ # • `callback:` pings your bot with the given data when tapped. To RECEIVE
25
+ # that tap you need a webhook (or long polling) handling
26
+ # `callback_query` updates, which this gem deliberately does
27
+ # not do. The button is built correctly either way; without a
28
+ # receiver the tap simply spins and does nothing.
29
+ #
30
+ # Why a builder at all, when `reply_markup:` already accepts a plain Hash:
31
+ # a malformed keyboard does not degrade into a message without buttons.
32
+ # Telegram rejects the ENTIRE sendMessage, so the alert nobody could act on
33
+ # is also the alert nobody received. Every check below turns one of those
34
+ # silent, total failures into an ArgumentError at the call site, naming the
35
+ # value that is wrong. Hand-built Hashes keep working exactly as before;
36
+ # this is sugar, never a gate.
37
+ class InlineKeyboard
38
+ # Bot API: "Data to be sent back to the bot when the button is pressed,
39
+ # 1-64 bytes". BYTES, not characters — one emoji is four of them, which is
40
+ # how a label-ish callback payload silently blows the limit.
41
+ # https://core.telegram.org/bots/api#inlinekeyboardbutton
42
+ CALLBACK_DATA_MAX_BYTES = 64
43
+
44
+ # Bot API: "HTTP or tg:// URL to be opened when the button is pressed".
45
+ # A Rails `_path` helper instead of `_url` is the classic way to get this
46
+ # wrong, and it is invisible until Telegram refuses the message.
47
+ URL_SCHEMES = %r{\A(?:https?|tg)://}i
48
+
49
+ def initialize
50
+ @rows = []
51
+ end
52
+
53
+ class << self
54
+ # One row, one kind, for the overwhelmingly common single-button case.
55
+ #
56
+ # Telegrama::InlineKeyboard.url("Abrir" => "https://example.com")
57
+ # Telegrama::InlineKeyboard.callback("Lo veo yo" => "claim:42")
58
+ def url(buttons) = new.row(url: buttons)
59
+ def callback(buttons) = new.row(callback: buttons)
60
+ end
61
+
62
+ # One line of buttons. `url:` and `callback:` are both `{ label => value }`
63
+ # hashes; within a row, url buttons render first, then callback ones, each
64
+ # in its own hash order. Mix kinds freely — or call `row` twice when the
65
+ # exact interleaving matters.
66
+ def row(url: {}, callback: {})
67
+ buttons = url.map { |label, target| url_button(label, target) } +
68
+ callback.map { |label, data| callback_button(label, data) }
69
+
70
+ if buttons.empty?
71
+ raise ArgumentError,
72
+ "Telegrama::InlineKeyboard#row needs at least one button — Telegram rejects an empty row."
73
+ end
74
+
75
+ @rows << buttons
76
+ self
77
+ end
78
+
79
+ # True when nothing has been added yet. Build conditionally, then guard:
80
+ #
81
+ # send_message(text, reply_markup: keyboard unless keyboard.empty?)
82
+ def empty? = @rows.empty?
83
+
84
+ def to_h
85
+ if empty?
86
+ raise ArgumentError,
87
+ "Telegrama::InlineKeyboard is empty — Telegram rejects a keyboard with no buttons. " \
88
+ "Add a row, or guard the send with `empty?`."
89
+ end
90
+
91
+ { inline_keyboard: @rows }
92
+ end
93
+
94
+ alias_method :as_json, :to_h
95
+
96
+ # Client#perform_request nests this under `reply_markup` and dumps the
97
+ # WHOLE payload in one `to_json` call, so the object has to serialize
98
+ # itself or it lands as "#<Telegrama::InlineKeyboard…>".
99
+ def to_json(*args) = to_h.to_json(*args)
100
+
101
+ private
102
+
103
+ def url_button(label, target)
104
+ target = target.to_s
105
+ unless target.match?(URL_SCHEMES)
106
+ raise ArgumentError,
107
+ "Telegrama::InlineKeyboard url button #{label.inspect} needs an absolute http(s):// or tg:// " \
108
+ "URL, got #{target.inspect}. (A Rails `_path` helper is a relative path — use `_url`.)"
109
+ end
110
+
111
+ { text: presence!(label, "button label"), url: target }
112
+ end
113
+
114
+ def callback_button(label, data)
115
+ data = presence!(data, "callback_data for button #{label.inspect}")
116
+ if data.bytesize > CALLBACK_DATA_MAX_BYTES
117
+ raise ArgumentError,
118
+ "Telegrama::InlineKeyboard callback_data for button #{label.inspect} is #{data.bytesize} bytes, " \
119
+ "over Telegram's #{CALLBACK_DATA_MAX_BYTES}-byte limit. (Emoji cost 4 bytes each — store the " \
120
+ "payload server-side and put an opaque key here.)"
121
+ end
122
+
123
+ { text: presence!(label, "button label"), callback_data: data }
124
+ end
125
+
126
+ def presence!(value, what)
127
+ string = value.to_s
128
+ raise ArgumentError, "Telegrama::InlineKeyboard #{what} cannot be blank." if string.strip.empty?
129
+
130
+ string
131
+ end
132
+ end
133
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Telegrama
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/telegrama.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "telegrama/error"
11
11
  require_relative "telegrama/version"
12
12
  require_relative "telegrama/configuration"
13
13
  require_relative "telegrama/formatter"
14
+ require_relative "telegrama/inline_keyboard"
14
15
  require_relative "telegrama/client"
15
16
  require_relative "telegrama/send_message_job"
16
17
 
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Javi R
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-22 00:00:00.000000000 Z
10
+ date: 2026-09-18 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
@@ -64,6 +65,7 @@ files:
64
65
  - lib/telegrama/configuration.rb
65
66
  - lib/telegrama/error.rb
66
67
  - lib/telegrama/formatter.rb
68
+ - lib/telegrama/inline_keyboard.rb
67
69
  - lib/telegrama/send_message_job.rb
68
70
  - lib/telegrama/version.rb
69
71
  - sig/telegrams.rbs