1549 lines
61 KiB
JavaScript
1549 lines
61 KiB
JavaScript
'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, '>').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 ? `,游戏总价 <b>${groupNGN.toFixed(2)} NGN</b>` : '';
|
||
|
||
let notifyText;
|
||
if (isLastGroup) {
|
||
notifyText =
|
||
`📦 <b>第 ${removed.groupIndex} 组 Product 已同步完成${ngnStr}</b>
|
||
|
||
` +
|
||
`✅ 所有分组已全部消费完毕,本次任务数据已清理。
|
||
` +
|
||
`待处理链接队列: <b>${pendingQueue.length}</b> 个`;
|
||
} else {
|
||
notifyText =
|
||
`📦 <b>第 ${removed.groupIndex} 组 Product 已同步完成${ngnStr}</b>
|
||
|
||
` +
|
||
`剩余待同步分组: <b>${queue.groups.length}</b> 组
|
||
` +
|
||
`下一组: 第 <b>${queue.groups[0].groupIndex}</b> 组(共 <b>${queue.groups[0].count}</b> 个商品)
|
||
` +
|
||
`待处理链接队列: <b>${pendingQueue.length}</b> 个`;
|
||
}
|
||
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 = '<b>🎮 游戏比价及代购信息</b>';
|
||
if (totalGroups > 1) header += ` <b>(第 ${g + 1} 组 / 共 ${totalGroups} 组)</b>`;
|
||
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 + `<pre>${escapeHTML(text.trim())}</pre>`);
|
||
}
|
||
}
|
||
|
||
// ── page2:每段对应同序号的 Surge 分组 ──
|
||
const page2Chunks = [];
|
||
if (groups.length === 0) {
|
||
page2Chunks.push('<b>📦 Surge 分组队列</b>\n\n📭 当前没有待同步的 Surge 数据。');
|
||
} else {
|
||
groups.forEach((group, idx) => {
|
||
let p = '<b>📦 Surge 分组队列</b>\n\n';
|
||
p += `当前剩余 <b>${groups.length}</b> 组待同步\n\n`;
|
||
p += `<b>【 第 ${group.groupIndex} 组 】</b>共 ${group.count} 个商品\n`;
|
||
p += `<code>${escapeHTML(JSON.stringify(group.products, null, 2))}</code>`;
|
||
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('<b>🔗 已解析链接记录</b>\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 = '<b>🔗 已解析链接记录</b>\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,
|
||
`⏳ <b>抓取进行中</b>\n\n` +
|
||
`${makeProgressBar(0, total, 10)}\n<b>0/${total}</b>\n\n` +
|
||
`汇率: <b>1 NGN ≈ ${Number(rate).toFixed(6)} CNY</b>`
|
||
);
|
||
} 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,
|
||
`⏳ <b>抓取完成,正在写入结果...</b>
|
||
|
||
` +
|
||
`${makeProgressBar(total, total, 10)}
|
||
<b>${total}/${total}</b>
|
||
|
||
` +
|
||
`汇率: <b>1 NGN ≈ ${Number(rate).toFixed(6)} CNY</b>`
|
||
);
|
||
} 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,
|
||
`⚠️ <b>注意</b>:有 <b>${failCount}</b> 个链接解析失败,已保留,可重新发送链接重试。`
|
||
);
|
||
}
|
||
|
||
} catch (error) {
|
||
if (String(error?.message || '').includes('汇率')) {
|
||
const msg = `❌ <b>任务终止</b>\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, `❌ <b>执行异常</b>\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 (
|
||
`📦 <b>Surge 分组队列</b> <b>${idx + 1} / ${queue.groups.length}</b>
|
||
|
||
` +
|
||
`组编号: <b>${g.groupIndex}</b> 商品数: <b>${g.count}</b>
|
||
|
||
` +
|
||
`<code>${escapeHTML(JSON.stringify(g.products, null, 2))}</code>`
|
||
);
|
||
}
|
||
|
||
// ========================= 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 =
|
||
`⚠️ <b>第 ${originalGroupIndex} 组部分加购失败</b>
|
||
|
||
` +
|
||
`成功: <b>${successCount}</b> 个
|
||
` +
|
||
`失败: <b>${failedCount}</b> 个(已写回队列等待重试)
|
||
`;
|
||
|
||
if (failedNames.length > 0) {
|
||
notifyText += '\n加购失败的游戏:\n' + failedNames.map(n => `• ${n}`).join('\n') + '\n';
|
||
}
|
||
|
||
notifyText += `\n当前组已消费的游戏总价: <b>${successNGN.toFixed(2)} NGN</b>`;
|
||
|
||
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 =
|
||
`🔄 <b>确认恢复 Product?</b>
|
||
|
||
` +
|
||
`将恢复以下 <b>${totalCount}</b> 个商品(共 ${surgeGroups.length} 组):
|
||
|
||
` +
|
||
`<code>${escapeHTML(JSON.stringify(allProducts, null, 2))}</code>
|
||
|
||
` +
|
||
`⚠️ 此操作将完全替换当前所有 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,
|
||
`✅ <b>Product 已恢复</b>\n\n` +
|
||
`已用历史记录完全替换当前 Surge 队列。\n` +
|
||
`共 <b>${surgeGroups.length}</b> 组,合计 <b>${totalRestored}</b> 个商品,共 <b>${ngnStr}</b>。`
|
||
);
|
||
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,
|
||
`⏳ <b>正在获取美区参数...</b>\n\n共 <b>${total}</b> 个链接` +
|
||
(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 =
|
||
`✅ <b>美区参数获取完成</b>\n\n` +
|
||
`成功: <b>${successCount}</b> 个\n` +
|
||
`失败: <b>${failCount}</b> 个\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 + `<code>${jsonStr}</code>` + 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, `<code>${escapeHTML(JSON.stringify(chunk, null, 2))}</code>`);
|
||
chunk = { [k]: v };
|
||
} else {
|
||
chunk[k] = v;
|
||
}
|
||
}
|
||
if (Object.keys(chunk).length > 0) {
|
||
await sendTelegramMessage(chatId, `<code>${escapeHTML(JSON.stringify(chunk, null, 2))}</code>` + 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已设置 <b>${keys.length}</b> 个商品。`);
|
||
} 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 = `📋 <b>当前待处理链接(共 ${urls.length} 个):</b>\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, '🗑️ 已彻底清空<b>待处理队列</b>、<b>成功解析记录</b>、<b>已解析链接记录</b>、<b>分页缓存</b>与云端 <b>Product 数据</b>。');
|
||
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 =
|
||
`⏳ <b>准备开始抓取</b>\n\n` +
|
||
`共 <b>${total}</b> 个链接,全量并发执行\n\n` +
|
||
`${makeProgressBar(0, total)}\n<b>0/${total}</b>`;
|
||
|
||
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 =
|
||
`⏳ <b>正在解析链接...</b>\n\n` +
|
||
`共 <b>${reallyNew.length}</b> 个新链接\n\n` +
|
||
`${makeProgressBar(0, total)} <b>0/${total}</b>`;
|
||
|
||
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=<VIEW_TOKEN>`);
|
||
scheduleNextClean();
|
||
});
|