From 5d605710f3683ae40eb61d1ac9203af52f7f6161 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 29 Jun 2026 16:40:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5tgbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 25 + migrations/009_telegram_bot.sql | 27 + migrations/postgres/009_telegram_bot.sql | 25 + src/app/admin/page.tsx | 252 ++++++++ src/app/api/admin/telegram/route.ts | 156 +++++ src/app/api/register/route.ts | 22 + src/app/api/server-config/route.ts | 9 + src/app/api/telegram/bind/route.ts | 46 ++ src/app/api/telegram/config/route.ts | 16 + src/app/api/telegram/login/create/route.ts | 24 + src/app/api/telegram/login/status/route.ts | 34 ++ .../api/telegram/webhook/[secret]/route.ts | 22 + src/app/layout.tsx | 11 + src/app/login/page.tsx | 88 ++- src/app/register/page.tsx | 57 +- src/components/EmailSettingsPanel.tsx | 73 ++- src/components/UserMenu.tsx | 43 ++ src/lib/admin.types.ts | 12 + src/lib/auth-cookie.ts | 110 ++++ src/lib/config.ts | 26 + src/lib/d1.db.ts | 136 ++++- src/lib/db.ts | 53 ++ src/lib/notification-dispatch.ts | 14 + src/lib/postgres.db.ts | 136 ++++- src/lib/redis-base.db.ts | 63 +- src/lib/telegram.ts | 574 ++++++++++++++++++ src/lib/types.ts | 36 ++ 27 files changed, 2079 insertions(+), 11 deletions(-) create mode 100644 migrations/009_telegram_bot.sql create mode 100644 migrations/postgres/009_telegram_bot.sql create mode 100644 src/app/api/admin/telegram/route.ts create mode 100644 src/app/api/telegram/bind/route.ts create mode 100644 src/app/api/telegram/config/route.ts create mode 100644 src/app/api/telegram/login/create/route.ts create mode 100644 src/app/api/telegram/login/status/route.ts create mode 100644 src/app/api/telegram/webhook/[secret]/route.ts create mode 100644 src/lib/auth-cookie.ts create mode 100644 src/lib/notification-dispatch.ts create mode 100644 src/lib/telegram.ts diff --git a/README.md b/README.md index 2ae6abe..5d5b21e 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,31 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | QR_LOGIN_STORE_MODE | 电视端扫码登录状态存储模式;serverless环境下多节点内存状态不可靠。 | auto、memory、hybrid、shared | auto | | WEB_PUSH_PROXY | Web Push 服务端发送代理地址,用于服务器访问 FCM 等 Push endpoint | HTTP/HTTPS 代理 URL | (空) | | WEB_PUSH_BASEURL | Web Push endpoint 反向代理 Base URL;支持 `{endpoint}`(URL编码)和 `{raw_endpoint}`(不编码)占位符 | URL | (空) | +| TELEGRAM_BOT_TOKEN | Telegram Bot Token,用于 Bot 登录、绑定和通知推送 | BotFather 生成的 token | (空) | +| TELEGRAM_BOT_USERNAME | Telegram Bot 用户名(不含或包含 @ 均可) | bot username | (空) | +| TELEGRAM_WEBHOOK_SECRET | Telegram Webhook Secret;Webhook 路径为 `/api/telegram/webhook/` | 随机长字符串 | (空) | +| TELEGRAM_API_PROXY | Telegram Bot API 系统代理(Node 部署可用,Cloudflare/Edge 会忽略) | HTTP/HTTPS 代理 URL | (空) | +| TELEGRAM_API_BASE_URL | Telegram Bot API 反代 Base URL,用于替换 `https://api.telegram.org` | URL | (空) | +| TELEGRAM_LOGIN_ENABLED | 是否启用 Telegram 快捷登录 | true/false | true | +| TELEGRAM_BINDING_ENABLED | 是否启用 Telegram 账号绑定 | true/false | true | +| TELEGRAM_NOTIFICATIONS_ENABLED | 是否启用 Telegram 通知推送 | true/false | true | +| TELEGRAM_DEFAULT_NOTIFICATIONS | 新绑定 Telegram 用户是否默认开启通知 | true/false | true | + + +### Telegram Bot 配置 + +1. 在 Telegram 通过 BotFather 创建 Bot,获取 `TELEGRAM_BOT_TOKEN` 和 Bot 用户名。 +2. 设置 `TELEGRAM_BOT_TOKEN`、`TELEGRAM_BOT_USERNAME`、`TELEGRAM_WEBHOOK_SECRET` 并重启服务。 +3. 如服务器无法直连 Telegram,可选填 `TELEGRAM_API_PROXY`(系统代理)或 `TELEGRAM_API_BASE_URL`(反代 Base URL)。 +4. 可在后台 Telegram Bot 配置页点击“一键设置 Webhook”,或手动将 Webhook 设置到:`https://你的域名/api/telegram/webhook/`。 + +可使用以下命令设置 Webhook: + +```bash +curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" -d "url=https://你的域名/api/telegram/webhook/$TELEGRAM_WEBHOOK_SECRET" -d "secret_token=$TELEGRAM_WEBHOOK_SECRET" +``` + +用户登录后可在“通知设置”中生成绑定码,也可在注册成功页直接绑定;绑定后可接收站内通知并使用 Telegram 确认登录。 NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: diff --git a/migrations/009_telegram_bot.sql b/migrations/009_telegram_bot.sql new file mode 100644 index 0000000..e39ba34 --- /dev/null +++ b/migrations/009_telegram_bot.sql @@ -0,0 +1,27 @@ +-- Telegram Bot bindings and bind sessions + +CREATE TABLE IF NOT EXISTS telegram_bindings ( + username TEXT PRIMARY KEY, + telegram_user_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + telegram_username TEXT, + first_name TEXT, + last_name TEXT, + notifications_enabled INTEGER DEFAULT 1, + bound_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_telegram_bindings_user_id ON telegram_bindings(telegram_user_id); + +CREATE TABLE IF NOT EXISTS telegram_bind_sessions ( + code TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used INTEGER DEFAULT 0, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_telegram_bind_sessions_expires ON telegram_bind_sessions(expires_at); diff --git a/migrations/postgres/009_telegram_bot.sql b/migrations/postgres/009_telegram_bot.sql new file mode 100644 index 0000000..52c2df1 --- /dev/null +++ b/migrations/postgres/009_telegram_bot.sql @@ -0,0 +1,25 @@ +-- Telegram Bot bindings and bind sessions for Postgres + +CREATE TABLE IF NOT EXISTS telegram_bindings ( + username TEXT PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE, + telegram_user_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + telegram_username TEXT, + first_name TEXT, + last_name TEXT, + notifications_enabled INTEGER DEFAULT 1, + bound_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_telegram_bindings_user_id ON telegram_bindings(telegram_user_id); + +CREATE TABLE IF NOT EXISTS telegram_bind_sessions ( + code TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + used INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_telegram_bind_sessions_expires ON telegram_bind_sessions(expires_at); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3f9373a..d024ca4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -44,6 +44,7 @@ import { Palette, Plus, Search, + Send, Settings, Smartphone, Tablet, @@ -13939,6 +13940,241 @@ const XiaoyaConfigComponent = ({ ); }; + +// Telegram Bot 配置组件 +const TelegramConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [botToken, setBotToken] = useState(''); + const [botUsername, setBotUsername] = useState(''); + const [webhookSecret, setWebhookSecret] = useState(''); + const [apiProxy, setApiProxy] = useState(''); + const [apiBaseUrl, setApiBaseUrl] = useState(''); + const [loginEnabled, setLoginEnabled] = useState(true); + const [bindingEnabled, setBindingEnabled] = useState(true); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const [defaultNotifications, setDefaultNotifications] = useState(true); + const [testChatId, setTestChatId] = useState(''); + + useEffect(() => { + const telegram = config?.TelegramConfig; + if (telegram) { + setEnabled(Boolean(telegram.enabled)); + setBotToken(telegram.botToken || ''); + setBotUsername(telegram.botUsername || ''); + setWebhookSecret(telegram.webhookSecret || ''); + setApiProxy(telegram.apiProxy || ''); + setApiBaseUrl(telegram.apiBaseUrl || ''); + setLoginEnabled(telegram.loginEnabled !== false); + setBindingEnabled(telegram.bindingEnabled !== false); + setNotificationsEnabled(telegram.notificationsEnabled !== false); + setDefaultNotifications(telegram.defaultNotifications !== false); + } + }, [config]); + + const buildConfig = (): AdminConfig['TelegramConfig'] => ({ + enabled, + botToken, + botUsername: botUsername.replace(/^@/, ''), + webhookSecret, + apiProxy, + apiBaseUrl, + loginEnabled, + bindingEnabled, + notificationsEnabled, + defaultNotifications, + }); + + const handleSave = async () => { + await withLoading('saveTelegram', async () => { + try { + const response = await fetch('/api/admin/telegram', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'save', config: buildConfig() }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || '保存失败'); + showSuccess('Telegram 配置保存成功', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleSetWebhook = async () => { + await withLoading('setTelegramWebhook', async () => { + try { + if (!enabled || !botToken.trim() || !botUsername.trim() || !webhookSecret.trim()) { + throw new Error('请先填写 Bot Token、Bot 用户名 和 Webhook Secret'); + } + + const webhookUrlValue = webhookSecret === '******' + ? '' + : `${window.location.origin}/api/telegram/webhook/${webhookSecret}`; + const response = await fetch('/api/admin/telegram', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'set_webhook', + config: buildConfig(), + webhookUrl: webhookUrlValue, + origin: window.location.origin, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const telegramDetail = data.telegram + ? `(HTTP ${data.telegram.status || '-'},响应:${data.telegram.body || data.telegram.statusText || '-'})` + : ''; + throw new Error(`${data.error || 'Webhook 设置失败'}${telegramDetail}`); + } + showSuccess('Webhook 设置成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : 'Webhook 设置失败', showAlert); + throw error; + } + }); + }; + + const handleTest = async () => { + if (!testChatId.trim()) { + showError('请输入测试 Chat ID', showAlert); + return; + } + + await withLoading('testTelegram', async () => { + try { + const response = await fetch('/api/admin/telegram', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'test', config: buildConfig(), testChatId: testChatId.trim() }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || '发送失败'); + showSuccess('测试消息发送成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '发送失败', showAlert); + throw error; + } + }); + }; + + const webhookUrl = webhookSecret + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/api/telegram/webhook/${webhookSecret === '******' ? '' : webhookSecret}` + : ''; + + return ( +
+
+

