Feature/game chat and cities - #174
Conversation
normalized_city жил в LocationFilters — концерне контроллеров, поэтому до него дотягивался только веб. Модель и телеграм-хендлеры сравнивали города сырым downcase, и «Ekaterinburg» у пользователя не совпадал с «Yekaterinburg» у кортов: Court.sorted_for_user, список турниров в боте и поиск тренера молча считали город чужим. Третья копия нашлась в TournamentsFlow: там ведущая Y перед гласной срезалась регуляркой, то есть York превращался в «ork», а Yokohama — в «okohama». Теперь все шесть точек зовут City.normalize_name, а таблица вариантов написания лежит в модели рядом с canonical_name. Заодно город в боте больше не проходит через titleize при выводе — он ломает дефисные названия, а из справочника город и так приходит каноническим. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs32cQQXRoYnQCqdL8jTZK
Первый срез: текст, без медиа и без веб-зеркала. Режим включается только явным нажатием кнопки — принятое приглашение само по себе ничего не включает, иначе согласием оказывалось бы бездействие. Игру выбираем сразу: одна — подтверждаем, несколько — показываем клавиатуру, потому что неявный дефолт это ровно тот отказ, которого мы избегаем, — сообщение уходит не в ту компанию. Указатель активной игры живёт в кеше: его потеря безопасна, бот просто снова спросит игру. Права он не даёт — участие и открытость чата перепроверяются и перед постановкой в очередь, и перед каждой отправкой, потому что человека могли вывести из состава в любой момент. Дедуп по message_id обязателен: Poller держит offset только в памяти процесса, после рестарта Telegram отдаёт неподтверждённые обновления заново, а деплой ходит по крону. Рассылка — по джобе на получателя, с переносом по retry_after на 429: лимит примерно одно сообщение в секунду на чат, и упершаяся доставка не должна задерживать остальных. Отправляем без parse_mode — это чужой текст, а не наш шаблон, и одиночный `_` или `[` иначе роняет отправку. Автовыход: конец дня игры, выход из состава (колбэки Participation) и пакетный сброс в ResetParticipationsJob, который идёт мимо колбэков. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs32cQQXRoYnQCqdL8jTZK
…постинг Вложения к игре: - game_media.show_in_feed: попадание в публичную ленту Tennis Life стало отдельным действием автора или админа, а не следствием загрузки. Права проверяет контроллер, а не факт отрисовки галки. Уже загруженное миграция помечает показанным: снимать с витрины задним числом чужие фото нельзя. - game_media.title: необязательное название, видно подписью в карточке и в тексте уведомления. - Уведомление о видео больше не называет обучающим ролик из обычной игры. Подпись автора собирает User#broadcast_label — имя и @ник, но никогда e-mail: письмо уходит всей команде. - Нормализация телеграм-ника переехала из хелпера в User, хелпер делегирует. - Раздел на странице игры оформлен как остальные секции: карточка с рамкой, удаление в подписи вместо оверлея, форма загрузки в своей рамке. Кросспостинг: Social::* с общим интерфейсом для threads, bluesky и nostr, ежедневная задача PostDailySocialPostJob и таблица social_posts с ключом дедупликации. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs32cQQXRoYnQCqdL8jTZK
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0603c05438
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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) |
There was a problem hiding this comment.
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 👍 / 👎.
| def available? | ||
| case variant | ||
| # Дата в ключе, а джоба могла отлежаться в очереди — «Tomorrow» должно | ||
| # оставаться завтрашним днём, иначе анонс уйдёт задним числом. | ||
| when "upcoming" then game.present? && game.occurrence_date?(date + 1) && game.spots_left.positive? |
There was a problem hiding this comment.
Recheck cancellation before publishing upcoming games
If an occurrence is cancelled after DailyPlanner selects it but before the queued network jobs run, this check still considers the content available and publishes a “Tomorrow” invitation for the cancelled game. The planner initially excludes game.cancelled_on?(date + 1), so the delayed availability check needs to enforce the same condition.
Useful? React with 👍 / 👎.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
PostSocialJob занимал ключ уже после похода в сеть, так что быстрый цикл «выключил-включил срочный поиск» при нескольких воркерах мог дать два публичных поста: уникальный индекс спасал базу, но не отменял второй пост. Теперь заявка на dedup_key создаётся до вызова адаптера и снимается при ошибке, а брошенная заявка перехватывается через 30 минут — иначе упавший воркер заблокировал бы материал навсегда. Операции идут по токену заявки, чтобы не затереть чужой перехват. Social::Content::Daily перепроверяла дату занятия, но не отмену, и анонс отменённой игры всё равно ушёл бы наружу. Telegram::DeliverChatMessageJob считал успехом всё, кроме 429, поэтому пятисотка от Telegram молча теряла сообщение чата. Временные ошибки повторяются с backoff, постоянные логируются. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUNN1ijLP4d1rm9c7hL4pa
No description provided.