На сопровождении легко держать в голове десять клиентов и тяжело сорок. До аванса несколько дней, в чате тишина, акты не пришли, выписку обещают вечером. Первое сообщение бухгалтер копирует утром, второе — на следующий день, третье уже пишет человек, которому надоело. Половина клиентов отвечает после третьего напоминания. Вторая пишет «сейчас», и это «сейчас» дотягивает до срока. Ниже схема, которой это снимается: календарь, тексты, тупой бот и график, который живёт либо в файле, либо в 1С. Бот не считает налог и не проводит документы. Он отправляет уже готовый текст в уже известный чат.

Что делает бот

Одна строка графика — одно сообщение. В строке есть идентификатор, chat_id клиента, дата отправки и текст. Бот раз в несколько минут смотрит график и отправляет то, у чего дата отправки — сегодня. Календарь, паузы и второй пинк собираются данными, не ветками в коде. Так одну и ту же программу можно кормить файлом schedule.json или HTTP-сервисом 1С. Клиент должен один раз открыть бота и нажать «Старт». Пока этого нет, Telegram не отдаст chat_id, и писать некуда. Номер телефона из карточки контрагента сам по себе чат не открывает. chat_id бухгалтер сохраняет в графике.

Чего в этой схеме нет

Сумму аванса в шаблон не подставляем. Её сверяет человек по банку и по расчёту. В сообщении есть дата и просьба назвать день платежа. Юридически значимые документы остаются в ЭДО. Бот напоминает и приносит ответ в чат бухгалтера. В графике лежат имя чата, дата и текст. Паспорт, картотека ИНН, пароли клиент-банка и переписка с налоговой сюда не входят. Ответ «оплатил» статусом получения не считается. «Получили» бухгалтер ставит после банка. «Получили» по документам — когда файл лежит в папке или документ пришёл по ЭДО.

Календарь

Пример: аванс 6 октября 2026, это вторник. Рабочие дни считаем без субботы и воскресенья. Праздники в этом черновике не вычтены: их знает производственный календарь 1С, и ниже дата отправки приходит уже посчитанной. За пять рабочих дней, 29 сентября, уходит первое сообщение по авансу. Если клиент промолчал и бухгалтер не поставил паузу, за один рабочий день, 5 октября, уходит второе. Третьего автоматического сообщения нет. 6 октября бот молчит, пишет или звонит бухгалтер. По документам ритм другой. Первое сообщение — в первый рабочий день после окончания месяца, только по тем позициям, которых нет в папке. Повтор — через три рабочих дня, и только по строкам, которые клиент не закрыл. Если клиент написал «пришлю в четверг», бухгалтер ставит паузу до этой даты. На следующее утро бот спрашивает уже только про этот пункт, отдельной строкой графика. Аванс и первичка уходят разными сообщениями и в разные дни. В одном тексте клиент отвечает на то, что проще, и откладывает платёж.

Тексты

Аванс, первое касание: «Добрый день. На следующей неделе у вас авансовый платёж. Напишите, пожалуйста, в какой день планируете его отправить, чтобы мы сверили поступление.» Аванс, второе касание, только если тишина: «Добрый день. Напоминаю про аванс: срок — 6 октября. Ответьте сегодня, пожалуйста, ушёл ли платёж или на какой день он перенесён.» Документы: «Добрый день. Чтобы закрыть месяц, не хватает: акт за сентябрь, счёт от поставщика. Можно файлом в этот чат или через ЭДО, как вам удобнее. Если чего-то ещё нет, напишите дату, когда будет.» Сумму, пени и формулировку про штраф в шаблон не ставим. Сообщение со штрафом клиент пересылает директору, и разговор уходит в спор, хотя платёж и так стоял в плане.

Статусы, которые ставит человек

