diff --git a/index.js b/index.js index d59f544..d76b842 100644 --- a/index.js +++ b/index.js @@ -714,6 +714,8 @@ function createDefaultUser(user) { mainMenuChatId: null, adminMenuMsgId: null, adminMenuChatId: null, + ephemeralBonusMsgId: null, + ephemeralBonusChatId: null, referralCount: 0, lastReferralAt: 0, boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2× SC @@ -1886,6 +1888,7 @@ function userMainMenuText(user) { async function sendPersistentUserMenu(ctx, user) { const chatId = ctx.chat ? ctx.chat.id : (ctx.callbackQuery && ctx.callbackQuery.message ? ctx.callbackQuery.message.chat.id : null); if (!chatId) return; + await deleteEphemeralBonusPrompt(ctx, user); // Show admin button only when admin view is ON const isAdminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; @@ -2099,6 +2102,62 @@ async function linkedUsernameError(ctx) { ); } + +async function deleteEphemeralBonusPrompt(ctx, user) { + const activePrompt = await runUserMutation(user.id, async () => { + if (!user.ephemeralBonusMsgId || !user.ephemeralBonusChatId) return null; + return { msgId: user.ephemeralBonusMsgId, chatId: user.ephemeralBonusChatId }; + }); + if (!activePrompt) return; + + try { + await ctx.telegram.deleteMessage(activePrompt.chatId, activePrompt.msgId); + } catch (_) { /* message may already be gone */ } + + await runUserMutation(user.id, async () => { + if (user.ephemeralBonusMsgId === activePrompt.msgId && user.ephemeralBonusChatId === activePrompt.chatId) { + user.ephemeralBonusMsgId = null; + user.ephemeralBonusChatId = null; + } + }); +} + +async function sendEphemeralBonusPrompt(ctx, user) { + await deleteEphemeralBonusPrompt(ctx, user); + + const sent = await ctx.reply( + `🎁 *Claim Your New User Bonus* + +Enter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('🎁 Enter Promo Code (Mini App)', LINKS.miniAppClaim)], + [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')], + ]), + }, + ); + + await runUserMutation(user.id, async () => { + user.ephemeralBonusMsgId = sent.message_id; + user.ephemeralBonusChatId = sent.chat.id; + }); + + setTimeout(async () => { + try { + await ctx.telegram.deleteMessage(sent.chat.id, sent.message_id); + } catch (_) { /* best effort */ } + + await runUserMutation(user.id, async () => { + if (user.ephemeralBonusMsgId === sent.message_id && user.ephemeralBonusChatId === sent.chat.id) { + user.ephemeralBonusMsgId = null; + user.ephemeralBonusChatId = null; + } + }); + }, 15 * 1000); +} + + const adminEventsLogFile = path.join(dataDir, 'admin-events.log'); const ADMIN_LOG_CAP = 200; @@ -2136,17 +2195,8 @@ async function showOnboardingPrompt(ctx, user, step) { await sendLinkAccountPrompt(ctx, user); break; case 3: - // Promo claim - await ctx.reply( - `🎁 *Claim Your New User Bonus*\n\nEnter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`, - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.url('🎁 Enter Promo Code (Mini App)', LINKS.miniAppClaim)], - [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')], - ]), - }, - ); + // Promo claim (auto-removes after 15s to keep chat organized) + await sendEphemeralBonusPrompt(ctx, user); break; case 4: // Join community channel + group @@ -4028,6 +4078,7 @@ bot.command('join', async (ctx) => { bot.action('to_main_menu', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); + await deleteEphemeralBonusPrompt(ctx, user); await sendPersistentUserMenu(ctx, user); }); diff --git a/prod-run.sh b/prod-run.sh index eedf0e2..b490bfb 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -32,6 +32,70 @@ say() { printf '[%s] %s\n' "$APP_NAME" "$*"; } warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; } err() { printf '[%s][error] %s\n' "$APP_NAME" "$*" >&2; } +read_env_value() { + local key="$1" + [[ -f "$PROJECT_DIR/.env" ]] || return 1 + grep -E "^${key}=" "$PROJECT_DIR/.env" | head -1 | cut -d= -f2- | sed "s/^[\'\"]//;s/[\'\"]$//" | tr -d $' +' +' +} + +resolve_health_url() { + local port tls_key tls_cert + port="$(read_env_value PORT)" + [[ -z "$port" ]] && port="3000" + tls_key="$(read_env_value HTTPS_KEY_PATH)" + tls_cert="$(read_env_value HTTPS_CERT_PATH)" + + if [[ -n "$tls_key" && -n "$tls_cert" && -f "$tls_key" && -f "$tls_cert" ]]; then + printf 'https://127.0.0.1:%s/health' "$port" + else + printf 'http://127.0.0.1:%s/health' "$port" + fi +} + +check_health_endpoint() { + local url="$1" + local code + if [[ "$url" == https://* ]]; then + code="$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)" + else + code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)" + fi + [[ "$code" == "200" ]] +} + +wait_for_health() { + local url="$1" + local attempts="${2:-15}" + local sleep_sec="${3:-2}" + local i + for ((i=1; i<=attempts; i++)); do + if check_health_endpoint "$url"; then + return 0 + fi + sleep "$sleep_sec" + done + return 1 +} + +is_port_listening() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltn "sport = :${port}" 2>/dev/null | awk 'NR>1 {found=1} END {exit(found ? 0 : 1)}' + else + return 1 + fi +} + +if id -u "$APP_NAME" >/dev/null 2>&1; then + chown -R "$APP_NAME:$APP_NAME" "$LOG_DIR" "$PROJECT_DIR/data" + chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$LOG_DIR" + chmod 0640 "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" +else + warn "System user '$APP_NAME' not found — using $(id -un):$(id -gn)" +fi + say "Starting production runner" say "Project directory: $PROJECT_DIR" say "Running as: $(id -un) (uid=$(id -u) gid=$(id -g))" @@ -47,9 +111,15 @@ else fi # --------------------------------------------------------- -# 2) Runtime directories & log files -mkdir -p "$LOG_DIR" "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" -touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" +# 2) Ensure required runtime directories & log files exist +# (logs/ and data/ are in .gitignore so they won't exist +# on a fresh clone — we always create them here) +# --------------------------------------------------------- +mkdir -p "$LOG_DIR" +mkdir -p "$PROJECT_DIR/data" +mkdir -p "$PROJECT_DIR/data/backups" +touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" # create if missing; safe on existing files + say "Ensured runtime directories and log files exist" # --------------------------------------------------------- @@ -264,4 +334,40 @@ if [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$ADMIN_IDS" ]]; then done fi -say "✅ Production runner + God-Mode Heal complete" +SYSTEMD_ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo inactive)" +HEALTH_URL="$(resolve_health_url)" +HEALTH_PORT="${HEALTH_URL#*://127.0.0.1:}" +HEALTH_PORT="${HEALTH_PORT%%/*}" + +if wait_for_health "$HEALTH_URL" 20 2; then + HEALTH_STATUS="healthy" +else + HEALTH_STATUS="unhealthy" +fi + +if is_port_listening "$HEALTH_PORT"; then + PORT_STATUS="listening" +else + PORT_STATUS="not-listening" +fi + +say "✔ Systemd service: ${SYSTEMD_ACTIVE}" +say "✔ Health URL: ${HEALTH_URL}" +say "✔ Health endpoint: ${HEALTH_STATUS}" +say "✔ Port ${HEALTH_PORT}: ${PORT_STATUS}" +say "✔ Logrotate installed: $(command -v logrotate >/dev/null 2>&1 && echo yes || echo no)" +say "✔ Logs directory: $LOG_DIR" +say "✔ Main log: $MAIN_LOG" +say "✔ Error log: $ERROR_LOG" + +if [[ "$HEALTH_STATUS" != "healthy" ]]; then + warn "Health endpoint check failed after retries; dumping last 50 log lines" + echo "---- Last 50 lines of $MAIN_LOG ----" + tail -n 50 "$MAIN_LOG" 2>/dev/null || true + echo "---- Last 50 lines of $ERROR_LOG ----" + tail -n 50 "$ERROR_LOG" 2>/dev/null || true +fi + +echo "--------------------------------------------------" +say "Production runner complete" +echo "--------------------------------------------------"