mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-20 11:45:44 +00:00
965 lines
50 KiB
Bash
Executable file
965 lines
50 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Interactive setup for Telemax (v0.4 — headless, no web panel).
|
||
#
|
||
# First run: generates the session-encryption key automatically, asks only for the
|
||
# 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.
|
||
#
|
||
# Re-run on a configured install (.env exists): opens a settings menu instead —
|
||
# show/change the Telegram proxy (with a live connectivity test BEFORE saving),
|
||
# change the bot token / target group, switch the sticker image mode, re-check
|
||
# connectivity, rebuild. This is the sanctioned way to change settings after the
|
||
# web panel was removed in v0.4 — everything lives in .env, this menu edits it.
|
||
set -euo pipefail
|
||
cd "$(dirname "$0")"
|
||
|
||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||
|
||
# AUTO-UPDATE FOR EVERY BRIDGE ON THIS HOST (0.6.4).
|
||
# The timer used to run one install's update-watcher.sh directly (ExecStart=$REPO_DIR/...), so the
|
||
# unit — global, fixed name — belonged to whichever install ran setup.sh last. A second bridge
|
||
# silently took auto-updates away from the first, and a routine trip into the settings menu did it
|
||
# too. Now the timer runs a DISPATCHER that walks a registry of install directories: nothing to
|
||
# steal, and adding a bridge is one line in a file.
|
||
#
|
||
# Migration matters here: an existing host has the old unit pointing at install #1. We read that
|
||
# WorkingDirectory and register it too, so converting to the dispatcher never orphans whoever the
|
||
# timer used to serve.
|
||
#
|
||
# `-d /run/systemd/system` is the real test for "systemd is running": the binary exists inside LXC
|
||
# containers and WSL too, where it exits 1 — and with no `|| true` that killed setup.sh outright,
|
||
# before .env, before the build, without printing a thing.
|
||
TELEMAX_REGISTRY=/etc/telemax/instances
|
||
TELEMAX_DISPATCHER=/usr/local/sbin/telemax-updater
|
||
if [ "$(id -u)" = "0" ] && command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||
REPO_DIR="$(pwd)"
|
||
WATCHER_UNIT=/etc/systemd/system/telemax-updater.service
|
||
|
||
# Whoever the old unit served must not lose auto-updates when we switch to the dispatcher.
|
||
# `sed` on a missing file exits 2, `pipefail` lifts that out of the pipe and the assignment kills
|
||
# the script under `set -e` — on a CLEAN install the unit doesn't exist yet, so read it only when
|
||
# it's there (this exact shape silently killed setup.sh once already).
|
||
LEGACY_DIR=""
|
||
if [ -f "$WATCHER_UNIT" ]; then
|
||
LEGACY_DIR=$(sed -n 's/^WorkingDirectory=//p' "$WATCHER_UNIT" | head -1) || LEGACY_DIR=""
|
||
fi
|
||
|
||
mkdir -p "$(dirname "$TELEMAX_REGISTRY")"
|
||
touch "$TELEMAX_REGISTRY"
|
||
# Register a directory once, and only if it really holds a bridge.
|
||
register_instance() {
|
||
local d="$1"
|
||
[ -n "$d" ] || return 0
|
||
[ -f "$d/update-watcher.sh" ] || return 0
|
||
grep -qxF "$d" "$TELEMAX_REGISTRY" 2>/dev/null || printf '%s\n' "$d" >> "$TELEMAX_REGISTRY"
|
||
# The dispatcher runs each install's git commands as root from a stripped systemd environment,
|
||
# where git's "dubious ownership" check would otherwise reject the repo.
|
||
git config --system --get-all safe.directory 2>/dev/null | grep -qxF "$d" \
|
||
|| git config --system --add safe.directory "$d" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
register_instance "$REPO_DIR"
|
||
[ "$LEGACY_DIR" != "$REPO_DIR" ] && register_instance "$LEGACY_DIR"
|
||
# Drop entries whose directory is gone, so a removed bridge stops being walked.
|
||
if [ -s "$TELEMAX_REGISTRY" ]; then
|
||
while IFS= read -r _d; do
|
||
[ -n "$_d" ] && [ -f "$_d/update-watcher.sh" ] && printf '%s\n' "$_d"
|
||
done < "$TELEMAX_REGISTRY" | sort -u > "$TELEMAX_REGISTRY.tmp" || true
|
||
mv "$TELEMAX_REGISTRY.tmp" "$TELEMAX_REGISTRY"
|
||
fi
|
||
|
||
mkdir -p "$(dirname "$TELEMAX_DISPATCHER")"
|
||
cat > "$TELEMAX_DISPATCHER" <<'DISPATCHER'
|
||
#!/usr/bin/env bash
|
||
# Telemax update dispatcher — ONE systemd timer serves EVERY bridge on this host.
|
||
# Written by setup.sh; the list of installs lives in /etc/telemax/instances (one absolute path per
|
||
# line). Each install's own update-watcher.sh does the real work (marker check + flock + update.sh),
|
||
# so this only decides WHO gets a turn. Deliberately forgiving: one broken install must not stop the
|
||
# others from updating.
|
||
set -uo pipefail
|
||
REG=/etc/telemax/instances
|
||
[ -f "$REG" ] || exit 0
|
||
while IFS= read -r dir; do
|
||
[ -n "$dir" ] || continue
|
||
case "$dir" in \#*) continue ;; esac
|
||
[ -f "$dir/update-watcher.sh" ] || continue
|
||
# `bash <script>`, not `./script`: git checkouts made on Windows lose the exec bit.
|
||
( cd "$dir" && bash ./update-watcher.sh ) || true
|
||
done < "$REG"
|
||
exit 0
|
||
DISPATCHER
|
||
chmod 755 "$TELEMAX_DISPATCHER"
|
||
|
||
cat > "$WATCHER_UNIT" <<EOF
|
||
[Unit]
|
||
Description=Telemax update dispatcher (one-shot, serves every installed bridge)
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=$TELEMAX_DISPATCHER
|
||
EOF
|
||
cat > /etc/systemd/system/telemax-updater.timer <<'EOF'
|
||
[Unit]
|
||
Description=Run the Telemax update dispatcher every minute
|
||
|
||
[Timer]
|
||
OnBootSec=30s
|
||
OnUnitActiveSec=60s
|
||
Unit=telemax-updater.service
|
||
|
||
[Install]
|
||
WantedBy=timers.target
|
||
EOF
|
||
systemctl daemon-reload || true
|
||
systemctl enable --now telemax-updater.timer >/dev/null 2>&1 || true
|
||
# Seed the heartbeat so the bot doesn't claim "автообновление не настроено" during the first
|
||
# minute, before the timer has had a chance to tick.
|
||
mkdir -p "$REPO_DIR/data" && date -u +%Y-%m-%dT%H:%M:%SZ > "$REPO_DIR/data/watcher-heartbeat" || true
|
||
# Report what actually happened, not what we attempted.
|
||
if systemctl is-enabled telemax-updater.timer >/dev/null 2>&1; then
|
||
echo "Автообновление (telemax-updater) включено и обслуживает мосты:"
|
||
sed 's/^/ • /' "$TELEMAX_REGISTRY" 2>/dev/null || true
|
||
else
|
||
echo "⚠️ Автообновление включить не удалось — обновляйтесь вручную: ./update.sh"
|
||
fi
|
||
else
|
||
echo "Пропускаю установку вотчера обновлений — нужны root и работающий systemd. Обновляться придётся вручную: ./update.sh"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared helpers — used by BOTH the first-run flow and the settings menu below.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Docker Compose derives the project name — and with it the container, image and network names —
|
||
# from the directory basename: lowercased, with every character outside [a-z0-9_-] dropped (verified
|
||
# 2026-09-17: Telemax -> telemax, telemax.2 -> telemax2, telemax-2 -> telemax-2). Reproducing that
|
||
# rule exactly is what makes writing COMPOSE_PROJECT_NAME into .env a NO-OP for every existing
|
||
# install: the name stays byte-identical, it just stops silently depending on the directory's name.
|
||
compose_name_from_dir() {
|
||
local n
|
||
n=$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')
|
||
[ -n "$n" ] || n=telemax
|
||
printf '%s' "$n"
|
||
}
|
||
|
||
# A name already used by a DIFFERENT install on this host means the two would share one container,
|
||
# image and network (two bridges in same-named directories, e.g. /opt/telemax and /root/telemax —
|
||
# the most natural choice for a second one). Step aside with a suffix instead.
|
||
unique_compose_name() {
|
||
local base n i dir
|
||
base=$(compose_name_from_dir); n="$base"; i=2
|
||
command -v docker >/dev/null 2>&1 || { printf '%s' "$n"; return 0; }
|
||
while [ "$i" -le 20 ]; do
|
||
dir=$(docker ps -a --filter "label=com.docker.compose.project=$n" --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | head -1)
|
||
if [ -z "$dir" ] || [ "$dir" = "$(pwd)" ]; then break; fi
|
||
n="${base}-${i}"; i=$((i + 1))
|
||
done
|
||
printf '%s' "$n"
|
||
}
|
||
|
||
# First 6 + last 4 chars of the bot token — enough to recognize it, useless to steal.
|
||
mask_token() {
|
||
local t="$1"
|
||
if [ "${#t}" -le 12 ]; then printf '***'; else printf '%s…%s' "${t:0:6}" "${t: -4}"; fi
|
||
}
|
||
|
||
# Docker Compose's env_file parser is NOT a shell. Simple single quotes it understands (verified
|
||
# 2026-09-17: `$`, a space, `&` and `#` all survive), but the shell idiom for an embedded apostrophe
|
||
# ('\'') makes it reject the WHOLE file — "unexpected character ' in variable name" — and the
|
||
# container then starts with no environment at all. No quoting form carries an apostrophe past both
|
||
# bash and Compose, so refuse it at the door instead of writing a file that breaks everything.
|
||
# Proxy URLs carry it percent-encoded natively, which is the normal way to write such a password.
|
||
has_apostrophe() {
|
||
case "$1" in
|
||
*"'"*)
|
||
echo " ❌ Апостроф ( ' ) в строке прокси не поддерживается — Docker не сможет прочитать настройки."
|
||
echo " Закодируйте его в пароле как %27, например: socks5://user:pa%27ss@host:1080"
|
||
return 0
|
||
;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
# Proxy URL with any user:pass@ stripped — safe to print (mirrors src/telegram/proxy.ts).
|
||
redact_proxy() {
|
||
printf '%s' "$1" | sed -E 's#//[^@/]+@#//#'
|
||
}
|
||
|
||
# Fails fast on a server whose network can't reach one of the two services this
|
||
# bridge depends on (firewall, geo-blocking, restrictive hosting policy). Retries a
|
||
# few times first: a single attempt right after boot can spuriously fail on a
|
||
# transient blip (DNS not warmed up yet, a flaky first packet) even though the
|
||
# server is perfectly reachable a couple seconds later — confirmed live.
|
||
check_tcp() {
|
||
for attempt in 1 2 3; do
|
||
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/$1/$2" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
[ "$attempt" -lt 3 ] && sleep 2
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# curl args for the current $TELEGRAM_PROXY (empty proxy -> direct).
|
||
build_tg_proxy_args() {
|
||
TG_PROXY_ARGS=()
|
||
# NOT `[ -n ... ] && TG_PROXY_ARGS=(...)`: with no proxy that list evaluates to 1, and as the
|
||
# function's last statement it became the function's exit status — under `set -e` every first-time
|
||
# setup WITHOUT a proxy silently died right after the "Сделали? Enter" prompt (the client's "Enter
|
||
# нажимаю и ничего", shipped since 0.4.9; caught 2026-09-11 by an end-to-end fresh-install run).
|
||
if [ -n "${TELEGRAM_PROXY:-}" ]; then
|
||
TG_PROXY_ARGS=(--proxy "$TELEGRAM_PROXY")
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# One Telegram reachability check through the CURRENT $TELEGRAM_PROXY. Prints the
|
||
# verdict; returns 0/1. MAX is checked separately (always direct, by hostname so an
|
||
# IPv6-only host resolves via DNS64/NAT64 — MAX itself is IPv4-only).
|
||
check_telegram_once() {
|
||
if [ -n "${TELEGRAM_PROXY:-}" ]; then
|
||
if curl -sS --proxy "$TELEGRAM_PROXY" --max-time 8 -o /dev/null https://api.telegram.org 2>/dev/null; then
|
||
echo " Telegram (через прокси $(redact_proxy "$TELEGRAM_PROXY")): OK"
|
||
return 0
|
||
fi
|
||
echo " Telegram через прокси недоступен — возможно, неверный адрес/логин/пароль прокси."
|
||
return 1
|
||
fi
|
||
if check_tcp api.telegram.org 443; then
|
||
echo " Telegram (api.telegram.org:443): OK"
|
||
return 0
|
||
fi
|
||
echo " Telegram напрямую недоступен (частая причина на хостингах в РФ — блокировка; помогает прокси)."
|
||
return 1
|
||
}
|
||
|
||
# Bot API call through the current $TELEGRAM_PROXY; prints the JSON body (empty on network failure).
|
||
tg_api() {
|
||
build_tg_proxy_args
|
||
curl -s "${TG_PROXY_ARGS[@]}" --max-time 15 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/$1" || true
|
||
}
|
||
|
||
# Token: format first (catches paste errors), then getMe — proves it's a real, live token AND shows
|
||
# which bot it belongs to (a token from the wrong bot passes every format check). Before this, a bad
|
||
# token only surfaced later as a puzzling "group not found".
|
||
verify_bot_token() {
|
||
echo " Проверяю токен:"
|
||
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]{6,12}:[A-Za-z0-9_-]{30,50}$ ]]; then
|
||
echo " ❌ Не похоже на токен бота — ожидается вид 123456789:AAAA…"
|
||
echo " → скопируйте его ЦЕЛИКОМ из @BotFather, без пробелов и переносов"
|
||
return 1
|
||
fi
|
||
echo " ✅ Формат токена верный"
|
||
command -v jq >/dev/null 2>&1 || { echo " ⚠️ jq не найден — дальше проверить не могу, продолжаю"; return 0; }
|
||
local me user desc
|
||
me=$(tg_api getMe)
|
||
user=$(echo "$me" | jq -r 'select(.ok == true) | .result.username // empty' 2>/dev/null)
|
||
if [ -z "$user" ]; then
|
||
desc=$(echo "$me" | jq -r '.description // empty' 2>/dev/null)
|
||
echo " ❌ Telegram не принял токен${desc:+: $desc}"
|
||
echo " → возьмите актуальный в @BotFather: /mybots → ваш бот → API Token"
|
||
return 1
|
||
fi
|
||
echo " ✅ Telegram принял токен: это бот @${user}"
|
||
return 0
|
||
}
|
||
|
||
# Group: must be reachable, a supergroup with Topics enabled, and the bot an admin holding
|
||
# "Manage Topics" — each missing piece is named explicitly (people used to hit a bare
|
||
# "can't create topics" much later, after the container was already up).
|
||
verify_group() {
|
||
command -v jq >/dev/null 2>&1 || return 0
|
||
local chat title type forum bot_id member status can ok=0
|
||
chat=$(tg_api "getChat?chat_id=$1")
|
||
if [ "$(echo "$chat" | jq -r '.ok' 2>/dev/null)" != "true" ]; then
|
||
echo " Группа $1:"
|
||
echo " ❌ Бот её не видит: $(echo "$chat" | jq -r '.description // "нет ответа"' 2>/dev/null)"
|
||
echo " → проверьте, что бот ДОБАВЛЕН именно в эту группу и id указан верно"
|
||
return 1
|
||
fi
|
||
title=$(echo "$chat" | jq -r '.result.title // "без названия"')
|
||
type=$(echo "$chat" | jq -r '.result.type')
|
||
forum=$(echo "$chat" | jq -r '.result.is_forum // false')
|
||
# Печатаем КАЖДЫЙ пункт, а не только провалившийся: иначе видно лишь первую проблему и неясно,
|
||
# что уже в порядке, а что ещё не проверялось. Каждый ❌ — со строкой «что нажать».
|
||
echo " Группа: «$title» ($1)"
|
||
if [ "$type" = "supergroup" ]; then
|
||
echo " ✅ Тип: супергруппа"
|
||
else
|
||
echo " ❌ Тип: $type — нужна супергруппа"
|
||
echo " → настройки группы → Темы → включить (группа сама станет супергруппой)"
|
||
ok=1
|
||
fi
|
||
if [ "$forum" = "true" ]; then
|
||
echo " ✅ Темы включены"
|
||
else
|
||
echo " ❌ Темы ВЫКЛЮЧЕНЫ — без них мост не сможет завести тему под каждый чат MAX"
|
||
echo " → настройки группы → Темы → включить"
|
||
ok=1
|
||
fi
|
||
bot_id="${TELEGRAM_BOT_TOKEN%%:*}"
|
||
member=$(tg_api "getChatMember?chat_id=$1&user_id=$bot_id")
|
||
status=$(echo "$member" | jq -r '.result.status // "unknown"')
|
||
can=$(echo "$member" | jq -r '.result.can_manage_topics // false')
|
||
if [ "$status" = "administrator" ] || [ "$status" = "creator" ]; then
|
||
echo " ✅ Бот — администратор группы"
|
||
else
|
||
echo " ❌ Бот НЕ администратор (статус: $status)"
|
||
echo " → настройки группы → Администраторы → добавить бота"
|
||
ok=1
|
||
fi
|
||
if [ "$can" = "true" ]; then
|
||
echo " ✅ Право «Управление темами» выдано"
|
||
else
|
||
echo " ❌ Право «Управление темами» — ВЫКЛЮЧЕНО"
|
||
echo " → Администраторы → ваш бот → включить «Управление темами»"
|
||
echo " (в базовый набор прав администратора оно НЕ входит — на этом спотыкаются чаще всего)"
|
||
ok=1
|
||
fi
|
||
if [ "$ok" -eq 0 ]; then
|
||
echo " Всё готово."
|
||
else
|
||
echo " Исправьте отмеченное ❌ — проверю снова."
|
||
fi
|
||
return $ok
|
||
}
|
||
|
||
# Interactive loop: keeps re-asking for a proxy until Telegram is reachable through
|
||
# the current setting (or the user types skip). Works off/into $TELEGRAM_PROXY —
|
||
# the caller decides what to do with the verified value. Telegram is NOT fatal here:
|
||
# the usual cause is a mistyped proxy — fixable right in the loop, with an explicit
|
||
# "skip" escape so a genuinely blocked host isn't a dead end.
|
||
verify_telegram_proxy_loop() {
|
||
while true; do
|
||
check_telegram_once && return 0
|
||
echo " • впишите прокси (socks5://… или http://…) и Enter — перепроверю через него;"
|
||
echo " • пустой Enter — перепроверить напрямую, без прокси;"
|
||
echo " • skip — продолжить без проверки (значение сохранится как есть)."
|
||
read -rp " > " TG_INPUT
|
||
if has_apostrophe "$TG_INPUT"; then continue; fi
|
||
if [ "$TG_INPUT" = "skip" ]; then
|
||
echo " Пропускаю проверку Telegram. Без связи с Telegram пересылки не будет —"
|
||
echo " прокси можно поменять позже, снова запустив ./setup.sh."
|
||
return 0
|
||
fi
|
||
TELEGRAM_PROXY="$TG_INPUT"
|
||
done
|
||
}
|
||
|
||
# Detects the target Telegram group via getUpdates (needs $TELEGRAM_BOT_TOKEN and jq;
|
||
# honors $TELEGRAM_PROXY). Sets $TARGET_TELEGRAM_GROUP. Loops until found/picked.
|
||
detect_group() {
|
||
if ! command -v jq >/dev/null 2>&1; then
|
||
echo "❌ Не найден jq — без него не могу определить группу. Установите: apt-get install -y jq — и запустите setup.sh заново."
|
||
exit 1
|
||
fi
|
||
build_tg_proxy_args
|
||
# A webhook left on this bot (tried elsewhere once) makes getUpdates refuse outright. Dropping it is
|
||
# harmless when there is none — this bridge only ever long-polls.
|
||
tg_api deleteWebhook >/dev/null 2>&1 || true
|
||
TARGET_TELEGRAM_GROUP=""
|
||
while [ -z "$TARGET_TELEGRAM_GROUP" ]; do
|
||
echo "Ищу группу..."
|
||
TG_GROUPS=""
|
||
for attempt in 1 2 3 4 5 6; do
|
||
# timeout=3 (a short long-poll) does two things: a message written right now arrives at once,
|
||
# and a COMPETING bot instance reveals itself — Telegram serves the newest getUpdates and
|
||
# terminates the older one with 409; a competitor re-polls immediately, so OUR pending call is
|
||
# the one that gets 409. A timeout=0 call returns before that race and never sees it (and the
|
||
# competitor quietly consumes every update, so we'd see an empty result forever).
|
||
UPDATES=$(curl -s --max-time 15 "${TG_PROXY_ARGS[@]}" "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=100&timeout=3" || true)
|
||
# An API error used to be swallowed here and looked exactly like "no group yet" — an endless
|
||
# "не вижу группу" no matter what the user did (reported live 2026-09-11). Name it instead:
|
||
# 409 = ANOTHER instance of this bot is long-polling somewhere (old server/container with the
|
||
# same token) — the one cause "write in the group" can never fix; 401 = the token.
|
||
TG_ERR=$(echo "$UPDATES" | jq -r 'select(.ok != true) | "\(.error_code // "?"): \(.description // "нет ответа")"' 2>/dev/null || true)
|
||
if [ -n "$TG_ERR" ]; then
|
||
echo " ❌ Telegram: $TG_ERR"
|
||
case "$TG_ERR" in
|
||
409*) echo " У этого бота уже запущен ДРУГОЙ экземпляр (старый сервер или контейнер с тем же токеном) — остановите его, иначе группу не найти." ;;
|
||
401*) echo " Токен не принят — проверьте его в @BotFather (/mybots → API Token)." ;;
|
||
esac
|
||
break
|
||
fi
|
||
# Собираем группы, которые бот «видел» — из сообщений И из события my_chat_member
|
||
# (бота добавили/сделали админом). Второе приходит на обязательном шаге «сделать
|
||
# админом», не зависит от того, отправит ли пользователь сообщение и попадёт ли в окно.
|
||
TG_GROUPS=$(echo "$UPDATES" | jq -r '
|
||
[ .result[]
|
||
| ( (.message // .channel_post // .my_chat_member // empty) | .chat )
|
||
| select(.type == "supergroup" or .type == "group")
|
||
| {id, title: (.title // "без названия")} ]
|
||
| unique_by(.id) | .[] | "\(.id)\t\(.title)"' 2>/dev/null || true)
|
||
[ -n "$TG_GROUPS" ] && break
|
||
echo " Пока не вижу бота в группе, жду 3с ($attempt/6)..."
|
||
sleep 3
|
||
done
|
||
|
||
if [ -z "$TG_GROUPS" ]; then
|
||
echo
|
||
echo "Пока не вижу группу. Чаще всего помогает одно:"
|
||
echo " ✍️ НАПИШИТЕ В ГРУППУ ЛЮБОЕ СООБЩЕНИЕ — и я её сразу замечу."
|
||
echo " (бот «видит» группу по свежему сообщению; если его добавили давно, событие о добавлении"
|
||
echo " могло не попасть в окно обновлений — сообщение это чинит.)"
|
||
echo "Заодно проверьте, что бот ДОБАВЛЕН в нужную группу и он АДМИНИСТРАТОР с правом"
|
||
echo "«Управление темами» (Manage Topics) — без прав администратора бот ваших сообщений НЕ ВИДИТ,"
|
||
echo "и «напишите в группу» не поможет."
|
||
read -rp "Сделайте это и нажмите Enter — или введите id группы вручную (вида -100…): " MANUAL_GROUP
|
||
if [[ "${MANUAL_GROUP:-}" =~ ^-?[0-9]{5,}$ ]]; then
|
||
TARGET_TELEGRAM_GROUP="$MANUAL_GROUP"
|
||
verify_group "$TARGET_TELEGRAM_GROUP" || { TARGET_TELEGRAM_GROUP=""; read -rp "Исправьте и нажмите Enter, чтобы проверить снова... "; }
|
||
fi
|
||
continue
|
||
fi
|
||
|
||
mapfile -t GROUP_LINES <<< "$TG_GROUPS"
|
||
if [ "${#GROUP_LINES[@]}" -eq 1 ]; then
|
||
TARGET_TELEGRAM_GROUP="${GROUP_LINES[0]%%$'\t'*}"
|
||
echo "Нашёл группу: «${GROUP_LINES[0]#*$'\t'}» ($TARGET_TELEGRAM_GROUP)"
|
||
verify_group "$TARGET_TELEGRAM_GROUP" || { TARGET_TELEGRAM_GROUP=""; read -rp "Исправьте права/темы и нажмите Enter, чтобы проверить снова... "; }
|
||
else
|
||
echo
|
||
echo "Бот состоит в нескольких группах — выберите целевую:"
|
||
for i in "${!GROUP_LINES[@]}"; do
|
||
printf " %d) %s (%s)\n" "$((i + 1))" "${GROUP_LINES[$i]#*$'\t'}" "${GROUP_LINES[$i]%%$'\t'*}"
|
||
done
|
||
while [ -z "$TARGET_TELEGRAM_GROUP" ]; do
|
||
read -rp "Номер: " choice
|
||
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#GROUP_LINES[@]}" ]; then
|
||
TARGET_TELEGRAM_GROUP="${GROUP_LINES[$((choice - 1))]%%$'\t'*}"
|
||
echo "Выбрана: «${GROUP_LINES[$((choice - 1))]#*$'\t'}» ($TARGET_TELEGRAM_GROUP)"
|
||
verify_group "$TARGET_TELEGRAM_GROUP" || { TARGET_TELEGRAM_GROUP=""; echo " Исправьте и выберите группу снова."; }
|
||
else
|
||
echo " Нет такого номера, попробуйте ещё раз."
|
||
fi
|
||
done
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Rewrites (or appends) NAME=value in .env, preserving every other line as-is.
|
||
# awk instead of sed so proxy URLs with #, @, / etc. can't break the substitution.
|
||
# Values go into .env SINGLE-QUOTED (with any ' escaped as '\''). Two independent reasons, both hit
|
||
# live: (1) bash `source .env` treats `$`, a space or a backtick in a bare value as code — under
|
||
# `set -u` that aborted setup.sh and update.sh outright, silently and permanently; (2) Docker Compose
|
||
# INTERPOLATES `$...` inside env_file, so a proxy password containing `$` reached the container
|
||
# MANGLED (verified 2026-09-17: `pa$w0rd` arrived as `pa`) and Telegram just never connected, with
|
||
# nothing anywhere saying why. Compose strips the surrounding single quotes and leaves `$` alone.
|
||
env_quote() {
|
||
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
|
||
}
|
||
|
||
set_env_var() {
|
||
local name="$1" value="$2"
|
||
value=$(env_quote "$value")
|
||
if grep -q "^${name}=" .env 2>/dev/null; then
|
||
# umask 077: .env.tmp carries every secret and root's default 022 made it world-readable 0644
|
||
# until the chmod below (the reconfigure menu runs before the script-wide umask).
|
||
(umask 077; awk -v n="$name" -v v="$value" 'index($0, n"=") == 1 { print n"=" v; next } { print }' .env > .env.tmp)
|
||
mv .env.tmp .env
|
||
else
|
||
printf '%s=%s\n' "$name" "$value" >> .env
|
||
fi
|
||
chmod 600 .env
|
||
}
|
||
|
||
compose_up() {
|
||
# The container runs as the unprivileged node user (uid 1000) — the mounted ./data
|
||
# volume must be writable by it (root-owned dirs from older installs aren't).
|
||
mkdir -p data
|
||
[ "$(id -u)" = "0" ] && chown -R 1000:1000 data 2>/dev/null || true
|
||
GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown) docker compose up -d "$@"
|
||
}
|
||
|
||
# Recreate the container so an .env change actually takes effect (docker only reads
|
||
# env_file at container creation; a plain restart keeps the old values).
|
||
apply_env_change() {
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
echo "Docker недоступен — изменения сохранены в .env, примените их там, где запущен контейнер."
|
||
return 0
|
||
fi
|
||
read -rp "Пересоздать контейнер, чтобы применить изменения? [Y/n] " APPLY_NOW
|
||
if [ "${APPLY_NOW:-Y}" = "n" ] || [ "${APPLY_NOW:-Y}" = "N" ]; then
|
||
echo "Ок. Применится при следующем: docker compose up -d --force-recreate"
|
||
return 0
|
||
fi
|
||
compose_up --force-recreate
|
||
echo "✅ Применено."
|
||
}
|
||
|
||
# Big files go by a link to THIS server, so its ports 80 and 443 must open from the internet. A home
|
||
# server behind a router has no white IP of its own, and ports held by another web server can't be
|
||
# shared without setup — both used to surface only on the first big file, as a failed certificate
|
||
# (a real install, 2026-10-11). Checked here instead. Sets FILES_CHECK to «nat:<address>», «busy» or «ok».
|
||
is_private_ipv4() {
|
||
case "$1" in
|
||
10.* | 127.* | 192.168.* | 169.254.* | 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 0 ;;
|
||
100.6[4-9].* | 100.[7-9][0-9].* | 100.1[01][0-9].* | 100.12[0-7].*) return 0 ;; # carrier-grade NAT
|
||
esac
|
||
return 1
|
||
}
|
||
files_check() {
|
||
local src
|
||
src=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<NF;i++) if ($i=="src") print $(i+1)}' | head -1) || src=""
|
||
FILES_CHECK=ok
|
||
if [ -n "$src" ] && is_private_ipv4 "$src"; then
|
||
FILES_CHECK="nat:$src"
|
||
return 0
|
||
fi
|
||
# Busy — unless it's our own shared files service (another bridge on this server already runs it).
|
||
if ss -ltnH '( sport = :80 or sport = :443 )' 2>/dev/null | grep -q . \
|
||
&& ! docker ps -q --filter "label=com.docker.compose.project=telemax-files" 2>/dev/null | grep -q .; then
|
||
FILES_CHECK=busy
|
||
fi
|
||
}
|
||
# Explains FILES_CHECK in plain words (nothing to say for «ok»).
|
||
files_explain() {
|
||
echo "Большие файлы (из Telegram больше 20 МБ, из MAX больше 50 МБ) мост передаёт по ссылке на этот сервер —"
|
||
echo "для этого его порты 80 и 443 должны открываться из интернета."
|
||
case "$FILES_CHECK" in
|
||
nat:*)
|
||
echo "⚠️ У сервера нет своего белого IP: он стоит за роутером (его адрес ${FILES_CHECK#nat:})."
|
||
echo " Ссылки заработают, только если у роутера белый IP и порты 80 и 443 проброшены на этот сервер."
|
||
echo " С серым IP от провайдера проброс не поможет — нужен белый IP у провайдера или VPS."
|
||
;;
|
||
busy)
|
||
echo "⚠️ Порты 80 и 443 уже заняты другим веб-сервером. Файлы можно пустить через него —"
|
||
echo " FILES_PUBLIC_URL и FILES_LISTEN в .env, пример в README (раздел «Большие файлы»)."
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Settings menu — .env already exists, so this is a configured install.
|
||
# ---------------------------------------------------------------------------
|
||
if [ -f .env ]; then
|
||
# Old installs (pre-0.3) left .env world-readable — tighten on every run.
|
||
chmod 600 .env
|
||
|
||
# Same parser update.sh uses: `source .env` executes the file as shell, so a legacy unquoted value
|
||
# containing `$`, a space or a backtick aborted the settings menu under `set -u` — on exactly the
|
||
# installs that need the menu to FIX that value. Reads both the new quoted form and the old bare one.
|
||
env_read() {
|
||
local line
|
||
line=$(grep -m1 "^$1=" .env 2>/dev/null) || return 0
|
||
line=${line#*=}
|
||
case "$line" in
|
||
"'"*"'") line=${line#\'}; line=${line%\'} ;;
|
||
'"'*'"') line=${line#\"}; line=${line%\"} ;;
|
||
esac
|
||
printf '%s' "$line"
|
||
}
|
||
|
||
show_status() {
|
||
# Re-read on every call so the menu always shows what's actually in .env.
|
||
TELEGRAM_BOT_TOKEN=$(env_read TELEGRAM_BOT_TOKEN)
|
||
TARGET_TELEGRAM_GROUP=$(env_read TARGET_TELEGRAM_GROUP)
|
||
TELEGRAM_PROXY=$(env_read TELEGRAM_PROXY)
|
||
STICKERS=$(env_read STICKERS)
|
||
MAX_SESSION_KEY=$(env_read MAX_SESSION_KEY)
|
||
local container="не запущен"
|
||
if command -v docker >/dev/null 2>&1 && [ -n "$(docker compose ps --status running --format '{{.Name}}' 2>/dev/null)" ]; then
|
||
container="работает"
|
||
fi
|
||
echo
|
||
bold "=== Telemax — текущие настройки ==="
|
||
echo " Контейнер: $container"
|
||
echo " Токен бота: $(mask_token "${TELEGRAM_BOT_TOKEN:-}")"
|
||
echo " Группа Telegram: ${TARGET_TELEGRAM_GROUP:-не задана}"
|
||
if [ -n "${TELEGRAM_PROXY:-}" ]; then
|
||
echo " Прокси Telegram: $(redact_proxy "$TELEGRAM_PROXY")"
|
||
else
|
||
echo " Прокси Telegram: нет (напрямую)"
|
||
fi
|
||
echo " Стикеры (образ): ${STICKERS:-full}"
|
||
if files_off; then
|
||
echo " Большие файлы: выключены (FILES=off)"
|
||
else
|
||
echo " Большие файлы: включены — по ссылке на этот сервер"
|
||
fi
|
||
echo " MAX-авторизация: через бота — /login в личку (статус виден в /panel)"
|
||
}
|
||
|
||
files_off() {
|
||
case "$(env_read FILES | tr '[:upper:]' '[:lower:]')" in 0 | false | off | no) return 0 ;; esac
|
||
return 1
|
||
}
|
||
|
||
toggle_files() {
|
||
echo
|
||
if files_off; then
|
||
echo "Сейчас пересылка больших файлов выключена (FILES=off)."
|
||
files_check
|
||
files_explain
|
||
[ "$FILES_CHECK" = ok ] && echo "✅ Проверка: у сервера свой белый IP, порты 80 и 443 свободны."
|
||
read -rp "Включить пересылку больших файлов? [y/N] " FA
|
||
if [ "${FA:-N}" != "y" ] && [ "${FA:-N}" != "Y" ]; then
|
||
echo "Оставляю выключенной."
|
||
return 0
|
||
fi
|
||
set_env_var FILES on
|
||
echo "Сохранено: большие файлы включены. Если что-то помешает, бот прямо скажет, что именно."
|
||
else
|
||
echo "Сейчас пересылка больших файлов включена."
|
||
read -rp "Выключить (FILES=off)? Файлы больше лимитов Telegram тогда открывают в MAX. [y/N] " FA
|
||
if [ "${FA:-N}" != "y" ] && [ "${FA:-N}" != "Y" ]; then
|
||
echo "Оставляю включённой."
|
||
return 0
|
||
fi
|
||
set_env_var FILES off
|
||
echo "Сохранено: большие файлы выключены."
|
||
fi
|
||
apply_env_change
|
||
}
|
||
|
||
change_proxy() {
|
||
echo
|
||
if [ -n "${TELEGRAM_PROXY:-}" ]; then
|
||
echo "Текущий прокси: $(redact_proxy "$TELEGRAM_PROXY")"
|
||
else
|
||
echo "Сейчас прокси не задан — Telegram идёт напрямую."
|
||
fi
|
||
echo "Форматы: socks5://[логин:пароль@]хост:порт или http://[логин:пароль@]хост:порт."
|
||
echo "Введите новый адрес; «-» — убрать прокси (напрямую); пустой Enter — оставить как есть."
|
||
read -rp "Прокси: " NEW_PROXY
|
||
if has_apostrophe "$NEW_PROXY"; then echo " Настройка не изменена."; return 0; fi
|
||
case "$NEW_PROXY" in
|
||
'') echo "Оставляю как есть."; return 0 ;;
|
||
-) TELEGRAM_PROXY="" ;;
|
||
*) TELEGRAM_PROXY="$NEW_PROXY" ;;
|
||
esac
|
||
echo "Проверяю связь с Telegram через новую настройку..."
|
||
verify_telegram_proxy_loop
|
||
set_env_var TELEGRAM_PROXY "$TELEGRAM_PROXY"
|
||
echo "Сохранено в .env: TELEGRAM_PROXY=$( [ -n "$TELEGRAM_PROXY" ] && redact_proxy "$TELEGRAM_PROXY" || echo '(напрямую)' )"
|
||
apply_env_change
|
||
}
|
||
|
||
change_bot() {
|
||
echo
|
||
echo "Текущий токен: $(mask_token "${TELEGRAM_BOT_TOKEN:-}")"
|
||
read -rp "Новый токен бота (пустой Enter — оставить текущий): " NEW_TOKEN
|
||
local token_changed=0
|
||
if [ -n "$NEW_TOKEN" ]; then
|
||
TELEGRAM_BOT_TOKEN="$NEW_TOKEN"
|
||
token_changed=1
|
||
fi
|
||
echo "Текущая группа: ${TARGET_TELEGRAM_GROUP:-не задана}"
|
||
local redetect="n"
|
||
if [ "$token_changed" = "1" ]; then
|
||
# A new bot almost certainly means the group binding needs re-checking too.
|
||
read -rp "Определить группу заново? [Y/n] " R
|
||
[ "${R:-Y}" != "n" ] && [ "${R:-Y}" != "N" ] && redetect="y"
|
||
else
|
||
read -rp "Определить группу заново? [y/N] " R
|
||
{ [ "${R:-N}" = "y" ] || [ "${R:-N}" = "Y" ]; } && redetect="y"
|
||
fi
|
||
if [ "$redetect" = "y" ]; then
|
||
echo "Добавьте бота в нужную группу администратором (право «Управление темами») и напишите в неё любое сообщение."
|
||
read -rp "Готово? Enter... "
|
||
detect_group
|
||
fi
|
||
[ "$token_changed" = "1" ] && set_env_var TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
|
||
set_env_var TARGET_TELEGRAM_GROUP "$TARGET_TELEGRAM_GROUP"
|
||
echo "Сохранено."
|
||
apply_env_change
|
||
}
|
||
|
||
change_stickers() {
|
||
echo
|
||
local current="${STICKERS:-full}"
|
||
echo "Сейчас: $current."
|
||
echo " • full — анимированные стикеры проигрываются как видео (образ +~1.4 ГБ, сборка дольше)."
|
||
echo " • slim — уходят статической картинкой (образ ~0.3 ГБ, быстрая сборка)."
|
||
local target="slim"
|
||
[ "$current" = "slim" ] && target="full"
|
||
read -rp "Переключить на $target и пересобрать образ? [y/N] " SW
|
||
if [ "${SW:-N}" != "y" ] && [ "${SW:-N}" != "Y" ]; then
|
||
echo "Оставляю $current."
|
||
return 0
|
||
fi
|
||
set_env_var STICKERS "$target"
|
||
if command -v docker >/dev/null 2>&1; then
|
||
echo "Пересобираю образ ($target)..."
|
||
compose_up --build
|
||
echo "✅ Готово."
|
||
else
|
||
echo "Docker недоступен — сохранено в .env, пересоберите там, где запущен контейнер."
|
||
fi
|
||
}
|
||
|
||
check_connectivity() {
|
||
echo
|
||
echo "Проверяю связь..."
|
||
if check_tcp api2.oneme.ru 443; then
|
||
echo " MAX (api2.oneme.ru:443): OK"
|
||
else
|
||
echo " MAX (api2.oneme.ru:443): нет связи — мост работать не сможет (файрвол/гео-блокировка;"
|
||
echo " на IPv6-only хосте нужен NAT64/DNS64 у провайдера)."
|
||
fi
|
||
check_telegram_once || true
|
||
}
|
||
|
||
# Make current .env values available to the menu handlers.
|
||
show_status
|
||
while true; do
|
||
echo
|
||
bold "Что сделать?"
|
||
echo " 1) Показать текущие настройки"
|
||
echo " 2) Изменить прокси Telegram (с проверкой связи до записи)"
|
||
echo " 3) Сменить токен бота / группу"
|
||
echo " 4) Переключить режим стикеров (full/slim, с пересборкой)"
|
||
echo " 5) Проверить связь с MAX и Telegram"
|
||
echo " 6) Пересобрать и перезапустить контейнер"
|
||
echo " 7) Большие файлы: включить или выключить (с проверкой белого IP и портов)"
|
||
echo " 0) Выход"
|
||
read -rp "Пункт: " MENU_CHOICE
|
||
case "$MENU_CHOICE" in
|
||
1) show_status ;;
|
||
2) change_proxy ;;
|
||
3) change_bot ;;
|
||
4) change_stickers ;;
|
||
5) check_connectivity ;;
|
||
7) toggle_files ;;
|
||
6)
|
||
if command -v docker >/dev/null 2>&1; then
|
||
compose_up --build
|
||
echo "✅ Пересобрано и запущено."
|
||
else
|
||
echo "Docker недоступен на этой машине."
|
||
fi
|
||
;;
|
||
0 | '')
|
||
echo "Если MAX ещё не авторизован — напишите боту в ЛИЧКУ: /login (или в группе: /panel → «🔐 Вход в MAX»)."
|
||
exit 0
|
||
;;
|
||
*) echo "Нет такого пункта." ;;
|
||
esac
|
||
done
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# First-time setup — no .env yet.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
if ! command -v openssl >/dev/null 2>&1; then
|
||
echo "Не найден openssl — он нужен для генерации ключа шифрования сессии. Установите (apt install openssl) и запустите скрипт снова."
|
||
exit 1
|
||
fi
|
||
|
||
bold "=== Telemax — первая настройка ==="
|
||
echo
|
||
|
||
# A leftover container from a previous attempt (e.g. .env was deleted to redo
|
||
# setup, but the container itself was never stopped) keeps long-polling
|
||
# Telegram with its own bot token — races the getUpdates call below and
|
||
# silently breaks group auto-detection. Confirmed live 2026-08-14.
|
||
if command -v docker >/dev/null 2>&1; then
|
||
# Scoped to THIS directory by label, not by compose's project scope: without an explicit project
|
||
# name Compose derives it from the directory basename, so a second bridge installed into a
|
||
# same-named directory saw the FIRST one's live container here and offered — with Enter as the
|
||
# default answer — to tear down a working bridge belonging to someone else's MAX account.
|
||
RUNNING=$(docker ps --filter "label=com.docker.compose.project.working_dir=$(pwd)" --format '{{.Names}}' 2>/dev/null | head -1 || true)
|
||
if [ -n "$RUNNING" ]; then
|
||
echo "⚠️ Уже запущен контейнер предыдущей установки: $RUNNING"
|
||
echo "Пока он работает, его бот перехватывает Telegram-обновления — автоопределение"
|
||
echo "группы ниже не найдёт сообщение."
|
||
read -rp "Остановить его сейчас? [Y/n] " STOP_OLD
|
||
if [ "${STOP_OLD:-Y}" != "n" ] && [ "${STOP_OLD:-Y}" != "N" ]; then
|
||
# NOT a bare `docker compose down`: there is no .env here (that's why we're in first-time
|
||
# setup), so Compose would fall back to the directory basename as the project name. An install
|
||
# whose project got a suffix from unique_compose_name (telemax-2) would then be missed — and
|
||
# the basename project it hit instead belongs, by construction, to ANOTHER, working bridge.
|
||
# Stop exactly the containers of THIS directory's own project (read off the container's label),
|
||
# both labels in the filter, and don't depend on Compose being able to load a project without
|
||
# its env_file.
|
||
OLD_PROJECT=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$RUNNING" 2>/dev/null || true)
|
||
OLD_IDS=$(docker ps -aq --filter "label=com.docker.compose.project=${OLD_PROJECT}" --filter "label=com.docker.compose.project.working_dir=$(pwd)" 2>/dev/null || true)
|
||
if [ -n "$OLD_PROJECT" ] && [ -n "$OLD_IDS" ]; then
|
||
# shellcheck disable=SC2086 # one container id per word
|
||
docker stop $OLD_IDS >/dev/null 2>&1 || true
|
||
# shellcheck disable=SC2086
|
||
docker rm $OLD_IDS >/dev/null 2>&1 || true
|
||
else
|
||
docker stop "$RUNNING" >/dev/null 2>&1 || true
|
||
fi
|
||
if [ -n "$(docker ps -q --filter "label=com.docker.compose.project.working_dir=$(pwd)" 2>/dev/null || true)" ]; then
|
||
echo "⚠️ Не удалось остановить контейнер $RUNNING — остановите вручную: docker stop $RUNNING"
|
||
else
|
||
echo "Остановлено."
|
||
fi
|
||
fi
|
||
echo
|
||
fi
|
||
fi
|
||
|
||
# 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
|
||
# stays direct. The value doubles as its own validation via the check below and is
|
||
# written to .env as TELEGRAM_PROXY (the container's Telegraf client picks it up).
|
||
echo
|
||
echo "Если этот сервер выходит в Telegram только через прокси (напр. домашний"
|
||
echo "сервер, где прямой доступ к api.telegram.org закрыт) — укажите его. MAX при"
|
||
echo "этом идёт напрямую. Форматы: socks5://[логин:пароль@]хост:порт или"
|
||
echo "http://[логин:пароль@]хост:порт. Ошибётесь — проверка ниже даст поправить."
|
||
read -rp "Прокси для Telegram (Enter — без прокси): " TELEGRAM_PROXY
|
||
while has_apostrophe "$TELEGRAM_PROXY"; do
|
||
read -rp "Прокси для Telegram (Enter — без прокси): " TELEGRAM_PROXY
|
||
done
|
||
|
||
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. 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 (api2.oneme.ru:443): нет связи"
|
||
echo
|
||
echo "Без связи с сервером MAX мост работать не сможет — обычно это файрвол хостинга"
|
||
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 in the loop (see verify_telegram_proxy_loop).
|
||
verify_telegram_proxy_loop
|
||
|
||
echo
|
||
echo "Понадобится токен бота — создайте его через @BotFather (https://t.me/BotFather), команда /newbot."
|
||
echo
|
||
|
||
read -rp "Токен бота (TELEGRAM_BOT_TOKEN): " TELEGRAM_BOT_TOKEN
|
||
until [ -n "$TELEGRAM_BOT_TOKEN" ] && verify_bot_token; do
|
||
read -rp "Токен бота (TELEGRAM_BOT_TOKEN): " TELEGRAM_BOT_TOKEN
|
||
done
|
||
|
||
echo
|
||
bold "Теперь группа:"
|
||
echo " 1. Создайте Telegram-группу (или возьмите существующую) и включите в ней Темы:"
|
||
echo " настройки группы → Темы → включить."
|
||
echo " 2. Добавьте бота в группу администратором — ОБЯЗАТЕЛЬНО включите ему право"
|
||
echo " «Управление темами» (Manage Topics), оно не входит в базовый набор прав."
|
||
echo " 3. id группы определю сам. Если несколько групп — дам выбрать."
|
||
echo " Не подхватится сразу — просто ✍️ напишите в группу любое сообщение, и я её замечу."
|
||
echo
|
||
|
||
read -rp "Сделали? Нажмите Enter, когда бот добавлен в группу администратором... "
|
||
detect_group
|
||
|
||
# Best-effort — needs the bot to already be a group admin with "Change Group
|
||
# Info" rights, which setup already asked for above. setChatPhoto needs an
|
||
# actual file upload (multipart), not a URL, unlike sendPhoto.
|
||
build_tg_proxy_args
|
||
AVATAR="$(dirname "$0")/assets/group-avatar.png"
|
||
if [ -f "$AVATAR" ]; then
|
||
if curl -s "${TG_PROXY_ARGS[@]}" -F "chat_id=$TARGET_TELEGRAM_GROUP" -F "photo=@$AVATAR" \
|
||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setChatPhoto" | grep -q '"ok":true'; then
|
||
echo "Аватарка группы установлена."
|
||
else
|
||
echo "Не удалось установить аватарку группы (не критично, можно поставить вручную)."
|
||
fi
|
||
fi
|
||
|
||
MAX_SESSION_KEY=$(openssl rand -hex 32)
|
||
|
||
# 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"
|
||
|
||
# Big files need this server reachable on 80/443 — when it plainly isn't (no white IP of its own, or
|
||
# the ports belong to another web server), ask before turning the feature on. Defaults to OFF there:
|
||
# otherwise the first big file would only fail with a certificate error. Everything else is unaffected.
|
||
FILES_SETTING=""
|
||
files_check
|
||
if [ "$FILES_CHECK" != ok ]; then
|
||
echo
|
||
files_explain
|
||
if read -t 30 -rp "Включить пересылку больших файлов? Впишите y, если порты уже настроены, или Enter (жду 30с) — выключить: " FILES_CHOICE \
|
||
&& { [ "${FILES_CHOICE:-}" = y ] || [ "${FILES_CHOICE:-}" = Y ]; }; then
|
||
echo " → большие файлы: включены"
|
||
else
|
||
FILES_SETTING=off
|
||
echo
|
||
echo " → большие файлы: выключены (FILES=off). Всё остальное работает как обычно, а файлы больше"
|
||
echo " лимитов Telegram открывают в MAX. Включить позже: ./setup.sh → «Большие файлы»."
|
||
fi
|
||
fi
|
||
|
||
# .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.
|
||
# Belt and braces: a value that can't be represented must never reach .env, because Compose then
|
||
# refuses the entire file and the bridge comes up with no configuration whatsoever.
|
||
for _v in "$MAX_SESSION_KEY" "$TELEGRAM_BOT_TOKEN" "$TARGET_TELEGRAM_GROUP" "$TELEGRAM_PROXY" "$STICKERS"; do
|
||
case "$_v" in
|
||
*"'"*)
|
||
echo "❌ Значение с апострофом нельзя записать в .env (Docker не прочитает файл). Уберите апостроф и повторите."
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
umask 077
|
||
cat > .env <<EOF
|
||
# сгенерировано setup.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
COMPOSE_PROJECT_NAME=$(env_quote "$(unique_compose_name)")
|
||
MAX_SESSION_KEY=$(env_quote "$MAX_SESSION_KEY")
|
||
TELEGRAM_BOT_TOKEN=$(env_quote "$TELEGRAM_BOT_TOKEN")
|
||
TARGET_TELEGRAM_GROUP=$(env_quote "$TARGET_TELEGRAM_GROUP")
|
||
TELEGRAM_PROXY=$(env_quote "$TELEGRAM_PROXY")
|
||
STICKERS=$(env_quote "$STICKERS")
|
||
EOF
|
||
[ "$FILES_SETTING" = off ] && printf "FILES='off'\n" >> .env
|
||
|
||
echo
|
||
bold "Готово: .env создан."
|
||
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
echo "Docker не найден — установите Docker и Docker Compose, затем: docker compose up -d --build"
|
||
exit 0
|
||
fi
|
||
|
||
read -rp "Собрать и запустить контейнер прямо сейчас? [Y/n] " RUN_NOW
|
||
if [ "${RUN_NOW:-Y}" = "n" ] || [ "${RUN_NOW:-Y}" = "N" ]; then
|
||
echo "Ок, когда будете готовы: docker compose up -d --build"
|
||
exit 0
|
||
fi
|
||
|
||
echo
|
||
if [ "$STICKERS" = "full" ]; then
|
||
echo "Собираю образ (full) — первая сборка дольше (внутри headless-браузер для стикеров), обычно несколько минут."
|
||
else
|
||
echo "Собираю образ (slim) — быстрая сборка."
|
||
fi
|
||
compose_up --build
|
||
|
||
echo
|
||
echo "════════════════════════════════════════════════════════════════"
|
||
bold " ✅ Мост собран и запущен. Остался ОДИН обязательный шаг: авторизация MAX."
|
||
echo " Откройте вашего бота в Telegram и напишите ему в ЛИЧКУ команду:"
|
||
bold " /login"
|
||
echo " Введёте номер MAX и код из SMS прямо в личке — в группу они не попадут."
|
||
echo " (Альтернатива: в группе /panel → «🔐 Вход в MAX».)"
|
||
echo "════════════════════════════════════════════════════════════════"
|
||
echo
|
||
echo "Изменить настройки позже (прокси, токен, группа, стикеры, большие файлы) — просто запустите ./setup.sh ещё раз."
|