Skip to content

Commit 1dc7146

Browse files
authored
Merge pull request #179 from denis1011101/fix/chat-close-notice-and-media-errors
fix: чат прощается, а лимиты вложений говорят по-русски
2 parents cac5ec4 + bd65038 commit 1dc7146

12 files changed

Lines changed: 274 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,17 @@ jobs:
4141
ruby-version: .ruby-version
4242
bundler-cache: true
4343

44+
# Аудит ходит в реестр npm по сети, и тот регулярно отвечает 503 или рвёт
45+
# соединение — проверка падала на PR, где JS никто не трогал. Уязвимость
46+
# так же уронит шаг, просто не с первой попытки.
4447
- name: Scan for security vulnerabilities in JavaScript dependencies
45-
run: bin/importmap audit
48+
run: |
49+
for attempt in 1 2 3; do
50+
bin/importmap audit && exit 0
51+
echo "importmap audit failed (attempt $attempt of 3), retrying in 15s"
52+
sleep 15
53+
done
54+
exit 1
4655
4756
lint:
4857
runs-on: ubuntu-latest

app/jobs/cleanup_past_one_off_games_job.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ class CleanupPastOneOffGamesJob < ApplicationJob
77
# then destroy the one-off game record.
88
def perform
99
Game.where(recurring: false).where("date < ?", Date.current).find_each do |game|
10+
# Чат уходит вместе с игрой, а текст письма ссылается на её город и дату —
11+
# поэтому готовим его до удаления, а шлём только если удаление удалось.
12+
notices = Telegram::Chat::Closure.prepare(game, :chat_closed_finished)
13+
1014
if game.participations.exists?
1115
game.participations.delete_all
1216
Rails.logger.info "Cleared participations for past one-off Game##{game.id} (date=#{game.date})"
1317
end
1418

1519
if game.destroy
20+
Telegram::Chat::Closure.deliver(game, notices)
1621
Rails.logger.info "Destroyed past one-off Game##{game.id} (date=#{game.date})"
1722
else
1823
Rails.logger.warn "Failed to destroy past one-off Game##{game.id}: #{game.errors.full_messages.join(', ')}"

app/jobs/reset_participations_job.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@ def perform
77
next unless nd
88

99
if game.should_reset_participations?(Date.current)
10+
# Снимок до сброса: кому чат закроется, видно только по разнице составов —
11+
# часть людей вернётся в состав из предзаписи и никуда не выбывает.
12+
chat_members = game.chat_members.to_a
13+
1014
if game.prebooking_enabled?
1115
apply_prebookings_for_occurrence!(game, nd)
1216
game.mark_participations_reset!(nd)
1317
Rails.logger.info "Reset participations from prebookings for Game##{game.id} for occurrence #{nd}"
1418
else
15-
close_chat_sessions(game)
1619
game.participations.delete_all
1720
game.mark_participations_reset!(nd)
1821
Rails.logger.info "Reset participations for Game##{game.id} for occurrence #{nd}"
1922
end
23+
24+
close_chat_for_dropped(game, chat_members)
2025
end
2126
end
2227
end
@@ -25,10 +30,13 @@ def perform
2530