Их четыре: ждём, обещали дату, получили, молчит после второго напоминания. Любой ответ клиента останавливает автоматику по этому авансу. Фраза «а какая сумма?» тоже останавливает: бот не отличает вопрос от обещания. Бухгалтер читает пересланный текст и либо ставит паузу, либо руками добавляет следующее напоминание. Спор («мы уже платили», «акт не подписываем») обрабатывается так же: автоматика молчит, пока человек не снимет паузу.

График без 1С

Пока базы нет, хватает файла рядом с ботом. Идентификатор второго шага другой: бот запоминает отправленные id и второй раз ту же строку не шлёт.
{
  "items": [
    {
      "id": "romashka-2026-10-06-1",
      "chat_id": 123456789,
      "remind_on": "2026-09-29",
      "text": "Добрый день. На следующей неделе у вас авансовый платёж. Напишите, пожалуйста, в какой день планируете его отправить, чтобы мы сверили поступление."
    },
    {
      "id": "romashka-2026-10-06-2",
      "chat_id": 123456789,
      "remind_on": "2026-10-05",
      "text": "Добрый день. Напоминаю про аванс: срок — 6 октября. Ответьте сегодня, пожалуйста, ушёл ли платёж или на какой день он перенесён."
    }
  ]
}
Вторую строку имеет смысл класть в файл только если после первого сообщения ответа не было. Иначе 5 октября клиент получит пинк поверх своего «пришлю в четверг».

Как сюда встаёт 1С

Готового модуля «Bothost для 1С» нет. Связка обычная: в расширении конфигурации лежит регистр сведений, HTTP-сервис отдаёт на сегодня готовые строки, бот их отправляет. Проводки, счета и зарплата боту не видны. Так сделано специально. Аванс в Бухгалтерии предприятия, УНФ и самописной базе лежит в разных местах. Общий запрос «найди все авансы» на чужой конфигурации соврать легче, чем помочь. Надёжный график — отдельный непериодический регистр, который бухгалтер заполняет сам или который заполняет ваше регламентное задание из уже проверенного списка. Регистр, одно измерение Идентификатор (строка). Остальное — ресурсы:
  • ChatId — строка, тот самый id чата после «Старт»
  • ДатаАванса
  • ДатаНапоминания — уже рабочий день, с учётом вашего производственного календаря
  • Текст
  • Отправлено — булево
  • ПаузаДо — дата, пустая, если паузы нет
  • ПоследнийОтвет — строка
Дата напоминания считается в 1С, не в боте. Черновик без праздников, его как раз и надо заменить календарём вашей конфигурации (в базах на БСП смотрите общий модуль календарных графиков и сверьте имя метода в своей версии):
Функция ДатаЗаNРабочихДней(ДатаОтсчета, ЧислоДней)
    Дата = НачалоДня(ДатаОтсчета);
    Осталось = ЧислоДней;
    Пока Осталось > 0 Цикл
        Дата = Дата - 86400;
        День = ДеньНедели(Дата);
        Если День < 6 Тогда
            Осталось = Осталось - 1;
        КонецЕсли;
    КонецЦикла;
    Возврат Дата;
КонецФункции
Для аванса 6 октября первое напоминание — ДатаЗаNРабочихДней(ДатаАванса, 5), второе — с пятёркой, заменённой на единицу. Вторую запись регламентное задание создаёт утром того дня, и только если по этому идентификатору аванса ПоследнийОтвет пустой и ПаузаДо пустая. Любой ответ клиента вторую запись не порождает. Токен сервиса лежит в константе или в безопасном хранилище, не в тексте модуля и не в статье. Пустая строка ниже — заглушка: пока её не замените, сервис отвечает 401, и это правильное поведение.
Функция ТокенНапоминаний()
    Возврат "";
КонецФункции
HTTP-сервис публикуйте в расширении, чтобы обновление конфигурации его не стёрло. Наружу открыт только этот сервис, по HTTPS, с заголовком Authorization: Bearer …. Весь веб-клиент базы в интернет для рассылки не нужен. Шаблон GET, условный адрес /hs/reminders/advances. Имена регистра в запросе замените на свои. Пустая дата в 1С — ДАТАВРЕМЯ(1, 1, 1).
Функция AdvancesGET(Запрос)
    Ожидаем = "Bearer " + ТокенНапоминаний();
    Если Запрос.Заголовки.Получить("Authorization") <> Ожидаем Тогда
        Возврат Новый HTTPСервисОтвет(401);
    КонецЕсли;

    Данные = Новый Запрос;
    Данные.Текст =
    "ВЫБРАТЬ
    |    График.Идентификатор КАК Идентификатор,
    |    График.ChatId КАК ChatId,
    |    График.ДатаНапоминания КАК ДатаНапоминания,
    |    График.Текст КАК Текст
    |ИЗ
    |    РегистрСведений.ГрафикНапоминаний КАК График
    |ГДЕ
    |    График.ДатаНапоминания = &Сегодня
    |    И График.Отправлено = ЛОЖЬ
    |    И (График.ПаузаДо = ДАТАВРЕМЯ(1, 1, 1) ИЛИ График.ПаузаДо < &Сегодня)";
    Данные.УстановитьПараметр("Сегодня", НачалоДня(ТекущаяДатаСеанса()));

    Массив = Новый Массив;
    Выборка = Данные.Выполнить().Выбрать();
    Пока Выборка.Следующий() Цикл
        Строка = Новый Структура;
        Строка.Вставить("id", Выборка.Идентификатор);
        Строка.Вставить("chat_id", Выборка.ChatId);
        Строка.Вставить("remind_on", Формат(Выборка.ДатаНапоминания, "ДФ=yyyy-MM-dd"));
        Строка.Вставить("text", Выборка.Текст);
        Массив.Добавить(Строка);
    КонецЦикла;

    Запись = Новый ЗаписьJSON;
    Запись.УстановитьСтроку();
    ЗаписатьJSON(Запись, Новый Структура("items", Массив));

    Ответ = Новый HTTPСервисОтвет(200);
    Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
    Ответ.УстановитьТелоИзСтроки(Запись.Закрыть(), КодировкаТекста.UTF8);
    Возврат Ответ;
КонецФункции
После успешной отправки бот вызывает POST /hs/reminders/ack с телом {"id":"…"}. Флаг Отправлено ставится здесь. Статус «получили» этот вызов не трогает: деньги ещё не сверяли. Идентификатор должен быть измерением, иначе набор записей по одному полю не отобрать.
Функция AckPOST(Запрос)
    Ожидаем = "Bearer " + ТокенНапоминаний();
    Если Запрос.Заголовки.Получить("Authorization") <> Ожидаем Тогда
        Возврат Новый HTTPСервисОтвет(401);
    КонецЕсли;

    Чтение = Новый ЧтениеJSON;
    Чтение.УстановитьСтроку(Запрос.ПолучитьТелоКакСтроку());
    Данные = ПрочитатьJSON(Чтение);
    Чтение.Закрыть();

    Идентификатор = "";
    Если ТипЗнч(Данные) = Тип("Структура") И Данные.Свойство("id") Тогда
        Идентификатор = Строка(Данные.id);
    ИначеЕсли ТипЗнч(Данные) = Тип("Соответствие") Тогда
        Идентификатор = Строка(Данные.Получить("id"));
    КонецЕсли;

    Если Идентификатор <> "" Тогда
        Набор = РегистрыСведений.ГрафикНапоминаний.СоздатьНаборЗаписей();
        Набор.Отбор.Идентификатор.Установить(Идентификатор);
        Набор.Прочитать();
        Если Набор.Количество() > 0 Тогда
            Набор[0].Отправлено = Истина;
            Набор.Записать();
        КонецЕсли;
    КонецЕсли;

    Возврат Новый HTTPСервисОтвет(204);
