Create worker.js

This commit is contained in:
Vaghr
2025-12-01 14:23:51 +08:00
committed by GitHub
parent c794ca82f5
commit 37f588cfc4

409
worker.js Normal file
View File

@@ -0,0 +1,409 @@
// Cloudflare WorkerTelegram 双向机器人 (纯本地极速版 v4.0)
// --- 本地题库 (14条) ---
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": ["喵喵", "咩咩", "呱呱"]}
];
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);
}
}