4 Commits

Author SHA1 Message Date
NW
c8d8aad8c3 feat(bot): SHOP_DB_PATH env — путь к БД настраиваемый (единая DB с админкой)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m54s
По умолчанию ../db/shop.db; если задана SHOP_DB_PATH (например общий файл
с админкой shop) — используется она. Позволяет боту и Next.js-админке
работать с одной базой (иначе товары из custom.db админки не видны боту).
2026-08-24 22:50:13 +01:00
NW
1c17501476 fix(bot): единый источник активности — env SHOP_ACTIVATED приоритет над БД
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 20m10s
Рассинхрон: панель Bbox пишет SHOP_ACTIVATED в env, но бот читал активность
из БД site_settings (первично) → даже при true в панели бот отвечал
'Shop is not activated'. Теперь env — единый источник; БД — фоллбэк
(для админки магазина, если env не задан). Кэш 30с сохранён.
Версия v1.0.2.
2026-08-24 21:08:08 +01:00
NW
d2324db105 fix(bot): проверка дубликата BOT_TOKEN при старте (409 Conflict → exit 1)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 5m53s
Перед запуском polling делаем пробный getUpdates(timeout:0). Если Telegram
возвращает 409 'Conflict: terminated by other getUpdates request' (токен уже
используется другой нодой/процессом) — печатаем '🚫 ОБНАРУЖЕН ДУБЛИКАТ ТОКЕНА
ДЛЯ ЗАПУСКА' и завершаемся с кодом 1: контейнер не стартует, рестарт-цикл
останавливается (вместо молчаливого конфликта long poll между нодами).
Версия v1.0.1.
2026-08-23 01:57:16 +01:00
NW
1d083e564c feat(install): автоустановка --auto/--clean + arch-скрипты
- install.sh: режим --auto (неинтерактивный: placeholder BOT_TOKEN, ADMIN_IDS=0,
  секреты генерируются автоматически — для CI/SSH/установки на машины без TTY),
  --clean (очистка старых контейнеров/образов), авто-детект не-TTY stdin
- package.json: arch:build/arch:index/arch:index:full/incremental/arch:status
2026-08-23 00:30:48 +01:00
5 changed files with 155 additions and 34 deletions

View File