КонецФункции
Ответ клиента бот не разбирает. Он пересылает текст бухгалтеру и на POST /hs/reminders/reply с телом {"chat_id":"…","text":"…"}. Обработчик находит открытые строки с этим ChatId и пишет ПоследнийОтвет. После этого регламентное задание второе напоминание уже не создаёт. Если 1С в момент ответа недоступна, копия всё равно остаётся в чате бухгалтера. Это основной канал. Повторная доставка в базу — ручная, из этого чата.

Код бота

Файлы: main.py, requirements.txt, при работе без 1С ещё schedule.json. Зависимость одна.
requests
Переменные окружения:
  • BOT_TOKEN — токен Telegram
  • BOT_TZ — часовой пояс графика, по умолчанию Europe/Moscow
  • POLL_SECONDS — пауза между проверками графика, по умолчанию 600
  • ACCOUNTANT_CHAT_ID — куда пересылать ответы клиентов
  • ONEC_URL — GET графика; если пусто, читается schedule.json
  • ONEC_ACK_URL — POST отметки «отправлено»
  • ONEC_REPLY_URL — POST текста ответа
  • ONEC_TOKEN — секрет заголовка Authorization
Одновременно с этим кодом вебхук Telegram включать не нужно: бот сам забирает обновления длинным опросом. Локальный файл sent.json — защита от повтора. Отметка в 1С делается после отправки. Если база не ответила, клиенту второе такое же сообщение не уйдёт: строка уже в sent.json, в логе останется ошибка ack.
import json
import os
import time
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo

import requests

BOT_TOKEN = os.environ["BOT_TOKEN"]
ONEC_URL = os.environ.get("ONEC_URL", "").strip()
ONEC_ACK_URL = os.environ.get("ONEC_ACK_URL", "").strip()
ONEC_REPLY_URL = os.environ.get("ONEC_REPLY_URL", "").strip()
ONEC_TOKEN = os.environ.get("ONEC_TOKEN", "").strip()
ACCOUNTANT_CHAT_ID = os.environ.get("ACCOUNTANT_CHAT_ID", "").strip()
STATE_PATH = Path(os.environ.get("STATE_PATH", "sent.json"))
POLL_SECONDS = int(os.environ.get("POLL_SECONDS", "600"))
TZ = ZoneInfo(os.environ.get("BOT_TZ", "Europe/Moscow"))

OFFSET_PATH = Path(os.environ.get("OFFSET_PATH", "offset.txt"))


def headers():
    return {"Authorization": f"Bearer {ONEC_TOKEN}"}


def load_sent():
    if not STATE_PATH.exists():
        return set()
    try:
        data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return set()
    return set(data.get("sent", []))


