chat_sdk-telegram 1.1.0 → 1.3.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: 8a2c77efea728df0e0f77552d2d709d7824f653e2137ae88678ddd7baa6b2415
4
- data.tar.gz: 51854b5c55aacf5504e16a70e0f5120e188800572cd4da6eecc5bbd4ec205e28
3
+ metadata.gz: 3684daba2940e20bf967eb893864d9ada4e88c1829564c8d6707cacb3d2d70af
4
+ data.tar.gz: f570ad027ae3a715268b64cf817743cd74278da097ddfa52ece098baeb18c5bc
5
5
  SHA512:
6
- metadata.gz: d222b99e2557c94c16465cff57e2858fb9aba5eda536446fee542502539414f76e9a55485b4706f5c8cedee6662325c7eb20c49e91059a37f67bc5a6a47c4de2
7
- data.tar.gz: af031c32d6518ac316888941ebc34a216ad05ec3d847cbdee2a3e9cd7418d66157213a59cf0d29616f99c719990a3cb04aadfa441d8d94df3ed163ec7cd18090
6
+ metadata.gz: 42b0ab459cba54c49a4b7402fdb3481e55369b280f61c0e30857bb75bcc75774b68fed1bbea5d5877deb01f40f6e5ecdec15e30d5c7f6d27bda59792d6d5c304
7
+ data.tar.gz: 303379f0c56a05e2a11b82b3225a64f51791bd3d439ad55750015568afc4b3df955b66f376fd294b8d2d4c6187b89986529ff42c51d3c7fc6c13998f55681cdd
@@ -10,10 +10,12 @@ module ChatSDK
10
10
 
11
11
  attr_reader :client
12
12
 
13
- def initialize(bot_token: nil, secret_token: nil, bot_username: nil, allowed_user_ids: nil, allow_unverified_webhooks: 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
17
19
  configured_user_ids = allowed_user_ids || ENV["TELEGRAM_ALLOWED_USER_IDS"]&.split(",")
18
20
  @allowed_user_ids = configured_user_ids&.map(&:to_s)&.to_set
19
21
  @allow_unverified_webhooks = if allow_unverified_webhooks.nil?
@@ -26,6 +28,7 @@ module ChatSDK
26
28
 
27
29
  @client = ApiClient.new(@bot_token)
28
30
  @renderer = KeyboardRenderer.new
31
+ @format_converter = FormatConverter.new
29
32
  end
30
33
 
31
34
  def name
@@ -62,7 +65,7 @@ module ChatSDK
62
65
  payload = read_json_body(rack_request)
63
66
  return [] unless allowed_user?(payload)
64
67
 
65
- EventParser.parse(payload, bot_username: @bot_username)
68
+ EventParser.parse(payload, bot_username: @bot_username, mention_on_reply: @mention_on_reply)
66
69
  rescue JSON::ParserError
67
70
  []
68
71
  end
@@ -93,7 +96,7 @@ module ChatSDK
93
96
  parse_telegram_message(result, channel_id)
94
97
  end
95
98
 
96
- def edit_message(channel_id:, message_id:, message:)
99
+ def edit_message(channel_id:, message_id:, message:, **_options)
97
100
  text, reply_markup = prepare_message_payload(message)
98
101
 
99
102
  @client.edit_message_text(
@@ -104,7 +107,7 @@ module ChatSDK
104
107
  )
105
108
  end
106
109
 
107
- def delete_message(channel_id:, message_id:)
110
+ def delete_message(channel_id:, message_id:, **_options)
108
111
  @client.delete_message(chat_id: channel_id, message_id: message_id)
109
112
  end
110
113
 
@@ -118,7 +121,7 @@ module ChatSDK
118
121
  )
119
122
  end
120
123
 
121
- def add_reaction(channel_id:, message_id:, emoji:)
124
+ def add_reaction(channel_id:, message_id:, emoji:, **_options)
122
125
  @client.set_message_reaction(
123
126
  chat_id: channel_id,
124
127
  message_id: message_id,
@@ -126,7 +129,7 @@ module ChatSDK
126
129
  )
127
130
  end
128
131
 
129
- 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
130
133
  @client.set_message_reaction(
131
134
  chat_id: channel_id,
132
135
  message_id: message_id,
@@ -151,7 +154,7 @@ module ChatSDK
151
154
  user_id
152
155
  end
153
156
 
154
- 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
155
158
  @client.send_chat_action(chat_id: channel_id, action: "typing")
156
159
  end
157
160
 
@@ -178,7 +181,7 @@ module ChatSDK
178
181
  offset = update["update_id"] + 1
179
182
  next unless allowed_user?(update)
180
183
 
181
- events = EventParser.parse(update, bot_username: @bot_username)
184
+ events = EventParser.parse(update, bot_username: @bot_username, mention_on_reply: @mention_on_reply)
182
185
  events.each { |event| block.call(event) }
183
186
  end
184
187
  end
@@ -207,7 +210,7 @@ module ChatSDK
207
210
  text = rendered[:text] if rendered[:text] && !rendered[:text].empty?
208
211
  reply_markup = rendered[:reply_markup]
209
212
  end
210
- [text, reply_markup]
213
+ [@format_converter.truncate_markdown_v2(text, 4096), reply_markup]
211
214
  end
212
215
 
213
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,11 +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,
39
41
  reply_to: build_reply_to(message["reply_to_message"], chat_id),
40
42
  raw: message
41
43
  )
42
44
 
43
- event_class = if mention?(text, bot_username)
45
+ event_class = if mention?(text, bot_username) || reply_mention
44
46
  ChatSDK::Events::Mention
45
47
  elsif chat_type == "private"
46
48
  ChatSDK::Events::DirectMessage
@@ -149,6 +151,18 @@ module ChatSDK
149
151
  /(?<!\w)@#{Regexp.escape(bot_username)}(?![\w-])/i.match?(text)
150
152
  end
151
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
+
152
166
  def build_reply_to(reply, chat_id)
153
167
  return unless reply
154
168
 
@@ -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.1.0
4
+ version: 1.3.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