961 lines
34 KiB
JavaScript
961 lines
34 KiB
JavaScript
TOKEN = ENV_BOT_TOKEN;
|
||
const WEBHOOK = '/endpoint';
|
||
const SECRET = ENV_BOT_SECRET;
|
||
const ADMIN_UID = ENV_ADMIN_UID;
|
||
|
||
const USER_TAG_COUNTER_KEY = 'user-tag-counter';
|
||
const SESSION_HEADER_INTERVAL = 10 * 60 * 1000;
|
||
|
||
const NOTIFY_INTERVAL = 3600 * 1000;
|
||
const ENABLE_INSTANT_CONFIRM = false;
|
||
const START_MSG_ZH_URL = 'https://git.loliloli.li/Administrator/TPM/raw/branch/main/data/startMessage.zh.md';
|
||
const START_MSG_EN_URL = 'https://git.loliloli.li/Administrator/TPM/raw/branch/main/data/startMessage.en.md';
|
||
const ENABLE_NOTIFICATION = true;
|
||
const ENABLE_KEYWORD_FILTER = true;
|
||
const KEYWORD_STORE_KEY = 'kw-list';
|
||
const DEFAULT_BLOCKLIST_URL = 'https://git.loliloli.li/Administrator/TPM/raw/branch/main/data/blocklist.txt';
|
||
const BLOCKLIST_REFRESH_MS = 15 * 60 * 1000;
|
||
const REMOTE_CACHE_KEY = 'blocked-words-cache';
|
||
const REMOTE_ETAG_KEY = 'blocked-words-etag';
|
||
const REMOTE_LASTFETCH_KEY = 'blocked-words-lastfetch';
|
||
const VERIFY_STORE_KEY = (uid) => `verify-${uid}`;
|
||
const VERIFY_REQUIRED_ZH = '🛡 为了防止骚扰,请先完成一次验证:点击下方按钮。';
|
||
const VERIFY_REQUIRED_EN = '🛡 To prevent spam, please complete a quick verification: tap the button below.';
|
||
const VERIFIED_SUCCESS_ZH = '✅ 验证通过!现在您可以正常发送消息了。';
|
||
const VERIFIED_SUCCESS_EN = '✅ Verified! You can now send messages normally.';
|
||
const ADMIN_REPLY_PROMPT_ZH = '🙅 请点击**转发的用户消息**进行回复,这样我才能知道您是想回复哪位用户。直接发送消息我无法识别目标用户。';
|
||
const ADMIN_REPLY_PROMPT_EN = '🙅 Please click **reply to the forwarded user message** so I know which user you want to reply to. I cannot identify the target user if you send a message directly.';
|
||
const USER_BLOCKED_PROMPT_ZH = '🚫 您已被管理员屏蔽,无法发送消息。';
|
||
const USER_BLOCKED_PROMPT_EN = '🚫 You have been blocked by the administrator and cannot send messages.';
|
||
const MESSAGE_FORWARD_FAIL_PROMPT_ZH = '抱歉,您的消息未能成功转发给管理员,请稍后再试或联系管理员。';
|
||
const MESSAGE_FORWARD_FAIL_PROMPT_EN = 'Sorry, your message could not be forwarded to the administrator. Please try again later or contact the administrator.';
|
||
const MESSAGE_FORWARDED_NOTIF_ZH = "🔔 您好,您的消息已转发给管理员,请耐心等待回复。如长时间未收到答复,可适当再次留言。";
|
||
const MESSAGE_FORWARDED_NOTIF_EN = "🔔 Hello, your message has been forwarded to the administrator. Please wait patiently for a reply. If there’s no response for a long time, feel free to send another message.";
|
||
const MESSAGE_FORWARDED_OK_ZH = "💬 您的消息已成功转发,管理员将尽快回复您。";
|
||
const MESSAGE_FORWARDED_OK_EN = "💬 Your message has been successfully forwarded. The admin will reply soon.";
|
||
const USER_UNBLOCKED_PROMPT_ZH = '🎉 您已被管理员解除屏蔽,现在可以正常发送消息了。';
|
||
const USER_UNBLOCKED_PROMPT_EN = '🎉 You have been unblocked by the administrator. You can now send messages normally.';
|
||
const ADMIN_BLOCK_SELF_PROMPT_ZH = '⚠️ 不能屏蔽自己!';
|
||
const ADMIN_BLOCK_SELF_PROMPT_EN = '⚠️ You cannot block yourself!';
|
||
const ADMIN_CANNOT_IDENTIFY_USER_PROMPT_ZH = '❌ 无法识别要操作的用户。请确保您回复的是用户转发给您的消息。';
|
||
const ADMIN_CANNOT_IDENTIFY_USER_PROMPT_EN = '❌ Cannot identify the user to operate on. Please make sure you are replying to a message forwarded to you by the user.';
|
||
const ADMIN_CANNOT_FIND_USER_ID_PROMPT_ZH = '⚠️ 无法找到对应的用户ID。可能是旧的转发消息或非转发消息。请检查。';
|
||
const ADMIN_CANNOT_FIND_USER_ID_PROMPT_EN = '⚠️ Cannot find the corresponding user ID. This may be an old forwarded message or a non-forwarded message. Please check.';
|
||
const USER_KEYWORD_BLOCKED_PROMPT_ZH = '⚠️ 您的消息包含被屏蔽的关键词,未被转发给管理员。';
|
||
const USER_KEYWORD_BLOCKED_PROMPT_EN = '⚠️ Your message contains blocked keywords and was not forwarded to the admin.';
|
||
const ADMIN_KEYWORD_ADDED_ZH = kw => `✅ 已添加屏蔽关键词:\`${kw}\``;
|
||
const ADMIN_KEYWORD_ADDED_EN = kw => `✅ Added blocked keyword: \`${kw}\``;
|
||
const ADMIN_KEYWORD_REMOVED_ZH = kw => `✅ 已移除屏蔽关键词:\`${kw}\``;
|
||
const ADMIN_KEYWORD_REMOVED_EN = kw => `✅ Removed blocked keyword: \`${kw}\``;
|
||
const ADMIN_KEYWORD_LIST_TITLE_ZH = '📃 当前屏蔽关键词列表:';
|
||
const ADMIN_KEYWORD_LIST_TITLE_EN = '📃 Current blocked keywords:';
|
||
const ADMIN_KEYWORD_EMPTY_ZH = '(空)尚未添加任何关键词。';
|
||
const ADMIN_KEYWORD_EMPTY_EN = '(empty) no keywords yet.';
|
||
const ADMIN_KEYWORD_USAGE_ZH = '用法:/addkw 关键词 | /rmkw 关键词 | /listkw';
|
||
const ADMIN_KEYWORD_USAGE_EN = 'Usage: /addkw <keyword> | /rmkw <keyword> | /listkw';
|
||
const ADMIN_BLOCKLIST_RELOADED_ZH = (source, updated, count, url=undefined) => `✅ 词表已刷新(${source}${updated ? ', 已更新' : ''})。` + (url ? `\n🌐 远程地址:${url}` : '') + `\n📦 当前共 ${count} 条。`;
|
||
const ADMIN_BLOCKLIST_RELOADED_EN = (source, updated, count, url=undefined) => `✅ Blocklist refreshed (${source}${updated ? ', updated' : ''}).` + (url ? `\n🌐 Remote URL: ${url}` : '') + `\n📦 Now ${count} items.`;
|
||
const ADMIN_BLOCKLIST_REMOTE_TITLE_ZH = (total, shown) => `🌐 远程词表信息:共 ${total} 条,前 ${shown} 条:`;
|
||
const ADMIN_BLOCKLIST_REMOTE_TITLE_EN = (total, shown) => `🌐 Remote blocklist: total ${total}, first ${shown}:`;
|
||
const ADMIN_BLOCKLIST_ALL_TITLE_ZH = (total, shown) => `🧩 合并列表(本地 + 远程):共 ${total} 条,前 ${shown} 条:`;
|
||
const ADMIN_BLOCKLIST_ALL_TITLE_EN = (total, shown) => `🧩 Merged list (local + remote): total ${total}, first ${shown}:`;
|
||
const ADMIN_KV_ERROR_ZH = (ctx, err) => `❌ KV操作失败(${ctx}):\n\`${String(err?.message || err)}\``;
|
||
const ADMIN_KV_ERROR_EN = (ctx, err) => `❌ KV operation failed (${ctx}):\n\`${String(err?.message || err)}\``;
|
||
const USER_TEMP_ERROR_ZH = '⚠️ 系统临时故障,请稍后再试。';
|
||
const USER_TEMP_ERROR_EN = '⚠️ Temporary system issue, please try again later.';
|
||
|
||
function apiUrl(method, params = null) {
|
||
let query = '';
|
||
if (params) {
|
||
query = '?' + new URLSearchParams(params).toString();
|
||
}
|
||
return `https://api.telegram.org/bot${TOKEN}/${method}${query}`;
|
||
}
|
||
|
||
function makeReqBody(body) {
|
||
return {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
};
|
||
}
|
||
|
||
async function requestTelegram(method, body, params = null) {
|
||
try {
|
||
const response = await fetch(apiUrl(method, params), makeReqBody(body));
|
||
if (!response.ok) {
|
||
const errorBody = await response.text().catch(()=> '');
|
||
console.error(`Telegram API请求失败 (${method}): ${response.status} ${response.statusText}`, errorBody);
|
||
return {
|
||
ok: false,
|
||
description: `API请求失败: ${response.status} ${response.statusText}`,
|
||
errorDetails: errorBody
|
||
};
|
||
}
|
||
return response.json();
|
||
} catch (error) {
|
||
console.error(`执行 ${method} 方法时发生Fetch错误:`, error);
|
||
return { ok: false, description: `网络或未知错误: ${error.message}` };
|
||
}
|
||
}
|
||
|
||
const sendMessage = (msg) => requestTelegram('sendMessage', msg);
|
||
const copyMessage = (msg) => requestTelegram('copyMessage', msg);
|
||
const forwardMessage = (msg) => requestTelegram('forwardMessage', msg);
|
||
const answerCallbackQuery = (msg) => requestTelegram('answerCallbackQuery', msg);
|
||
const setMyCommands = (commands, scope = null) => {
|
||
const body = { commands };
|
||
if (scope && Object.keys(scope).length > 0) body.scope = scope;
|
||
return requestTelegram('setMyCommands', body);
|
||
};
|
||
const setWebhook = (url, secret_token, opts = {}) =>
|
||
requestTelegram('setWebhook', {
|
||
url,
|
||
secret_token,
|
||
allowed_updates: opts.allowed_updates || ["message", "callback_query"],
|
||
drop_pending_updates: !!opts.drop_pending_updates
|
||
});
|
||
|
||
addEventListener('fetch', event => {
|
||
const url = new URL(event.request.url);
|
||
if (url.pathname === WEBHOOK) {
|
||
event.respondWith(handleWebhook(event));
|
||
} else if (url.pathname === '/registerWebhook') {
|
||
event.respondWith(registerWebhook(event, url));
|
||
} else if (url.pathname === '/unRegisterWebhook') {
|
||
event.respondWith(unRegisterWebhook());
|
||
} else if (url.pathname === '/setMenu') {
|
||
event.respondWith(handleSetMenu());
|
||
} else if (url.pathname === '/debugWebhook') {
|
||
event.respondWith(debugWebhook());
|
||
} else {
|
||
event.respondWith(new Response('请求路径未找到处理程序', { status: 404 }));
|
||
}
|
||
});
|
||
|
||
async function handleWebhook(event) {
|
||
if (event.request.headers.get('X-Telegram-Bot-Api-Secret-Token') !== SECRET) {
|
||
return new Response('未经授权', { status: 403 });
|
||
}
|
||
try {
|
||
const update = await event.request.json();
|
||
event.waitUntil(onUpdate(update));
|
||
return new Response('Ok');
|
||
} catch (error) {
|
||
console.error('解析Webhook更新数据时出错:', error);
|
||
return new Response('错误请求,JSON解析失败', { status: 400 });
|
||
}
|
||
}
|
||
|
||
async function onUpdate(update) {
|
||
if ('message' in update) {
|
||
await onMessage(update.message);
|
||
} else if ('callback_query' in update) {
|
||
await onCallbackQuery(update.callback_query);
|
||
}
|
||
}
|
||
|
||
function getLocalizedPrompt(langCode, prompts) {
|
||
if (langCode && langCode.startsWith('zh')) return prompts.zh;
|
||
return prompts.en;
|
||
}
|
||
|
||
async function notifyAdminKvError(lang, context, error) {
|
||
const text = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_KV_ERROR_ZH(context, error),
|
||
en: ADMIN_KV_ERROR_EN(context, error)
|
||
});
|
||
try {
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text,
|
||
parse_mode: 'Markdown'
|
||
});
|
||
} catch (e) {
|
||
console.error('通知管理员KV错误时再次失败:', e);
|
||
}
|
||
}
|
||
|
||
async function kvPutJson(key, value) {
|
||
await KV.put(key, JSON.stringify(value));
|
||
}
|
||
|
||
async function loadKeywordsLocal() {
|
||
const arr = await KV.get(KEYWORD_STORE_KEY, { type: "json" });
|
||
return Array.isArray(arr) ? arr : [];
|
||
}
|
||
|
||
async function saveKeywords(list) {
|
||
const cleaned = Array.from(new Set(
|
||
list.map(s => String(s || '').trim()).filter(Boolean)
|
||
));
|
||
await kvPutJson(KEYWORD_STORE_KEY, cleaned);
|
||
return cleaned;
|
||
}
|
||
|
||
async function addKeyword(kw) {
|
||
const list = await loadKeywordsLocal();
|
||
list.push(kw);
|
||
return saveKeywords(list);
|
||
}
|
||
|
||
async function removeKeyword(kw) {
|
||
const list = await loadKeywordsLocal();
|
||
const lowered = String(kw).toLowerCase();
|
||
const filtered = list.filter(x => String(x).toLowerCase() !== lowered);
|
||
return saveKeywords(filtered);
|
||
}
|
||
|
||
function extractSearchableText(message) {
|
||
const segs = [];
|
||
if (typeof message.text === 'string') segs.push(message.text);
|
||
if (typeof message.caption === 'string') segs.push(message.caption);
|
||
return segs.join('\n').trim();
|
||
}
|
||
|
||
function hitBlockedKeyword(text, keywords) {
|
||
if (!text) return null;
|
||
const low = text.toLowerCase();
|
||
for (const kw of keywords) {
|
||
const k = String(kw || '').trim().toLowerCase();
|
||
if (!k) continue;
|
||
if (low.includes(k)) return kw;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseBlocklist(text) {
|
||
const trimmed = (text || '').trim();
|
||
if (!trimmed) return [];
|
||
if ((trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
||
(trimmed.startsWith('{') && trimmed.endsWith('}'))) {
|
||
try {
|
||
const data = JSON.parse(trimmed);
|
||
if (Array.isArray(data)) return data.map(s => String(s).trim()).filter(Boolean);
|
||
if (data && Array.isArray(data.words)) return data.words.map(s => String(s).trim()).filter(Boolean);
|
||
} catch {}
|
||
}
|
||
return trimmed
|
||
.split(/\r?\n/)
|
||
.map(l => l.trim())
|
||
.filter(l => l && !l.startsWith('#'));
|
||
}
|
||
|
||
async function getRemoteUrl() {
|
||
return DEFAULT_BLOCKLIST_URL;
|
||
}
|
||
|
||
async function getRemoteCachedWords() {
|
||
try {
|
||
const txt = await KV.get(REMOTE_CACHE_KEY, { type: 'text' });
|
||
if (!txt) return [];
|
||
const obj = JSON.parse(txt);
|
||
if (obj && Array.isArray(obj.words)) return obj.words;
|
||
} catch {}
|
||
return [];
|
||
}
|
||
|
||
async function saveRemoteCache(words) {
|
||
const payload = { words, updatedAt: Date.now() };
|
||
await KV.put(REMOTE_CACHE_KEY, JSON.stringify(payload));
|
||
await KV.put(REMOTE_LASTFETCH_KEY, String(payload.updatedAt));
|
||
}
|
||
|
||
async function fetchRemoteBlocklist({ force = false } = {}) {
|
||
const url = await getRemoteUrl();
|
||
const lastFetchTxt = await KV.get(REMOTE_LASTFETCH_KEY, { type: 'text' });
|
||
const lastFetch = lastFetchTxt ? parseInt(lastFetchTxt, 10) : 0;
|
||
if (!force && lastFetch && (Date.now() - lastFetch) < BLOCKLIST_REFRESH_MS) {
|
||
const words = await getRemoteCachedWords();
|
||
return { words, updated: false, source: 'cache-fresh', url };
|
||
}
|
||
|
||
const etag = await KV.get(REMOTE_ETAG_KEY, { type: 'text' });
|
||
const headers = {};
|
||
if (etag) headers['If-None-Match'] = etag;
|
||
|
||
let res;
|
||
try {
|
||
res = await fetch(url, { headers });
|
||
} catch (e) {
|
||
const words = await getRemoteCachedWords();
|
||
return { words, updated: false, source: 'cache-fallback', url };
|
||
}
|
||
|
||
if (res.status === 304) {
|
||
await KV.put(REMOTE_LASTFETCH_KEY, String(Date.now()));
|
||
const words = await getRemoteCachedWords();
|
||
return { words, updated: false, source: 'not-modified', url };
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const words = await getRemoteCachedWords();
|
||
return { words, updated: false, source: 'cache-on-error', url };
|
||
}
|
||
|
||
const text = await res.text();
|
||
const words = parseBlocklist(text);
|
||
await saveRemoteCache(words);
|
||
const newEtag = res.headers.get('ETag');
|
||
if (newEtag) await KV.put(REMOTE_ETAG_KEY, newEtag);
|
||
return { words, updated: true, source: 'remote', url };
|
||
}
|
||
|
||
async function getBlockedWordsRemote({ force = false } = {}) {
|
||
const { words } = await fetchRemoteBlocklist({ force });
|
||
return words;
|
||
}
|
||
|
||
async function getAllBlockedWords() {
|
||
const local = await loadKeywordsLocal();
|
||
const remote = await getBlockedWordsRemote();
|
||
const set = new Set(local.map(x => String(x).toLowerCase()));
|
||
for (const w of remote) set.add(String(w).toLowerCase());
|
||
return Array.from(set);
|
||
}
|
||
|
||
async function onMessage(message) {
|
||
const chatId = message.chat.id;
|
||
const isAdmin = (message.from?.id?.toString() === ADMIN_UID);
|
||
const lang = message.from?.language_code || 'en';
|
||
|
||
if (message.text === '/start') {
|
||
const startMsgUrl = lang.startsWith('zh') ? START_MSG_ZH_URL : START_MSG_EN_URL;
|
||
try {
|
||
const startMsg = await fetch(startMsgUrl).then(r => r.text());
|
||
await sendMessage({ chat_id: chatId, text: startMsg, parse_mode: 'Markdown' });
|
||
} catch (error) {
|
||
const fallbackWelcome = getLocalizedPrompt(lang, {
|
||
zh: '欢迎!很抱歉,未能加载完整的欢迎消息。',
|
||
en: 'Welcome! Sorry, the full welcome message could not be loaded.'
|
||
});
|
||
await sendMessage({ chat_id: chatId, text: fallbackWelcome });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isAdmin) {
|
||
if (/^\/addkw(?:\s+(.+))?$/i.test(message.text || '')) {
|
||
const m = (message.text || '').match(/^\/addkw(?:\s+(.+))?$/i);
|
||
const kw = (m && m[1] || '').trim();
|
||
const usage = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_USAGE_ZH, en: ADMIN_KEYWORD_USAGE_EN });
|
||
if (!kw) return sendMessage({ chat_id: ADMIN_UID, text: usage });
|
||
try {
|
||
await addKeyword(kw);
|
||
const ok = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_ADDED_ZH(kw), en: ADMIN_KEYWORD_ADDED_EN(kw) });
|
||
await sendMessage({ chat_id: ADMIN_UID, text: ok, parse_mode: 'Markdown' });
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'addKeyword', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (/^\/rmkw(?:\s+(.+))?$/i.test(message.text || '')) {
|
||
const m = (message.text || '').match(/^\/rmkw(?:\s+(.+))?$/i);
|
||
const kw = (m && m[1] || '').trim();
|
||
const usage = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_USAGE_ZH, en: ADMIN_KEYWORD_USAGE_EN });
|
||
if (!kw) return sendMessage({ chat_id: ADMIN_UID, text: usage });
|
||
try {
|
||
await removeKeyword(kw);
|
||
const ok = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_REMOVED_ZH(kw), en: ADMIN_KEYWORD_REMOVED_EN(kw) });
|
||
await sendMessage({ chat_id: ADMIN_UID, text: ok, parse_mode: 'Markdown' });
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'removeKeyword', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (/^\/listkw$/i.test(message.text || '')) {
|
||
try {
|
||
const list = await loadKeywordsLocal();
|
||
const title = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_LIST_TITLE_ZH, en: ADMIN_KEYWORD_LIST_TITLE_EN });
|
||
const empty = getLocalizedPrompt(lang, { zh: ADMIN_KEYWORD_EMPTY_ZH, en: ADMIN_KEYWORD_EMPTY_EN });
|
||
const body = list.length
|
||
? list.map((x, i) => `${i + 1}. \`${x}\``).join('\n')
|
||
: empty;
|
||
await sendMessage({
|
||
chat_id: ADMIN_UID,
|
||
text: `${title}\n${body}`,
|
||
parse_mode: 'Markdown'
|
||
});
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'listKeywordsLocal', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((message.text || '') === '/reloadblock') {
|
||
try {
|
||
const { words, updated, source, url } = await fetchRemoteBlocklist({ force: true });
|
||
const t = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_BLOCKLIST_RELOADED_ZH(source, updated, words.length, url),
|
||
en: ADMIN_BLOCKLIST_RELOADED_EN(source, updated, words.length, url)
|
||
});
|
||
await sendMessage({ chat_id: ADMIN_UID, text: t });
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'reloadblock', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((message.text || '') === '/listkw_remote') {
|
||
try {
|
||
const words = await getBlockedWordsRemote();
|
||
const sample = words.slice(0, 100);
|
||
const t = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_BLOCKLIST_REMOTE_TITLE_ZH(words.length, sample.length),
|
||
en: ADMIN_BLOCKLIST_REMOTE_TITLE_EN(words.length, sample.length)
|
||
}) + '\n' + sample.join(', ');
|
||
await sendMessage({ chat_id: ADMIN_UID, text: t });
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'listkw_remote', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((message.text || '') === '/listkw_all') {
|
||
try {
|
||
const local = await loadKeywordsLocal();
|
||
const remote = await getBlockedWordsRemote();
|
||
const merged = Array.from(new Set([...local.map(String), ...remote.map(String)]));
|
||
const sample = merged.slice(0, 100);
|
||
const t = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_BLOCKLIST_ALL_TITLE_ZH(merged.length, sample.length),
|
||
en: ADMIN_BLOCKLIST_ALL_TITLE_EN(merged.length, sample.length)
|
||
}) + '\n' + sample.join(', ');
|
||
await sendMessage({ chat_id: ADMIN_UID, text: t });
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'listkw_all', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((message.text || '') === '/version') {
|
||
await sendMessage({
|
||
chat_id: ADMIN_UID,
|
||
text: `ZH:\n${MESSAGE_FORWARDED_NOTIF_ZH}\n\nEN:\n${MESSAGE_FORWARDED_NOTIF_EN}`
|
||
});
|
||
return;
|
||
}
|
||
|
||
if ((message.text || '') === '/notifytest') {
|
||
const lang2 = message.from?.language_code || 'en';
|
||
const notificationText = getLocalizedPrompt(lang2, {
|
||
zh: MESSAGE_FORWARDED_NOTIF_ZH,
|
||
en: MESSAGE_FORWARDED_NOTIF_EN
|
||
});
|
||
await sendMessage({ chat_id: ADMIN_UID, text: notificationText });
|
||
return;
|
||
}
|
||
|
||
if (/^\/resetnotify(?:\s+(\d+))?$/i.test(message.text || '')) {
|
||
const m = (message.text || '').match(/^\/resetnotify(?:\s+(\d+))?$/i);
|
||
const targetId = (m && m[1])
|
||
? m[1]
|
||
: String(message.reply_to_message?.forward_from?.id || '');
|
||
if (!targetId) {
|
||
await sendMessage({
|
||
chat_id: ADMIN_UID,
|
||
text: '用法: /resetnotify <userId> 或对转发消息回复 /resetnotify'
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
await KV.delete(`notify:until:${targetId}`);
|
||
await KV.delete(`notify:last:${targetId}`);
|
||
await KV.delete(`lastmsg-${targetId}`);
|
||
} catch (_) {}
|
||
await sendMessage({
|
||
chat_id: ADMIN_UID,
|
||
text: `已清理节流键:${targetId}`
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (message.reply_to_message) {
|
||
if (/^\/block$/.test(message.text)) return handleBlock(message, lang);
|
||
if (/^\/unblock$/.test(message.text)) return handleUnblock(message, lang);
|
||
if (/^\/checkblock$/.test(message.text)) return checkBlock(message, lang);
|
||
|
||
try {
|
||
const guestId = await KV.get('msg-map-' + message.reply_to_message.message_id, { type: "text" });
|
||
if (guestId) {
|
||
await copyMessage({
|
||
chat_id: guestId,
|
||
from_chat_id: message.chat.id,
|
||
message_id: message.message_id
|
||
});
|
||
} else {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_CANNOT_FIND_USER_ID_PROMPT_ZH,
|
||
en: ADMIN_CANNOT_FIND_USER_ID_PROMPT_EN
|
||
});
|
||
await sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'admin_reply_lookup_msg_map', err);
|
||
}
|
||
} else {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_REPLY_PROMPT_ZH,
|
||
en: ADMIN_REPLY_PROMPT_EN
|
||
});
|
||
await sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
return;
|
||
}
|
||
|
||
await handleGuestMessage(message, lang);
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, m => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[m]));
|
||
}
|
||
|
||
function formatUserForAdmin(u) {
|
||
const id = u?.id;
|
||
const uname = u?.username;
|
||
const name = [u?.first_name, u?.last_name].filter(Boolean).join(' ') || 'user';
|
||
if (uname) return `@${uname}`;
|
||
if (id) return `<a href="tg://user?id=${id}">${escapeHtml(name)}</a>`;
|
||
return escapeHtml(name);
|
||
}
|
||
|
||
async function getOrCreateUserTag(userId) {
|
||
const key = `user-tag-${userId}`;
|
||
const existing = await KV.get(key, { type: 'text' }).catch(() => null);
|
||
if (existing) return existing;
|
||
|
||
let counter = 0;
|
||
try {
|
||
const raw = await KV.get(USER_TAG_COUNTER_KEY, { type: 'text' });
|
||
counter = raw ? parseInt(raw, 10) || 0 : 0;
|
||
} catch (_) {}
|
||
|
||
counter += 1;
|
||
const tag = 'U' + String(counter).padStart(3, '0');
|
||
|
||
await KV.put(USER_TAG_COUNTER_KEY, String(counter));
|
||
await KV.put(key, tag);
|
||
|
||
return tag;
|
||
}
|
||
|
||
async function sendAdminSessionHeader(message, guestId, lang) {
|
||
const tag = await getOrCreateUserTag(guestId);
|
||
|
||
const key = `session-header-last:${guestId}`;
|
||
let last = 0;
|
||
try {
|
||
const raw = await KV.get(key, { type: 'text' });
|
||
last = raw ? parseInt(raw, 10) || 0 : 0;
|
||
} catch (_) {}
|
||
|
||
const now = Date.now();
|
||
if (last && (now - last) < SESSION_HEADER_INTERVAL) {
|
||
return;
|
||
}
|
||
|
||
await KV.put(key, String(now));
|
||
|
||
const actor = formatUserForAdmin(message.from || {});
|
||
const langCode = message.from?.language_code || 'n/a';
|
||
|
||
const text =
|
||
`📂 会话 ${tag}\n` +
|
||
`👤 用户: ${actor}\n` +
|
||
`🆔 ID: \`${guestId}\`\n` +
|
||
`🌐 语言: \`${langCode}\``;
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text,
|
||
parse_mode: 'Markdown'
|
||
});
|
||
}
|
||
|
||
async function ensureVerified(userId, lang) {
|
||
const state = await KV.get(VERIFY_STORE_KEY(userId), { type: "json" }).catch(() => null);
|
||
if (state && state.verified === true && state.verifiedAt && (Date.now() - state.verifiedAt) <= 3 * 60 * 60 * 1000)
|
||
return true;
|
||
|
||
const token = Math.random().toString(36).slice(2, 10);
|
||
const payload = {
|
||
token,
|
||
exp: Date.now() + 10 * 60 * 1000,
|
||
verified: false,
|
||
verifiedAt: null
|
||
};
|
||
await kvPutJson(VERIFY_STORE_KEY(userId), payload);
|
||
|
||
const text = getLocalizedPrompt(lang, {
|
||
zh: VERIFY_REQUIRED_ZH,
|
||
en: VERIFY_REQUIRED_EN
|
||
});
|
||
|
||
await sendMessage({
|
||
chat_id: userId,
|
||
text,
|
||
reply_markup: {
|
||
inline_keyboard: [[{
|
||
text: lang && lang.startsWith('zh') ? '✅ 我是人类' : '✅ I’m human',
|
||
callback_data: `verify:${token}`
|
||
}]]
|
||
}
|
||
});
|
||
|
||
return false;
|
||
}
|
||
|
||
async function onCallbackQuery(cbq) {
|
||
const fromId = cbq.from?.id;
|
||
const lang = cbq.from?.language_code || 'en';
|
||
const data = cbq.data || '';
|
||
if (!fromId || !data.startsWith('verify:')) {
|
||
await answerCallbackQuery({ callback_query_id: cbq.id });
|
||
return;
|
||
}
|
||
|
||
const token = data.split(':')[1];
|
||
const key = VERIFY_STORE_KEY(fromId);
|
||
const state = await KV.get(key, { type: "json" }).catch(() => null);
|
||
if (!state || state.exp < Date.now() || state.verified === true) {
|
||
await KV.delete(key).catch(()=>{});
|
||
await ensureVerified(fromId, lang);
|
||
await answerCallbackQuery({ callback_query_id: cbq.id });
|
||
return;
|
||
}
|
||
|
||
if (state.token === token) {
|
||
await kvPutJson(key, { verified: true, verifiedAt: Date.now() });
|
||
await answerCallbackQuery({
|
||
callback_query_id: cbq.id,
|
||
text: lang.startsWith('zh') ? '已验证' : 'Verified'
|
||
});
|
||
const ok = getLocalizedPrompt(lang, {
|
||
zh: VERIFIED_SUCCESS_ZH,
|
||
en: VERIFIED_SUCCESS_EN
|
||
});
|
||
await sendMessage({ chat_id: fromId, text: ok });
|
||
} else {
|
||
await answerCallbackQuery({
|
||
callback_query_id: cbq.id,
|
||
text: lang.startsWith('zh') ? '验证失败,请重试' : 'Verification failed. Try again.'
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleGuestMessage(message, lang) {
|
||
const chatId = message.chat.id;
|
||
|
||
const blocked = await KV.get(`isblocked-${chatId}`, { type: "json" }).catch(() => false);
|
||
if (blocked) {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: USER_BLOCKED_PROMPT_ZH,
|
||
en: USER_BLOCKED_PROMPT_EN
|
||
});
|
||
return sendMessage({ chat_id: chatId, text: prompt });
|
||
}
|
||
|
||
const verifyState = await KV.get(VERIFY_STORE_KEY(chatId), { type: "json" }).catch(() => null);
|
||
const now = Date.now();
|
||
const maxValidMs = 3 * 60 * 60 * 1000;
|
||
let needVerify = false;
|
||
if (!verifyState || verifyState.verified !== true) {
|
||
needVerify = true;
|
||
} else if (!verifyState.verifiedAt || (now - verifyState.verifiedAt) > maxValidMs) {
|
||
needVerify = true;
|
||
}
|
||
|
||
if (needVerify) {
|
||
const ok = await ensureVerified(chatId, lang);
|
||
if (!ok) return;
|
||
}
|
||
|
||
if (ENABLE_KEYWORD_FILTER) {
|
||
try {
|
||
const text = extractSearchableText(message);
|
||
const allWords = await getAllBlockedWords();
|
||
const hit = hitBlockedKeyword(text, allWords);
|
||
if (hit) {
|
||
const userMsg = getLocalizedPrompt(lang, {
|
||
zh: USER_KEYWORD_BLOCKED_PROMPT_ZH,
|
||
en: USER_KEYWORD_BLOCKED_PROMPT_EN
|
||
});
|
||
await sendMessage({ chat_id: chatId, text: userMsg });
|
||
|
||
const actor = formatUserForAdmin(message.from || {});
|
||
const adminAlert = getLocalizedPrompt(lang, {
|
||
zh: `⚠️ ${actor} 的消息命中被屏蔽关键词:<code>${escapeHtml(hit)}</code>,已拦截。`,
|
||
en: `⚠️ Message from ${actor} contained blocked keyword: <code>${escapeHtml(hit)}</code> and was intercepted.`
|
||
});
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text: adminAlert,
|
||
parse_mode: 'HTML'
|
||
});
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
const adminDegrade = getLocalizedPrompt(lang, {
|
||
zh: `❗关键词过滤出现故障,已降级直转。\n<code>${escapeHtml(String(err?.message || err))}</code>`,
|
||
en: `❗Keyword filter failed; falling back to forward.\n<code>${escapeHtml(String(err?.message || err))}</code>`
|
||
});
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text: adminDegrade,
|
||
parse_mode: 'HTML'
|
||
});
|
||
}
|
||
}
|
||
|
||
const forwardResult = await forwardMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
from_chat_id: chatId,
|
||
message_id: message.message_id
|
||
});
|
||
|
||
if (forwardResult.ok) {
|
||
await KV.put('msg-map-' + forwardResult.result.message_id, chatId.toString())
|
||
.catch(err => notifyAdminKvError(lang, 'write_msg_map', err));
|
||
|
||
await sendAdminSessionHeader(message, chatId, lang);
|
||
|
||
if (ENABLE_INSTANT_CONFIRM) {
|
||
const okText = getLocalizedPrompt(lang, {
|
||
zh: MESSAGE_FORWARDED_OK_ZH,
|
||
en: MESSAGE_FORWARDED_OK_EN
|
||
});
|
||
await sendMessage({ chat_id: chatId, text: okText });
|
||
}
|
||
|
||
await handleNotify(message, lang);
|
||
} else {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: MESSAGE_FORWARD_FAIL_PROMPT_ZH,
|
||
en: MESSAGE_FORWARD_FAIL_PROMPT_EN
|
||
});
|
||
await sendMessage({ chat_id: chatId, text: prompt });
|
||
}
|
||
}
|
||
|
||
async function handleNotify(message, lang) {
|
||
const chatId = message.chat.id;
|
||
if (!ENABLE_NOTIFICATION) return;
|
||
|
||
const now = Date.now();
|
||
const interval = NOTIFY_INTERVAL;
|
||
const keyUntil = `notify:until:${chatId}`;
|
||
const legacyJsonKey = `notify:last:${chatId}`;
|
||
const legacyTextKey = 'lastmsg-' + chatId;
|
||
|
||
let until = 0;
|
||
|
||
try {
|
||
const obj = await KV.get(keyUntil, { type: "json" });
|
||
if (obj && typeof obj.until === "number" && isFinite(obj.until)) {
|
||
until = obj.until;
|
||
}
|
||
} catch (_) {}
|
||
|
||
if (!until) {
|
||
try {
|
||
const j = await KV.get(legacyJsonKey, { type: "json" });
|
||
if (j && typeof j.t === "number" && isFinite(j.t)) {
|
||
until = j.t + interval;
|
||
}
|
||
} catch (_) {}
|
||
if (!until) {
|
||
try {
|
||
const s = await KV.get(legacyTextKey, { type: "text" });
|
||
const t = s ? parseInt(s, 10) : 0;
|
||
if (t && isFinite(t)) until = t + interval;
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
if (until && now < until) return;
|
||
|
||
try {
|
||
await KV.put(keyUntil, JSON.stringify({ until: now + interval }));
|
||
} catch (_) {}
|
||
|
||
const notificationText = getLocalizedPrompt(lang, {
|
||
zh: MESSAGE_FORWARDED_NOTIF_ZH,
|
||
en: MESSAGE_FORWARDED_NOTIF_EN
|
||
});
|
||
await sendMessage({ chat_id: chatId, text: notificationText });
|
||
}
|
||
|
||
async function handleBlock(message, lang) {
|
||
try {
|
||
const guestId = await KV.get('msg-map-' + message.reply_to_message.message_id, { type: "text" });
|
||
if (!guestId) {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_ZH,
|
||
en: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_EN
|
||
});
|
||
return sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
|
||
if (guestId === ADMIN_UID) {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_BLOCK_SELF_PROMPT_ZH,
|
||
en: ADMIN_BLOCK_SELF_PROMPT_EN
|
||
});
|
||
return sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
|
||
await kvPutJson('isblocked-' + guestId, true)
|
||
.catch(err => notifyAdminKvError(lang, 'block_user', err));
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text: getLocalizedPrompt(lang, {
|
||
zh: `✅ 用户 \`${guestId}\` 已被成功屏蔽。`,
|
||
en: `✅ User \`${guestId}\` has been successfully blocked.`
|
||
}),
|
||
parse_mode: 'Markdown'
|
||
});
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(guestId),
|
||
text: `${USER_BLOCKED_PROMPT_ZH}\n${USER_BLOCKED_PROMPT_EN}`
|
||
});
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'handleBlock', err);
|
||
}
|
||
}
|
||
|
||
async function handleUnblock(message, lang) {
|
||
try {
|
||
const guestId = await KV.get('msg-map-' + message.reply_to_message.message_id, { type: "text" });
|
||
if (!guestId) {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_ZH,
|
||
en: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_EN
|
||
});
|
||
return sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
|
||
await kvPutJson('isblocked-' + guestId, false)
|
||
.catch(err => notifyAdminKvError(lang, 'unblock_user', err));
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text: getLocalizedPrompt(lang, {
|
||
zh: `✅ 用户 \`${guestId}\` 已被成功解除屏蔽。`,
|
||
en: `✅ User \`${guestId}\` has been successfully unblocked.`
|
||
}),
|
||
parse_mode: 'Markdown'
|
||
});
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(guestId),
|
||
text: `${USER_UNBLOCKED_PROMPT_ZH}\n${USER_UNBLOCKED_PROMPT_EN}`
|
||
});
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'handleUnblock', err);
|
||
}
|
||
}
|
||
|
||
async function checkBlock(message, lang) {
|
||
try {
|
||
const guestId = await KV.get('msg-map-' + message.reply_to_message.message_id, { type: "text" });
|
||
if (!guestId) {
|
||
const prompt = getLocalizedPrompt(lang, {
|
||
zh: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_ZH,
|
||
en: ADMIN_CANNOT_IDENTIFY_USER_PROMPT_EN
|
||
});
|
||
return sendMessage({ chat_id: ADMIN_UID, text: prompt });
|
||
}
|
||
|
||
let blocked = false;
|
||
blocked = await KV.get('isblocked-' + guestId, { type: "json" }).catch(err => {
|
||
notifyAdminKvError(lang, 'read_block_state_in_checkBlock', err);
|
||
return false;
|
||
});
|
||
|
||
await sendMessage({
|
||
chat_id: parseInt(ADMIN_UID),
|
||
text: getLocalizedPrompt(lang, {
|
||
zh: `用户信息:\`${guestId}\` ${blocked ? '已被屏蔽 🚫' : '未被屏蔽 ✅'}`,
|
||
en: `User Info: \`${guestId}\` ${blocked ? 'is blocked 🚫' : 'is not blocked ✅'}`
|
||
}),
|
||
parse_mode: 'Markdown'
|
||
});
|
||
} catch (err) {
|
||
await notifyAdminKvError(lang, 'checkBlock', err);
|
||
}
|
||
}
|
||
|
||
async function registerWebhook(event, url) {
|
||
const webhookUrl = `${url.protocol}//${url.hostname}${WEBHOOK}`;
|
||
const res = await setWebhook(webhookUrl, SECRET, {
|
||
allowed_updates: ["message", "callback_query"],
|
||
drop_pending_updates: true
|
||
});
|
||
return new Response(JSON.stringify(res, null, 2), {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
async function unRegisterWebhook() {
|
||
const res = await setWebhook('', undefined, {
|
||
drop_pending_updates: false
|
||
});
|
||
return new Response(JSON.stringify(res, null, 2), {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
async function setBotCommands() {
|
||
const adminCommands = [
|
||
{ command: "block", description: "屏蔽用户 (需回复用户消息)" },
|
||
{ command: "unblock", description: "解除屏蔽 (需回复用户消息)" },
|
||
{ command: "checkblock", description: "查询屏蔽状态 (需回复用户消息)" },
|
||
{ command: "addkw", description: "添加屏蔽关键词" },
|
||
{ command: "rmkw", description: "移除屏蔽关键词" },
|
||
{ command: "listkw", description: "查看本地关键词" },
|
||
{ command: "reloadblock", description: "刷新远程拦截词" },
|
||
{ command: "listkw_all", description: "查看合并关键词预览" }
|
||
];
|
||
|
||
const userCommands = [
|
||
{ command: "start", description: "获取关于此机器人的信息" }
|
||
];
|
||
|
||
const userRes = await setMyCommands(userCommands);
|
||
if (!userRes.ok) console.error('设置用户命令失败:', userRes);
|
||
|
||
const adminScope = { type: "chat", chat_id: parseInt(ADMIN_UID) };
|
||
const adminRes = await setMyCommands(adminCommands, adminScope);
|
||
if (!adminRes.ok) console.error('设置管理员命令失败:', adminRes);
|
||
|
||
return {
|
||
userCommandsSet: userRes.ok,
|
||
adminCommandsSet: adminRes.ok,
|
||
adminResponse: adminRes
|
||
};
|
||
}
|
||
|
||
async function handleSetMenu() {
|
||
const res = await setBotCommands();
|
||
return new Response(JSON.stringify(res, null, 2), {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
async function debugWebhook() {
|
||
const r = await fetch(`https://api.telegram.org/bot${TOKEN}/getWebhookInfo`);
|
||
const j = await r.json();
|
||
return new Response(JSON.stringify(j, null, 2), {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
} |