2631
# delete_all идёт мимо колбэков Participation, поэтому режим чата у выбывших
2732
# гасим здесь — иначе они продолжат писать в состав, из которого их убрали.
28-
def close_chat_sessions(game)
29-
game.participations.includes(:user).find_each do |participation|
30-
Telegram::Chat::Session.stop_for(participation.user, game)
31-
end
33+
# Сброс идёт ночью, и без письма человек заметил бы это, только когда его
34+
# сообщение уже никому не ушло.
35+
def close_chat_for_dropped(game, previous_members)
36+
game.participations.reset
37+
remaining = game.team_member_ids
38+
dropped = previous_members.reject { |user| remaining.include?(user.id) }
39+
Telegram::Chat::Closure.notify(game, :chat_closed_reset, dropped)
3240
rescue StandardError => e
3341
Rails.logger.warn("[ResetParticipationsJob] chat cleanup failed for Game##{game.id}: #{e.class}: #{e.message}")
3442
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
module Telegram
2+
module Chat
3+
# Чат закрывается не действием человека, а расписанием: в ночь на субботу
4+
# состав сбрасывают, а прошедшую разовую игру удаляют совсем. Без письма
5+
# человек узнал бы об этом, только когда его сообщение перестало доходить
6+
# до команды, — а оно просто не уходит, молча.
7+
module Closure
8+
Notice = Struct.new(:user, :text)
9+
10+
class << self
11+
# Текст собираем до того, как игру снесут: после удаления ни состава, ни
12+
# ярлыка с городом и датой уже не собрать.
13+
def prepare(game, reason, users = nil)
14+
return [] if game.nil?
15+
16+
(users || game.chat_members).to_a.filter_map do |user|
17+
next if user.nil? || user.telegram_chat_id.blank?
18+
19+
locale = Telegram::I18n.locale_for(user)
20+
Notice.new(user, Telegram::I18n.t(
21+
reason, locale: locale, game: Message.game_label(game, locale: locale)
22+
))
23+
end
24+
end
25+
26+
# Указатель гасим здесь же: иначе человек останется в чате, которого уже
27+
# нет, и следующее его сообщение уйдёт в никуда. Двумя проходами и с
28+
# оговоркой на каждого: сброс состава и удаление игры уже случились,
29+
# второго захода по этим людям не будет — так что одна упавшая отправка
30+
# не должна ни оставить остальных в мёртвом чате, ни съесть их письма.
31+
def deliver(game, notices)
32+
notices.each { |notice| guard(notice) { Session.stop_for(notice.user, game) } }
33+
notices.count do |notice|
34+
guard(notice) do
35+
SendTelegramNotificationJob.perform_later(notice.user.telegram_chat_id.to_s, notice.text)
36+
end
37+
end
38+
end
39+
40+
def notify(game, reason, users = nil)
41+
deliver(game, prepare(game, reason, users))
42+
end
43+
44+
private
45+
46+
def guard(notice)
47+
yield
48+
true
49+
rescue StandardError => e
50+
Rails.logger.warn("[Chat::Closure] failed for User##{notice.user.id}: #{e.class}: #{e.message}")
51+
false
52+
end
53+
end
54+
end
55+
end
56+
end

config/locales/en.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@
2828
# enabled: "ON"
2929

3030
en:
31+
activerecord:
32+
attributes:
33+
game_medium:
34+
file: "File"
35+
errors:
36+
models:
37+
game_medium:
38+
attributes:
39+
file:
40+
blank: "is missing"
41+
invalid_type: "must be JPEG, PNG, WebP, MP4 or MOV"
42+
too_large: "is too big: up to 5 MB for photos and 25 MB for video"
43+
base:
44+
too_many_videos: "A game can hold only one video"
45+
too_many_images: "A game can hold at most six photos"
3146
errors:
3247
messages:
3348
pending_exists: "already has a pending suggestion for this court"

config/locales/es.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
es:
2+
activerecord:
3+
attributes:
4+
game_medium:
5+
file: "Archivo"
6+
errors:
7+
models:
8+
game_medium:
9+
attributes:
10+
file:
11+
blank: "falta"
12+
invalid_type: "debe ser JPEG, PNG, WebP, MP4 o MOV"
13+
too_large: "es demasiado grande: hasta 5 MB para fotos y 25 MB para vídeo"
14+
base:
15+
too_many_videos: "Un partido admite un solo vídeo"
16+
too_many_images: "Un partido admite como máximo seis fotos"
217
errors:
318
messages:
419
pending_exists: "ya tiene una propuesta pendiente para esta pista"

config/locales/ru.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
ru:
2+
activerecord:
3+
attributes:
4+
game_medium:
5+
file: "Файл"
6+
errors:
7+
models:
8+
game_medium:
9+
attributes:
10+
file:
11+
blank: "не выбран"
12+
invalid_type: "должен быть JPEG, PNG, WebP, MP4 или MOV"
13+
too_large: "слишком большой: фото — до 5 МБ, видео — до 25 МБ"
14+
base:
15+
too_many_videos: "К игре можно приложить только одно видео"
16+
too_many_images: "К игре можно приложить не больше шести фото"
217
errors:
318
messages:
419
pending_exists: "уже имеет предложение для этого корта на проверке"

