接入tgbot
This commit is contained in:
@@ -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<void>;
|
||||
}) => {
|
||||
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 === '******' ? '<secret>' : webhookSecret}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-800 rounded-lg p-4'>
|
||||
<h3 className='text-sm font-medium text-sky-900 dark:text-sky-100 mb-2'>
|
||||
关于 Telegram Bot
|
||||
</h3>
|
||||
<div className='text-sm text-sky-800 dark:text-sky-200 space-y-1'>
|
||||
<p>• 支持用户绑定 Telegram、快捷确认登录和站内通知推送</p>
|
||||
<p>• Webhook 地址需在 Telegram Bot API 中手动设置</p>
|
||||
<p>• Bot Token 和 Webhook Secret 仅服务端保存,不会暴露给前端</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>启用 Telegram Bot</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>开启后显示绑定与 Telegram 登录入口</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>Bot Token *</label>
|
||||
<input type='password' value={botToken} onChange={(e) => 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' />
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>Bot 用户名 *</label>
|
||||
<input type='text' value={botUsername} onChange={(e) => 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' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>Webhook Secret</label>
|
||||
<input type='password' value={webhookSecret} onChange={(e) => 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 && <p className='mt-2 break-all text-xs text-gray-500 dark:text-gray-400'>Webhook URL:{webhookUrl}</p>}
|
||||
<div className='mt-3 flex flex-col gap-2 sm:flex-row'>
|
||||
<button onClick={handleSetWebhook} disabled={isLoading('setTelegramWebhook')} className={`w-full sm:w-auto ${buttonStyles.primary}`}>{isLoading('setTelegramWebhook') ? '设置中...' : '一键设置 Webhook'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>系统代理</label>
|
||||
<input type='text' value={apiProxy} onChange={(e) => 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' />
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>Node 部署可用;Cloudflare/Edge 环境会忽略。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>反代 Base URL</label>
|
||||
<input type='text' value={apiBaseUrl} onChange={(e) => 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' />
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>用于替换 https://api.telegram.org。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700'>
|
||||
{[
|
||||
['允许绑定', bindingEnabled, setBindingEnabled],
|
||||
['允许 Telegram 登录', loginEnabled, setLoginEnabled],
|
||||
['启用 Telegram 通知', notificationsEnabled, setNotificationsEnabled],
|
||||
['新绑定默认开启通知', defaultNotifications, setDefaultNotifications],
|
||||
].map(([label, value, setter]) => (
|
||||
<label key={label as string} className='flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300'>
|
||||
<input type='checkbox' checked={value as boolean} onChange={(e) => (setter as (value: boolean) => void)(e.target.checked)} />
|
||||
{label as string}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-gray-200 p-4 dark:border-gray-700'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>测试 Chat ID</label>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<input type='text' value={testChatId} onChange={(e) => 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' />
|
||||
<button onClick={handleTest} disabled={isLoading('testTelegram')} className={`w-full shrink-0 sm:w-auto ${buttonStyles.primary}`}>{isLoading('testTelegram') ? '发送中...' : '测试'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button onClick={handleSave} disabled={isLoading('saveTelegram')} className={buttonStyles.success}>
|
||||
{isLoading('saveTelegram') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 邮件配置组件
|
||||
const EmailConfigComponent = ({
|
||||
config,
|
||||
@@ -16757,6 +16993,7 @@ function AdminPageClient() {
|
||||
customAdFilter: false,
|
||||
themeConfig: false,
|
||||
emailConfig: false,
|
||||
telegramConfig: false,
|
||||
});
|
||||
|
||||
// 获取管理员配置
|
||||
@@ -17392,6 +17629,21 @@ function AdminPageClient() {
|
||||
/>
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* Telegram Bot 配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='Telegram Bot'
|
||||
icon={
|
||||
<Send size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.telegramConfig}
|
||||
onToggle={() => toggleTab('telegramConfig')}
|
||||
>
|
||||
<TelegramConfigComponent
|
||||
config={config}
|
||||
refreshConfig={fetchConfig}
|
||||
/>
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 分类配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='分类配置'
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { AdminConfig } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { getTelegramConfig, sendTelegramMessage, setTelegramWebhook, TelegramApiError } from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
|
||||
async function assertAdmin(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) return { error: 'Unauthorized', status: 401 } as const;
|
||||
|
||||
const storage = getStorage();
|
||||
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
|
||||
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
|
||||
return { error: 'Forbidden', status: 403 } as const;
|
||||
}
|
||||
|
||||
return { storage } as const;
|
||||
}
|
||||
|
||||
function maskTelegramConfig(config: AdminConfig['TelegramConfig']) {
|
||||
return {
|
||||
enabled: config?.enabled || false,
|
||||
botToken: config?.botToken ? '******' : '',
|
||||
botUsername: config?.botUsername || '',
|
||||
webhookSecret: config?.webhookSecret ? '******' : '',
|
||||
apiProxy: config?.apiProxy || '',
|
||||
apiBaseUrl: config?.apiBaseUrl || '',
|
||||
loginEnabled: config?.loginEnabled !== false,
|
||||
bindingEnabled: config?.bindingEnabled !== false,
|
||||
notificationsEnabled: config?.notificationsEnabled !== false,
|
||||
defaultNotifications: config?.defaultNotifications !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await assertAdmin(request);
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
return NextResponse.json(maskTelegramConfig(adminConfig.TelegramConfig));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await assertAdmin(request);
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
const body = await request.json();
|
||||
const { action, config, testChatId, webhookUrl, origin } = body;
|
||||
|
||||
if (action === 'set_webhook') {
|
||||
const overrideConfig = config as AdminConfig['TelegramConfig'] | undefined;
|
||||
const savedConfig = await getTelegramConfig();
|
||||
const botToken =
|
||||
overrideConfig?.botToken && overrideConfig.botToken !== '******'
|
||||
? overrideConfig.botToken
|
||||
: savedConfig.botToken;
|
||||
const secret =
|
||||
overrideConfig?.webhookSecret && overrideConfig.webhookSecret !== '******'
|
||||
? overrideConfig.webhookSecret
|
||||
: savedConfig.webhookSecret;
|
||||
const baseOrigin = String(origin || '').trim().replace(/\/$/, '');
|
||||
const resolvedWebhookUrl = String(webhookUrl || '').trim() ||
|
||||
(baseOrigin && secret ? `${baseOrigin}/api/telegram/webhook/${secret}` : '');
|
||||
|
||||
if (!botToken || !secret || !resolvedWebhookUrl) {
|
||||
return NextResponse.json({ error: '缺少 Bot Token、Webhook Secret 或站点地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiProxy = overrideConfig?.apiProxy || savedConfig.apiProxy;
|
||||
const apiBaseUrl = overrideConfig?.apiBaseUrl || savedConfig.apiBaseUrl;
|
||||
|
||||
try {
|
||||
const result = await setTelegramWebhook(botToken, resolvedWebhookUrl, secret, {
|
||||
apiProxy,
|
||||
apiBaseUrl,
|
||||
});
|
||||
return NextResponse.json({ success: true, message: 'Webhook 设置成功', result });
|
||||
} catch (error) {
|
||||
if (error instanceof TelegramApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
telegram: {
|
||||
status: error.status,
|
||||
statusText: error.statusText,
|
||||
body: error.body,
|
||||
data: error.data,
|
||||
apiBaseUrl: apiBaseUrl || 'https://api.telegram.org',
|
||||
apiProxyEnabled: Boolean(apiProxy),
|
||||
},
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Webhook 设置失败' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'test') {
|
||||
if (!testChatId) {
|
||||
return NextResponse.json({ error: '请填写测试 Chat ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const overrideConfig = config as AdminConfig['TelegramConfig'] | undefined;
|
||||
const savedConfig = await getTelegramConfig();
|
||||
const botToken =
|
||||
overrideConfig?.botToken && overrideConfig.botToken !== '******'
|
||||
? overrideConfig.botToken
|
||||
: savedConfig.botToken;
|
||||
|
||||
await sendTelegramMessage(
|
||||
String(testChatId),
|
||||
'MoonTVPlus Telegram Bot 测试消息发送成功。',
|
||||
undefined,
|
||||
{
|
||||
enabled: true,
|
||||
botToken,
|
||||
botUsername: overrideConfig?.botUsername || savedConfig.botUsername,
|
||||
apiProxy: overrideConfig?.apiProxy || savedConfig.apiProxy,
|
||||
apiBaseUrl: overrideConfig?.apiBaseUrl || savedConfig.apiBaseUrl,
|
||||
}
|
||||
);
|
||||
return NextResponse.json({ success: true, message: '测试消息发送成功' });
|
||||
}
|
||||
|
||||
if (action !== 'save') {
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
const telegramConfig = config as AdminConfig['TelegramConfig'];
|
||||
if (!telegramConfig) {
|
||||
return NextResponse.json({ error: 'Telegram 配置不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (telegramConfig.enabled && (!telegramConfig.botToken || !telegramConfig.botUsername)) {
|
||||
return NextResponse.json({ error: '启用 Telegram 时必须填写 Bot Token 和 Bot 用户名' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
const oldConfig = adminConfig.TelegramConfig;
|
||||
if (telegramConfig.botToken === '******') telegramConfig.botToken = oldConfig?.botToken || '';
|
||||
if (telegramConfig.webhookSecret === '******') telegramConfig.webhookSecret = oldConfig?.webhookSecret || '';
|
||||
|
||||
adminConfig.TelegramConfig = telegramConfig;
|
||||
await auth.storage.setAdminConfig(adminConfig);
|
||||
return NextResponse.json({ success: true, message: 'Telegram 配置保存成功' });
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { lockManager } from '@/lib/lock';
|
||||
import {
|
||||
createTelegramBindSession,
|
||||
getTelegramConfig,
|
||||
getTelegramDeepLink,
|
||||
} from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -171,6 +176,23 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
await db.createUserV2(username, password, 'user', defaultTags);
|
||||
|
||||
const telegramConfig = await getTelegramConfig();
|
||||
if (telegramConfig.enabled && telegramConfig.bindingEnabled && telegramConfig.botToken) {
|
||||
const bindSession = await createTelegramBindSession(username);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: '注册成功',
|
||||
telegramBind: {
|
||||
code: bindSession.code,
|
||||
expiresAt: bindSession.expiresAt,
|
||||
botUsername: telegramConfig.botUsername,
|
||||
deepLink: telegramConfig.botUsername
|
||||
? getTelegramDeepLink(telegramConfig.botUsername, `bind_${bindSession.code}`)
|
||||
: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 注册成功
|
||||
return NextResponse.json({ ok: true, message: '注册成功' });
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -40,6 +40,8 @@ export async function GET(request: NextRequest) {
|
||||
WatchRoom: watchRoomConfig,
|
||||
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||
DanmakuAutoLoadDefault: true,
|
||||
EnableTelegramLogin: Boolean(process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_BOT_USERNAME && process.env.TELEGRAM_LOGIN_ENABLED !== 'false'),
|
||||
TelegramBotUsername: process.env.TELEGRAM_BOT_USERNAME || '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +62,13 @@ export async function GET(request: NextRequest) {
|
||||
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
|
||||
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
|
||||
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
|
||||
EnableTelegramLogin: Boolean(
|
||||
config.TelegramConfig?.enabled &&
|
||||
config.TelegramConfig?.loginEnabled &&
|
||||
(config.TelegramConfig?.botToken || process.env.TELEGRAM_BOT_TOKEN) &&
|
||||
(config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME)
|
||||
),
|
||||
TelegramBotUsername: config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME || '',
|
||||
DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false,
|
||||
loginBackgroundImage: config.ThemeConfig?.loginBackgroundImage || '',
|
||||
registerBackgroundImage: config.ThemeConfig?.registerBackgroundImage || '',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import {
|
||||
createTelegramBindSession,
|
||||
getTelegramBinding,
|
||||
getTelegramConfig,
|
||||
getTelegramDeepLink,
|
||||
} from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getTelegramConfig();
|
||||
const binding = await getTelegramBinding(authInfo.username);
|
||||
return NextResponse.json({
|
||||
enabled: config.enabled && config.bindingEnabled && Boolean(config.botToken),
|
||||
botUsername: config.botUsername,
|
||||
binding,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getTelegramConfig();
|
||||
if (!config.enabled || !config.bindingEnabled || !config.botToken) {
|
||||
return NextResponse.json({ error: 'Telegram Bot 未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await createTelegramBindSession(authInfo.username);
|
||||
return NextResponse.json({
|
||||
code: session.code,
|
||||
expiresAt: session.expiresAt,
|
||||
botUsername: config.botUsername,
|
||||
deepLink: config.botUsername ? getTelegramDeepLink(config.botUsername, `bind_${session.code}`) : '',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getTelegramConfig } from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const config = await getTelegramConfig();
|
||||
return NextResponse.json({
|
||||
enabled: config.enabled && Boolean(config.botToken),
|
||||
botUsername: config.botUsername,
|
||||
loginEnabled: config.loginEnabled,
|
||||
bindingEnabled: config.bindingEnabled,
|
||||
notificationsEnabled: config.notificationsEnabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
createTelegramLoginSession,
|
||||
getTelegramConfig,
|
||||
getTelegramDeepLink,
|
||||
} from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
const config = await getTelegramConfig();
|
||||
if (!config.enabled || !config.loginEnabled || !config.botToken || !config.botUsername) {
|
||||
return NextResponse.json({ error: 'Telegram 登录未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await createTelegramLoginSession();
|
||||
return NextResponse.json({
|
||||
token: session.token,
|
||||
expiresAt: session.expiresAt,
|
||||
botUsername: config.botUsername,
|
||||
deepLink: getTelegramDeepLink(config.botUsername, `login_${session.token}`),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
consumeConfirmedTelegramLogin,
|
||||
getTelegramLoginSession,
|
||||
} from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = new URL(request.url).searchParams.get('token');
|
||||
const session = await getTelegramLoginSession(token);
|
||||
if (!session) return NextResponse.json({ status: 'expired' });
|
||||
|
||||
if (session.status === 'confirmed' && session.authToken) {
|
||||
const consumed = await consumeConfirmedTelegramLogin(session.token);
|
||||
const response = NextResponse.json({ status: 'confirmed', username: consumed?.username });
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 60);
|
||||
response.cookies.set('auth', session.authToken, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: session.status,
|
||||
expiresAt: session.expiresAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
handleTelegramWebhookUpdate,
|
||||
validateTelegramWebhookRequest,
|
||||
} from '@/lib/telegram';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { secret: string } }
|
||||
) {
|
||||
if (!(await validateTelegramWebhookRequest(request, params.secret))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const update = await request.json();
|
||||
await handleTelegramWebhookUpdate(update);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export default async function RootLayout({
|
||||
let enableOIDCLogin = false;
|
||||
let enableOIDCRegistration = false;
|
||||
let oidcButtonText = '';
|
||||
let telegramLoginEnabled = false;
|
||||
let telegramBotUsername = '';
|
||||
let aiEnabled = false;
|
||||
let aiEnableHomepageEntry = false;
|
||||
let aiEnableVideoCardEntry = false;
|
||||
@@ -173,6 +175,13 @@ export default async function RootLayout({
|
||||
enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false;
|
||||
enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false;
|
||||
oidcButtonText = config.SiteConfig.OIDCButtonText || '';
|
||||
telegramLoginEnabled = Boolean(
|
||||
config.TelegramConfig?.enabled &&
|
||||
config.TelegramConfig?.loginEnabled &&
|
||||
(config.TelegramConfig?.botToken || process.env.TELEGRAM_BOT_TOKEN) &&
|
||||
(config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME)
|
||||
);
|
||||
telegramBotUsername = config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME || '';
|
||||
// AI配置
|
||||
aiEnabled = config.AIConfig?.Enabled || false;
|
||||
aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false;
|
||||
@@ -275,6 +284,8 @@ export default async function RootLayout({
|
||||
ENABLE_OIDC_LOGIN: enableOIDCLogin,
|
||||
ENABLE_OIDC_REGISTRATION: enableOIDCRegistration,
|
||||
OIDC_BUTTON_TEXT: oidcButtonText,
|
||||
ENABLE_TELEGRAM_LOGIN: telegramLoginEnabled,
|
||||
TELEGRAM_BOT_USERNAME: telegramBotUsername,
|
||||
AI_ENABLED: aiEnabled && userFeatureAccess.ai_ask,
|
||||
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
|
||||
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
|
||||
|
||||
+87
-1
@@ -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';
|
||||
|
||||
@@ -108,6 +108,9 @@ function LoginPageClient() {
|
||||
const [siteConfig, setSiteConfig] = useState<any>(null);
|
||||
const [turnstileWidgetId, setTurnstileWidgetId] = useState<string | null>(null);
|
||||
const [backgroundImage, setBackgroundImage] = useState<string>('');
|
||||
const [telegramLoginEnabled, setTelegramLoginEnabled] = useState(false);
|
||||
const [telegramLoginLoading, setTelegramLoginLoading] = useState(false);
|
||||
const [telegramLoginHint, setTelegramLoginHint] = useState<string | null>(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() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Telegram登录按钮 */}
|
||||
{telegramLoginEnabled && shouldAskUsername && (
|
||||
<div className='mt-6'>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='px-2 bg-white/60 dark:bg-zinc-900/60 text-gray-500 dark:text-gray-400'>
|
||||
或
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
disabled={telegramLoginLoading}
|
||||
onClick={handleTelegramLogin}
|
||||
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-sky-300 dark:border-sky-700 bg-white/60 dark:bg-zinc-800/60 py-3 text-base font-semibold text-sky-700 dark:text-sky-300 shadow-sm transition-all duration-200 hover:bg-sky-50 dark:hover:bg-sky-900/30 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
<Send className='w-5 h-5 mr-2' />
|
||||
{telegramLoginLoading ? '等待 Telegram 确认...' : '使用 Telegram 登录'}
|
||||
</button>
|
||||
{telegramLoginHint && (
|
||||
<p className='mt-2 text-center text-xs text-gray-500 dark:text-gray-400'>
|
||||
{telegramLoginHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OIDC登录按钮 */}
|
||||
{siteConfig?.EnableOIDCLogin && shouldAskUsername && (
|
||||
<div className='mt-6'>
|
||||
|
||||
@@ -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<any>(null);
|
||||
const [turnstileWidgetId, setTurnstileWidgetId] = useState<string | null>(null);
|
||||
const [backgroundImage, setBackgroundImage] = useState<string>('');
|
||||
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() {
|
||||
<p className='text-center text-sm text-gray-600 dark:text-gray-400 mb-8'>
|
||||
创建新账号
|
||||
</p>
|
||||
{registerSuccess ? (
|
||||
<div className='space-y-5'>
|
||||
<div className='rounded-2xl border border-green-200 bg-green-50 p-4 text-green-800 dark:border-green-800 dark:bg-green-900/30 dark:text-green-200'>
|
||||
<div className='mb-2 flex items-center gap-2 font-semibold'>
|
||||
<CheckCircle className='h-5 w-5' />
|
||||
注册成功
|
||||
</div>
|
||||
<p className='text-sm'>账号已创建。你可以现在绑定 Telegram,用于接收通知和后续快捷登录。</p>
|
||||
</div>
|
||||
|
||||
{telegramBind && (
|
||||
<div className='rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sky-900 dark:border-sky-800 dark:bg-sky-900/30 dark:text-sky-100'>
|
||||
<div className='mb-3 flex items-center gap-2 font-semibold'>
|
||||
<Send className='h-5 w-5' />
|
||||
Telegram 绑定
|
||||
</div>
|
||||
<p className='text-sm'>在 Bot 中发送:</p>
|
||||
<div className='my-3 rounded-lg bg-white/80 px-3 py-2 font-mono text-lg font-bold tracking-widest dark:bg-zinc-900/70'>
|
||||
/bind {telegramBind.code}
|
||||
</div>
|
||||
{telegramBind.deepLink && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => window.open(telegramBind.deepLink, '_blank', 'noopener,noreferrer')}
|
||||
className='mb-3 inline-flex w-full items-center justify-center rounded-lg bg-sky-600 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-sky-700'
|
||||
>
|
||||
打开 Telegram
|
||||
</button>
|
||||
)}
|
||||
<p className='text-xs opacity-80'>绑定码 10 分钟内有效,也可稍后登录后在通知设置中重新生成。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => router.replace(searchParams.get('redirect') || '/login')}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:bg-green-700'
|
||||
>
|
||||
前往登录
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<div>
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
@@ -402,6 +456,7 @@ function RegisterPageClient() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 版本信息显示 */}
|
||||
|
||||
Reference in New Issue
Block a user