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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9b1e05e9da55df58039cc8861917f9d91bcc0bbc8391e0863c1ab623331e83b
4
- data.tar.gz: 9e41b3269dc1116e2bc7169b146770337e8f9bbb6020b85f7f36c575828cf678
3
+ metadata.gz: dd12f1353b787c17a0d76a18f868f68851c4b64fb6735075f56c478e8caa0be6
4
+ data.tar.gz: f570ad027ae3a715268b64cf817743cd74278da097ddfa52ece098baeb18c5bc
5
5
  SHA512:
6
- metadata.gz: c5a3fb292395624ea3f812ff2c48c92a8c700ab092124c2c8b5ed5047c901191d0a1bf5b4dea05fcca7a20d02b48aa935ce5cef2ec479c5df0706972b5fc0e2a
7
- data.tar.gz: 9843eee8e48fab1a345487035beac68a014bc558e343346dc1cfe6a170f7799b9170f9cff2e10d9bc8fdb738c4cc73fa118731558508baf8970509e54aeea3e2
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 unless @secret_token
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
- EventParser.parse(payload, bot_username: @bot_username)
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 edit_message(channel_id:, message_id:, message:)
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:) # rubocop:disable Lint/UnusedMethodArgument
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
- events = EventParser.parse(update, bot_username: @bot_username)
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
- text.include?("@#{bot_username}")
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.1
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: '0.1'
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: '0.1'
25
+ version: '1.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement