Files
telegram_private_chatbot/worker.js
2025-12-01 14:23:51 +08:00

410 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 双向机器人 (纯本地极速版 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);
}
}