config/locales/telegram.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ ru:
7777
invite_skipped_self: 'Пропущены (это вы): %{users}'
7878
game_or_user_not_found: Игра или пользователь не найдены
7979
joined_game: Вы присоединились к игре
80-
chat_started: "Теперь вы пишете в чат игры %{game}. Сообщения увидят участники, подключённые к Telegram (%{count})."
80+
chat_started: "Теперь вы пишете в чат игры %{game}. Кроме вас, сообщения увидят участники, подключённые к Telegram (%{count})."
8181
chat_lifetime: "Чат живёт столько же, сколько состав: до субботы, 4:00."
8282
chat_switch_btn: "Сменить игру"
8383
chat_exit_btn: "Выйти из чата"
84-
chat_stopped: "Вышли из чата. Сообщения боту снова видите только вы."
84+
chat_stopped: "Вышли из чата. Сообщения боту снова видите только вы. Вернуться — командой /chat."
85+
chat_closed_reset: "Чат игры %{game} закрыт: состав сброшен до следующей игры. Запишитесь снова — и чат откроется."
86+
chat_closed_finished: "Игра %{game} прошла — её чат закрыт."
8587
chat_pick_prompt: "В какую игру писать?"
8688
chat_none: "Нет игр, в чат которых можно писать."
8789
chat_not_available: "Чат этой игры недоступен: игра прошла или вас нет в составе."
@@ -685,11 +687,13 @@ en:
685687
invite_skipped_self: 'Skipped self: %{users}'
686688
game_or_user_not_found: Game or user not found
687689
joined_game: You joined the game
688-
chat_started: "You are now writing to game %{game}. Players connected to Telegram will see your messages (%{count})."
690+
chat_started: "You are now writing to game %{game}. Besides you, players connected to Telegram will see your messages (%{count})."
689691
chat_lifetime: "The chat lives as long as the squad does: until Saturday, 4:00."
690692
chat_switch_btn: "Switch game"
691693
chat_exit_btn: "Leave chat"
692-
chat_stopped: "Left the chat. Messages to the bot are private again."
694+
chat_stopped: "Left the chat. Messages to the bot are private again. Send /chat to come back."
695+
chat_closed_reset: "The chat for game %{game} is closed: the squad has been reset for the next game. Join again and the chat will reopen."
696+
chat_closed_finished: "Game %{game} is over — its chat is closed."
693697
chat_pick_prompt: "Which game do you want to write to?"
694698
chat_none: "No games with an open chat."
695699
chat_not_available: "This game's chat is unavailable: the game is over or you are not in the squad."
@@ -1250,11 +1254,13 @@ es:
12501254
invite_skipped_self: 'Omitidos (eres tú): %{users}'
12511255
game_or_user_not_found: Partido o usuario no encontrado
12521256
joined_game: Te has unido al partido
1253-
chat_started: "Ahora escribes al partido %{game}. Lo verán los jugadores conectados a Telegram (%{count})."
1257+
chat_started: "Ahora escribes al partido %{game}. Además de ti, lo verán los jugadores conectados a Telegram (%{count})."
12541258
chat_lifetime: "El chat vive lo mismo que la alineación: hasta el sábado a las 4:00."
12551259
chat_switch_btn: "Cambiar de partido"
12561260
chat_exit_btn: "Salir del chat"
1257-
chat_stopped: "Has salido del chat. Tus mensajes al bot vuelven a ser privados."
1261+
chat_stopped: "Has salido del chat. Tus mensajes al bot vuelven a ser privados. Escribe /chat para volver."
1262+
chat_closed_reset: "El chat del partido %{game} está cerrado: la alineación se ha reiniciado hasta el próximo partido. Apúntate de nuevo y el chat volverá a abrirse."
1263+
chat_closed_finished: "El partido %{game} ha terminado: su chat está cerrado."
12581264
chat_pick_prompt: "¿A qué partido quieres escribir?"
12591265
chat_none: "No hay partidos con chat abierto."
12601266
chat_not_available: "El chat de este partido no está disponible: ya terminó o no estás en la alineación."

test/jobs/cleanup_past_one_off_games_job_test.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require "test_helper"
22

