Skip to content

Commit 9ac4818

Browse files
authored
Merge pull request #177 from denis1011101/fix/game-chat-reply-delivery
Fix/game chat reply delivery
2 parents 8c16e44 + 1a84abf commit 9ac4818

4 files changed

Lines changed: 262 additions & 19 deletions

File tree

app/jobs/telegram/deliver_chat_message_job.rb

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,38 @@ def perform(game_id, recipient_id, text)
1919
# доставкой человека могли вывести из состава.
2020
return unless game.chat_open? && game.team_member_ids.include?(recipient.id)
2121

22+
# Отвечать в том же окне, где пришло сообщение, — первое, что делает
23+
# человек. Без указателя ответ пропадал: Relay не знает, в какую игру его
24+
# отдать. Поэтому доставка сама включает получателю чат этой игры — но
25+
# только после того, как сообщение действительно ушло.
26+
arming = Telegram::Chat::Session.automatic_start_needed?(
27+
recipient.telegram_chat_id, recipient, game
28+
)
29+
2230
# Без parse_mode: это чужой текст, а не наш шаблон. С Markdown одиночный
2331
# `_` или `[` либо исказит сообщение, либо уронит отправку четырёхсоткой.
32+
params = {
33+
"chat_id" => recipient.telegram_chat_id.to_s,
34+
"link_preview_options" => Telegram::Api::LINK_PREVIEW_DISABLED,
35+
"text" => text.to_s
36+
}
37+
params["reply_markup"] = { inline_keyboard: Telegram::Chat::Flow.controls(recipient) }.to_json if arming
38+
2439
response = begin
25-
Telegram::Api.post("sendMessage", {
26-
"chat_id" => recipient.telegram_chat_id.to_s,
27-
"link_preview_options" => Telegram::Api::LINK_PREVIEW_DISABLED,
28-
"text" => text.to_s
29-
})
40+
Telegram::Api.post("sendMessage", params)
3041
rescue StandardError => error
3142
raise TransientDeliveryError, error.message
3243
end
3344

34-
handle_response(response, game_id, recipient_id, text)
45+
delivered = handle_response(response, game_id, recipient_id, text)
46+
# Ретрай и постоянная ошибка не должны оставлять человека в чате, о котором
47+
# он не узнал: сообщение с кнопками до него не дошло. Следующая попытка
48+
# увидит, что указателя нет, и пришлёт кнопки снова. Запись — только если
49+
# выбора всё ещё нет: пока шла отправка, человек мог открыть другую игру.
50+
if arming && delivered
51+
Telegram::Chat::Session.start_automatically(recipient.telegram_chat_id, recipient, game)
52+
end
53+
delivered
3554
end
3655

3756
private

app/services/telegram/chat/flow.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def enter(user, game)
4343
return false if chat_id.blank? || game.nil?
4444
return false unless game.chat_open? && game.team_member_ids.include?(user.id)
4545

