From 1978c31bf0d9a5290d1e7aa0b8f8386735c2e570 Mon Sep 17 00:00:00 2001 From: XXhaos Date: Thu, 23 Apr 2026 17:25:35 +0800 Subject: [PATCH] Add files via upload --- xbox-bot/Dockerfile | 12 + xbox-bot/docker-compose.yml | 26 + xbox-bot/package.json | 16 + xbox-bot/server.js | 1548 +++++++++++++++++++++++++++++++++++ 4 files changed, 1602 insertions(+) create mode 100644 xbox-bot/Dockerfile create mode 100644 xbox-bot/docker-compose.yml create mode 100644 xbox-bot/package.json create mode 100644 xbox-bot/server.js diff --git a/xbox-bot/Dockerfile b/xbox-bot/Dockerfile new file mode 100644 index 0000000..6ff422e --- /dev/null +++ b/xbox-bot/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY server.js ./ + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/xbox-bot/docker-compose.yml b/xbox-bot/docker-compose.yml new file mode 100644 index 0000000..1f16a90 --- /dev/null +++ b/xbox-bot/docker-compose.yml @@ -0,0 +1,26 @@ +services: + bot: + build: . + restart: unless-stopped + ports: + - "3000:3000" # 如果有反向代理(nginx/caddy),可以去掉这行只留内网 + env_file: + - .env + depends_on: + redis: + condition: service_healthy + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data # 数据持久化,重启不丢 + command: redis-server --save 60 1 --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + redis_data: diff --git a/xbox-bot/package.json b/xbox-bot/package.json new file mode 100644 index 0000000..7f935ef --- /dev/null +++ b/xbox-bot/package.json @@ -0,0 +1,16 @@ +{ + "name": "xbox-bot", + "version": "1.0.0", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "redis": "^4.6.13" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/xbox-bot/server.js b/xbox-bot/server.js new file mode 100644 index 0000000..6b43d73 --- /dev/null +++ b/xbox-bot/server.js @@ -0,0 +1,1548 @@ +'use strict'; + +import express from 'express'; +import { createClient } from 'redis'; +import crypto from 'node:crypto'; + +// ========================= 环境变量 ========================= +const BOT_TOKEN = process.env.BOT_TOKEN; +const ALLOWED_USER_ID = parseInt(process.env.ALLOWED_USER_ID, 10); +const VIEW_TOKEN = process.env.VIEW_TOKEN; +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''; +const PORT = parseInt(process.env.PORT || '3000', 10); +const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379'; + +if (!BOT_TOKEN || !ALLOWED_USER_ID || !VIEW_TOKEN) { + console.error('❌ 缺少必要环境变量:BOT_TOKEN / ALLOWED_USER_ID / VIEW_TOKEN'); + process.exit(1); +} + +// ========================= 常量 ========================= +const MAX_TELEGRAM_TEXT = 4000; +const FETCH_TIMEOUT_MS = 8000; +const EXCHANGE_TIMEOUT_MS = 6000; +const RUN_LOCK_TTL = 1800; +const RUN_TIMEOUT_MS = 15000; // 超时 15 秒,方便手动 /force_stop 控制 + +// ========================= Redis ========================= +const redis = createClient({ url: REDIS_URL }); +redis.on('error', err => console.error('[Redis]', err)); +await redis.connect(); +console.log('[Redis] 连接成功:', REDIS_URL); + +const KV = { + async get(key) { return redis.get(key); }, + async put(key, value, opts) { + if (opts?.expirationTtl) await redis.set(key, value, { EX: opts.expirationTtl }); + else await redis.set(key, value); + }, + async delete(key) { await redis.del(key); } +}; + +// ========================= Redis 自动清理 ========================= +// 每天凌晨 3 点检查一次,超过 20MB 则删除最旧的 PAGES_MSG_* 存档 +const MAX_REDIS_BYTES = 20 * 1024 * 1024; // 20MB + +async function scheduledClean() { + try { + // 获取 Redis 内存用量 + const info = await redis.info('memory'); + const match = info.match(/used_memory:(\d+)/); + if (!match) return; + const memoryBytes = parseInt(match[1], 10); + + console.log(`[AutoClean] Redis 当前用量: ${(memoryBytes / 1024 / 1024).toFixed(2)} MB`); + + if (memoryBytes <= MAX_REDIS_BYTES) { + console.log('[AutoClean] 用量正常,无需清理'); + return; + } + + // 扫描所有 PAGES_MSG_* key + let keys = []; + let cursor = 0; + do { + const result = await redis.scan(cursor, { MATCH: 'PAGES_MSG_*', COUNT: 100 }); + cursor = result.cursor; + keys = keys.concat(result.keys); + } while (cursor !== 0); + + if (keys.length === 0) return; + + // 按 key 里的 msgId 数字排序(数字越小越旧) + keys.sort((a, b) => { + const na = parseInt(a.replace('PAGES_MSG_', ''), 10) || 0; + const nb = parseInt(b.replace('PAGES_MSG_', ''), 10) || 0; + return na - nb; + }); + + // 删除最旧的一半 + const toDelete = keys.slice(0, Math.ceil(keys.length / 2)); + for (const k of toDelete) { + await KV.delete(k); + } + console.log(`[AutoClean] 已删除 ${toDelete.length} 条旧存档,剩余 ${keys.length - toDelete.length} 条`); + } catch (e) { + console.error('[AutoClean] 清理失败:', e.message); + } +} + +// 每天凌晨 3 点执行一次 +function scheduleNextClean() { + const now = new Date(); + const next = new Date(); + next.setHours(3, 0, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + const delay = next - now; + setTimeout(() => { + scheduledClean(); + setInterval(scheduledClean, 24 * 60 * 60 * 1000); + }, delay); + console.log(`[AutoClean] 下次清理时间: ${next.toLocaleString()}`); +} + +// ========================= 工具函数 ========================= +function safeTelegramText(text) { + let s = String(text ?? ''); + if (s.length > MAX_TELEGRAM_TEXT) s = s.substring(0, MAX_TELEGRAM_TEXT); + return s; +} + +function escapeHTML(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function makeProgressBar(done, total, length = 10) { + if (total <= 0) return '□□□□□□□□□□'; + const filled = Math.round(Math.max(0, Math.min(1, done / total)) * length); + return '■'.repeat(filled) + '□'.repeat(length - filled); +} + +function getPaginationKeyboard(currentPage) { + return { + inline_keyboard: [ + [ + { text: currentPage === 'page_1' ? '✅ 报价' : '🎮 报价', callback_data: 'page_1' }, + { text: currentPage === 'page_2' ? '✅ Surge' : '📦 Surge', callback_data: 'page_2' }, + { text: currentPage === 'page_3' ? '✅ 链接' : '🔗 链接', callback_data: 'page_3' } + ], + [ + { text: '🔄 恢复 Product', callback_data: 'restore_product' } + ] + ] + }; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +// ========================= Telegram API ========================= +async function answerCallbackQuery(callbackQueryId, text = null) { + const body = { callback_query_id: callbackQueryId }; + if (text) { body.text = text; body.show_alert = true; } + await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/answerCallbackQuery`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }); +} + +async function sendTelegramMessage(chatId, text, replyMarkup = null) { + const payload = { chat_id: chatId, text: safeTelegramText(text), parse_mode: 'HTML', disable_web_page_preview: true }; + if (replyMarkup) payload.reply_markup = replyMarkup; + const resp = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) + }); + try { return await resp.json(); } catch { return null; } +} + +async function editTelegramMessage(chatId, messageId, text, replyMarkup = null) { + const payload = { chat_id: chatId, message_id: messageId, text: safeTelegramText(text), parse_mode: 'HTML', disable_web_page_preview: true }; + if (replyMarkup) payload.reply_markup = replyMarkup; + await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/editMessageText`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) + }); +} + +// ========================= 业务计算 ========================= +function calculateProxyPrice(cnyPrices) { + const totalCNY = cnyPrices.reduce((acc, val) => acc + parseFloat(val), 0); + const count = cnyPrices.length; + const baseStr = cnyPrices.join('+'); + const wrap = count > 1 ? `(${baseStr})` : baseStr; + let proxyPrice, expressionStr; + + if (totalCNY < 40) { + // 40元以内:+12元,超过3个游戏每多一个再+2元 + let fee = 12; + let feeStr = '12'; + if (count > 3) { + const extra = (count - 3) * 2; + fee += extra; + feeStr = `12+${count - 3}×2`; + } + proxyPrice = totalCNY + fee; + expressionStr = `${baseStr}+${feeStr}=${proxyPrice.toFixed(2)}`; + } else if (totalCNY <= 100) { + // 40-100元:×1.25,但手续费不足12元按12元算 + const fee1 = totalCNY * 0.25; + if (fee1 < 12) { + // 手续费不足12元,改用 +12 + proxyPrice = totalCNY + 12; + expressionStr = `${baseStr}+12=${proxyPrice.toFixed(2)}`; + } else { + proxyPrice = totalCNY * 1.25; + expressionStr = `${wrap}×1.25=${proxyPrice.toFixed(2)}`; + } + } else { + // 100元以上:×1.20 + proxyPrice = totalCNY * 1.20; + expressionStr = `${wrap}×1.20=${proxyPrice.toFixed(2)}`; + } + + return { totalCNY, proxyPrice, expressionStr }; +} + +// ========================= 汇率 ========================= +async function getRealTimeExchangeRate() { + const apis = [ + { url: 'https://open.er-api.com/v6/latest/NGN', parse: d => d?.rates?.CNY }, + { url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/ngn.json', parse: d => d?.ngn?.cny }, + { url: 'https://api.exchangerate-api.com/v4/latest/NGN', parse: d => d?.rates?.CNY } + ]; + for (const api of apis) { + try { + const resp = await fetchWithTimeout(api.url, {}, EXCHANGE_TIMEOUT_MS); + if (!resp.ok) continue; + const rate = api.parse(await resp.json()); + if (rate && typeof rate === 'number') return rate; + } catch (_) {} + } + throw new Error('所有备用实时汇率 API 均请求失败,请稍后再试。'); +} + +// ========================= KV Keys ========================= +const getQueueKey = userId => `xbox_urls_${userId}`; +const getRunLockKey = userId => `RUNNING_${userId}`; +const getRunMetaKey = userId => `RUN_META_${userId}`; +const getStateKey = userId => `SUCCESS_STATE_${userId}`; +const getPagesKey = userId => `LATEST_RESULT_${userId}`; +const getPagesByMsgKey = msgId => `PAGES_MSG_${msgId}`; +const getSurgeQueueKey = userId => `SURGE_GROUP_QUEUE_${userId}`; +const getResolvedLinksKey = userId => `RESOLVED_LINKS_${userId}`; +const getSurgeLockKey = userId => `SURGE_CONSUMING_${userId}`; + +// ========================= 状态读写 ========================= +async function loadPendingQueue(userId) { + const raw = await KV.get(getQueueKey(userId)); + return raw ? JSON.parse(raw) : []; +} + +async function savePendingQueue(userId, queue) { + const unique = [...new Set(queue)]; + if (unique.length > 0) await KV.put(getQueueKey(userId), JSON.stringify(unique)); + else await KV.delete(getQueueKey(userId)); +} + +async function loadSuccessState(userId) { + const raw = await KV.get(getStateKey(userId)); + if (!raw) return { items: [], lastRate: null, updatedAt: null }; + try { + const p = JSON.parse(raw); + return { items: Array.isArray(p.items) ? p.items : [], lastRate: p.lastRate ?? null, updatedAt: p.updatedAt ?? null }; + } catch { return { items: [], lastRate: null, updatedAt: null }; } +} + +async function saveSuccessState(userId, state) { + await KV.put(getStateKey(userId), JSON.stringify({ + items: state.items || [], lastRate: state.lastRate ?? null, updatedAt: new Date().toISOString() + })); +} + +async function loadResolvedLinks(userId) { + const raw = await KV.get(getResolvedLinksKey(userId)); + if (!raw) return []; + try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch { return []; } +} + +async function saveResolvedLinks(userId, links) { + await KV.put(getResolvedLinksKey(userId), JSON.stringify(Array.isArray(links) ? links : [])); +} + +async function appendResolvedLink(userId, linkInfo) { + const links = await loadResolvedLinks(userId); + if (!links.some(x => x.sourceUrl === linkInfo.sourceUrl)) { + links.push({ ...linkInfo, createdAt: new Date().toISOString() }); + await saveResolvedLinks(userId, links); + } +} + +async function loadRunMeta(userId) { + const raw = await KV.get(getRunMetaKey(userId)); + return raw ? JSON.parse(raw) : null; +} + +async function saveRunMeta(userId, meta) { + await KV.put(getRunMetaKey(userId), JSON.stringify(meta), { expirationTtl: RUN_LOCK_TTL }); +} + +async function clearRunMeta(userId) { await KV.delete(getRunMetaKey(userId)); } + +async function isRunActive(userId, runId) { + const meta = await loadRunMeta(userId); + return !!(meta && meta.runId === runId); +} + +// ========================= Surge 队列 ========================= +async function loadSurgeQueue(userId) { + const raw = await KV.get(getSurgeQueueKey(userId)); + if (!raw) return { nextGroupIndex: 1, groups: [] }; + try { + const p = JSON.parse(raw); + return { + nextGroupIndex: typeof p.nextGroupIndex === 'number' ? p.nextGroupIndex : 1, + groups: Array.isArray(p.groups) ? p.groups : [] + }; + } catch { return { nextGroupIndex: 1, groups: [] }; } +} + +async function saveSurgeQueue(userId, queue) { + const safe = { + nextGroupIndex: typeof queue.nextGroupIndex === 'number' ? queue.nextGroupIndex : 1, + groups: Array.isArray(queue.groups) ? queue.groups : [] + }; + await KV.put(getSurgeQueueKey(userId), JSON.stringify(safe)); + if (safe.groups.length > 0) await KV.put('LATEST_XBOX_LIST', JSON.stringify(safe.groups[0].products)); + else await KV.delete('LATEST_XBOX_LIST'); +} + +// ★ 每次全新任务开始时重置,组号从 1 重新计数 +async function resetSurgeQueue(userId) { + await saveSurgeQueue(userId, { nextGroupIndex: 1, groups: [] }); +} + +async function appendProductToSurgeQueue(userId, item) { + const queue = await loadSurgeQueue(userId); + const product = { ProductId: item.bigId, SkuId: item.targetSkuId, AvailabilityId: item.targetAvailabilityId, PriceNGN: item.currentPriceNum ?? null }; + let last = queue.groups[queue.groups.length - 1]; + if (!last || last.count >= 15) { + last = { groupIndex: queue.nextGroupIndex, count: 0, products: {} }; + queue.groups.push(last); + queue.nextGroupIndex += 1; + } + last.count += 1; + // 用 "(序号) 游戏名" 作为 key,方便识别 + const safeGameName = (item.gameName || 'Unknown').replace(/['"]/g, ''); + const productKey = `(${last.count}) ${safeGameName}`; + last.products[productKey] = product; + await saveSurgeQueue(userId, queue); +} + +async function popFirstSurgeGroup(userId) { + const queue = await loadSurgeQueue(userId); + if (!queue.groups.length) return { ok: true, cleared: false, remainingGroups: 0, nextGroupIndex: null, nextGroupCount: 0 }; + const removed = queue.groups.shift(); + await saveSurgeQueue(userId, queue); + + // ★ 最后一组弹出后,清理本次任务所有数据,下次 run 从零开始 + const isLastGroup = queue.groups.length === 0; + + // ★ 先读取 pendingQueue(清理前),供通知使用 + const pendingQueue = await loadPendingQueue(userId); + + if (isLastGroup) { + await KV.delete(getQueueKey(userId)); + await KV.delete(getStateKey(userId)); + await KV.delete(getResolvedLinksKey(userId)); + await KV.delete(getPagesKey(userId)); + console.log(`[popFirstSurgeGroup] 所有组已消费,任务数据已清理 userId=${userId}`); + } + + // ★ 发送 Telegram 通知 + // 直接从 product 的 PriceNGN 字段取价格(存储在 Surge 分组里,不依赖 state) + const groupNGN = Object.values(removed.products) + .reduce((s, p) => s + (typeof p.PriceNGN === 'number' ? p.PriceNGN : 0), 0); + const ngnStr = groupNGN > 0 ? `,游戏总价 ${groupNGN.toFixed(2)} NGN` : ''; + + let notifyText; + if (isLastGroup) { + notifyText = + `📦 第 ${removed.groupIndex} 组 Product 已同步完成${ngnStr} + +` + + `✅ 所有分组已全部消费完毕,本次任务数据已清理。 +` + + `待处理链接队列: ${pendingQueue.length} 个`; + } else { + notifyText = + `📦 第 ${removed.groupIndex} 组 Product 已同步完成${ngnStr} + +` + + `剩余待同步分组: ${queue.groups.length} 组 +` + + `下一组: 第 ${queue.groups[0].groupIndex} 组(共 ${queue.groups[0].count} 个商品) +` + + `待处理链接队列: ${pendingQueue.length} 个`; + } + sendTelegramMessage(userId, notifyText).catch(() => {}); + + return { + ok: true, cleared: true, + clearedGroupIndex: removed.groupIndex, clearedGroupCount: removed.count, + remainingGroups: queue.groups.length, + nextGroupIndex: queue.groups[0]?.groupIndex ?? null, + nextGroupCount: queue.groups[0]?.count ?? 0, + groupNGN: groupNGN // ★ 本组游戏总价 + }; +} + +// ========================= 页面渲染 ========================= +// 按 Surge 分组(每组最多 15 个)拆分游戏列表,返回多段 page1 和对应 page2 +// page1Chunks[i] 对应 surgeGroups[i],序号连续衔接,总价只在最后一段 +function buildPagesFromState(state, resolvedLinks, surgeQueue) { + const items = Array.isArray(state.items) ? state.items : []; + const rate = state.lastRate; + const groups = Array.isArray(surgeQueue?.groups) ? surgeQueue.groups : []; + const GROUP_SIZE = 15; + + // ── 构建多段 page1(每段对应一个 Surge 分组)── + const page1Chunks = []; + + if (items.length === 0) { + page1Chunks.push('📭 目前还没有成功解析的游戏。'); + } else { + const totalGroups = Math.ceil(items.length / GROUP_SIZE); + + for (let g = 0; g < totalGroups; g++) { + const groupItems = items.slice(g * GROUP_SIZE, (g + 1) * GROUP_SIZE); + const isLast = g === totalGroups - 1; + const startIdx = g * GROUP_SIZE; // 全局起始序号(0-based) + + let header = '🎮 游戏比价及代购信息'; + if (totalGroups > 1) header += ` (第 ${g + 1} 组 / 共 ${totalGroups} 组)`; + header += '\n\n'; + if (rate) header += `💱 汇率: 1 NGN ≈ ${Number(rate).toFixed(6)} CNY\n\n`; + + let text = ''; + let groupNGN = 0; + + groupItems.forEach((info, idx) => { + const displayNum = startIdx + idx + 1; // 序号从全局 1 开始 + text += `游戏(${displayNum})\n名称: ${info.gameName}\n`; + text += `原价: ${info.originalPriceStr}${info.originalPriceCNY !== 'N/A' ? ` (¥${info.originalPriceCNY})` : ''}\n`; + text += `现价: ${info.currentPriceStr}${info.currentPriceCNY !== 'N/A' ? ` (¥${info.currentPriceCNY})` : ''}\n`; + text += '----------------------------------------\n'; + if (typeof info.currentPriceNum === 'number') groupNGN += info.currentPriceNum; + }); + + // 多组:每组显示本组小计,最后一段额外追加全局总价(带分隔线) + // 单组:直接显示游戏总价,不重复输出小计 + const allCnyPrices = items.map(i => i.currentPriceCNY).filter(p => p !== 'N/A'); + const totalNGN = items.reduce((s, i) => s + (typeof i.currentPriceNum === 'number' ? i.currentPriceNum : 0), 0); + + if (totalGroups === 1) { + // 只有一组:直接输出总价 + text += `📊 游戏总价: NGN ${totalNGN.toFixed(2)}\n`; + if (allCnyPrices.length > 0) { + text += `🛍️ 代购总价: ${calculateProxyPrice(allCnyPrices).expressionStr}`; + } else { + text += '🛍️ 代购总价: ¥0.00'; + } + } else { + // 多组:每组有小计 + text += `📊 本组小计: NGN ${groupNGN.toFixed(2)}`; + if (isLast) { + text += `\n· · · · · · · · · · · · · · · ·\n📊 游戏总价: NGN ${totalNGN.toFixed(2)}\n`; + if (allCnyPrices.length > 0) { + text += `🛍️ 代购总价: ${calculateProxyPrice(allCnyPrices).expressionStr}`; + } else { + text += '🛍️ 代购总价: ¥0.00'; + } + } + } + + page1Chunks.push(header + `
${escapeHTML(text.trim())}
`); + } + } + + // ── page2:每段对应同序号的 Surge 分组 ── + const page2Chunks = []; + if (groups.length === 0) { + page2Chunks.push('📦 Surge 分组队列\n\n📭 当前没有待同步的 Surge 数据。'); + } else { + groups.forEach((group, idx) => { + let p = '📦 Surge 分组队列\n\n'; + p += `当前剩余 ${groups.length} 组待同步\n\n`; + p += `【 第 ${group.groupIndex} 组 】共 ${group.count} 个商品\n`; + p += `${escapeHTML(JSON.stringify(group.products, null, 2))}`; + page2Chunks.push(p); + }); + // 如果 Surge 组数少于游戏消息段数,最后几段复用最后一个 Surge 组 + while (page2Chunks.length < page1Chunks.length) { + page2Chunks.push(page2Chunks[page2Chunks.length - 1]); + } + } + + // ── page3:链接记录,同样按 GROUP_SIZE 分段,序号衔接 ── + const page3Chunks = []; + if (resolvedLinks.length === 0) { + page3Chunks.push('🔗 已解析链接记录\n\n📭 当前还没有已解析的链接记录。'); + } else { + const totalGroups = Math.ceil(resolvedLinks.length / GROUP_SIZE); + for (let g = 0; g < totalGroups; g++) { + const groupLinks = resolvedLinks.slice(g * GROUP_SIZE, (g + 1) * GROUP_SIZE); + const startIdx = g * GROUP_SIZE; + let p = '🔗 已解析链接记录\n\n'; + groupLinks.forEach((item, idx) => { + const displayNum = startIdx + idx + 1; + p += `游戏(${displayNum})\n`; + p += `名称: ${item.gameName || 'Unknown Game'}\n`; + p += `bigId: ${item.bigId || 'N/A'}\n`; + p += `解析链接: ${escapeHTML(item.resolvedUrl || '')}\n`; + p += '----------------------------------------\n'; + }); + page3Chunks.push(p); + } + while (page3Chunks.length < page1Chunks.length) { + page3Chunks.push(page3Chunks[page3Chunks.length - 1]); + } + } + + // 兜底:确保三组 chunks 长度一致 + const maxLen = Math.max(page1Chunks.length, page2Chunks.length, page3Chunks.length); + while (page1Chunks.length < maxLen) page1Chunks.push(page1Chunks[page1Chunks.length - 1] || ''); + while (page2Chunks.length < maxLen) page2Chunks.push(page2Chunks[page2Chunks.length - 1] || ''); + while (page3Chunks.length < maxLen) page3Chunks.push(page3Chunks[page3Chunks.length - 1] || ''); + + // 向后兼容:page1/page2/page3 取第一段 + return { + page1: page1Chunks[0], + page2: page2Chunks[0], + page3: page3Chunks[0], + page1Chunks, + page2Chunks, + page3Chunks + }; +} + +async function persistRenderedPages(userId) { + const state = await loadSuccessState(userId); + const surgeQueue = await loadSurgeQueue(userId); + const resolvedLinks = await loadResolvedLinks(userId); + const pages = buildPagesFromState(state, resolvedLinks, surgeQueue); + // 存所有分段 + 原始 surgeGroups(供恢复 Product 用) + await KV.put(getPagesKey(userId), JSON.stringify({ + page_1: pages.page1, page_2: pages.page2, page_3: pages.page3, + page1Chunks: pages.page1Chunks, page2Chunks: pages.page2Chunks, page3Chunks: pages.page3Chunks, + surgeGroups: surgeQueue.groups, // ★ 原始分组数据 + stateItems: state.items // ★ 商品价格数据,供恢复时计算总价 + })); + return { ...pages, surgeGroups: surgeQueue.groups, stateItems: state.items }; +} + +async function showQuotePage(chatId, userId, messageId = null) { + const pages = await persistRenderedPages(userId); + const chunks = pages.page1Chunks; + const sentMessageIds = []; // 记录所有发出去的消息 ID,顺序对应 chunk 序号 + + for (let i = 0; i < chunks.length; i++) { + const isLast = i === chunks.length - 1; + const keyboard = isLast ? getPaginationKeyboard('page_1') : null; + + if (i === 0 && messageId) { + // 第一段:edit 原进度消息 + try { + await editTelegramMessage(chatId, messageId, chunks[i], keyboard); + sentMessageIds.push(messageId); + } catch (err) { + console.error('[showQuotePage] edit 第1段失败:', err?.message || err); + const msg = await sendTelegramMessage(chatId, chunks[i], keyboard); + sentMessageIds.push(msg?.result?.message_id || null); + } + } else { + const msg = await sendTelegramMessage(chatId, chunks[i], keyboard); + sentMessageIds.push(msg?.result?.message_id || null); + } + } + + // 存档:以最后一条消息 ID 为 key,同时存入所有消息 ID,供翻页时按序 edit + const lastMsgId = sentMessageIds[sentMessageIds.length - 1] || messageId; + if (lastMsgId) { + await KV.put(getPagesByMsgKey(lastMsgId), JSON.stringify({ + page_1: pages.page1, page_2: pages.page2, page_3: pages.page3, + page1Chunks: pages.page1Chunks, page2Chunks: pages.page2Chunks, page3Chunks: pages.page3Chunks, + messageIds: sentMessageIds, // ★ 所有消息 ID,顺序与 chunk 对应 + surgeGroups: pages.surgeGroups, // ★ 原始分组数据,供恢复 Product 用 + stateItems: pages.stateItems // ★ 商品价格数据,供恢复时计算总价 + })); + + } +} + +// ========================= 单链接解析 ========================= +async function processSingleXboxLink(startUrl, currentRate) { + const urlObj = new URL(startUrl); + urlObj.searchParams.set('r', 'en-us'); + + const redirectResp = await fetchWithTimeout(urlObj.toString(), { redirect: 'follow' }, FETCH_TIMEOUT_MS); + const finalUrl = redirectResp.url; + try { redirectResp.body?.cancel(); } catch (_) {} + + const idMatch = finalUrl.match(/\/([a-zA-Z0-9]{12})(?:[\/?#]|$)/); + if (!idMatch) throw new Error('无法提取 bigId'); + + const bigId = idMatch[1]; + const apiResp = await fetchWithTimeout( + `https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${bigId}&market=NG&languages=en-ng&MS-CV=DUMMY.1`, + {}, FETCH_TIMEOUT_MS + ); + if (!apiResp.ok) throw new Error(`微软接口请求失败: ${apiResp.status}`); + + const data = await apiResp.json(); + if (!data.Products?.length) throw new Error('Products 为空'); + + const product = data.Products[0]; + const gameName = product.LocalizedProperties?.[0]?.ProductTitle || 'Unknown Game'; + + let targetSkuId = '', targetAvailabilityId = ''; + let originalPriceStr = 'N/A', currentPriceStr = 'N/A'; + let originalPriceNum = null, currentPriceNum = null; + + for (const skuObj of product.DisplaySkuAvailabilities || []) { + if (skuObj.Sku?.SkuType === 'full') { + for (const avail of skuObj.Availabilities || []) { + if (avail.Actions?.includes('Purchase')) { + targetSkuId = skuObj.Sku.SkuId; + targetAvailabilityId = avail.AvailabilityId; + if (avail.OrderManagementData?.Price) { + const p = avail.OrderManagementData.Price; + originalPriceNum = p.MSRP; + currentPriceNum = p.ListPrice; + originalPriceStr = `${p.CurrencyCode} ${originalPriceNum}`; + currentPriceStr = `${p.CurrencyCode} ${currentPriceNum}`; + } + break; + } + } + } + if (targetAvailabilityId) break; + } + + if (!targetAvailabilityId) throw new Error('没有找到可购买的 SKU / Availability'); + + return { + sourceUrl: startUrl, resolvedUrl: finalUrl, bigId, gameName, + targetSkuId, targetAvailabilityId, + originalPriceStr, currentPriceStr, originalPriceNum, currentPriceNum, + originalPriceCNY: originalPriceNum !== null ? (originalPriceNum * currentRate).toFixed(2) : 'N/A', + currentPriceCNY: currentPriceNum !== null ? (currentPriceNum * currentRate).toFixed(2) : 'N/A' + }; +} + +// ========================= 美区链接解析(/us 命令用)========================= +async function processSingleXboxLinkUS(startUrl) { + const urlObj = new URL(startUrl); + urlObj.searchParams.set('r', 'en-us'); + + const redirectResp = await fetchWithTimeout(urlObj.toString(), { redirect: 'follow' }, FETCH_TIMEOUT_MS); + const finalUrl = redirectResp.url; + try { redirectResp.body?.cancel(); } catch (_) {} + + const idMatch = finalUrl.match(/\/([a-zA-Z0-9]{12})(?:[\/?#]|$)/); + if (!idMatch) throw new Error('无法提取 bigId'); + + const bigId = idMatch[1]; + const apiResp = await fetchWithTimeout( + `https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${bigId}&market=US&languages=en-us&MS-CV=DUMMY.1`, + {}, FETCH_TIMEOUT_MS + ); + if (!apiResp.ok) throw new Error(`微软接口请求失败: ${apiResp.status}`); + + const data = await apiResp.json(); + if (!data.Products?.length) throw new Error('Products 为空'); + + const product = data.Products[0]; + const gameName = product.LocalizedProperties?.[0]?.ProductTitle || 'Unknown Game'; + + let targetSkuId = '', targetAvailabilityId = ''; + + for (const skuObj of product.DisplaySkuAvailabilities || []) { + if (skuObj.Sku?.SkuType === 'full') { + for (const avail of skuObj.Availabilities || []) { + if (avail.Actions?.includes('Purchase')) { + targetSkuId = skuObj.Sku.SkuId; + targetAvailabilityId = avail.AvailabilityId; + break; + } + } + } + if (targetAvailabilityId) break; + } + + if (!targetAvailabilityId) throw new Error('没有找到可购买的 SKU / Availability'); + + return { bigId, gameName, targetSkuId, targetAvailabilityId }; +} + +// ========================= 运行主逻辑 ========================= +async function runQueueTask(chatId, userId, runId, progressMessageId, targets) { + const total = targets.length; + + try { + const rate = await getRealTimeExchangeRate(); + if (!(await isRunActive(userId, runId))) return; + + const state = await loadSuccessState(userId); + state.lastRate = rate; + await saveSuccessState(userId, state); + + if (progressMessageId) { + try { + await editTelegramMessage(chatId, progressMessageId, + `⏳ 抓取进行中\n\n` + + `${makeProgressBar(0, total, 10)}\n0/${total}\n\n` + + `汇率: 1 NGN ≈ ${Number(rate).toFixed(6)} CNY` + ); + } catch (_) {} + } + + // 全量并发 + 超时保护 + const raceResult = await Promise.race([ + Promise.allSettled(targets.map(url => processSingleXboxLink(url, rate))), + new Promise(resolve => setTimeout(() => resolve('__TIMEOUT__'), RUN_TIMEOUT_MS)) + ]); + + if (raceResult === '__TIMEOUT__') { + await KV.delete(getRunLockKey(userId)); + await clearRunMeta(userId); + await showQuotePage(chatId, userId, progressMessageId); + return; + } + + if (!(await isRunActive(userId, runId))) return; + + // 抓取完成,立即更新消息,告知用户正在写入 + if (progressMessageId) { + try { + await editTelegramMessage(chatId, progressMessageId, + `⏳ 抓取完成,正在写入结果... + +` + + `${makeProgressBar(total, total, 10)} +${total}/${total} + +` + + `汇率: 1 NGN ≈ ${Number(rate).toFixed(6)} CNY` + ); + } catch (_) {} + } + + let successCount = 0, failCount = 0; + const failedUrls = []; + + for (let i = 0; i < targets.length; i++) { + const sourceUrl = targets[i]; + const result = raceResult[i]; + + if (result.status === 'fulfilled') { + const item = result.value; + const freshState = await loadSuccessState(userId); + const freshSurge = await loadSurgeQueue(userId); + + // ★ 双重去重:URL 去重 + ProductId(bigId)去重 + const urlExists = freshState.items.some(x => x.sourceUrl === sourceUrl); + const existingProductIds = new Set( + freshSurge.groups.flatMap(g => Object.values(g.products).map(p => (p.ProductId || '').toUpperCase())) + ); + const productExists = existingProductIds.has((item.bigId || '').toUpperCase()); + + if (!urlExists && !productExists) { + freshState.items.push(item); + freshState.lastRate = rate; + await saveSuccessState(userId, freshState); + await appendProductToSurgeQueue(userId, item); + await appendResolvedLink(userId, { + sourceUrl: item.sourceUrl, + resolvedUrl: item.resolvedUrl, + bigId: item.bigId, + gameName: item.gameName + }); + } else if (productExists) { + console.log(`[run] 跳过重复 ProductId: ${item.bigId} (${item.gameName})`); + } + successCount++; + } else { + // ★ 失败:记录 URL,稍后写回队列 + failedUrls.push(sourceUrl); + failCount++; + } + } + + // ★ 失败的 URL 写回队列,成功的则清掉 + if (failedUrls.length > 0) { + await savePendingQueue(userId, failedUrls); + } else { + await KV.delete(getQueueKey(userId)); + } + + await KV.delete(getRunLockKey(userId)); + await clearRunMeta(userId); + + // 直接展示结果(相当于自动点击"查看信息") + await showQuotePage(chatId, userId, progressMessageId); + + // 有失败的链接,额外通知 + if (failCount > 0) { + await sendTelegramMessage(chatId, + `⚠️ 注意:有 ${failCount} 个链接解析失败,已保留,可重新发送链接重试。` + ); + } + + } catch (error) { + if (String(error?.message || '').includes('汇率')) { + const msg = `❌ 任务终止\n\n获取汇率失败,已停止执行。\n当前待处理仍保留在队列中。`; + if (progressMessageId) { + try { await editTelegramMessage(chatId, progressMessageId, msg, getPaginationKeyboard('page_1')); } + catch (_) { await sendTelegramMessage(chatId, msg); } + } else { + await sendTelegramMessage(chatId, msg); + } + } else { + await sendTelegramMessage(chatId, `❌ 执行异常\n\n${escapeHTML(error?.message || '未知错误')}\n\n已完成的数据不会丢失。`); + } + await KV.delete(getRunLockKey(userId)); + await clearRunMeta(userId); + } +} + +// ========================= Surge 分组翻页 ========================= +function buildSurgeGroupKeyboard(currentIdx, totalGroups) { + const buttons = []; + if (currentIdx > 0) { + buttons.push({ text: '⬅️ 上一组', callback_data: `surge_page_${currentIdx - 1}` }); + } + if (currentIdx < totalGroups - 1) { + buttons.push({ text: '下一组 ➡️', callback_data: `surge_page_${currentIdx + 1}` }); + } + if (!buttons.length) return null; + return { inline_keyboard: [buttons] }; +} + +function buildSurgeGroupText(queue, idx) { + const g = queue.groups[idx]; + return ( + `📦 Surge 分组队列 ${idx + 1} / ${queue.groups.length} + +` + + `组编号: ${g.groupIndex} 商品数: ${g.count} + +` + + `${escapeHTML(JSON.stringify(g.products, null, 2))}` + ); +} + +// ========================= Express ========================= +const app = express(); +app.use(express.json()); + +app.get('/surge', async (req, res) => { + if (req.query.token !== VIEW_TOKEN) return res.status(403).send('Forbidden'); + const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }; + const lockKey = getSurgeLockKey(ALLOWED_USER_ID); + + if (req.query.action === 'clear') { + // ★ clear:只有持有锁的人才能清除,清除后释放锁 + const lock = await KV.get(lockKey); + if (!lock) { + // 没有锁,说明这次是重复触发,直接忽略 + console.log('[surge/clear] 无锁,忽略重复 clear'); + return res.set(headers).json({ ok: true, cleared: false, ignored: true }); + } + await KV.delete(lockKey); + return res.set(headers).json(await popFirstSurgeGroup(ALLOWED_USER_ID)); + } + + // 普通读取:先检查是否已有消费在进行中 + const existingLock = await KV.get(lockKey); + if (existingLock) { + // 已有消费进行中,返回空,防止重复触发 + console.log('[surge/read] 已有消费进行中,返回空'); + return res.set(headers).json({ ok: true, currentGroupIndex: null, currentGroupCount: 0, remainingGroups: 0, remainingAfterCurrent: 0, currentGroup: {} }); + } + + const queue = await loadSurgeQueue(ALLOWED_USER_ID); + if (!queue.groups.length) { + return res.set(headers).json({ ok: true, currentGroupIndex: null, currentGroupCount: 0, remainingGroups: 0, remainingAfterCurrent: 0, currentGroup: {} }); + } + + // 设置消费锁,TTL 60 秒(异常情况自动过期) + await KV.put(lockKey, '1', { expirationTtl: 60 }); + + const current = queue.groups[0]; + return res.set(headers).json({ + ok: true, + currentGroupIndex: current.groupIndex, + currentGroupCount: current.count, + remainingGroups: queue.groups.length, + remainingAfterCurrent: Math.max(queue.groups.length - 1, 0), + currentGroup: current.products + }); +}); + +// POST:Surge clear 接口(避免被 Surge 规则重复拦截) +app.post('/surge/clear', async (req, res) => { + if (req.query.token !== VIEW_TOKEN) return res.status(403).send('Forbidden'); + const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }; + const lockKey = getSurgeLockKey(ALLOWED_USER_ID); + const lock = await KV.get(lockKey); + if (!lock) { + console.log('[surge/clear POST] 无锁,忽略重复 clear'); + return res.set(headers).json({ ok: true, cleared: false, ignored: true }); + } + await KV.delete(lockKey); + console.log(`[surge/clear POST] 收到 clear 请求,时间: ${new Date().toISOString()}`); + return res.set(headers).json(await popFirstSurgeGroup(ALLOWED_USER_ID)); +}); + +// POST:只释放锁,不弹出分组(加购失败时调用,保留数据供重试) +app.post('/surge/unlock', async (req, res) => { + if (req.query.token !== VIEW_TOKEN) return res.status(403).send('Forbidden'); + const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }; + const lockKey = getSurgeLockKey(ALLOWED_USER_ID); + await KV.delete(lockKey); + console.log('[surge/unlock POST] 锁已释放,数据保留'); + return res.set(headers).json({ ok: true, unlocked: true }); +}); + +// POST /surge/commit:提交本次执行结果 +// body: { remaining: { product1: {...}, ... } } +// - remaining 有内容:用 remaining 更新当前组(删掉已成功的),释放锁 +// - remaining 为空:弹出当前组(等同 clear),释放锁 +app.post('/surge/commit', async (req, res) => { + if (req.query.token !== VIEW_TOKEN) return res.status(403).send('Forbidden'); + const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }; + const lockKey = getSurgeLockKey(ALLOWED_USER_ID); + + // 释放锁 + await KV.delete(lockKey); + + const remaining = req.body?.remaining ?? {}; + // 接受任意格式的 key(支持旧版 product1 和新版 (1) GameName) + const remainingKeys = Object.keys(remaining).filter(k => remaining[k]?.ProductId || remaining[k]?.productId); + + if (remainingKeys.length === 0) { + // 全部成功,弹出当前组 + console.log('[surge/commit] 全部成功,弹出当前组'); + return res.set(headers).json(await popFirstSurgeGroup(ALLOWED_USER_ID)); + } + + // 部分失败,用 remaining 更新当前组 + const queue = await loadSurgeQueue(ALLOWED_USER_ID); + if (!queue.groups.length) { + return res.set(headers).json({ ok: true, updated: false, reason: 'no groups' }); + } + + const originalGroup = queue.groups[0]; + const originalGroupIndex = originalGroup.groupIndex; + const originalCount = originalGroup.count; + const failedCount = remainingKeys.length; + const successCount = originalCount - failedCount; + + // 重新编号,保持连续 + const reindexed = {}; + // 保留原 key 名(如 "(1) GameName"),直接写入 + remainingKeys.forEach(k => { reindexed[k] = remaining[k]; }); + + queue.groups[0].products = reindexed; + queue.groups[0].count = failedCount; + await saveSurgeQueue(ALLOWED_USER_ID, queue); + + console.log(`[surge/commit] 部分失败,当前组更新为 ${failedCount} 个`); + + // ★ 发送 bot 通知 + // 直接从 product 的 PriceNGN 字段取价格 + const failedProductIdSet = new Set( + Object.values(remaining).map(p => (p.ProductId || p.productId || '').toUpperCase()) + ); + // 已成功消费 = 原组里不在 remaining 里的 + const successNGN = Object.values(originalGroup.products) + .filter(p => !failedProductIdSet.has((p.ProductId || p.productId || '').toUpperCase())) + .reduce((s, p) => s + (typeof p.PriceNGN === 'number' ? p.PriceNGN : 0), 0); + + // 失败的游戏名称(从 state 里取,取不到就用 ProductId 代替) + const stateForCommit = await loadSuccessState(ALLOWED_USER_ID); + const failedNames = Object.values(remaining).map(p => { + const pid = (p.ProductId || p.productId || '').toUpperCase(); + const found = (stateForCommit.items || []).find(i => (i.bigId || '').toUpperCase() === pid); + return found ? found.gameName : (p.ProductId || p.productId || pid); + }); + + let notifyText = + `⚠️ 第 ${originalGroupIndex} 组部分加购失败 + +` + + `成功: ${successCount} 个 +` + + `失败: ${failedCount} 个(已写回队列等待重试) +`; + + if (failedNames.length > 0) { + notifyText += '\n加购失败的游戏:\n' + failedNames.map(n => `• ${n}`).join('\n') + '\n'; + } + + notifyText += `\n当前组已消费的游戏总价: ${successNGN.toFixed(2)} NGN`; + + sendTelegramMessage(ALLOWED_USER_ID, notifyText).catch(() => {}); + + return res.set(headers).json({ + ok: true, updated: true, + remainingInGroup: failedCount, + remainingGroups: queue.groups.length, + successCount, + failedCount, + successNGN: successNGN, // ★ 已成功消费的游戏总价 + failedNames // ★ 失败的游戏名称列表 + }); +}); + +// POST:更新当前组内容为失败的 product,并释放锁(部分失败时调用) +app.post('/surge/update_group', async (req, res) => { + if (req.query.token !== VIEW_TOKEN) return res.status(403).send('Forbidden'); + const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }; + + let body = {}; + try { body = req.body || {}; } catch (_) {} + + const products = body.products; // { product1: {...}, product2: {...}, ... } + if (!products || typeof products !== 'object' || Object.keys(products).length === 0) { + return res.status(400).set(headers).json({ ok: false, error: 'products 为空或格式错误' }); + } + + const queue = await loadSurgeQueue(ALLOWED_USER_ID); + if (queue.groups.length > 0) { + // 用失败的 product 替换当前第一组内容 + queue.groups[0].products = products; + queue.groups[0].count = Object.keys(products).length; + await saveSurgeQueue(ALLOWED_USER_ID, queue); + console.log(`[surge/update_group POST] 当前组更新为 ${queue.groups[0].count} 个失败 product`); + } + + // 释放锁 + await KV.delete(getSurgeLockKey(ALLOWED_USER_ID)); + return res.set(headers).json({ ok: true, updated: true, count: Object.keys(products).length }); +}); + +app.post('/webhook', async (req, res) => { + if (WEBHOOK_SECRET && req.headers['x-telegram-bot-api-secret-token'] !== WEBHOOK_SECRET) { + return res.status(403).send('Forbidden'); + } + res.send('OK'); + handleUpdate(req.body).catch(err => console.error('[handleUpdate]', err)); +}); + +async function handleUpdate(payload) { + let chatId, userId, text; + let cbId = null, callbackMessageId = null; + + if (payload.callback_query) { + chatId = payload.callback_query.message.chat.id; + userId = payload.callback_query.from.id; + text = payload.callback_query.data; + cbId = payload.callback_query.id; + callbackMessageId = payload.callback_query.message.message_id; + + // ── 恢复 Product 回调 ── + if (text === 'restore_product') { + await answerCallbackQuery(cbId); + let stored = await KV.get(getPagesByMsgKey(callbackMessageId)); + if (!stored) stored = await KV.get(getPagesKey(userId)); + if (!stored) { + await answerCallbackQuery(cbId, '⚠️ 无法读取存档数据'); + return; + } + const pages = JSON.parse(stored); + const surgeGroups = pages.surgeGroups; + if (!surgeGroups || surgeGroups.length === 0) { + await answerCallbackQuery(cbId, '⚠️ 存档中没有 Surge 分组数据'); + return; + } + // 合并所有组的 products 展示给用户确认 + let allProducts = {}; + let pIdx = 1; + for (const g of surgeGroups) { + for (const k of Object.keys(g.products)) { + allProducts[k] = g.products[k]; // 保留原 key(如 "(1) GameName") + pIdx++; + } + } + const totalCount = pIdx - 1; + const confirmKeyboard = { + inline_keyboard: [[ + { text: '✅ 确定恢复', callback_data: 'restore_confirm' }, + { text: '❌ 取消', callback_data: 'restore_cancel' } + ]] + }; + const confirmText = + `🔄 确认恢复 Product? + +` + + `将恢复以下 ${totalCount} 个商品(共 ${surgeGroups.length} 组): + +` + + `${escapeHTML(JSON.stringify(allProducts, null, 2))} + +` + + `⚠️ 此操作将完全替换当前所有 Surge 队列数据。`; + try { + await editTelegramMessage(chatId, callbackMessageId, confirmText, confirmKeyboard); + } catch (_) { + await sendTelegramMessage(chatId, confirmText, confirmKeyboard); + } + return; + } + + if (text === 'restore_confirm') { + await answerCallbackQuery(cbId); + // 从存档里取 surgeGroups 恢复 + let stored = await KV.get(getPagesByMsgKey(callbackMessageId)); + if (!stored) stored = await KV.get(getPagesKey(userId)); + if (!stored) { + await editTelegramMessage(chatId, callbackMessageId, '⚠️ 无法读取存档数据,恢复失败。'); + return; + } + const pages = JSON.parse(stored); + const surgeGroups = pages.surgeGroups; + if (!surgeGroups || surgeGroups.length === 0) { + await editTelegramMessage(chatId, callbackMessageId, '⚠️ 存档中没有分组数据,恢复失败。'); + return; + } + // 完全替换当前 Surge 队列 + const maxIdx = surgeGroups.reduce((m, g) => Math.max(m, g.groupIndex), 0); + await saveSurgeQueue(userId, { nextGroupIndex: maxIdx + 1, groups: surgeGroups }); + const totalRestored = surgeGroups.reduce((s, g) => s + g.count, 0); + + // 第一步:把确认消息那条消息恢复为原始三元组页面(带翻页按钮) + const restoredPage = pages.page_1 || pages.page1Chunks?.[0] || ''; + const restoredMessageIds = pages.messageIds; + if (restoredMessageIds && restoredMessageIds.length > 1) { + // 多段:逐条恢复 + const restoredChunks = pages.page1Chunks || []; + for (let i = 0; i < restoredChunks.length; i++) { + const isLast = i === restoredChunks.length - 1; + const kb = isLast ? getPaginationKeyboard('page_1') : null; + const msgId = restoredMessageIds[i]; + if (i === 0) { + // 第一条就是 callbackMessageId + try { await editTelegramMessage(chatId, callbackMessageId, restoredChunks[i], kb); } catch (_) {} + } else if (msgId) { + try { await editTelegramMessage(chatId, msgId, restoredChunks[i], kb); } catch (_) {} + } + } + } else { + // 单段 + try { await editTelegramMessage(chatId, callbackMessageId, restoredPage, getPaginationKeyboard('page_1')); } catch (_) {} + } + + // 第二步:发送通知消息 + // 直接从 surgeGroups 的 PriceNGN 字段取价格(最可靠,不依赖 stateItems) + const ngnSum = surgeGroups.reduce((s, g) => + s + Object.values(g.products).reduce((gs, p) => gs + (typeof p.PriceNGN === 'number' ? p.PriceNGN : 0), 0), 0); + const ngnStr = ngnSum > 0 ? `${ngnSum.toFixed(2)} NGN` : '未知'; + await sendTelegramMessage(chatId, + `✅ Product 已恢复\n\n` + + `已用历史记录完全替换当前 Surge 队列。\n` + + `共 ${surgeGroups.length} 组,合计 ${totalRestored} 个商品,共 ${ngnStr}。` + ); + return; + } + + if (text === 'restore_cancel') { + await answerCallbackQuery(cbId, '已取消'); + // 恢复原来的翻页键盘 + let stored = await KV.get(getPagesByMsgKey(callbackMessageId)); + if (!stored) stored = await KV.get(getPagesKey(userId)); + if (stored) { + const pages = JSON.parse(stored); + try { + await editTelegramMessage(chatId, callbackMessageId, pages.page_1 || pages.page1Chunks?.[0] || '', getPaginationKeyboard('page_1')); + } catch (_) {} + } + return; + } + + if (text.startsWith('surge_page_')) { + const idx = parseInt(text.replace('surge_page_', ''), 10); + const queue = await loadSurgeQueue(userId); + await answerCallbackQuery(cbId); + if (!queue.groups.length || idx < 0 || idx >= queue.groups.length) { + await answerCallbackQuery(cbId, '⚠️ 该分组不存在'); + return; + } + try { + await editTelegramMessage( + chatId, callbackMessageId, + buildSurgeGroupText(queue, idx), + buildSurgeGroupKeyboard(idx, queue.groups.length) + ); + } catch (_) {} + return; + } + + if (text.startsWith('page_')) { + let stored = await KV.get(getPagesByMsgKey(callbackMessageId)); + if (!stored) stored = await KV.get(getPagesKey(userId)); + + if (stored) { + const pages = JSON.parse(stored); + const chunkKey = text.replace('page_', 'page') + 'Chunks'; // page1Chunks / page2Chunks / page3Chunks + const chunks = pages[chunkKey]; + const messageIds = pages.messageIds; // 存档里的所有消息 ID + + await answerCallbackQuery(cbId); + + if (chunks && chunks.length > 1) { + if (messageIds && messageIds.length === chunks.length) { + // 已有所有消息 ID:直接逐条 edit,原地覆盖 + for (let i = 0; i < chunks.length; i++) { + const isLast = i === chunks.length - 1; + const keyboard = isLast ? getPaginationKeyboard(text) : null; + const targetMsgId = messageIds[i]; + if (targetMsgId) { + try { await editTelegramMessage(chatId, targetMsgId, chunks[i], keyboard); } + catch (_) {} + } + } + } else { + // 首次展开多段(从摘要点击进来,没有 messageIds): + // edit 当前消息为第一段,其余段发新消息,收集所有 ID 写回存档 + const newMessageIds = []; + for (let i = 0; i < chunks.length; i++) { + const isLast = i === chunks.length - 1; + const keyboard = isLast ? getPaginationKeyboard(text) : null; + if (i === 0) { + try { await editTelegramMessage(chatId, callbackMessageId, chunks[i], keyboard); } + catch (_) { await sendTelegramMessage(chatId, chunks[i], keyboard); } + newMessageIds.push(callbackMessageId); + } else { + const msg = await sendTelegramMessage(chatId, chunks[i], keyboard); + newMessageIds.push(msg?.result?.message_id || null); + } + } + // 以最后一条消息 ID 为 key 更新存档,存入 messageIds 供后续翻页使用 + const lastMsgId = newMessageIds[newMessageIds.length - 1] || callbackMessageId; + pages.messageIds = newMessageIds; + await KV.put(getPagesByMsgKey(lastMsgId), JSON.stringify(pages)); // pages 已含 surgeGroups/stateItems + } + } else if (pages[text]) { + // 单段 + try { await editTelegramMessage(chatId, callbackMessageId, pages[text], getPaginationKeyboard(text)); } + catch (_) {} + } else { + await answerCallbackQuery(cbId, '⚠️ 页面不存在'); + } + } else { + await answerCallbackQuery(cbId, '⚠️ 页面数据为空'); + } + return; + } + + if (text === 'action_run') await answerCallbackQuery(cbId); + + } else if (payload.message?.text) { + chatId = payload.message.chat.id; + userId = payload.message.from.id; + text = payload.message.text.trim(); + } else { + return; + } + + if (userId !== ALLOWED_USER_ID) return; + + // ── /us ── + if (text.toLowerCase().startsWith('/us')) { + // 合并 list 中的链接 + 消息中内联的链接,去重 + const listQueue = await loadPendingQueue(userId); + const inlineUrls = text.match(/(https?:\/\/[^\s]+)/g) || []; + const merged = [...new Set([...listQueue, ...inlineUrls])]; + + if (merged.length === 0) { + await sendTelegramMessage(chatId, '📭 链接队列为空,且消息中未检测到链接。'); + return; + } + + const listCount = listQueue.length; + const inlineCount = [...new Set(inlineUrls)].filter(u => !new Set(listQueue).has(u)).length; + const total = merged.length; + const initMsg = await sendTelegramMessage(chatId, + `⏳ 正在获取美区参数...\n\n共 ${total} 个链接` + + (listCount > 0 && inlineCount > 0 ? `(队列 ${listCount} 个 + 新增 ${inlineCount} 个)` : + listCount > 0 ? `(来自队列)` : `(来自消息)`) + ); + const msgId = initMsg?.result?.message_id || null; + + // 并发抓取 + const results = await Promise.allSettled( + merged.map(url => processSingleXboxLinkUS(url)) + ); + + // 处理队列:只有原本在 listQueue 里的 URL 才参与写回逻辑 + const listQueueSet = new Set(listQueue); + const failedListUrls = merged.filter((url, i) => + results[i].status === 'rejected' && listQueueSet.has(url) + ); + if (failedListUrls.length > 0) { + // 有失败的队列链接,写回 + await savePendingQueue(userId, failedListUrls); + } else if (listCount > 0) { + // 队列链接全部成功,清空 list + await KV.delete(getQueueKey(userId)); + } + + // 构建 Surge 参数 JSON + const products = {}; + const failedUrls = []; + let idx = 1; + + for (let i = 0; i < results.length; i++) { + if (results[i].status === 'fulfilled') { + const item = results[i].value; + const safeUsName = (item.gameName || 'Unknown').replace(/['"]/g, ''); + products[`(${idx++}) ${safeUsName}`] = { + ProductId: item.bigId, + SkuId: item.targetSkuId, + AvailabilityId: item.targetAvailabilityId, + PriceNGN: 0 // 美区无 NGN 价格,标记为 0,不影响 AddToCart + }; + } else { + failedUrls.push(pendingQueue[i]); + } + } + + const successCount = idx - 1; + const failCount = failedUrls.length; + + const jsonStr = escapeHTML(JSON.stringify(products, null, 2)); + const header = + `✅ 美区参数获取完成\n\n` + + `成功: ${successCount} 个\n` + + `失败: ${failCount} 个\n\n`; + const footer = failCount > 0 + ? '\n\n⚠️ 失败链接:\n' + failedUrls.map((u, i) => `${i+1}. ${escapeHTML(u)}`).join('\n') + : ''; + + // 如果 JSON 太长,分多条消息发送 + const MAX_JSON = 3000; + const jsonLines = JSON.stringify(products, null, 2); + if (jsonStr.length <= MAX_JSON) { + const resultText = header + `${jsonStr}` + footer; + if (msgId) { + try { await editTelegramMessage(chatId, msgId, resultText, null); } + catch (_) { await sendTelegramMessage(chatId, resultText); } + } else { + await sendTelegramMessage(chatId, resultText); + } + } else { + // 分段:头部信息先发,然后每段最多 3500 字符的 JSON + if (msgId) { + try { await editTelegramMessage(chatId, msgId, header + '(JSON 较长,分段发送)'); } + catch (_) {} + } + // 按 product key 分段 + const entries = Object.entries(products); + let chunk = {}; + for (const [k, v] of entries) { + const testChunk = { ...chunk, [k]: v }; + if (escapeHTML(JSON.stringify(testChunk)).length > MAX_JSON && Object.keys(chunk).length > 0) { + await sendTelegramMessage(chatId, `${escapeHTML(JSON.stringify(chunk, null, 2))}`); + chunk = { [k]: v }; + } else { + chunk[k] = v; + } + } + if (Object.keys(chunk).length > 0) { + await sendTelegramMessage(chatId, `${escapeHTML(JSON.stringify(chunk, null, 2))}` + footer); + } else if (footer) { + await sendTelegramMessage(chatId, footer); + } + } + return; + } + + if (text.toLowerCase() === '/view_product') { + const queue = await loadSurgeQueue(userId); + if (!queue.groups.length) { + await sendTelegramMessage(chatId, '📭 当前没有待同步的 Surge 分组数据。'); + } else { + await sendTelegramMessage( + chatId, + buildSurgeGroupText(queue, 0), + buildSurgeGroupKeyboard(0, queue.groups.length) + ); + } + return; + } + + if (text.toLowerCase().startsWith('/update_product')) { + let rawStr = text.substring('/update_product'.length).trim(); + + // ★ 自动从任意文本中提取第一个合法 JSON 对象 + // 支持直接粘贴 /view_product 完整输出,自动抽出 JSON 部分 + if (rawStr) { + const jsonMatch = rawStr.match(/\{[\s\S]*\}/); + if (jsonMatch) rawStr = jsonMatch[0]; + } + + if (!rawStr) { await sendTelegramMessage(chatId, '⚠️ 请在命令后附带合法的 JSON 字符串。'); return; } + try { + const parsedData = JSON.parse(rawStr); + const keys = Object.keys(parsedData || {}); + await saveSurgeQueue(userId, { nextGroupIndex: 2, groups: keys.length > 0 ? [{ groupIndex: 1, count: keys.length, products: parsedData }] : [] }); + await persistRenderedPages(userId); + await sendTelegramMessage(chatId, `✅ 成功覆盖更新 Surge 分组数据!\n\n已设置 ${keys.length} 个商品。`); + } catch (e) { await sendTelegramMessage(chatId, `❌ JSON 格式错误:\n${escapeHTML(e.message)}`); } + return; + } + + if (text.toLowerCase() === '/view_list') { + const urls = await loadPendingQueue(userId); + if (urls.length === 0) { await sendTelegramMessage(chatId, '📭 当前待处理链接队列为空。'); } + else { + let listText = `📋 当前待处理链接(共 ${urls.length} 个):\n\n`; + urls.forEach((url, i) => { listText += `${i + 1}. ${escapeHTML(url)}\n`; }); + await sendTelegramMessage(chatId, listText); + } + return; + } + + if (text.toLowerCase() === '/force_stop') { + const meta = await loadRunMeta(userId); + if (!meta) { await sendTelegramMessage(chatId, 'ℹ️ 当前没有正在执行的任务。'); return; } + await KV.delete(getRunLockKey(userId)); + await clearRunMeta(userId); + await showQuotePage(chatId, userId, meta.progressMessageId); + return; + } + + if (text.toLowerCase() === '/clear') { + await KV.delete(getQueueKey(userId)); + await KV.delete(getStateKey(userId)); + await KV.delete(getPagesKey(userId)); + await KV.delete(getRunMetaKey(userId)); + await KV.delete(getSurgeQueueKey(userId)); + await KV.delete(getResolvedLinksKey(userId)); + await KV.delete('LATEST_XBOX_LIST'); + await KV.delete(getRunLockKey(userId)); + await sendTelegramMessage(chatId, '🗑️ 已彻底清空待处理队列成功解析记录已解析链接记录分页缓存与云端 Product 数据。'); + return; + } + + if (text.toLowerCase() === '/run' || text === 'action_run') { + let isRunning = await KV.get(getRunLockKey(userId)); + if (isRunning) { + const meta = await loadRunMeta(userId); + if (!meta) { await KV.delete(getRunLockKey(userId)); isRunning = null; } + } + if (isRunning) { await sendTelegramMessage(chatId, '⏳ 当前已有任务正在执行中,请先 /force_stop。'); return; } + + const pendingQueue = await loadPendingQueue(userId); + if (pendingQueue.length === 0) { await sendTelegramMessage(chatId, '📭 链接队列为空,请先发送游戏链接。'); return; } + + // ★ 全新任务(state 为空)→ 重置 Surge 队列组号从 1 开始 + const existingState = await loadSuccessState(userId); + if (existingState.items.length === 0) { + await resetSurgeQueue(userId); + } + + const total = pendingQueue.length; + const initText = + `⏳ 准备开始抓取\n\n` + + `共 ${total} 个链接,全量并发执行\n\n` + + `${makeProgressBar(0, total)}\n0/${total}`; + + let progressMessageId; + if (callbackMessageId) { + await editTelegramMessage(chatId, callbackMessageId, initText, null); + progressMessageId = callbackMessageId; + } else { + const startMsg = await sendTelegramMessage(chatId, initText); + progressMessageId = startMsg?.result?.message_id || null; + } + + const runId = crypto.randomUUID(); + await KV.put(getRunLockKey(userId), runId, { expirationTtl: RUN_LOCK_TTL }); + await saveRunMeta(userId, { runId, chatId, progressMessageId, total, processed: 0, startedAt: new Date().toISOString() }); + + runQueueTask(chatId, userId, runId, progressMessageId, pendingQueue) + .catch(err => console.error('[runQueueTask]', err)); + return; + } + + const newUrls = text.match(/(https?:\/\/[^\s]+)/g); + if (newUrls?.length > 0) { + const state = await loadSuccessState(userId); + const resolvedLinks = await loadResolvedLinks(userId); + const successSet = new Set((state.items || []).map(x => x.sourceUrl)); + const resolvedSet = new Set(resolvedLinks.map(x => x.sourceUrl)); + const reallyNew = newUrls.filter(url => !successSet.has(url) && !resolvedSet.has(url)); + + if (reallyNew.length === 0) { + // 全部已解析过,直接显示当前结果 + await showQuotePage(chatId, userId); + return; + } + + // ★ 检查是否已有任务在跑 + let isRunning = await KV.get(getRunLockKey(userId)); + if (isRunning) { + const meta = await loadRunMeta(userId); + if (!meta) { await KV.delete(getRunLockKey(userId)); isRunning = null; } + } + if (isRunning) { + await sendTelegramMessage(chatId, '⏳ 当前已有任务正在执行中,请先 /force_stop。'); + return; + } + + // 写入队列 + const pendingQueue = await loadPendingQueue(userId); + const pendingSet = new Set(pendingQueue); + const toAdd = reallyNew.filter(url => !pendingSet.has(url)); + const merged = [...pendingQueue, ...toAdd]; + await savePendingQueue(userId, merged); + + // 重置 Surge 队列组号(全新任务) + const existingState = await loadSuccessState(userId); + if (existingState.items.length === 0) { + await resetSurgeQueue(userId); + } + + const total = merged.length; + const initText = + `⏳ 正在解析链接...\n\n` + + `共 ${reallyNew.length} 个新链接\n\n` + + `${makeProgressBar(0, total)} 0/${total}`; + + const startMsg = await sendTelegramMessage(chatId, initText); + const progressMessageId = startMsg?.result?.message_id || null; + + const runId = crypto.randomUUID(); + await KV.put(getRunLockKey(userId), runId, { expirationTtl: RUN_LOCK_TTL }); + await saveRunMeta(userId, { runId, chatId, progressMessageId, total, processed: 0, startedAt: new Date().toISOString() }); + + runQueueTask(chatId, userId, runId, progressMessageId, merged) + .catch(err => console.error('[runQueueTask]', err)); + } +} + +// ========================= 启动 ========================= +app.listen(PORT, () => { + console.log(`[Bot] 服务已启动,监听端口 ${PORT}`); + console.log(`[Bot] Webhook 地址: POST /webhook`); + console.log(`[Bot] Surge 接口: GET /surge?token=`); + scheduleNextClean(); +});