Skip to content

Commit 5fccad1

Browse files
authored
Merge pull request #174 from denis1011101/feature/game-chat-and-cities
Feature/game chat and cities
2 parents ba80f2e + fa64c7c commit 5fccad1

90 files changed

Lines changed: 3763 additions & 272 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env-example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,18 @@ GITHUB_REPO=
3131
BRANCH=
3232
SERVICE=
3333
HEALTHCHECK_URL=
34+
35+
# Внешний кросспостинг (app/services/social.rb). Незаполненная сеть просто
36+
# молчит — постим только туда, где есть ключи.
37+
APP_HOST=
38+
# Threads: токен из дев-программы Meta, из РФ пока недоступен.
39+
THREADS_ACCESS_TOKEN=
40+
THREADS_USER_ID=
41+
# Bluesky: обычный аккаунт по email, пароль — App Password из настроек профиля
42+
# (не основной пароль!). Идентификатор — хэндл вида getcourt.bsky.social.
43+
BLUESKY_IDENTIFIER=
44+
BLUESKY_APP_PASSWORD=
45+
# Nostr: приватный ключ в hex или nsec1..., релеи через запятую (по умолчанию
46+
# берётся встроенный список).
47+
NOSTR_SECRET_KEY=
48+
NOSTR_RELAYS=

app/controllers/concerns/location_filters.rb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,6 @@ module LocationFilters
149149
"Yuexiu District" => "CN"
150150
}.freeze
151151