+ 关于 Telegram Bot +

+
+

• 支持用户绑定 Telegram、快捷确认登录和站内通知推送

+

• Webhook 地址需在 Telegram Bot API 中手动设置

+

• Bot Token 和 Webhook Secret 仅服务端保存,不会暴露给前端

+
+
+ +
+
+
+

启用 Telegram Bot

+

开启后显示绑定与 Telegram 登录入口

+
+ +
+ +
+
+ + setBotToken(e.target.value)} placeholder='123456:ABC...' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> +
+
+ + setBotUsername(e.target.value)} placeholder='your_bot' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> +
+
+ +
+ + setWebhookSecret(e.target.value)} placeholder='建议填写随机长字符串' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> + {webhookUrl &&

Webhook URL:{webhookUrl}

} +
+ +
+
+ +
+
+ + setApiProxy(e.target.value)} placeholder='http://127.0.0.1:7890' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> +

Node 部署可用;Cloudflare/Edge 环境会忽略。

+
+
+ + setApiBaseUrl(e.target.value)} placeholder='https://telegram-api.example.com' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> +

用于替换 https://api.telegram.org。

+
+
+ +
+ {[ + ['允许绑定', bindingEnabled, setBindingEnabled], + ['允许 Telegram 登录', loginEnabled, setLoginEnabled], + ['启用 Telegram 通知', notificationsEnabled, setNotificationsEnabled], + ['新绑定默认开启通知', defaultNotifications, setDefaultNotifications], + ].map(([label, value, setter]) => ( + + ))} +
+ +
+ +
+ setTestChatId(e.target.value)} placeholder='用户或群组 chat_id' className='min-w-0 flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' /> + +
+
+ +
+ +
+
+ + +
+ ); +}; + // 邮件配置组件 const EmailConfigComponent = ({ config, @@ -16757,6 +16993,7 @@ function AdminPageClient() { customAdFilter: false, themeConfig: false, emailConfig: false, + telegramConfig: false, }); // 获取管理员配置 @@ -17392,6 +17629,21 @@ function AdminPageClient() { /> + {/* Telegram Bot 配置标签 */} + + } + isExpanded={expandedTabs.telegramConfig} + onToggle={() => toggleTab('telegramConfig')} + > + + + {/* 分类配置标签 */} (null); const [turnstileWidgetId, setTurnstileWidgetId] = useState(null); const [backgroundImage, setBackgroundImage] = useState(''); + const [telegramLoginEnabled, setTelegramLoginEnabled] = useState(false); + const [telegramLoginLoading, setTelegramLoginLoading] = useState(false); + const [telegramLoginHint, setTelegramLoginHint] = useState(null); const { siteName } = useSite(); @@ -150,6 +153,7 @@ function LoginPageClient() { EnableOIDCLogin: runtimeConfig?.ENABLE_OIDC_LOGIN || false, OIDCButtonText: runtimeConfig?.OIDC_BUTTON_TEXT || '', }); + setTelegramLoginEnabled(Boolean(runtimeConfig?.ENABLE_TELEGRAM_LOGIN)); // 从localStorage读取记住的密码信息 const rememberedCredentials = localStorage.getItem('rememberedCredentials'); @@ -275,6 +279,58 @@ function LoginPageClient() { } }; + const handleTelegramLogin = async () => { + setError(null); + setTelegramLoginHint(null); + + try { + setTelegramLoginLoading(true); + const createRes = await fetch('/api/telegram/login/create', { method: 'POST' }); + const createData = await createRes.json().catch(() => ({})); + if (!createRes.ok) { + setError(createData.error || 'Telegram 登录未启用'); + return; + } + + setTelegramLoginHint('请在 Telegram 中确认登录'); + window.open(createData.deepLink, '_blank', 'noopener,noreferrer'); + + const startedAt = Date.now(); + const timer = window.setInterval(async () => { + if (Date.now() - startedAt > 5 * 60 * 1000) { + window.clearInterval(timer); + setTelegramLoginLoading(false); + setTelegramLoginHint(null); + setError('Telegram 登录已超时,请重试'); + return; + } + + const statusRes = await fetch(`/api/telegram/login/status?token=${encodeURIComponent(createData.token)}`); + const statusData = await statusRes.json().catch(() => ({})); + if (statusData.status === 'confirmed') { + window.clearInterval(timer); + const redirect = searchParams.get('redirect') || '/'; + window.location.replace(redirect); + } else if (statusData.status === 'denied') { + window.clearInterval(timer); + setTelegramLoginLoading(false); + setTelegramLoginHint(null); + setError('已拒绝 Telegram 登录'); + } else if (statusData.status === 'expired') { + window.clearInterval(timer); + setTelegramLoginLoading(false); + setTelegramLoginHint(null); + setError('Telegram 登录已过期'); + } + }, 2000); + } catch (error) { + setError('Telegram 登录请求失败,请稍后重试'); + setTelegramLoginLoading(false); + setTelegramLoginHint(null); + } + }; + + return ( @@ -400,6 +456,36 @@ function LoginPageClient() { )} + {/* Telegram登录按钮 */} + {telegramLoginEnabled && shouldAskUsername && ( +
+
+
+
+
+
+ + 或 + +
+
+ + {telegramLoginHint && ( +

+ {telegramLoginHint} +

+ )} +
+ )} + {/* OIDC登录按钮 */} {siteConfig?.EnableOIDCLogin && shouldAskUsername && (
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 030a323..45790a1 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { AlertCircle, CheckCircle, Eye, EyeOff, User, Lock } from 'lucide-react'; +import { AlertCircle, CheckCircle, Eye, EyeOff, Send, User, Lock } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; @@ -83,6 +83,8 @@ function RegisterPageClient() { const [siteConfig, setSiteConfig] = useState(null); const [turnstileWidgetId, setTurnstileWidgetId] = useState(null); const [backgroundImage, setBackgroundImage] = useState(''); + const [registerSuccess, setRegisterSuccess] = useState(false); + const [telegramBind, setTelegramBind] = useState<{ code: string; deepLink?: string } | null>(null); const { siteName } = useSite(); @@ -204,6 +206,16 @@ function RegisterPageClient() { }); if (res.ok) { + const data = await res.json().catch(() => ({})); + if (data.telegramBind?.code) { + setTelegramBind({ + code: data.telegramBind.code, + deepLink: data.telegramBind.deepLink || '', + }); + setRegisterSuccess(true); + return; + } + // 注册成功,跳转到登录页 const redirect = searchParams.get('redirect') || '/login'; router.replace(redirect); @@ -265,6 +277,48 @@ function RegisterPageClient() {

创建新账号

+ {registerSuccess ? ( +
+
+
+ + 注册成功 +
+

账号已创建。你可以现在绑定 Telegram,用于接收通知和后续快捷登录。

+
+ + {telegramBind && ( +
+
+ + Telegram 绑定 +
+

在 Bot 中发送:

+
+ /bind {telegramBind.code} +
+ {telegramBind.deepLink && ( + + )} +

绑定码 10 分钟内有效,也可稍后登录后在通知设置中重新生成。

+
+ )} + + +
+ ) : (
+ )}
{/* 版本信息显示 */} diff --git a/src/components/EmailSettingsPanel.tsx b/src/components/EmailSettingsPanel.tsx index 1ea4dc8..34653f0 100644 --- a/src/components/EmailSettingsPanel.tsx +++ b/src/components/EmailSettingsPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Bell, Info, Mail, MonitorSmartphone, X } from 'lucide-react'; +import { Bell, Info, Mail, MonitorSmartphone, Send, X } from 'lucide-react'; import { createPortal } from 'react-dom'; interface EmailSettingsPanelProps { @@ -16,6 +16,13 @@ interface EmailSettingsPanelProps { pushNotificationsSupported: boolean; pushNotificationsConfigured: boolean; pushNotificationsBusy: boolean; + telegramEnabled?: boolean; + telegramBound?: boolean; + telegramUsername?: string; + telegramBindCode?: string; + telegramDeepLink?: string; + telegramBindingBusy?: boolean; + onCreateTelegramBindCode?: () => void; emailSettingsLoading: boolean; emailSettingsSaving: boolean; onSave: () => void; @@ -74,6 +81,13 @@ export function EmailSettingsPanel({ pushNotificationsSupported, pushNotificationsConfigured, pushNotificationsBusy, + telegramEnabled, + telegramBound, + telegramUsername, + telegramBindCode, + telegramDeepLink, + telegramBindingBusy, + onCreateTelegramBindCode, emailSettingsLoading, emailSettingsSaving, onSave, @@ -113,7 +127,7 @@ export function EmailSettingsPanel({ 通知设置

- 管理邮件通知和当前设备浏览器系统通知。 + 管理邮件通知、浏览器系统通知和 Telegram Bot 通知。

+ {telegramDeepLink && ( + + )} + + + )} + + + )} +