chat_sdk-telegram 1.0.1 → 1.2.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/lib/chat_sdk/telegram/adapter.rb +54 -11
- data/lib/chat_sdk/telegram/event_parser.rb +41 -5
- data/lib/chat_sdk/telegram/format_converter.rb +148 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd12f1353b787c17a0d76a18f868f68851c4b64fb6735075f56c478e8caa0be6
|
|
4
|
+
data.tar.gz: f570ad027ae3a715268b64cf817743cd74278da097ddfa52ece098baeb18c5bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0814d842b005c420a39dc7f2c06fa9fe9b9609c283947b6669de9dfb73d0d628204850ee15047f1fc44f7bd3fc9003d8c517d2830f2a41e8ad5e30566d08686
|
|
7
|
+
data.tar.gz: 303379f0c56a05e2a11b82b3225a64f51791bd3d439ad55750015568afc4b3df955b66f376fd294b8d2d4c6187b89986529ff42c51d3c7fc6c13998f55681cdd
|
|
@@ -6,19 +6,29 @@ module ChatSDK
|
|
|
6
6
|
module Telegram
|
|
7
7
|
class Adapter < ChatSDK::Adapter::Base
|
|
8
8
|
capabilities :edit_messages, :delete_messages, :reactions, :file_uploads,
|
|
9
|
-
:typing_indicator, :streaming_edit, :direct_messages
|
|
9
|
+
:typing_indicator, :streaming_edit, :direct_messages, :replies
|
|
10
10
|
|
|
11
11
|
attr_reader :client
|
|
12
12
|
|
|
13
|
-
def initialize(bot_token: nil, secret_token: nil, bot_username: nil
|
|
13
|
+
def initialize(bot_token: nil, secret_token: nil, bot_username: nil, allowed_user_ids: nil, allow_unverified_webhooks: nil,
|
|
14
|
+
mention_on_reply: false)
|
|
14
15
|
@bot_token = bot_token || ENV["TELEGRAM_BOT_TOKEN"]
|
|
15
16
|
@secret_token = secret_token || ENV["TELEGRAM_WEBHOOK_SECRET_TOKEN"]
|
|
16
17
|
@bot_username = bot_username || ENV["TELEGRAM_BOT_USERNAME"]
|
|
18
|
+
@mention_on_reply = mention_on_reply
|
|
19
|
+
configured_user_ids = allowed_user_ids || ENV["TELEGRAM_ALLOWED_USER_IDS"]&.split(",")
|
|
20
|
+
@allowed_user_ids = configured_user_ids&.map(&:to_s)&.to_set
|
|
21
|
+
@allow_unverified_webhooks = if allow_unverified_webhooks.nil?
|
|
22
|
+
ENV["TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS"] == "true"
|
|
23
|
+
else
|
|
24
|
+
allow_unverified_webhooks
|
|
25
|
+
end
|
|
17
26
|
|
|
18
27
|
raise ChatSDK::ConfigurationError, "Telegram bot_token required" unless @bot_token
|
|
19
28
|
|
|
20
29
|
@client = ApiClient.new(@bot_token)
|
|
21
30
|
@renderer = KeyboardRenderer.new
|
|
31
|
+
@format_converter = FormatConverter.new
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def name
|
|
@@ -27,7 +37,12 @@ module ChatSDK
|
|
|
27
37
|
|
|
28
38
|
# Inbound
|
|
29
39
|
def verify_request!(rack_request)
|
|
30
|
-
return true
|
|
40
|
+
return true if !@secret_token && @allow_unverified_webhooks
|
|
41
|
+
|
|
42
|
+
unless @secret_token
|
|
43
|
+
raise ChatSDK::SignatureVerificationError,
|
|
44
|
+
"Telegram secret_token required; set allow_unverified_webhooks: true to explicitly disable verification"
|
|
45
|
+
end
|
|
31
46
|
|
|
32
47
|
header_token = rack_request.get_header("HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN")
|
|
33
48
|
|
|
@@ -48,7 +63,9 @@ module ChatSDK
|
|
|
48
63
|
|
|
49
64
|
def parse_events(rack_request)
|
|
50
65
|
payload = read_json_body(rack_request)
|
|
51
|
-
|
|
66
|
+
return [] unless allowed_user?(payload)
|
|
67
|
+
|
|
68
|
+
EventParser.parse(payload, bot_username: @bot_username, mention_on_reply: @mention_on_reply)
|
|
52
69
|
rescue JSON::ParserError
|
|
53
70
|
[]
|
|
54
71
|
end
|
|
@@ -67,7 +84,19 @@ module ChatSDK
|
|
|
67
84
|
parse_telegram_message(result, channel_id)
|
|
68
85
|
end
|
|
69
86
|
|
|
70
|
-
def
|
|
87
|
+
def reply_message(channel_id:, message_id:, message:, thread_id: nil)
|
|
88
|
+
text, reply_markup = prepare_message_payload(message)
|
|
89
|
+
result = @client.send_message(
|
|
90
|
+
chat_id: channel_id,
|
|
91
|
+
text: text,
|
|
92
|
+
reply_markup: reply_markup,
|
|
93
|
+
reply_to_message_id: message_id
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parse_telegram_message(result, channel_id)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def edit_message(channel_id:, message_id:, message:, **_options)
|
|
71
100
|
text, reply_markup = prepare_message_payload(message)
|
|
72
101
|
|
|
73
102
|
@client.edit_message_text(
|
|
@@ -78,7 +107,7 @@ module ChatSDK
|
|
|
78
107
|
)
|
|
79
108
|
end
|
|
80
109
|
|
|
81
|
-
def delete_message(channel_id:, message_id
|
|
110
|
+
def delete_message(channel_id:, message_id:, **_options)
|
|
82
111
|
@client.delete_message(chat_id: channel_id, message_id: message_id)
|
|
83
112
|
end
|
|
84
113
|
|
|
@@ -92,7 +121,7 @@ module ChatSDK
|
|
|
92
121
|
)
|
|
93
122
|
end
|
|
94
123
|
|
|
95
|
-
def add_reaction(channel_id:, message_id:, emoji
|
|
124
|
+
def add_reaction(channel_id:, message_id:, emoji:, **_options)
|
|
96
125
|
@client.set_message_reaction(
|
|
97
126
|
chat_id: channel_id,
|
|
98
127
|
message_id: message_id,
|
|
@@ -100,7 +129,7 @@ module ChatSDK
|
|
|
100
129
|
)
|
|
101
130
|
end
|
|
102
131
|
|
|
103
|
-
def remove_reaction(channel_id:, message_id:, emoji
|
|
132
|
+
def remove_reaction(channel_id:, message_id:, emoji:, **_options) # rubocop:disable Lint/UnusedMethodArgument
|
|
104
133
|
@client.set_message_reaction(
|
|
105
134
|
chat_id: channel_id,
|
|
106
135
|
message_id: message_id,
|
|
@@ -125,7 +154,7 @@ module ChatSDK
|
|
|
125
154
|
user_id
|
|
126
155
|
end
|
|
127
156
|
|
|
128
|
-
def start_typing(channel_id:, thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
157
|
+
def start_typing(channel_id:, thread_id: nil, message_id: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
129
158
|
@client.send_chat_action(chat_id: channel_id, action: "typing")
|
|
130
159
|
end
|
|
131
160
|
|
|
@@ -150,7 +179,9 @@ module ChatSDK
|
|
|
150
179
|
updates = updates.is_a?(Array) ? updates : []
|
|
151
180
|
updates.each do |update|
|
|
152
181
|
offset = update["update_id"] + 1
|
|
153
|
-
|
|
182
|
+
next unless allowed_user?(update)
|
|
183
|
+
|
|
184
|
+
events = EventParser.parse(update, bot_username: @bot_username, mention_on_reply: @mention_on_reply)
|
|
154
185
|
events.each { |event| block.call(event) }
|
|
155
186
|
end
|
|
156
187
|
end
|
|
@@ -158,6 +189,18 @@ module ChatSDK
|
|
|
158
189
|
|
|
159
190
|
private
|
|
160
191
|
|
|
192
|
+
def allowed_user?(payload)
|
|
193
|
+
return true unless @allowed_user_ids
|
|
194
|
+
|
|
195
|
+
user_id = payload.dig("callback_query", "from", "id") ||
|
|
196
|
+
payload.dig("message_reaction", "user", "id") ||
|
|
197
|
+
payload.dig("message", "from", "id") ||
|
|
198
|
+
payload.dig("edited_message", "from", "id") ||
|
|
199
|
+
payload.dig("channel_post", "from", "id") ||
|
|
200
|
+
payload.dig("edited_channel_post", "from", "id")
|
|
201
|
+
user_id && @allowed_user_ids.include?(user_id.to_s)
|
|
202
|
+
end
|
|
203
|
+
|
|
161
204
|
def prepare_message_payload(message)
|
|
162
205
|
msg = ChatSDK::PostableMessage.from(message)
|
|
163
206
|
text = msg.text || msg.card&.fallback_text || ""
|
|
@@ -167,7 +210,7 @@ module ChatSDK
|
|
|
167
210
|
text = rendered[:text] if rendered[:text] && !rendered[:text].empty?
|
|
168
211
|
reply_markup = rendered[:reply_markup]
|
|
169
212
|
end
|
|
170
|
-
[text, reply_markup]
|
|
213
|
+
[@format_converter.truncate_markdown_v2(text, 4096), reply_markup]
|
|
171
214
|
end
|
|
172
215
|
|
|
173
216
|
def parse_telegram_message(data, channel_id)
|
|
@@ -4,7 +4,7 @@ module ChatSDK
|
|
|
4
4
|
module Telegram
|
|
5
5
|
class EventParser
|
|
6
6
|
class << self
|
|
7
|
-
def parse(payload, bot_username: nil)
|
|
7
|
+
def parse(payload, bot_username: nil, mention_on_reply: false)
|
|
8
8
|
return [] unless payload.is_a?(Hash)
|
|
9
9
|
|
|
10
10
|
if payload["callback_query"]
|
|
@@ -12,7 +12,7 @@ module ChatSDK
|
|
|
12
12
|
elsif payload["message_reaction"]
|
|
13
13
|
parse_message_reaction(payload["message_reaction"])
|
|
14
14
|
elsif payload["message"]
|
|
15
|
-
parse_message(payload["message"], bot_username: bot_username)
|
|
15
|
+
parse_message(payload["message"], bot_username: bot_username, mention_on_reply: mention_on_reply)
|
|
16
16
|
else
|
|
17
17
|
[]
|
|
18
18
|
end
|
|
@@ -20,7 +20,7 @@ module ChatSDK
|
|
|
20
20
|
|
|
21
21
|
private
|
|
22
22
|
|
|
23
|
-
def parse_message(message, bot_username: nil)
|
|
23
|
+
def parse_message(message, bot_username: nil, mention_on_reply: false)
|
|
24
24
|
return parse_bot_command(message, extract_user(message), message.dig("chat", "id")&.to_s, resolve_thread_id(message)) if bot_command?(message)
|
|
25
25
|
|
|
26
26
|
user = extract_user(message)
|
|
@@ -28,6 +28,7 @@ module ChatSDK
|
|
|
28
28
|
chat_type = message.dig("chat", "type")
|
|
29
29
|
text = message["text"] || ""
|
|
30
30
|
thread_id = resolve_thread_id(message)
|
|
31
|
+
reply_mention = reply_to_bot?(message, bot_username, mention_on_reply)
|
|
31
32
|
|
|
32
33
|
msg = ChatSDK::Message.new(
|
|
33
34
|
id: message["message_id"]&.to_s,
|
|
@@ -36,10 +37,12 @@ module ChatSDK
|
|
|
36
37
|
thread_id: thread_id,
|
|
37
38
|
channel_id: chat_id,
|
|
38
39
|
platform: :telegram,
|
|
40
|
+
is_mention: reply_mention ? true : nil,
|
|
41
|
+
reply_to: build_reply_to(message["reply_to_message"], chat_id),
|
|
39
42
|
raw: message
|
|
40
43
|
)
|
|
41
44
|
|
|
42
|
-
event_class = if mention?(text, bot_username)
|
|
45
|
+
event_class = if mention?(text, bot_username) || reply_mention
|
|
43
46
|
ChatSDK::Events::Mention
|
|
44
47
|
elsif chat_type == "private"
|
|
45
48
|
ChatSDK::Events::DirectMessage
|
|
@@ -145,7 +148,40 @@ module ChatSDK
|
|
|
145
148
|
def mention?(text, bot_username)
|
|
146
149
|
return false unless bot_username
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
/(?<!\w)@#{Regexp.escape(bot_username)}(?![\w-])/i.match?(text)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def reply_to_bot?(message, bot_username, mention_on_reply)
|
|
155
|
+
return false unless mention_on_reply && bot_username
|
|
156
|
+
|
|
157
|
+
reply = message["reply_to_message"] || {}
|
|
158
|
+
bot_user = reply["from"] || {}
|
|
159
|
+
return false unless bot_user["is_bot"] && bot_user["username"].to_s.casecmp?(bot_username)
|
|
160
|
+
|
|
161
|
+
topic_root_reply = message["is_topic_message"] &&
|
|
162
|
+
reply["message_id"].to_s == message["message_thread_id"].to_s
|
|
163
|
+
!topic_root_reply
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_reply_to(reply, chat_id)
|
|
167
|
+
return unless reply
|
|
168
|
+
|
|
169
|
+
user = extract_user(reply)
|
|
170
|
+
ChatSDK::Message.new(
|
|
171
|
+
id: reply["message_id"]&.to_s,
|
|
172
|
+
text: reply["text"] || reply["caption"] || "",
|
|
173
|
+
author: ChatSDK::Author.new(
|
|
174
|
+
id: user[:id],
|
|
175
|
+
name: user[:name],
|
|
176
|
+
platform: :telegram,
|
|
177
|
+
bot: !!reply.dig("from", "is_bot"),
|
|
178
|
+
locale: user[:locale]
|
|
179
|
+
),
|
|
180
|
+
thread_id: resolve_thread_id(reply),
|
|
181
|
+
channel_id: chat_id,
|
|
182
|
+
platform: :telegram,
|
|
183
|
+
raw: reply
|
|
184
|
+
)
|
|
149
185
|
end
|
|
150
186
|
|
|
151
187
|
def parse_bot_command(message, user, chat_id, thread_id)
|
|
@@ -33,8 +33,156 @@ module ChatSDK
|
|
|
33
33
|
parts.map.with_index { |part, i| i.odd? ? part : escape_special_chars(part) }.join
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def truncate_markdown_v2(text, limit)
|
|
37
|
+
text = text.to_s
|
|
38
|
+
return text if text.length <= limit
|
|
39
|
+
|
|
40
|
+
ellipsis = "\\.\\.\\."
|
|
41
|
+
slice = text[0, limit - ellipsis.length]
|
|
42
|
+
"#{trim_to_safe_boundary(slice)}#{ellipsis}"
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
private
|
|
37
46
|
|
|
47
|
+
def trim_to_safe_boundary(text)
|
|
48
|
+
text = text[0...-1] while trailing_backslashes(text).odd?
|
|
49
|
+
link_detection_text, = mask_code_spans(text)
|
|
50
|
+
link_start = incomplete_link_start(link_detection_text)
|
|
51
|
+
text = text[0...link_start] if link_start
|
|
52
|
+
|
|
53
|
+
masked_links = mask_links(text)
|
|
54
|
+
masked, incomplete_code_start = mask_code_spans(masked_links)
|
|
55
|
+
text = text[0...incomplete_code_start] if incomplete_code_start
|
|
56
|
+
return text unless incomplete_code_start.nil?
|
|
57
|
+
|
|
58
|
+
marker_positions = Hash.new { |hash, key| hash[key] = [] }
|
|
59
|
+
chars = masked.chars
|
|
60
|
+
index = 0
|
|
61
|
+
while index < chars.length
|
|
62
|
+
if chars[index] == "\\"
|
|
63
|
+
index += 2
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
if %w[* _ ~ |].include?(chars[index])
|
|
67
|
+
run_length = 1
|
|
68
|
+
run_length += 1 while chars[index + run_length] == chars[index]
|
|
69
|
+
marker_positions[[chars[index], run_length]] << index
|
|
70
|
+
index += run_length
|
|
71
|
+
else
|
|
72
|
+
index += 1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
cut = marker_positions.values
|
|
77
|
+
.select { |positions| positions.length.odd? }
|
|
78
|
+
.map(&:last)
|
|
79
|
+
.min
|
|
80
|
+
cut ? text[0...cut] : text
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def trailing_backslashes(text)
|
|
84
|
+
text[/\\+\z/].to_s.length
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def incomplete_link_start(text)
|
|
88
|
+
chars = text.chars
|
|
89
|
+
openings = []
|
|
90
|
+
index = 0
|
|
91
|
+
while index < chars.length
|
|
92
|
+
if chars[index] == "\\"
|
|
93
|
+
index += 2
|
|
94
|
+
next
|
|
95
|
+
elsif chars[index] == "["
|
|
96
|
+
openings << index
|
|
97
|
+
elsif chars[index] == "]" && !openings.empty?
|
|
98
|
+
opening = openings.pop
|
|
99
|
+
if chars[index + 1] == "("
|
|
100
|
+
closing = index + 2
|
|
101
|
+
closing += 1 while closing < chars.length &&
|
|
102
|
+
(chars[closing] != ")" || escaped_at?(chars, closing))
|
|
103
|
+
return opening if closing == chars.length
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
index += 1
|
|
107
|
+
end
|
|
108
|
+
openings.last
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def mask_code_spans(text)
|
|
112
|
+
chars = text.chars
|
|
113
|
+
masked = chars.dup
|
|
114
|
+
index = 0
|
|
115
|
+
while index < chars.length
|
|
116
|
+
if chars[index] == "\\"
|
|
117
|
+
index += 2
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
unless chars[index] == "`"
|
|
121
|
+
index += 1
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
run_length = 1
|
|
126
|
+
run_length += 1 while chars[index + run_length] == "`"
|
|
127
|
+
closing = index + run_length
|
|
128
|
+
while closing < chars.length
|
|
129
|
+
if chars[closing] == "`"
|
|
130
|
+
candidate_run_length = backtick_run_length(chars, closing)
|
|
131
|
+
break if candidate_run_length == run_length
|
|
132
|
+
|
|
133
|
+
closing += candidate_run_length
|
|
134
|
+
else
|
|
135
|
+
closing += 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
return [masked.join, index] if closing >= chars.length
|
|
139
|
+
|
|
140
|
+
closing_end = closing + run_length
|
|
141
|
+
(index...closing_end).each { |position| masked[position] = " " }
|
|
142
|
+
index = closing_end
|
|
143
|
+
end
|
|
144
|
+
[masked.join, nil]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def mask_links(text)
|
|
148
|
+
chars = text.chars
|
|
149
|
+
masked = chars.dup
|
|
150
|
+
openings = []
|
|
151
|
+
index = 0
|
|
152
|
+
while index < chars.length
|
|
153
|
+
if chars[index] == "\\"
|
|
154
|
+
index += 2
|
|
155
|
+
next
|
|
156
|
+
elsif chars[index] == "["
|
|
157
|
+
openings << index
|
|
158
|
+
elsif chars[index] == "]" && !openings.empty? && chars[index + 1] == "("
|
|
159
|
+
opening = openings.pop
|
|
160
|
+
closing = index + 2
|
|
161
|
+
closing += 1 while closing < chars.length &&
|
|
162
|
+
(chars[closing] != ")" || escaped_at?(chars, closing))
|
|
163
|
+
if closing < chars.length
|
|
164
|
+
(opening..closing).each { |position| masked[position] = " " }
|
|
165
|
+
index = closing
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
index += 1
|
|
169
|
+
end
|
|
170
|
+
masked.join
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def backtick_run_length(chars, index)
|
|
174
|
+
length = 1
|
|
175
|
+
length += 1 while chars[index + length] == "`"
|
|
176
|
+
length
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def escaped_at?(chars, index)
|
|
180
|
+
backslashes = 0
|
|
181
|
+
backslashes += 1 while index - backslashes - 1 >= 0 &&
|
|
182
|
+
chars[index - backslashes - 1] == "\\"
|
|
183
|
+
backslashes.odd?
|
|
184
|
+
end
|
|
185
|
+
|
|
38
186
|
# Escape Telegram MarkdownV2 special chars outside of markdown syntax.
|
|
39
187
|
# Preserves bold (**), italic (*), strikethrough (~~), links, etc.
|
|
40
188
|
def escape_special_chars(text)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chat_sdk-telegram
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Quentin Rousseau
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '1.2'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '1.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|