152-
CITY_ALIASES = {
153-
"yekaterinburg" => "ekaterinburg"
154-
}.freeze
155-
156152
included do
157153
helper_method :country_name_for, :location_filter_labels, :country_cities_map_for_select,
158154
:city_country_map_for, :normalized_city
@@ -197,14 +193,11 @@ def location_filter_labels(country_code = params[:country], city_name = params[:
197193
end
198194

199195
def normalized_city(value)
200-
city = I18n.transliterate(value.to_s).downcase.strip.gsub(/\s+/, " ")
201-
return nil if city.blank?
202-
203-
CITY_ALIASES.fetch(city, city)
196+
City.normalize_name(value)
204197
end
205198

206199
def city_aliases_for(city_name)
207-
([ city_name ] + CITY_ALIASES.select { |_alias, canonical| canonical == city_name }.keys).uniq
200+
City.alias_names_for(city_name)
208201
end
209202

210203
def country_cities_map_for_select

app/controllers/game_media_controller.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ class GameMediaController < ApplicationController
44
# Прикладывать может организатор, админ и любой записавшийся участник:
55
# снимает обычно не тот, кто игру создал.
66
def create
7-
medium = @game.game_media.new(user: current_user, file: params[:file])
7+
medium = @game.game_media.new(
8+
user: current_user,
9+
file: params[:file],
10+
title: params[:title].to_s.strip.presence
11+
)
812

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

25+
# Витрину Tennis Life видно без логина, поэтому показ там включается вручную
26+
# и только автором вложения или админом. Права проверяем здесь: галку видит
27+
# каждый, кому мы её отрисовали, но это не аргумент — запрос может прийти и
28+
# от того, кому её не показывали.
29+
def update
30+
medium = @game.game_media.find(params[:id])
31+
32+
unless medium.user_id == current_user.id || current_user.admin?
33+
redirect_to @game, alert: t("game_media.not_allowed"), status: :see_other and return
34+
end
35+
36+
medium.update!(show_in_feed: ActiveModel::Type::Boolean.new.cast(params[:show_in_feed]))
37+
notice = medium.show_in_feed? ? t("game_media.feed_enabled") : t("game_media.feed_disabled")
38+
redirect_to @game, notice: notice, status: :see_other
39+
end
40+
2141
# Автор убирает своё вложение совсем; админ прячет чужое, оставляя запись —
2242
# так видно, что модерация была, и файл не пропадает у автора из-под ног.
2343
def destroy

app/controllers/games_controller.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,6 @@ def decline_coach_invitation
132132
def toggle_urgent_player_search
133133
was_enabled = @game.urgent_player_search?
134134
@game.update!(urgent_player_search: !was_enabled)
135-
if !was_enabled && @game.urgent_player_search?
136-
PostToThreadsJob.perform_later(@game.id, I18n.locale.to_s)
137-
end
138135
state = @game.urgent_player_search? ? "enabled" : "disabled"
139136
redirect_to @game, notice: "Players search #{state}."
140137
end

app/helpers/application_helper.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,7 @@ def absolute_url(path)
191191
end
192192

193193
def normalize_telegram_username(username)
194-
candidate = username.to_s.strip.delete_prefix("@")
195-
return if candidate.blank?
196-
return unless candidate.match?(/\A[A-Za-z0-9_]{5,32}\z/)
197-
198-
candidate
194+
User.normalize_telegram_username(username)
199195
end
200196

201197
# The handle a person is actually known by: @nick for those who came from the
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Один пост в день, каждый раз другой. Если материала нет — молчим: третий
2+
# «⏱ 12 hours played» за неделю хуже пустого дня, в том числе для
3+
# automated-labeling у модерации Bluesky.
4+
class PostDailySocialPostJob < ApplicationJob
5+
queue_as :default
6+
7+
def perform
8+
content = Social::DailyPlanner.new.pick
9+
10+
if content.nil?
11+
Rails.logger.info("[Social] daily post skipped: no fresh material")
12+
return
13+
end
14+
15+
Social.publish(content)
16+
end
17+
end

app/jobs/post_social_job.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Одна джоба на все типы постов и все сети. Каждый ранний выход логируем с
2+
# причиной: без этого отладка токенов превращается в гадание.
3+
class PostSocialJob < ApplicationJob
4+
queue_as :default
5+
6+
CLAIM_TIMEOUT = 30.minutes
7+
8+
def perform(kind, dedup_key, network)
9+
adapter = Social.adapter_for(network)
10+
return log(kind, dedup_key, network, "unknown network") unless adapter
11+
return log(kind, dedup_key, network, "adapter not configured") unless adapter.configured?
12+
13+
content = Social::Content.build(kind, dedup_key)
14+
return log(kind, dedup_key, network, "no content") unless content
15+
return log(kind, dedup_key, network, "material is gone") unless content.available?
16+
17+
claim = claim(kind, dedup_key, network)
18+
return log(kind, dedup_key, network, "already posted or claimed") unless claim
19+
20+
begin
21+
post_id = adapter.new(content: content, locale: Social.locale_for(network)).call
22+
rescue StandardError
23+
release(claim)
24+
raise
25+
end
26+
27+
unless post_id
28+
release(claim)
29+
return log(kind, dedup_key, network, "adapter returned nothing")
30+
end
31+
32+
complete(claim, post_id)
33+
end
34+
35+
private
36+
37+
def claim(kind, dedup_key, network)
38+
token = "claim:#{SecureRandom.uuid}"
39+
post = SocialPost.create!(network: network, kind: kind, dedup_key: dedup_key,
40+
external_post_id: token, posted_at: nil)
41+
[ post.id, token ]
42+
rescue ActiveRecord::RecordNotUnique
43+
post = SocialPost.find_by!(network: network, kind: kind, dedup_key: dedup_key)
44+
return if post.posted_at? || post.updated_at >= CLAIM_TIMEOUT.ago
45+
46+
claimed_at = Time.current
47+
taken = SocialPost.where(id: post.id, posted_at: nil)
48+
.where("updated_at < ?", CLAIM_TIMEOUT.ago)
49+
.update_all(external_post_id: token, updated_at: claimed_at)
50+
[ post.id, token ] if taken == 1
51+
end
52+
53+
def release(claim)
54+
id, token = claim
55+
SocialPost.where(id: id, external_post_id: token, posted_at: nil).delete_all
56+
end
57+
58+
def complete(claim, post_id)
59+
id, token = claim
60+
now = Time.current
61+
updated = SocialPost.where(id: id, external_post_id: token, posted_at: nil)
62+
.update_all(external_post_id: post_id, posted_at: now, updated_at: now)
63+
raise "Social post claim was lost" unless updated == 1
64+
end
65+
66+
def log(kind, dedup_key, network, reason)
67+
Rails.logger.info("[Social] skip #{network} #{kind}/#{dedup_key}: #{reason}")
68+
nil
69+
end
70+
end

app/jobs/post_to_threads_job.rb

Lines changed: 0 additions & 23 deletions
This file was deleted.

app/jobs/reset_participations_job.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def perform
1212
game.mark_participations_reset!(nd)
1313
Rails.logger.info "Reset participations from prebookings for Game##{game.id} for occurrence #{nd}"
1414
else
15+
close_chat_sessions(game)
1516
game.participations.delete_all
1617
game.mark_participations_reset!(nd)
1718
Rails.logger.info "Reset participations for Game##{game.id} for occurrence #{nd}"
@@ -22,6 +23,16 @@ def perform
2223

2324
private
2425

26+
# delete_all идёт мимо колбэков Participation, поэтому режим чата у выбывших
27+
# гасим здесь — иначе они продолжат писать в состав, из которого их убрали.
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
32+
rescue StandardError => e
33+
Rails.logger.warn("[ResetParticipationsJob] chat cleanup failed for Game##{game.id}: #{e.class}: #{e.message}")
34+
end
35+
2536
# Promote users from prebookings on nd into participations, shift next prebookings up,
2637
# and append a new empty prebooking date at the end.
2738
def apply_prebookings_for_occurrence!(game, nd)

app/jobs/send_training_video_job.rb

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,21 @@ def build_notification(medium)
3232
**Rails.application.config.action_mailer.default_url_options.to_h.symbolize_keys
3333
)
3434
# Ролик может выложить не только организатор, но и админ или принятый
35-
# тренер, поэтому имя берём из самой загрузки. Без имени — нейтральный
36-
# текст: приписывать видео организатору наугад нельзя.
37-
author = medium.user&.name.to_s.strip.presence
35+
# тренер, поэтому имя берём из самой загрузки. Без имени и ника —
36+
# нейтральный текст: приписывать видео организатору наугад нельзя.
37+
author = medium.user&.broadcast_label
38+
title = medium.title.presence
39+
# Четыре формулировки вместо склейки из кусков: подставлять имя и название
40+
# в обрубки фразы — значит ломать порядок слов в других языках.
41+
key = "game_media.shared_video.body"
42+
key += "_by" if author
43+
key += "_titled" if title
3844

3945
NotificationDelivery::Notification.new(
40-
subject: ->(locale) { I18n.t("game_media.training_video.subject", locale: locale) },
41-
body: lambda { |locale|
42-
if author
43-
I18n.t("game_media.training_video.body_by", author: author, locale: locale)
44-
else
45-
I18n.t("game_media.training_video.body", locale: locale)
46-
end
47-
},
46+
subject: ->(locale) { I18n.t("game_media.shared_video.subject", locale: locale) },
47+
body: ->(locale) { I18n.t(key, author: author, title: title, locale: locale) },
4848
actions: lambda { |locale|
49-
[ { label: I18n.t("game_media.training_video.action", locale: locale), url: url } ]
49+
[ { label: I18n.t("game_media.shared_video.action", locale: locale), url: url } ]
5050
}
5151
)
5252
end

0 commit comments

Comments
 (0)