46-
Session.start(chat_id, game)
46+
return false unless Session.start_automatically(chat_id, user, game)
47+
4748
show_status(chat_id.to_s, user, game)
4849
true
4950
end
@@ -96,11 +97,17 @@ def stop(chat_id, user)
9697
def show_status(chat_id, user, game)
9798
recipients = game.chat_members.where.not(id: user.id).count
9899
text = t(user, :chat_started, game: Message.game_label(game), count: recipients)
99-
buttons = [ [
100+
Telegram::Api.send_with_buttons(chat_id, text, controls(user), parse_mode: nil)
101+
end
102+
103+
# Кнопки управления режимом. Их же вешает на доставленное сообщение
104+
# DeliverChatMessageJob, когда чат включается получателю: состояние видно
105+
# там же, где оно появилось, без отдельной карточки вдогонку.
106+
def controls(user)
107+
[ [
100108
{ text: t(user, :chat_switch_btn), callback_data: "chat:pick" },
101109
{ text: t(user, :chat_exit_btn), callback_data: "chat:exit" }
102110
] ]
103-
Telegram::Api.send_with_buttons(chat_id, text, buttons, parse_mode: nil)
104111
end
105112

106113
private

app/services/telegram/chat/session.rb

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,62 @@ module Chat
1010
# игры уже после того, как включил режим.
1111
module Session
1212
KEY_PREFIX = "tg:chat:active".freeze
13+
AUTOMATIC_KEY_PREFIX = "tg:chat:auto".freeze
1314

1415
class << self
16+
# Явный выбор пользователя всегда имеет приоритет над автоматическим.
1517
def start(chat_id, game)
1618
closes_at = game&.chat_open_until
1719
return false unless closes_at
1820

1921
ttl = [ closes_at - Time.current, 1.minute ].max
2022
Rails.cache.write(key(chat_id), game.id, expires_in: ttl)
23+
Rails.cache.delete(automatic_key(chat_id))
2124
true
2225
end
2326

27+
# Автоматические сессии сменяют друг друга, но лежат отдельно от явного
28+
# выбора и поэтому не могут его затереть даже при параллельной записи.
29+
def start_automatically(chat_id, user, game)
30+
closes_at = game&.chat_open_until
31+
return false unless closes_at
32+
33+
ttl = [ closes_at - Time.current, 1.minute ].max
34+
Rails.cache.write(automatic_key(chat_id), game.id, expires_in: ttl)
35+
36+
# Явный выбор мог появиться, пока шла доставка. Он имеет приоритет;
37+
# автоматический указатель заодно убираем, чтобы тот не ожил после TTL.
38+
if active_game_for(explicit_game_id(chat_id), user)
39+
Rails.cache.delete(automatic_key(chat_id))
40+
return false
41+
end
42+
43+
true
44+
end
45+
46+
def automatic_start_needed?(chat_id, user, game)
47+
return false if active_game_for(explicit_game_id(chat_id), user)
48+
49+
active_game_for(automatic_game_id(chat_id), user)&.id != game.id
50+
end
51+
2452
def stop(chat_id)
2553
Rails.cache.delete(key(chat_id))
54+
Rails.cache.delete(automatic_key(chat_id))
2655
end
2756

2857
def game_id(chat_id)
29-
Rails.cache.read(key(chat_id))
58+
explicit_game_id(chat_id) || automatic_game_id(chat_id)
3059
end
3160

32-
# Игра, в которую человек пишет прямо сейчас, — или nil, и тогда режим
33-
# гасится: игра прошла, человека вывели из состава, игру удалили.
61+
# Игра, в которую человек пишет прямо сейчас. Протухшие указатели не
62+
# удаляем здесь: параллельный явный выбор мог уже записать в тот же ключ
63+
# новое значение. Они безвредны и исчезнут сами по TTL.
3464
def active_game(chat_id, user)
35-
id = game_id(chat_id)
36-
return nil unless id && user
37-
38-
game = Game.find_by(id: id)
39-
return game if game&.chat_open? && game.team_member_ids.include?(user.id)
65+
return nil unless user
4066

41-
stop(chat_id)
42-
nil
67+
active_game_for(explicit_game_id(chat_id), user) ||
68+
active_game_for(automatic_game_id(chat_id), user)
4369
end
4470

4571
# Человек вышел из игры (или его вывели) — гасим режим, но только если
@@ -56,6 +82,27 @@ def stop_for(user, game)
5682
def key(chat_id)
5783
"#{KEY_PREFIX}:#{chat_id}"
5884
end
85+
86+
def automatic_key(chat_id)
87+
"#{AUTOMATIC_KEY_PREFIX}:#{chat_id}"
88+
end
89+
90+
private
91+
92+
def explicit_game_id(chat_id)
93+
Rails.cache.read(key(chat_id))
94+
end
95+
96+
def automatic_game_id(chat_id)
97+
Rails.cache.read(automatic_key(chat_id))
98+
end
99+
100+
def active_game_for(id, user)
101+
return nil unless id && user
102+
103+
game = Game.find_by(id: id)
104+
game if game&.chat_open? && game.team_member_ids.include?(user.id)
105+
end
59106
end
60107
end
61108
end

test/jobs/telegram/chat_relay_test.rb

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
require "test_helper"
2+
require "support/cache_helper"
23

34
class Telegram::ChatRelayTest < ActiveSupport::TestCase
45
include ActiveJob::TestHelper
6+
include CacheHelper
57

68
setup do
79
@court = Court.create!(name: "Relay Court", city_name: "Yekaterinburg")
@@ -53,6 +55,174 @@ class Telegram::ChatRelayTest < ActiveSupport::TestCase
5355
assert_not params.key?("parse_mode")
5456
end
5557

58+
# Ответ в том же окне — первое, что делает получатель. Раньше он пропадал:
59+
# у человека не было указателя, и Relay не знал, в какую игру его отдать.
60+
test "delivery turns the chat mode on for a recipient who has none" do
61+
params = nil
62+
63+
with_memory_cache do
64+
stub_singleton(Telegram::Api, :post, ->(_path, sent) { params = sent; { "ok" => true } }) do
65+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
66+
end
67+
68+
assert_equal @game.id, Telegram::Chat::Session.active_game(@owner.telegram_chat_id, @owner).id
69+
end
70+
71+
buttons = JSON.parse(params["reply_markup"])["inline_keyboard"].first
72+
assert_equal [ "chat:pick", "chat:exit" ], buttons.map { |button| button["callback_data"] }
73+
end
74+
75+
test "delivery leaves an existing chat choice alone" do
76+
other = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
77+
params = nil
78+
79+
with_memory_cache do
80+
Telegram::Chat::Session.start(@owner.telegram_chat_id, other)
81+
82+
stub_singleton(Telegram::Api, :post, ->(_path, sent) { params = sent; { "ok" => true } }) do
83+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
84+
end
85+
86+
assert_equal other.id, Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
87+
end
88+
89+
assert_not params.key?("reply_markup")
90+
ensure
91+
other&.destroy
92+
end
93+
94+
test "a later automatic delivery becomes the reply target" do
95+
other = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
96+
second_delivery = nil
97+
98+
with_memory_cache do
99+
stub_singleton(Telegram::Api, :post, ->(*) { { "ok" => true } }) do
100+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "из первой игры")
101+
end
102+
103+
stub_singleton(Telegram::Api, :post, ->(_path, sent) { second_delivery = sent; { "ok" => true } }) do
104+
Telegram::DeliverChatMessageJob.perform_now(other.id, @owner.id, "из второй игры")
105+
end
106+
107+
assert_equal other.id, Telegram::Chat::Session.active_game(@owner.telegram_chat_id, @owner).id
108+
end
109+
110+
assert second_delivery.key?("reply_markup")
111+
ensure
112+
other&.destroy
113+
end
114+
115+
test "a failed delivery leaves no chat mode behind" do
116+
# 403 — постоянная ошибка: сообщение с кнопками до человека не дошло, и
117+
# оказаться в чате втихаря он не должен.
118+
with_memory_cache do
119+
stub_singleton(Telegram::Api, :post, ->(*) { { "ok" => false, "error_code" => 403, "description" => "Forbidden" } }) do
120+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
121+
end
122+
123+
assert_nil Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
124+
end
125+
end
126+
127+
test "a throttled delivery arms nothing and sends the buttons again on the retry" do
128+
throttled = { "ok" => false, "error_code" => 429, "parameters" => { "retry_after" => 7 } }
129+
later = Class.new { def perform_later(*) = true }.new
130+
second_attempt = nil
131+
132+
with_memory_cache do
133+
stub_singleton(Telegram::Api, :post, ->(*) { throttled }) do
134+
stub_singleton(Telegram::DeliverChatMessageJob, :set, ->(wait:) { later }) do
135+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
136+
end
137+
end
138+
139+
assert_nil Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
140+
141+
stub_singleton(Telegram::Api, :post, ->(_path, sent) { second_attempt = sent; { "ok" => true } }) do
142+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
143+
end
144+
145+
assert_equal @game.id, Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
146+
end
147+
148+
assert second_attempt.key?("reply_markup")
149+
end
150+
151+
test "a retried server error leaves no chat mode behind" do
152+
with_memory_cache do
153+
assert_enqueued_jobs 1, only: Telegram::DeliverChatMessageJob do
154+
stub_singleton(Telegram::Api, :post, ->(*) { { "ok" => false, "error_code" => 503 } }) do
155+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
156+
end
157+
end
158+
159+
assert_nil Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
160+
end
161+
end
162+
163+
# Указатель на игру, которой больше нет, — мусор: он не должен навсегда
164+
# запирать включение чата для живой игры.
165+
test "a stale pointer does not block arming the chat mode" do
166+
other = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
167+
params = nil
168+
169+
with_memory_cache do
170+
Telegram::Chat::Session.start(@owner.telegram_chat_id, other)
171+
other.destroy
172+
173+
stub_singleton(Telegram::Api, :post, ->(_path, sent) { params = sent; { "ok" => true } }) do
174+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
175+
end
176+
177+
assert_equal @game.id, Telegram::Chat::Session.active_game(@owner.telegram_chat_id, @owner).id
178+
end
179+
180+
assert params.key?("reply_markup")
181+
end
182+
183+
# Отправка не мгновенна: пока она идёт, человек мог сам открыть другую игру.
184+
test "a choice made while the message was in flight is not overwritten" do
185+
other = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
186+
187+
with_memory_cache do
188+
posting = lambda do |*|
189+
Telegram::Chat::Session.start(@owner.telegram_chat_id, other)
190+
{ "ok" => true }
191+
end
192+
193+
stub_singleton(Telegram::Api, :post, posting) do
194+
Telegram::DeliverChatMessageJob.perform_now(@game.id, @owner.id, "во сколько?")
195+
end
196+
197+
assert_equal other.id, Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
198+
end
199+
ensure
200+
other&.destroy
201+
end
202+
203+
test "stale validation does not delete a concurrent explicit choice" do
204+
stale = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
205+
fresh = Game.create!(court: @court, user: @owner, date: Date.current, kind: "game")
206+
207+
with_memory_cache do
208+
Telegram::Chat::Session.start(@owner.telegram_chat_id, stale)
209+
stale.destroy
210+
211+
lookup = lambda do |**|
212+
Telegram::Chat::Session.start(@owner.telegram_chat_id, fresh)
213+
nil
214+
end
215+
stub_singleton(Game, :find_by, lookup) do
216+
assert_nil Telegram::Chat::Session.active_game(@owner.telegram_chat_id, @owner)
217+
end
218+
219+
assert_equal fresh.id, Telegram::Chat::Session.game_id(@owner.telegram_chat_id)
220+
end
221+
ensure
222+
fresh&.destroy
223+
stale&.destroy
224+
end
225+
56226
test "delivery re-checks membership right before sending" do
57227
@participation.destroy
58228

0 commit comments

Comments
 (0)