Skip to content
Merged
15 changes: 9 additions & 6 deletions app/jobs/game_reminder_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def notification_for(game, target_date, recipients, day_offset)
NotificationDelivery::Notification.new(
subject: ->(locale) { I18n.t("user_mailer.notification.#{subject_key}", locale: locale) },
body: ->(locale, channel) { reminder_text(game, target_date, recipients, day_offset, locale, channel, game_url) },
parse_mode: "HTML",
actions: lambda do |locale|
[ { label: I18n.t("user_mailer.notification.view_game", locale: locale), url: game_url, telegram: false } ]
end
Expand All @@ -87,12 +88,14 @@ def notification_for(game, target_date, recipients, day_offset)

def reminder_text(game, target_date, recipients, day_offset, locale, channel, game_url)
t = ->(key, **args) { Telegram::I18n.t(key, locale: locale, **args) }
esc = Telegram::Helpers::Markup.escaper(channel)
time = game.next_time || game.time
time_text = Telegram::Helpers::GameFormatting.format_time_hhmm(time, locale: locale) || "—:--"
when_text = day_offset == 1 ? t.call(:tomorrow) : t.call(:today)
court_name = game.court&.name || t.call(:unknown_court)
court_name = Telegram::Helpers::Markup.court_name(game.court, base_url: game_url, channel: channel) ||
esc.call(t.call(:unknown_court))
participant_names = recipients.filter_map do |user|
Telegram::Helpers::UserLookup.display_name(user, fallback: t.call(:user_fallback), channel: channel)
esc.call(Telegram::Helpers::UserLookup.display_name(user, fallback: t.call(:user_fallback), channel: channel))
end.join("\n")
head = t.call(
game.training? ? :reminder_head_training : :reminder_head,
Expand All @@ -102,10 +105,10 @@ def reminder_text(game, target_date, recipients, day_offset, locale, channel, ga
court: court_name
)
coach = Telegram::Helpers::GameFormatting.coach_mark(game, locale: locale, with_names: true, channel: channel)
title = coach ? "#{head} — #{coach}" : "#{head}."
program = Telegram::Helpers::GameFormatting.training_program(game)
program_line = t.call(:program_label, items: program) if program.present?
title = coach ? "#{head} — #{esc.call(coach)}" : "#{head}."
program = Telegram::Helpers::GameFormatting.training_program(game, locale: locale)
program_line = t.call(:program_label, items: esc.call(program)) if program.present?

[ title, program_line, "#{t.call(:participants_label)}\n#{participant_names}", game_url ].compact.join("\n\n")
[ title, program_line, "#{t.call(:participants_label)}\n#{participant_names}", esc.call(game_url) ].compact.join("\n\n")
end
end
42 changes: 30 additions & 12 deletions app/services/game_invitation_delivery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,8 @@ def notification_for(role)
key = coach_invitation ? "coach_invitation_subject" : "game_invitation_subject"
I18n.t("user_mailer.notification.#{key}", locale: locale, game_id: game.id)
end,
body: lambda do |locale, channel|
lines = [
Telegram::I18n.t(title_key(coach_invitation), locale: locale),
Telegram::Handlers::GamesHandler.game_label(game, owner: inviter, locale: locale, coach_names: true, channel: channel),
program_line(locale),
Telegram::I18n.t(:game_invitation_from, locale: locale, name: inviter_name(locale, channel))
]
"#{lines.compact.join("\n")}\n\n#{game_url}"
end,
body: ->(locale, channel) { body_text(locale, channel, coach_invitation) },
parse_mode: "HTML",
actions: lambda do |locale|
if coach_invitation
coach_actions(locale)
Expand All @@ -43,6 +36,23 @@ def notification_for(role)
)
end

# В телеграме сообщение уходит размеченным — ради ссылки на корт, — поэтому
# всё, что пришло от людей (названия, имена, план), экранируется. В письме
# тот же текст идёт без разметки: @body вставляется и в text-шаблон тоже.
def body_text(locale, channel, coach_invitation)
esc = Telegram::Helpers::Markup.escaper(channel)

lines = [
esc.(Telegram::I18n.t(title_key(coach_invitation), locale: locale)),
esc.(Telegram::Handlers::GamesHandler.game_label(game, owner: inviter, locale: locale, coach_names: true, channel: channel)),
court_line(locale, channel),
program_line(locale, esc),
esc.(Telegram::I18n.t(:game_invitation_from, locale: locale, name: inviter_name(locale, channel)))
]

"#{lines.compact.join("\n")}\n\n#{esc.(game_url)}"
end

# Шапка сразу говорит, куда зовут: в игру, на тренировку или тренером на неё.
def title_key(coach_invitation)
if coach_invitation
Expand All @@ -54,11 +64,19 @@ def title_key(coach_invitation)
end
end

# Куда ехать — вопрос, который задают первым. В напоминании корт назван, а в
# приглашении его не было вовсе: человек шёл за ним по ссылке.
def court_line(locale, channel)
name = Telegram::Helpers::Markup.court_name(game.court, base_url: game_url, channel: channel)

Telegram::I18n.t(:court_label, locale: locale, name: name) if name.present?
end

# План занятия — самое важное в приглашении на тренировку после времени и корта.
def program_line(locale)
program = Telegram::Helpers::GameFormatting.training_program(game)
def program_line(locale, esc)
program = Telegram::Helpers::GameFormatting.training_program(game, locale: locale)

Telegram::I18n.t(:program_label, locale: locale, items: program) if program.present?
Telegram::I18n.t(:program_label, locale: locale, items: esc.(program)) if program.present?
end

def inviter_name(locale, channel)
Expand Down
13 changes: 10 additions & 3 deletions app/services/notification_delivery.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
class NotificationDelivery
class Notification
def initialize(subject:, body:, actions: [])
# parse_mode нужен тем сообщениям, где есть разметка — например ссылка на
# корт в приглашении. Остальные остаются чистым текстом: тогда телеграму
# нечего разбирать и нечем подавиться на имени с «&».
attr_reader :parse_mode

def initialize(subject:, body:, actions: [], parse_mode: nil)
@subject = subject
@body = body
@actions = actions
@parse_mode = parse_mode
end

def subject(locale, channel = nil)
Expand Down Expand Up @@ -41,10 +47,11 @@ def self.deliver(user, notification)
end
keyboard = buttons.group_by(&:first).values.map { |row| row.map(&:last) }

parse_mode = notification.parse_mode
if keyboard.any?
Telegram::Api.send_with_buttons(user.telegram_chat_id, text, keyboard, parse_mode: nil)
Telegram::Api.send_with_buttons(user.telegram_chat_id, text, keyboard, parse_mode: parse_mode)
else
SendTelegramNotificationJob.perform_later(user.telegram_chat_id, text, parse_mode: nil)
SendTelegramNotificationJob.perform_later(user.telegram_chat_id, text, parse_mode: parse_mode)
end
end
end
Expand Down
11 changes: 9 additions & 2 deletions app/services/telegram/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,22 @@ def self.send_api(path, params = {})
nil
end

def self.send_with_buttons(chat_id, text, buttons, parse_mode: "Markdown")
# Превью ссылки телеграм рисует из своего кэша и обновляет его, когда сам
# сочтёт нужным: под приглашением висела карточка чужого корта и чужой даты.
# Всё нужное и так есть в тексте, поэтому по умолчанию превью выключено.
LINK_PREVIEW_DISABLED = { is_disabled: true }.to_json

def self.send_with_buttons(chat_id, text, buttons, parse_mode: "Markdown", link_preview: false)
params = { "chat_id" => chat_id.to_s, "text" => text.to_s, "reply_markup" => { inline_keyboard: buttons }.to_json }
params["parse_mode"] = parse_mode if parse_mode.present?
params["link_preview_options"] = LINK_PREVIEW_DISABLED unless link_preview
post("sendMessage", params)
end

def self.send_simple(chat_id, text, parse_mode: "Markdown")
def self.send_simple(chat_id, text, parse_mode: "Markdown", link_preview: false)
params = { "chat_id" => chat_id.to_s, "text" => text.to_s }
params["parse_mode"] = parse_mode if parse_mode.present?
params["link_preview_options"] = LINK_PREVIEW_DISABLED unless link_preview
post("sendMessage", params)
end

Expand Down
19 changes: 13 additions & 6 deletions app/services/telegram/handlers/games_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,28 @@ def game_label(g, owner: nil, locale: Telegram::I18n::DEFAULT_LOCALE, coach_name
date = Telegram::Helpers::GameFormatting.game_datetime(g, locale: locale)
title = Telegram::Helpers::GameFormatting.game_title(g, locale: locale)

# На тренировке мест не считают: на один корт выходит хоть десять
# человек, а «0 мест свободно» читается как «не приходи».
training = g.respond_to?(:training?) && g.training?
spots_text = spots_left_text(g, locale: locale) unless training
coach = Telegram::Helpers::GameFormatting.coach_mark(g, locale: locale, with_names: coach_names, channel: channel)

title_with_id = "#{title || (g.respond_to?(:title) && g.title.to_s.presence) || 'Game'} ##{g.id}"
[ title_with_id, date, spots_text, coach ].compact.join(" — ")
end

def spots_left_text(g, locale:)
required = (g.respond_to?(:players_count) && g.players_count.to_i > 0) ? g.players_count.to_i : 4
approved_count =
if g.participations.loaded?
g.participations.select { |p| p.respond_to?(:approved?) ? p.approved? : (p.status == "approved") }.size
else
g.participations.respond_to?(:approved) ? g.participations.approved.count : g.participations.count
end
taken = approved_count
spots_left = required - taken
spots_left = required - approved_count
spots_left = 0 if spots_left.negative?
spots_text = Telegram::I18n.spots_left_text(spots_left, locale: locale)
coach = Telegram::Helpers::GameFormatting.coach_mark(g, locale: locale, with_names: coach_names, channel: channel)

title_with_id = "#{title || (g.respond_to?(:title) && g.title.to_s.presence) || 'Game'} ##{g.id}"
[ title_with_id, date, spots_text, coach ].compact.join(" — ")
Telegram::I18n.spots_left_text(spots_left, locale: locale)
end

# [bot-menu-off] Отключено намеренно: пользуемся сайтом getcourt.co,
Expand Down
20 changes: 18 additions & 2 deletions app/services/telegram/helpers/game_formatting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,27 @@ def self.coach_names(game, locale: Telegram::I18n::DEFAULT_LOCALE, channel: :tel
end

# План занятия в одну строку: названия блоков без описаний и минут.
def self.training_program(game, limit: 160)
#
# Режем по целым блокам: «Подачи на т...» посреди слова не читается, а
# сколько блоков не поместилось — видно из хвоста. Предел нужен, чтобы
# длинный план не выбил всё сообщение за 4096 символов телеграма.
def self.training_program(game, limit: 600, locale: Telegram::I18n::DEFAULT_LOCALE)
return nil unless game.respond_to?(:game_training_blocks)

titles = game.game_training_blocks.filter_map { |entry| entry.training_block&.title&.strip.presence }
titles.join(", ").presence&.truncate(limit)
return nil if titles.empty?

kept = []
titles.each do |title|
break if kept.any? && (kept + [ title ]).join(", ").length > limit
kept << title
Comment on lines +92 to +93

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 Enforce the limit for the first training block

When the first block title alone exceeds limit, kept.any? is false, so the block is appended without any bound. A sufficiently long user-provided title can therefore make invitation and reminder messages exceed Telegram's 4096-character limit and be rejected, despite this method being intended to prevent that; truncate or otherwise cap an oversized individual block.

Useful? React with 👍 / 👎.

end

text = kept.join(", ")
dropped = titles.size - kept.size
return text if dropped.zero?

"#{text} #{Telegram::I18n.t(:program_more, locale: locale, count: dropped)}"
end

def self.resolve_date(game)
Expand Down
50 changes: 50 additions & 0 deletions app/services/telegram/helpers/markup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require "cgi"
require "uri"

module Telegram
module Helpers
# Имя корта — ссылка, и в приглашении, и в напоминании. Ради неё сообщение
# уходит в телеграм с parse_mode: HTML, а значит всё, что ввели люди,
# придётся экранировать. В письме тот же текст собирается без разметки:
# @body вставляется и в текстовый шаблон, где теги видно как есть.
module Markup
module_function

def markup?(channel)
channel.to_s == "telegram"
end

# Экранирует, только когда текст пойдёт размеченным.
def escaper(channel)
markup = markup?(channel)
->(value) { markup ? CGI.escapeHTML(value.to_s) : value.to_s }
end

# Готовое имя корта: в телеграме ссылкой, в письме просто именем.
def court_name(court, base_url:, channel:)
name = court&.name.to_s.strip
return nil if name.blank?

esc = escaper(channel)
url = markup?(channel) ? court_url(court, base_url) : nil
return esc.call(name) if url.blank?

%(<a href="#{esc.call(url)}">#{esc.call(name)}</a>)
end

# Хост берём у ссылки на игру: она пришла из запроса и верна и на проде,
# и локально.
def court_url(court, base_url)
return nil if court&.id.blank? || base_url.blank?

uri = URI.parse(base_url)
uri.path = "/courts/#{court.id}"
uri.query = nil
uri.fragment = nil
uri.to_s
rescue URI::InvalidURIError
nil
end
end
end
end
3 changes: 2 additions & 1 deletion app/services/telegram/notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ module Telegram
class Notifier
TOKEN = ENV["TELEGRAM_BOT_TOKEN"].to_s

def self.send_message(chat_id, text, parse_mode: "Markdown")
def self.send_message(chat_id, text, parse_mode: "Markdown", link_preview: false)
return false if TOKEN.empty? || chat_id.blank?

uri = URI("https://api.telegram.org/bot#{TOKEN}/sendMessage")
payload = { "chat_id" => chat_id.to_s, "text" => text.to_s }
payload["parse_mode"] = parse_mode if parse_mode.present?
payload["link_preview_options"] = Telegram::Api::LINK_PREVIEW_DISABLED unless link_preview
res = Net::HTTP.post_form(uri, payload)
body = JSON.parse(res.body) rescue {}
body["ok"] == true
Expand Down
3 changes: 3 additions & 0 deletions config/locales/telegram.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ ru:
reminder_head_training: 'Напоминание: у вас тренировка %{when} (%{date}) в %{time}
на корте %{court}'
program_label: 'Программа: %{items}'
program_more: '+ ещё %{count}'
participants_label: 'Участники:'
prebooking_request: 'Запрос на предбронирование игры #%{game_id} от %{name}'
dates_label: 'Даты: %{dates}'
Expand Down Expand Up @@ -704,6 +705,7 @@ en:
reminder_head_training: 'Reminder: you have a training %{when} (%{date}) at %{time}
on %{court}'
program_label: 'Programme: %{items}'
program_more: '+ %{count} more'
participants_label: 'Participants:'
prebooking_request: 'Prebooking request for Game #%{game_id} from %{name}'
dates_label: 'Dates: %{dates}'
Expand Down Expand Up @@ -1234,6 +1236,7 @@ es:
reminder_head_training: 'Recordatorio: tienes un entrenamiento %{when} (%{date})
a las %{time} en %{court}'
program_label: 'Programa: %{items}'
program_more: '+ %{count} más'
participants_label: 'Participantes:'
prebooking_request: 'Solicitud de reserva previa para el partido #%{game_id} de
%{name}'
Expand Down
Loading