接入tgbot

This commit is contained in:
mtvpls
2026-06-29 16:40:03 +08:00
parent b1aad6a9bc
commit 5d605710f3
27 changed files with 2079 additions and 11 deletions
+12
View File
@@ -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 地址
+110
View File
@@ -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));
}
+26
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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);
}
}
}
// 导出默认实例
+14
View File
@@ -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
View File
@@ -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
+61 -2
View File
@@ -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(
+574
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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)
);
}
+36
View File
@@ -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' // 收藏更新