Files
telegram_private_chatbot/worker.js

1264 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Cloudflare WorkerTelegram 双向机器人 v5.1
// --- 配置常量 ---
const CONFIG = {
VERIFY_ID_LENGTH: 12,
VERIFY_EXPIRE_SECONDS: 300, // 5分钟
VERIFIED_EXPIRE_SECONDS: 2592000, // 30天
MEDIA_GROUP_EXPIRE_SECONDS: 60,
MEDIA_GROUP_DELAY_MS: 3000, // 3秒从2秒增加
RATE_LIMIT_MESSAGE: 45,
RATE_LIMIT_VERIFY: 3,
RATE_LIMIT_WINDOW: 60,
BUTTON_COLUMNS: 2,
MAX_TITLE_LENGTH: 128,
MAX_NAME_LENGTH: 30,
API_TIMEOUT_MS: 10000,
CLEANUP_BATCH_SIZE: 10,
MAX_CLEANUP_DISPLAY: 20,
MAX_RETRY_ATTEMPTS: 3,
THREAD_HEALTH_TTL_MS: 60000
};
// 线程健康检查缓存,减少频繁探测请求
const threadHealthCache = new Map();
// --- 本地题库 (15条) ---
const LOCAL_QUESTIONS = [
{"question": "冰融化后会变成什么?", "correct_answer": "水", "incorrect_answers": ["石头", "木头", "火"]},
{"question": "正常人有几只眼睛?", "correct_answer": "2", "incorrect_answers": ["1", "3", "4"]},
{"question": "以下哪个属于水果?", "correct_answer": "香蕉", "incorrect_answers": ["白菜", "猪肉", "大米"]},
{"question": "1 加 2 等于几?", "correct_answer": "3", "incorrect_answers": ["2", "4", "5"]},
{"question": "5 减 2 等于几?", "correct_answer": "3", "incorrect_answers": ["1", "2", "4"]},
{"question": "2 乘以 3 等于几?", "correct_answer": "6", "incorrect_answers": ["4", "5", "7"]},
{"question": "10 加 5 等于几?", "correct_answer": "15", "incorrect_answers": ["10", "12", "20"]},
{"question": "8 减 4 等于几?", "correct_answer": "4", "incorrect_answers": ["2", "3", "5"]},
{"question": "在天上飞的交通工具是什么?", "correct_answer": "飞机", "incorrect_answers": ["汽车", "轮船", "自行车"]},
{"question": "星期一的后面是星期几?", "correct_answer": "星期二", "incorrect_answers": ["星期日", "星期五", "星期三"]},
{"question": "鱼通常生活在哪里?", "correct_answer": "水里", "incorrect_answers": ["树上", "土里", "火里"]},
{"question": "我们用什么器官来听声音?", "correct_answer": "耳朵", "incorrect_answers": ["眼睛", "鼻子", "嘴巴"]},
{"question": "晴朗的天空通常是什么颜色的?", "correct_answer": "蓝色", "incorrect_answers": ["绿色", "红色", "紫色"]},
{"question": "太阳从哪个方向升起?", "correct_answer": "东方", "incorrect_answers": ["西方", "南方", "北方"]},
{"question": "小狗发出的叫声通常是?", "correct_answer": "汪汪", "incorrect_answers": ["喵喵", "咩咩", "呱呱"]}
];
// --- 辅助工具函数 ---
// 结构化日志系统
const Logger = {
/**
* 记录信息级别日志
* @param {string} action - 操作名称
* @param {object} data - 附加数据
*/
info(action, data = {}) {
const log = {
timestamp: new Date().toISOString(),
level: 'INFO',
action,
...data
};
console.log(JSON.stringify(log));
},
/**
* 记录警告级别日志
* @param {string} action - 操作名称
* @param {object} data - 附加数据
*/
warn(action, data = {}) {
const log = {
timestamp: new Date().toISOString(),
level: 'WARN',
action,
...data
};
console.warn(JSON.stringify(log));
},
/**
* 记录错误级别日志
* @param {string} action - 操作名称
* @param {Error|string} error - 错误对象或消息
* @param {object} data - 附加数据
*/
error(action, error, data = {}) {
const log = {
timestamp: new Date().toISOString(),
level: 'ERROR',
action,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
...data
};
console.error(JSON.stringify(log));
},
/**
* 记录调试级别日志
* @param {string} action - 操作名称
* @param {object} data - 附加数据
*/
debug(action, data = {}) {
const log = {
timestamp: new Date().toISOString(),
level: 'DEBUG',
action,
...data
};
console.log(JSON.stringify(log));
}
};
// 加密安全的随机数生成
function secureRandomInt(min, max) {
const range = max - min;
const bytes = new Uint32Array(1);
crypto.getRandomValues(bytes);
return min + (bytes[0] % range);
}
function secureRandomId(length = 12) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => chars[b % chars.length]).join('');
}
// 安全的 JSON 获取
async function safeGetJSON(env, key, defaultValue = null) {
try {
const data = await env.TOPIC_MAP.get(key, { type: "json" });
if (data === null || data === undefined) {
return defaultValue;
}
if (typeof data !== 'object') {
Logger.warn('kv_invalid_type', { key, type: typeof data });
return defaultValue;
}
return data;
} catch (e) {
Logger.error('kv_parse_failed', e, { key });
return defaultValue;
}
}
// 获取所有 KV keys处理分页
async function getAllKeys(env, prefix) {
const allKeys = [];
let cursor = undefined;
do {
const result = await env.TOPIC_MAP.list({ prefix, cursor });
allKeys.push(...result.keys);
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
return allKeys;
}
// Fisher-Yates 洗牌算法
function shuffleArray(arr) {
const array = [...arr];
for (let i = array.length - 1; i > 0; i--) {
const j = secureRandomInt(0, i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
// 速率限制检查
async function checkRateLimit(userId, env, action = 'message', limit = 20, window = 60) {
const key = `ratelimit:${action}:${userId}`;
const countStr = await env.TOPIC_MAP.get(key);
const count = parseInt(countStr || "0");
if (count >= limit) {
return { allowed: false, remaining: 0 };
}
await env.TOPIC_MAP.put(key, String(count + 1), { expirationTtl: window });
return { allowed: true, remaining: limit - count - 1 };
}
export default {
async fetch(request, env, ctx) {
// 环境自检
if (!env.TOPIC_MAP) return new Response("Error: KV 'TOPIC_MAP' not bound.");
if (!env.BOT_TOKEN) return new Response("Error: BOT_TOKEN not set.");
if (!env.SUPERGROUP_ID) return new Response("Error: SUPERGROUP_ID not set.");
// 【修复 #7】规范化环境变量统一为字符串类型
const normalizedEnv = {
...env,
SUPERGROUP_ID: String(env.SUPERGROUP_ID),
BOT_TOKEN: String(env.BOT_TOKEN)
};
// 验证 SUPERGROUP_ID 格式
if (!normalizedEnv.SUPERGROUP_ID.startsWith("-100")) {
return new Response("Error: SUPERGROUP_ID must start with -100");
}
if (request.method !== "POST") return new Response("OK");
// 验证 Content-Type
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
Logger.warn('invalid_content_type', { contentType });
return new Response("OK");
}
let update;
try {
update = await request.json();
// 验证基本结构
if (!update || typeof update !== 'object') {
Logger.warn('invalid_json_structure', { update: typeof update });
return new Response("OK");
}
} catch (e) {
Logger.error('json_parse_failed', e);
return new Response("OK");
}
if (update.callback_query) {
await handleCallbackQuery(update.callback_query, normalizedEnv, ctx);
return new Response("OK");
}
const msg = update.message;
if (!msg) return new Response("OK");
ctx.waitUntil(flushExpiredMediaGroups(normalizedEnv, Date.now()));
if (msg.chat && msg.chat.type === "private") {
try {
await handlePrivateMessage(msg, normalizedEnv, ctx);
} catch (e) {
// 不向用户泄露技术细节
const errText = `⚠️ 系统繁忙,请稍后再试。`;
await tgCall(normalizedEnv, "sendMessage", { chat_id: msg.chat.id, text: errText });
Logger.error('private_message_failed', e, { userId: msg.chat.id });
}
return new Response("OK");
}
// 【修复 #7】使用字符串比较
if (msg.chat && String(msg.chat.id) === normalizedEnv.SUPERGROUP_ID) {
if (msg.forum_topic_closed && msg.message_thread_id) {
await updateThreadStatus(msg.message_thread_id, true, normalizedEnv);
return new Response("OK");
}
if (msg.forum_topic_reopened && msg.message_thread_id) {
await updateThreadStatus(msg.message_thread_id, false, normalizedEnv);
return new Response("OK");
}
// 【修复】支持 General 话题和普通话题
// General 话题的 message_thread_id 可能不存在,或者等于 1
if (msg.message_thread_id || msg.text) {
await handleAdminReply(msg, normalizedEnv, ctx);
return new Response("OK");
}
}
return new Response("OK");
},
};
// ---------------- 核心业务逻辑 ----------------
async function handlePrivateMessage(msg, env, ctx) {
const userId = msg.chat.id;
const key = `user:${userId}`;
// 速率限制检查
const rateLimit = await checkRateLimit(userId, env, 'message', CONFIG.RATE_LIMIT_MESSAGE, CONFIG.RATE_LIMIT_WINDOW);
if (!rateLimit.allowed) {
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "⚠️ 发送过于频繁,请稍后再试。"
});
return;
}
// 拦截普通用户发送的指令
if (msg.text && msg.text.startsWith("/") && msg.text.trim() !== "/start") {
return;
}
const isBanned = await env.TOPIC_MAP.get(`banned:${userId}`);
if (isBanned) return;
const verified = await env.TOPIC_MAP.get(`verified:${userId}`);
if (!verified) {
// 验证请求速率限制
const verifyLimit = await checkRateLimit(userId, env, 'verify', CONFIG.RATE_LIMIT_VERIFY, 300);
if (!verifyLimit.allowed) {
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "⚠️ 验证请求过于频繁请5分钟后再试。"
});
return;
}
const isStart = msg.text && msg.text.trim() === "/start";
const pendingMsgId = isStart ? null : msg.message_id;
await sendVerificationChallenge(userId, env, pendingMsgId);
return;
}
await forwardToTopic(msg, userId, key, env, ctx);
}
async function forwardToTopic(msg, userId, key, env, ctx) {
// 【修复 #4】使用安全的 JSON 解析
let rec = await safeGetJSON(env, key, null);
if (rec && rec.closed) {
await tgCall(env, "sendMessage", { chat_id: userId, text: "🚫 当前对话已被管理员关闭。" });
return;
}
// 【修复 #5】重试计数器防止无限循环
const retryKey = `retry:${userId}`;
let retryCount = parseInt(await env.TOPIC_MAP.get(retryKey) || "0");
if (retryCount > CONFIG.MAX_RETRY_ATTEMPTS) {
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "❌ 系统繁忙,请稍后再试。"
});
await env.TOPIC_MAP.delete(retryKey);
return;
}
if (!rec || !rec.thread_id) {
rec = await createTopic(msg.from, key, env, userId);
if (!rec || !rec.thread_id) {
throw new Error("创建话题失败");
}
}
// 补建 thread->user 映射(兼容旧数据)
if (rec && rec.thread_id) {
const mappedUser = await env.TOPIC_MAP.get(`thread:${rec.thread_id}`);
if (!mappedUser) {
await env.TOPIC_MAP.put(`thread:${rec.thread_id}`, String(userId));
}
}
// 【修复1】验证话题是否仍然存在带缓存降低探测频率
// 当话题被删除后KV中的thread_id仍然存在但实际话题已不可用
if (rec && rec.thread_id) {
const cacheKey = rec.thread_id;
const now = Date.now();
const cached = threadHealthCache.get(cacheKey);
const withinTTL = cached && (now - cached.ts < CONFIG.THREAD_HEALTH_TTL_MS);
if (!withinTTL) {
const testRes = await tgCall(env, "sendMessage", {
chat_id: env.SUPERGROUP_ID,
message_thread_id: rec.thread_id,
text: "", // 零宽度字符,对用户不可见
});
const actualThreadId = testRes.result?.message_thread_id;
const expectedThreadId = rec.thread_id;
// 只要未返回 thread_id或 thread_id 不匹配,均视为话题失效/被重定向
const redirectedOrMissing = !testRes.ok || !actualThreadId || Number(actualThreadId) !== Number(expectedThreadId);
if (redirectedOrMissing) {
const desc = (testRes.description || "").toLowerCase();
const isTopicDeleted = redirectedOrMissing ||
desc.includes("thread not found") ||
desc.includes("topic not found") ||
desc.includes("message thread not found") ||
desc.includes("topic deleted") ||
desc.includes("thread deleted") ||
desc.includes("forum topic not found") ||
desc.includes("topic closed permanently");
if (isTopicDeleted) {
await env.TOPIC_MAP.put(retryKey, String(retryCount + 1), { expirationTtl: 60 });
Logger.info('topic_auto_repair', {
userId,
oldThreadId: rec.thread_id,
attempt: retryCount + 1,
maxAttempts: CONFIG.MAX_RETRY_ATTEMPTS,
errorDescription: testRes.description
});
await env.TOPIC_MAP.delete(`thread:${rec.thread_id}`);
threadHealthCache.delete(cacheKey);
rec = await createTopic(msg.from, key, env, userId);
if (!rec || !rec.thread_id) {
throw new Error("重建话题失败");
}
Logger.info('topic_recreated', {
userId,
newThreadId: rec.thread_id
});
} else {
Logger.warn('topic_test_failed_unknown', {
userId,
threadId: rec.thread_id,
errorDescription: testRes.description
});
}
} else {
await env.TOPIC_MAP.delete(retryKey);
threadHealthCache.set(cacheKey, { ts: now, ok: true });
try {
await tgCall(env, "deleteMessage", {
chat_id: env.SUPERGROUP_ID,
message_id: testRes.result.message_id
});
} catch (e) {
// 删除失败不影响主流程
}
}
}
}
if (msg.media_group_id) {
await handleMediaGroup(msg, env, ctx, {
direction: "p2t",
targetChat: env.SUPERGROUP_ID,
threadId: rec.thread_id
});
return;
}
const res = await tgCall(env, "forwardMessage", {
chat_id: env.SUPERGROUP_ID,
from_chat_id: userId,
message_id: msg.message_id,
message_thread_id: rec.thread_id,
});
// 检测 Telegram 静默重定向到 General 的情况
const resThreadId = res.result?.message_thread_id;
if (res.ok && (!resThreadId || Number(resThreadId) !== Number(rec.thread_id))) {
Logger.warn('forward_redirected_to_general', {
userId,
expectedThreadId: rec.thread_id,
actualThreadId: resThreadId
});
await env.TOPIC_MAP.delete(`thread:${rec.thread_id}`);
threadHealthCache.delete(rec.thread_id);
const newRec = await createTopic(msg.from, key, env, userId);
// 删除误投到 General 的消息
if (res.result?.message_id) {
try {
await tgCall(env, "deleteMessage", {
chat_id: env.SUPERGROUP_ID,
message_id: res.result.message_id
});
} catch (e) {
// 删除失败不影响重发
}
}
await tgCall(env, "copyMessage", {
chat_id: env.SUPERGROUP_ID,
from_chat_id: userId,
message_id: msg.message_id,
message_thread_id: newRec.thread_id
});
return;
}
// 【修复2】增强错误处理双重保险
// 如果上面的测试没有捕获到,这里再次检测
if (!res.ok) {
const desc = (res.description || "").toLowerCase();
if (desc.includes("thread not found") ||
desc.includes("topic not found") ||
desc.includes("message thread not found")) {
console.log(`[二次修复] 转发失败,话题不存在,正在重建...`);
await env.TOPIC_MAP.delete(`thread:${rec.thread_id}`);
threadHealthCache.delete(rec.thread_id);
const newRec = await createTopic(msg.from, key, env, userId);
await tgCall(env, "forwardMessage", {
chat_id: env.SUPERGROUP_ID,
from_chat_id: userId,
message_id: msg.message_id,
message_thread_id: newRec.thread_id,
});
return;
}
if (desc.includes("chat not found")) throw new Error(`群组ID错误: ${env.SUPERGROUP_ID}`);
if (desc.includes("not enough rights")) throw new Error("机器人权限不足 (需 Manage Topics)");
// 如果forwardMessage失败尝试使用copyMessage作为降级方案
await tgCall(env, "copyMessage", {
chat_id: env.SUPERGROUP_ID,
from_chat_id: userId,
message_id: msg.message_id,
message_thread_id: rec.thread_id
});
}
}
async function handleAdminReply(msg, env, ctx) {
const threadId = msg.message_thread_id;
const text = (msg.text || "").trim();
// 【修复】允许在任何话题执行 /cleanup 命令
if (text === "/cleanup") {
await handleCleanupCommand(threadId, env);
return;
}
// 优先通过 thread 映射快速反查用户,缺失时再降级全量扫描
let userId = null;
const mappedUser = await env.TOPIC_MAP.get(`thread:${threadId}`);
if (mappedUser) {
userId = Number(mappedUser);
} else {
const allKeys = await getAllKeys(env, "user:");
for (const { name } of allKeys) {
const rec = await safeGetJSON(env, name, null);
if (rec && Number(rec.thread_id) === Number(threadId)) {
userId = Number(name.slice(5));
break;
}
}
}
// 如果找不到用户,说明可能是在普通话题,或者数据丢失,直接返回
if (!userId) return;
// --- 指令区域 ---
if (text === "/close") {
const key = `user:${userId}`;
let rec = await safeGetJSON(env, key, null);
if (rec) {
rec.closed = true;
await env.TOPIC_MAP.put(key, JSON.stringify(rec));
await tgCall(env, "closeForumTopic", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId });
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "🚫 **对话已强制关闭**", parse_mode: "Markdown" });
}
return;
}
if (text === "/open") {
const key = `user:${userId}`;
let rec = await safeGetJSON(env, key, null);
if (rec) {
rec.closed = false;
await env.TOPIC_MAP.put(key, JSON.stringify(rec));
await tgCall(env, "reopenForumTopic", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId });
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "✅ **对话已恢复**", parse_mode: "Markdown" });
}
return;
}
if (text === "/reset") {
await env.TOPIC_MAP.delete(`verified:${userId}`);
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "🔄 **验证重置**", parse_mode: "Markdown" });
return;
}
if (text === "/trust") {
await env.TOPIC_MAP.put(`verified:${userId}`, "trusted");
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "🌟 **已设置永久信任**", parse_mode: "Markdown" });
return;
}
if (text === "/ban") {
await env.TOPIC_MAP.put(`banned:${userId}`, "1");
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "🚫 **用户已封禁**", parse_mode: "Markdown" });
return;
}
if (text === "/unban") {
await env.TOPIC_MAP.delete(`banned:${userId}`);
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: "✅ **用户已解封**", parse_mode: "Markdown" });
return;
}
if (text === "/info") {
const userKey = `user:${userId}`;
const userRec = await safeGetJSON(env, userKey, null);
const verifyStatus = await env.TOPIC_MAP.get(`verified:${userId}`);
const banStatus = await env.TOPIC_MAP.get(`banned:${userId}`);
const info = `👤 **用户信息**\nUID: \`${userId}\`\nTopic ID: \`${threadId}\`\n话题标题: ${userRec?.title || "未知"}\n验证状态: ${verifyStatus ? (verifyStatus === 'trusted' ? '🌟 永久信任' : '✅ 已验证') : '❌ 未验证'}\n封禁状态: ${banStatus ? '🚫 已封禁' : '✅ 正常'}\nLink: [点击私聊](tg://user?id=${userId})`;
await tgCall(env, "sendMessage", { chat_id: env.SUPERGROUP_ID, message_thread_id: threadId, text: info, parse_mode: "Markdown" });
return;
}
// 转发管理员消息给用户
if (msg.media_group_id) {
await handleMediaGroup(msg, env, ctx, { direction: "t2p", targetChat: userId, threadId: null });
return;
}
await tgCall(env, "copyMessage", { chat_id: userId, from_chat_id: env.SUPERGROUP_ID, message_id: msg.message_id });
}
// ---------------- 验证模块 (纯本地) ----------------
async function sendVerificationChallenge(userId, env, pendingMsgId) {
// 【修复 #1】检查是否已有进行中的验证
const existingChallenge = await env.TOPIC_MAP.get(`user_challenge:${userId}`);
if (existingChallenge) {
Logger.info('verification_duplicate_skipped', { userId });
return;
}
// 【修复 #9】使用加密安全的随机数
const q = LOCAL_QUESTIONS[secureRandomInt(0, LOCAL_QUESTIONS.length)];
const challenge = {
question: q.question,
correct: q.correct_answer,
options: shuffleArray([...q.incorrect_answers, q.correct_answer])
};
// 【修复 #9】使用加密安全的ID生成
const verifyId = secureRandomId(CONFIG.VERIFY_ID_LENGTH);
// 【修复 #6】使用答案索引而非文本避免截断问题
const answerIndex = challenge.options.indexOf(challenge.correct);
const state = {
answerIndex: answerIndex, // 存储索引
options: challenge.options, // 存储完整选项列表
pending: pendingMsgId,
userId: userId // 添加用户ID验证
};
await env.TOPIC_MAP.put(`chal:${verifyId}`, JSON.stringify(state), { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
// 【修复 #1】标记用户正在验证中
await env.TOPIC_MAP.put(`user_challenge:${userId}`, verifyId, { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
Logger.info('verification_sent', {
userId,
verifyId,
question: q.question,
hasPending: !!pendingMsgId
});
// 【修复 #6】按钮使用索引而非文本
const buttons = challenge.options.map((opt, idx) => ({
text: opt,
callback_data: `verify:${verifyId}:${idx}` // 使用索引
}));
const keyboard = [];
for (let i = 0; i < buttons.length; i += CONFIG.BUTTON_COLUMNS) {
keyboard.push(buttons.slice(i, i + CONFIG.BUTTON_COLUMNS));
}
await tgCall(env, "sendMessage", {
chat_id: userId,
text: `🛡️ **人机验证**\n\n${challenge.question}\n\n请点击下方按钮回答 (回答正确后将自动发送您刚才的消息)。`,
parse_mode: "Markdown",
reply_markup: { inline_keyboard: keyboard }
});
}
async function handleCallbackQuery(query, env, ctx) {
try {
const data = query.data;
if (!data.startsWith("verify:")) return;
const parts = data.split(":");
if (parts.length !== 3) return;
const verifyId = parts[1];
const selectedIndex = parseInt(parts[2]); // 【修复 #6】用户选择的索引
const userId = query.from.id;
const stateStr = await env.TOPIC_MAP.get(`chal:${verifyId}`);
if (!stateStr) {
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "❌ 验证已过期,请重发消息",
show_alert: true
});
return;
}
let state;
try {
state = JSON.parse(stateStr);
} catch(e) {
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "❌ 数据错误",
show_alert: true
});
return;
}
// 【修复 #1】验证用户ID匹配
if (state.userId && state.userId !== userId) {
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "❌ 无效的验证",
show_alert: true
});
return;
}
// 【修复 #6】验证索引有效性
if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= state.options.length) {
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "❌ 无效选项",
show_alert: true
});
return;
}
if (selectedIndex === state.answerIndex) {
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "✅ 验证通过"
});
Logger.info('verification_passed', {
userId,
verifyId,
selectedOption: state.options[selectedIndex]
});
// 30天有效期 - 使用配置常量
await env.TOPIC_MAP.put(`verified:${userId}`, "1", { expirationTtl: CONFIG.VERIFIED_EXPIRE_SECONDS });
// 【修复 #1】清理所有相关挑战
await env.TOPIC_MAP.delete(`chal:${verifyId}`);
await env.TOPIC_MAP.delete(`user_challenge:${userId}`);
await tgCall(env, "editMessageText", {
chat_id: userId,
message_id: query.message.message_id,
text: "✅ **验证成功**\n\n您现在可以自由对话了。",
parse_mode: "Markdown"
});
if (state.pending) {
try {
// 【修复 #3】检查消息是否已经被转发过
const forwardedKey = `forwarded:${userId}:${state.pending}`;
const alreadyForwarded = await env.TOPIC_MAP.get(forwardedKey);
if (alreadyForwarded) {
Logger.info('message_forward_duplicate_skipped', {
userId,
messageId: state.pending
});
return;
}
const fakeMsg = {
message_id: state.pending,
chat: { id: userId, type: "private" },
from: query.from,
};
await forwardToTopic(fakeMsg, userId, `user:${userId}`, env, ctx);
// 【修复 #3】标记已转发
await env.TOPIC_MAP.put(forwardedKey, "1", { expirationTtl: 3600 });
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "📩 刚才的消息已帮您送达。",
reply_to_message_id: state.pending
});
} catch (e) {
Logger.error('pending_message_forward_failed', e, { userId });
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "⚠️ 自动发送失败,请重新发送您的消息。"
});
}
}
} else {
Logger.info('verification_failed', {
userId,
verifyId,
selectedIndex,
correctIndex: state.answerIndex
});
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: "❌ 答案错误",
show_alert: true
});
}
} catch (e) {
Logger.error('callback_query_error', e, {
userId: query.from?.id,
callbackData: query.data
});
await tgCall(env, "answerCallbackQuery", {
callback_query_id: query.id,
text: `⚠️ 系统错误,请重试`,
show_alert: true
});
}
}
// ---------------- 辅助函数 ----------------
/**
* 【修复 #8】批量清理命令处理函数优化并发性能
*
* 功能说明:
* 1. 检查所有用户的话题记录
* 2. 找出话题ID已不存在被删除的用户
* 3. 删除这些用户的KV存储记录和验证状态
* 4. 让他们下次发消息时重新验证并创建新话题
*
* 使用场景:
* - 管理员手动删除了多个用户话题后
* - 需要批量重置这些用户的状态
*
* @param {number} threadId - 当前话题ID通常在General话题中调用
* @param {object} env - 环境变量对象
*/
async function handleCleanupCommand(threadId, env) {
// 发送处理中的消息
await tgCall(env, "sendMessage", {
chat_id: env.SUPERGROUP_ID,
message_thread_id: threadId,
text: "🔄 **正在扫描需要清理的用户...**",
parse_mode: "Markdown"
});
let cleanedCount = 0;
let errorCount = 0;
const cleanedUsers = [];
try {
// 【修复 #11】获取所有用户记录处理分页
const allKeys = await getAllKeys(env, "user:");
// 【修复 #8】批量并发处理限制并发数
for (let i = 0; i < allKeys.length; i += CONFIG.CLEANUP_BATCH_SIZE) {
const batch = allKeys.slice(i, i + CONFIG.CLEANUP_BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async ({ name }) => {
const rec = await safeGetJSON(env, name, null);
if (!rec || !rec.thread_id) return null;
const userId = name.slice(5);
const topicThreadId = rec.thread_id;
// 检测话题是否存在:尝试向话题发送测试消息
const testRes = await tgCall(env, "sendMessage", {
chat_id: env.SUPERGROUP_ID,
message_thread_id: topicThreadId,
text: "", // 零宽度字符
});
const actualThreadId = testRes.result?.message_thread_id;
// 同时检测 Telegram 静默重定向到 General 的场景
if (!testRes.ok || (actualThreadId && Number(actualThreadId) !== Number(topicThreadId))) {
const desc = (testRes.description || "").toLowerCase();
const redirected = actualThreadId && Number(actualThreadId) !== Number(topicThreadId);
const isTopicDeleted = redirected ||
desc.includes("thread not found") ||
desc.includes("topic not found") ||
desc.includes("message thread not found");
if (isTopicDeleted) {
await env.TOPIC_MAP.delete(name);
await env.TOPIC_MAP.delete(`verified:${userId}`);
await env.TOPIC_MAP.delete(`thread:${topicThreadId}`);
return {
userId,
threadId: topicThreadId,
title: rec.title || "未知"
};
}
}
// 话题存在或检测后,尝试删除测试消息
if (testRes.ok && testRes.result?.message_id) {
try {
await tgCall(env, "deleteMessage", {
chat_id: env.SUPERGROUP_ID,
message_id: testRes.result.message_id
});
} catch (e) {
// 删除失败不影响主流程
}
}
return null;
})
);
// 处理结果
results.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
cleanedCount++;
cleanedUsers.push(result.value);
Logger.info('cleanup_user', {
userId: result.value.userId,
threadId: result.value.threadId
});
} else if (result.status === 'rejected') {
errorCount++;
Logger.error('cleanup_batch_error', result.reason);
}
});
// 防止速率限制
if (i + CONFIG.CLEANUP_BATCH_SIZE < allKeys.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
// 生成并发送清理报告
let reportText = `✅ **清理完成**\n\n`;
reportText += `📊 **统计信息**\n`;
reportText += `- 已清理用户数: ${cleanedCount}\n`;
reportText += `- 错误数: ${errorCount}\n\n`;
if (cleanedCount > 0) {
reportText += `🗑️ **已清理的用户** (话题已删除):\n`;
for (const user of cleanedUsers.slice(0, CONFIG.MAX_CLEANUP_DISPLAY)) {
reportText += `- UID: \`${user.userId}\` | 话题: ${user.title}\n`;
}
if (cleanedUsers.length > CONFIG.MAX_CLEANUP_DISPLAY) {
reportText += `\n...(还有 ${cleanedUsers.length - CONFIG.MAX_CLEANUP_DISPLAY} 个用户)\n`;
}
reportText += `\n💡 这些用户下次发消息时将重新进行人机验证并创建新话题。`;
} else {
reportText += `✨ 没有发现需要清理的用户记录。`;
}
Logger.info('cleanup_completed', {
cleanedCount,
errorCount,
totalUsers: allKeys.length
});
await tgCall(env, "sendMessage", {
chat_id: env.SUPERGROUP_ID,
message_thread_id: threadId,
text: reportText,
parse_mode: "Markdown"
});
} catch (e) {
Logger.error('cleanup_failed', e, { threadId });
await tgCall(env, "sendMessage", {
chat_id: env.SUPERGROUP_ID,
message_thread_id: threadId,
text: `❌ **清理过程出错**\n\n错误信息: \`${e.message}\``,
parse_mode: "Markdown"
});
}
}
// ---------------- 其他辅助函数 ----------------
// 为话题建立 thread->user 映射,避免管理员命令时全量 KV 反查
async function createTopic(from, key, env, userId) {
const title = buildTopicTitle(from);
if (!env.SUPERGROUP_ID.toString().startsWith("-100")) throw new Error("SUPERGROUP_ID必须以-100开头");
const res = await tgCall(env, "createForumTopic", { chat_id: env.SUPERGROUP_ID, name: title });
if (!res.ok) throw new Error(`创建话题失败: ${res.description}`);
const rec = { thread_id: res.result.message_thread_id, title, closed: false };
await env.TOPIC_MAP.put(key, JSON.stringify(rec));
if (userId) {
await env.TOPIC_MAP.put(`thread:${rec.thread_id}`, String(userId));
}
return rec;
}
// 【修复 #2】更新话题状态 - 修复异步操作未等待
async function updateThreadStatus(threadId, isClosed, env) {
try {
const allKeys = await getAllKeys(env, "user:");
// 收集所有更新操作
const updates = [];
for (const { name } of allKeys) {
const rec = await safeGetJSON(env, name, null);
if (rec && Number(rec.thread_id) === Number(threadId)) {
rec.closed = isClosed;
updates.push(env.TOPIC_MAP.put(name, JSON.stringify(rec)));
}
}
// 等待所有更新完成
await Promise.all(updates);
Logger.info('thread_status_updated', {
threadId,
isClosed,
updatedCount: updates.length
});
} catch (e) {
Logger.error('thread_status_update_failed', e, { threadId, isClosed });
throw e;
}
}
// 改进的话题标题构建(清理特殊字符)
function buildTopicTitle(from) {
const firstName = (from.first_name || "").trim().substring(0, CONFIG.MAX_NAME_LENGTH);
const lastName = (from.last_name || "").trim().substring(0, CONFIG.MAX_NAME_LENGTH);
// 清理 username
let username = "";
if (from.username) {
username = from.username
.replace(/[^\w]/g, '') // 只保留字母数字下划线
.substring(0, 20);
}
// 移除控制字符和换行符
const cleanName = (firstName + " " + lastName)
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
.replace(/\s+/g, ' ')
.trim();
const name = cleanName || "User";
const usernameStr = username ? ` @${username}` : "";
// Telegram 话题标题最大长度为 128 字符
const title = (name + usernameStr).substring(0, CONFIG.MAX_TITLE_LENGTH);
return title;
}
// 改进的 Telegram API 调用(添加超时和 HTTPS 强制)
async function tgCall(env, method, body, timeout = CONFIG.API_TIMEOUT_MS) {
let base = env.API_BASE || "https://api.telegram.org";
// 【修复 #20】强制 HTTPS
if (base.startsWith("http://")) {
Logger.warn('api_http_upgraded', { originalBase: base });
base = base.replace("http://", "https://");
}
// 验证 URL 格式
try {
new URL(`${base}/test`);
} catch (e) {
Logger.error('api_base_invalid', e, { base });
base = "https://api.telegram.org";
}
// 【修复 #13】添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const resp = await fetch(`${base}/bot${env.BOT_TOKEN}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!resp.ok && resp.status >= 500) {
Logger.warn('telegram_api_server_error', {
method,
status: resp.status
});
}
const result = await resp.json();
// 记录速率限制
if (!result.ok && result.description && result.description.includes('Too Many Requests')) {
const retryAfter = result.parameters?.retry_after || 5;
Logger.warn('telegram_api_rate_limit', {
method,
retryAfter
});
}
return result;
} catch (e) {
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
Logger.error('telegram_api_timeout', e, { method, timeout });
return { ok: false, description: 'Request timeout' };
}
Logger.error('telegram_api_failed', e, { method });
throw e;
}
}
async function handleMediaGroup(msg, env, ctx, { direction, targetChat, threadId }) {
const groupId = msg.media_group_id;
const key = `mg:${direction}:${groupId}`;
const item = extractMedia(msg);
if (!item) {
await tgCall(env, "copyMessage", { chat_id: targetChat, from_chat_id: msg.chat.id, message_id: msg.message_id, message_thread_id: threadId });
return;
}
let rec = await safeGetJSON(env, key, null);
if (!rec) rec = { direction, targetChat, threadId, items: [], last_ts: Date.now() };
rec.items.push({ ...item, msg_id: msg.message_id });
rec.last_ts = Date.now();
await env.TOPIC_MAP.put(key, JSON.stringify(rec), { expirationTtl: CONFIG.MEDIA_GROUP_EXPIRE_SECONDS });
ctx.waitUntil(delaySend(env, key, rec.last_ts));
}
// 【修复 #15, #19】改进的媒体提取支持更多类型不修改原数组
function extractMedia(msg) {
// 图片
if (msg.photo && msg.photo.length > 0) {
const highestResolution = msg.photo[msg.photo.length - 1]; // 不使用 pop()
return {
type: "photo",
id: highestResolution.file_id,
cap: msg.caption || ""
};
}
// 视频
if (msg.video) {
return {
type: "video",
id: msg.video.file_id,
cap: msg.caption || ""
};
}
// 文档
if (msg.document) {
return {
type: "document",
id: msg.document.file_id,
cap: msg.caption || ""
};
}
// 音频
if (msg.audio) {
return {
type: "audio",
id: msg.audio.file_id,
cap: msg.caption || ""
};
}
// 动图
if (msg.animation) {
return {
type: "animation",
id: msg.animation.file_id,
cap: msg.caption || ""
};
}
// 语音和视频消息不支持 media group
return null;
}
// 【修复 #21】实现媒体组清理
async function flushExpiredMediaGroups(env, now) {
try {
const prefix = "mg:";
const allKeys = await getAllKeys(env, prefix);
let deletedCount = 0;
for (const { name } of allKeys) {
const rec = await safeGetJSON(env, name, null);
if (rec && rec.last_ts && (now - rec.last_ts > 300000)) { // 超过 5 分钟
await env.TOPIC_MAP.delete(name);
deletedCount++;
}
}
if (deletedCount > 0) {
Logger.info('media_groups_cleaned', { deletedCount });
}
} catch (e) {
Logger.error('media_group_cleanup_failed', e);
}
}
// 【修复 #12, #28】改进媒体组延迟发送
async function delaySend(env, key, ts) {
await new Promise(r => setTimeout(r, CONFIG.MEDIA_GROUP_DELAY_MS));
const rec = await safeGetJSON(env, key, null);
if (rec && rec.last_ts === ts) {
// 验证媒体数组
if (!rec.items || rec.items.length === 0) {
Logger.warn('media_group_empty', { key });
await env.TOPIC_MAP.delete(key);
return;
}
const media = rec.items.map((it, i) => {
if (!it.type || !it.id) {
Logger.warn('media_group_invalid_item', { key, item: it });
return null;
}
// 【修复 #28】限制 caption 长度
const caption = i === 0 ? (it.cap || "").substring(0, 1024) : "";
return {
type: it.type,
media: it.id,
caption
};
}).filter(Boolean); // 过滤掉无效项
if (media.length > 0) {
try {
const result = await tgCall(env, "sendMediaGroup", {
chat_id: rec.targetChat,
message_thread_id: rec.threadId,
media
});
if (!result.ok) {
Logger.error('media_group_send_failed', result.description, {
key,
mediaCount: media.length
});
} else {
Logger.info('media_group_sent', {
key,
mediaCount: media.length,
targetChat: rec.targetChat
});
}
} catch (e) {
Logger.error('media_group_send_exception', e, { key });
}
}
await env.TOPIC_MAP.delete(key);
}
}