diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18cddc7..eb654c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,8 @@ jobs: run: npm ci - name: Validate entrypoint loads cleanly + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | # node --check ensures syntax correctness node --check index.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9a4cdb6..daddf9f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,7 @@ permissions: contents: read env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} AFFILIATE_SOURCE: GambleCodez ADMIN_IDS: "6668510825" DISCORD_CODE_GENERATION_IMAGE_URL: "https://github.com/gamblecodezcom/Runewager/blob/38c1bbab2d954238829852981514ce92c49d8b10/images/discord_code_generation.png" @@ -84,7 +85,8 @@ By: \`${{ github.actor }}\`" name: Deploy to VPS runs-on: ubuntu-latest needs: quality-gates - environment: production + environment: + name: production steps: - uses: actions/checkout@v4 diff --git a/index.js b/index.js index 8bcd5f8..d281bbd 100644 --- a/index.js +++ b/index.js @@ -1298,6 +1298,16 @@ function isValidHttpUrl(value) { } } +/** + * Escape user-supplied text before interpolating into a Telegram Markdown + * (legacy) message to prevent admin-provided titles or names from breaking + * message formatting. + * Escapes the characters Telegram Markdown treats as special: _ * ` [ ] + */ +function escapeMarkdownV2(text) { + return String(text).replace(/[_*`[\]]/g, '\\$&'); +} + function ensureDataDir() { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); } @@ -1698,6 +1708,9 @@ bot.start(safeStepHandler('start', async (ctx) => { const giveaway = giveawayStore.running.get(gwId); if (!giveaway) { await ctx.reply('⚠️ That giveaway has already ended or does not exist.'); + } else if (giveaway.joinSurface === 'group') { + // Group-only giveaway — DM joins are not allowed + await ctx.reply('⚠️ This giveaway can only be joined from the group chat.'); } else if (giveaway.participants.has(user.id)) { await ctx.reply(`✅ You are already entered in Giveaway #${gwId}!`); } else { @@ -3961,9 +3974,6 @@ bot.action('menu_admin_tab', async (ctx) => { ); }); -// admin_dashboard_action: second registration — sends the rich dashboard (replaces the minimal version above) -bot.action('admin_dashboard_action_v2_noop', () => {}); // placeholder to document merge - bot.action('admin_auth_bypass', async (ctx) => { const user = getUser(ctx); if (!ADMIN_IDS.includes(Number(user.id))) { await ctx.answerCbQuery('Admin only.'); return; } @@ -4279,6 +4289,20 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { return; } + // Enforce joinSurface: DM-only giveaways can't be joined from a group button + const isPrivate = ctx.chat && ctx.chat.type === 'private'; + if (giveaway.joinSurface === 'dm' && !isPrivate) { + await ctx.answerCbQuery('Join via DM only'); + await ctx.reply('⚠️ This giveaway can only be joined via the bot DM. Use the DM join link instead.'); + return; + } + // Group-only giveaways can't be joined from a DM button + if (giveaway.joinSurface === 'group' && isPrivate) { + await ctx.answerCbQuery('Join from group only'); + await ctx.reply('⚠️ This giveaway can only be joined from the group chat.'); + return; + } + const checks = evaluateEligibility(user, giveaway); if (!checks.ok) { await ctx.answerCbQuery(); @@ -4403,11 +4427,12 @@ bot.action(/^gw_auto_extend_(\d+)$/, async (ctx) => { const gwId = Number(ctx.match[1]); const giveaway = giveawayStore.running.get(gwId); if (!giveaway) { await ctx.reply(`Giveaway #${gwId} is no longer running.`); return; } + giveaway.awaitingAdminDecision = false; // allow finalizeGiveaway to re-evaluate on next timer fire giveaway.endTime += 15 * 60 * 1000; giveaway.extendedCount = (giveaway.extendedCount || 0) + 1; resetGiveawayTimer(giveaway); await ctx.reply(`✅ Giveaway #${gwId} extended by 15 minutes (extension ${giveaway.extendedCount}/2).`); - await bot.telegram.sendMessage(giveaway.chatId, `⏱ Giveaway #${gwId} extended! ${15} minutes added — not enough participants yet.`).catch(() => {}); + await bot.telegram.sendMessage(giveaway.chatId, `⏱ Giveaway #${gwId} extended! 15 minutes added — not enough participants yet.`).catch(() => {}); }); bot.action(/^gw_force_end_(\d+)$/, async (ctx) => { @@ -5397,7 +5422,7 @@ function createGiveaway(config) { } async function announceGiveaway(giveaway, botUsername) { - const titleLine = giveaway.title ? `\n📌 *${giveaway.title}*\n` : ''; + const titleLine = giveaway.title ? `\n📌 *${escapeMarkdownV2(giveaway.title)}*\n` : ''; const minPartLine = giveaway.minParticipants > 0 ? `• Min participants: ${giveaway.minParticipants}\n` : ''; @@ -5536,6 +5561,9 @@ async function finalizeGiveaway(gwId, forceEnd = false) { if (!forceEnd && giveaway.minParticipants > 0 && eligibleCount < giveaway.minParticipants) { const extendedCount = giveaway.extendedCount || 0; if (extendedCount < 2) { + // Guard against double-trigger: only notify admins once per timer fire + if (giveaway.awaitingAdminDecision) return; + giveaway.awaitingAdminDecision = true; // Notify admins with extend/force-end options for (const adminId of ADMIN_IDS) { bot.telegram.sendMessage( @@ -5558,14 +5586,16 @@ async function finalizeGiveaway(gwId, forceEnd = false) { } giveaway.status = 'ended'; + giveaway.awaitingAdminDecision = false; // clear the guard giveawayStore.running.delete(gwId); giveawayStore.history.set(gwId, giveaway); giveaway.reminders.forEach((t) => clearTimeout(t)); - // Unpin the announcement if we pinned it + // Unpin the announcement if we pinned it, then clear the stored ID if (giveaway.pinnedMsgId) { bot.telegram.unpinChatMessage(giveaway.chatId, giveaway.pinnedMsgId).catch(() => {}); + giveaway.pinnedMsgId = null; } if (eligibleCount === 0) {