@@ -5,8 +5,28 @@ set -euo pipefail
# Telegram Shop — установщик
# Скрипт для развёртывания на x86_64 и ARM64 (Orange Pi, RPi)
# Совместим с POSIX sh (Alpine ash)
#
# Режимы:
# install.sh — интерактивный (запрашивает BOT_TOKEN/ADMIN_IDS)
# install.sh --auto — неинтерактивный: placeholder BOT_TOKEN, ADMIN_IDS=0,
# секреты генерируются автоматически (для CI/SSH)
# install.sh --clean — очистка старых контейнеров/образов перед установкой
# ============================================================
AUTO=0
CLEAN=0
for ARG in "$@"; do
case "$ARG" in
--auto) AUTO=1 ;;
--clean) CLEAN=1 ;;
esac
done
# Неинтерактивный режим также включается, если stdin не терминал
if [ ! -t 0 ]; then
AUTO=1
fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -51,6 +71,27 @@ esac
print_ok "Архитектура: $ARCH_NAME"
print_info "Docker автоматически соберёт образ под текущую архитектуру"
# ============================================================
# 0.5. Очистка (--clean): удалить старые контейнеры/образы/тома
# ============================================================
if [ "$CLEAN" -eq 1 ]; then
print_step "Очистка старых контейнеров и образов"
if [ -f docker-compose.yml ]; then
docker compose down --rmi all --volumes 2>/dev/null || true
fi
# Удалить осиротевшие контейнеры/образы/тома проекта telegram-shop
docker ps -aq --filter "name=telegram_shop_prod" | xargs -r docker rm -f 2>/dev/null || true
docker ps -aq --filter "name=tg_shop_admin" | xargs -r docker rm -f 2>/dev/null || true
docker ps -aq --filter "name=tor-proxy" | xargs -r docker rm -f 2>/dev/null || true
docker images -q "git.softuniq.eu/telegram-market/telegram-shop*" | xargs -r docker rmi -f 2>/dev/null || true
docker images -q "telegram-shop-*" | xargs -r docker rmi -f 2>/dev/null || true
docker volume rm tor_proxy_data 2>/dev/null || true
docker network rm tor_proxy_net telegram-shop_default 2>/dev/null || true
# Удалить локальную БД и загрузки (чистая переустановка)
rm -rf db uploads
print_ok "Очистка завершена (tor-ssh-client не тронут)"
fi
# ============================================================
# 1. Проверка / установка Docker
# ============================================================
@@ -200,10 +241,16 @@ generate_random_hex() {
# BOT_TOKEN
current_bot_token=$(grep -E "^BOT_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_bot_token" ] || [ "$current_bot_token" = "your_bot_token_here" ]; then
print_warn "BOT_TOKEN не задан или содержит placeholder."
read_secret "Введите BOT_TOKEN (от @BotFather): "
update_env_var "BOT_TOKEN" "$input"
bot_token="$input"
if [ "$AUTO" -eq 1 ]; then
bot_token="REPLACE_ME_LATER"
update_env_var "BOT_TOKEN" "$bot_token"
print_warn "BOT_TOKEN не задан — установлен placeholder (внесите реальный токен в админке)"
else
print_warn "BOT_TOKEN не задан или содержит placeholder."
read_secret "Введите BOT_TOKEN (от @BotFather): "
update_env_var "BOT_TOKEN" "$input"
bot_token="$input"
fi
else
bot_token="$current_bot_token"
fi
@@ -211,11 +258,17 @@ fi
# ADMIN_IDS
current_admin_ids=$(grep -E "^ADMIN_IDS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_ids" ] || [ "$current_admin_ids" = "123456789,987654321" ]; then
print_warn "ADMIN_IDS не задан или содержит placeholder."
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
read -r input
update_env_var "ADMIN_IDS" "$input"
admin_ids="$input"
if [ "$AUTO" -eq 1 ]; then
admin_ids="0"
update_env_var "ADMIN_IDS" "$admin_ids"
print_warn "ADMIN_IDS не задан — установлен 0 (настройте в админке)"
else
print_warn "ADMIN_IDS не задан или содержит placeholder."
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
read -r input
update_env_var "ADMIN_IDS" "$input"
admin_ids="$input"
fi
else
admin_ids="$current_admin_ids"
fi
@@ -232,7 +285,7 @@ fi
# ADMIN_SECRET
current_admin_secret=$(grep -E "^ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ]; then
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ] || [ "$current_admin_secret" = "your_admin_token_here" ]; then
admin_secret=$(generate_random_hex)
update_env_var "ADMIN_SECRET" "$admin_secret"
print_ok "ADMIN_SECRET сгенерирован автоматически"
@@ -242,7 +295,7 @@ fi
# SUPER_ADMIN_SECRET
current_super_admin_secret=$(grep -E "^SUPER_ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ]; then
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ] || [ "$current_super_admin_secret" = "your_super_admin_token_here" ]; then
# Генерация до тех пор, пока не получим значение, отличное от ADMIN_SECRET
super_admin_secret=""
while [ -z "$super_admin_secret" ] || [ "$super_admin_secret" = "$admin_secret" ]; do
@@ -266,7 +319,7 @@ fi
# ADMIN_URL
current_admin_url=$(grep -E "^ADMIN_URL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_url" ]; then
if [ -z "$current_admin_url" ] || [ "$current_admin_url" = "http://your-host:3000" ] || [ "$current_admin_url" = "your-host" ]; then
# Попытка определить LAN IP
lan_ip=$(hostname -I | awk '{print $1}')
if [ -z "$lan_ip" ]; then
@@ -307,6 +360,13 @@ print_info "Образы собираются в CI (Gitea Actions, buildx multi
IMAGE_TAG="${IMAGE_TAG:-latest}"
print_info "Используемая версия образа: $IMAGE_TAG"
# Логин в приватный Gitea Container Registry (если задан токен)
if [ -n "${GITEA_TOKEN:-}" ]; then
echo "${GITEA_TOKEN}" | docker login git.softuniq.eu -u oauth2 --password-stdin >/dev/null 2>&1 \
&& print_ok "Авторизация в Gitea Container Registry" \
|| print_warn "Не удалось авторизоваться в registry (GITEA_TOKEN)"
fi
$COMPOSE_CMD pull telegram_shop_prod tg_shop_admin
print_ok "Образы telegram-shop:${IMAGE_TAG} и telegram-shop-admin:${IMAGE_TAG} загружены"

View File

@@ -1,13 +1,18 @@
{
"name": "telegram-shop-bot",
"version": "1.0.0",
"version": "1.0.2",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check"
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check",
"arch:build": "docker compose -f docker/docker-compose.architect.yml build",
"arch:index": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer",
"arch:index:full": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode full",
"arch:index:incremental": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode incremental",
"arch:status": "docker compose -f docker/docker-compose.architect.yml ps"
},
"dependencies": {
"archiver": "^7.0.1",

View File

@@ -5,7 +5,13 @@ import { dirname } from 'path';
import { pathToFileURL } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname;
// Путь к БД: env SHOP_DB_PATH (для единой БД с админкой) → по умолчанию ../db/shop.db.
// Рассинхрон: админка (Next.js/Prisma) писала в custom.db, бот — в shop.db → товары,
// добавленные в админке, не были видны боту. Оба должны использовать ОДИН файл.
const DB_PATH =
process.env.SHOP_DB_PATH && process.env.SHOP_DB_PATH !== 'false'
? process.env.SHOP_DB_PATH
: new URL('../../db/shop.db', import.meta.url).pathname;
let betterDb;

View File

@@ -15,7 +15,53 @@ const initBot = async () => {
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const instance = new TelegramBot(config.BOT_TOKEN, {polling: true});
const instance = new TelegramBot(config.BOT_TOKEN, {polling: false});
// ── Проверка на ДУБЛИКАТ токена ─────────────────────────────
// Перед запуском polling делаем разовый getUpdates. Если токен уже
// используется другой нодой/процессом — Telegram возвращает 409
// "Conflict: terminated by other getUpdates request" (или webhook).
// Такой бот НЕ должен стартовать: он будет конфликтовать, терять
// обновления и «прыгать» между серверами (issue: дубликат BotFather
// токена на нескольких нодах).
const dupCheck = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => resolve(null), 10000);
instance
.getUpdates({ timeout: 0, allowed_updates: [] })
.then((updates) => {
clearTimeout(timeout);
// допустимые значения — любой успешный ответ ([] или список)
resolve({ ok: true, updates });
})
.catch((error) => {
clearTimeout(timeout);
const status = error?.response?.statusCode ?? error?.response?.status;
const description = error?.response?.body?.description ?? error?.message ?? String(error);
const is409 = status === 409 || /conflict/i.test(description);
resolve({ ok: false, status, description, is409 });
});
});
if (!dupCheck?.ok) {
if (dupCheck?.is409) {
console.error(
'\n====================================================\n' +
'🚫 ОБНАРУЖЕН ДУБЛИКАТ ТОКЕНА ДЛЯ ЗАПУСКА\n' +
' Telegram вернул 409 Conflict — этот BOT_TOKEN уже\n' +
' используется другим экземпляром/нодой.\n' +
` (${dupCheck.description})\n` +
' Запуск бота остановлен. Создайте новый бот в @BotFather\n' +
' или замените BOT_TOKEN в настройках сервиса.\n' +
'====================================================\n'
);
process.exit(1);
}
if (dupCheck?.status === 404 || (dupCheck?.description || '').includes('Not Found')) {
// неверный токен — пробуем дальше; ниже полноценная валидация
logger.warn('validate: getUpdates 404 (invalid token?)');
}
}
// Реальная инициализация: включаем polling и проверяем getMe
await instance.startPolling();
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
instance.stopPolling();

