diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..b38a2b4 --- /dev/null +++ b/worker.js @@ -0,0 +1,961 @@ +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 | /rmkw | /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 或对转发消息回复 /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 `${escapeHtml(name)}`; + 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} 的消息命中被屏蔽关键词:${escapeHtml(hit)},已拦截。`, + en: `⚠️ Message from ${actor} contained blocked keyword: ${escapeHtml(hit)} and was intercepted.` + }); + await sendMessage({ + chat_id: parseInt(ADMIN_UID), + text: adminAlert, + parse_mode: 'HTML' + }); + return; + } + } catch (err) { + const adminDegrade = getLocalizedPrompt(lang, { + zh: `❗关键词过滤出现故障,已降级直转。\n${escapeHtml(String(err?.message || err))}`, + en: `❗Keyword filter failed; falling back to forward.\n${escapeHtml(String(err?.message || err))}` + }); + 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' } + }); +} \ No newline at end of file