mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 02:20:58 +00:00
Headless-мост: веб-панель вырезана, опциональные стикеры (slim/full), подпись обновлений
Вырезана веб-панель целиком (express/ws/react/vite/tailwind, TLS-серт, /api/*, API_KEY, порт 3000) — мост теперь headless: MAX по исходящему сокету, Telegram long-polling, управление и авторизация только в боте (/login, /panel). Убрана поверхность атаки, bundle меньше. Анимированные стикеры (.tgs) стали опциональными: build-arg STICKERS=full|slim. full (деф.) ставит Chromium+ffmpeg (образ ~1.7 ГБ); slim пропускает их (~0.3 ГБ, быстрая сборка), анимированные уходят статической картинкой (thumbnail). setup.sh спрашивает при установке (30с таймаут, не тормозит). Подпись обновлений (Phase C): update.sh/release.sh проверяют GPG-подпись релизного тега при наличии pinned-ключа release-signing-key.asc (транспорт GitHub/зеркало недоверенный, важна только подпись мейнтейнера). Активируется после добавления публичного ключа.
This commit is contained in:
parent
812f33efc2
commit
a518a7d408
23 changed files with 188 additions and 3551 deletions
20
.env.example
20
.env.example
|
|
@ -2,18 +2,6 @@
|
|||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
MAX_SESSION_KEY=
|
||||
|
||||
# Shared secret required on every /api/* route (and the /ws upgrade) except /api/health.
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
||||
API_KEY=
|
||||
|
||||
PORT=3000
|
||||
|
||||
# Веб-панель по умолчанию работает по HTTPS с самоподписанным сертификатом
|
||||
# (генерируется при первом старте в .data/tls; браузер один раз предупредит о
|
||||
# неизвестном издателе — это ожидаемо). Раскомментируйте off, только если панель
|
||||
# стоит за вашим собственным reverse proxy с настоящим TLS.
|
||||
# PANEL_TLS=off
|
||||
|
||||
# Опционально: адрес сервера MAX. По умолчанию (пусто) подключаемся по имени
|
||||
# api2.oneme.ru — это единственный рабочий путь для IPv6-only хостов (резолв имени
|
||||
# даёт DNS64/NAT64; захардкоженный IPv4-литерал этот путь обходит и рвётся раз в ~несколько
|
||||
|
|
@ -29,7 +17,9 @@ TARGET_TELEGRAM_GROUP=
|
|||
# Опционально: прокси для Telegram, если сервер выходит в Telegram только через него
|
||||
# (напр. домашний сервер, где прямой доступ к api.telegram.org закрыт). MAX всегда
|
||||
# идёт напрямую, минуя прокси. Форматы: socks5://[логин:пароль@]хост:порт или
|
||||
# http://[логин:пароль@]хост:порт. Пусто — без прокси. То же можно задать в веб-панели
|
||||
# (вкладка Configuration → Telegram-прокси), там значение сохраняется в ./data и
|
||||
# перекрывает эту переменную.
|
||||
# http://[логин:пароль@]хост:порт. Пусто — без прокси.
|
||||
TELEGRAM_PROXY=
|
||||
|
||||
# Анимированные стикеры (.tgs) — это НЕ переменная .env, а build-arg образа: setup.sh спросит
|
||||
# при установке (full = проигрываются видео, +~1.4 ГБ; slim = уходят картинкой). Пересобрать
|
||||
# в нужном режиме вручную: STICKERS=slim docker compose build && docker compose up -d
|
||||
|
|
|
|||
20
Dockerfile
20
Dockerfile
|
|
@ -14,15 +14,15 @@ ENV NODE_ENV=production
|
|||
# commit is actually running vs what's latest on GitHub.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
# chromium + ffmpeg: rendering Telegram's animated (.tgs/Lottie) stickers to a
|
||||
# short WebM so they can go through the ordinary VIDEO_UPLOAD pipeline — MAX has
|
||||
# no confirmed native sticker-upload opcode, but its own animated stickers arrive
|
||||
# as autoplaying VIDEO attaches, so this gets the same "plays in the feed" result.
|
||||
# Uses puppeteer-core (no bundled Chromium download) against this system package
|
||||
# instead, keeping the image smaller.
|
||||
# openssl: generates the web panel's self-signed TLS certificate on first start
|
||||
# (see loadOrCreatePanelCert in src/server/app.ts).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates chromium ffmpeg openssl && rm -rf /var/lib/apt/lists/*
|
||||
# Animated-sticker rendering (.tgs/Lottie → short WebM via headless Chromium + ffmpeg, so it can go
|
||||
# through the ordinary VIDEO_UPLOAD pipeline and autoplay in the feed like MAX's own animated stickers)
|
||||
# is OPTIONAL — chromium + ffmpeg together weigh ~1.4 GB. Build with --build-arg STICKERS=slim to skip
|
||||
# them (image ~0.3 GB); animated stickers then relay as their static thumbnail instead of a playable
|
||||
# video. STICKERS=full (default) installs them. ca-certificates is always needed for TLS.
|
||||
ARG STICKERS=full
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& if [ "$STICKERS" = "full" ]; then apt-get install -y --no-install-recommends chromium ffmpeg; fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV CHROMIUM_PATH=/usr/bin/chromium
|
||||
# MAX's TLS chain goes through Russia's state CA (Минцифры "Russian Trusted
|
||||
# Root/Sub CA"), which no standard trust store includes. Deliberately NOT
|
||||
|
|
@ -36,5 +36,5 @@ COPY certs ./certs
|
|||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
# Headless bridge (v0.4): no inbound web server — MAX is an outbound socket, Telegram is long-polled.
|
||||
CMD ["node", "dist/server.mjs"]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ services:
|
|||
context: .
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
# full (default) = animated stickers play as video (needs Chromium+ffmpeg, ~1.4 GB extra).
|
||||
# slim = animated stickers relay as a static image; much smaller/faster image. Set by setup.sh.
|
||||
STICKERS: ${STICKERS:-full}
|
||||
restart: unless-stopped
|
||||
# Hard cap on container stdout: the default json-file driver grows without
|
||||
# limit between container recreations. 10m x 3 bounds it at ~30 MB total;
|
||||
|
|
@ -14,8 +17,6 @@ services:
|
|||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
|
|
|
|||
12
index.html
12
index.html
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Telemax Bridge</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -54,11 +54,12 @@ apt-get update -y </dev/null
|
|||
apt_get upgrade -y
|
||||
|
||||
echo
|
||||
echo "[2/5] Проверяю git и jq..."
|
||||
echo "[2/5] Проверяю git, jq и gnupg..."
|
||||
# jq: used by setup.sh to auto-detect the Telegram group id (no need to hunt
|
||||
# for it manually — see setup.sh).
|
||||
for pkg in git jq; do
|
||||
if ! command -v "$pkg" >/dev/null 2>&1; then
|
||||
# for it manually — see setup.sh). gnupg: used by update.sh to verify the signed
|
||||
# release tag before building (v0.4 signed-update trust — see update.sh).
|
||||
for pkg in git jq gnupg; do
|
||||
if ! command -v "${pkg/gnupg/gpg}" >/dev/null 2>&1; then
|
||||
apt_get install -y "$pkg"
|
||||
fi
|
||||
done
|
||||
|
|
|
|||
2047
package-lock.json
generated
2047
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
|
@ -1,20 +1,19 @@
|
|||
{
|
||||
"name": "telemax",
|
||||
"private": true,
|
||||
"version": "0.3.9",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server/app.ts",
|
||||
"build": "vite build && esbuild src/server/app.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.mjs",
|
||||
"build": "esbuild src/server/app.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.mjs",
|
||||
"start": "NODE_ENV=production node dist/server.mjs",
|
||||
"lint": "tsc --noEmit && tsc --noEmit -p tsconfig.web.json",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"auth:cli": "tsx scripts/dev/auth-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"lottie-web": "^5.13.0",
|
||||
|
|
@ -23,25 +22,13 @@
|
|||
"puppeteer-core": "^25.6.0",
|
||||
"socks-proxy-agent": "^10.1.0",
|
||||
"telegraf": "^4.16.3",
|
||||
"undici": "^8.10.0",
|
||||
"ws": "^8.21.1"
|
||||
"undici": "^8.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"esbuild": "^0.25.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,26 @@ echo ">> Releasing ${TAG}"
|
|||
|
||||
# The tag must already exist (coordinate the tag first, then run this).
|
||||
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "!! Tag ${TAG} not found. Create it first: git tag ${TAG} then re-run." >&2
|
||||
echo "!! Tag ${TAG} not found. Create it first: git tag -s ${TAG} then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Signed-release trust (v0.4): once the maintainer's public key is pinned in the repo
|
||||
# (release-signing-key.asc), refuse to publish a tag that isn't validly signed by it — so neither
|
||||
# GitHub nor the mirror can ever carry code that update.sh's verify-tag would (correctly) reject.
|
||||
# Create signed tags with: git tag -s vX.Y.Z (needs the private key + gpg on this machine).
|
||||
if [ -f release-signing-key.asc ]; then
|
||||
VERIFY_HOME=$(mktemp -d)
|
||||
GNUPGHOME="$VERIFY_HOME" gpg --quiet --import release-signing-key.asc >/dev/null 2>&1 || true
|
||||
if ! GNUPGHOME="$VERIFY_HOME" git -c gpg.program=gpg verify-tag "${TAG}" >/dev/null 2>&1; then
|
||||
rm -rf "$VERIFY_HOME"
|
||||
echo "!! Tag ${TAG} is not validly signed by the pinned release key. Sign it: git tag -s -f ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$VERIFY_HOME"
|
||||
echo ">> release signature OK: ${TAG}"
|
||||
fi
|
||||
|
||||
echo ">> [1/4] push to GitHub (origin)"
|
||||
git push origin main
|
||||
git push origin "${TAG}"
|
||||
|
|
|
|||
245
setup.sh
245
setup.sh
|
|
@ -1,113 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# Interactive first-time setup for Telemax. Generates the two secret keys
|
||||
# automatically, asks only for the two things nobody but you can provide
|
||||
# (the Telegram bot token and target group id), writes .env, and offers to
|
||||
# build + start the bridge right away.
|
||||
# Interactive first-time setup for Telemax (v0.4 — headless, no web panel).
|
||||
# Generates the session-encryption key automatically, asks only for the two things
|
||||
# nobody but you can provide (the Telegram bot token and target group), lets you pick
|
||||
# the image size (animated stickers on/off), writes .env, and builds + starts the
|
||||
# bridge. MAX authorization happens afterwards IN THE BOT: send /login to it in a DM.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||
|
||||
# Waits until the container's HTTPS panel answers /health. MAX connects a moment
|
||||
# after the container starts, so /auth/* would 503 if we asked too early. Expects
|
||||
# API_URL set. Panel uses a self-signed cert generated on first start — hence -k.
|
||||
wait_for_server() {
|
||||
echo "Жду готовности сервера..."
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -sk --max-time 2 "$API_URL/health" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# Interactive MAX auth (phone -> SMS -> optional cloud password) against the running
|
||||
# container's /api/auth/*, the same endpoints the web panel uses. Runs both on a
|
||||
# fresh install and on the re-auth path below (an already-configured install where
|
||||
# auth was never finished). Expects API_URL, API_KEY, HOST, PORT set; sets AUTHED=1
|
||||
# on success.
|
||||
run_max_auth() {
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
bold " ШАГ АВТОРИЗАЦИИ MAX — БЕЗ НЕГО МОСТ НЕ ЗАРАБОТАЕТ"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo "Введите номер MAX и код из SMS. Если пропустить (просто Enter) —"
|
||||
echo "контейнер останется запущенным, но НЕ подключённым к аккаунту, пока"
|
||||
echo "вы не авторизуетесь позже через веб-панель."
|
||||
echo
|
||||
AUTHED=""
|
||||
read -rp "Номер телефона MAX (с кодом страны, напр. +79991234567), или Enter чтобы позже: " MAX_PHONE
|
||||
if [ -n "$MAX_PHONE" ]; then
|
||||
PHONE_OK=""
|
||||
for attempt in 1 2 3; do
|
||||
PHONE_RESP=$(curl -sk -X POST -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
|
||||
-d "{\"phone\":\"$MAX_PHONE\"}" "$API_URL/auth/phone")
|
||||
if echo "$PHONE_RESP" | grep -q '"success":true'; then
|
||||
PHONE_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -n "$PHONE_OK" ]; then
|
||||
echo "Код отправлен на $MAX_PHONE."
|
||||
read -rp "Код из SMS: " MAX_CODE
|
||||
VERIFY_RESP=$(curl -sk -X POST -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
|
||||
-d "{\"code\":\"$MAX_CODE\"}" "$API_URL/auth/verify")
|
||||
if echo "$VERIFY_RESP" | grep -q '"passwordRequired":true'; then
|
||||
# Some MAX accounts have a password set as a second factor on top of SMS.
|
||||
# A wrong password can be retried freely — the auth session behind it
|
||||
# doesn't expire until a correct one goes through (confirmed live 2026-08-14).
|
||||
HINT=$(echo "$VERIFY_RESP" | grep -o '"hint":"[^"]*"' | sed 's/"hint":"//;s/"$//')
|
||||
echo "Этот MAX-аккаунт защищён паролем (второй фактор поверх SMS)."
|
||||
[ -n "$HINT" ] && echo "Подсказка: $HINT"
|
||||
PASSWORD_OK=""
|
||||
while [ -z "$PASSWORD_OK" ]; do
|
||||
read -rsp "Пароль: " MAX_PASSWORD
|
||||
echo
|
||||
PASSWORD_RESP=$(curl -sk -X POST -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
|
||||
-d "{\"password\":\"$MAX_PASSWORD\"}" "$API_URL/auth/password")
|
||||
if echo "$PASSWORD_RESP" | grep -q '"success":true'; then
|
||||
PASSWORD_OK=1
|
||||
AUTHED=1
|
||||
bold "Готово — мост авторизован и подключён к MAX."
|
||||
else
|
||||
echo "❌ Неверный пароль, попробуйте ещё раз (или Ctrl+C — тогда через веб-панель: https://$HOST:$PORT)."
|
||||
fi
|
||||
done
|
||||
elif echo "$VERIFY_RESP" | grep -q '"success":true'; then
|
||||
AUTHED=1
|
||||
bold "Готово — мост авторизован и подключён к MAX."
|
||||
else
|
||||
echo "❌ Не удалось подтвердить код: $VERIFY_RESP"
|
||||
echo "Попробуйте ещё раз через веб-панель: https://$HOST:$PORT"
|
||||
fi
|
||||
else
|
||||
echo "❌ Не удалось запросить SMS: $PHONE_RESP"
|
||||
echo "Попробуйте через веб-панель: https://$HOST:$PORT"
|
||||
fi
|
||||
else
|
||||
echo "Ок — авторизацию можно завершить позже через веб-панель (см. ниже)."
|
||||
fi
|
||||
}
|
||||
|
||||
# Big, unmissable closing summary — authed vs not, plus the panel URL and key.
|
||||
# Expects AUTHED, HOST, PORT, API_KEY.
|
||||
print_final_status() {
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
if [ -n "$AUTHED" ]; then
|
||||
bold " ✅ ГОТОВО. Мост авторизован, запущен и подключён к MAX."
|
||||
else
|
||||
bold " ⚠️ МОСТ ЗАПУЩЕН, НО MAX ПОКА НЕ АВТОРИЗОВАН"
|
||||
echo " Без авторизации сообщения не будут пересылаться. Завершите её:"
|
||||
echo " откройте https://$HOST:$PORT, введите ключ ниже, затем номер и код из SMS."
|
||||
fi
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " Веб-панель: https://$HOST:$PORT"
|
||||
echo " Ключ входа: $API_KEY"
|
||||
echo " (сертификат самоподписанный — браузер предупредит один раз:"
|
||||
echo " «Дополнительно» → «Перейти на сайт». Ключ можно вернуть командой /apikey у бота.)"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
# Runs on every invocation, even on an already-configured install (the .env
|
||||
# check below exits before the rest of setup) — an update pulled in via the
|
||||
# watcher itself needs the watcher already installed to have gotten here, and
|
||||
|
|
@ -149,60 +50,28 @@ else
|
|||
echo "Пропускаю установку вотчера обновлений — нужны root и systemd. Обновляться придётся вручную: ./update.sh"
|
||||
fi
|
||||
|
||||
# Already configured — nothing to re-ask. If MAX isn't authorized yet, that's now
|
||||
# a one-liner in the bot (/login), so we don't need the old console-auth flow.
|
||||
if [ -f .env ]; then
|
||||
# Already configured — but the MAX auth step might never have been finished
|
||||
# (interrupted mid-flow, or only the bot/group part was done, or the account
|
||||
# has a cloud password and the person bailed at that prompt). Re-running the
|
||||
# installer used to just say "нечего делать" and exit, leaving the web panel as
|
||||
# the only recovery path. Instead: check whether MAX is actually authorized and,
|
||||
# if not, offer to finish it right here in the console.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env 2>/dev/null || true
|
||||
set +a
|
||||
PORT="${PORT:-3000}"
|
||||
API_URL="https://localhost:$PORT/api"
|
||||
PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me || true)
|
||||
HOST="${PUBLIC_IP:-<адрес-сервера>}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo ".env уже существует, но Docker недоступен на этой машине —"
|
||||
echo "запустите контейнер там, где есть Docker, и авторизуйтесь через веб-панель."
|
||||
echo ".env уже существует, но Docker недоступен на этой машине — запустите контейнер там, где есть Docker."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$(docker compose ps --status running --format '{{.Name}}' 2>/dev/null)" ]; then
|
||||
read -rp ".env есть, но контейнер не запущен. Запустить сейчас? [Y/n] " RUN_NOW
|
||||
if [ "${RUN_NOW:-Y}" = "n" ] || [ "${RUN_NOW:-Y}" = "N" ]; then
|
||||
echo "Ок. Когда будете готовы: docker compose up -d, затем ./setup.sh или веб-панель."
|
||||
echo "Ок. Когда будете готовы: docker compose up -d"
|
||||
exit 0
|
||||
fi
|
||||
GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown) docker compose up -d --build
|
||||
fi
|
||||
|
||||
wait_for_server
|
||||
# /api/status returns the active phone once MAX is authorized; empty until then.
|
||||
STATUS=$(curl -sk --max-time 3 -H "x-api-key: ${API_KEY:-}" "$API_URL/status" 2>/dev/null || true)
|
||||
if echo "$STATUS" | grep -q '"phone":"[^"]'; then
|
||||
echo "✅ Уже настроено и авторизовано в MAX. Ничего делать не нужно."
|
||||
echo " Веб-панель: https://$HOST:$PORT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
bold "⚠️ .env есть, но MAX ещё не авторизован — шаг авторизации не завершён."
|
||||
read -rp "Пройти авторизацию сейчас? [Y/n] " DO_AUTH
|
||||
if [ "${DO_AUTH:-Y}" = "n" ] || [ "${DO_AUTH:-Y}" = "N" ]; then
|
||||
echo "Ок — можно позже через веб-панель: https://$HOST:$PORT (ключ: /apikey у бота или grep API_KEY .env)."
|
||||
exit 0
|
||||
fi
|
||||
run_max_auth
|
||||
print_final_status
|
||||
echo "✅ Уже настроено и запущено."
|
||||
echo " Если MAX ещё не авторизован — напишите боту в ЛИЧКУ: /login (или в группе: /panel → «🔐 Вход в MAX»)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "Не найден openssl — он нужен, чтобы сгенерировать ключи. Установите его (обычно уже есть на Ubuntu/Debian: apt install openssl) и запустите скрипт снова."
|
||||
echo "Не найден openssl — он нужен для генерации ключа шифрования сессии. Установите (apt install openssl) и запустите скрипт снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -244,18 +113,6 @@ check_tcp() {
|
|||
return 1
|
||||
}
|
||||
|
||||
# Starting from $1, returns the first port nothing is already listening on
|
||||
# locally. Used instead of hard-failing when 3000 is taken (e.g. a leftover
|
||||
# container from an earlier install attempt) — just use the next one free.
|
||||
find_free_port() {
|
||||
local port=$1
|
||||
while (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; do
|
||||
exec 3>&- 3<&-
|
||||
port=$((port + 1))
|
||||
done
|
||||
echo "$port"
|
||||
}
|
||||
|
||||
# Ask about a Telegram proxy BEFORE the reachability check: on a host where Telegram
|
||||
# is reachable only through a proxy (e.g. a home server with no direct route to
|
||||
# api.telegram.org), a direct check would falsely fail and abort setup. MAX always
|
||||
|
|
@ -272,21 +129,23 @@ echo
|
|||
echo "Проверяю связь с серверами MAX и Telegram..."
|
||||
|
||||
# MAX is fatal: it's always direct, so nothing the user could re-type fixes a blocked
|
||||
# MAX — a firewall/geo-block is an environment problem, not a typo.
|
||||
if check_tcp 155.212.204.150 443; then
|
||||
echo " MAX (155.212.204.150:443): OK"
|
||||
# MAX — a firewall/geo-block is an environment problem, not a typo. Checked by NAME so
|
||||
# an IPv6-only host resolves via DNS64/NAT64 (MAX itself is IPv4-only).
|
||||
if check_tcp api2.oneme.ru 443; then
|
||||
echo " MAX (api2.oneme.ru:443): OK"
|
||||
else
|
||||
echo " MAX (155.212.204.150:443): нет связи"
|
||||
echo " MAX (api2.oneme.ru:443): нет связи"
|
||||
echo
|
||||
echo "Без связи с сервером MAX мост работать не сможет — обычно это файрвол хостинга"
|
||||
echo "или гео-блокировка. Проверьте сеть сервера и запустите setup.sh снова."
|
||||
echo "или гео-блокировка. На IPv6-only хосте нужен NAT64/DNS64 у провайдера. Проверьте"
|
||||
echo "сеть сервера и запустите setup.sh снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Telegram is NOT fatal: the usual cause is a mistyped proxy (or a host that needs a
|
||||
# proxy at all) — both fixable right here. So loop and let the user re-enter the proxy
|
||||
# and re-check instead of aborting, with an explicit "skip" escape so a genuinely
|
||||
# blocked host isn't a dead end (the proxy can still be set later in the panel).
|
||||
# blocked host isn't a dead end (the proxy can still be set later in .env).
|
||||
while true; do
|
||||
TG_PROXY_ARGS=()
|
||||
if [ -n "$TELEGRAM_PROXY" ]; then
|
||||
|
|
@ -305,11 +164,11 @@ while true; do
|
|||
fi
|
||||
echo " • впишите прокси (socks5://… или http://…) и Enter — перепроверю через него;"
|
||||
echo " • пустой Enter — перепроверить напрямую, без прокси;"
|
||||
echo " • skip — продолжить установку без проверки (прокси можно задать позже в панели)."
|
||||
echo " • skip — продолжить установку без проверки (прокси можно задать позже в .env)."
|
||||
read -rp " > " TG_INPUT
|
||||
if [ "$TG_INPUT" = "skip" ]; then
|
||||
echo " Пропускаю проверку Telegram. Мост поднимется, но без связи с Telegram пересылки"
|
||||
echo " не будет — задайте прокси в веб-панели (Configuration → Telegram-прокси)."
|
||||
echo " не будет — задайте TELEGRAM_PROXY в .env и пересоберите."
|
||||
break
|
||||
fi
|
||||
TELEGRAM_PROXY="$TG_INPUT"
|
||||
|
|
@ -403,37 +262,45 @@ if [ -f "$AVATAR" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
API_KEY=$(openssl rand -hex 24)
|
||||
MAX_SESSION_KEY=$(openssl rand -hex 32)
|
||||
|
||||
PORT=$(find_free_port 3000)
|
||||
if [ "$PORT" != "3000" ]; then
|
||||
echo "Порт 3000 занят — использую $PORT вместо него."
|
||||
# Animated stickers (.tgs) — the one real image-size knob. Rendering them to playable
|
||||
# video needs a headless Chromium + ffmpeg (~1.4 GB); opting out ("slim") relays them as
|
||||
# a static picture instead and keeps the image ~0.3 GB with a much faster build. 30-second
|
||||
# timeout so an unattended install isn't blocked — defaults to full.
|
||||
echo
|
||||
echo "Анимированные стикеры (.tgs):"
|
||||
echo " • FULL (по умолчанию) — проигрываются как видео. Образ +~1.4 ГБ (Chromium+ffmpeg), сборка дольше."
|
||||
echo " • SLIM — уходят статической картинкой. Образ ~0.3 ГБ, быстрая сборка."
|
||||
STICKERS=full
|
||||
if read -t 30 -rp "Впишите slim для лёгкого образа, или Enter (жду 30с) — оставить full: " STICKERS_CHOICE; then
|
||||
case "${STICKERS_CHOICE:-}" in
|
||||
slim | SLIM | s | S) STICKERS=slim ;;
|
||||
*) STICKERS=full ;;
|
||||
esac
|
||||
else
|
||||
echo
|
||||
echo " (30с прошло — оставляю full)"
|
||||
fi
|
||||
echo " → образ: $STICKERS"
|
||||
|
||||
# .env carries every secret this install has (API key, session-encryption key,
|
||||
# bot token) — don't leave it readable to other local users.
|
||||
# .env carries the session-encryption key and bot token — don't leave it readable to
|
||||
# other local users. STICKERS is read by docker-compose.yml as a build arg.
|
||||
umask 077
|
||||
cat > .env <<EOF
|
||||
# сгенерировано setup.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
API_KEY=$API_KEY
|
||||
MAX_SESSION_KEY=$MAX_SESSION_KEY
|
||||
TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN
|
||||
TARGET_TELEGRAM_GROUP=$TARGET_TELEGRAM_GROUP
|
||||
PORT=$PORT
|
||||
TELEGRAM_PROXY=$TELEGRAM_PROXY
|
||||
STICKERS=$STICKERS
|
||||
EOF
|
||||
|
||||
echo
|
||||
bold "Готово: .env создан."
|
||||
echo "Ключ для входа в веб-панель (сохраните — если потеряете, его всегда можно"
|
||||
echo "запросить снова у бота командой /apikey прямо в вашей Telegram-группе):"
|
||||
bold " $API_KEY"
|
||||
echo
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker не найден на этой машине — установите Docker и Docker Compose, затем запустите:"
|
||||
echo " docker compose up -d --build"
|
||||
echo "Docker не найден — установите Docker и Docker Compose, затем: docker compose up -d --build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -444,18 +311,18 @@ if [ "${RUN_NOW:-Y}" = "n" ] || [ "${RUN_NOW:-Y}" = "N" ]; then
|
|||
fi
|
||||
|
||||
echo
|
||||
echo "Собираю образ — первая сборка дольше обычного (внутри headless-браузер для рендера стикеров), обычно несколько минут."
|
||||
if [ "$STICKERS" = "full" ]; then
|
||||
echo "Собираю образ (full) — первая сборка дольше (внутри headless-браузер для стикеров), обычно несколько минут."
|
||||
else
|
||||
echo "Собираю образ (slim) — быстрая сборка."
|
||||
fi
|
||||
GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown) docker compose up -d --build
|
||||
|
||||
PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me || true)
|
||||
HOST="${PUBLIC_IP:-<адрес-сервера>}"
|
||||
|
||||
echo
|
||||
bold "Контейнер собран и запущен. Остался ОДИН обязательный шаг ниже."
|
||||
|
||||
# Авторизация в MAX прямо здесь, без переключения в браузер — тот же /api/auth/*,
|
||||
# которым пользуется веб-панель, просто из консоли.
|
||||
API_URL="https://localhost:$PORT/api"
|
||||
wait_for_server
|
||||
run_max_auth
|
||||
print_final_status
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
bold " ✅ Мост собран и запущен. Остался ОДИН обязательный шаг: авторизация MAX."
|
||||
echo " Откройте вашего бота в Telegram и напишите ему в ЛИЧКУ команду:"
|
||||
bold " /login"
|
||||
echo " Введёте номер MAX и код из SMS прямо в личке — в группу они не попадут."
|
||||
echo " (Альтернатива: в группе /panel → «🔐 Вход в MAX».)"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
|
|
|
|||
674
src/App.tsx
674
src/App.tsx
|
|
@ -1,674 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Activity, KeyRound, MessageSquare, Server, Settings, Shield } from 'lucide-react';
|
||||
import { apiFetch, clearStoredApiKey, getStoredApiKey, openStatusSocket, setStoredApiKey } from './api';
|
||||
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
opcode: string;
|
||||
dir: string;
|
||||
size: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface StatusData {
|
||||
max: boolean;
|
||||
tg: boolean;
|
||||
deviceId: string;
|
||||
phone: string;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
interface Metrics {
|
||||
packetsSent: number;
|
||||
packetsReceived: number;
|
||||
uptimeSeconds: number;
|
||||
}
|
||||
|
||||
interface ChatMapping {
|
||||
maxChatId: string; // normalized to a decimal string server-side (BigInt-safe)
|
||||
telegramTopicId: number;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MaxChat {
|
||||
id: number;
|
||||
type: string;
|
||||
displayName: string;
|
||||
lastMessage?: { text?: string; sender?: number };
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// Human-readable current proxy mode for the "Сейчас:" line, with credentials stripped.
|
||||
function proxyDisplay(url: string): string {
|
||||
if (!url) return 'напрямую (без прокси)';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.username = '';
|
||||
u.password = '';
|
||||
return `через прокси ${u.toString()}`;
|
||||
} catch {
|
||||
return `через прокси ${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
function ApiKeyGate({ onSubmit, error }: { onSubmit: (key: string) => void; error: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0c0d0f] text-slate-300 flex items-center justify-center">
|
||||
<div className="w-full max-w-sm bg-[#141518] p-6 rounded-lg border border-white/10 shadow-xl">
|
||||
<h2 className="text-lg font-bold text-white mb-2 flex items-center gap-2">
|
||||
<KeyRound className="text-blue-500" size={20} /> API Key
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Ключ печатается в консоли при установке (setup.sh). Если потеряли — он лежит в файле <code className="text-slate-400">.env</code> в папке установки: <code className="text-slate-400">grep API_KEY .env</code>. Либо спросите бота командой <code className="text-slate-400">/apikey</code> в вашей Telegram-группе.
|
||||
</p>
|
||||
{error && <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">{error}</div>}
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSubmit(value)}
|
||||
className="w-full bg-[#0c0d0f] border border-white/10 rounded px-4 py-3 text-sm text-white mb-4 focus:outline-none focus:border-blue-500"
|
||||
placeholder="x-api-key"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSubmit(value)}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 rounded text-sm transition-colors"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Lets the bot's /apikey link (http://host:port/?key=...) log you straight
|
||||
// in instead of just pointing at the login form — store it like a normal
|
||||
// login and strip it from the URL bar right away so it doesn't linger in
|
||||
// history/bookmarks.
|
||||
const [apiKey, setApiKey] = useState<string | null>(() => {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('key');
|
||||
if (fromUrl) {
|
||||
setStoredApiKey(fromUrl);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
return fromUrl;
|
||||
}
|
||||
return getStoredApiKey();
|
||||
});
|
||||
const [gateError, setGateError] = useState('');
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [status, setStatus] = useState<StatusData>({ max: false, tg: false, deviceId: '', phone: '', latencyMs: null });
|
||||
const [metrics, setMetrics] = useState<Metrics>({ packetsSent: 0, packetsReceived: 0, uptimeSeconds: 0 });
|
||||
const [chatMappings, setChatMappings] = useState<ChatMapping[]>([]);
|
||||
const [maxChats, setMaxChats] = useState<MaxChat[]>([]);
|
||||
const [sendChatId, setSendChatId] = useState('');
|
||||
const [sendText, setSendText] = useState('');
|
||||
const [sendStatus, setSendStatus] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('logs');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordHint, setPasswordHint] = useState('');
|
||||
const [authStep, setAuthStep] = useState<'phone' | 'code' | 'password' | 'done'>('phone');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [proxyUrl, setProxyUrl] = useState('');
|
||||
const [proxyActive, setProxyActive] = useState('');
|
||||
const [proxyStatus, setProxyStatus] = useState('');
|
||||
const [proxyBusy, setProxyBusy] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const isSubmitting = useRef(false);
|
||||
|
||||
function handleUnauthorized() {
|
||||
clearStoredApiKey();
|
||||
setApiKey(null);
|
||||
setGateError('Неверный или отозванный API key.');
|
||||
}
|
||||
|
||||
function handleGateSubmit(key: string) {
|
||||
if (!key.trim()) return;
|
||||
setStoredApiKey(key.trim());
|
||||
setApiKey(key.trim());
|
||||
setGateError('');
|
||||
}
|
||||
|
||||
const handleForceStop = async () => {
|
||||
await apiFetch('/api/system/stop', { method: 'POST' }).catch(console.error);
|
||||
};
|
||||
const handleForceStart = async () => {
|
||||
await apiFetch('/api/system/start', { method: 'POST' }).catch(console.error);
|
||||
};
|
||||
|
||||
const refreshChats = () => {
|
||||
apiFetch('/api/chats')
|
||||
.then((r: Response) => (r.ok ? r.json() : { data: { chats: [] } }))
|
||||
.then((body: { data?: { chats?: MaxChat[] } }) => setMaxChats(body.data?.chats ?? []))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!sendChatId || !sendText) return;
|
||||
setSendStatus('Sending...');
|
||||
try {
|
||||
const res = await apiFetch(`/api/chats/${sendChatId}/messages`, { method: 'POST', body: JSON.stringify({ text: sendText }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setSendStatus(res.ok ? `Sent (cid ${data.cid})` : data.error || 'Failed');
|
||||
if (res.ok) {
|
||||
setSendText('');
|
||||
// the server only learns the send succeeded once MAX echoes a confirmation frame back,
|
||||
// which lands shortly after this request returns — give it a beat before refetching
|
||||
setTimeout(refreshChats, 700);
|
||||
}
|
||||
} catch {
|
||||
setSendStatus('Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestSms = async () => {
|
||||
if (isLoading || isSubmitting.current) return;
|
||||
isSubmitting.current = true;
|
||||
setIsLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/phone', { method: 'POST', body: JSON.stringify({ phone }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) setAuthError(data.error || 'Failed to request SMS');
|
||||
else setAuthStep('code');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isSubmitting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (isLoading || isSubmitting.current) return;
|
||||
isSubmitting.current = true;
|
||||
setIsLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/verify', { method: 'POST', body: JSON.stringify({ code }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) setAuthError(data.error || 'Failed to verify code');
|
||||
else if (data.passwordRequired) {
|
||||
setPasswordHint(data.hint || '');
|
||||
setAuthStep('password');
|
||||
} else setAuthStep('done');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isSubmitting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Only reached for password-protected MAX accounts — verifyCode above
|
||||
// returned passwordRequired instead of completing the login directly. A
|
||||
// wrong password can be retried freely (the server keeps the same
|
||||
// trackId), so this deliberately doesn't fall back to the code step.
|
||||
const handleVerifyPassword = async () => {
|
||||
if (isLoading || isSubmitting.current) return;
|
||||
isSubmitting.current = true;
|
||||
setIsLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/password', { method: 'POST', body: JSON.stringify({ password }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) setAuthError(data.error || 'Failed to verify password');
|
||||
else setAuthStep('done');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isSubmitting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnects MAX and deletes the encrypted session, same as /kill's MAX-side
|
||||
// teardown — but Telegram topics/history are untouched, so this is safe as a
|
||||
// plain "log out and reauthorize" action rather than something destructive.
|
||||
const handleLogout = async () => {
|
||||
if (isLoading || isSubmitting.current) return;
|
||||
if (!window.confirm('Разлогинить MAX-сессию? Понадобится новая SMS-авторизация. История переписки в Telegram не затрагивается.')) return;
|
||||
isSubmitting.current = true;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/logout', { method: 'POST' });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setPassword('');
|
||||
setPasswordHint('');
|
||||
setAuthError('');
|
||||
setAuthStep('phone');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isSubmitting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Telegram proxy: test a value live before committing it. MAX always stays direct;
|
||||
// saving persists to ./data and restarts the process to apply (Telegraf binds its
|
||||
// agent at construction), and compose's restart policy brings the container back.
|
||||
const handleTestProxy = async () => {
|
||||
setProxyBusy(true);
|
||||
setProxyStatus('Проверяю…');
|
||||
try {
|
||||
const res = await apiFetch('/api/proxy/test', { method: 'POST', body: JSON.stringify({ proxy: proxyUrl }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setProxyStatus(data.ok ? '✅ Прокси доступен' : `❌ ${data.error || 'нет связи'}`);
|
||||
} catch {
|
||||
setProxyStatus('❌ ошибка проверки');
|
||||
} finally {
|
||||
setProxyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProxyRestart = async () => {
|
||||
if (!window.confirm('Сохранить прокси и перезапустить мост? MAX останется напрямую. Перерыв в работе — несколько секунд.')) return;
|
||||
setProxyBusy(true);
|
||||
setProxyStatus('Сохраняю…');
|
||||
try {
|
||||
const res = await apiFetch('/api/proxy', { method: 'POST', body: JSON.stringify({ proxy: proxyUrl }) });
|
||||
if (res.status === 401) return handleUnauthorized();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) {
|
||||
setProxyStatus(`❌ ${data.error || 'не сохранено'}`);
|
||||
return;
|
||||
}
|
||||
setProxyStatus('Сохранено. Перезапускаю…');
|
||||
setProxyActive(proxyUrl.trim()); // reflect the new mode immediately; restart applies it
|
||||
await apiFetch('/api/system/restart', { method: 'POST' }).catch(() => {});
|
||||
} finally {
|
||||
setProxyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiKey) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const refreshStatus = (isInitial: boolean) => {
|
||||
apiFetch('/api/status')
|
||||
.then((r: Response) => {
|
||||
if (r.status === 401) throw new Error('unauthorized');
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { packetsSent: number; packetsReceived: number; uptimeSeconds: number; maxOnline: boolean; tgActive: boolean; deviceId: string; phone: string; latencyMs: number | null }) => {
|
||||
if (cancelled) return;
|
||||
setMetrics({ packetsSent: data.packetsSent, packetsReceived: data.packetsReceived, uptimeSeconds: data.uptimeSeconds });
|
||||
setStatus({ max: data.maxOnline, tg: data.tgActive, deviceId: data.deviceId, phone: data.phone, latencyMs: data.latencyMs });
|
||||
if (isInitial && data.phone) setAuthStep('done');
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled && err.message === 'unauthorized') handleUnauthorized();
|
||||
});
|
||||
};
|
||||
|
||||
refreshStatus(true);
|
||||
// The WS pushes status changes, but packets/uptime only come from /api/status —
|
||||
// without polling they froze at their on-mount values until a full page reload.
|
||||
const metricsTimer = window.setInterval(() => refreshStatus(false), 30_000);
|
||||
|
||||
apiFetch('/api/chat-mappings')
|
||||
.then((r: Response) => (r.ok ? r.json() : { data: [] }))
|
||||
.then((body: { data?: ChatMapping[] }) => !cancelled && setChatMappings(body.data ?? []))
|
||||
.catch(() => {});
|
||||
|
||||
apiFetch('/api/chats')
|
||||
.then((r: Response) => (r.ok ? r.json() : { data: { chats: [] } }))
|
||||
.then((body: { data?: { chats?: MaxChat[] } }) => !cancelled && setMaxChats(body.data?.chats ?? []))
|
||||
.catch(() => {});
|
||||
|
||||
apiFetch('/api/proxy')
|
||||
.then((r: Response) => (r.ok ? r.json() : { proxy: '' }))
|
||||
.then((body: { proxy?: string }) => {
|
||||
if (cancelled) return;
|
||||
setProxyUrl(body.proxy ?? '');
|
||||
setProxyActive(body.proxy ?? '');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Reconnect with a small delay when the socket drops (server redeploy/restart) —
|
||||
// otherwise the panel silently froze until a manual reload.
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
const connect = () => {
|
||||
ws = openStatusSocket();
|
||||
ws.onmessage = (event: MessageEvent<string>) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'init_logs') setLogs(msg.data);
|
||||
else if (msg.type === 'log') setLogs((prev) => [...prev, msg.data].slice(-50));
|
||||
else if (msg.type === 'status') setStatus(msg.data);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!cancelled) reconnectTimer = window.setTimeout(connect, 3_000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(metricsTimer);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
ws?.close();
|
||||
};
|
||||
}, [apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
if (!apiKey) return <ApiKeyGate onSubmit={handleGateSubmit} error={gateError} />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0c0d0f] text-slate-300 font-sans flex flex-col overflow-hidden select-none">
|
||||
<header className="h-16 border-b border-white/10 bg-[#141518] flex items-center justify-between px-6 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center">
|
||||
<span className="text-white font-bold">M</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white tracking-tight">MAX Bridge</h1>
|
||||
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold">Real-time Protocol Proxy & Sync</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${status.max ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className="text-xs font-medium uppercase">MAX TCP: {status.max ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${status.tg ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className="text-xs font-medium uppercase">TG Bot: {status.tg ? 'Active' : 'Offline'}</span>
|
||||
</div>
|
||||
{status.max ? (
|
||||
<button onClick={handleForceStop} className="px-4 py-1.5 bg-red-600/20 hover:bg-red-600/40 border border-red-500/50 rounded text-red-400 text-xs font-bold">
|
||||
FORCE STOP
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleForceStart} className="px-4 py-1.5 bg-green-600/20 hover:bg-green-600/40 border border-green-500/50 rounded text-green-400 text-xs font-bold">
|
||||
RECONNECT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex overflow-hidden">
|
||||
<aside className="w-72 border-r border-white/5 bg-[#0f1012] p-6 flex flex-col gap-8">
|
||||
<section>
|
||||
<h3 className="text-[10px] text-slate-500 uppercase font-bold mb-4 tracking-wider flex items-center gap-2">
|
||||
<Server size={14} /> Connection
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-white/5 rounded border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase mb-1">Latency</div>
|
||||
<div className="font-mono text-sm text-blue-300">{status.latencyMs != null ? `${status.latencyMs} ms` : '—'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-white/5 rounded border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase mb-1">Uptime</div>
|
||||
<div className="text-xs text-white">{formatUptime(metrics.uptimeSeconds)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex-1">
|
||||
<h3 className="text-[10px] text-slate-500 uppercase font-bold mb-4 tracking-wider flex items-center gap-2">
|
||||
<Shield size={14} /> Active Session
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-400">Phone</span>
|
||||
<span className="text-white">{status.phone || 'Not authenticated'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-400">Device ID</span>
|
||||
<span className="text-white font-mono truncate max-w-[120px]">{status.deviceId || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col bg-[#0c0d0f]">
|
||||
<div className="h-14 border-b border-white/5 flex items-center px-6 gap-8">
|
||||
<button onClick={() => setActiveTab('logs')} className={`text-xs font-bold h-full flex items-center gap-2 ${activeTab === 'logs' ? 'text-white border-b-2 border-blue-500' : 'text-slate-500'}`}>
|
||||
<Activity size={16} /> Live Protocol Logs
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('chats')} className={`text-xs font-bold h-full flex items-center gap-2 ${activeTab === 'chats' ? 'text-white border-b-2 border-blue-500' : 'text-slate-500'}`}>
|
||||
<MessageSquare size={16} /> MAX Chats
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('mapping')} className={`text-xs font-bold h-full flex items-center gap-2 ${activeTab === 'mapping' ? 'text-white border-b-2 border-blue-500' : 'text-slate-500'}`}>
|
||||
<MessageSquare size={16} /> Chat Mapping
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('config')} className={`text-xs font-bold h-full flex items-center gap-2 ${activeTab === 'config' ? 'text-white border-b-2 border-blue-500' : 'text-slate-500'}`}>
|
||||
<Settings size={16} /> Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col gap-6 overflow-hidden">
|
||||
{activeTab === 'logs' && (
|
||||
<div className="flex-1 border border-white/5 bg-[#0f1012] rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="grid grid-cols-6 gap-4 p-4 bg-white/5 text-[10px] uppercase font-bold text-slate-500 border-b border-white/5">
|
||||
<div>Timestamp</div>
|
||||
<div>OpCode</div>
|
||||
<div className="text-center">Dir</div>
|
||||
<div>Length</div>
|
||||
<div className="col-span-2">Payload (masked)</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto font-mono text-[11px] divide-y divide-white/[0.03]">
|
||||
{logs.length === 0 && <div className="p-4 text-center text-slate-500 italic">Waiting for traffic...</div>}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="grid grid-cols-6 gap-4 p-4 items-center">
|
||||
<div className="text-slate-500">{log.time}</div>
|
||||
<div className="font-bold text-blue-300">{log.opcode}</div>
|
||||
<div className="text-center">
|
||||
<span className="bg-white/10 px-2 py-0.5 rounded font-bold">{log.dir}</span>
|
||||
</div>
|
||||
<div className="text-slate-400">{log.size}</div>
|
||||
<div className="col-span-2 text-slate-300 truncate opacity-80">{log.payload}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'config' && (
|
||||
<div className="flex-1 bg-[#0f1012] rounded-lg border border-white/5 p-8 flex flex-col items-center gap-6 overflow-auto">
|
||||
<div className="max-w-md w-full bg-[#141518] p-6 rounded-lg border border-white/10 shadow-xl">
|
||||
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Shield className="text-blue-500" /> MAX Authentication
|
||||
</h2>
|
||||
{authError && <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-sm">{authError}</div>}
|
||||
{authStep === 'phone' && (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+7 999 123 45 67"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-[#0c0d0f] border border-white/10 rounded px-4 py-3 text-sm text-white"
|
||||
/>
|
||||
<button onClick={handleRequestSms} disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-bold py-3 rounded text-sm">
|
||||
{isLoading ? 'Requesting...' : 'Request SMS Code'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{authStep === 'code' && (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full bg-[#0c0d0f] border border-white/10 rounded px-4 py-3 text-sm text-white font-mono tracking-widest"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setAuthStep('phone')} className="w-1/3 bg-white/10 hover:bg-white/20 text-white font-bold py-3 rounded text-sm">
|
||||
Back
|
||||
</button>
|
||||
<button onClick={handleVerifyCode} disabled={isLoading} className="w-2/3 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-bold py-3 rounded text-sm">
|
||||
{isLoading ? 'Verifying...' : 'Verify & Login'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{authStep === 'password' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-white/60">Этот MAX-аккаунт защищён паролем (второй фактор поверх SMS).</p>
|
||||
{passwordHint && <p className="text-sm text-white/60">Подсказка: {passwordHint}</p>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-[#0c0d0f] border border-white/10 rounded px-4 py-3 text-sm text-white"
|
||||
/>
|
||||
<button onClick={handleVerifyPassword} disabled={isLoading} className="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-bold py-3 rounded text-sm">
|
||||
{isLoading ? 'Verifying...' : 'Verify & Login'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{authStep === 'done' && (
|
||||
<div className="text-center space-y-4 py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 text-green-500 rounded-full flex items-center justify-center mx-auto">
|
||||
<Shield size={32} />
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg">Authenticated as {status.phone}</h3>
|
||||
<button onClick={handleLogout} disabled={isLoading} className="bg-red-600/20 hover:bg-red-600/30 disabled:opacity-50 text-red-400 font-bold py-2 px-4 rounded text-sm">
|
||||
Разлогиниться / сменить номер
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full bg-[#141518] p-6 rounded-lg border border-white/10 shadow-xl">
|
||||
<h2 className="text-lg font-bold text-white mb-2 flex items-center gap-2">
|
||||
<Server className="text-blue-500" size={20} /> Telegram-прокси
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
Если сервер выходит в Telegram только через прокси. MAX всегда идёт напрямую.
|
||||
Форматы: <span className="font-mono text-slate-300">socks5://…</span> или{' '}
|
||||
<span className="font-mono text-slate-300">http://…</span> (можно с логином:паролем@).
|
||||
Применяется после перезапуска.
|
||||
</p>
|
||||
<div className="text-xs mb-3">
|
||||
<span className="text-slate-500">Сейчас: </span>
|
||||
<span className={proxyActive ? 'text-blue-300 font-mono' : 'text-green-400'}>{proxyDisplay(proxyActive)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="socks5://user:pass@host:1080 (пусто — напрямую)"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
className="w-full bg-[#0c0d0f] border border-white/10 rounded px-4 py-3 text-sm text-white font-mono"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-2">Пусто = напрямую, без прокси</p>
|
||||
{proxyStatus && <div className="mt-3 text-sm text-slate-300">{proxyStatus}</div>}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={handleTestProxy} disabled={proxyBusy} className="w-1/2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white font-bold py-3 rounded text-sm">
|
||||
Проверить
|
||||
</button>
|
||||
<button onClick={handleSaveProxyRestart} disabled={proxyBusy} className="w-1/2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-bold py-3 rounded text-sm">
|
||||
Сохранить и перезапустить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'chats' && (
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="bg-[#141518] p-4 rounded-lg border border-white/10 flex gap-3 items-center shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="chatId"
|
||||
value={sendChatId}
|
||||
onChange={(e) => setSendChatId(e.target.value)}
|
||||
className="w-32 bg-[#0c0d0f] border border-white/10 rounded px-3 py-2 text-sm text-white font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Текст сообщения"
|
||||
value={sendText}
|
||||
onChange={(e) => setSendText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
className="flex-1 bg-[#0c0d0f] border border-white/10 rounded px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
<button onClick={handleSendMessage} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded text-sm">
|
||||
Send
|
||||
</button>
|
||||
{sendStatus && <span className="text-xs text-slate-400">{sendStatus}</span>}
|
||||
</div>
|
||||
<div className="flex-1 bg-[#0f1012] rounded-lg border border-white/5 overflow-hidden flex flex-col">
|
||||
<div className="grid grid-cols-5 gap-4 p-4 bg-white/5 text-[10px] uppercase font-bold text-slate-500 border-b border-white/5">
|
||||
<div>Name</div>
|
||||
<div>chatId</div>
|
||||
<div>Type</div>
|
||||
<div className="col-span-2">Last message</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto font-mono text-[11px] divide-y divide-white/[0.03]">
|
||||
{maxChats.length === 0 && <div className="p-4 text-center text-slate-500 italic">No chats (or not authenticated yet).</div>}
|
||||
{maxChats.map((c) => (
|
||||
<div key={c.id} className="grid grid-cols-5 gap-4 p-4 items-center cursor-pointer hover:bg-white/[0.02]" onClick={() => setSendChatId(String(c.id))}>
|
||||
<div className="text-white font-sans font-semibold truncate">{c.displayName}</div>
|
||||
<div className="text-slate-500">{c.id}</div>
|
||||
<div className="text-slate-400">{c.type}</div>
|
||||
<div className="col-span-2 text-slate-300 truncate">{c.lastMessage?.text ?? '—'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mapping' && (
|
||||
<div className="flex-1 bg-[#0f1012] rounded-lg border border-white/5 overflow-hidden flex flex-col">
|
||||
<div className="grid grid-cols-4 gap-4 p-4 bg-white/5 text-[10px] uppercase font-bold text-slate-500 border-b border-white/5">
|
||||
<div>MAX chatId</div>
|
||||
<div>TG topicId</div>
|
||||
<div>Title</div>
|
||||
<div>Created</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto font-mono text-[11px] divide-y divide-white/[0.03]">
|
||||
{chatMappings.length === 0 && <div className="p-4 text-center text-slate-500 italic">No chat mappings yet — created automatically on first message.</div>}
|
||||
{chatMappings.map((m) => (
|
||||
<div key={m.maxChatId} className="grid grid-cols-4 gap-4 p-4 items-center">
|
||||
<div className="text-white">{m.maxChatId}</div>
|
||||
<div className="text-white">{m.telegramTopicId}</div>
|
||||
<div className="text-slate-300 truncate">{m.title ?? '—'}</div>
|
||||
<div className="text-slate-500">{m.createdAt}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-[#141518] p-5 rounded-lg border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold mb-2">Packets S/R</div>
|
||||
<div className="text-2xl font-light text-white">{metrics.packetsSent} / {metrics.packetsReceived}</div>
|
||||
</div>
|
||||
<div className="bg-[#141518] p-5 rounded-lg border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold mb-2">Uptime</div>
|
||||
<div className="text-2xl font-light text-white">{formatUptime(metrics.uptimeSeconds)}</div>
|
||||
</div>
|
||||
<div className="bg-[#141518] p-5 rounded-lg border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold mb-2">Latency</div>
|
||||
<div className="text-2xl font-light text-blue-400 font-mono">{status.latencyMs != null ? `${status.latencyMs}ms` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/api.ts
28
src/api.ts
|
|
@ -1,28 +0,0 @@
|
|||
const STORAGE_KEY = 'telemax_api_key';
|
||||
|
||||
export function getStoredApiKey(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function setStoredApiKey(key: string): void {
|
||||
localStorage.setItem(STORAGE_KEY, key);
|
||||
}
|
||||
|
||||
export function clearStoredApiKey(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** Attaches the stored API key to every request; the caller handles a 401 by clearing it and re-prompting. */
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const apiKey = getStoredApiKey();
|
||||
const headers = new Headers(init.headers);
|
||||
if (apiKey) headers.set('x-api-key', apiKey);
|
||||
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
return fetch(path, { ...init, headers });
|
||||
}
|
||||
|
||||
export function openStatusSocket(): WebSocket {
|
||||
const apiKey = getStoredApiKey() ?? '';
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return new WebSocket(`${protocol}//${window.location.host}/ws?apiKey=${encodeURIComponent(apiKey)}`);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
import { gunzipSync } from 'node:zlib';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
|
@ -25,6 +25,15 @@ import { createLogger } from '../logger.js';
|
|||
const logger = createLogger('lottie');
|
||||
|
||||
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium';
|
||||
|
||||
/**
|
||||
* Whether this image can render animated (.tgs) stickers to video. The "slim" build (STICKERS=slim,
|
||||
* see Dockerfile) omits Chromium+ffmpeg to save ~1.4 GB; there, animated stickers relay as their
|
||||
* static thumbnail instead. Presence of the Chromium binary IS the signal — no separate flag needed.
|
||||
*/
|
||||
export function canRenderAnimatedStickers(): boolean {
|
||||
return existsSync(CHROMIUM_PATH);
|
||||
}
|
||||
// esbuild bundles this whole module into dist/server.mjs — import.meta.url at
|
||||
// runtime points at THAT file's location (/app/dist/server.mjs), not this
|
||||
// source file's, so the relative path has to be resolved from there.
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ export interface ControlPanelDeps {
|
|||
/** Reused slash-command bodies — the panel just triggers the same output. */
|
||||
leaves: {
|
||||
sendHelp: LeafSender;
|
||||
sendLoginLink: LeafSender;
|
||||
sendApiKey: LeafSender;
|
||||
sendVersion: LeafSender;
|
||||
sendBanList: LeafSender;
|
||||
sendUnbanList: LeafSender;
|
||||
|
|
@ -89,7 +87,7 @@ function rootView(phone: string): View {
|
|||
text: `🎛 Telemax — пульт\n${status} · версия ${version}`,
|
||||
markup: Markup.inlineKeyboard([
|
||||
[Markup.button.callback('👤 Найти контакт', 'tlmx_panel:contacts'), Markup.button.callback('🚫 Чаты', 'tlmx_panel:chats')],
|
||||
[Markup.button.callback('🌐 Веб-панель', 'tlmx_panel:web'), Markup.button.callback('⚙️ Система', 'tlmx_panel:system')],
|
||||
[Markup.button.callback('🔐 Вход в MAX', 'tlmx_panel:web'), Markup.button.callback('⚙️ Система', 'tlmx_panel:system')],
|
||||
]).reply_markup,
|
||||
};
|
||||
}
|
||||
|
|
@ -112,15 +110,16 @@ function chatsView(): View {
|
|||
};
|
||||
}
|
||||
function webView(botUsername?: string): View {
|
||||
// The MAX-login button is a deep link into the bot's DM (t.me/<bot>?start=login) — auth (SMS code,
|
||||
// 2FA password) happens privately there, not in the group. Shown only once we know the username.
|
||||
// Auth is a deep link into the bot's DM (t.me/<bot>?start=login) — SMS code and 2FA password
|
||||
// are entered privately there, never in the group. Button shown only once we know the username.
|
||||
const rows = [
|
||||
[Markup.button.callback('🔗 Ссылка для входа', 'tlmx_panel:link'), Markup.button.callback('🔑 API-ключ', 'tlmx_panel:apikey')],
|
||||
...(botUsername ? [[Markup.button.url('🔐 Войти в MAX', `https://t.me/${botUsername}?start=login`)]] : []),
|
||||
[Markup.button.callback('◀️ Назад', 'tlmx_panel:root')],
|
||||
];
|
||||
return {
|
||||
text: '🌐 Веб-панель:',
|
||||
text: botUsername
|
||||
? '🔐 Вход в MAX — в личке бота:\nнажмите кнопку (или напишите боту в личку /login). Код и пароль не попадут в группу.'
|
||||
: '🔐 Вход в MAX: напишите боту в личку /login.',
|
||||
markup: Markup.inlineKeyboard(rows).reply_markup,
|
||||
};
|
||||
}
|
||||
|
|
@ -233,14 +232,6 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
|
|||
await ctx.answerCbQuery();
|
||||
await leaves.sendHelp(chatIdOf(ctx));
|
||||
});
|
||||
bot.action('tlmx_panel:apikey', async (ctx) => {
|
||||
await ctx.answerCbQuery();
|
||||
await leaves.sendApiKey(chatIdOf(ctx));
|
||||
});
|
||||
bot.action('tlmx_panel:link', async (ctx) => {
|
||||
await ctx.answerCbQuery();
|
||||
await leaves.sendLoginLink(chatIdOf(ctx));
|
||||
});
|
||||
bot.action('tlmx_panel:update', async (ctx) => {
|
||||
await ctx.answerCbQuery();
|
||||
await leaves.sendVersion(chatIdOf(ctx));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { writeFile } from 'node:fs/promises';
|
|||
import { existsSync } from 'node:fs';
|
||||
import { Markup, type Telegraf } from 'telegraf';
|
||||
import type { ChatAction, TelegramEmoji } from 'telegraf/types';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import type { MaxClient, MaxMessageEvent, MaxHistoryMessage } from '../max/client.js';
|
||||
import { OPCODES, formatOpcode } from '../max/opcodes.js';
|
||||
import { resolveContactDisplayName, resolveChatName, type ContactProfile } from '../max/names.js';
|
||||
|
|
@ -11,6 +10,7 @@ import type { ChatMapStore } from '../store/chatMapStore.js';
|
|||
import { ensureTopicForMaxChat } from '../telegram/bot.js';
|
||||
import { downloadMaxAttachment, describeAttachment, type MaxAttachment, type DownloadContext } from './attachments.js';
|
||||
import { uploadTelegramAttachmentToMax } from './upload.js';
|
||||
import { canRenderAnimatedStickers } from './lottie.js';
|
||||
import { reportBridgeError } from './errorReporter.js';
|
||||
import { wireControlPanel } from './panel.js';
|
||||
import { createBugReports, BUGREPORT_BOT_HANDLE, type BugReports } from './bugReports.js';
|
||||
|
|
@ -668,32 +668,6 @@ async function pinInfoCard(bot: Telegraf, targetGroupId: string, messageId: numb
|
|||
}
|
||||
}
|
||||
|
||||
// ifconfig.me is dual-stack; Node's built-in fetch (undici) doesn't reliably honor the process
|
||||
// ipv4-first DNS order, so it can answer over IPv6 and echo back the server's IPv6 address. That IPv6
|
||||
// then lands in the /apikey panel link — unbracketed (a broken URL) and pointing at a family the panel
|
||||
// doesn't even serve (it listens on 0.0.0.0, IPv4 only). Pin this one probe to IPv4 so the login link
|
||||
// is always a reachable IPv4 URL. Reported live: a dual-stack user's panel button handed out IPv6.
|
||||
const ipv4OnlyDispatcher = new Agent({ connect: { family: 4 } });
|
||||
|
||||
/** Same lookup setup.sh does once at install time, run live for /apikey's link — best-effort, `null` just falls back to the bare key. */
|
||||
async function detectPublicIp(): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await undiciFetch('https://ifconfig.me/ip', {
|
||||
signal: controller.signal,
|
||||
dispatcher: ipv4OnlyDispatcher,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
if (!res.ok) return null;
|
||||
const ip = (await res.text()).trim();
|
||||
// Safety net: should be IPv4 given the dispatcher above, but never emit a bare IPv6 into a URL.
|
||||
return ip.includes(':') ? `[${ip}]` : ip;
|
||||
} catch (err) {
|
||||
logger.error('Failed to detect public IP for /apikey link', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Picked up within a minute by update-watcher.sh on the host (see setup.sh) — writing it is the only thing the container itself does towards an update, everything else (git pull, rebuild, restart) happens outside it. */
|
||||
const UPDATE_REQUESTED_MARKER = path.join(process.cwd(), '.data', 'update-requested');
|
||||
/** Written by update.sh while it runs (data/ is the same mount as .data/) — lets the /version button refuse a second request mid-update. */
|
||||
|
|
@ -732,8 +706,6 @@ export interface BridgeOptions {
|
|||
getMyAccountId: () => number | null;
|
||||
getContactProfiles: () => Map<number, ContactProfile>;
|
||||
getActivePhone: () => string;
|
||||
/** 'https' once the panel booted with its (self-signed) certificate, 'http' when TLS is off or cert setup failed — /apikey builds its login link with it. */
|
||||
getPanelScheme: () => 'http' | 'https';
|
||||
/** Refetches MAX's chat list and re-runs the full backfill sync — used by /reboot after wiping local state. Fire-and-forget on the caller's side (server/app.ts already guards against overlapping runs). */
|
||||
triggerFullResync: () => Promise<void>;
|
||||
/** Disconnects from MAX and deletes the encrypted session — used by /kill. Awaited (unlike triggerFullResync) since /kill's own confirmation message should only go out once this has actually finished. */
|
||||
|
|
@ -756,7 +728,6 @@ export function wireBridge({
|
|||
getMyAccountId,
|
||||
getContactProfiles,
|
||||
getActivePhone,
|
||||
getPanelScheme,
|
||||
triggerFullResync,
|
||||
killEverything,
|
||||
auth,
|
||||
|
|
@ -1584,29 +1555,6 @@ export function wireBridge({
|
|||
});
|
||||
|
||||
/** Recovers the web-panel API_KEY without needing SSH/file access to the server — safe now that the target-group middleware above actually gates who can ask. Bundles it into a ready-to-open link (App.tsx reads ?key= and logs straight in) when the server's public IP can be detected, falling back to the bare key otherwise. */
|
||||
bot.command('apikey', async (ctx) => {
|
||||
const apiKey = process.env.API_KEY;
|
||||
if (!apiKey) {
|
||||
await bot.telegram.sendMessage(ctx.chat.id, 'API_KEY не задан в .env.', { message_thread_id: ctx.message.message_thread_id });
|
||||
return;
|
||||
}
|
||||
const port = process.env.PORT ?? '3000';
|
||||
const ip = await detectPublicIp();
|
||||
const scheme = getPanelScheme();
|
||||
const certNote =
|
||||
scheme === 'https'
|
||||
? '\n\n🔒 Сертификат самоподписанный — браузер один раз предупредит «подключение не защищено»: жми «Дополнительно» → «Перейти на сайт».'
|
||||
: '';
|
||||
const text = ip
|
||||
? `🔑 Вход в веб-панель (ссылка сразу авторизует):\n${scheme}://${ip}:${port}/?key=${encodeURIComponent(apiKey)}${certNote}`
|
||||
: `🔑 Ключ для веб-панели (не удалось определить IP сервера — откройте панель вручную и введите ключ):\n<code>${apiKey}</code>${certNote}`;
|
||||
await bot.telegram.sendMessage(ctx.chat.id, text, {
|
||||
message_thread_id: ctx.message.message_thread_id,
|
||||
parse_mode: 'HTML',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
// In-Telegram MAX login. In a DM the private-chat auth flow already handles /login; this group
|
||||
// handler just hands over the deep link into that DM, so the SMS code and 2FA password never
|
||||
// touch the group chat.
|
||||
|
|
@ -1795,31 +1743,6 @@ export function wireBridge({
|
|||
const { text, replyMarkup } = formatVersionMessage(await checkVersion());
|
||||
await bot.telegram.sendMessage(chatId, text, { reply_markup: replyMarkup });
|
||||
},
|
||||
sendApiKey: async (chatId) => {
|
||||
const key = process.env.API_KEY;
|
||||
await bot.telegram.sendMessage(chatId, key ? `🔑 Ключ веб-панели:\n<code>${key}</code>` : 'API_KEY не задан в .env.', {
|
||||
parse_mode: 'HTML',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
},
|
||||
sendLoginLink: async (chatId) => {
|
||||
const apiKey = process.env.API_KEY;
|
||||
if (!apiKey) {
|
||||
await bot.telegram.sendMessage(chatId, 'API_KEY не задан в .env.');
|
||||
return;
|
||||
}
|
||||
const port = process.env.PORT ?? '3000';
|
||||
const ip = await detectPublicIp();
|
||||
const scheme = getPanelScheme();
|
||||
const certNote =
|
||||
scheme === 'https'
|
||||
? '\n\n🔒 Сертификат самоподписанный — браузер предупредит один раз: «Дополнительно» → «Перейти на сайт».'
|
||||
: '';
|
||||
const text = ip
|
||||
? `🔗 Вход в веб-панель (ссылка сразу авторизует):\n${scheme}://${ip}:${port}/?key=${encodeURIComponent(apiKey)}${certNote}`
|
||||
: `🔗 Не удалось определить IP сервера — откройте панель вручную и введите ключ (кнопка «API-ключ»).${certNote}`;
|
||||
await bot.telegram.sendMessage(chatId, text, { parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
||||
},
|
||||
sendBanList: async (chatId) => {
|
||||
const active = (await chatMapStore.list()).filter((m) => !m.banned);
|
||||
if (active.length === 0) {
|
||||
|
|
@ -2259,7 +2182,7 @@ export function wireBridge({
|
|||
const video = (ctx.message as { video?: { file_id: string; file_name?: string } }).video;
|
||||
const videoNote = (ctx.message as { video_note?: { file_id: string } }).video_note;
|
||||
const voice = (ctx.message as { voice?: { file_id: string; duration?: number } }).voice;
|
||||
const sticker = (ctx.message as { sticker?: { file_id: string; is_animated?: boolean; is_video?: boolean } }).sticker;
|
||||
const sticker = (ctx.message as { sticker?: { file_id: string; is_animated?: boolean; is_video?: boolean; thumbnail?: { file_id: string } } }).sticker;
|
||||
const poll = (ctx.message as { poll?: { id: string; question: string; options: Array<{ text: string }>; is_anonymous: boolean; allows_multiple_answers: boolean } }).poll;
|
||||
const location = (ctx.message as { location?: { latitude: number; longitude: number } }).location;
|
||||
const contact = (ctx.message as { contact?: { phone_number: string; first_name: string; last_name?: string } }).contact;
|
||||
|
|
@ -2395,7 +2318,13 @@ export function wireBridge({
|
|||
if (sticker.is_video) {
|
||||
attach = await uploadTelegramAttachmentToMax(bot, max, sticker.file_id, 'video', 'sticker.webm');
|
||||
} else if (sticker.is_animated) {
|
||||
attach = await uploadTelegramAttachmentToMax(bot, max, sticker.file_id, 'sticker_animated');
|
||||
// Animated .tgs needs a headless-Chromium render (see lottie.ts). On a "slim" image without
|
||||
// Chromium, fall back to the sticker's static thumbnail so something still comes through.
|
||||
if (canRenderAnimatedStickers()) {
|
||||
attach = await uploadTelegramAttachmentToMax(bot, max, sticker.file_id, 'sticker_animated');
|
||||
} else if (sticker.thumbnail?.file_id) {
|
||||
attach = await uploadTelegramAttachmentToMax(bot, max, sticker.thumbnail.file_id, 'photo');
|
||||
}
|
||||
} else {
|
||||
attach = await uploadTelegramAttachmentToMax(bot, max, sticker.file_id, 'photo');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -1,10 +0,0 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -1,37 +1,20 @@
|
|||
import express from 'express';
|
||||
import dns from 'node:dns';
|
||||
import { createServer } from 'node:http';
|
||||
import { createServer as createTlsServer } from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import { Telegraf } from 'telegraf';
|
||||
import path from 'node:path';
|
||||
import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { MaxClient, type MaxMessageEvent, type MaxContactInfo } from '../max/client.js';
|
||||
import { OPCODES, formatOpcode } from '../max/opcodes.js';
|
||||
import { OPCODES } from '../max/opcodes.js';
|
||||
import { extractMyAccountId, resolveChatName, type ContactProfile } from '../max/names.js';
|
||||
import { SessionStore, type MaxSession } from '../store/sessionStore.js';
|
||||
import { ChatMapStore } from '../store/chatMapStore.js';
|
||||
import { wireBridge, syncAllChatsToTelegram, MessageLinkStore } from '../bridge/sync.js';
|
||||
import { configureErrorReporter, reportBridgeError, resetErrorKey } from '../bridge/errorReporter.js';
|
||||
import { getAppVersion } from '../bridge/version.js';
|
||||
import { createLogger, jsonStringify, redactSecrets } from '../logger.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { config } from './config.js';
|
||||
import { isValidApiKey, requireApiKey } from './authMiddleware.js';
|
||||
import {
|
||||
buildTelegramProxyAgent,
|
||||
getResolvedProxyUrl,
|
||||
getTelegramProxyAgent,
|
||||
initTelegramProxy,
|
||||
redactProxyUrl,
|
||||
writePersistedProxy,
|
||||
} from '../telegram/proxy.js';
|
||||
import https from 'node:https';
|
||||
import { getTelegramProxyAgent, initTelegramProxy } from '../telegram/proxy.js';
|
||||
|
||||
const logger = createLogger('server');
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Prefer IPv4 when a host resolves to both. On dual-stack servers where IPv6 has no
|
||||
// working route to Telegram (common in RU — Telegram is blocked over v6 while v4
|
||||
|
|
@ -74,9 +57,6 @@ let pendingAuthToken: string | null = null;
|
|||
/** Set when verifyCode() comes back password_required — cleared only on a successful login, since the trackId survives a wrong password and can be retried (confirmed live 2026-08-14). */
|
||||
let pendingPasswordTrackId: string | null = null;
|
||||
let currentSession: MaxSession | null = null;
|
||||
let latestLatencyMs: number | null = null;
|
||||
let packetsSent = 0;
|
||||
let packetsReceived = 0;
|
||||
// LOGIN's embedded chat list is capped at chatsCount (<=50) and, live, has also
|
||||
// been observed to just omit chats a later resumed-session LOGIN included —
|
||||
// cachedChats gets replaced with the real, fully-paginated CHATS_LIST result
|
||||
|
|
@ -148,7 +128,6 @@ async function completeMaxLogin(loginToken: string): Promise<void> {
|
|||
activePhone = session.phone;
|
||||
lastKnownPhone = session.phone;
|
||||
notifyMaxSessionRestored();
|
||||
broadcastStatus();
|
||||
refreshBotDescription();
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +216,6 @@ async function killMaxSession(): Promise<void> {
|
|||
myAccountId = null;
|
||||
contactProfiles = new Map();
|
||||
maxConnected = false;
|
||||
broadcastStatus();
|
||||
refreshBotDescription();
|
||||
}
|
||||
|
||||
|
|
@ -261,88 +239,6 @@ function upsertCachedChat(chat: unknown): void {
|
|||
else cachedChats.push(chat);
|
||||
}
|
||||
|
||||
interface UiLogEntry {
|
||||
time: string;
|
||||
opcode: string;
|
||||
dir: string;
|
||||
size: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const packetLogs: UiLogEntry[] = [];
|
||||
const uiClients = new Set<WebSocket>();
|
||||
|
||||
function broadcast(message: unknown): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of uiClients) {
|
||||
if (client.readyState === client.OPEN) client.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastStatus(): void {
|
||||
broadcast({
|
||||
type: 'status',
|
||||
data: {
|
||||
max: maxConnected,
|
||||
tg: tgActive,
|
||||
deviceId: max.deviceId,
|
||||
phone: activePhone,
|
||||
latencyMs: latestLatencyMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pushLog(dir: string, opcode: number, length: number, payload: unknown): void {
|
||||
const entry: UiLogEntry = {
|
||||
time: new Date().toISOString().substring(11, 23),
|
||||
opcode: formatOpcode(opcode),
|
||||
dir,
|
||||
size: `${length} B`,
|
||||
payload: jsonStringify(redactSecrets(payload)),
|
||||
};
|
||||
packetLogs.push(entry);
|
||||
if (packetLogs.length > 50) packetLogs.shift();
|
||||
broadcast({ type: 'log', data: entry });
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TLS_DIR = path.join(process.cwd(), '.data', 'tls');
|
||||
|
||||
/** What /apikey should build its login link with — flips to 'https' once the panel actually boots with a certificate (not just intends to), so the link never points at a scheme the server isn't serving. */
|
||||
let panelScheme: 'http' | 'https' = 'http';
|
||||
|
||||
/**
|
||||
* Self-signed TLS for the web panel. Installs are typically bare-IP VPSes, so a
|
||||
* publicly-trusted certificate isn't attainable by default — self-signed still
|
||||
* closes the real gap: the API key, the SMS code and the MAX 2FA password used
|
||||
* to cross the open internet as plain HTTP. The browser warns once about the
|
||||
* unknown issuer (expected; /apikey's link says so). Generated with the openssl
|
||||
* CLI (present in the Docker image) and persisted in .data/tls — the ./data
|
||||
* volume — so the browser exception survives container rebuilds.
|
||||
*/
|
||||
async function loadOrCreatePanelCert(): Promise<{ key: Buffer; cert: Buffer } | null> {
|
||||
const keyPath = path.join(TLS_DIR, 'key.pem');
|
||||
const certPath = path.join(TLS_DIR, 'cert.pem');
|
||||
try {
|
||||
return { key: await readFile(keyPath), cert: await readFile(certPath) };
|
||||
} catch {
|
||||
// not generated yet — fall through
|
||||
}
|
||||
try {
|
||||
await mkdir(TLS_DIR, { recursive: true });
|
||||
await execFileAsync('openssl', [
|
||||
'req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyPath, '-out', certPath,
|
||||
'-days', '3650', '-nodes', '-subj', '/CN=telemax',
|
||||
]);
|
||||
await chmod(keyPath, 0o600).catch(() => {});
|
||||
logger.info('Generated a self-signed TLS certificate for the web panel (.data/tls)');
|
||||
return { key: await readFile(keyPath), cert: await readFile(certPath) };
|
||||
} catch (err) {
|
||||
logger.error('Failed to generate a self-signed TLS certificate — the panel stays on plain HTTP', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set once we've told the group the MAX session was lost, so a "restored" notice only
|
||||
// fires after an actual loss — and only once.
|
||||
let maxSessionLostReported = false;
|
||||
|
|
@ -446,7 +342,6 @@ async function loginWithSession(session: MaxSession): Promise<void> {
|
|||
} finally {
|
||||
resumeInFlight = false;
|
||||
}
|
||||
broadcastStatus();
|
||||
refreshBotDescription();
|
||||
}
|
||||
|
||||
|
|
@ -463,7 +358,6 @@ async function startServer(): Promise<void> {
|
|||
|
||||
max.on('connected', () => {
|
||||
maxConnected = true;
|
||||
broadcastStatus();
|
||||
if (maxDownTimer) {
|
||||
clearTimeout(maxDownTimer);
|
||||
maxDownTimer = null;
|
||||
|
|
@ -481,7 +375,6 @@ async function startServer(): Promise<void> {
|
|||
|
||||
max.on('disconnected', () => {
|
||||
maxConnected = false;
|
||||
broadcastStatus();
|
||||
if (maxDownTimer == null && !maxDownReported) {
|
||||
maxDownTimer = setTimeout(() => {
|
||||
maxDownTimer = null;
|
||||
|
|
@ -493,21 +386,7 @@ async function startServer(): Promise<void> {
|
|||
|
||||
max.on('error', (err: Error) => logger.error('MAX client error:', err.message));
|
||||
|
||||
max.on('latency', (ms: number) => {
|
||||
latestLatencyMs = ms;
|
||||
broadcastStatus();
|
||||
});
|
||||
|
||||
max.on('sent', ({ opcode, payload, length }: { opcode: number; payload: unknown; length: number }) => {
|
||||
packetsSent += 1;
|
||||
pushLog('TX', opcode, length, payload);
|
||||
});
|
||||
|
||||
max.on('message', (event: MaxMessageEvent) => {
|
||||
packetsReceived += 1;
|
||||
const dirLabel = event.dir === 0x03 ? 'ERR' : event.dir === 0x01 ? 'RX' : 'PUSH';
|
||||
pushLog(dirLabel, event.opcode, event.length, event.payload);
|
||||
|
||||
if (event.opcode === OPCODES.PUSH_MESSAGE || (event.opcode === OPCODES.MSG_SEND && event.dir === 0x01)) {
|
||||
const p = event.payload as { chatId?: number; message?: unknown } | null;
|
||||
if (p?.chatId != null && p.message) patchCachedChatLastMessage(p.chatId, p.message);
|
||||
|
|
@ -541,7 +420,6 @@ async function startServer(): Promise<void> {
|
|||
getMyAccountId: () => myAccountId,
|
||||
getContactProfiles: () => contactProfiles,
|
||||
getActivePhone: () => activePhone,
|
||||
getPanelScheme: () => panelScheme,
|
||||
triggerFullResync: () => refreshChatsAndNames().then(() => syncChatsIfPossible()),
|
||||
killEverything: killMaxSession,
|
||||
auth: {
|
||||
|
|
@ -562,260 +440,14 @@ async function startServer(): Promise<void> {
|
|||
logger.warn('TELEGRAM_BOT_TOKEN / TARGET_TELEGRAM_GROUP not set — Telegram bridge stays disabled');
|
||||
}
|
||||
|
||||
// --- HTTP + WS server ---
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No inbound server: the bridge is headless (v0.4). Auth, status and control all live in
|
||||
// the Telegram bot now (see /login, the /panel menu). MAX runs over an outbound TCP socket
|
||||
// and Telegram over long-polling, so nothing needs to listen on a port.
|
||||
|
||||
// TLS on by default in production (self-signed — see loadOrCreatePanelCert).
|
||||
// PANEL_TLS=off opts out for setups with their own TLS-terminating reverse
|
||||
// proxy in front. Dev mode stays plain HTTP on localhost.
|
||||
const wantTls = process.env.NODE_ENV === 'production' && process.env.PANEL_TLS !== 'off';
|
||||
const tlsMaterial = wantTls ? await loadOrCreatePanelCert() : null;
|
||||
panelScheme = tlsMaterial ? 'https' : 'http';
|
||||
const httpServer = tlsMaterial ? createTlsServer({ key: tlsMaterial.key, cert: tlsMaterial.cert }, app) : createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
if (req.url?.startsWith('/ws')) {
|
||||
const key = new URL(req.url, 'http://localhost').searchParams.get('apiKey');
|
||||
if (!isValidApiKey(config.apiKey, key)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
uiClients.add(ws);
|
||||
ws.send(JSON.stringify({ type: 'init_logs', data: packetLogs }));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { max: maxConnected, tg: tgActive, deviceId: max.deviceId, phone: activePhone, latencyMs: latestLatencyMs },
|
||||
}),
|
||||
);
|
||||
ws.on('close', () => uiClients.delete(ws));
|
||||
});
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const api = express.Router();
|
||||
api.use(requireApiKey(config.apiKey));
|
||||
|
||||
api.get('/status', (_req, res) => {
|
||||
res.json({
|
||||
maxOnline: maxConnected,
|
||||
tgActive,
|
||||
deviceId: max.deviceId,
|
||||
phone: activePhone,
|
||||
latencyMs: latestLatencyMs,
|
||||
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
|
||||
packetsSent,
|
||||
packetsReceived,
|
||||
});
|
||||
});
|
||||
|
||||
api.post('/system/stop', (_req, res) => {
|
||||
max.disconnect();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
api.post('/system/start', (_req, res) => {
|
||||
max.connect();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Restarts the whole process to apply config that's only read at startup (the
|
||||
// Telegram proxy). compose's `restart: unless-stopped` brings the container right
|
||||
// back, so from the operator's side this is just "apply & reconnect".
|
||||
api.post('/system/restart', (_req, res) => {
|
||||
res.json({ success: true });
|
||||
logger.warn('Restart requested from the web panel — exiting so the container comes back up');
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
});
|
||||
|
||||
// --- Telegram proxy config (see src/telegram/proxy.ts) ---
|
||||
api.get('/proxy', (_req, res) => {
|
||||
res.json({ proxy: getResolvedProxyUrl() });
|
||||
});
|
||||
|
||||
api.post('/proxy/test', async (req, res) => {
|
||||
const url = typeof req.body?.proxy === 'string' ? req.body.proxy : '';
|
||||
res.json(await testTelegramProxyReachable(url));
|
||||
});
|
||||
|
||||
api.post('/proxy', async (req, res) => {
|
||||
const url = typeof req.body?.proxy === 'string' ? req.body.proxy.trim() : '';
|
||||
try {
|
||||
buildTelegramProxyAgent(url); // validates scheme/format — throws on garbage
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
await writePersistedProxy(url);
|
||||
logger.info(url ? `Telegram proxy saved (${redactProxyUrl(url)}) — applies on restart` : 'Telegram proxy cleared — applies on restart');
|
||||
res.json({ success: true, restartRequired: true });
|
||||
});
|
||||
|
||||
api.post('/auth/phone', async (req, res) => {
|
||||
const rawPhone = req.body?.phone;
|
||||
if (!rawPhone) {
|
||||
res.status(400).json({ error: 'phone missing' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await maxAuthRequestSms(String(rawPhone));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.error('Failed to request SMS', err);
|
||||
res.status(msg === 'MAX is disconnected' ? 503 : 502).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/auth/verify', async (req, res) => {
|
||||
const code = req.body?.code;
|
||||
if (!code) {
|
||||
res.status(400).json({ error: 'code missing' });
|
||||
return;
|
||||
}
|
||||
if (!pendingAuthToken) {
|
||||
res.status(400).json({ error: 'no pending auth — call /api/auth/phone first' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await maxAuthVerifyCode(String(code));
|
||||
if ('passwordRequired' in result) {
|
||||
res.json({ success: true, passwordRequired: true, hint: result.hint });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to verify SMS code', err);
|
||||
res.status(502).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Only reached for password-protected accounts — verifyCode() above set
|
||||
// pendingPasswordTrackId instead of completing the login directly.
|
||||
api.post('/auth/password', async (req, res) => {
|
||||
const password = req.body?.password;
|
||||
if (!password) {
|
||||
res.status(400).json({ error: 'password missing' });
|
||||
return;
|
||||
}
|
||||
if (!pendingPasswordTrackId) {
|
||||
res.status(400).json({ error: 'no pending password challenge — call /api/auth/verify first' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await maxAuthCheckPassword(String(password));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to verify MAX password', err);
|
||||
res.status(502).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Same MAX-side teardown /kill's bot command uses (disconnect + delete the
|
||||
// encrypted session) — but scoped to just that, unlike /kill, which also
|
||||
// wipes every Telegram topic. Lets the web panel offer "log out / change
|
||||
// number" without touching chat history.
|
||||
api.post('/auth/logout', async (_req, res) => {
|
||||
await killMaxSession();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
api.get('/chats', (_req, res) => {
|
||||
const chats = cachedChats.map((chat) => ({
|
||||
...(chat as object),
|
||||
displayName: resolveChatName(chat, myAccountId, contactProfiles),
|
||||
}));
|
||||
res.type('application/json').send(jsonStringify({ success: true, data: { chats } }));
|
||||
});
|
||||
|
||||
api.get('/chat-mappings', async (_req, res) => {
|
||||
res.json({ success: true, data: await chatMapStore.list() });
|
||||
});
|
||||
|
||||
// The /api/debug/* one-shot probes that used to live here (test-poll, test-contact,
|
||||
// test-group*, test-delete, chats-list, contact-info, chat-history) were removed
|
||||
// 2026-08-14 after every probed opcode got wired into real bot commands — they were
|
||||
// API-key-gated but still let a caller send messages and manage groups on the MAX
|
||||
// account, which is needless surface on a production install. Recover from git
|
||||
// history if a new opcode ever needs live probing again.
|
||||
|
||||
api.post('/chats/:id/messages', async (req, res) => {
|
||||
if (!maxConnected) {
|
||||
res.status(503).json({ error: 'MAX is disconnected' });
|
||||
return;
|
||||
}
|
||||
const text = req.body?.text;
|
||||
if (!text) {
|
||||
res.status(400).json({ error: 'text missing' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { cid } = await max.sendMessage(req.params.id, String(text));
|
||||
res.json({ success: true, cid });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', api);
|
||||
|
||||
// --- Frontend ---
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(distPath, 'index.html')));
|
||||
} else {
|
||||
// Dynamic import, NOT a static top-level one: vite is a devDependency, so it
|
||||
// doesn't exist in the production image at all. A static import crashed the
|
||||
// container on boot (ERR_MODULE_NOT_FOUND) the moment @tailwindcss/vite left
|
||||
// "dependencies" — it had been pulling vite into the runtime image as its
|
||||
// peer dependency this whole time, masking the problem. Hit live 2026-08-14.
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'spa' });
|
||||
app.use(vite.middlewares);
|
||||
}
|
||||
|
||||
let listener: net.Server = httpServer;
|
||||
if (tlsMaterial) {
|
||||
// Single-port polyglot: a TLS ClientHello always starts with byte 0x16, so
|
||||
// anything else on the socket is a plain-HTTP client — an old bookmark or a
|
||||
// pre-TLS /apikey link — and gets a 301 to the same URL over https instead
|
||||
// of a cryptic protocol error.
|
||||
const redirectServer = createServer((req, res) => {
|
||||
res.writeHead(301, { Location: `https://${req.headers.host ?? `localhost:${config.port}`}${req.url ?? '/'}` });
|
||||
res.end();
|
||||
});
|
||||
listener = net.createServer((socket) => {
|
||||
socket.on('error', () => socket.destroy());
|
||||
socket.once('data', (firstChunk) => {
|
||||
socket.pause();
|
||||
socket.unshift(firstChunk);
|
||||
(firstChunk[0] === 0x16 ? httpServer : redirectServer).emit('connection', socket);
|
||||
// NOT a synchronous resume: the TLS wrap set up by the 'connection'
|
||||
// listener needs this tick, or the handshake never sees the ClientHello
|
||||
// and hangs forever (caught by a local smoke test before shipping).
|
||||
process.nextTick(() => socket.resume());
|
||||
});
|
||||
});
|
||||
}
|
||||
listener.listen(config.port, '0.0.0.0', () => {
|
||||
logger.info(`Server listening on port ${config.port}${tlsMaterial ? ' (HTTPS, self-signed)' : ''}`);
|
||||
});
|
||||
|
||||
// docker stop / systemd send SIGTERM (node runs as PID 1 — exec-form CMD, so it
|
||||
// actually receives it). Stop polling Telegram and close the MAX socket cleanly
|
||||
// instead of letting the runtime kill mid-write; the backfill cursor is persisted
|
||||
// per message, so an in-flight backfill resumes where it left off either way.
|
||||
// docker stop / systemd send SIGTERM (node runs as PID 1 — exec-form CMD, so it actually
|
||||
// receives it). Stop polling Telegram and close the MAX socket cleanly instead of letting the
|
||||
// runtime kill mid-write; the backfill cursor is persisted per message, so an in-flight
|
||||
// backfill resumes where it left off either way.
|
||||
const shutdown = (signal: string): void => {
|
||||
logger.info(`Received ${signal}, shutting down`);
|
||||
try {
|
||||
|
|
@ -824,11 +456,9 @@ async function startServer(): Promise<void> {
|
|||
// bot may not have launched (bad token, mid-retry) — nothing to stop
|
||||
}
|
||||
max.disconnect();
|
||||
if (listener !== httpServer) httpServer.close();
|
||||
listener.close(() => process.exit(0));
|
||||
// Failsafe: don't let a lingering keep-alive socket hold the process past
|
||||
// docker's own stop timeout.
|
||||
// Failsafe: don't let a lingering keep-alive socket hold the process past docker's stop timeout.
|
||||
setTimeout(() => process.exit(0), 5_000).unref();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.once('SIGINT', () => shutdown('SIGINT'));
|
||||
|
|
@ -869,7 +499,6 @@ const BOT_COMMANDS = [
|
|||
{ command: 'panel', description: '🎛 Пульт управления (меню с кнопками)' },
|
||||
{ command: 'help', description: 'Полный список команд и ограничений' },
|
||||
{ command: 'donate', description: 'Поддержать проект (рубли / TON)' },
|
||||
{ command: 'apikey', description: 'Показать ключ для веб-панели' },
|
||||
{ command: 'login', description: 'Войти в MAX (номер + SMS, в личке бота)' },
|
||||
{ command: 'version', description: 'Версия бота, обновление по кнопке' },
|
||||
{ command: 'info', description: 'Карточка контакта/чата (просто в теме)' },
|
||||
|
|
@ -945,7 +574,6 @@ async function announceGroupReadyOnce(bot: Telegraf): Promise<void> {
|
|||
|
||||
function retryTelegramLaunch(bot: Telegraf, attempt: number, reason: unknown): void {
|
||||
tgActive = false;
|
||||
broadcastStatus();
|
||||
const idx = Math.min(attempt, TELEGRAM_RETRY_DELAYS_MS.length - 1);
|
||||
const delay = TELEGRAM_RETRY_DELAYS_MS[idx] as number;
|
||||
logger.error(`Telegram bot stopped, retrying in ${delay}ms:`, reason instanceof Error ? reason.message : reason);
|
||||
|
|
@ -970,7 +598,6 @@ async function launchTelegramBotWithRetry(bot: Telegraf, attempt = 0): Promise<v
|
|||
|
||||
tgActive = true;
|
||||
logger.info('Telegram bot active');
|
||||
broadcastStatus();
|
||||
|
||||
// Best-effort: keeps the bot's Telegram profile description in sync with its
|
||||
// actual command surface and the two platform-level gaps a user could otherwise
|
||||
|
|
@ -992,28 +619,6 @@ async function launchTelegramBotWithRetry(bot: Telegraf, attempt = 0): Promise<v
|
|||
.catch((err) => retryTelegramLaunch(bot, attempt, err));
|
||||
}
|
||||
|
||||
/** Tries to reach api.telegram.org through the given proxy URL (empty = direct). Backs
|
||||
* the panel's "test" button before a proxy is saved. Never throws — returns a verdict. */
|
||||
async function testTelegramProxyReachable(proxyUrl: string): Promise<{ ok: boolean; error?: string }> {
|
||||
let agent: ReturnType<typeof buildTelegramProxyAgent>;
|
||||
try {
|
||||
agent = buildTelegramProxyAgent(proxyUrl);
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get('https://api.telegram.org/', { agent, timeout: 8000 }, (res) => {
|
||||
res.resume();
|
||||
resolve({ ok: true });
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, error: 'таймаут подключения' });
|
||||
});
|
||||
req.on('error', (err) => resolve({ ok: false, error: err.message }));
|
||||
});
|
||||
}
|
||||
|
||||
function createBotSafely(token: string): Telegraf | null {
|
||||
try {
|
||||
const agent = getTelegramProxyAgent();
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export function isValidApiKey(expected: string, provided: string | null | undefined): boolean {
|
||||
if (!provided) return false;
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const providedBuf = Buffer.from(provided);
|
||||
return providedBuf.length === expectedBuf.length && timingSafeEqual(providedBuf, expectedBuf);
|
||||
}
|
||||
|
||||
/** Requires a matching `x-api-key` header. Applied to every /api/* route except /api/health (ТЗ.md §3.4). */
|
||||
export function requireApiKey(expected: string): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (!isValidApiKey(expected, req.header('x-api-key'))) {
|
||||
res.status(401).json({ error: 'missing or invalid x-api-key' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,17 +1,6 @@
|
|||
import 'dotenv/config';
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set — see .env.example`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
// Shared secret required on every /api/* route except /api/health (ТЗ.md §3.4).
|
||||
apiKey: required('API_KEY'),
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN ?? '',
|
||||
targetTelegramGroup: process.env.TARGET_TELEGRAM_GROUP ?? '',
|
||||
// Override the MAX endpoint. Unset → connect by hostname (api2.oneme.ru), which lets
|
||||
|
|
|
|||
|
|
@ -13,6 +13,5 @@
|
|||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "scripts", "tests"],
|
||||
"exclude": ["src/App.tsx", "src/main.tsx", "src/api.ts"]
|
||||
"include": ["src", "scripts", "tests"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/App.tsx", "src/main.tsx", "src/api.ts"]
|
||||
}
|
||||
30
update.sh
30
update.sh
|
|
@ -64,6 +64,36 @@ fi
|
|||
# two up originally, which silently broke the whole watcher — confirmed live
|
||||
# 2026-08-14: a marker written straight to `data/` was never picked up
|
||||
# because update-watcher.sh was checking `.data/` instead).
|
||||
STEP="проверка подписи релиза"
|
||||
# Signed-release trust (v0.4): the transport (GitHub OR the self-hosted mirror) is untrusted — only
|
||||
# the maintainer's GPG signature on the release tag is. When release-signing-key.asc is pinned in the
|
||||
# repo, REQUIRE a valid signed tag at the pulled HEAD before building anything, so a compromised
|
||||
# GitHub/mirror can't auto-deploy code. Absent (pre-signing installs, mid-transition) → skip.
|
||||
PUBKEY_FILE="release-signing-key.asc"
|
||||
if [ -f "$PUBKEY_FILE" ]; then
|
||||
if ! command -v gpg >/dev/null 2>&1; then
|
||||
notify "❌ Обновление отклонено: не установлен gpg для проверки подписи релиза (apt-get install -y gnupg)."
|
||||
exit 1
|
||||
fi
|
||||
VERIFY_HOME=$(mktemp -d)
|
||||
GNUPGHOME="$VERIFY_HOME" gpg --quiet --import "$PUBKEY_FILE" >/dev/null 2>&1 || true
|
||||
TAG_AT_HEAD=$(git tag --points-at HEAD 2>/dev/null | grep -E '^v[0-9]' | sort -V | tail -1 || true)
|
||||
if [ -z "$TAG_AT_HEAD" ]; then
|
||||
rm -rf "$VERIFY_HOME"
|
||||
notify "❌ Обновление отклонено: на новой версии нет подписанного тега."
|
||||
exit 1
|
||||
fi
|
||||
if ! GNUPGHOME="$VERIFY_HOME" git -c gpg.program=gpg verify-tag "$TAG_AT_HEAD" >/dev/null 2>&1; then
|
||||
rm -rf "$VERIFY_HOME"
|
||||
notify "❌ Обновление отклонено: подпись релиза ${TAG_AT_HEAD} не прошла проверку. Возможна компрометация источника — версия НЕ установлена."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$VERIFY_HOME"
|
||||
echo "[update] release signature OK: ${TAG_AT_HEAD}"
|
||||
else
|
||||
echo "[update] no release-signing-key.asc pinned — skipping signature check (bootstrap)."
|
||||
fi
|
||||
|
||||
mkdir -p data
|
||||
git rev-parse HEAD > data/update-completed
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue