'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=