View File

@@ -55,7 +55,10 @@ export async function getCommissionWallets() {
/**
* Check if the shop is activated.
* Reads site_settings.shop_activated; falls back to env SHOP_ACTIVATED !== 'false' (default true).
* Приоритет: env SHOP_ACTIVATED (единый источник — панель Bbox) → БД site_settings.
* Раньше БД была первичной, и смена «Магазин активирован» в панели (env) не
* влияла на бота (в БД оставалось старое значение) — рассинхрон «пишет true,
* а бот говорит not activated». Теперь env главный; БД — зеркало-фоллбэк.
* @returns {Promise<boolean>}
*/
export async function getShopActivated() {
@@ -64,25 +67,26 @@ export async function getShopActivated() {
return shopActivatedCache;
}
try {
const row = await db.getAsync(
"SELECT value FROM site_settings WHERE key = 'shop_activated'"
);
let activated;
if (row) {
activated = row.value !== 'false';
} else {
activated = process.env.SHOP_ACTIVATED !== 'false';
let activated;
// 1. env (панель Bbox / compose) — единый источник правды
if (process.env.SHOP_ACTIVATED !== undefined) {
activated = process.env.SHOP_ACTIVATED !== 'false';
} else {
// 2. БД site_settings (админка магазина может вести туда же)
try {
const row = await db.getAsync(
"SELECT value FROM site_settings WHERE key = 'shop_activated'"
);
activated = row ? row.value !== 'false' : true;
} catch (err) {
logger.error({ err }, 'Failed to read shop_activated from site_settings');
activated = true;
}
shopActivatedCache = activated;
shopActivatedCachedAt = now;
return activated;
} catch (err) {
logger.error({ err }, 'Failed to read shop_activated from site_settings');
return process.env.SHOP_ACTIVATED !== 'false';
}
shopActivatedCache = activated;
shopActivatedCachedAt = now;
return activated;
}
/**