33
class CleanupPastOneOffGamesJobTest < ActiveJob::TestCase
4+
include StubHelper
5+
46
def setup
57
@user = users(:one)
68
@court = courts(:one)
@@ -77,4 +79,23 @@ def setup
7779
assert_nil Game.find_by(id: game.id)
7880
assert_equal 0, Participation.where(game_id: game.id).count
7981
end
82+
83+
# Разовая игра исчезает вместе с чатом, и текст письма ссылается на её город и
84+
# дату — значит, собрать его надо до удаления записи.
85+
test "tells the squad that the chat died with the game" do
86+
player = User.create!(email: "oneoff-chat@example.com", telegram_chat_id: 910_200_001, telegram_locale: "ru")
87+
game = Game.create!(user: @user, court: @court, date: Date.yesterday, recurring: false)
88+
game.participations.create!(user: player, status: "approved", approved_at: Time.current)
89+
90+
sent = []
91+
stub_singleton(SendTelegramNotificationJob, :perform_later, ->(chat_id, text) { sent << [ chat_id, text ] }) do
92+
CleanupPastOneOffGamesJob.perform_now
93+
end
94+
95+
assert_nil Game.find_by(id: game.id)
96+
notice = sent.find { |chat_id, _| chat_id == player.telegram_chat_id.to_s }
97+
assert notice, "участнику удалённой игры должно уйти письмо о закрытии чата"
98+
assert_match "чат", notice.last
99+
assert_no_match(/translation missing/i, notice.last)
100+
end
80101
end

test/jobs/reset_participations_job_test.rb

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

34
class ResetParticipationsJobTest < ActiveJob::TestCase
5+
include StubHelper
6+
include CacheHelper
7+
48
setup do
59
@owner = users(:one)
610
@player = User.create!(email: "reset-player@example.com", notification_channel: "email", locale: "en")
@@ -44,4 +48,47 @@ class ResetParticipationsJobTest < ActiveJob::TestCase
4448
assert_empty game.participations.reload
4549
assert_equal game.next_date, game.reload.last_participations_reset_at
4650
end
51+
52+
# Сброс идёт ночью и уносит с собой чат. Без письма человек узнал бы об этом
53+
# только по тому, что его сообщение никому не дошло, — а оно уходит молча.
54+
test "tells the players it drops that their chat is gone" do
55+
player = User.create!(email: "reset-chat@example.com", telegram_chat_id: 910_100_001, telegram_locale: "ru")
56+
game = Game.create!(
57+
court: courts(:one), user: @owner, date: Date.current - 14.days, time: "18:00", recurring: true
58+
)
59+
game.participations.create!(user: player, status: "approved", approved_at: Time.current)
60+
61+
sent = []
62+
with_memory_cache do
63+
Telegram::Chat::Session.start(player.telegram_chat_id.to_s, game)
64+
65+
stub_singleton(SendTelegramNotificationJob, :perform_later, ->(chat_id, text) { sent << [ chat_id, text ] }) do
66+
ResetParticipationsJob.perform_now
67+
end
68+
69+
# Указатель гасим тут же: писать этому составу человек больше не вправе.
70+
assert_nil Telegram::Chat::Session.active_game(player.telegram_chat_id.to_s, player)
71+
end
72+
73+
assert_equal [ player.telegram_chat_id.to_s ], sent.map(&:first)
74+
assert_match "закрыт", sent.first.last
75+
assert_no_match(/translation missing/i, sent.first.last)
76+
end
77+
78+
# Организатор из состава не выпадает — ему закрывать нечего.
79+
test "leaves the organiser alone" do
80+
@owner.update_column(:telegram_chat_id, 910_100_002)
81+
game = Game.create!(
82+
court: courts(:one), user: @owner, date: Date.current - 14.days, time: "18:00", recurring: true
83+
)
84+
85+
sent = []
86+
with_memory_cache do
87+
stub_singleton(SendTelegramNotificationJob, :perform_later, ->(chat_id, text) { sent << [ chat_id, text ] }) do
88+
ResetParticipationsJob.perform_now
89+
end
90+
end
91+
92+
assert_not_includes sent.map(&:first), @owner.telegram_chat_id.to_s
93+
end
4794
end

0 commit comments

Comments
 (0)