接入tgbot
This commit is contained in:
@@ -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/<secret>` | 随机长字符串 | (空) |
|
||||
| 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/<TELEGRAM_WEBHOOK_SECRET>`。
|
||||
|
||||
可使用以下命令设置 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 选项解释:
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
{/* 版本信息显示 */}
|
||||
|
||||
@@ -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({
|
||||
通知设置
|
||||
</h3>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
管理邮件通知和当前设备浏览器系统通知。
|
||||
管理邮件通知、浏览器系统通知和 Telegram Bot 通知。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -228,6 +242,59 @@ export function EmailSettingsPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{telegramEnabled && (
|
||||
<section className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='mb-4 flex items-start gap-3'>
|
||||
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-100 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300'>
|
||||
<Send className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-base font-semibold text-gray-900 dark:text-gray-100'>Telegram Bot 通知</h4>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
绑定 Telegram 后可接收站内通知,也可在登录页使用 Telegram 确认登录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-3 rounded-xl bg-white p-3 dark:bg-gray-900/70'>
|
||||
<div className='flex items-center justify-between gap-3 text-sm'>
|
||||
<span className='text-gray-600 dark:text-gray-400'>绑定状态</span>
|
||||
<span className={`font-medium ${telegramBound ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{telegramBound ? `已绑定${telegramUsername ? ` @${telegramUsername}` : ''}` : '未绑定'}
|
||||
</span>
|
||||
</div>
|
||||
{!telegramBound && (
|
||||
<>
|
||||
{telegramBindCode && (
|
||||
<div className='rounded-lg bg-sky-50 p-3 text-sm text-sky-800 dark:bg-sky-900/30 dark:text-sky-200'>
|
||||
绑定码:<span className='font-mono text-base font-bold'>{telegramBindCode}</span>
|
||||
<p className='mt-1 text-xs'>在 Bot 中发送 /bind {telegramBindCode},或点击下方按钮打开 Telegram。</p>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onCreateTelegramBindCode}
|
||||
disabled={telegramBindingBusy}
|
||||
className='flex-1 rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-700 disabled:cursor-not-allowed disabled:bg-sky-400'
|
||||
>
|
||||
{telegramBindingBusy ? '生成中...' : '生成绑定码'}
|
||||
</button>
|
||||
{telegramDeepLink && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => window.open(telegramDeepLink, '_blank', 'noopener,noreferrer')}
|
||||
className='flex-1 rounded-lg border border-sky-300 px-3 py-2 text-sm font-medium text-sky-700 transition-colors hover:bg-sky-50 dark:border-sky-700 dark:text-sky-300 dark:hover:bg-sky-900/30'
|
||||
>
|
||||
打开 Telegram
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={emailSettingsSaving}
|
||||
@@ -261,7 +328,7 @@ export function EmailSettingsPanel({
|
||||
<div className='mt-6 flex gap-2 rounded-xl border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20'>
|
||||
<Info className='mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-300' />
|
||||
<p className='text-xs leading-5 text-blue-800 dark:text-blue-200'>
|
||||
邮件通知需要管理员配置邮件服务;当前设备浏览器系统通知需要当前浏览器授权通知权限。
|
||||
邮件通知需要管理员配置邮件服务;浏览器系统通知需要当前浏览器授权。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +231,12 @@ export const UserMenu: React.FC = () => {
|
||||
const [emailSettingsMessageType, setEmailSettingsMessageType] = useState<
|
||||
'success' | 'error' | null
|
||||
>(null);
|
||||
const [telegramEnabled, setTelegramEnabled] = useState(false);
|
||||
const [telegramBound, setTelegramBound] = useState(false);
|
||||
const [telegramUsername, setTelegramUsername] = useState('');
|
||||
const [telegramBindCode, setTelegramBindCode] = useState('');
|
||||
const [telegramDeepLink, setTelegramDeepLink] = useState('');
|
||||
const [telegramBindingBusy, setTelegramBindingBusy] = useState(false);
|
||||
|
||||
// 设备管理状态
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
@@ -896,6 +902,14 @@ export const UserMenu: React.FC = () => {
|
||||
);
|
||||
setPushNotifications(Boolean(pushData.pushNotifications));
|
||||
}
|
||||
|
||||
const telegramResponse = await fetch('/api/telegram/bind');
|
||||
if (telegramResponse.ok) {
|
||||
const telegramData = await telegramResponse.json();
|
||||
setTelegramEnabled(Boolean(telegramData.enabled));
|
||||
setTelegramBound(Boolean(telegramData.binding));
|
||||
setTelegramUsername(telegramData.binding?.telegramUsername || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载通知设置失败:', error);
|
||||
} finally {
|
||||
@@ -903,6 +917,28 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTelegramBindCode = async () => {
|
||||
setTelegramBindingBusy(true);
|
||||
setEmailSettingsMessage('');
|
||||
setEmailSettingsMessageType(null);
|
||||
try {
|
||||
const response = await fetch('/api/telegram/bind', { method: 'POST' });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '生成 Telegram 绑定码失败');
|
||||
}
|
||||
setTelegramBindCode(data.code || '');
|
||||
setTelegramDeepLink(data.deepLink || '');
|
||||
setEmailSettingsMessage('Telegram 绑定码已生成,请在 10 分钟内完成绑定');
|
||||
setEmailSettingsMessageType('success');
|
||||
} catch (error) {
|
||||
setEmailSettingsMessage(error instanceof Error ? error.message : '生成 Telegram 绑定码失败');
|
||||
setEmailSettingsMessageType('error');
|
||||
} finally {
|
||||
setTelegramBindingBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string) => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
@@ -5276,6 +5312,13 @@ export const UserMenu: React.FC = () => {
|
||||
pushNotificationsSupported={pushNotificationsSupported}
|
||||
pushNotificationsConfigured={pushNotificationsConfigured}
|
||||
pushNotificationsBusy={pushNotificationsBusy}
|
||||
telegramEnabled={telegramEnabled}
|
||||
telegramBound={telegramBound}
|
||||
telegramUsername={telegramUsername}
|
||||
telegramBindCode={telegramBindCode}
|
||||
telegramDeepLink={telegramDeepLink}
|
||||
telegramBindingBusy={telegramBindingBusy}
|
||||
onCreateTelegramBindCode={handleCreateTelegramBindCode}
|
||||
emailSettingsLoading={emailSettingsLoading}
|
||||
emailSettingsSaving={emailSettingsSaving}
|
||||
onSave={handleSaveEmailSettings}
|
||||
|
||||
@@ -342,6 +342,18 @@ export interface AdminConfig {
|
||||
from: string; // 发件人邮箱
|
||||
};
|
||||
};
|
||||
TelegramConfig?: {
|
||||
enabled: boolean; // 是否启用 Telegram Bot
|
||||
botToken?: string; // Bot Token,仅服务端使用
|
||||
botUsername?: string; // Bot 用户名,用于前端跳转
|
||||
webhookSecret?: string; // Webhook Secret Token
|
||||
apiProxy?: string; // Telegram Bot API 系统代理(HTTP/HTTPS proxy)
|
||||
apiBaseUrl?: string; // Telegram Bot API 反代 Base URL
|
||||
loginEnabled?: boolean; // 是否启用 Telegram 登录
|
||||
bindingEnabled?: boolean; // 是否启用用户绑定
|
||||
notificationsEnabled?: boolean; // 是否启用 Telegram 通知
|
||||
defaultNotifications?: boolean; // 新绑定用户默认开启通知
|
||||
};
|
||||
MusicConfig?: {
|
||||
Enabled?: boolean; // 启用音乐功能
|
||||
BaseUrl?: string; // lxserver 地址
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from './refresh-token';
|
||||
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| 'kvrocks'
|
||||
| 'd1'
|
||||
| 'postgres'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
export async function generateAuthSignature(
|
||||
data: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function getDeviceInfoFromUserAgent(userAgent: string): string {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
if (ua.includes('moontvplus')) return 'MoonTVPlus APP';
|
||||
if (ua.includes('oriontv')) return 'OrionTV';
|
||||
if (ua.includes('telegram')) return 'Telegram Login';
|
||||
if (ua.includes('chrome')) return 'Chrome';
|
||||
if (ua.includes('firefox')) return 'Firefox';
|
||||
if (ua.includes('safari')) return 'Safari';
|
||||
if (ua.includes('edge')) return 'Edge';
|
||||
if (ua.includes('android')) return 'Android';
|
||||
if (ua.includes('iphone') || ua.includes('ios')) return 'iOS';
|
||||
if (ua.includes('windows')) return 'Windows';
|
||||
if (ua.includes('mac')) return 'macOS';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
export async function generateAuthCookieValue(input: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
includePassword?: boolean;
|
||||
deviceInfo?: string;
|
||||
}): Promise<string> {
|
||||
const now = Date.now();
|
||||
const authData: any = { role: input.role || 'user' };
|
||||
|
||||
if (input.includePassword && input.password) {
|
||||
authData.password = input.password;
|
||||
}
|
||||
|
||||
if (input.username && process.env.PASSWORD) {
|
||||
authData.username = input.username;
|
||||
authData.timestamp = now;
|
||||
|
||||
if (!input.includePassword && STORAGE_TYPE !== 'localstorage') {
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
await storeRefreshToken(input.username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo: input.deviceInfo || 'Unknown Device',
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp,
|
||||
});
|
||||
authData.signature = await generateAuthSignature(
|
||||
dataToSign,
|
||||
process.env.PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
}
|
||||
@@ -331,6 +331,18 @@ async function getInitConfig(
|
||||
SourceConfig: [],
|
||||
CustomCategories: [],
|
||||
LiveConfig: [],
|
||||
TelegramConfig: {
|
||||
enabled: process.env.TELEGRAM_BOT_ENABLED === 'true' || Boolean(process.env.TELEGRAM_BOT_TOKEN),
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
botUsername: process.env.TELEGRAM_BOT_USERNAME || '',
|
||||
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET || '',
|
||||
apiProxy: process.env.TELEGRAM_API_PROXY || '',
|
||||
apiBaseUrl: process.env.TELEGRAM_API_BASE_URL || '',
|
||||
loginEnabled: process.env.TELEGRAM_LOGIN_ENABLED !== 'false',
|
||||
bindingEnabled: process.env.TELEGRAM_BINDING_ENABLED !== 'false',
|
||||
notificationsEnabled: process.env.TELEGRAM_NOTIFICATIONS_ENABLED !== 'false',
|
||||
defaultNotifications: process.env.TELEGRAM_DEFAULT_NOTIFICATIONS !== 'false',
|
||||
},
|
||||
SpecialSourceApis: Array.isArray(cfgFile.special_source_apis)
|
||||
? cfgFile.special_source_apis
|
||||
: Array.isArray(cfgFile.specialSourceApis)
|
||||
@@ -566,6 +578,20 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
|
||||
adminConfig.SiteConfig.DefaultUserTags = [];
|
||||
}
|
||||
if (!adminConfig.TelegramConfig) {
|
||||
adminConfig.TelegramConfig = {
|
||||
enabled: process.env.TELEGRAM_BOT_ENABLED === 'true' || Boolean(process.env.TELEGRAM_BOT_TOKEN),
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
botUsername: process.env.TELEGRAM_BOT_USERNAME || '',
|
||||
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET || '',
|
||||
apiProxy: process.env.TELEGRAM_API_PROXY || '',
|
||||
apiBaseUrl: process.env.TELEGRAM_API_BASE_URL || '',
|
||||
loginEnabled: process.env.TELEGRAM_LOGIN_ENABLED !== 'false',
|
||||
bindingEnabled: process.env.TELEGRAM_BINDING_ENABLED !== 'false',
|
||||
notificationsEnabled: process.env.TELEGRAM_NOTIFICATIONS_ENABLED !== 'false',
|
||||
defaultNotifications: process.env.TELEGRAM_DEFAULT_NOTIFICATIONS !== 'false',
|
||||
};
|
||||
}
|
||||
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
|
||||
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
|
||||
}
|
||||
|
||||
+134
-2
@@ -26,7 +26,7 @@ import {
|
||||
MusicV2PlaylistRecord,
|
||||
} from './music-v2';
|
||||
import { userInfoCache } from './user-cache';
|
||||
import { dispatchWebPushNotification } from './web-push';
|
||||
import { dispatchNotificationChannels } from './notification-dispatch';
|
||||
|
||||
/**
|
||||
* Cloudflare D1 存储实现
|
||||
@@ -3083,7 +3083,7 @@ export class D1Storage implements IStorage {
|
||||
)
|
||||
.run();
|
||||
|
||||
await dispatchWebPushNotification(this, userName, notification);
|
||||
await dispatchNotificationChannels(this, userName, notification);
|
||||
} catch (err) {
|
||||
console.error('D1Storage.addNotification error:', err);
|
||||
throw err;
|
||||
@@ -3473,6 +3473,138 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private mapTelegramBinding(row: any): import('./types').TelegramBindingRecord {
|
||||
return {
|
||||
username: row.username as string,
|
||||
telegramUserId: String(row.telegram_user_id),
|
||||
chatId: String(row.chat_id),
|
||||
telegramUsername: (row.telegram_username as string | null) || null,
|
||||
firstName: (row.first_name as string | null) || null,
|
||||
lastName: (row.last_name as string | null) || null,
|
||||
notificationsEnabled: row.notifications_enabled === 1,
|
||||
boundAt: Number(row.bound_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
async getTelegramBinding(userName: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bindings WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first();
|
||||
return row ? this.mapTelegramBinding(row) : null;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getTelegramBinding error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTelegramBindingByTelegramUserId(telegramUserId: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bindings WHERE telegram_user_id = ?')
|
||||
.bind(telegramUserId)
|
||||
.first();
|
||||
return row ? this.mapTelegramBinding(row) : null;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getTelegramBindingByTelegramUserId error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertTelegramBinding(binding: import('./types').TelegramBindingRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO telegram_bindings (
|
||||
username, telegram_user_id, chat_id, telegram_username, first_name, last_name,
|
||||
notifications_enabled, bound_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
telegram_user_id = excluded.telegram_user_id,
|
||||
chat_id = excluded.chat_id,
|
||||
telegram_username = excluded.telegram_username,
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
notifications_enabled = excluded.notifications_enabled,
|
||||
bound_at = excluded.bound_at,
|
||||
updated_at = excluded.updated_at
|
||||
`)
|
||||
.bind(
|
||||
binding.username,
|
||||
binding.telegramUserId,
|
||||
binding.chatId,
|
||||
binding.telegramUsername || null,
|
||||
binding.firstName || null,
|
||||
binding.lastName || null,
|
||||
binding.notificationsEnabled ? 1 : 0,
|
||||
binding.boundAt,
|
||||
binding.updatedAt
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.upsertTelegramBinding error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByUsername(userName: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM telegram_bindings WHERE username = ?').bind(userName).run();
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByTelegramUserId(telegramUserId: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM telegram_bindings WHERE telegram_user_id = ?').bind(telegramUserId).run();
|
||||
}
|
||||
|
||||
async getTelegramBindSession(code: string): Promise<import('./types').TelegramBindSessionRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bind_sessions WHERE code = ?')
|
||||
.bind(code)
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
code: row.code as string,
|
||||
username: row.username as string,
|
||||
createdAt: Number(row.created_at),
|
||||
expiresAt: Number(row.expires_at),
|
||||
used: row.used === 1,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getTelegramBindSession error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertTelegramBindSession(session: import('./types').TelegramBindSessionRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO telegram_bind_sessions (code, username, created_at, expires_at, used)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at,
|
||||
used = excluded.used
|
||||
`)
|
||||
.bind(session.code, session.username, session.createdAt, session.expiresAt, session.used ? 1 : 0)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.upsertTelegramBindSession error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async markTelegramBindSessionUsed(code: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('UPDATE telegram_bind_sessions SET used = 1 WHERE code = ?')
|
||||
.bind(code)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
|
||||
@@ -1102,6 +1102,59 @@ export class DbManager {
|
||||
await (this.storage as any).deleteGlobalValue(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Telegram Bot绑定相关 ----------
|
||||
async getTelegramBinding(userName: string) {
|
||||
if (typeof (this.storage as any).getTelegramBinding === 'function') {
|
||||
return (this.storage as any).getTelegramBinding(userName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getTelegramBindingByTelegramUserId(telegramUserId: string) {
|
||||
if (typeof (this.storage as any).getTelegramBindingByTelegramUserId === 'function') {
|
||||
return (this.storage as any).getTelegramBindingByTelegramUserId(telegramUserId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async upsertTelegramBinding(binding: import('./types').TelegramBindingRecord): Promise<void> {
|
||||
if (typeof (this.storage as any).upsertTelegramBinding === 'function') {
|
||||
await (this.storage as any).upsertTelegramBinding(binding);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByUsername(userName: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteTelegramBindingByUsername === 'function') {
|
||||
await (this.storage as any).deleteTelegramBindingByUsername(userName);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByTelegramUserId(telegramUserId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteTelegramBindingByTelegramUserId === 'function') {
|
||||
await (this.storage as any).deleteTelegramBindingByTelegramUserId(telegramUserId);
|
||||
}
|
||||
}
|
||||
|
||||
async getTelegramBindSession(code: string) {
|
||||
if (typeof (this.storage as any).getTelegramBindSession === 'function') {
|
||||
return (this.storage as any).getTelegramBindSession(code);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async upsertTelegramBindSession(session: import('./types').TelegramBindSessionRecord): Promise<void> {
|
||||
if (typeof (this.storage as any).upsertTelegramBindSession === 'function') {
|
||||
await (this.storage as any).upsertTelegramBindSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
async markTelegramBindSessionUsed(code: string): Promise<void> {
|
||||
if (typeof (this.storage as any).markTelegramBindSessionUsed === 'function') {
|
||||
await (this.storage as any).markTelegramBindSessionUsed(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { IStorage, Notification } from './types';
|
||||
import { dispatchTelegramNotification } from './telegram';
|
||||
import { dispatchWebPushNotification } from './web-push';
|
||||
|
||||
export async function dispatchNotificationChannels(
|
||||
storage: IStorage,
|
||||
userName: string,
|
||||
notification: Notification
|
||||
): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
dispatchWebPushNotification(storage, userName, notification),
|
||||
dispatchTelegramNotification(storage, userName, notification),
|
||||
]);
|
||||
}
|
||||
+134
-2
@@ -27,7 +27,7 @@ import {
|
||||
MusicV2PlaylistItem,
|
||||
MusicV2PlaylistRecord,
|
||||
} from './music-v2';
|
||||
import { dispatchWebPushNotification } from './web-push';
|
||||
import { dispatchNotificationChannels } from './notification-dispatch';
|
||||
|
||||
/**
|
||||
* Vercel Postgres 存储实现
|
||||
@@ -3064,7 +3064,7 @@ export class PostgresStorage implements IStorage {
|
||||
)
|
||||
.run();
|
||||
|
||||
await dispatchWebPushNotification(this, userName, notification);
|
||||
await dispatchNotificationChannels(this, userName, notification);
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.addNotification error:', err);
|
||||
throw err;
|
||||
@@ -3463,6 +3463,138 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private mapTelegramBinding(row: any): import('./types').TelegramBindingRecord {
|
||||
return {
|
||||
username: row.username as string,
|
||||
telegramUserId: String(row.telegram_user_id),
|
||||
chatId: String(row.chat_id),
|
||||
telegramUsername: (row.telegram_username as string | null) || null,
|
||||
firstName: (row.first_name as string | null) || null,
|
||||
lastName: (row.last_name as string | null) || null,
|
||||
notificationsEnabled: row.notifications_enabled === 1 || row.notifications_enabled === true,
|
||||
boundAt: Number(row.bound_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
async getTelegramBinding(userName: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bindings WHERE username = $1')
|
||||
.bind(userName)
|
||||
.first();
|
||||
return row ? this.mapTelegramBinding(row) : null;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getTelegramBinding error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTelegramBindingByTelegramUserId(telegramUserId: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bindings WHERE telegram_user_id = $1')
|
||||
.bind(telegramUserId)
|
||||
.first();
|
||||
return row ? this.mapTelegramBinding(row) : null;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getTelegramBindingByTelegramUserId error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertTelegramBinding(binding: import('./types').TelegramBindingRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO telegram_bindings (
|
||||
username, telegram_user_id, chat_id, telegram_username, first_name, last_name,
|
||||
notifications_enabled, bound_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
telegram_user_id = EXCLUDED.telegram_user_id,
|
||||
chat_id = EXCLUDED.chat_id,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
notifications_enabled = EXCLUDED.notifications_enabled,
|
||||
bound_at = EXCLUDED.bound_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`)
|
||||
.bind(
|
||||
binding.username,
|
||||
binding.telegramUserId,
|
||||
binding.chatId,
|
||||
binding.telegramUsername || null,
|
||||
binding.firstName || null,
|
||||
binding.lastName || null,
|
||||
binding.notificationsEnabled ? 1 : 0,
|
||||
binding.boundAt,
|
||||
binding.updatedAt
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.upsertTelegramBinding error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByUsername(userName: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM telegram_bindings WHERE username = $1').bind(userName).run();
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByTelegramUserId(telegramUserId: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM telegram_bindings WHERE telegram_user_id = $1').bind(telegramUserId).run();
|
||||
}
|
||||
|
||||
async getTelegramBindSession(code: string): Promise<import('./types').TelegramBindSessionRecord | null> {
|
||||
try {
|
||||
const row = await this.db
|
||||
.prepare('SELECT * FROM telegram_bind_sessions WHERE code = $1')
|
||||
.bind(code)
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
code: row.code as string,
|
||||
username: row.username as string,
|
||||
createdAt: Number(row.created_at),
|
||||
expiresAt: Number(row.expires_at),
|
||||
used: row.used === 1 || row.used === true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getTelegramBindSession error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertTelegramBindSession(session: import('./types').TelegramBindSessionRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO telegram_bind_sessions (code, username, created_at, expires_at, used)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
created_at = EXCLUDED.created_at,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
used = EXCLUDED.used
|
||||
`)
|
||||
.bind(session.code, session.username, session.createdAt, session.expiresAt, session.used ? 1 : 0)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.upsertTelegramBindSession error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async markTelegramBindSessionUsed(code: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('UPDATE telegram_bind_sessions SET used = 1 WHERE code = $1')
|
||||
.bind(code)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { RedisAdapter } from './redis-adapter';
|
||||
import { Favorite, IStorage, Notification, PlayRecord, PushSubscriptionRecord, SkipConfig } from './types';
|
||||
import { userInfoCache } from './user-cache';
|
||||
import { dispatchWebPushNotification } from './web-push';
|
||||
import { dispatchNotificationChannels } from './notification-dispatch';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -2098,6 +2098,65 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
await this.withRetry(() => this.adapter.del(this.globalValueKey(key)));
|
||||
}
|
||||
|
||||
|
||||
private telegramBindingKey(userName: string) {
|
||||
return `telegram:binding:user:${userName}`;
|
||||
}
|
||||
|
||||
private telegramUserBindingKey(telegramUserId: string) {
|
||||
return `telegram:binding:tg:${telegramUserId}`;
|
||||
}
|
||||
|
||||
private telegramBindSessionKey(code: string) {
|
||||
return `telegram:bind:${code}`;
|
||||
}
|
||||
|
||||
async getTelegramBinding(userName: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
const raw = await this.withRetry(() => this.adapter.get(this.telegramBindingKey(userName)));
|
||||
return raw ? (JSON.parse(ensureString(raw)) as import('./types').TelegramBindingRecord) : null;
|
||||
}
|
||||
|
||||
async getTelegramBindingByTelegramUserId(telegramUserId: string): Promise<import('./types').TelegramBindingRecord | null> {
|
||||
const userName = await this.withRetry(() => this.adapter.get(this.telegramUserBindingKey(telegramUserId)));
|
||||
return userName ? this.getTelegramBinding(ensureString(userName)) : null;
|
||||
}
|
||||
|
||||
async upsertTelegramBinding(binding: import('./types').TelegramBindingRecord): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.set(this.telegramBindingKey(binding.username), JSON.stringify(binding)));
|
||||
await this.withRetry(() => this.adapter.set(this.telegramUserBindingKey(binding.telegramUserId), binding.username));
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByUsername(userName: string): Promise<void> {
|
||||
const binding = await this.getTelegramBinding(userName);
|
||||
await this.withRetry(() => this.adapter.del(this.telegramBindingKey(userName)));
|
||||
if (binding) {
|
||||
await this.withRetry(() => this.adapter.del(this.telegramUserBindingKey(binding.telegramUserId)));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTelegramBindingByTelegramUserId(telegramUserId: string): Promise<void> {
|
||||
const binding = await this.getTelegramBindingByTelegramUserId(telegramUserId);
|
||||
if (binding) {
|
||||
await this.withRetry(() => this.adapter.del(this.telegramBindingKey(binding.username)));
|
||||
}
|
||||
await this.withRetry(() => this.adapter.del(this.telegramUserBindingKey(telegramUserId)));
|
||||
}
|
||||
|
||||
async getTelegramBindSession(code: string): Promise<import('./types').TelegramBindSessionRecord | null> {
|
||||
const raw = await this.withRetry(() => this.adapter.get(this.telegramBindSessionKey(code)));
|
||||
return raw ? (JSON.parse(ensureString(raw)) as import('./types').TelegramBindSessionRecord) : null;
|
||||
}
|
||||
|
||||
async upsertTelegramBindSession(session: import('./types').TelegramBindSessionRecord): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.set(this.telegramBindSessionKey(session.code), JSON.stringify(session)));
|
||||
}
|
||||
|
||||
async markTelegramBindSessionUsed(code: string): Promise<void> {
|
||||
const session = await this.getTelegramBindSession(code);
|
||||
if (!session) return;
|
||||
await this.upsertTelegramBindSession({ ...session, used: true });
|
||||
}
|
||||
|
||||
// ---------- 通知相关 ----------
|
||||
private notificationsKey(userName: string) {
|
||||
return `u:${userName}:notifications`;
|
||||
@@ -2133,7 +2192,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
)
|
||||
);
|
||||
|
||||
await dispatchWebPushNotification(this, userName, notification);
|
||||
await dispatchNotificationChannels(this, userName, notification);
|
||||
}
|
||||
|
||||
async markNotificationAsRead(
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
import type { AdminConfig } from './admin.types';
|
||||
import { generateAuthCookieValue } from './auth-cookie';
|
||||
import { db, getStorage } from './db';
|
||||
import type { IStorage, Notification } from './types';
|
||||
import { getNotificationClickUrl } from './web-push';
|
||||
|
||||
export interface TelegramConfig {
|
||||
enabled: boolean;
|
||||
botToken: string;
|
||||
botUsername: string;
|
||||
webhookSecret: string;
|
||||
apiProxy: string;
|
||||
apiBaseUrl: string;
|
||||
loginEnabled: boolean;
|
||||
bindingEnabled: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
defaultNotifications: boolean;
|
||||
}
|
||||
|
||||
export class TelegramApiError extends Error {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: string;
|
||||
data: unknown;
|
||||
|
||||
constructor(message: string, response: Response, body: string, data: unknown) {
|
||||
super(message);
|
||||
this.name = 'TelegramApiError';
|
||||
this.status = response.status;
|
||||
this.statusText = response.statusText;
|
||||
this.body = body;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TelegramBinding {
|
||||
username: string;
|
||||
telegramUserId: string;
|
||||
chatId: string;
|
||||
telegramUsername?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
boundAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
type TelegramLoginStatus = 'pending' | 'awaiting_confirm' | 'confirmed' | 'denied' | 'expired' | 'used';
|
||||
|
||||
interface TelegramLoginSession {
|
||||
token: string;
|
||||
status: TelegramLoginStatus;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
username?: string;
|
||||
telegramUserId?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface TelegramBindSession {
|
||||
code: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
used?: boolean;
|
||||
}
|
||||
|
||||
const LOGIN_TTL_MS = 5 * 60 * 1000;
|
||||
const BIND_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function randomToken(bytes = 24): string {
|
||||
const array = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(array);
|
||||
return Buffer.from(array).toString('base64url');
|
||||
}
|
||||
|
||||
function randomBindCode(): string {
|
||||
const array = new Uint8Array(4);
|
||||
crypto.getRandomValues(array);
|
||||
const value = new DataView(array.buffer).getUint32(0) % 1_000_000;
|
||||
return value.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function readEnvTelegramConfig(): TelegramConfig {
|
||||
const botToken = process.env.TELEGRAM_BOT_TOKEN || '';
|
||||
const botUsername = process.env.TELEGRAM_BOT_USERNAME || '';
|
||||
const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET || '';
|
||||
const enabled = process.env.TELEGRAM_BOT_ENABLED === 'true' || Boolean(botToken);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
botToken,
|
||||
botUsername,
|
||||
webhookSecret,
|
||||
apiProxy: process.env.TELEGRAM_API_PROXY || '',
|
||||
apiBaseUrl: process.env.TELEGRAM_API_BASE_URL || '',
|
||||
loginEnabled: process.env.TELEGRAM_LOGIN_ENABLED !== 'false',
|
||||
bindingEnabled: process.env.TELEGRAM_BINDING_ENABLED !== 'false',
|
||||
notificationsEnabled: process.env.TELEGRAM_NOTIFICATIONS_ENABLED !== 'false',
|
||||
defaultNotifications: process.env.TELEGRAM_DEFAULT_NOTIFICATIONS !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAdminTelegramConfig(base: TelegramConfig, admin?: AdminConfig | null): TelegramConfig {
|
||||
const cfg = admin?.TelegramConfig;
|
||||
if (!cfg) return base;
|
||||
|
||||
return {
|
||||
enabled: cfg.enabled ?? base.enabled,
|
||||
botToken: cfg.botToken || base.botToken,
|
||||
botUsername: cfg.botUsername || base.botUsername,
|
||||
webhookSecret: cfg.webhookSecret || base.webhookSecret,
|
||||
apiProxy: cfg.apiProxy || base.apiProxy,
|
||||
apiBaseUrl: cfg.apiBaseUrl || base.apiBaseUrl,
|
||||
loginEnabled: cfg.loginEnabled ?? base.loginEnabled,
|
||||
bindingEnabled: cfg.bindingEnabled ?? base.bindingEnabled,
|
||||
notificationsEnabled: cfg.notificationsEnabled ?? base.notificationsEnabled,
|
||||
defaultNotifications: cfg.defaultNotifications ?? base.defaultNotifications,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTelegramConfig(storage?: IStorage): Promise<TelegramConfig> {
|
||||
const base = readEnvTelegramConfig();
|
||||
try {
|
||||
const resolvedStorage = storage || getStorage();
|
||||
const adminConfig = await resolvedStorage.getAdminConfig?.();
|
||||
return mergeAdminTelegramConfig(base, adminConfig);
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function loginSessionKey(token: string) {
|
||||
return `telegram:login:${token}`;
|
||||
}
|
||||
|
||||
async function readJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await db.getGlobalValue(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
await db.deleteGlobalValue(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(key: string, value: unknown) {
|
||||
await db.setGlobalValue(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function getTelegramBinding(username: string): Promise<TelegramBinding | null> {
|
||||
return db.getTelegramBinding(username) as Promise<TelegramBinding | null>;
|
||||
}
|
||||
|
||||
export async function getTelegramBindingByTelegramUser(telegramUserId: string): Promise<TelegramBinding | null> {
|
||||
return db.getTelegramBindingByTelegramUserId(telegramUserId) as Promise<TelegramBinding | null>;
|
||||
}
|
||||
|
||||
export async function createTelegramBindSession(username: string): Promise<TelegramBindSession> {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const code = randomBindCode();
|
||||
const existing = await db.getTelegramBindSession(code);
|
||||
if (existing && existing.expiresAt > now() && !existing.used) continue;
|
||||
|
||||
const session: TelegramBindSession = {
|
||||
code,
|
||||
username,
|
||||
createdAt: now(),
|
||||
expiresAt: now() + BIND_TTL_MS,
|
||||
};
|
||||
await db.upsertTelegramBindSession({ ...session, used: false });
|
||||
return session;
|
||||
}
|
||||
|
||||
throw new Error('生成 Telegram 绑定码失败');
|
||||
}
|
||||
|
||||
export async function bindTelegramUser(input: {
|
||||
code: string;
|
||||
telegramUserId: string;
|
||||
chatId: string;
|
||||
telegramUsername?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}): Promise<TelegramBinding> {
|
||||
const session = await db.getTelegramBindSession(input.code);
|
||||
if (!session || session.used || session.expiresAt <= now()) {
|
||||
throw new Error('绑定码无效或已过期');
|
||||
}
|
||||
|
||||
const config = await getTelegramConfig();
|
||||
const existingByTelegram = await getTelegramBindingByTelegramUser(input.telegramUserId);
|
||||
if (existingByTelegram && existingByTelegram.username !== session.username) {
|
||||
await db.deleteTelegramBindingByUsername(existingByTelegram.username);
|
||||
}
|
||||
|
||||
const binding: TelegramBinding = {
|
||||
username: session.username,
|
||||
telegramUserId: input.telegramUserId,
|
||||
chatId: input.chatId,
|
||||
telegramUsername: input.telegramUsername,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
notificationsEnabled: config.defaultNotifications,
|
||||
boundAt: now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
|
||||
await db.upsertTelegramBinding(binding);
|
||||
await db.markTelegramBindSessionUsed(input.code);
|
||||
return binding;
|
||||
}
|
||||
|
||||
export async function unbindTelegramUser(telegramUserId: string): Promise<boolean> {
|
||||
const binding = await getTelegramBindingByTelegramUser(telegramUserId);
|
||||
if (!binding) return false;
|
||||
|
||||
await db.deleteTelegramBindingByTelegramUserId(telegramUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createTelegramLoginSession(): Promise<TelegramLoginSession> {
|
||||
const session: TelegramLoginSession = {
|
||||
token: randomToken(),
|
||||
status: 'pending',
|
||||
createdAt: now(),
|
||||
expiresAt: now() + LOGIN_TTL_MS,
|
||||
};
|
||||
await writeJson(loginSessionKey(session.token), session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getTelegramLoginSession(token?: string | null): Promise<TelegramLoginSession | null> {
|
||||
if (!token) return null;
|
||||
const session = await readJson<TelegramLoginSession>(loginSessionKey(token));
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt <= now() && session.status !== 'confirmed' && session.status !== 'used') {
|
||||
session.status = 'expired';
|
||||
await writeJson(loginSessionKey(token), session);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function getUserRole(username: string): Promise<'owner' | 'admin' | 'user'> {
|
||||
if (username === process.env.USERNAME) return 'owner';
|
||||
const userInfo = await db.getUserInfoV2(username);
|
||||
return userInfo?.role || 'user';
|
||||
}
|
||||
|
||||
export async function requestTelegramLoginConfirm(token: string, telegramUserId: string): Promise<TelegramLoginSession> {
|
||||
const session = await getTelegramLoginSession(token);
|
||||
if (!session || session.expiresAt <= now()) throw new Error('登录请求无效或已过期');
|
||||
|
||||
const binding = await getTelegramBindingByTelegramUser(telegramUserId);
|
||||
if (!binding) throw new Error('当前 Telegram 账号尚未绑定站内账号');
|
||||
|
||||
session.status = 'awaiting_confirm';
|
||||
session.telegramUserId = telegramUserId;
|
||||
session.username = binding.username;
|
||||
await writeJson(loginSessionKey(token), session);
|
||||
|
||||
await sendTelegramMessage(binding.chatId, `确认登录 MoonTVPlus 账号:${binding.username}`, {
|
||||
inline_keyboard: [[
|
||||
{ text: '确认登录', callback_data: `tg_login_confirm:${token}` },
|
||||
{ text: '拒绝', callback_data: `tg_login_deny:${token}` },
|
||||
]],
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function confirmTelegramLogin(token: string, telegramUserId: string): Promise<TelegramLoginSession> {
|
||||
const session = await getTelegramLoginSession(token);
|
||||
if (!session || session.expiresAt <= now()) throw new Error('登录请求无效或已过期');
|
||||
if (session.telegramUserId && session.telegramUserId !== telegramUserId) throw new Error('登录请求与 Telegram 账号不匹配');
|
||||
|
||||
const binding = await getTelegramBindingByTelegramUser(telegramUserId);
|
||||
if (!binding) throw new Error('当前 Telegram 账号尚未绑定站内账号');
|
||||
|
||||
const role = await getUserRole(binding.username);
|
||||
const authToken = await generateAuthCookieValue({
|
||||
username: binding.username,
|
||||
role,
|
||||
includePassword: false,
|
||||
deviceInfo: 'Telegram Bot Login',
|
||||
});
|
||||
|
||||
session.status = 'confirmed';
|
||||
session.username = binding.username;
|
||||
session.telegramUserId = telegramUserId;
|
||||
session.authToken = authToken;
|
||||
await writeJson(loginSessionKey(token), session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function denyTelegramLogin(token: string, telegramUserId: string): Promise<void> {
|
||||
const session = await getTelegramLoginSession(token);
|
||||
if (!session) return;
|
||||
if (session.telegramUserId && session.telegramUserId !== telegramUserId) return;
|
||||
session.status = 'denied';
|
||||
await writeJson(loginSessionKey(token), session);
|
||||
}
|
||||
|
||||
export async function consumeConfirmedTelegramLogin(token: string): Promise<TelegramLoginSession | null> {
|
||||
const session = await getTelegramLoginSession(token);
|
||||
if (!session || session.status !== 'confirmed' || !session.authToken) return session;
|
||||
session.status = 'used';
|
||||
await writeJson(loginSessionKey(token), session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function isCloudflareEnvironment(): boolean {
|
||||
return process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||
}
|
||||
|
||||
function normalizeTelegramApiBaseUrl(input?: string | null): string {
|
||||
const base = (input || 'https://api.telegram.org').trim().replace(/\/+$/, '');
|
||||
return base || 'https://api.telegram.org';
|
||||
}
|
||||
|
||||
function telegramApiUrl(method: string, token: string, apiBaseUrl?: string) {
|
||||
return `${normalizeTelegramApiBaseUrl(apiBaseUrl)}/bot${token}/${method}`;
|
||||
}
|
||||
|
||||
async function fetchTelegramApi(
|
||||
method: string,
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
config?: Partial<TelegramConfig>
|
||||
): Promise<Response> {
|
||||
const requestUrl = telegramApiUrl(method, token, config?.apiBaseUrl);
|
||||
const init = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (isCloudflareEnvironment()) {
|
||||
if (config?.apiProxy) {
|
||||
console.warn('TELEGRAM_API_PROXY is ignored in Cloudflare runtime; use TELEGRAM_API_BASE_URL instead.');
|
||||
}
|
||||
return fetch(requestUrl, init) as Promise<Response>;
|
||||
}
|
||||
|
||||
const fetchOptions: any = { ...init };
|
||||
if (config?.apiProxy) {
|
||||
fetchOptions.agent = new HttpsProxyAgent(config.apiProxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
});
|
||||
}
|
||||
|
||||
return nodeFetch(requestUrl, fetchOptions) as unknown as Response;
|
||||
}
|
||||
|
||||
export async function setTelegramWebhook(
|
||||
botToken: string,
|
||||
webhookUrl: string,
|
||||
webhookSecret: string,
|
||||
config?: Partial<TelegramConfig>
|
||||
): Promise<unknown> {
|
||||
const response = await fetchTelegramApi(
|
||||
'setWebhook',
|
||||
botToken,
|
||||
{
|
||||
url: webhookUrl,
|
||||
secret_token: webhookSecret,
|
||||
drop_pending_updates: false,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
const rawText = await response.text().catch(() => '');
|
||||
const trimmed = rawText.trim();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = trimmed && trimmed.startsWith('{') ? JSON.parse(trimmed) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
const successByBody = /^(true|ok)$/i.test(trimmed) || /(^|\b)ok(\b|$)/i.test(trimmed);
|
||||
const successByJson = data?.ok === true;
|
||||
const explicitJsonFailure = data?.ok === false;
|
||||
const successByHttp = response.ok && !explicitJsonFailure;
|
||||
|
||||
if (!successByJson && !successByBody && !successByHttp) {
|
||||
const detail = data?.description || trimmed || response.statusText || `HTTP ${response.status}`;
|
||||
throw new TelegramApiError(`Webhook 设置失败: ${detail}`, response, rawText, data);
|
||||
}
|
||||
|
||||
return data?.result ?? true;
|
||||
}
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
chatId: string,
|
||||
text: string,
|
||||
replyMarkup?: any,
|
||||
configOverride?: Partial<TelegramConfig>
|
||||
): Promise<void> {
|
||||
const config = { ...(await getTelegramConfig()), ...(configOverride || {}) } as TelegramConfig;
|
||||
if (!config.enabled || !config.botToken) return;
|
||||
|
||||
const response = await fetchTelegramApi(
|
||||
'sendMessage',
|
||||
config.botToken,
|
||||
{
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(`Telegram 发送失败: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function answerCallbackQuery(callbackQueryId: string, text: string) {
|
||||
const config = await getTelegramConfig();
|
||||
if (!config.enabled || !config.botToken) return;
|
||||
|
||||
await fetchTelegramApi(
|
||||
'answerCallbackQuery',
|
||||
config.botToken,
|
||||
{ callback_query_id: callbackQueryId, text },
|
||||
config
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildNotificationText(notification: Notification, baseUrl?: string) {
|
||||
const title = escapeHtml(notification.title);
|
||||
const message = escapeHtml(notification.message);
|
||||
const path = getNotificationClickUrl(notification);
|
||||
const url = baseUrl ? new URL(path, baseUrl).toString() : '';
|
||||
return url ? `<b>${title}</b>\n${message}\n\n<a href="${escapeHtml(url)}">打开查看</a>` : `<b>${title}</b>\n${message}`;
|
||||
}
|
||||
|
||||
export async function dispatchTelegramNotification(
|
||||
storage: IStorage,
|
||||
username: string,
|
||||
notification: Notification
|
||||
): Promise<void> {
|
||||
const config = await getTelegramConfig(storage);
|
||||
if (!config.enabled || !config.notificationsEnabled || !config.botToken) return;
|
||||
|
||||
const binding = await getTelegramBinding(username);
|
||||
if (!binding || !binding.notificationsEnabled) return;
|
||||
|
||||
try {
|
||||
await sendTelegramMessage(binding.chatId, buildNotificationText(notification, process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_BASE));
|
||||
} catch (error) {
|
||||
console.error('Telegram notification failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelegramDeepLink(botUsername: string, payload: string) {
|
||||
return `https://t.me/${botUsername}?start=${encodeURIComponent(payload)}`;
|
||||
}
|
||||
|
||||
function parseMessageText(update: any) {
|
||||
const message = update.message;
|
||||
if (!message?.text || !message.from || !message.chat) return null;
|
||||
return {
|
||||
text: String(message.text).trim(),
|
||||
telegramUserId: String(message.from.id),
|
||||
chatId: String(message.chat.id),
|
||||
telegramUsername: message.from.username ? String(message.from.username) : undefined,
|
||||
firstName: message.from.first_name ? String(message.from.first_name) : undefined,
|
||||
lastName: message.from.last_name ? String(message.from.last_name) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTelegramWebhookUpdate(update: any): Promise<void> {
|
||||
const parsed = parseMessageText(update);
|
||||
if (parsed) {
|
||||
const startLoginMatch = parsed.text.match(/^\/start\s+login_(.+)$/i);
|
||||
if (startLoginMatch) {
|
||||
try {
|
||||
await requestTelegramLoginConfirm(startLoginMatch[1], parsed.telegramUserId);
|
||||
} catch (error) {
|
||||
await sendTelegramMessage(parsed.chatId, error instanceof Error ? error.message : 'Telegram 登录失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bindMatch = parsed.text.match(/^\/(?:bind|start)\s+(?:bind_)?(\d{6})$/i);
|
||||
if (bindMatch) {
|
||||
try {
|
||||
const binding = await bindTelegramUser({ ...parsed, code: bindMatch[1] });
|
||||
await sendTelegramMessage(parsed.chatId, `绑定成功:${binding.username}\n后续可使用 Telegram 登录和接收通知。`);
|
||||
} catch (error) {
|
||||
await sendTelegramMessage(parsed.chatId, error instanceof Error ? error.message : '绑定失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/unbind$/i.test(parsed.text)) {
|
||||
const ok = await unbindTelegramUser(parsed.telegramUserId);
|
||||
await sendTelegramMessage(parsed.chatId, ok ? '已解除 Telegram 绑定。' : '当前 Telegram 账号尚未绑定。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/status$/i.test(parsed.text)) {
|
||||
const binding = await getTelegramBindingByTelegramUser(parsed.telegramUserId);
|
||||
await sendTelegramMessage(parsed.chatId, binding ? `已绑定账号:${binding.username}\n通知:${binding.notificationsEnabled ? '开启' : '关闭'}` : '当前 Telegram 账号尚未绑定。');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendTelegramMessage(parsed.chatId, '可用命令:\n/bind 绑定码 - 绑定账号\n/status - 查看状态\n/unbind - 解除绑定');
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = update.callback_query;
|
||||
if (callback?.data && callback.from?.id && callback.id) {
|
||||
const telegramUserId = String(callback.from.id);
|
||||
const data = String(callback.data);
|
||||
const confirmMatch = data.match(/^tg_login_confirm:(.+)$/);
|
||||
const denyMatch = data.match(/^tg_login_deny:(.+)$/);
|
||||
|
||||
if (confirmMatch) {
|
||||
try {
|
||||
await confirmTelegramLogin(confirmMatch[1], telegramUserId);
|
||||
await answerCallbackQuery(callback.id, '已确认登录');
|
||||
} catch (error) {
|
||||
await answerCallbackQuery(callback.id, error instanceof Error ? error.message : '确认失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (denyMatch) {
|
||||
await denyTelegramLogin(denyMatch[1], telegramUserId);
|
||||
await answerCallbackQuery(callback.id, '已拒绝登录');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateTelegramWebhookRequest(request: NextRequest, secretParam: string) {
|
||||
const config = await getTelegramConfig();
|
||||
const configuredSecret = config.webhookSecret || process.env.TELEGRAM_WEBHOOK_SECRET || '';
|
||||
const headerSecret = request.headers.get('x-telegram-bot-api-secret-token') || '';
|
||||
return Boolean(
|
||||
secretParam &&
|
||||
configuredSecret &&
|
||||
secretParam === configuredSecret &&
|
||||
(!headerSecret || headerSecret === configuredSecret)
|
||||
);
|
||||
}
|
||||
@@ -254,6 +254,22 @@ export interface IStorage {
|
||||
success: boolean
|
||||
): Promise<void>;
|
||||
|
||||
// Telegram Bot绑定相关
|
||||
getTelegramBinding?(userName: string): Promise<TelegramBindingRecord | null>;
|
||||
getTelegramBindingByTelegramUserId?(
|
||||
telegramUserId: string
|
||||
): Promise<TelegramBindingRecord | null>;
|
||||
upsertTelegramBinding?(binding: TelegramBindingRecord): Promise<void>;
|
||||
deleteTelegramBindingByUsername?(userName: string): Promise<void>;
|
||||
deleteTelegramBindingByTelegramUserId?(telegramUserId: string): Promise<void>;
|
||||
getTelegramBindSession?(
|
||||
code: string
|
||||
): Promise<TelegramBindSessionRecord | null>;
|
||||
upsertTelegramBindSession?(
|
||||
session: TelegramBindSessionRecord
|
||||
): Promise<void>;
|
||||
markTelegramBindSessionUsed?(code: string): Promise<void>;
|
||||
|
||||
// TVBox订阅token相关
|
||||
getTvboxSubscribeToken?(userName: string): Promise<string | null>;
|
||||
setTvboxSubscribeToken?(userName: string, token: string): Promise<void>;
|
||||
@@ -362,6 +378,26 @@ export interface PushSubscriptionRecord {
|
||||
failureCount?: number;
|
||||
}
|
||||
|
||||
export interface TelegramBindingRecord {
|
||||
username: string;
|
||||
telegramUserId: string;
|
||||
chatId: string;
|
||||
telegramUsername?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
boundAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TelegramBindSessionRecord {
|
||||
code: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
// 通知类型枚举
|
||||
export type NotificationType =
|
||||
| 'favorite_update' // 收藏更新
|
||||
|
||||
Reference in New Issue
Block a user