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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +70 -0
- data/context7.json +4 -0
- data/lib/telegrama/formatter.rb +10 -1
- data/lib/telegrama/inline_keyboard.rb +133 -0
- data/lib/telegrama/version.rb +1 -1
- data/lib/telegrama.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c449a525419c3f57a7349b9bd75427be2df8b8b685650aada9ed6cfae31d832b
|
|
4
|
+
data.tar.gz: fb840dd179f5ff1f1a06b0e1e210bffb1894470b4fe1d596ad5a8e71de7aab98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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}" }
|
|
@@ -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
|
data/lib/telegrama/version.rb
CHANGED
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.
|
|
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-
|
|
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
|