def save_sent(sent):
    STATE_PATH.write_text(
        json.dumps({"sent": sorted(sent)}, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


def fetch_items():
    if ONEC_URL:
        response = requests.get(ONEC_URL, headers=headers(), timeout=30)
        response.raise_for_status()
        payload = response.json()
    else:
        payload = json.loads(Path("schedule.json").read_text(encoding="utf-8"))
    items = payload.get("items", [])
    if not isinstance(items, list):
        raise ValueError("В ответе нет списка items")
    return items


def send_message(chat_id, text):
    response = requests.post(
        f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
        json={"chat_id": chat_id, "text": text},
        timeout=20,
    )
    response.raise_for_status()
    body = response.json()
    if not body.get("ok"):
        raise RuntimeError(body.get("description", "telegram error"))


def ack(item_id):
    if not ONEC_ACK_URL or not item_id:
        return
    response = requests.post(
        ONEC_ACK_URL,
        headers=headers(),
        json={"id": item_id},
        timeout=20,
    )
    response.raise_for_status()


def dispatch_due():
    today = datetime.now(TZ).date()
    sent = load_sent()
    for item in fetch_items():
        item_id = str(item.get("id", "")).strip()
        raw_date = str(item.get("remind_on", "")).strip()
        text = str(item.get("text", "")).strip()
        chat_id = item.get("chat_id")
        if not item_id or item_id in sent or not text or chat_id in (None, ""):
            continue
        try:
            remind_on = date.fromisoformat(raw_date)
        except ValueError:
            print(f"bad date {item_id}")
            continue
        if remind_on != today:
            continue
        send_message(int(chat_id), text)
        sent.add(item_id)
        save_sent(sent)
        try:
            ack(item_id)
        except requests.RequestException as exc:
            print(f"ack {item_id} failed: {exc}")
        print(f"sent {item_id}")


def load_offset():
    if not OFFSET_PATH.exists():
        return 0
    raw = OFFSET_PATH.read_text(encoding="utf-8").strip()
    return int(raw) if raw.isdigit() else 0


def save_offset(value):
    OFFSET_PATH.write_text(str(value), encoding="utf-8")


def forward_reply(chat_id, text):
    if ACCOUNTANT_CHAT_ID:
        send_message(int(ACCOUNTANT_CHAT_ID), f"Ответ из чата {chat_id}:\n{text}")
    if not ONEC_REPLY_URL:
        return
    try:
        response = requests.post(
            ONEC_REPLY_URL,
            headers=headers(),
            json={"chat_id": str(chat_id), "text": text},
            timeout=20,
        )
        response.raise_for_status()
    except requests.RequestException as exc:
        print(f"reply sync failed: {exc}")


def poll_replies():
    offset = load_offset()
    response = requests.get(
        f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates",
        params={"timeout": 30, "offset": offset},
        timeout=40,
    )
    response.raise_for_status()
    body = response.json()
    if not body.get("ok"):
        raise RuntimeError(body.get("description", "getUpdates error"))
    for update in body.get("result", []):
        update_id = int(update["update_id"])
        message = update.get("message") or {}
        text = str(message.get("text") or "").strip()
        chat = message.get("chat") or {}
        chat_id = chat.get("id")
        if text and chat_id is not None and str(chat_id) != ACCOUNTANT_CHAT_ID:
            forward_reply(chat_id, text)
        save_offset(update_id + 1)


def main():
    next_send = 0.0
    while True:
        now = time.time()
        if now >= next_send:
            try:
                dispatch_due()
            except Exception as exc:
                print(f"dispatch failed: {exc}")
            next_send = now + POLL_SECONDS
        try:
            poll_replies()
        except Exception as exc:
            print(f"poll failed: {exc}")
            time.sleep(5)


if __name__ == "__main__":
    main()

Где бот работает постоянно

Ноутбук бухгалтера вечером закрывается. Рассылка на утро понедельника от этого не должна вставать, поэтому процесс живёт отдельно от рабочей машины. Собрать и запустить такого бота на Bothost можно по короткой инструкции: быстрый старт. Если кода в git нет, тот же каталог уходит архивом: загрузка архива. В переменные окружения кладутся BOT_TOKEN и, когда появится сервис, адреса 1С. Файл sent.json должен лежать на диске контейнера, а не только в памяти: иначе после перезапуска утренние сообщения уйдут второй раз. Пока ONEC_URL пустой, бот читает schedule.json из каталога программы. Это нормальный первый месяц: сначала календарь и тексты, потом регистр.

Что проверить в первую неделю

Четыре числа, без них схема не настраивается. Сколько первых сообщений получили ответ в тот же день. Если почти никто не ответил, текст слишком ранний или слишком обтекаемый. Сколько диалогов дошли до второй строки. Если больше половины, первое касание люди откладывают. Раньше двигать дату не стоит: за десять дней сообщение забывают. Сколько обещаний «пришлю в четверг» закрылись на следующее утро. Если почти ни одного, паузу ставят, а файл в папку не кладут. Это уже работа бухгалтера, не бота. Сколько звонков осталось на день аванса. Цель — короткий список. Пустой список в сезон чаще значит, что кто-то потерялся между «ждём» и «обещали дату». Если через две недели второе напоминание всё ещё уходит почти всем, дело в сроках и в тексте. Бот к этому моменту шлёт ровно те строки, которые лежат в графике.

220 просмотров
0 лайков
0 комментариев