Newer
Older
gnexus-tasks / backend / app / services / push_texts.py
"""Тексты системных уведомлений (ТЗ 3.21).

Строки собирает сервер, а не service worker: воркер живёт между сессиями и не
знает ни языка пользователя, ни его настроек — у него нет ни бандла локалей, ни
запроса, из которого язык можно взять. Поэтому ru/en/uk живут здесь и осознанно
дублируют фронтовые `gamify.claimed`/`gamify.review`: тост и системное
уведомление говорят об одном и том же разными словами.

Язык разрешается той же цепочкой, что и на фронте (`i18n/locale.ts`): ручное
переопределение в настройках → локаль SSO → «ru».
"""

from sqlalchemy.orm import Session

from app.models import AppSetting, User

# Тот же ключ, что `LANGUAGE_KEY` в api/settings.py: импортировать оттуда значило бы
# развернуть зависимость «сервис → API» ради одного строкового литерала
LANGUAGE_KEY = "language"
DEFAULT_LANGUAGE = "ru"
# Языки, для которых есть строки; всё остальное — «ru»
SUPPORTED = ("ru", "en", "uk")

AGENT_FALLBACK: dict[str, str] = {
    "ru": "ИИ-агент",
    "en": "the AI agent",
    "uk": "ШІ-агент",
}

# kind → язык → (заголовок, тело). Плейсхолдеры подставляются через str.format
TEXTS: dict[str, dict[str, tuple[str, str]]] = {
    "task.claimed": {
        "ru": (
            "Агент взял задачу в работу",
            "«{title}» — {name} взялась за дело, не трогайте её, пока работает",
        ),
        "en": (
            "An agent took the task",
            "“{title}” — {name} got to work, leave it alone while she works",
        ),
        "uk": (
            "Агент узяв завдання в роботу",
            "«{title}» — {name} взялася до справи, не чіпайте його, поки працює",
        ),
    },
    "task.review": {
        "ru": (
            "Работа ждёт приёмки",
            "«{title}» — посмотрите, что сделано, и примите или верните",
        ),
        "en": (
            "Work awaits review",
            "“{title}” — check what was done, then accept or return it",
        ),
        "uk": (
            "Робота чекає на приймання",
            "«{title}» — подивіться, що зроблено, і прийміть або поверніть",
        ),
    },
    # Напоминания о сроке (0.89). Нестрогий период («в течение недели») сюда не
    # попадает вовсе — у него нет дня отсчёта, см. services/reminders.py
    "task.due_tomorrow": {
        "ru": ("Срок завтра", "«{title}» — дедлайн завтра"),
        "en": ("Due tomorrow", "“{title}” — the deadline is tomorrow"),
        "uk": ("Термін завтра", "«{title}» — дедлайн завтра"),
    },
    "task.due_today": {
        "ru": ("Срок сегодня", "«{title}» — сегодня последний день"),
        "en": ("Due today", "“{title}” — today is the last day"),
        "uk": ("Термін сьогодні", "«{title}» — сьогодні останній день"),
    },
    "task.overdue": {
        "ru": ("Просрочено", "«{title}» — срок прошёл, задача всё ещё открыта"),
        "en": ("Overdue", "“{title}” — the deadline has passed, the task is still open"),
        "uk": ("Прострочено", "«{title}» — термін минув, завдання досі відкрите"),
    },
    # Регулярная задача: напоминание по ритму, а не «появился экземпляр» —
    # экземпляр рождается только после закрытия предыдущего
    "task.rhythm": {
        "ru": ("Пора", "«{title}» — по расписанию"),
        "en": ("Time for it", "“{title}” — it is on the schedule"),
        "uk": ("Час", "«{title}» — за розкладом"),
    },
    # Сводка: тело собирается из частей ниже, пустые не показываем
    "summary": {
        "ru": ("Сводка на сегодня", "{parts}"),
        "en": ("Today at a glance", "{parts}"),
        "uk": ("Підсумок на сьогодні", "{parts}"),
    },
}

# Части сводки: ключ → счётчик от `reminders.summary_counts`. Нулевые части
# выбрасываются, поэтому строка-шаблон описывает все три сразу
SUMMARY_PARTS: dict[str, dict[str, str]] = {
    "ru": {
        "overdue": "Просрочено: {overdue}",
        "today": "Срок сегодня: {today}",
        "review": "Ждут приёмки: {review}",
    },
    "en": {
        "overdue": "Overdue: {overdue}",
        "today": "Due today: {today}",
        "review": "Awaiting review: {review}",
    },
    "uk": {
        "overdue": "Прострочено: {overdue}",
        "today": "Термін сьогодні: {today}",
        "review": "Чекають на приймання: {review}",
    },
}

# Тексты уведомления живут секунды на экране блокировки — длинное тело в нём не
# читают, а усекать его по дороге обиднее, чем обрезать здесь
MAX_TITLE = 60
MAX_BODY = 140


def user_language(db: Session, user_id: str) -> str:
    """Язык уведомлений пользователя: настройка → локаль SSO → «ru».

    Настройка читается напрямую, а не через `api/settings.py`: импортировать роутер
    в сервис значило бы завести обратную зависимость, а правило — три строки.
    """
    row = db.get(AppSetting, (user_id, LANGUAGE_KEY))
    if row is not None and row.value in SUPPORTED:
        return row.value
    user = db.get(User, user_id)
    locale = (user.locale or "") if user is not None else ""
    # SSO отдаёт локали вида «ru-RU»: берём язык, отбрасывая регион
    language = locale.replace("_", "-").split("-")[0].lower()
    return language if language in SUPPORTED else DEFAULT_LANGUAGE


def _clip(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    return text[: limit - 1].rstrip() + "…"


def summary_body(language: str, counts: dict[str, int]) -> str:
    """Тело сводки: только непустые части, через « · ».

    Отдельная функция, а не `render`: сводка — не «текст с подстановкой одного
    поля», а склейка переменного числа частей, и собирать её шаблоном неудобно.
    """
    parts = SUMMARY_PARTS.get(language, SUMMARY_PARTS[DEFAULT_LANGUAGE])
    keys = ("overdue", "today", "review")
    text = " · ".join(parts[key].format(**counts) for key in keys if counts.get(key))
    return _clip(text, MAX_BODY)


def render(kind: str, language: str, **fields: str) -> tuple[str, str] | None:
    """Собрать (заголовок, тело) для события; неизвестный kind → None.

    Заголовок задачи усекаем: он приходит от пользователя (до 500 символов), а
    системное уведомление длиннее пары строк всё равно не показывает.
    """
    variants = TEXTS.get(kind)
    if variants is None:
        return None
    title, body = variants.get(language, variants[DEFAULT_LANGUAGE])
    fields.setdefault("name", AGENT_FALLBACK.get(language, AGENT_FALLBACK[DEFAULT_LANGUAGE]))
    if "title" in fields:
        fields["title"] = _clip(fields["title"], MAX_TITLE)
    return title, _clip(body.format(**fields), MAX_BODY)