Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,18 @@ GITHUB_REPO=
BRANCH=
SERVICE=
HEALTHCHECK_URL=

# Внешний кросспостинг (app/services/social.rb). Незаполненная сеть просто
# молчит — постим только туда, где есть ключи.
APP_HOST=
# Threads: токен из дев-программы Meta, из РФ пока недоступен.
THREADS_ACCESS_TOKEN=
THREADS_USER_ID=
# Bluesky: обычный аккаунт по email, пароль — App Password из настроек профиля
# (не основной пароль!). Идентификатор — хэндл вида getcourt.bsky.social.
BLUESKY_IDENTIFIER=
BLUESKY_APP_PASSWORD=
# Nostr: приватный ключ в hex или nsec1..., релеи через запятую (по умолчанию
# берётся встроенный список).
NOSTR_SECRET_KEY=
NOSTR_RELAYS=
11 changes: 2 additions & 9 deletions app/controllers/concerns/location_filters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,6 @@ module LocationFilters
"Yuexiu District" => "CN"
}.freeze

CITY_ALIASES = {
"yekaterinburg" => "ekaterinburg"
}.freeze

included do
helper_method :country_name_for, :location_filter_labels, :country_cities_map_for_select,
:city_country_map_for, :normalized_city
Expand Down Expand Up @@ -197,14 +193,11 @@ def location_filter_labels(country_code = params[:country], city_name = params[:
end

def normalized_city(value)
city = I18n.transliterate(value.to_s).downcase.strip.gsub(/\s+/, " ")
return nil if city.blank?

CITY_ALIASES.fetch(city, city)
City.normalize_name(value)
end

def city_aliases_for(city_name)
([ city_name ] + CITY_ALIASES.select { |_alias, canonical| canonical == city_name }.keys).uniq
City.alias_names_for(city_name)
end

def country_cities_map_for_select
Expand Down
22 changes: 21 additions & 1 deletion app/controllers/game_media_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ class GameMediaController < ApplicationController
# Прикладывать может организатор, админ и любой записавшийся участник:
# снимает обычно не тот, кто игру создал.
def create
medium = @game.game_media.new(user: current_user, file: params[:file])
medium = @game.game_media.new(
user: current_user,
file: params[:file],
title: params[:title].to_s.strip.presence
)

unless contributor?
redirect_to @game, alert: t("game_media.not_allowed"), status: :see_other and return
Expand All @@ -18,6 +22,22 @@ def create
end
end

# Витрину Tennis Life видно без логина, поэтому показ там включается вручную
# и только автором вложения или админом. Права проверяем здесь: галку видит
# каждый, кому мы её отрисовали, но это не аргумент — запрос может прийти и
# от того, кому её не показывали.
def update
medium = @game.game_media.find(params[:id])

unless medium.user_id == current_user.id || current_user.admin?
redirect_to @game, alert: t("game_media.not_allowed"), status: :see_other and return
end

medium.update!(show_in_feed: ActiveModel::Type::Boolean.new.cast(params[:show_in_feed]))
notice = medium.show_in_feed? ? t("game_media.feed_enabled") : t("game_media.feed_disabled")
redirect_to @game, notice: notice, status: :see_other
end

# Автор убирает своё вложение совсем; админ прячет чужое, оставляя запись —
# так видно, что модерация была, и файл не пропадает у автора из-под ног.
def destroy
Expand Down
3 changes: 0 additions & 3 deletions app/controllers/games_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,6 @@ def decline_coach_invitation
def toggle_urgent_player_search
was_enabled = @game.urgent_player_search?
@game.update!(urgent_player_search: !was_enabled)
if !was_enabled && @game.urgent_player_search?
PostToThreadsJob.perform_later(@game.id, I18n.locale.to_s)
end
state = @game.urgent_player_search? ? "enabled" : "disabled"
redirect_to @game, notice: "Players search #{state}."
end
Expand Down
6 changes: 1 addition & 5 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,7 @@ def absolute_url(path)
end

def normalize_telegram_username(username)
candidate = username.to_s.strip.delete_prefix("@")
return if candidate.blank?
return unless candidate.match?(/\A[A-Za-z0-9_]{5,32}\z/)

candidate
User.normalize_telegram_username(username)
end

# The handle a person is actually known by: @nick for those who came from the
Expand Down
17 changes: 17 additions & 0 deletions app/jobs/post_daily_social_post_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Один пост в день, каждый раз другой. Если материала нет — молчим: третий
# «⏱ 12 hours played» за неделю хуже пустого дня, в том числе для
# automated-labeling у модерации Bluesky.
class PostDailySocialPostJob < ApplicationJob
queue_as :default

def perform
content = Social::DailyPlanner.new.pick

if content.nil?
Rails.logger.info("[Social] daily post skipped: no fresh material")
return
end

Social.publish(content)
end
end
42 changes: 42 additions & 0 deletions app/jobs/post_social_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Одна джоба на все типы постов и все сети. Каждый ранний выход логируем с
# причиной: без этого отладка токенов превращается в гадание.
class PostSocialJob < ApplicationJob
queue_as :default

def perform(kind, dedup_key, network)
adapter = Social.adapter_for(network)
return log(kind, dedup_key, network, "unknown network") unless adapter
return log(kind, dedup_key, network, "adapter not configured") unless adapter.configured?
return log(kind, dedup_key, network, "already posted") if already_posted?(kind, dedup_key, network)

content = Social::Content.build(kind, dedup_key)
return log(kind, dedup_key, network, "no content") unless content
return log(kind, dedup_key, network, "material is gone") unless content.available?

post_id = adapter.new(content: content, locale: Social.locale_for(network)).call
return log(kind, dedup_key, network, "adapter returned nothing") unless post_id

record(kind, dedup_key, network, post_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve the dedup key before publishing

When two jobs for the same network/material overlap—for example after a rapid urgent-search off/on cycle—both can pass already_posted? and call the external adapter. The unique index is only exercised afterward, so rescuing RecordNotUnique prevents a duplicate database row but cannot undo the second public post. Claim or lock the dedup record before performing the external side effect.

Useful? React with 👍 / 👎.

end

private

def already_posted?(kind, dedup_key, network)
SocialPost.exists?(network: network, kind: kind, dedup_key: dedup_key)
end

def record(kind, dedup_key, network, post_id)
SocialPost.create!(
network: network, kind: kind, dedup_key: dedup_key,
external_post_id: post_id, posted_at: Time.current
)
rescue ActiveRecord::RecordNotUnique
# Две джобы на один материал разошлись по времени — пост уже записан.
log(kind, dedup_key, network, "duplicate record")
end

def log(kind, dedup_key, network, reason)
Rails.logger.info("[Social] skip #{network} #{kind}/#{dedup_key}: #{reason}")
nil
end
end
23 changes: 0 additions & 23 deletions app/jobs/post_to_threads_job.rb

This file was deleted.

11 changes: 11 additions & 0 deletions app/jobs/reset_participations_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def perform
game.mark_participations_reset!(nd)
Rails.logger.info "Reset participations from prebookings for Game##{game.id} for occurrence #{nd}"
else
close_chat_sessions(game)
game.participations.delete_all
game.mark_participations_reset!(nd)
Rails.logger.info "Reset participations for Game##{game.id} for occurrence #{nd}"
Expand All @@ -22,6 +23,16 @@ def perform

private

# delete_all идёт мимо колбэков Participation, поэтому режим чата у выбывших
# гасим здесь — иначе они продолжат писать в состав, из которого их убрали.
def close_chat_sessions(game)
game.participations.includes(:user).find_each do |participation|
Telegram::Chat::Session.stop_for(participation.user, game)
end
rescue StandardError => e
Rails.logger.warn("[ResetParticipationsJob] chat cleanup failed for Game##{game.id}: #{e.class}: #{e.message}")
end

# Promote users from prebookings on nd into participations, shift next prebookings up,
# and append a new empty prebooking date at the end.
def apply_prebookings_for_occurrence!(game, nd)
Expand Down
24 changes: 12 additions & 12 deletions app/jobs/send_training_video_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,21 @@ def build_notification(medium)
**Rails.application.config.action_mailer.default_url_options.to_h.symbolize_keys
)
# Ролик может выложить не только организатор, но и админ или принятый
# тренер, поэтому имя берём из самой загрузки. Без имени — нейтральный
# текст: приписывать видео организатору наугад нельзя.
author = medium.user&.name.to_s.strip.presence
# тренер, поэтому имя берём из самой загрузки. Без имени и ника —
# нейтральный текст: приписывать видео организатору наугад нельзя.
author = medium.user&.broadcast_label
title = medium.title.presence
# Четыре формулировки вместо склейки из кусков: подставлять имя и название
# в обрубки фразы — значит ломать порядок слов в других языках.
key = "game_media.shared_video.body"
key += "_by" if author
key += "_titled" if title

NotificationDelivery::Notification.new(
subject: ->(locale) { I18n.t("game_media.training_video.subject", locale: locale) },
body: lambda { |locale|
if author
I18n.t("game_media.training_video.body_by", author: author, locale: locale)
else
I18n.t("game_media.training_video.body", locale: locale)
end
},
subject: ->(locale) { I18n.t("game_media.shared_video.subject", locale: locale) },
body: ->(locale) { I18n.t(key, author: author, title: title, locale: locale) },
actions: lambda { |locale|
[ { label: I18n.t("game_media.training_video.action", locale: locale), url: url } ]
[ { label: I18n.t("game_media.shared_video.action", locale: locale), url: url } ]
}
)
end
Expand Down
44 changes: 44 additions & 0 deletions app/jobs/telegram/deliver_chat_message_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module Telegram
# Доставка одного сообщения чата одному человеку. Отдельная джоба на
# получателя: Telegram ограничивает примерно одним сообщением в секунду на
# чат, и упершаяся в лимит доставка не должна задерживать остальных.
class DeliverChatMessageJob < ApplicationJob
queue_as :default

MAX_RETRY_WAIT = 5.minutes

def perform(game_id, recipient_id, text)
game = Game.find_by(id: game_id)
recipient = User.find_by(id: recipient_id)
return unless game && recipient && recipient.telegram_chat_id.present?

# Проверяем участие именно перед отправкой: между постановкой в очередь и
# доставкой человека могли вывести из состава.
return unless game.chat_open? && game.team_member_ids.include?(recipient.id)

# Без parse_mode: это чужой текст, а не наш шаблон. С Markdown одиночный
# `_` или `[` либо исказит сообщение, либо уронит отправку четырёхсоткой.
response = Telegram::Api.post("sendMessage", {
"chat_id" => recipient.telegram_chat_id.to_s,
"link_preview_options" => Telegram::Api::LINK_PREVIEW_DISABLED,
"text" => text.to_s
})

handle_rate_limit(response, game_id, recipient_id, text)
end

private

def handle_rate_limit(response, game_id, recipient_id, text)
return true if response.is_a?(Hash) && response["ok"]
return false unless response.is_a?(Hash) && response["error_code"].to_i == 429

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry transient non-429 Telegram failures

When Telegram returns a parsed failure response such as { "ok": false, "error_code": 500 }, Telegram::Api.post returns that hash, but this branch treats every non-429 error as a successful job completion. A transient Telegram server failure therefore silently loses the chat message instead of retrying; distinguish retryable 5xx responses from permanent request errors and raise or reschedule them.

Useful? React with 👍 / 👎.


wait = response.dig("parameters", "retry_after").to_i
wait = 1 if wait <= 0
return false if wait > MAX_RETRY_WAIT.to_i

self.class.set(wait: wait.seconds).perform_later(game_id, recipient_id, text)
false
end
end
end
22 changes: 22 additions & 0 deletions app/jobs/telegram/relay_chat_message_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Telegram
# Разбор одного сообщения чата: кто его увидит. Сама отправка — в
# DeliverChatMessageJob, по джобе на получателя.
class RelayChatMessageJob < ApplicationJob
queue_as :default

def perform(game_id, sender_id, body)
game = Game.find_by(id: game_id)
sender = User.find_by(id: sender_id)
return unless game && sender && body.present?

# Отправитель тоже мог выйти из состава, пока сообщение ждало очереди.
return unless game.chat_open? && game.team_member_ids.include?(sender.id)

text = Telegram::Chat::Message.render(game: game, sender: sender, body: body)

game.chat_members.where.not(id: sender.id).find_each do |recipient|
Telegram::DeliverChatMessageJob.perform_later(game.id, recipient.id, text)
end
end
end
end
25 changes: 25 additions & 0 deletions app/models/city.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@ def self.country_codes_for(names)
.transform_values { |rows| rows.max_by { |(_, _, population)| population.to_i }[1] }
end

# Написания одного города, разошедшиеся по базе: у пользователей город долго
# был свободным текстом, у кортов он приходит из геокодера. Ключ — вариант,
# значение — то, к чему приводим.
NAME_ALIASES = {
"yekaterinburg" => "ekaterinburg"
}.freeze

# Единая нормализация названия города для сравнений. Живёт в модели, потому
# что сравнивают города и контроллеры, и модели, и телеграм-хендлеры; пока
# это лежало в концерне контроллеров, до него дотягивались не все, и часть
# мест сравнивала сырой downcase — для «Ekaterinburg» против «Yekaterinburg»
# это молчаливое «город не совпал».
def self.normalize_name(value)
name = I18n.transliterate(value.to_s).downcase.strip.gsub(/\s+/, " ")
return nil if name.blank?

NAME_ALIASES.fetch(name, name)
end

# Все написания, которые нормализуются в это же название, — нужны там, где
# город ищут запросом по справочнику, а не сравнением в памяти.
def self.alias_names_for(name)
([ name ] + NAME_ALIASES.select { |_variant, canonical| canonical == name }.keys).uniq
end

# Каноническое имя города для профиля и матчинга с courts.city_name: берём
# name, а не asciiname — в кортах города записаны с диакритикой («Båstad»,
# «Acapulco de Juárez»), и asciiname с ними бы не совпал.
Expand Down
4 changes: 2 additions & 2 deletions app/models/court.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ class Court < ApplicationRecord
def self.sorted_for_user(user)
# По id корты идут вперемешку, поэтому раскладываем их по городу и названию.
courts = all.to_a.sort_by { |court| [ court.city_name.to_s, court.name.to_s ] }
user_city = user&.city_name.to_s.strip.downcase.presence
user_city = City.normalize_name(user&.city_name)
return courts unless user_city

local, other = courts.partition do |court|
court.city_name.to_s.strip.downcase == user_city
City.normalize_name(court.city_name) == user_city
end
local + other
end
Expand Down
Loading