Updated the bot version and modified the local question bank. Added mandatory username check for private messages and adjusted the /info command to remove full name display.
411 lines
16 KiB
JavaScript
411 lines
16 KiB
JavaScript
// Cloudflare Worker:Telegram 双向机器人 (纯本地极速版 v4.0)
|
||
|
||
// --- 本地题库 (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": ["喵喵", "咩咩", "呱呱"]}
|
||
];
|
||
|
||
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.");
|
||
|
||
if (request.method !== "POST") return new Response("OK");
|
||
|
||
let update;
|
||
try {
|
||
update = await request.json();
|
||
} catch {
|
||
return new Response("OK");
|
||
}
|
||
|
||
if (update.callback_query) {
|
||
await handleCallbackQuery(update.callback_query, env, ctx);
|
||
return new Response("OK");
|
||
}
|
||
|
||
const msg = update.message;
|
||
if (!msg) return new Response("OK");
|
||
|
||
ctx.waitUntil(flushExpiredMediaGroups(env, Date.now()));
|
||
|
||
if (msg.chat && msg.chat.type === "private") {
|
||
try {
|
||
await handlePrivateMessage(msg, env, ctx);
|
||
} catch (e) {
|
||
const errText = `⚠️ **系统错误**\n\n\`${e.message}\`\n\n请检查配置: SUPERGROUP_ID / BOT_TOKEN / TOPIC_MAP`;
|
||
await tgCall(env, "sendMessage", { chat_id: msg.chat.id, text: errText, parse_mode: "Markdown" });
|
||
console.error(e);
|
||
}
|
||
return new Response("OK");
|
||
}
|
||
|
||
const supergroupId = Number(env.SUPERGROUP_ID);
|
||
if (msg.chat && Number(msg.chat.id) === supergroupId) {
|
||
if (msg.forum_topic_closed && msg.message_thread_id) {
|
||
await updateThreadStatus(msg.message_thread_id, true, env);
|
||
return new Response("OK");
|
||
}
|
||
if (msg.forum_topic_reopened && msg.message_thread_id) {
|
||
await updateThreadStatus(msg.message_thread_id, false, env);
|
||
return new Response("OK");
|
||
}
|
||
if (msg.message_thread_id) {
|
||
await handleAdminReply(msg, env, ctx);
|
||
return new Response("OK");
|
||
}
|
||
}
|
||
|
||
return new Response("OK");
|
||
},
|
||
};
|
||
|
||
// ---------------- 核心业务逻辑 ----------------
|
||
|
||
async function handlePrivateMessage(msg, env, ctx) {
|
||
const userId = msg.chat.id;
|
||
const key = `user:${userId}`;
|
||
|
||
// 拦截普通用户发送的指令
|
||
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 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) {
|
||
let rec = await env.TOPIC_MAP.get(key, { type: "json" });
|
||
|
||
if (rec && rec.closed) {
|
||
await tgCall(env, "sendMessage", { chat_id: userId, text: "🚫 当前对话已被管理员关闭。" });
|
||
return;
|
||
}
|
||
|
||
if (!rec || !rec.thread_id) {
|
||
rec = await createTopic(msg.from, key, env);
|
||
}
|
||
|
||
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,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const desc = (res.description || "").toLowerCase();
|
||
if (desc.includes("thread not found") || desc.includes("topic not found")) {
|
||
const newRec = await createTopic(msg.from, key, env);
|
||
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)");
|
||
|
||
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();
|
||
|
||
// 反查 UserId
|
||
let userId = null;
|
||
const list = await env.TOPIC_MAP.list({ prefix: "user:" });
|
||
for (const { name } of list.keys) {
|
||
const rec = await env.TOPIC_MAP.get(name, { type: "json" });
|
||
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 env.TOPIC_MAP.get(key, { type: "json" });
|
||
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 env.TOPIC_MAP.get(key, { type: "json" });
|
||
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 info = `👤 **用户信息**\nUID: \`${userId}\`\nTopic ID: \`${threadId}\`\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) {
|
||
// 直接从本地题库随机
|
||
const q = LOCAL_QUESTIONS[Math.floor(Math.random() * LOCAL_QUESTIONS.length)];
|
||
const challenge = {
|
||
question: q.question,
|
||
correct: q.correct_answer,
|
||
options: shuffleArray([...q.incorrect_answers, q.correct_answer])
|
||
};
|
||
|
||
// 使用 8 位短 ID 防止按钮失效
|
||
const verifyId = Math.random().toString(36).substring(2, 10);
|
||
|
||
const state = { ans: challenge.correct, pending: pendingMsgId };
|
||
await env.TOPIC_MAP.put(`chal:${verifyId}`, JSON.stringify(state), { expirationTtl: 300 });
|
||
|
||
const buttons = challenge.options.map(opt => {
|
||
const safeOpt = opt.length > 20 ? opt.substring(0, 20) : opt;
|
||
return { text: opt, callback_data: `verify:${verifyId}:${safeOpt}` };
|
||
});
|
||
|
||
const keyboard = [];
|
||
for (let i = 0; i < buttons.length; i += 2) keyboard.push(buttons.slice(i, i + 2));
|
||
|
||
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 userAns = parts.slice(2).join(":");
|
||
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;
|
||
}
|
||
|
||
if (userAns === state.ans) {
|
||
await tgCall(env, "answerCallbackQuery", { callback_query_id: query.id, text: "✅ 验证通过" });
|
||
|
||
// 30天有效期
|
||
await env.TOPIC_MAP.put(`verified:${userId}`, "1", { expirationTtl: 2592000 });
|
||
await env.TOPIC_MAP.delete(`chal:${verifyId}`);
|
||
|
||
await tgCall(env, "editMessageText", {
|
||
chat_id: userId,
|
||
message_id: query.message.message_id,
|
||
text: "✅ **验证成功**\n\n您现在可以自由对话了。",
|
||
parse_mode: "Markdown"
|
||
});
|
||
|
||
if (state.pending) {
|
||
try {
|
||
const fakeMsg = {
|
||
message_id: state.pending,
|
||
chat: { id: userId, type: "private" },
|
||
from: query.from,
|
||
};
|
||
await forwardToTopic(fakeMsg, userId, `user:${userId}`, env, ctx);
|
||
await tgCall(env, "sendMessage", { chat_id: userId, text: "📩 刚才的消息已帮您送达。", reply_to_message_id: state.pending });
|
||
} catch (e) { }
|
||
}
|
||
} else {
|
||
await tgCall(env, "answerCallbackQuery", { callback_query_id: query.id, text: "❌ 答案错误", show_alert: true });
|
||
}
|
||
} catch (e) {
|
||
console.error("Callback Error", e);
|
||
await tgCall(env, "answerCallbackQuery", {
|
||
callback_query_id: query.id,
|
||
text: `⚠️ 系统错误: ${e.message}`,
|
||
show_alert: true
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------- 辅助函数 ----------------
|
||
|
||
async function createTopic(from, key, env) {
|
||
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));
|
||
return rec;
|
||
}
|
||
|
||
function updateThreadStatus(threadId, isClosed, env) {
|
||
return env.TOPIC_MAP.list({ prefix: "user:" }).then(list => {
|
||
for (const { name } of list.keys) {
|
||
env.TOPIC_MAP.get(name, { type: "json" }).then(rec => {
|
||
if (rec && Number(rec.thread_id) === Number(threadId)) {
|
||
rec.closed = isClosed;
|
||
env.TOPIC_MAP.put(name, JSON.stringify(rec));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function shuffleArray(arr) { return arr.sort(() => Math.random() - 0.5); }
|
||
function buildTopicTitle(from) {
|
||
const name = (from.first_name + " " + (from.last_name || "")).trim();
|
||
return (name || "User") + (from.username ? ` @${from.username}` : "");
|
||
}
|
||
|
||
async function tgCall(env, method, body) {
|
||
const base = env.API_BASE || "https://api.telegram.org";
|
||
const resp = await fetch(`${base}/bot${env.BOT_TOKEN}/${method}`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
return await resp.json();
|
||
}
|
||
|
||
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 env.TOPIC_MAP.get(key, { type: "json" });
|
||
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: 60 });
|
||
ctx.waitUntil(delaySend(env, key, rec.last_ts));
|
||
}
|
||
|
||
function extractMedia(msg) {
|
||
if (msg.photo) return { type: "photo", id: msg.photo.pop().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 };
|
||
return null;
|
||
}
|
||
|
||
async function flushExpiredMediaGroups(env, now) {}
|
||
async function delaySend(env, key, ts) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
const rec = await env.TOPIC_MAP.get(key, { type: "json" });
|
||
if (rec && rec.last_ts === ts) {
|
||
const media = rec.items.map((it, i) => ({ type: it.type, media: it.id, caption: i===0?it.cap:"" }));
|
||
if (media.length > 0) await tgCall(env, "sendMediaGroup", { chat_id: rec.targetChat, message_thread_id: rec.threadId, media });
|
||
await env.TOPIC_MAP.delete(key);
|
||
}
|
||
}
|