Техническая документация: Система фиксации контактов менеджеров с клиентами
1. Обзор системы
1.1 Назначение и бизнес-контекст
Система решает одну задачу — собрать в одном месте всю историю контактов менеджеров с клиентами за месяц и выдать готовую таблицу со статистикой. Источников данных четыре, и у каждого своя роль:
| Источник | Файл(ы) | Роль в системе |
|---|---|---|
| МоиЗвонки | apiClient.gs |
Основной источник — реальные звонки: кто звонил, кому, сколько длилось, отвечен или нет. Ядро отчёта. |
| Битрикс24 | bitrixService.gs, integrations.gs |
Справочник сотрудников (user.get) для сопоставления email из МоиЗвонки с ФИО менеджера; также CRM-контакты (crm.contact.list) и компании (crm.company.list). |
| ОКБ (общая клиентская база) | okbService.gs |
Отдельная Google-таблица (OKB_SPREADSHEET_ID), обогащает номер телефона названием компании-клиента. |
| Telegram Logger API | telegramService.gs |
Внешний сервис, агрегирует переписки менеджеров с клиентами по дням — вторая «сущность» отчёта (тип Чат, наравне со Звонок). |
1.2 Карта возможностей (что видно в меню)
Меню строится в onOpen() (main.gs) и является фактической точкой входа для пользователя — вся функциональность системы доступна только через него (или через триггер):
Коммуникации
├── ▶ Запустить обработку данных → runManualProcessing()
├── ─────────────────────────────
├── ☏ Список менеджеров → openManagersManager()
├── ✕ Черный список → openBlacklistManager()
├── ─────────────────────────────
├── ◷ Настроить автоматический запуск → setupAutoTrigger()
├── ✗ Удалить все триггеры → removeAllTriggers()
├── ─────────────────────────────
├── ▤ Архивация данных → openArchiveDialog()
├── ─────────────────────────────
├── ≡ Настройки → openSettingsDialog()
├── ⌁ Тестировать подключение к API → testApiConnectionUI()
└── ↺ Восстановить настройки по умолчанию → forceReinitializeConfig()
1.3 Ограничения платформы Google Apps Script, которые повлияли на архитектуру
GAS — не полноценный сервер, а событийная модель выполнения с жёсткими рамками, и большая часть решений в коде — это подстройка под эти рамки, а не «как красивее».
Лимит времени выполнения (6 минут на обычный вызов, до 30 минут для триггеров на некоторых типах аккаунтов). Отсюда:
FETCH_TIMEOUT_SECONDS = 30вapiClient.gs— искусственное ограничение одного HTTP-запроса, чтобы зависший запрос к МоиЗвонки не съел весь бюджет времени скрипта.- Постраничная загрузка звонков в
fetchCalls()сMAX_RESULTS = 100— вместо одного гигантского запроса, который рискует не уложиться в таймаут.
Отсутствие постоянной памяти между вызовами — каждый вызов функции (в том числе через триггер) стартует «с нуля», без глобального состояния предыдущего запуска. Это прямая причина существования progress.gs:
function _setProcessingStatus(obj) {
PropertiesService.getScriptProperties()
.setProperty('PROCESSING_STATUS', JSON.stringify(obj));
}
Раз нельзя держать прогресс в памяти между запросами от progressDialog.gs, статус пишется в ScriptProperties и читается через getProcessingStatus() при каждом опросе (poll() раз в секунду на стороне HTML). Это же решение — по сути единственный доступный способ дать пользователю живой прогресс-бар в GAS без WebSocket'ов и Server-Sent Events, которых платформа не поддерживает.
Внутрисессионный кэш через переменные модуля. Раз GAS не гарантирует, что между двумя вызовами функций внутри одного запуска будет переиспользован тот же процесс надолго, но в рамках одного триггера/вызова — переменные уровня файла (let _configCache = null; в config.gs, let _blacklistCache = null; в blacklist.gs, let _companiesMapCache = null; в bitrixService.gs) используются как дешёвый кэш на одну сессию выполнения. Это объясняет комментарий в config.gs:
«GAS-скрипт живёт одну сессию — кэш сбрасывается автоматически»
Ограничение размера PropertiesService (~500 КБ на свойство / ~9 МБ суммарно, по факту в коде фигурирует отметка ~900 КБ). Отсюда защитный код в cache.gs и okbService.gs:
try {
scriptProps.setProperty(CACHE_KEY, JSON.stringify(freshMap));
scriptProps.setProperty(CACHE_TIME_KEY, now.toString());
} catch (e) {
// Если объект слишком большой для PropertiesService (>900 КБ)
Logger.log('⚠️ Не удалось сохранить кэш: данные слишком большие');
}
Если карта компаний Битрикс24 или индекс ОКБ разрастутся — кэш просто перестанет сохраняться, без явного алерта пользователю. Технически некритично (пайплайн продолжит работать, просто без ускорения от кэша), но это тихий деградационный сценарий, который стоит держать в голове при росте базы клиентов.
Модель HtmlService с IFRAME sandbox. Все диалоги (monthPicker.html, progressDialog.gs, blacklistManager.gs, settingsDialog.gs, managers.html, archiveDialog.html, timePicker.html) открываются через HtmlService.createHtmlOutputFromFile(...).setSandboxMode(HtmlService.SandboxMode.IFRAME), что обязывает использовать google.script.run вместо прямого вызова серверных функций и заставляет городить withSuccessHandler/withFailureHandler callback-цепочки вместо async/await. Это объясняет, почему весь клиентский JS в проекте написан в колбэк-стиле, а не на промисах.
Нет нативного планировщика с точной точкой во времени. ScriptApp.newTrigger(...).atHour(hour).nearMinute(minute) — это приблизительное время (GAS не гарантирует запуск ровно в указанную минуту, а в течение окна). Именно поэтому в getCurrentTriggerInfo() (main.gs) время триггера хранится вручную в Script Property TRIGGER_TIME, а не читается из самого объекта триггера — GAS API попросту не даёт такого геттера:
// GAS не предоставляет прямого геттера времени — читаем через getEventType()
// и храним время в ScriptProperties при создании триггера
2. Архитектура и поток данных
2.1 Диаграмма верхнеуровневого пайплайна
Центральная функция всей системы — runProcessingPipeline() в main.gs. Она вызывается либо вручную (startMonthProcessing() → выбор месяца в monthPicker.html), либо по триггеру (runProcessingPipelineTrigger()).
flowchart TD
A[Старт: runProcessingPipeline] --> B[getBitrixUserList]
B --> C{Список пуст?}
C -->|Да| D[Логируем WARN, продолжаем без него]
C -->|Нет| E[Определяем период: год/месяц]
D --> E
E --> F[fetchCalls: postраничная загрузка звонков МоиЗвонки]
F --> G{Есть звонки?}
G -->|Нет| H[progress: 'Нет данных', return false]
G -->|Да| I[processCalls: фильтр ЧС + нормализация + перезвоны]
I --> J{Есть данные после обработки?}
J -->|Нет| K[return false]
J -->|Да| L[groupByMonth: группировка YYYY-MM]
L --> M[exportToSheets: ОКБ + Telegram + запись в Sheets]
M --> N[hideOutdatedSheets: скрыть листы старше 3 мес]
N --> O{Есть email для отчётов?}
O -->|Нет| P[Логируем: PDF не генерируем]
O -->|Да| Q[generatePdfReports]
P --> R[progress: done, return true]
Q --> R
Ключевые архитектурные решения, зашитые в эту схему:
- Отсутствие пользователей Битрикс24 или пустой результат от МоиЗвонки не роняют весь пайплайн — система деградирует мягко (звонки без резолва имён менеджеров, либо ранний
return falseс логом «нет данных»). Это осознанное решение — судя по количеству проверокif (bitrixUsers.length === 0)и аналогичных по коду. - Каждый крупный шаг обёрнут в
safeExecute()(utils.gs) — единая точка перехвата ошибок с логированием и flush логов, но при этом ошибка одного шага (fetchCalls,processCalls,groupByMonth,exportToSheets) прерывает весь пайплайн черезthrow— то есть частичного отчёта при сбое экспорта не получится: либо всё, либо ничего (кроме PDF-шага, который черезsafeExecuteне пробрасывает ошибку наверх и не ломает финальный статусdone: true).
2.2 Диаграмма источников данных при экспорте
Самая содержательная точка сборки данных — exportToSheets() в sheetExporter.gs. Здесь пересекаются все три «обогащающих» источника (Битрикс24 для менеджеров, ОКБ для компаний, Telegram для чатов):
flowchart LR
subgraph Входные данные
MD[monthlyData из groupByMonth]
OKB[getCachedOkbPhoneIndex]
TG[fetchAggregatedMessages]
MGR[getManagersList]
end
MD --> EXP[exportToSheets]
OKB --> EXP
TG --> EXP
MGR --> EXP
EXP --> LOOP[Для каждого месяца: calls + chatRows → allRows, sort by date/time]
LOOP --> EM[exportMonthData: запись в лист Звонки_YYYY-MM]
EM --> RES[resolveManagerName: email → ФИО через managersList]
EM --> FCB[findCompanyByPhone: телефон → компания через okbPhoneIndex]
EM --> STATS[generateStatistics: лист Звонки_YYYY-MM_Статистика]
Важные детали, которые не видны на схеме, но критичны для понимания:
-
ОКБ грузится один раз на весь вызов
exportToSheets(), а не на каждый месяц —getCachedOkbPhoneIndex()вызывается один раз до цикла по месяцам. Если доступ к таблице ОКБ упадёт (ACCESS_DENIED), это не прерывает экспорт — простоokbPhoneIndexостаётся пустым объектом{}, и все компании в отчёте будут помечены как «Не найдена в ОКБ». -
Telegram-сообщения грузятся за весь диапазон месяцев разом, затем раскладываются по
chatRowsByMonthвручную (row.date.substring(0, 7)), а не запрашиваются по месяцам отдельно. Это разумная оптимизация — один HTTP-вызов вместо N, — но одновременно означает, что при сбое запроса к Telegram API (catchвокругfetchAggregatedMessages) все месяцы разом остаются без чатов, без частичного отката на уровне одного месяца. -
Компания резолвится по-разному для звонков и для чатов. Для звонков —
findCompanyByPhone(row.clientNumber, okbPhoneIndex)внутриexportMonthData(), то есть каждый раз заново по телефону через индекс ОКБ. Для чатов — компания уже приходит предрассчитанной внутри объектаrow.companyNameизfetchAggregatedMessages(), которая сама резолвит номер через параметрphoneToContactMap. Здесь и кроется расхождение, разобранное подробно в разделе 6 — вызовfetchAggregatedMessages(dateFrom, dateTo, okbPhoneIndex)вsheetExporter.gsпередаёт 3 аргумента, тогда как сама функция вtelegramService.gsобъявлена с 4-мя (dateFrom, dateTo, phoneToContactMap, companyIdToName). По фактуokbPhoneIndexпопадает на местоphoneToContactMap, аcompanyIdToNameвнутри функции окажетсяundefined— что сломает резолв названия компании черезcompanyIdToName[String(bxContact.companyId)]в блоке, гдеbxContact.companyIdне пуст. Технически это не всегда стреляет (зависит от формы данных, лежащих вokbPhoneIndex), но потенциальный источникTypeError: Cannot read properties of undefinedв проде — реальный, актуальный баг, не гипотетический.
2.3 Асинхронный UI и опрос статуса (polling)
Так как GAS не даёт push-уведомлений от сервера к открытому диалогу, прогресс обработки реализован через классический long polling каждую секунду:
sequenceDiagram
participant U as Пользователь
participant MP as monthPicker.html
participant Srv as main.gs (сервер)
participant PD as progressDialog.gs
participant PS as ScriptProperties
U->>MP: Выбирает месяц, жмёт "Запустить"
MP->>Srv: startMonthProcessing(year, month)
Srv->>PS: _setProcessingStatus({progress:0, ...})
Srv->>PD: showModelessDialog (открывается прогресс-диалог)
Srv->>Srv: runProcessingPipeline(year, month) — синхронный вызов
loop каждую секунду
PD->>Srv: getProcessingStatus()
Srv->>PS: getProperty('PROCESSING_STATUS')
PS-->>Srv: JSON статуса
Srv-->>PD: applyStatus(status)
PD->>PD: обновление прогресс-бара и шагов
end
Srv->>PS: _setProcessingStatus({progress:100, done:true})
PD->>PD: clearInterval(_poll), закрытие через 1.5с
Важный нюанс архитектуры, который стоит проговорить явно: runProcessingPipeline() вызывается синхронно из startMonthProcessing(), то есть сам факт того, что модальное окно прогресса открылось до вызова пайплайна (showModelessDialog идёт раньше runProcessingPipeline(...) по коду), — это не гарантия параллельности, а просто порядок в очереди операций одного и того же серверного вызова. Модальное окно открывается, но реальное обновление статуса, который оно должно отображать, произойдёт только после того, как основная функция допишет очередной _setProcessingStatus(...) — и все эти записи происходят внутри одного и того же выполнения runProcessingPipeline, последовательно, а не из отдельного потока. Пользователь видит прогресс благодаря тому, что PropertiesService — это внешнее относительно самого выполнения хранилище, и progressDialog.gs, опрашивая его каждую секунду отдельным HTTP-запросом от клиента, реально видит промежуточные записи, сделанные ещё выполняющимся серверным скриптом.
2.4 Карта файлов проекта и их ответственность
| Файл | Тип | Зона ответственности |
|---|---|---|
main.gs |
Server | Точка входа, меню, основной пайплайн runProcessingPipeline, триггеры, архивация листов |
config.gs |
Server | Конфигурация приложения, чтение/запись Script Properties, инициализация дефолтов |
apiClient.gs |
Server | HTTP-клиент к API МоиЗвонки, постраничная загрузка звонков, тест подключения |
blacklist.gs |
Server | Чёрный список номеров: нормализация телефонов, добавление/удаление/загрузка |
dataProcessor.gs |
Server | Обработка сырых звонков: фильтрация ЧС, анализ перезвонов, группировка по месяцам |
sheetExporter.gs |
Server | Экспорт в Google Sheets, объединение звонков+чатов, генерация статистики, скрытие старых листов |
pdfExporter.gs |
Server | Формирование и рассылка PDF-отчётов по email, fallback-логика при сбое рассылки |
utils.gs |
Server | Вторая реализация нормализации телефона, буферизованное логирование, safeExecute |
blacklistManager.gs |
HTML (Client) | UI управления чёрным списком номеров |
settingsDialog.gs |
HTML (Client) | UI настроек приложения (API, интеграции, email) |
integrations.gs |
Server | Отправка email-отчётов, создание задач в Битрикс24, тест CRM-вебхука |
bitrixService.gs |
Server | Получение сотрудников/контактов/компаний Битрикс24, тест обоих вебхуков |
testFunctions.gs |
Server | Диагностические функции для ручного запуска из редактора |
managers.html |
HTML (Client) | UI управления списком менеджеров и их Telegram Manager ID |
managersList.gs |
Server | CRUD списка менеджеров, резолв email→имя, построение карты Telegram ID→имя |
cache.gs |
Server | Кэш карты компаний Битрикс24 через ScriptProperties (TTL 1 час) |
archiveDialog.html |
HTML (Client) | UI архивации листов в отдельный файл |
telegramService.gs |
Server | Загрузка и агрегация сообщений из Telegram Logger API |
progressDialog.gs |
HTML (Client) | UI прогресс-бара с long polling статуса пайплайна |
progress.gs |
Server | Хранение/чтение статуса обработки через ScriptProperties |
balancer.gs |
Server | Прокси-обёртка для обхода лимитов Битрикс24 (HMAC-подписанные запросы) |
okbService.gs |
Server | Загрузка таблицы ОКБ, построение и кэширование индекса телефон→компания |
monthPicker.html |
HTML (Client) | UI выбора года/месяца для ручного запуска обработки |
timePicker.html |
HTML (Client) | UI настройки времени ежедневного автозапуска |
Обрати внимание на несостыковку в именовании: файлы blacklistManager.gs и settingsDialog.gs физически содержат HTML-разметку (<!DOCTYPE html>), а не Apps Script код, несмотря на расширение .gs. Это, скорее всего, артефакт выгрузки/именования в репозитории, а не ошибка исполнения (GAS сам разруливает файлы по объявленному типу внутри проекта, а не по расширению в имени) — но при поддержке проекта стоит явно понимать, что это HTML-файлы, иначе легко потерять время, пытаясь найти в них серверный код.
3. Развёртывание и первоначальная настройка
3.1 Требования к Google Sheets и правам доступа
Для корректной работы системы нужны следующие внешние ресурсы и разрешения:
- Основная Google-таблица — привязывается автоматически при первом запуске через
PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', ...)вinitializeProject()иstartMonthProcessing()(если свойство ещё не установлено). Требует, чтобы Apps Script проект был привязан (bound) к этой таблице (Container-bound script), так какonOpen()/SpreadsheetApp.getActiveSpreadsheet()подразумевают именно такой сценарий. - Доступ к таблице ОКБ (
OKB_SPREADSHEET_ID = '1F1ansQeSuAwhe3Y_mhfm4ja370k5hq67RruE-CVp83w', см.okbService.gs) — учётная запись, под которой выполняется скрипт, должна иметь доступ на чтение к этой конкретной таблице. Проверка идёт через тестовый запрос сScriptApp.getOAuthToken(), и при коде ответа 403/404 система продолжает работать без ОКБ, но с пометкой «Не найдена в ОКБ» у всех компаний. - Права на
DriveApp— нужны для перемещения файла-архива в нужную папку при архивации (archiveSheets()вmain.gs) и потенциально для доступа кREPORTS_FOLDER_ID, если PDF-отчёты складываются в папку Google Drive (сама логика сохранения в папку — за пределами предоставленных файлов, см. пробел ниже). - Права на
MailApp/GmailApp— используются параллельно в разных местах:MailApp.sendEmail()вsendEmailReport()(integrations.gs) и в fallback-рассылке (pdfExporter.gs), аGmailApp.sendEmail()— в основной рассылке PDF-отчётов (sendPdfReportsByEmail()) и вtestEmailConnection(). Смешанное использование двух разных email-сервисов GAS в одном проекте — не проблема с точки зрения работоспособности (оба доступны одновременно), но стоит иметь в виду при настройке квот: уMailAppиGmailAppразные суточные лимиты на отправку. - Внешний сетевой доступ (
UrlFetchApp) — по умолчанию доступен в GAS без дополнительных разрешений, но целевые домены (МоиЗвонки, Balancer, Telegram Logger API) должны быть доступны из дата-центров Google (без белых списков IP на стороне этих сервисов, иначе запросы будут падать).
3.2 Порядок инициализации проекта
Первый запуск (вручную либо через установку как Add-on) идёт по цепочке:
flowchart TD
A[onInstall] --> B[initializeProject]
B --> C[initializeDefaultConfig]
C --> D{Ключ уже есть в Script Properties?}
D -->|Да| E[Пропускаем, не перезаписываем]
D -->|Нет| F[Устанавливаем дефолт из объекта defaults]
E --> G[SpreadsheetApp.getActiveSpreadsheet]
F --> G
G --> H[Сохраняем SPREADSHEET_ID]
H --> I[_clearConfigCache]
I --> J[createRequiredSheets]
J --> K{Лист 'Черный список' существует?}
K -->|Нет| L[insertSheet + hideSheet]
K -->|Да| M[Пропускаем]
J --> N{Лист 'Менеджеры' существует?}
N -->|Нет| O[insertSheet + hideSheet]
N -->|Да| P[Пропускаем]
Ключевая деталь: initializeDefaultConfig() только устанавливает отсутствующие ключи, никогда не перетирает существующие:
const toSet = {};
Object.entries(defaults).forEach(([k, v]) => {
if (!existing[k]) toSet[k] = v;
});
Это разумное, безопасное поведение для первого разворачивания — повторный вызов initializeProject() (например, после переустановки Add-on) не затрёт уже настроенные пользователем значения API_KEY, USER_EMAIL и т.д.
3.3 Настройка триггера автозапуска
Ежедневный автозапуск обработки настраивается через setupAutoTriggerWithTime(hour, minute) (main.gs), вызываемую из timePicker.html:
flowchart LR
A[timePicker.html: пользователь выбирает часы/минуты] --> B[setupAutoTriggerWithTime]
B --> C[removeAllTriggers]
C --> D[ScriptApp.newTrigger 'runProcessingPipelineTrigger']
D --> E[.timeBased.everyDays 1 .atHour .nearMinute .inTimezone]
E --> F[Сохранить TRIGGER_TIME в ScriptProperties]
Два важных технических момента:
-
removeAllTriggers()вызывается перед созданием нового — то есть система жёстко ограничивает себя одним триггеромrunProcessingPipelineTriggerв любой момент времени. Это защищает от дублирования запусков (частая проблема в GAS-проектах, где повторный вызов «Настроить автозапуск» без предварительной очистки плодит по два-три идентичных триггера), но одновременно means: если в проекте появятся другие триггеры (например, для очистки логов по расписанию), их тоже снесёт эта функция — стоит иметь в виду при дальнейшем расширении функциональности. -
TRIGGER_TIMEв ScriptProperties — это единственный источник истины о времени триггера для UI. Как уже отмечалось в разделе 1.3, GAS не даёт способа прочитать точное время существующего time-based триггера черезScriptApp.getProjectTriggers()— методы объектаTriggerне раскрывают час/минуту напрямую. ПоэтомуgetCurrentTriggerInfo()использует двухступенчатую проверку: сначала ищет сам факт существования триггера с нужнымgetHandlerFunction(), а потом отдельно читаетTRIGGER_TIMEиз свойств. Из этого следует важное ограничение: если триггер был создан не черезsetupAutoTriggerWithTime()(например, вручную через редактор Apps Script или программно из другого места), UI покажет дефолтное время16:30, даже если реальный триггер настроен на другое время —TRIGGER_TIMEи фактическое расписание триггера могут разойтись, если кто-то обходит штатный UI.
removeAllTriggers() также доступен отдельным пунктом меню и вызывается из timePicker.html (кнопка «Удалить триггеры») — при этом TRIGGER_TIME из ScriptProperties не удаляется, только сами триггеры. Это значит, что при следующем открытии timePicker.html (до создания нового триггера) поля часов/минут всё ещё будут показывать последнее сохранённое время, хотя реального триггера уже не существует — не баг, но потенциально сбивающее с толку поведение UI, которое стоит держать в голове при поддержке.
4. Конфигурация (config.gs)
4.1 Структура объекта конфигурации и кэширование
Всё приложение читает настройки через единственную точку входа — getConfig(). Функция не читает PropertiesService на каждый вызов, а строит один раз объект-конфиг и держит его в переменной модуля _configCache:
let _configCache = null;
function getConfig() {
if (_configCache) return _configCache;
// ... сборка конфига из props ...
return _configCache;
}
Обратная сторона — кэш нужно вручную сбрасывать после любой записи в ScriptProperties в обход updateConfig(). Разработчики это учли: _clearConfigCache() вызывается в трёх местах — после updateConfig(), после initializeDefaultConfig() и после прямой записи SPREADSHEET_ID в initializeProject()/startMonthProcessing()/runProcessingPipeline().
if (!props['BITRIX24_USERS_WEBHOOK'] || !props['BITRIX24_CRM_WEBHOOK']) {
initializeDefaultConfig();
Object.assign(props, sp.getProperties());
}
То есть чтение конфига может неявно приводить к записи в Script Properties (самовосстановление дефолтов). Само по себе это удобно для «самозаживления» после ручного удаления свойств через редактор, но для человека, читающего getConfig() первый раз, это неочевидный побочный эффект — геттер, который иногда пишет.
Поле apiUrl в возвращаемом объекте конфигурации — не строка, а функция (() => https://${subdomain}.moizvonki.ru/api/v1), которую нужно вызывать как config.apiUrl(). Это сделано, чтобы URL всегда собирался из актуального subdomain в момент использования, а не «замораживался» на момент вызова getConfig() — хотя при текущей архитектуре (subdomain читается один раз при построении кэша) разница на практике не проявляется, пока сессия не перезапустится.
4.2 Таблица всех Script Properties
| Ключ Script Property | Где читается | Где записывается | Дефолт / поведение при отсутствии |
|---|---|---|---|
API_SUBDOMAIN |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'test' |
USER_EMAIL |
getConfig() |
updateConfig(), forceReinitializeConfig() |
'' |
API_KEY |
getConfig() |
updateConfig(), forceReinitializeConfig() |
'' |
VERIFY_SSL |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'true' (сравнение строкой === 'true') |
CALLBACK_WINDOW_HOURS |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'24' |
MIN_CALL_DURATION |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'10' |
SPREADSHEET_ID |
getConfig() |
initializeProject(), startMonthProcessing(), forceReinitializeConfig() |
'' — при пустом значении часть функций (exportToSheets, loadBlacklist, hideOutdatedSheets) молча завершаются с логом, не бросая исключение |
REPORTS_FOLDER_ID |
getConfig() |
updateConfig() |
'' |
ENABLE_LOGGING |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'true' |
REPORT_EMAIL |
getConfig() |
(нет прямого пути через updateConfig() — см. 4.4) |
'' — устаревшее поле, помечено в коде как «одиночный email (устаревшее поле)» |
REPORT_EMAILS |
getConfig() |
updateConfig() (с нормализацией списка) |
'' — актуальное поле-список |
BITRIX24_ENABLED |
getConfig() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
'true' |
BITRIX24_USERS_WEBHOOK |
getConfig(), _getBitrixUsersWebhook() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
захардкоженный реальный вебхук — см. раздел 3.3 |
BITRIX24_CRM_WEBHOOK |
getConfig(), _getBitrixCrmWebhook() |
updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() |
захардкоженный реальный вебхук — см. раздел 3.3 |
BITRIX24_WEBHOOK |
getConfig() |
(нет пути записи через updateConfig()) |
'' — помечено как «устаревшее поле, оставлено для обратной совместимости» |
TELEGRAM_API_URL |
getConfig(), _getTelegramApiUrl() |
updateConfig(), initializeDefaultConfig(), initTelegramApiSettings() |
значение читается напрямую из PropertiesService в telegramService.gs, минуя getConfig() |
TELEGRAM_API_KEY |
getConfig(), _getTelegramApiKey() |
updateConfig(), initializeDefaultConfig(), initTelegramApiSettings() |
дефолт 'supersecretkey' захардкожен в трёх местах — см. раздел 9.3 |
TRIGGER_TIME |
getCurrentTriggerInfo() |
setupAutoTriggerWithTime() |
не сбрасывается при removeAllTriggers() — см. раздел 3.4 |
4.3 updateConfig() и whitelist разрешённых настроек
updateConfig(newSettings) — единственная функция, которая записывает в ScriptProperties из UI (settingsDialog.gs → updateSettings() в main.gs → updateConfig()). Она защищена белым списком:
const allowedSettings = new Set([
'API_SUBDOMAIN', 'USER_EMAIL', 'API_KEY', 'VERIFY_SSL',
'CALLBACK_WINDOW_HOURS', 'MIN_CALL_DURATION',
'REPORTS_FOLDER_ID', 'ENABLE_LOGGING', 'REPORT_EMAILS',
'BITRIX24_USERS_WEBHOOK', 'BITRIX24_CRM_WEBHOOK', 'BITRIX24_ENABLED',
'TELEGRAM_API_URL', 'TELEGRAM_API_KEY',
]);
Смысл этого списка — не дать случайному/вредоносному вызову из клиентского JS перезаписать произвольный ключ (например, SPREADSHEET_ID через самодельный google.script.run вызов из консоли браузера). Всё, что не входит в allowedSettings, тихо игнорируется циклом Object.entries(newSettings).forEach(...).
Обрати внимание на два прямых следствия этого списка:
-
REPORT_EMAIL(единственное число) не входит в whitelist. Значит, даже если бы клиентская форма вsettingsDialog.gsдействительно отправлялаREPORT_EMAIL(а она пытается — см.saveSettings()),updateConfig()бы это значение всё равно проигнорировал. Это ещё одно косвенное подтверждение бага из раздела 4.2: старое полеREPORT_EMAILв текущей версии кода — фактически не работающий путь end-to-end, ни на фронте (несуществующий DOM-элемент), ни на бэке (не в whitelist). -
SPREADSHEET_IDиBITRIX24_WEBHOOKнельзя изменить через UI настроек — только напрямую черезPropertiesServiceв редакторе кода или черезforceReinitializeConfig(). ДляSPREADSHEET_IDэто скорее осознанное решение (таблица привязывается автоматически при первом запуске, менять её через UI — редкий и рискованный сценарий), а вот отсутствие пути измененияBITRIX24_WEBHOOKсовпадает с тем, что само поле помечено как устаревшее — то есть согласовано с остальным кодом.
Отдельно стоит разобрать нормализацию REPORT_EMAILS внутри updateConfig() — единственное поле, которое обрабатывается не через простое String(value), а через полноценный pipeline:
const emails = String(value)
.split(',')
.map(e => e.trim().toLowerCase())
.filter(e => e && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))
.filter((e, i, arr) => arr.indexOf(e) === i);
toSet[key] = emails.join(', ');
Трим, приведение к нижнему регистру, фильтрация по простому regex-паттерну email и удаление дублей. Тот же самый паттерн валидации email (/^[^\s@]+@[^\s@]+\.[^\s@]+$/) продублирован ещё в двух местах — getEmailAddresses() в utils.gs и getEmailAddresses() в pdfExporter.gs (разбирается подробнее в разделе 12.4). Здесь это тот же паттерн third-time — то есть по факту в проекте есть три копии одной и той же регулярки для валидации email, которые нужно синхронно поддерживать, если формат когда-нибудь понадобится ужесточить (например, добавить поддержку кириллических доменов).
5. Модель данных
5.1 Структура записи звонка после обработки
Каждый сырой звонок из API МоиЗвонки (fetchCalls()) проходит через processCalls() (dataProcessor.gs) и превращается в объект со следующими полями:
| Поле | Тип | Источник / логика | Комментарий |
|---|---|---|---|
id |
string/number | call.db_call_id |
Идентификатор звонка из МоиЗвонки, используется только для логов ошибок обработки |
date |
string YYYY-MM-DD |
timestampToDate(startTimestamp, config.timezone) |
Часовой пояс — Europe/Moscow, зашит в config.timezone |
time |
string HH:mm:ss |
timestampToTime(startTimestamp, config.timezone) |
Используется как вторичный ключ сортировки в groupByMonth() |
direction |
'Исходящий' | 'Входящий' |
call.direction === 1 ? 'Исходящий' : 'Входящий' |
Бинарная логика — направление 1 жёстко зашито как исходящий |
clientNumber |
string | normalizePhone(call.client_number) |
Нормализованный номер, см. раздел 5.4 |
clientName |
string | call.client_name || 'Неизвестно' |
Из МоиЗвонки, без сопоставления с CRM на этом шаге |
duration |
number | call.duration || 0 |
В секундах |
answered |
'Да' | 'Нет' |
answered === 1 ? 'Да' : 'Нет' |
Строковое представление для вывода в таблицу |
recording |
string | call.recording || '' |
Ссылка на запись разговора (формат ссылки не описан в коде — см. пробел ниже) |
managerEmail |
string | call.user_account || 'unknown@company.com' |
Ключ для последующего resolveManagerName() |
status |
string | 'Отвечен' / 'Пропущен' → далее переопределяется в analyzeCallbacks() |
Финальное значение зависит от анализа перезвонов, см. ниже |
isMissed |
boolean | answered === 0 |
Внутренний флаг, не идёт в таблицу напрямую |
hasCallback |
boolean | по умолчанию false, пересчитывается в analyzeCallbacks() |
См. раздел 6.4 |
dateTimeObject |
Date | null |
new Date(startTimestamp * 1000) |
Используется только для сравнения временных окон внутри analyzeCallbacks(), в таблицу не экспортируется |
Важный нюанс по полю status: изначально оно принимает значение 'Отвечен' или 'Пропущен' в processCalls(), но для пропущенных звонков analyzeCallbacks() его перезаписывает на 'Пропущен, перезвон совершен' или 'Пропущен, перезвон не совершен'. То есть финальный набор возможных значений status в отчёте — три штуки, а не два, и это стоит иметь в виду при написании любых фильтров или формул поверх листа Звонки_YYYY-MM (простое сравнение status === 'Пропущен' в клиентском коде уже не сработает после обработки — это же используется внутри generateStatistics() через .includes('Пропущен'), а не строгое равенство, именно по этой причине).
Отдельно стоит зафиксировать: фильтрация по чёрному списку происходит до сборки этого объекта (if (blacklist.has(clientNum) || blacklist.has(srcNum)) continue;), то есть звонки от/на номера из ЧС в датасет не попадают вообще — не помечаются каким-то статусом, а физически отсутствуют в отчёте. Если аналитику понадобится позже посчитать «сколько звонков было отфильтровано» — этих данных нет нигде, continue их просто выбрасывает без логирования счётчика.
5.2 Структура записи чата
Telegram-переписки агрегируются в fetchAggregatedMessages() (telegramService.gs) в объекты со схожим, но не идентичным набором полей:
| Поле | Тип | Источник / логика | Комментарий |
|---|---|---|---|
type |
'chat' |
константа | Используется в sheetExporter.gs для ветвления логики (row.type === 'chat') |
id |
string | 'tg_' + row.dialog_id + '_' + row.message_date |
Синтетический составной ключ, не пересекается по формату с id звонков |
date |
string YYYY-MM-DD |
row.message_date |
Приходит уже в готовом формате от Telegram Logger API, без преобразования часового пояса |
time |
string | всегда '—' |
Чат агрегируется за день, а не по конкретному времени сообщения — точного времени просто нет в модели |
direction |
string | всегда 'Чат' |
Заполняет ту же колонку, что и 'Исходящий'/'Входящий' у звонков |
clientNumber |
string | normalizedPhone || ('tg_client_' + row.client_id) |
Если телефон не резолвится — используется синтетический псевдо-номер на основе client_id |
clientName |
string | '—' / 'Неизвестно' / имя из Битрикс24-контакта |
Зависит от результата поиска в phoneToContactMap, см. раздел 6.7 |
companyName |
string | из Битрикс24-контакта, либо 'Не найден контакт' / 'Номер скрыт' / 'Нет телефона в БД' |
Резолвится внутри telegramService.gs, а не в sheetExporter.gs, в отличие от звонков — см. раздел 2.2, пункт 3 |
messageCount |
number | row.message_count |
Суммарное количество сообщений за день по этому диалогу |
managerName |
string | tgManagersMap[String(row.manager_id)] либо 'Telegram #' + row.manager_id |
Резолв через лист «Менеджеры», колонка Telegram Manager ID |
lastMessage |
string | row.last_message_text, обрезано до 150 символов |
Может быть пустой строкой, если поле отсутствует в ответе API |
status |
string | '{count} сообщ. (исх: {N} / вх: {M})' |
Человекочитаемая сводка, используется как замена колонке «Статус» у звонков |
dateTimeObject |
Date |
new Date(row.message_date + 'T00:00:00Z') |
Синтетическая полночь UTC — не реальное время переписки, только для сортировки по дате |
Ключевое структурное отличие от звонков — у чата нет отдельной колонки длительности/количества, вместо неё переиспользуется поле messageCount в той же позиции таблицы, где у звонков стоит duration (см. exportMonthData() в sheetExporter.gs, столбец «Длит. (сек) / Кол-во сообщ.» — это буквально одна колонка на два разных физических смысла). Аналогично колонка «Отвечен» для чата всегда содержит '—', а «Запись» — последнее сообщение дня вместо ссылки на аудиозапись. Решение разумное с точки зрения не плодить два разных листа с разными шапками, но оно означает, что при построении сводных таблиц или графиков поверх листа Звонки_YYYY-MM нельзя слепо суммировать «шестую колонку» — её смысл меняется в зависимости от значения первой колонки («Тип»).
5.3 Листы Google Sheets и их назначение
| Лист | Создаётся в | Видимость | Формат |
|---|---|---|---|
Звонки_YYYY-MM |
exportMonthData() (sheetExporter.gs), по одному на месяц с данными |
Виден, пока не скрыт hideOutdatedSheets() (старше 3 месяцев по умолчанию) |
12 колонок: Тип, Дата, Время, Направление, Номер контакта, Имя контакта, Компания, Длит./Кол-во сообщ., Отвечен, Запись/Последнее сообщение, Менеджер, Статус |
Звонки_YYYY-MM_Статистика |
generateStatistics() (sheetExporter.gs), автоматически при экспорте соответствующего месяца |
Виден, скрывается вместе с родительским листом данных [Требует уточнения: hideOutdatedSheets() матчит только паттерн ^Звонки_(\d{4})-(\d{2}), под который лист ..._Статистика тоже подходит, т.к. regex не заякорён в конце строки — нужно подтвердить, что это осознанное поведение, а не побочный эффект] |
9 колонок: Менеджер, разделитель «// Звонки //», Всего звонков, Пропущено, Перезвонов, Эффективность (%), разделитель «// Чаты //», Дней с чатами, Сообщений всего |
Черный список |
initializeProject() → createRequiredSheets(), либо лениво в loadBlacklist()/addToBlacklist() |
Скрыт (hideSheet()) |
2 колонки: Номер телефона, Комментарий |
Менеджеры |
initializeProject() → createRequiredSheets(), либо лениво в saveManagersList() |
Скрыт (hideSheet()) |
3 колонки: Email менеджера, Имя менеджера, Telegram Manager ID |
Логи |
_getLogSheet() (utils.gs), лениво при первом вызове logMessage() |
Скрыт | 3 колонки: Дата и время, Уровень, Сообщение; обрезается сверху при превышении LOG_MAX_ROWS = 1000 |
5.4 Три независимые реализации нормализации телефона
В проекте объявлены три отдельные функции для нормализации номера телефона, и это не разделение по смыслу (например, «строгая» и «мягкая» версии для разных задач), а фактическое дублирование одной и той же идеи с разными деталями реализации:
| Функция | Файл | Ключевая особенность |
|---|---|---|
normalizePhone() |
blacklist.gs |
Проверяет HIDDEN_NUMBER_PATTERNS до и после очистки цифр; при цифрах меньше порога — просто возвращает исходные digits без изменений (нет явной обработки «слишком короткого» номера, кроме проверки на скрытый паттерн) |
normalizePhone() |
utils.gs |
Использует _HIDDEN_PATTERNS (переиспользует HIDDEN_NUMBER_PATTERNS из blacklist.gs, если он уже объявлен, иначе — свою локальную копию списка); явно возвращает '' для номеров короче 7 цифр — поведение, которого нет в версии из blacklist.gs |
normalizePhoneStrict() |
okbService.gs |
Не проверяет текстовые паттерны (anonymous, скрыт и т.д.) вообще — работает только с цифрами; возвращает null (а не строку 'Номер скрыт' или '') при невалидном номере |
Обе функции с именем normalizePhone() (в blacklist.gs и в utils.gs) объявлены как function normalizePhone(...) на верхнем уровне файла — то есть в терминах правил JavaScript/GAS это ровно тот же случай, что уже разбирался в разделе 4.3 с initializeDefaultConfig(): при загрузке всех .gs файлов в общую область видимости одного проекта Apps Script побеждает то объявление, которое было загружено последним. Какое из двух объявлений реально выполняется в проде, зависит от порядка файлов в самом проекте Apps Script (не от порядка, в котором файлы лежат в этой документации или в репозитории) — этот порядок не входит в состав переданных файлов и не может быть установлен по одному лишь содержимому кода.
6. Основной поток обработки (бизнес-логика)
6.1 Определение периода обработки
Период обработки вычисляется в начале runProcessingPipeline(targetYear, targetMonth) (main.gs). Логика опирается на часовой пояс Europe/Moscow вне зависимости от того, в каком часовом поясе физически исполняется сам скрипт (GAS-сервера работают в UTC):
const nowDate = new Date();
const currentYear = parseInt(Utilities.formatDate(nowDate, 'Europe/Moscow', 'yyyy'), 10);
const currentMonth = parseInt(Utilities.formatDate(nowDate, 'Europe/Moscow', 'MM'), 10);
const year = targetYear || currentYear;
const month = targetMonth || currentMonth;
Если targetYear/targetMonth не переданы (сценарий автозапуска через runProcessingPipelineTrigger(), где runProcessingPipeline() вызывается без аргументов) — берётся текущий месяц по МСК. Если переданы (ручной запуск через monthPicker.html) — используется выбор пользователя.
Дальше идёт важная развилка — текущий месяц обрабатывается не целиком, а "по сейчас":
const isCurrentMonth = (year === currentYear && month === currentMonth);
const endOfPeriod = isCurrentMonth
? nowTimestamp
: Math.min(getEndOfMonthTimestamp(year, month), nowTimestamp);
6.2 Постраничная загрузка звонков из API МоиЗвонки
fetchCalls(fromDate, toDate, supervised) в apiClient.gs — единственная точка получения сырых данных о звонках. Особенности реализации:
Пагинация через from_offset/results_next_offset. API МоиЗвонки не отдаёт весь период одним ответом — используется классический паттерн keyset-пагинации:
let allCalls = [];
let fromOffset = 0;
while (true) {
const payload = { /* ..., max_results: 100, from_offset: fromOffset };
// запрос страницы
const nextOffset = page.results_next_offset || 0;
allCalls = allCalls.concat(calls);
if (calls.length === 0 || nextOffset === 0 || nextOffset === fromOffset) {
break;
}
fromOffset = nextOffset;
}
Retry-логика на уровне одной страницы, а не всего запроса целиком — если страница №3 из десяти упадёт по сети, повторно перезапрашиваются не все десять, а только неудавшаяся:
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = UrlFetchApp.fetch(config.apiUrl(), options);
// ...
break; // успех — выходим из retry-цикла
} catch (err) {
if (attempt < MAX_RETRIES) {
Utilities.sleep(RETRY_DELAY * attempt); // экспоненциальная задержка
} else {
throw new Error(`fetchCalls: не удалось получить страницу после ${MAX_RETRIES} попыток. ${err}`);
}
}
}
MAX_RETRIES = 3, задержка растёт линейно (RETRY_DELAY * attempt, то есть 2с/4с/6с) — не совсем экспоненциальный backoff, вопреки комментарию «экспоненциальная задержка» в коде, а линейный. Мелкая неточность в терминологии комментария, не влияющая на поведение, но стоит знать при чтении кода, чтобы не искать экспоненту там, где её нет.
6.3 Фильтрация чёрного списка и построение звонка
processCalls(rawCalls) (dataProcessor.gs) — первый шаг обработки после получения сырых данных. Порядок операций внутри цикла по звонкам важен:
for (const call of rawCalls) {
const clientNum = normalizePhone(call.client_number || '');
const srcNum = normalizePhone(call.src_number || '');
if (blacklist.has(clientNum) || blacklist.has(srcNum)) continue;
// ... построение объекта call ...
}
Фильтрация идёт до построения итогового объекта — отфильтрованные звонки физически не попадают в processed, что уже отмечалось в разделе 5.1. Проверяются оба номера — и client_number, и src_number — то есть в чёрный список можно занести как номер клиента, так и внутренний номер источника вызова (последнее менее очевидно с точки зрения бизнес-смысла, но защищает от, например, случайных технических/тестовых номеров АТС, которые иначе засоряли бы отчёт).
loadBlacklist() (blacklist.gs) читает лист «Черный список» один раз за сессию и кэширует в _blacklistCache как Set<string> — то есть проверка blacklist.has(...) работает за O(1), а не линейным перебором массива на каждый звонок, что важно при тысячах записей в месяц.
6.4 Логика определения перезвона (analyzeCallbacks)
Дальше для каждого пропущенного звонка (call.isMissed) ищется, был ли по этому же номеру клиента отвеченный звонок после пропущенного, в пределах окна callbackWindowHours (настраивается в UI, дефолт 24 часа), и длительностью не меньше minCallDuration (дефолт 10 секунд):
const hasCallback = clientCalls.some(c =>
c.answered === 'Да' &&
c.duration >= config.minCallDuration &&
c.dateTimeObject &&
c.dateTimeObject > call.dateTimeObject &&
c.dateTimeObject <= windowEnd
);
Проверка длительности (duration >= minCallDuration) — важная бизнес-деталь: короткий «звонок» на 2 секунды формально отвеченный (answered === 1 в исходных данных), но по факту это может быть случайное поднятие трубки без реального разговора. Такой звонок не засчитывается как перезвон, даже если формально попадает в нужное временное окно.
Ещё одна деталь — windowEnd считается от момента пропущенного звонка, а не от конца рабочего дня/суток:
const windowEnd = new Date(call.dateTimeObject.getTime() + callbackWindowMs);
То есть окно «плавающее» — пропущенный звонок в 23:50 с окном 24 часа даёт дедлайн перезвона на 23:50 следующего дня, а не на конец следующего дня целиком. Это стандартное и предсказуемое поведение, но стоит явно знать, объясняя логику метрики аналитикам, которые могут интуитивно ожидать «в течение следующих суток» в смысле календарных суток.
6.5 Группировка по месяцам и экспорт
groupByMonth(calls) (dataProcessor.gs) — финальный шаг перед экспортом, разбивает единый массив обработанных звонков на объект { 'YYYY-MM': Call[] } по первым 7 символам поля date, с сортировкой внутри каждого месяца по дате, а при совпадении дат — по времени:
grouped[month].sort((a, b) => {
const dateCmp = a.date.localeCompare(b.date);
return dateCmp !== 0 ? dateCmp : a.time.localeCompare(b.time);
});
На практике, поскольку период обработки в runProcessingPipeline() всегда укладывается в границы одного календарного месяца (см. раздел 6.1), результат groupByMonth() почти всегда содержит ровно один ключ в объекте — сама возможность мульти-месячной группировки в коде существует, но реальный сценарий использования (ручной или автоматический запуск за один месяц) её не задействует в полной мере. Функция явно спроектирована с запасом на будущее — например, на случай, если понадобится когда-нибудь обрабатывать произвольный диапазон дат, а не строго один месяц.
Финальная запись в Sheets идёт через exportMonthData() (sheetExporter.gs), которая для каждого месяца полностью очищает данные существующего листа перед повторной записью:
const lastRow = sheet.getLastRow();
if (lastRow > 1) {
sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent();
}
Это значит, что повторный запуск обработки за один и тот же месяц не дублирует строки, а полностью пересчитывает лист заново — удобно для идемпотентности (можно смело перезапускать обработку после сбоя без риска задвоить данные), но одновременно означает, что любые ручные правки, внесённые аналитиком напрямую в лист Звонки_YYYY-MM (например, дописанные вручную комментарии в свободную колонку, если бы такая была), будут потеряны при следующем автозапуске или ручном перезапуске того же месяца — колонок для «ручных заметок» в текущей структуре листа нет, но стоит держать этот риск в голове, если бизнес когда-нибудь попросит такую возможность.
6.6 Статистика по менеджерам
generateStatistics() (sheetExporter.gs) считается по тому же массиву allRows, что был записан в лист данных — звонки и чаты обрабатываются раздельно внутри одного объекта stats, ключом которого выступает разрешённое имя менеджера, а не email/telegramId:
if (row.type === 'chat') {
const key = row.managerName || 'Неизвестный менеджер';
// ...
} else {
const key = resolveManagerName(row.managerEmail, managersList) || row.managerEmail || 'Неизвестный';
// ...
}
Эффективность считается только для звонков, чатам эта метрика не присваивается:
const efficiency = s.missed > 0
? (s.callbacks / s.missed * 100).toFixed(1)
: (s.totalCalls > 0 ? '100.0' : '—');
При нуле пропущенных звонков, но при наличии хоть одного звонка вообще — эффективность жёстко выставляется в '100.0%'. Это разумное допущение (нет пропущенных — значит, отвечали на всё), но стоит явно знать: это не результат вычисления 0/0, а захардкоженная константа для этого частного случая.
7. Интеграция с Битрикс24
7.1 Два независимых механизма вебхуков — сравнение
В проекте фактически существуют два параллельных обращения к Битрикс24
Через config (BITRIX24_USERS_WEBHOOK / BITRIX24_CRM_WEBHOOK) |
Через «Балансировщик» (balancer.gs) |
|
|---|---|---|
| Где настраивается | UI настроек (settingsDialog.gs → updateConfig()), либо forceReinitializeConfig() |
Захардкожен в коде как константа BITRIX_WEBHOOK |
| Кто использует | integrations.gs (createBitrix24Task, testBitrix24Connection), bitrixService.gs (testBitrixConnection, через _getBitrixUsersWebhook()/_getBitrixCrmWebhook()) |
bitrixService.gs — getBitrixUserList() (user.get), getBitrix24Contacts() (crm.contact.list), getAllCompaniesMap() (crm.company.list) |
| Метод вызова | Прямой UrlFetchApp.fetch() на URL вебхука |
balancerPost_() / balancerRecurrentPost_() — HTTP-запрос к серверу балансировщика, который сам проксирует запрос в Битрикс24 |
| Обход лимитов | Нет — обычный вебхук Битрикс24 со стандартными ограничениями | Есть — балансировщик сам разруливает QUERY_LIMIT_EXCEEDED |
Используется ли в реальном пайплайне runProcessingPipeline() |
Только getBitrixUserList() вызывается в пайплайне, но внутри неё используется balancerRecurrentPost_(), а не прямой вебхук |
Да — это фактический рабочий путь для получения пользователей внутри пайплайна |
Эти два конфигурационных вебхука фактически используются только для:
testBitrixConnection()(bitrixService.gs) — тестовый запросuser.currentиcrm.contact.list(одна запись) при нажатии «Тестировать подключение к API» в меню.testBitrix24Connection()иcreateBitrix24Task()(integrations.gs) — тестовый запросcrm.contact.listи потенциальное создание задачи (последнее сейчас нигде не вызывается, см. раздел 14).
7.2 Балансировщик как обход лимитов Битрикс24
«Балансировщик» — внешний сервис (https://72.56.235.246.nip.io/balancer/api/balancer/send), который используется в этом проекте для того, чтобы получать данные из Битрикс24 CRM (списки пользователей, контактов, компаний) без риска упереться в rate limit самого Битрикс24 при больших объёмах. Логика его внутренней работы (как именно он считает лимиты, сколько запросов пропускает в секунду, как балансирует нагрузку) находится вне зоны ответственности этой документации — сервис используется как чёрный ящик через два метода в balancer.gs:
balancerPost_(method, body)— одиночный запрос без пагинации, для методов, которые не возвращают список с продолжением (add,update,deleteи т.п.).balancerRecurrentPost_(method, body, resultKey)— запрос с флагомis_recurrent: true, при котором балансировщик сам обходит пагинацию Битрикс24 и возвращает собранный результат целиком одним массивом (data.body).
Запросы к балансировщику подписываются HMAC-SHA256 (generateBalancerSignature_()) по паре ключей BALANCER_PUBLIC/BALANCER_PRIVATE, с таймстампом в заголовках X-Timestamp/X-Signature — то есть это не просто прокси по URL, а авторизованный канал, привязанный к конкретному клиенту (этому проекту). Для целей данной документации важно зафиксировать сам факт использования балансировщика и назначение — обход лимитов Битрикс24 при массовых операциях — без дальнейшего погружения в его собственную инфраструктуру.
7.3 Получение пользователей и контактов
getBitrixUserList() (bitrixService.gs) — вызывается в самом начале runProcessingPipeline() (шаг «Пользователи Битрикс24 загружены» на диаграмме из раздела 2.1) и возвращает список активных сотрудников:
const results = balancerRecurrentPost_('user.get', {});
const users = results
.filter(u => u.ACTIVE && u.EMAIL)
.map(u => ({
id : u.ID,
name : `${u.NAME || ''} ${u.LAST_NAME || ''}`.trim() || `Пользователь #${u.ID}`,
email : u.EMAIL.toLowerCase().trim(),
position : u.WORK_POSITION || '',
department: Array.isArray(u.UF_DEPARTMENT) ? u.UF_DEPARTMENT.join(', ') : '',
}));
Фильтр u.ACTIVE && u.EMAIL отсекает уволенных/заблокированных сотрудников и записи без email — что логично, так как единственное практическое применение этого списка в текущем пайплайне (getUserNameByEmail(), используется в testFunctions.gs для диагностики сопоставления email) требует именно email как ключ поиска. При этом сам список не используется напрямую в основном экспорте — резолв имени менеджера для звонков в sheetExporter.gs идёт не через getBitrixUserList(), а через resolveManagerName() из листа «Менеджеры» (managersList.gs), то есть список сотрудников Битрикс24, полученный в самом начале пайплайна, фактически нужен пайплайну лишь постольку, поскольку его отсутствие логируется как WARN — реального влияния на итоговый отчёт (кроме диагностических функций) он не оказывает.
getBitrix24Contacts() — получает все контакты CRM с телефонами, нормализует каждый номер через normalizePhone() (ещё одно место использования одной из трёх версий этой функции, см. раздел 5.4) и строит плоский массив { phone, name, id, companyId }. Есть защитный лимит:
const CONTACTS_WARN_LIMIT = 5000;
// ...
if (allContacts.length >= CONTACTS_WARN_LIMIT) {
Logger.log(`⚠️ getBitrix24Contacts: достигнут лимит ${CONTACTS_WARN_LIMIT} контактов.`);
break;
}
8. Интеграция с ОКБ
8.1 Проверка доступа и загрузка данных
ОКБ — отдельная Google-таблица, которая не принадлежит этому проекту и ведётся отдельно, как справочник компаний-клиентов с привязанными телефонами. Это единственный из четырёх источников данных (МоиЗвонки, Битрикс24, ОКБ, Telegram), к которому обращаются напрямую как к чужой Google-таблице, а не через HTTP API — отсюда и специфика проверки доступа.
loadOkbData() перед полноценным открытием таблицы делает тестовый запрос через export-URL:
const testUrl = `https://docs.google.com/spreadsheets/d/${OKB_SPREADSHEET_ID}/export?format=csv&gid=0`;
const testResponse = UrlFetchApp.fetch(testUrl, {
muteHttpExceptions: true,
headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() },
});
const code = testResponse.getResponseCode();
if (code === 403 || code === 404) {
return { success: false, error: 'ACCESS_DENIED' };
}
Учётная запись, от имени которой выполняется сам Apps Script проект (то есть — тот, кто в последний раз авторизовал скрипт, либо владелец триггера при автозапуске, должна иметь права хотя бы на чтение таблицы ОКБ. Если доступа нет — exportToSheets() (см. раздел 2.2) не прерывается, а просто продолжает работу с пустым индексом okbPhoneIndex = {}, и все компании в отчёте помечаются как «Не найдена в ОКБ».
После успешной проверки доступа таблица открывается уже штатно через SpreadsheetApp.openById(OKB_SPREADSHEET_ID), читается первый лист (okbSpreadsheet.getSheets()[0], без проверки имени листа — если структура таблицы ОКБ когда-нибудь изменится и первым листом станет не тот, что нужен, это тихо сломает интеграцию без явной ошибки), и из заголовков ищутся четыре конкретных колонки по точному текстовому совпадению:
const nameIndex = headers.indexOf('Название 1');
const idIndex = headers.indexOf('bitrix ID');
const managerIndex = headers.indexOf('Менеджер');
const phonesIndex = headers.indexOf('Контакты (все данные)');
Обязательными считаются только nameIndex и phonesIndex — если хотя бы одна из этих двух колонок не найдена, функция возвращает явную ошибку MISSING_COLUMNS. Колонки bitrix ID и Менеджер опциональны — их отсутствие не прерывает загрузку, просто соответствующие поля (id, manager) в итоговых записях останутся пустыми строками. Это довольно хрупкая связь с внешней таблицей: переименование любой из этих четырёх колонок в самой таблице ОКБ (человеком, который её ведёт и, вероятно, не подозревает о существовании этого скрипта) тихо сломает интеграцию — в лучшем случае с явной ошибкой MISSING_COLUMNS (для Название 1/Контакты (все данные)), в худшем — без какой-либо ошибки вообще, просто с пустыми id/manager у всех компаний (для bitrix ID/Менеджер).
8.2 Парсинг телефонов и построение индекса
Один из самых нетривиальных участков во всём проекте — разбор колонки «Контакты (все данные)» (parseOkbPhones()), потому что формат этой колонки, судя по коду и комментариям, не строго структурирован, а представляет собой человеко-заполняемое текстовое поле:
// Формат: "79892692631,88616634548" или "79033210072 # 11" или "ТЕСТ"
Разбор идёт по цепочке: сначала строка режется по запятым (несколько телефонов через запятую в одной ячейке — нормальный сценарий для компании с несколькими контактными номерами), затем из каждой части отрезается всё после символа # (судя по примеру "79033210072 # 11", это, вероятно, добавочный номер или комментарий, зашитый прямо в ту же ячейку — [Требует уточнения: что именно означает # в этом контексте — добавочный, приоритет контакта, что-то ещё, — сам код это никак не поясняет]), и наконец каждый остаток нормализуется через normalizePhoneStrict() (третья, отдельная реализация нормализации телефона, разобранная в разделе 5.4).
Отдельная защита от заведомо мусорных значений:
if (cleaned === '' || cleaned.toUpperCase() === 'ТЕСТ') return [];
Наличие в реальных данных буквального значения 'ТЕСТ' в колонке телефонов — прямое свидетельство того, что таблица ОКБ ведётся вручную живыми людьми, а не генерируется автоматически, и в ней встречаются тестовые/заглушечные записи, которые нужно явно отфильтровывать по строковому совпадению. Это не гипотетическая забота о будущем — раз в коде есть специальная проверка именно на это значение, значит, оно уже встречалось в проде и ломало парсинг до того, как проверку добавили.
buildOkbPhoneIndex() строит финальный индекс { [нормализованный_телефон]: { companyName, companyId, manager } }, попутно логируя (но не прерывая построение индекса) обнаруженные дубли телефонов между разными компаниями:
if (index[normalizedPhone]) {
Logger.log(
`⚠️ Дубликат телефона ${normalizedPhone}: ` +
`"${index[normalizedPhone].companyName}" vs "${company.name}"`,
'WARN'
);
continue;
}
Победителем при дубле становится первая по порядку строк таблицы компания — вторая молча отбрасывается (continue) после логирования предупреждения. Если в ОКБ реально попадаются такие коллизии (например, один и тот же корпоративный номер по ошибке привязан к двум разным юрлицам одной группы компаний), то в отчёте вся звонковая/чатовая активность по этому номеру будет приписана только первой найденной записи, а не обеим или не той, что «правильнее» с точки зрения бизнеса. Порядок строк в самой Google-таблице ОКБ, соответственно, косвенно влияет на то, какая компания «выигрывает» — довольно неочевидная зависимость для человека, который просто добавляет новую строку в таблицу ОКБ, не подозревая о влиянии порядка на результат.
8.3 Кэширование индекса
getCachedOkbPhoneIndex() — персистентный кэш через PropertiesService с TTL 1 час (OKB_CACHE_DURATION_MS = 60 * 60 * 1000), структурно идентичный по паттерну кэшированию карты компаний Битрикс24 из cache.gs (раздел 7.5) — обе функции читают timestamp последнего обновления, сравнивают с Date.now(), и либо отдают закэшированный JSON, либо строят индекс заново.
Единственное отличие в обработке ошибок — если сериализованный кэш в PropertiesService оказался повреждён (JSON.parse бросил исключение), функция не падает, а просто логирует предупреждение и продолжает — переходит к перестроению индекса с нуля, как будто кэша не было вообще:
try {
const parsed = JSON.parse(cachedData);
return parsed;
} catch (e) {
Logger.log('⚠️ Кэш ОКБ поврежден, загружаем заново');
}
Как и в случае с картой компаний Битрикс24, здесь есть защита от превышения лимита размера одного свойства PropertiesService (~900 КБ):
try {
scriptProps.setProperty(OKB_CACHE_KEY, JSON.stringify(freshIndex));
scriptProps.setProperty(OKB_CACHE_TIME_KEY, now.toString());
} catch (e) {
Logger.log('⚠️ Не удалось сохранить кэш ОКБ: данные слишком большие', 'WARN');
}
При срабатывании этого catch-блока функция продолжает работать — возвращает freshIndex, построенный в текущем выполнении, просто без сохранения в кэш на будущее. То есть деградация полностью «тихая»: пайплайн отработает корректно в моменте, но каждый следующий запуск (в том числе ежедневный автозапуск) будет заново перестраивать индекс с нуля вместо использования кэша, платя за это временем выполнения (загрузка всей таблицы ОКБ и парсинг телефонов на каждый запуск) — но без единого явного алерта пользователю или в лист «Логи» о том, что кэш ОКБ фактически перестал работать. Единственный способ заметить деградацию — сравнить время выполнения пайплайна до и после того, как таблица ОКБ выросла настолько, что индекс перестал помещаться в лимит PropertiesService.
clearOkbCache() — ручной сброс кэша, вызывается извне только при необходимости принудительно обновить данные раньше истечения часового TTL; в текущем пайплайне (runProcessingPipeline()) не вызывается вообще — это исключительно «служебная» функция для ручного вызова из редактора Apps Script при необходимости, без привязки к какому-либо пункту меню UI.
9. Интеграция с Telegram Logger API
9.1 Загрузка агрегированных сообщений
Telegram Logger API — судя по всему, отдельный внешний сервис (https://45.10.40.213.nip.io/api, значение по умолчанию TELEGRAM_API_URL), который сам где-то на своей стороне логирует переписки менеджеров с клиентами в Telegram и отдаёт уже агрегированные по дням данные, а не сырую историю сообщений. Сам этот сервис, его хранилище и то, как он собирает данные из Telegram, не входят в состав переданных файлов — здесь описывается только клиентская часть интеграции, реализованная в telegramService.gs.
Запрос идёт одним HTTP GET на эндпоинт /messages с параметрами периода:
var url = _getTelegramApiUrl()
+ '/messages'
+ '?date_from=' + fmt(dateFrom)
+ '&date_to=' + fmt(dateTo);
где fmt() форматирует дату в UTC (Utilities.formatDate(d, 'UTC', 'yyyy-MM-dd')) — в отличие от большей части остального проекта, который систематически завязан на Europe/Moscow (см. раздел 6.1). Это единственное место в проекте, где явно используется UTC вместо московского времени при работе с датами, и стоит зафиксировать этот нюанс отдельно: если Telegram Logger API на своей стороне тоже считает границы суток по UTC, всё согласовано, но если он сам где-то внутри переводит даты в московское время до агрегации — на границе суток (примерно 21:00–00:00 по МСК = 00:00–03:00 UTC следующих суток) сообщения теоретически могут попасть не в тот день, в который их отнёс бы человек, смотрящий на часы в Москве
Авторизация — простой статический заголовок:
headers: { 'X-API-Key': _getTelegramApiKey() },
Запрос обёрнут в try/catch на уровне fetchAggregatedMessages(), и при любой ошибке (сетевой сбой, HTTP-код не 200, невалидный JSON) функция не бросает исключение наружу, а логирует и возвращает пустой массив:
try {
rawData = _telegramApiFetch(url);
} catch (err) {
Logger.log('❌ fetchAggregatedMessages: ' + err, 'ERROR');
return [];
}
Это согласуется с общей стратегией мягкой деградации, разобранной в разделе 2.1 — отсутствие данных из Telegram не должно ронять весь пайплайн, звонки всё равно должны попасть в отчёт. Но, как уже отмечалось в разделе 2.2, поскольку запрос идёт одним вызовом за весь диапазон месяцев, а не помесячно, любой сбой (в том числе временный, на 1 запрос из десятков потенциальных, если бы была пагинация) убирает чаты из отчёта целиком за весь запрошенный период, а не только за проблемный день/месяц.
Каждая запись из ответа API проходит через три этапа резолва перед превращением в объект чата (структура которого разобрана в разделе 5.2):
- Менеджер — через
tgManagersMap[String(row.manager_id)], с fallback на'Telegram #' + row.manager_id, если сопоставления в листе «Менеджеры» нет (см. 9.2 ниже). - Телефон клиента — берётся из готового поля
row.client_phone, которое, судя по комментарию в коде (// JOIN clients.phone в SQL-запросе на бэке), само API уже подтягивает через JOIN на своей стороне — то есть Apps Script не делает отдельного запроса к Telegram API за телефоном клиента, это уже готовое поле ответа/messages. - Контакт и компания — резолвятся через параметр
phoneToContactMap(на практике — индекс ОКБ, см. подробный разбор несовпадения в разделе 6.7) и, при заполненномcompanyId, дополнительно черезcompanyIdToName(который на практике оказываетсяundefined— тот же баг).
Стоит отдельно отметить подробное построчное логирование телефона на каждую запись:
Logger.log(
'client_id=' + row.client_id
+ ' | raw_phone=' + (rawPhone || 'NULL')
+ ' | normalized=' + (normalizedPhone || 'пусто'),
'INFO'
);
При большом количестве переписок за месяц это может ощутимо раздувать лист «Логи» (с учётом LOG_MAX_ROWS = 1000 и буферизации по LOG_FLUSH_SIZE = 20, разобранных в разделе 12.1) — потенциально логи Telegram-резолва телефонов будут вытеснять из листа «Логи» более важные сообщения об ошибках других этапов пайплайна, если общий объём логов за один прогон превысит тысячу строк.
9.2 Сопоставление менеджеров Telegram ↔ МоиЗвонки
Проблема, которую решает этот механизм, прямо сформулирована в самом UI (managers.html): у менеджера есть email в МоиЗвонки и есть числовой идентификатор в Telegram Logger API, и эти два идентификатора никак не связаны на уровне данных — их нужно сопоставить вручную, один раз, через административный интерфейс.
Лист «Менеджеры» (см. структуру в разделе 5.3) содержит три колонки: Email менеджера, Имя менеджера, Telegram Manager ID. Первые два поля используются для резолва имени по email в звонках (resolveManagerName()), третье — специально для чатов. buildTelegramManagersMap() (managersList.gs) строит из этого же листа отдельную карту:
function buildTelegramManagersMap() {
const list = getManagersList();
var map = {};
for (var i = 0; i < list.length; i++) {
var m = list[i];
if (m.telegramId) {
map[m.telegramId] = m.name || m.email;
}
}
return map;
}
Ключевая деталь бизнес-процесса, зашитая в UI (managers.html, блок «ℹ️ Как узнать Telegram Manager ID»): администратор должен вручную открыть Swagger UI документацию Telegram Logger API, вызвать GET /managers, найти нужного человека по какому-то внешнему признаку, скопировать числовой id и вставить его в таблицу. Это полностью ручной, неавтоматизированный процесс сопоставления — никакой синхронизации или автоматического подтягивания списка менеджеров из Telegram Logger API в коде нет.
Практическое следствие: если новый менеджер добавляется в Telegram Logger API (например, подключается к системе логирования переписок), но администратор забывает вручную вписать его telegramId в лист «Менеджеры» этого проекта — все его чаты в отчёте будут подписаны как 'Telegram #<id>' вместо настоящего имени, без какой-либо автоматической эскалации или уведомления об этом несоответствии (кроме строки WARN в логах: manager_id ${row.manager_id} не сопоставлен в листе Менеджеры, которую нужно целенаправленно искать в листе «Логи»).
10. Пользовательские интерфейсы (HTML-диалоги)
10.1 Обзор всех модалок и их вызовов
Все диалоги проекта открываются исключительно из пунктов меню onOpen() (main.gs), через HtmlService.createHtmlOutputFromFile(...) с SandboxMode.IFRAME:
| Файл | Функция открытия | Размер окна | Тип открытия |
|---|---|---|---|
monthPicker.html |
openMonthPickerDialog() |
420×380 | showModalDialog |
managers.html |
openManagersManager() |
800×600 | showModalDialog |
timePicker.html |
setupAutoTrigger() |
500×350 | showModalDialog |
archiveDialog.html |
openArchiveDialog() |
520×560 | showModalDialog |
10.2 monthPicker.html — выбор периода обработки
Точка входа в ручной запуск обработки. Ключевая клиентская логика — динамическое ограничение выбора месяца текущим:
function rebuildMonthOptions() {
const selectedYear = parseInt(yearSelect.value, 10);
const maxMonth = selectedYear === currentYear ? currentMonth : 12;
// ... заполнение <select> месяцев от 1 до maxMonth ...
}
Если выбран текущий год — список месяцев обрезается текущим месяцем включительно (нельзя выбрать «Декабрь», если сейчас, например, март текущего года). Для прошлых лет доступны все 12 месяцев. Список годов ограничен текущим и двумя предыдущими (for (let y = currentYear; y >= currentYear - 2; y--)) — то есть глубина ручной обработки «в прошлое» жёстко ограничена тремя годами на уровне клиентской формы. Технически runProcessingPipeline(targetYear, targetMonth) на сервере не содержит никакой проверки на этот трёхлетний лимит — если вызвать функцию напрямую с более старым годом (например, из редактора кода), она отработает; ограничение существует только на уровне UI, а не как серверная бизнес-правило.
Эта же клиентская проверка ограничения месяца дублирует серверную проверку в runProcessingPipeline() (if (startOfMonth > nowTimestamp) throw ..., разобранную в разделе 6.1) — то есть защита от выбора будущего периода есть в двух независимых местах: на клиенте (просто не даёт выбрать в <select>) и на сервере (бросает исключение, если такой вызов всё же случится). Это нормальная, а не избыточная практика — клиентская проверка улучшает UX (не даёт пользователю в принципе увидеть недопустимый вариант), серверная защищает от прямого вызова в обход UI.
После выбора и нажатия «Запустить» диалог не закрывается сам — закрытие происходит только в колбэке успеха вызова startMonthProcessing():
google.script.run
.withSuccessHandler(function() { google.script.host.close(); })
.withFailureHandler(onError)
.startMonthProcessing(year, month);
Технически это значит, что monthPicker.html закрывается только после того, как весь синхронный вызов startMonthProcessing() (внутри которого целиком выполняется runProcessingPipeline() — тот самый пайплайн на несколько минут) полностью завершится на сервере. Диалог выбора месяца, таким образом, остаётся открытым на экране пользователя на всё время работы пайплайна, и лишь потом закрывается сам — притом что модальный progressDialog.gs открывается сервером ещё раньше, в начале startMonthProcessing(). На практике пользователь в течение всей обработки видит два наложенных друг на друга модальных/немодальных окна (выбор месяца и прогресс), пока первое не закроется автоматически по завершении вызова.
10.3 progressDialog.gs — визуализация прогресса
Клиентский JS содержит жёстко заданный список шагов и соответствующих им значений прогресса, дублирующий структуру, которая реально выставляется на сервере в _setProcessingStatus() по всему runProcessingPipeline():
const STEPS = ['bitrix','fetch','telegram','process','export','pdf'];
const STEP_PROGRESS = { bitrix:10, fetch:30, telegram:50, process:65, export:85, pdf:100 };
Примечательно, что объект STEP_PROGRESS в клиентском коде фактически не используется для расчёта — applyStatus() берёт готовое числовое значение status.progress напрямую из ответа сервера (document.getElementById('progressBar').style.width = pct + '%'), а не пересчитывает его через локальную таблицу STEP_PROGRESS. То есть эта константа в HTML-файле — мёртвый код, оставшийся, вероятно, от более ранней версии, где прогресс вычислялся на клиенте по номеру текущего шага, а не передавался сервером в готовом виде.
Механизм polling запускается сразу при загрузке диалога и продолжается до явного сигнала done/error в статусе:
window._poll = setInterval(poll, 1000);
poll();
При получении status.done === true — интервал останавливается, и диалог закрывается сам через 1.5 секунды (setTimeout(..., 1500)), давая пользователю время увидеть финальное сообщение «✅ Обработка завершена!». Символично, что раз runProcessingPipeline() выполняется полностью синхронно (см. раздел 2.3), финальный статус done: true физически попадает в PropertiesService только после того, как вся обработка уже завершена — то есть цикл poll() не столько «следит за прогрессом в реальном времени», сколько считывает уже отработавшие, ранее записанные метки прогресса, догоняя их с интервалом в секунду. Пользователь видит красивую последовательную анимацию шагов, но фактически смотрит на журнал уже случившихся событий, а не на живой процесс — разница неощутима на глаз, но важна для понимания архитектуры при отладке.
10.4 Остальные диалоги
blacklistManager.gs — CRUD-интерфейс поверх листа «Черный список». Валидация на клиенте примитивная (только длина в 10+ цифр: digitCount < 10), реальная нормализация и повторная проверка длины (< 10 в addToBlacklist()) происходит уже на сервере — то есть клиентская валидация здесь чисто косметическая, для UX (не даёт нажать кнопку раньше времени), а не единственный барьер.
settingsDialog.gs — самый большой по количеству полей диалог, единая точка правки всех настроек из раздела 4.2. Уже разобранная в разделе 4.2 проблема с несуществующим DOM-элементом #reportEmail в saveSettings() находится именно в этом файле — стоит помнить, что это не абстрактный баг «где-то в конфиге», а конкретная строка кода в форме настроек, которая должна была отправлять письмо на сохранение и вместо этого упадёт на document.getElementById('reportEmail').value.trim(), вернув null.value.
managers.html — единственный диалог с встроенным блоком-инструкцией прямо в разметке («ℹ️ Как узнать Telegram Manager ID»), что напрямую отражает ручной, неавтоматизированный характер процесса сопоставления менеджеров, разобранного в разделе 9.2. UI поддерживает добавление, редактирование через модальное окно (#editModal, отдельный небольшой modal внутри модального диалога — вложенность, специфичная для того, чтобы не городить отдельный полноценный HtmlService-диалог ради формы редактирования одной записи) и удаление с обязательным confirm().
archiveDialog.html — единственный диалог с продуманной эвристикой автогенерации имени файла архива на основе выбранных листов (updateFilename(), парсит YYYY-MM из названий выбранных листов регуляркой /(\d{4}-\d{2})/ и строит диапазон дат). Также содержит кнопку быстрого выбора «Только данные» (selectData()), которая фильтрует список листов по префиксу 'Звонки_' — включая, что стоит отметить, и листы _Статистика, поскольку startsWith('Звонки_') не делает различия между Звонки_2026-01 и Звонки_2026-01_Статистика, в отличие от чуть более строгого фильтра в самом archiveDialog.html при первичной отрисовке списка (isData = sheet.name.startsWith('Звонки_') && !sheet.name.includes('Статистика')) — то есть кнопка «Только данные» и первоначальное состояние чекбоксов при загрузке диалога используют разную логику определения «что считать данными», хоть и с одинаковым намерением.
timePicker.html — самый простой по бизнес-логике диалог, но именно в нём реализован уже разобранный в разделе 3.4 механизм чтения TRIGGER_TIME для отображения текущего состояния автозапуска, и кнопка полного удаления всех триггеров, продублированная с одноимённым пунктом меню.
11. Работа с чёрным списком номеров
Этот раздел фокусируется на CRUD-операциях над листом «Черный список» (blacklist.gs) как таковых — сама роль чёрного списка в фильтрации звонков внутри пайплайна уже разобрана в разделе 6.3, а тройное дублирование логики нормализации телефона — в разделе 5.4.
11.1 Добавление и удаление
addToBlacklist(phoneNumber, comment) — точка входа из UI (blacklistManager.gs). Последовательность проверок нетривиальна и стоит разбора по шагам:
const normalized = normalizePhone(phoneNumber);
if (normalized === 'Номер скрыт') {
return { success: false, message: 'Некорректный или скрытый номер' };
}
if (normalized.length < 10) {
return { success: false, message: 'Слишком короткий номер телефона' };
}
Проверка на существующий номер в списке читает весь столбец A одним вызовом, а не построчным перебором через getCell():
const existingValues = sheet.getRange(1, 1, sheet.getLastRow(), 1).getValues();
const exists = existingValues.some((row) => normalizePhone(String(row[0])) === normalized);
11.2 Кэширование и инвалидация
_blacklistCache — тот же паттерн внутрисессионного кэша через переменную модуля, что уже разбирался для _configCache (раздел 4.1) и _companiesMapCache (раздел 7.5), только без персистентности между запусками (в отличие, например, от кэша ОКБ или альтернативной реализации кэша компаний в cache.gs) — то есть каждый новый запуск пайплайна или открытие диалога чёрного списка всегда читает лист заново с нуля, кэш работает только в границах одного выполнения.
Инвалидация (_clearBlacklistCache()) вызывается ровно в двух местах — после addToBlacklist() и после removeFromBlacklist() — оба раза непосредственно перед возвратом успешного результата в UI. Это корректно устраняет тот класс багов, где пользователь добавляет номер через диалог, а затем в рамках того же выполнения скрипта (что в GAS происходит нечасто, но теоретически возможно, если, например, addToBlacklist() и последующий вызов loadBlacklist() оказались бы в одной цепочке выполнения) видел бы устаревшие данные без только что добавленной записи.
Стоит отдельно отметить асимметрию с обработкой ошибок: loadBlacklist() при отсутствии config.spreadsheetId или при возникновении любой другой ошибки чтения листа не бросает исключение, а возвращает пустой Set с логированием предупреждения — то есть при сбое доступа к листу «Черный список» (например, временная недоступность Sheets API) пайплайн продолжит работу так, как будто чёрный список полностью пуст, отфильтровав ноль номеров, а не откажется от обработки вовсе. Это согласуется с общей стратегией мягкой деградации всего проекта (см. раздел 2.1), но именно в этом случае деградация особенно незаметна: отчёт будет выглядеть полностью нормальным, просто в него попадут звонки, которые должны были быть отфильтрованы — обнаружить такую деградацию можно только сверив количество отфильтрованных записей с ожидаемым, а такого счётчика в системе, как уже отмечалось в разделе 5.1, попросту нет.
12. Логирование, обработка ошибок и отправка отчётов
12.1 Буферизованное логирование
logMessage() (utils.gs) — обёртка над Logger.log(), которая дополнительно копит сообщения для записи в лист «Логи», но не пишет в Sheets на каждый вызов, а буферизует:
const _logBuffer = [];
const LOG_FLUSH_SIZE = 20;
function logMessage(message, level = 'INFO') {
const config = getConfig();
if (!config.enableLogging && level !== 'ERROR') return;
// ...
Logger.log(`[${timestamp}] [${level}] ${message}`);
if (config.spreadsheetId) {
_logBuffer.push([timestamp, level, message]);
if (_logBuffer.length >= LOG_FLUSH_SIZE) {
flushLogs();
}
}
}
Стоит обратить внимание на две детали, зашитые прямо в проверку в начале функции:
ENABLE_LOGGINGне отключаетLogger.log()— только запись в лист «Логи» через буфер. Встроенный лог GAS (Logger.log, видимый в редакторе выполнения Apps Script) пишется всегда, независимо от настройки — отключается только «пользовательский» лог в Google-таблице. Это логично:Logger.log()— встроенный, бесплатный и не создаёт нагрузки на Sheets API, а запись в лист — это уже дополнительная операция, которую имеет смысл давать отключить при больших объёмах.- Ошибки уровня
ERRORпишутся в лист «Логи» всегда, даже еслиenableLogging === false(level !== 'ERROR'в условии выхода) — то есть отключение логирования в настройках снижает шум отINFO/WARN, но не скрывает критические ошибки от аналитика, который потом придёт разбираться, почему отчёт неполный.
_getLogSheet() реализует ленивое создание/поиск листа с двумя уровнями кэша — успешный результат (_logSheet) и флаг неудачной попытки (_logSheetTried), чтобы не пытаться повторно открывать таблицу при каждом вызове logMessage(), если первая попытка уже провалилась в рамках этого выполнения (например, SPREADSHEET_ID ещё не задан на момент первого лога):
function _getLogSheet() {
if (_logSheet) return _logSheet;
if (_logSheetTried) return null;
_logSheetTried = true;
// ... попытка открыть/создать лист ...
}
flushLogs() пишет накопленный буфер одним вызовом setValues() вместо N вызовов appendRow() — тот же систематический паттерн батч-записи, что уже отмечался в разделах 6.3 и 11.1. Дополнительно flushLogs() обрезает лист сверху при превышении LOG_MAX_ROWS = 1000:
const totalRows = sheet.getLastRow();
if (totalRows > LOG_MAX_ROWS + 1) {
sheet.deleteRows(2, totalRows - LOG_MAX_ROWS - 1);
}
То есть лист «Логи» ведёт себя как кольцевой буфер фиксированного размера — старые записи вытесняются новыми, полной истории логов система не хранит. При объёме логирования, разобранном в разделе 9.1 (построчный лог телефона на каждую запись Telegram), эта тысяча строк может исчерпываться в пределах одного-двух прогонов пайплайна, если объём переписок велик — то есть на практике глубина «истории», доступной аналитику в листе «Логи», может составлять не недели, а буквально один-два последних запуска.
safeExecute() (разбирается подробнее в 12.2) вызывает flushLogs() явно после каждого шага пайплайна — это гарантирует, что даже при падении скрипта где-то дальше по цепочке (и, соответственно, без штатного завершения и без вызова flushLogs() в самом конце runProcessingPipeline()), логи уже выполненных шагов не потеряются в буфере, а успеют попасть на лист.
12.2 safeExecute() как единая точка обработки ошибок пайплайна
function safeExecute(func, operationName = 'Операция') {
try {
const result = func();
logMessage(`✅ ${operationName} выполнена`, 'INFO');
flushLogs();
return { success: true, data: result };
} catch (error) {
const msg = `❌ Ошибка в '${operationName}': ${error}\n${error.stack || ''}`;
logMessage(msg, 'ERROR');
flushLogs();
return { success: false, error: msg };
}
}
Функция сама по себе никогда не бросает исключение — она перехватывает любую ошибку внутри func() и возвращает структурированный результат { success, data|error }. Ответственность за то, прерывать ли пайплайн при неудаче, полностью лежит на вызывающем коде — и здесь важно понимать, что именно runProcessingPipeline() в main.gs решает превратить неудачу обратно в исключение:
const fetchResult = safeExecute(() => fetchCalls(startOfMonth, endOfPeriod, 1), 'Получение данных из API');
if (!fetchResult.success) throw new Error('Ошибка получения данных из API');
12.3 Рассылка PDF-отчётов
sendPdfReportsByEmail(pdfBlobs, reportInfo) принимает на вход массив PDF-заготовок и параллельный массив метаданных reportInfo, формирует единое письмо с сводкой по всем месяцам и вложениями, и отправляет его через GmailApp.sendEmail() одним вызовом на всех получателей сразу, через строку адресов, объединённую запятой:
const emailString = emailAddresses.join(', ');
GmailApp.sendEmail(emailString, subject, emailBody, { attachments, name: "..." });
При сбое массовой отправки (catch вокруг GmailApp.sendEmail()) включается sendFallbackEmails() — та же рассылка, но поштучно на каждый адрес через MailApp.sendEmail() (не GmailApp, что стоит заметить — fallback сознательно использует другой сервис отправки, а не просто другой цикл вызовов того же GmailApp, вероятно, чтобы исключить сценарий, при котором именно GmailApp временно недоступен, а MailApp — нет):
emailAddresses.forEach(email => {
try {
MailApp.sendEmail({ to: email, subject, body, attachments, name: "... [РЕЗЕРВ]" });
successfulSends++;
} catch (error) {
failedSends++;
}
});
Fallback-письма помечены как «[РЕЗЕРВ]» и в теме, и в имени отправителя, и вложения переименовываются с суффиксом _резерв.pdf — то есть получатель однозначно поймёт, что письмо пришло не по основному, штатному пути, если вдруг возникнет путаница с двумя разными версиями одного отчёта (основная попытка технически могла частично пройти для части получателей до сбоя — сама реализация sendPdfReportsByEmail() не разделяет получателей на «уже получивших» и «не получивших» при частичном сбое, GmailApp.sendEmail() с несколькими адресами в одной строке — это одна операция целиком, либо полностью успешная, либо полностью упавшая, частичной доставки на уровне GAS API здесь не бывает).
12.4 Уведомление об ошибке автозапуска
runProcessingPipelineTrigger() (main.gs) — обёртка над runProcessingPipeline(), вызываемая исключительно по расписанию (ScriptApp.newTrigger). В отличие от ручного запуска через startMonthProcessing(), здесь при падении пайплайна пользователю физически некому показать ошибку в интерфейсе — никто не наблюдает за экраном в момент ночного/дневного автозапуска. Поэтому при перехвате исключения функция дополнительно отправляет письмо на config.userEmail:
} catch (err) {
Logger.log(`❌ Ошибка авто-обработки: ${err}`, 'ERROR');
try {
const email = getConfig().userEmail;
if (email) {
MailApp.sendEmail({
to: email,
subject: '🚨 Ошибка автозапуска MoiZvonki',
body: `Ошибка:\n\n${err}\n\nПроверьте логи выполнения.`,
});
}
} catch (mailErr) {
Logger.log(`❌ Email об ошибке не отправлен: ${mailErr}`, 'ERROR');
}
throw err;
}
13. Тестовые и отладочные функции
13.1 UI-тесты подключения
В отличие от testFunctions.gs, эти функции доступны пользователю через штатный интерфейс — либо через пункт меню, либо через кнопки внутри settingsDialog.gs:
| Функция | Файл | Как вызывается | Что проверяет |
|---|---|---|---|
testApiConnection() / testApiConnectionUI() |
apiClient.gs / main.gs |
Пункт меню «⌁ Тестировать подключение к API» | Один запрос calls.list к МоиЗвонки с окном в 1 час и лимитом в 1 запись — проверяет связку apiSubdomain+userEmail+apiKey |
testApiConnectionWithSettings() |
main.gs |
Кнопка «🔍 Протестировать подключение» в settingsDialog.gs |
Временно подменяет конфиг введёнными в форму значениями, тестирует, затем восстанавливает исходные значения — позволяет проверить новые настройки API, не сохраняя их заранее |
testBitrixConnection() |
bitrixService.gs |
(нигде не вызывается из UI/меню — см. ниже) | Оба вебхука Битрикс24 (user.current + crm.contact.list), плюс пробует получить список сотрудников |
testBitrix24Connection() |
integrations.gs |
(тоже нигде не вызывается из UI/меню) | Только CRM-вебхук через crm.contact.list |
testEmailConnection() / testEmailConnectionUI() |
pdfExporter.gs / main.gs |
Кнопка «📧 Протестировать отправку на email» в settingsDialog.gs |
Реальная отправка тестового письма через GmailApp.sendEmail() на введённые адреса |
14. Термины
Термины, специфичные для этого проекта — общие определения из документации МоиЗвонки/Битрикс24/Telegram здесь не приводятся, только то, как эти понятия используются конкретно в этой кодовой базе.
| Термин | Значение в контексте проекта |
|---|---|
| ОКБ | Общая клиентская база — отдельная Google-таблица (OKB_SPREADSHEET_ID), которая ведётся вручную и служит источником сопоставления «номер телефона → название компании-клиента» для звонков в отчёте. Не путать с CRM Битрикс24 — это независимый, отдельно поддерживаемый справочник. |
| МоиЗвонки | Внешний сервис телефонии/колл-трекинга ({subdomain}.moizvonki.ru), основной источник данных о звонках. В коде — apiClient.gs. |
| Балансировщик | Сторонний прокси-сервис, обходящий rate limit Битрикс24 при массовых чтениях (user.get, crm.contact.list, crm.company.list). Подписывает запросы HMAC-SHA256. Внутренняя логика вне зоны ответственности этого проекта — используется как чёрный ящик. См. balancer.gs, раздел 7.2. |
| Callback window / окно перезвона | Настраиваемый период (CALLBACK_WINDOW_HOURS, по умолчанию 24 часа), в течение которого отвеченный звонок после пропущенного засчитывается как «перезвон совершён». См. раздел 6.4. |
| Telegram Manager ID | Числовой идентификатор менеджера во внешнем Telegram Logger API (получается через GET /managers на стороне того сервиса), вручную сопоставляется с email менеджера МоиЗвонки в листе «Менеджеры». См. раздел 9.2. |
| Чат (в контексте отчёта) | Не единичное сообщение, а агрегированная за один день переписка одного диалога — с полями «количество сообщений», «последнее сообщение дня» и т.д. Не путать с обычным пониманием «чата» как окна переписки. См. раздел 5.2. |
| Номер скрыт | Специальное строковое значение, в которое нормализуются телефоны, не поддающиеся распознаванию (анонимные звонки, текстовые метки АТС, паттерны-заглушки вроде 77777777777). Используется вместо null/undefined как явный маркер по всей кодовой базе. См. HIDDEN_NUMBER_PATTERNS в разделе 5.4. |
| Триггер (в контексте GAS) | Здесь конкретно — единственный разрешённый в проекте time-based триггер с обработчиком runProcessingPipelineTrigger; система жёстко ограничивает себя одним активным триггером за раз через removeAllTriggers() перед созданием нового. См. раздел 3.4. |
| Сессия выполнения (в контексте GAS) | Один вызов серверной функции целиком (от старта до return/исключения), в границах которого живут кэши на переменных модуля (_configCache, _blacklistCache и т.д.). Не путать с пользовательской сессией браузера — это исполнительная единица на стороне Apps Script. См. раздел 1.3. |
Нет комментариев для отображения
Нет комментариев для отображения