mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:24:49 +00:00
feat(backend): full Apps Script Web App — /api/me, /api/measurement, /api/podbor + Claude integration + Telegram notifications
This commit is contained in:
parent
57eefbbf5c
commit
b87dce16a8
650
backend/Code.gs
650
backend/Code.gs
@ -1,17 +1,25 @@
|
||||
/**
|
||||
* ЗОВ — Backend (Google Apps Script Web App)
|
||||
*
|
||||
* Деплой: Deploy → New deployment → Type: Web app
|
||||
* Execute as: Me
|
||||
* Who has access: Anyone (только так MiniApp сможет POST'ить)
|
||||
* Точки входа:
|
||||
* POST {WEBAPP_URL}?path=me → handleMe
|
||||
* POST {WEBAPP_URL}?path=measurement → handleMeasurement
|
||||
* POST {WEBAPP_URL}?path=podbor → handlePodbor
|
||||
* GET {WEBAPP_URL} → ping (health check)
|
||||
*
|
||||
* Тело POST: JSON { initData: "tg-init-data", ...payload }
|
||||
*
|
||||
* Script Properties (Project Settings → Script Properties):
|
||||
* - BOT_TOKEN
|
||||
* - ANTHROPIC_API_KEY
|
||||
* - ADMIN_TG_ID
|
||||
* - SHEET_ID
|
||||
* BOT_TOKEN — токен Telegram-бота (для проверки initData и отправки уведомлений)
|
||||
* ANTHROPIC_API_KEY — ключ Claude API
|
||||
* ANTHROPIC_MODEL — опционально, модель (default: claude-haiku-4-5-20251001)
|
||||
* ADMIN_TG_ID — tg_id куратора, кому слать алерты об ошибках
|
||||
*/
|
||||
|
||||
// =================================================================
|
||||
// 1. Entry & routing
|
||||
// =================================================================
|
||||
|
||||
function doPost(e) {
|
||||
try {
|
||||
const path = (e.parameter && e.parameter.path) || "";
|
||||
@ -21,52 +29,624 @@ function doPost(e) {
|
||||
|
||||
let result;
|
||||
switch (path) {
|
||||
case "me":
|
||||
result = handleMe(body);
|
||||
break;
|
||||
case "measurement":
|
||||
result = handleMeasurement(body);
|
||||
break;
|
||||
case "podbor":
|
||||
result = handlePodbor(body);
|
||||
break;
|
||||
case "me": result = handleMe(body); break;
|
||||
case "measurement": result = handleMeasurement(body); break;
|
||||
case "podbor": result = handlePodbor(body); break;
|
||||
case "ping": result = { pong: true, time: new Date().toISOString() }; break;
|
||||
default:
|
||||
return jsonResponse({ error: "unknown_path", path }, 404);
|
||||
return jsonResponse({ error: "unknown_path", path });
|
||||
}
|
||||
return jsonResponse(result);
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: String(err) }, 500);
|
||||
log("api_error", null, { path: e.parameter && e.parameter.path, error: String(err), stack: err.stack });
|
||||
return jsonResponse({ error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(obj, _status) {
|
||||
function doGet() {
|
||||
return jsonResponse({
|
||||
status: "ok",
|
||||
service: "zov-tech-backend",
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(obj) {
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify(obj))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
|
||||
// =============== handlers ===============
|
||||
// =================================================================
|
||||
// 2. Auth — Telegram WebApp initData verification
|
||||
// =================================================================
|
||||
|
||||
function verifyInitData(initData) {
|
||||
if (!initData) return null;
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const botToken = props.getProperty("BOT_TOKEN");
|
||||
if (!botToken) throw new Error("BOT_TOKEN not in Script Properties");
|
||||
|
||||
const params = parseInitData(initData);
|
||||
const receivedHash = params["hash"];
|
||||
if (!receivedHash) return null;
|
||||
delete params["hash"];
|
||||
|
||||
const keys = Object.keys(params).sort();
|
||||
const dataCheckString = keys.map(k => k + "=" + params[k]).join("\n");
|
||||
|
||||
const tokenBytes = Utilities.newBlob(botToken).getBytes();
|
||||
const wadBytes = Utilities.newBlob("WebAppData").getBytes();
|
||||
const dcsBytes = Utilities.newBlob(dataCheckString).getBytes();
|
||||
|
||||
const secretKey = Utilities.computeHmacSha256Signature(tokenBytes, wadBytes);
|
||||
const computedBytes = Utilities.computeHmacSha256Signature(dcsBytes, secretKey);
|
||||
const computedHash = bytesToHex(computedBytes);
|
||||
|
||||
if (computedHash !== receivedHash) return null;
|
||||
|
||||
// 24-hour freshness check
|
||||
const authDate = parseInt(params["auth_date"] || "0", 10);
|
||||
const ageSec = Math.floor(Date.now() / 1000) - authDate;
|
||||
if (ageSec > 86400) return null;
|
||||
|
||||
let user = null;
|
||||
try { user = JSON.parse(params["user"] || "null"); } catch (e) {}
|
||||
|
||||
return {
|
||||
user,
|
||||
auth_date: authDate,
|
||||
start_param: params["start_param"] || null,
|
||||
chat_instance: params["chat_instance"] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseInitData(initData) {
|
||||
const result = {};
|
||||
initData.split("&").forEach(pair => {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx < 0) return;
|
||||
const key = decodeURIComponent(pair.slice(0, idx));
|
||||
const val = decodeURIComponent(pair.slice(idx + 1));
|
||||
result[key] = val;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
return bytes.map(b => {
|
||||
const v = (b < 0 ? b + 256 : b).toString(16);
|
||||
return v.length === 1 ? "0" + v : v;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 3. Handlers
|
||||
// =================================================================
|
||||
|
||||
function handleMe(body) {
|
||||
// TODO:
|
||||
// 1. Проверить hash в body.initData по BOT_TOKEN.
|
||||
// 2. Извлечь tg_id из initData.
|
||||
// 3. Найти пользователя в Sheet "Users" / "Managers" / "Clients".
|
||||
// 4. Вернуть профиль + статус.
|
||||
return { error: "not_implemented" };
|
||||
const auth = verifyInitData(body.initData);
|
||||
if (!auth || !auth.user || !auth.user.id) {
|
||||
return { error: "invalid_init_data" };
|
||||
}
|
||||
const tgId = auth.user.id;
|
||||
|
||||
// Регистрируем пользователя если первый раз, обновляем last_seen_at
|
||||
const startParam = body.startParam || auth.start_param;
|
||||
const user = getOrCreateUser(auth.user, startParam);
|
||||
|
||||
if (user.role === "manager") {
|
||||
const m = getManagerProfile(tgId) || synthesizeManagerFromUser(user);
|
||||
return {
|
||||
role: "manager",
|
||||
user: {
|
||||
tg_id: tgId,
|
||||
full_name: m.full_name || user.full_name,
|
||||
salon: m.salon || "",
|
||||
avatar_initial: getInitial(m.full_name || user.first_name),
|
||||
},
|
||||
status: m.status || "lapsed",
|
||||
status_until: m.active_until ? formatDate(m.active_until) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// client
|
||||
const c = getClientProfile(tgId);
|
||||
let manager = null;
|
||||
if (c && c.manager_tg_id) {
|
||||
const mp = getManagerProfile(c.manager_tg_id);
|
||||
manager = mp ? { full_name: mp.full_name, salon: mp.salon } : null;
|
||||
}
|
||||
return {
|
||||
role: "client",
|
||||
user: {
|
||||
tg_id: tgId,
|
||||
full_name: (c && c.full_name) || user.full_name,
|
||||
avatar_initial: getInitial((c && c.full_name) || user.first_name),
|
||||
},
|
||||
manager,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMeasurement(body) {
|
||||
// TODO: сохранить замер в Sheet "Measurements", уведомить менеджера.
|
||||
return { error: "not_implemented" };
|
||||
const auth = verifyInitData(body.initData);
|
||||
if (!auth || !auth.user || !auth.user.id) return { error: "invalid_init_data" };
|
||||
const tgId = auth.user.id;
|
||||
const user = findUser(tgId);
|
||||
if (!user) return { error: "user_not_found" };
|
||||
|
||||
const m = body.measurement || {};
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const filledBy = user.role === "manager" ? "manager_for_client" : "client_self";
|
||||
const clientTgId = user.role === "client" ? tgId : (m.client_tg_id || "");
|
||||
const c = user.role === "client" ? getClientProfile(tgId) : null;
|
||||
const managerTgId = user.role === "manager" ? tgId : (c && c.manager_tg_id) || "";
|
||||
|
||||
appendRow("Measurements", [
|
||||
id,
|
||||
now,
|
||||
clientTgId,
|
||||
managerTgId,
|
||||
filledBy,
|
||||
m.layout || "",
|
||||
m.area_m2 || "",
|
||||
m.ceiling_mm || "",
|
||||
JSON.stringify(m.walls || {}),
|
||||
JSON.stringify(m.openings || {}),
|
||||
JSON.stringify(m.infra || {}),
|
||||
JSON.stringify(m.niches || {}),
|
||||
(m.photos || []).join(","),
|
||||
m.notes || "",
|
||||
"submitted",
|
||||
]);
|
||||
|
||||
// Обновляем last_measurement_id у клиента
|
||||
if (clientTgId) {
|
||||
updateColumnByKey("Clients", "tg_id", clientTgId, "last_measurement_id", id);
|
||||
}
|
||||
|
||||
// Уведомляем менеджера, если замер сделал клиент
|
||||
if (filledBy === "client_self" && managerTgId) {
|
||||
sendTelegram(
|
||||
managerTgId,
|
||||
`📐 Новый замер от клиента <b>${user.full_name || tgId}</b>.\n` +
|
||||
`Площадь: ${m.area_m2 || "?"} м², форма: ${m.layout || "?"}.\n` +
|
||||
`Открыть в кабинете для просмотра.`
|
||||
);
|
||||
}
|
||||
|
||||
log("measurement_submitted", tgId, { id, filledBy });
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
function handlePodbor(body) {
|
||||
// TODO:
|
||||
// 1. Сохранить заявку в Sheet "Leads".
|
||||
// 2. Собрать prompt из body.checklist + measurement.
|
||||
// 3. Вызвать Claude API.
|
||||
// 4. Записать ответ.
|
||||
// 5. Отправить менеджеру через Telegram Bot API.
|
||||
return { error: "not_implemented" };
|
||||
const auth = verifyInitData(body.initData);
|
||||
if (!auth || !auth.user || !auth.user.id) return { error: "invalid_init_data" };
|
||||
const tgId = auth.user.id;
|
||||
const user = findUser(tgId);
|
||||
if (!user) return { error: "user_not_found" };
|
||||
if (user.role !== "manager") return { error: "only_manager_can_request_podbor" };
|
||||
|
||||
const checklist = body.checklist || {};
|
||||
const measurementId = body.measurement_id || "";
|
||||
const clientName = body.client_name || "";
|
||||
const clientTgId = body.client_tg_id || "";
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
|
||||
// Pre-create lead row
|
||||
appendRow("Leads", [
|
||||
id, now, tgId, clientTgId, clientName, measurementId,
|
||||
JSON.stringify(checklist), "", "", 0, false, "new", 0
|
||||
]);
|
||||
|
||||
// Build prompt and call Claude
|
||||
const measurement = measurementId ? getMeasurement(measurementId) : null;
|
||||
const prompt = buildPickerPrompt(checklist, measurement, clientName);
|
||||
const ai = callClaude(prompt);
|
||||
|
||||
// Update lead row with AI response
|
||||
updateLeadAI(id, ai);
|
||||
|
||||
// Send result to manager via bot
|
||||
const summary = formatPodborForTelegram(ai, clientName);
|
||||
sendTelegram(tgId, summary);
|
||||
|
||||
log("podbor_completed", tgId, { id, tokens: ai.tokens, has_json: !!ai.json });
|
||||
return { ok: true, id, summary };
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 4. Sheet helpers
|
||||
// =================================================================
|
||||
|
||||
function ss() { return SpreadsheetApp.getActiveSpreadsheet(); }
|
||||
function sheet(name) {
|
||||
const s = ss().getSheetByName(name);
|
||||
if (!s) throw new Error("Sheet not found: " + name);
|
||||
return s;
|
||||
}
|
||||
function appendRow(sheetName, row) { sheet(sheetName).appendRow(row); }
|
||||
|
||||
function findUser(tgId) {
|
||||
if (!tgId) return null;
|
||||
const data = sheet("Users").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][0]) === String(tgId)) {
|
||||
return {
|
||||
tg_id: data[i][0],
|
||||
tg_username: data[i][1],
|
||||
first_name: data[i][2],
|
||||
last_name: data[i][3],
|
||||
role: data[i][4],
|
||||
full_name: ((data[i][2] || "") + " " + (data[i][3] || "")).trim() || data[i][1] || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrCreateUser(tgUser, startParam) {
|
||||
const tgId = tgUser.id;
|
||||
const existing = findUser(tgId);
|
||||
if (existing) {
|
||||
updateColumnByKey("Users", "tg_id", tgId, "last_seen_at", new Date());
|
||||
return existing;
|
||||
}
|
||||
// Если пришли по invite-коду менеджера — клиент с привязкой
|
||||
let role = "client";
|
||||
let inviteCode = "";
|
||||
if (startParam && startParam.indexOf("client_inv_") === 0) {
|
||||
role = "client";
|
||||
inviteCode = startParam;
|
||||
}
|
||||
const now = new Date();
|
||||
appendRow("Users", [
|
||||
tgId, tgUser.username || "", tgUser.first_name || "", tgUser.last_name || "",
|
||||
role, now, now, inviteCode,
|
||||
]);
|
||||
log("user_registered", tgId, { role, startParam });
|
||||
return findUser(tgId);
|
||||
}
|
||||
|
||||
function getManagerProfile(tgId) {
|
||||
const data = sheet("Managers").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][0]) === String(tgId)) {
|
||||
const isZov = !!data[i][6];
|
||||
const lastOrder = data[i][8];
|
||||
const activePeriod = parseInt(getSetting("ACTIVE_PERIOD_DAYS") || "90", 10);
|
||||
const gracePeriod = parseInt(getSetting("GRACE_PERIOD_DAYS") || "14", 10);
|
||||
let activeUntil = null;
|
||||
let status = "lapsed";
|
||||
if (isZov) {
|
||||
status = "active";
|
||||
} else if (lastOrder instanceof Date) {
|
||||
activeUntil = new Date(lastOrder.getTime() + activePeriod * 86400000);
|
||||
const grace = new Date(activeUntil.getTime() + gracePeriod * 86400000);
|
||||
const now = new Date();
|
||||
if (now <= activeUntil) status = "active";
|
||||
else if (now <= grace) status = "grace";
|
||||
else status = "lapsed";
|
||||
}
|
||||
return {
|
||||
tg_id: data[i][0],
|
||||
full_name: data[i][1],
|
||||
email: data[i][2],
|
||||
phone: data[i][3],
|
||||
salon: data[i][4],
|
||||
city: data[i][5],
|
||||
is_zov_employee: isZov,
|
||||
last_order_date: lastOrder,
|
||||
active_until: activeUntil,
|
||||
status,
|
||||
invite_code: data[i][13],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getClientProfile(tgId) {
|
||||
const data = sheet("Clients").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][0]) === String(tgId)) {
|
||||
return {
|
||||
tg_id: data[i][0],
|
||||
full_name: data[i][1],
|
||||
phone: data[i][2],
|
||||
email: data[i][3],
|
||||
address: data[i][4],
|
||||
city: data[i][5],
|
||||
budget_total: data[i][6],
|
||||
manager_tg_id: data[i][7],
|
||||
source: data[i][8],
|
||||
last_measurement_id: data[i][9],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMeasurement(id) {
|
||||
const data = sheet("Measurements").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (data[i][0] === id) {
|
||||
return {
|
||||
id, layout: data[i][5], area_m2: data[i][6], ceiling_mm: data[i][7],
|
||||
walls: safeParse(data[i][8]),
|
||||
openings: safeParse(data[i][9]),
|
||||
infra: safeParse(data[i][10]),
|
||||
niches: safeParse(data[i][11]),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeParse(s) { try { return JSON.parse(s || "{}"); } catch (e) { return {}; } }
|
||||
|
||||
function updateColumnByKey(sheetName, keyCol, keyVal, targetCol, newVal) {
|
||||
const s = sheet(sheetName);
|
||||
const headers = s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0];
|
||||
const keyIdx = headers.indexOf(keyCol);
|
||||
const targetIdx = headers.indexOf(targetCol);
|
||||
if (keyIdx < 0 || targetIdx < 0) return false;
|
||||
const data = s.getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][keyIdx]) === String(keyVal)) {
|
||||
s.getRange(i + 1, targetIdx + 1).setValue(newVal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateLeadAI(leadId, ai) {
|
||||
const s = sheet("Leads");
|
||||
const data = s.getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (data[i][0] === leadId) {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001";
|
||||
s.getRange(i + 1, 8).setValue(JSON.stringify(ai.json || ai.text || ""));
|
||||
s.getRange(i + 1, 9).setValue(model);
|
||||
s.getRange(i + 1, 10).setValue(ai.tokens || 0);
|
||||
s.getRange(i + 1, 11).setValue(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSetting(key) {
|
||||
const data = sheet("Settings").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][0]) === String(key)) return String(data[i][1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Если менеджер ещё не заведён в Managers — синтезируем «болванку» с lapsed-статусом
|
||||
function synthesizeManagerFromUser(user) {
|
||||
return {
|
||||
full_name: user.full_name,
|
||||
salon: "",
|
||||
is_zov_employee: false,
|
||||
status: "lapsed",
|
||||
active_until: null,
|
||||
};
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 5. Claude AI
|
||||
// =================================================================
|
||||
|
||||
const SYSTEM_PROMPT_PICKER = (
|
||||
"Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" +
|
||||
"Помогаешь менеджерам салонов быстро согласовать с клиентом комплект техники.\n\n" +
|
||||
"Принципы:\n" +
|
||||
"1. Физические ограничения важнее эстетики. Если ниша 600×1850×600 — не предлагай 700×2000×650.\n" +
|
||||
"2. Уважай бюджет. Лимит в категории — не превышай >10%.\n" +
|
||||
"3. Уважай предпочтения по брендам: сначала preferred (★), потом alternative (✓).\n" +
|
||||
"4. Сценарий использования. Семья с детьми = простой UI, защита от детей. Выпечка = пар + конвекция.\n" +
|
||||
"5. Инфраструктура. Газ исключает индукцию. Нет шахты = только рециркуляция.\n" +
|
||||
"6. По каждой позиции: модель (линейка), цена, 2-3 преимущества под клиента, 1 предупреждение.\n\n" +
|
||||
"Формат ответа — валидный JSON без markdown:\n" +
|
||||
"{\n" +
|
||||
' "summary": "...", \n' +
|
||||
' "items": [{"category":"fridge","brand":"Bosch","model":"Serie 4 60см","price_rub":79990,' +
|
||||
'"size_mm":{"w":600,"h":2030,"d":660},"fits_niche":true,"highlights":["NoFrost","инвертор"],' +
|
||||
'"caveats":"Глубина 660мм","match_score":0.92}],\n' +
|
||||
' "total_price_rub": 350000,\n' +
|
||||
' "budget_status": "в_рамках|превышение|значительно_ниже",\n' +
|
||||
' "warnings": [],\n' +
|
||||
' "next_steps": []\n' +
|
||||
"}\n\n" +
|
||||
"Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)."
|
||||
);
|
||||
|
||||
function buildPickerPrompt(checklist, measurement, clientName) {
|
||||
const payload = {
|
||||
client: { name: clientName || "" },
|
||||
checklist: checklist,
|
||||
measurement: measurement || null,
|
||||
};
|
||||
return "Подбери технику для следующего клиента:\n\n" + JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function callClaude(userPrompt) {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const apiKey = props.getProperty("ANTHROPIC_API_KEY");
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not in Script Properties");
|
||||
const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001";
|
||||
const temperature = parseFloat(getSetting("AI_TEMPERATURE") || "0.3");
|
||||
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 4000,
|
||||
temperature,
|
||||
system: SYSTEM_PROMPT_PICKER,
|
||||
messages: [{ role: "user", content: userPrompt }],
|
||||
};
|
||||
|
||||
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "post",
|
||||
contentType: "application/json",
|
||||
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true,
|
||||
});
|
||||
|
||||
const status = res.getResponseCode();
|
||||
const text = res.getContentText();
|
||||
if (status >= 400) {
|
||||
log("claude_error", null, { status, text: text.slice(0, 500) });
|
||||
return { json: null, text: "AI ошибка: HTTP " + status, tokens: 0, error: true };
|
||||
}
|
||||
const data = JSON.parse(text);
|
||||
const responseText = (data.content || []).map(c => c.text || "").join("");
|
||||
const tokens = (data.usage && (data.usage.input_tokens + data.usage.output_tokens)) || 0;
|
||||
let json = null;
|
||||
try { json = JSON.parse(responseText); } catch (e) {}
|
||||
return { json, text: responseText, tokens };
|
||||
}
|
||||
|
||||
function formatPodborForTelegram(ai, clientName) {
|
||||
if (ai.error) return "❌ Не удалось получить подбор от AI. Попробуйте позже.";
|
||||
if (!ai.json) return "<b>Подбор готов</b>\n\n" + (ai.text || "").slice(0, 3500);
|
||||
|
||||
const j = ai.json;
|
||||
const lines = [
|
||||
"✅ <b>Подбор готов</b>",
|
||||
clientName ? "Клиент: <b>" + clientName + "</b>" : "",
|
||||
"",
|
||||
j.summary || "",
|
||||
"",
|
||||
];
|
||||
(j.items || []).forEach(item => {
|
||||
const sizeStr = item.size_mm ? " (" + item.size_mm.w + "×" + item.size_mm.h + "×" + item.size_mm.d + "мм)" : "";
|
||||
lines.push("<b>" + (item.brand || "") + " " + (item.model || "") + "</b>" + sizeStr);
|
||||
if (item.price_rub) lines.push("💰 " + formatPrice(item.price_rub) + " ₽");
|
||||
if (item.highlights && item.highlights.length) lines.push("✓ " + item.highlights.join(", "));
|
||||
if (item.caveats) lines.push("⚠️ " + item.caveats);
|
||||
lines.push("");
|
||||
});
|
||||
if (j.total_price_rub) lines.push("<b>ИТОГО: " + formatPrice(j.total_price_rub) + " ₽</b> · " + (j.budget_status || ""));
|
||||
if (j.warnings && j.warnings.length) lines.push("\n⚠️ " + j.warnings.join("; "));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatPrice(n) {
|
||||
if (n === null || n === undefined || n === "") return "—";
|
||||
return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 6. Telegram Bot API
|
||||
// =================================================================
|
||||
|
||||
function sendTelegram(chatId, text, options) {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const botToken = props.getProperty("BOT_TOKEN");
|
||||
if (!botToken || !chatId) return;
|
||||
const url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
|
||||
const payload = {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
if (options) Object.keys(options).forEach(k => payload[k] = options[k]);
|
||||
try {
|
||||
UrlFetchApp.fetch(url, {
|
||||
method: "post",
|
||||
contentType: "application/json",
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true,
|
||||
});
|
||||
} catch (e) {
|
||||
log("telegram_send_error", chatId, { error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 7. Util
|
||||
// =================================================================
|
||||
|
||||
function generateId() { return Utilities.getUuid().slice(0, 13); }
|
||||
|
||||
function formatDate(d) {
|
||||
if (!(d instanceof Date)) return "";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return dd + "." + mm + "." + d.getFullYear();
|
||||
}
|
||||
|
||||
function getInitial(name) {
|
||||
return ((name || "").trim().slice(0, 1) || "?").toUpperCase();
|
||||
}
|
||||
|
||||
function log(event, tgId, payload) {
|
||||
try {
|
||||
appendRow("Logs", [new Date(), event, tgId || "", payload ? JSON.stringify(payload) : ""]);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 8. Утилиты для одноразового запуска (через Apps Script Run)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Заводит Руслана (admin) как ZOV-employee менеджера.
|
||||
* Запустить ОДИН РАЗ после деплоя через Run в редакторе.
|
||||
*/
|
||||
function seedAdminAsManager() {
|
||||
const adminId = 5937498515;
|
||||
const data = sheet("Managers").getDataRange().getValues();
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (String(data[i][0]) === String(adminId)) {
|
||||
Logger.log("Уже заведён, ничего не делаем.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
appendRow("Managers", [
|
||||
adminId, // tg_id
|
||||
"Руслан Васильев", // full_name
|
||||
"vasrusgen@gmail.com", // email
|
||||
"", // phone
|
||||
"ЗОВ — куратор сети", // salon
|
||||
"Санкт-Петербург", // city
|
||||
true, // is_zov_employee → всегда active
|
||||
"active", // status (стат-поле; реальный считается на лету)
|
||||
"", // last_order_date
|
||||
"", // active_until
|
||||
0, 0, 0, // total_leads, total_deals, conversion
|
||||
"MGR_ADMIN", // invite_code
|
||||
]);
|
||||
// Также заводим в Users с ролью manager, если ещё нет
|
||||
const u = findUser(adminId);
|
||||
if (!u) {
|
||||
appendRow("Users", [adminId, "VASRUSGEN", "Руслан", "Васильев", "manager", new Date(), new Date(), ""]);
|
||||
} else if (u.role !== "manager") {
|
||||
updateColumnByKey("Users", "tg_id", adminId, "role", "manager");
|
||||
}
|
||||
Logger.log("✅ Руслан Васильев заведён как ZOV-employee менеджер.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Удобный тестовый прогон Claude API: проверяет что ключ работает.
|
||||
*/
|
||||
function testClaude() {
|
||||
const ai = callClaude("Скажи одной фразой: что за фабрика ЗОВ?");
|
||||
Logger.log(JSON.stringify(ai, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест отправки сообщения через бота — пришлёт админу «привет».
|
||||
*/
|
||||
function testTelegram() {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
const adminId = props.getProperty("ADMIN_TG_ID") || 5937498515;
|
||||
sendTelegram(adminId, "🟢 Привет из Apps Script бэкенда! Если видишь — связка бот↔backend работает.");
|
||||
}
|
||||
|
||||
@ -1,37 +1,72 @@
|
||||
# Backend (Google Apps Script)
|
||||
|
||||
Этот код — зеркало того, что лежит в Apps Script-проекте, привязанном к Google Sheet «ЗОВ — База».
|
||||
Это код, который живёт в Apps Script-проекте, привязанном к Google Sheet «ЗОВ — База».
|
||||
|
||||
## Как синхронизировать
|
||||
## Файлы
|
||||
|
||||
Вариант 1 (вручную): копировать содержимое `Code.gs` в редактор Apps Script.
|
||||
| Файл | Когда запускать |
|
||||
|---|---|
|
||||
| `setup_database.gs` | **один раз**, чтобы создать 8 листов (уже выполнено) |
|
||||
| `Code.gs` | основной backend, обслуживает MiniApp и бот через `doPost` |
|
||||
|
||||
Вариант 2 (через clasp): использовать [clasp](https://github.com/google/clasp).
|
||||
## Шаги после первой установки `Code.gs`
|
||||
|
||||
```bash
|
||||
npm install -g @google/clasp
|
||||
clasp login
|
||||
clasp clone <SCRIPT_ID>
|
||||
# или для уже существующего:
|
||||
clasp pull / clasp push
|
||||
```
|
||||
### 1. Добавить Script Properties (секреты)
|
||||
|
||||
## Деплой
|
||||
|
||||
1. Открыть Apps Script проект, привязанный к Sheet.
|
||||
2. Deploy → New deployment.
|
||||
3. Type: **Web app**.
|
||||
4. Execute as: **Me**.
|
||||
5. Who has access: **Anyone** (только так MiniApp сможет POST'ить).
|
||||
6. Скопировать выданный URL — это `BACKEND_URL` в `miniapp/assets/app.js`.
|
||||
|
||||
## Script Properties (секреты)
|
||||
|
||||
В Apps Script: ⚙️ Project Settings → Script Properties → Add property.
|
||||
В Apps Script: **⚙️ Project Settings** → секция **Script Properties** → **Add property**.
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `BOT_TOKEN` | токен @BotFather |
|
||||
| `ANTHROPIC_API_KEY` | ключ Anthropic Console |
|
||||
| `ADMIN_TG_ID` | tg_id куратора |
|
||||
| `SHEET_ID` | ID Google Sheet (из URL) |
|
||||
| `BOT_TOKEN` | токен Telegram-бота (из @BotFather) |
|
||||
| `ANTHROPIC_API_KEY` | `sk-ant-api03-…` из console.anthropic.com |
|
||||
| `ADMIN_TG_ID` | `5937498515` (ваш tg_id) |
|
||||
| `ANTHROPIC_MODEL` | (опционально) `claude-haiku-4-5-20251001` |
|
||||
|
||||
### 2. Запустить разовые setup-функции
|
||||
|
||||
В верхней панели селект функций → выбрать → **▶ Run**:
|
||||
|
||||
- **`seedAdminAsManager`** — заведёт Руслана Васильева как admin-менеджера в Managers/Users (статус всегда active как ZOV-employee).
|
||||
- **`testClaude`** — проверит, что Anthropic API ключ работает (увидите ответ AI в Execution log).
|
||||
- **`testTelegram`** — проверит связку: бот должен прислать вам "🟢 Привет из Apps Script бэкенда".
|
||||
|
||||
### 3. Деплой как Web App
|
||||
|
||||
В Apps Script: **Deploy** → **New deployment** → шестерёнка **Type → Web app**.
|
||||
|
||||
Параметры:
|
||||
- **Description:** `zov-tech-backend v1`
|
||||
- **Execute as:** `Me (vasrusgen@gmail.com)`
|
||||
- **Who has access:** `Anyone` (чтобы MiniApp мог POST'ить, ОБЯЗАТЕЛЬНО)
|
||||
|
||||
Жмём **Deploy**. Получим URL вида:
|
||||
```
|
||||
https://script.google.com/macros/s/AKfycbz.../exec
|
||||
```
|
||||
Этот URL — `BACKEND_URL` для MiniApp.
|
||||
|
||||
### 4. Прислать URL разработчику
|
||||
|
||||
После деплоя URL подставляется в `miniapp/assets/app.js`, MiniApp начинает реально читать профиль из Sheet вместо мок-данных.
|
||||
|
||||
## API endpoints
|
||||
|
||||
Все запросы — `POST {BACKEND_URL}?path=<endpoint>` с JSON-телом `{ initData, ... }`.
|
||||
|
||||
| Endpoint | Body | Ответ |
|
||||
|---|---|---|
|
||||
| `?path=ping` | `{}` | `{ pong: true, time }` (для health-check) |
|
||||
| `?path=me` | `{ initData, startParam? }` | профиль пользователя (роль, имя, статус, менеджер) |
|
||||
| `?path=measurement` | `{ initData, measurement: { layout, area_m2, ... } }` | `{ ok: true, id }` |
|
||||
| `?path=podbor` | `{ initData, checklist, measurement_id?, client_name? }` | `{ ok: true, id, summary }` + AI-ответ в Telegram |
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Все запросы (кроме `ping`) обязательно содержат `initData` от Telegram WebApp.
|
||||
- Backend проверяет HMAC-SHA-256 подпись `initData` по `BOT_TOKEN`.
|
||||
- При невалидной подписи → `{ error: "invalid_init_data" }`.
|
||||
- 24-часовая свежесть подписи (`auth_date`).
|
||||
|
||||
## Обновление
|
||||
|
||||
Изменили `Code.gs` локально → скопировать в Apps Script → **Save** → **Deploy → Manage deployments → Edit → Version: New version → Deploy**. URL не меняется.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user