diff --git a/scripts/desktop/generate-icons.mjs b/scripts/desktop/generate-icons.mjs index ac587fe..81b6722 100644 --- a/scripts/desktop/generate-icons.mjs +++ b/scripts/desktop/generate-icons.mjs @@ -6,6 +6,7 @@ import sharp from 'sharp'; export const DESKTOP_ICON_SIZE = 512; export const DESKTOP_ICON_PADDING = 40; export const DESKTOP_ICON_RADIUS = 96; +export const DESKTOP_TRAY_TEMPLATE_PADDING = 152; function createRoundedMask(size, cornerRadius) { return Buffer.from( @@ -44,34 +45,73 @@ async function renderDesktopIconBuffer({ .toBuffer(); } +async function renderTrayTemplateIconBuffer({ + sourcePath, + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_TRAY_TEMPLATE_PADDING, +}) { + const innerSize = Math.max(1, size - padding * 2); + const alphaMask = await sharp(sourcePath) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .extractChannel('alpha') + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 1 }, + }, + }) + .composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }]) + .png() + .toBuffer(); +} + export async function generateDesktopIconAssets({ sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'), buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'), webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'), + trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'), size = DESKTOP_ICON_SIZE, padding = DESKTOP_ICON_PADDING, cornerRadius = DESKTOP_ICON_RADIUS, } = {}) { - const outputBuffer = await renderDesktopIconBuffer({ - sourcePath, - size, - padding, - cornerRadius, - }); + const [outputBuffer, trayTemplateBuffer] = await Promise.all([ + renderDesktopIconBuffer({ + sourcePath, + size, + padding, + cornerRadius, + }), + renderTrayTemplateIconBuffer({ + sourcePath, + size, + }), + ]); await Promise.all([ mkdir(dirname(buildOutputPath), { recursive: true }), mkdir(dirname(webOutputPath), { recursive: true }), + mkdir(dirname(trayTemplateOutputPath), { recursive: true }), ]); await Promise.all([ sharp(outputBuffer).toFile(buildOutputPath), sharp(outputBuffer).toFile(webOutputPath), + sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath), ]); return { buildOutputPath, webOutputPath, + trayTemplateOutputPath, }; } @@ -81,5 +121,6 @@ if (isDirectRun) { const outputs = await generateDesktopIconAssets(); console.log(`[metapi-desktop] Generated desktop icons: - ${outputs.buildOutputPath} -- ${outputs.webOutputPath}`); +- ${outputs.webOutputPath} +- ${outputs.trayTemplateOutputPath}`); } diff --git a/scripts/desktop/generate-icons.test.ts b/scripts/desktop/generate-icons.test.ts index b65247b..e2e6151 100644 --- a/scripts/desktop/generate-icons.test.ts +++ b/scripts/desktop/generate-icons.test.ts @@ -21,13 +21,14 @@ afterEach(async () => { }); describe('generateDesktopIconAssets', () => { - it('writes rounded desktop icon outputs for build and runtime usage', async () => { + it('writes rounded desktop icon outputs and a tray template asset', async () => { const { generateDesktopIconAssets } = await import('./generate-icons.mjs'); const dir = await makeTempDir(); const sourcePath = join(dir, 'logo.png'); const buildOutputPath = join(dir, 'build.png'); const webOutputPath = join(dir, 'desktop-icon.png'); + const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png'); await sharp({ create: { @@ -42,15 +43,19 @@ describe('generateDesktopIconAssets', () => { sourcePath, buildOutputPath, webOutputPath, + trayTemplateOutputPath, }); const buildMeta = await sharp(buildOutputPath).metadata(); const webMeta = await sharp(webOutputPath).metadata(); + const trayMeta = await sharp(trayTemplateOutputPath).metadata(); expect(buildMeta.width).toBe(512); expect(buildMeta.height).toBe(512); expect(webMeta.width).toBe(512); expect(webMeta.height).toBe(512); + expect(trayMeta.width).toBe(512); + expect(trayMeta.height).toBe(512); const { data, info } = await sharp(buildOutputPath) .ensureAlpha() @@ -64,5 +69,18 @@ describe('generateDesktopIconAssets', () => { expect(await sharp(buildOutputPath).png().toBuffer()) .toEqual(await sharp(webOutputPath).png().toBuffer()); + + const trayBuffer = await sharp(trayTemplateOutputPath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0); + expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255); + + const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4; + expect(trayBuffer.data[trayCenterOffset]).toBe(0); + expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0); + expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0); }); }); diff --git a/src/desktop/iconAssets.test.ts b/src/desktop/iconAssets.test.ts index 34b3227..caea752 100644 --- a/src/desktop/iconAssets.test.ts +++ b/src/desktop/iconAssets.test.ts @@ -7,12 +7,17 @@ describe('desktop icon assets', () => { const { DESKTOP_BUILD_ICON_RELATIVE_PATH, DESKTOP_RUNTIME_ICON_RELATIVE_PATH, + DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH, getDesktopRuntimeIconPath, + getDesktopTrayIconPath, } = await import('./iconAssets.js'); expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png')); expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png')); + expect(DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-tray-template.png')); expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); + expect(getDesktopTrayIconPath('/app', 'darwin')).toBe(join('/app', 'dist', 'web', 'desktop-tray-template.png')); + expect(getDesktopTrayIconPath('/app', 'win32')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); }); it('points electron-builder at the generated desktop package icon', async () => { diff --git a/src/desktop/iconAssets.ts b/src/desktop/iconAssets.ts index ee73dd0..92ec148 100644 --- a/src/desktop/iconAssets.ts +++ b/src/desktop/iconAssets.ts @@ -2,8 +2,19 @@ import { join } from 'node:path'; export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png'); export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png'); +export const DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-tray-template.png'); export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png'; export function getDesktopRuntimeIconPath(appPath: string) { return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH); } + +export function getDesktopTrayTemplateIconPath(appPath: string) { + return join(appPath, DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH); +} + +export function getDesktopTrayIconPath(appPath: string, platform = process.platform) { + return platform === 'darwin' + ? getDesktopTrayTemplateIconPath(appPath) + : getDesktopRuntimeIconPath(appPath); +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index dd81eff..b029673 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -21,7 +21,7 @@ import { resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; -import { getDesktopRuntimeIconPath } from './iconAssets.js'; +import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js'; const { autoUpdater } = electronUpdater; @@ -52,7 +52,7 @@ function getServerEntryPath() { } function getTrayIconPath() { - return getDesktopRuntimeIconPath(app.getAppPath()); + return getDesktopTrayIconPath(app.getAppPath(), process.platform); } function getWindowIconPath() { @@ -121,7 +121,15 @@ function buildTrayMenu() { function setupTray() { if (tray) return; - const trayImage = nativeImage.createFromPath(getTrayIconPath()); + let trayImage = nativeImage.createFromPath(getTrayIconPath()); + if (process.platform === 'darwin' && !trayImage.isEmpty()) { + trayImage = trayImage.resize({ + width: 18, + height: 18, + quality: 'best', + }); + trayImage.setTemplateImage(true); + } tray = new Tray(trayImage); tray.setToolTip('Metapi'); tray.setContextMenu(buildTrayMenu()); diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 60ef01b..85db8ae 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -36,6 +36,15 @@ describe('buildConfig', () => { const config = buildConfig({}); expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org'); + expect(config.telegramMessageThreadId).toBe(''); + }); + + it('accepts telegram message thread id from environment', () => { + const config = buildConfig({ + TELEGRAM_MESSAGE_THREAD_ID: '77', + }); + + expect(config.telegramMessageThreadId).toBe('77'); }); it('ships CLI-aligned OAuth defaults', () => { diff --git a/src/server/config.ts b/src/server/config.ts index fad6fa2..80d11df 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -67,6 +67,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) { systemProxyUrl: env.SYSTEM_PROXY_URL || '', accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', checkinCron: env.CHECKIN_CRON || '0 8 * * *', + checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval' + ? 'interval' as const + : 'cron' as const, + checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))), balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *', logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *', logCleanupConfigured: false, @@ -84,6 +88,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) { telegramBotToken: env.TELEGRAM_BOT_TOKEN || '', telegramChatId: env.TELEGRAM_CHAT_ID || '', telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false), + telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(), smtpEnabled: parseBoolean(env.SMTP_ENABLED, false), smtpHost: env.SMTP_HOST || '', smtpPort: parseInt(env.SMTP_PORT || '587'), diff --git a/src/server/index.ts b/src/server/index.ts index 45a2b96..9cf4fcd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -162,6 +162,16 @@ function applyRuntimeSettings(settingsMap: Map) { const checkinCron = parseSettingFromMap(settingsMap, 'checkin_cron'); if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; + const checkinScheduleMode = parseSettingFromMap(settingsMap, 'checkin_schedule_mode'); + if (checkinScheduleMode === 'cron' || checkinScheduleMode === 'interval') { + config.checkinScheduleMode = checkinScheduleMode; + } + + const checkinIntervalHours = parseSettingFromMap(settingsMap, 'checkin_interval_hours'); + if (typeof checkinIntervalHours === 'number' && Number.isFinite(checkinIntervalHours) && checkinIntervalHours >= 1 && checkinIntervalHours <= 24) { + config.checkinIntervalHours = Math.trunc(checkinIntervalHours); + } + const balanceRefreshCron = parseSettingFromMap(settingsMap, 'balance_refresh_cron'); if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron; @@ -219,6 +229,12 @@ function applyRuntimeSettings(settingsMap: Map) { const telegramChatId = parseSettingFromMap(settingsMap, 'telegram_chat_id'); if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId; + const telegramUseSystemProxy = parseSettingFromMap(settingsMap, 'telegram_use_system_proxy'); + if (typeof telegramUseSystemProxy === 'boolean') config.telegramUseSystemProxy = telegramUseSystemProxy; + + const telegramMessageThreadId = parseSettingFromMap(settingsMap, 'telegram_message_thread_id'); + if (typeof telegramMessageThreadId === 'string') config.telegramMessageThreadId = telegramMessageThreadId; + const smtpEnabled = parseSettingFromMap(settingsMap, 'smtp_enabled'); if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; diff --git a/src/server/routes/api/checkin.lock.test.ts b/src/server/routes/api/checkin.lock.test.ts index ee33546..ca22ffb 100644 --- a/src/server/routes/api/checkin.lock.test.ts +++ b/src/server/routes/api/checkin.lock.test.ts @@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({ })); vi.mock('../../services/checkinScheduler.js', () => ({ - updateCheckinCron: vi.fn(), + updateCheckinSchedule: vi.fn(), })); vi.mock('../../db/index.js', () => { @@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => { await new Promise((resolve) => setTimeout(resolve, 20)); await app.close(); }); + + it('accepts the legacy cron-only schedule payload', async () => { + const { checkinRoutes } = await import('./checkin.js'); + const schedulerModule = await import('../../services/checkinScheduler.js'); + const app = Fastify(); + await app.register(checkinRoutes); + + const response = await app.inject({ + method: 'PUT', + url: '/api/checkin/schedule', + payload: { cron: '0 8 * * *' }, + }); + + expect(response.statusCode).toBe(200); + expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({ + mode: 'cron', + cronExpr: '0 8 * * *', + intervalHours: undefined, + }); + await app.close(); + }); }); diff --git a/src/server/routes/api/checkin.ts b/src/server/routes/api/checkin.ts index 408d923..e247879 100644 --- a/src/server/routes/api/checkin.ts +++ b/src/server/routes/api/checkin.ts @@ -1,9 +1,10 @@ import { FastifyInstance } from 'fastify'; +import { config } from '../../config.js'; import { db, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { eq, desc } from 'drizzle-orm'; import { checkinAccount, checkinAll } from '../../services/checkinService.js'; -import { updateCheckinCron } from '../../services/checkinScheduler.js'; +import { updateCheckinSchedule } from '../../services/checkinScheduler.js'; import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js'; import { classifyFailureReason } from '../../services/failureReasonService.js'; @@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) { failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`, }, async () => { - const results = await checkinAll(); + const results = await checkinAll({ scheduleMode: config.checkinScheduleMode }); return { summary: summarizeCheckinResults(results), total: results.length, @@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) { // Trigger check-in for a specific account app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => { const id = parseInt(request.params.id, 10); - const result = await checkinAccount(id); + const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode }); return result; }); @@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) { }); // Update check-in schedule - app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => { + app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => { try { - updateCheckinCron(request.body.cron); - await upsertSetting('checkin_cron', request.body.cron); - return { success: true, cron: request.body.cron }; + const body = request.body || {}; + const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron'; + const nextCron = typeof body.cron === 'string' ? body.cron : undefined; + const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined; + const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours) + ? Math.trunc(nextIntervalHours) + : undefined; + + updateCheckinSchedule({ + mode: nextMode, + cronExpr: nextCron, + intervalHours: normalizedIntervalHours, + }); + + await upsertSetting('checkin_schedule_mode', nextMode); + if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron); + if (normalizedIntervalHours !== undefined) { + await upsertSetting('checkin_interval_hours', normalizedIntervalHours); + } + return { + success: true, + mode: nextMode, + cron: nextCron, + intervalHours: normalizedIntervalHours, + }; } catch (err: any) { return { error: err.message }; } diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 86ffe92..431bcc3 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -44,6 +44,8 @@ describe('settings and auth events', () => { config.proxyToken = 'sk-old-proxy-token-123'; config.systemProxyUrl = ''; config.checkinCron = '0 8 * * *'; + (config as any).checkinScheduleMode = 'cron'; + (config as any).checkinIntervalHours = 6; config.balanceRefreshCron = '0 * * * *'; config.logCleanupConfigured = false; config.logCleanupCron = '0 6 * * *'; @@ -56,6 +58,7 @@ describe('settings and auth events', () => { (config as any).telegramBotToken = ''; (config as any).telegramChatId = ''; (config as any).telegramUseSystemProxy = false; + (config as any).telegramMessageThreadId = ''; }); afterAll(async () => { @@ -86,6 +89,28 @@ describe('settings and auth events', () => { expect(events[0].message || '').toContain('签到 Cron'); }); + it('persists and returns checkin interval mode from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + checkinScheduleMode: 'interval', + checkinIntervalHours: 8, + checkinCron: '0 8 * * *', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number }; + expect(updated.checkinScheduleMode).toBe('interval'); + expect(updated.checkinIntervalHours).toBe(8); + + const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get(); + const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get(); + expect(savedMode?.value).toBe(JSON.stringify('interval')); + expect(savedInterval?.value).toBe(JSON.stringify(8)); + }); + it('returns current recognized admin IP in runtime settings response', async () => { const response = await app.inject({ method: 'GET', @@ -203,6 +228,32 @@ describe('settings and auth events', () => { expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom'); }); + it('persists and returns telegram message thread id from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramMessageThreadId: '77', + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { telegramMessageThreadId?: string }; + expect(updated.telegramMessageThreadId).toBe('77'); + expect((config as any).telegramMessageThreadId).toBe('77'); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get(); + expect(saved?.value).toBe(JSON.stringify('77')); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { telegramMessageThreadId?: string }; + expect(runtime.telegramMessageThreadId).toBe('77'); + }); + it('rejects invalid telegram api base url when telegram is enabled', async () => { const response = await app.inject({ method: 'PUT', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 07b7743..2ef507d 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -4,7 +4,7 @@ import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; +import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; @@ -28,6 +28,8 @@ interface RuntimeSettingsBody { proxyToken?: string; systemProxyUrl?: string; checkinCron?: string; + checkinScheduleMode?: 'cron' | 'interval'; + checkinIntervalHours?: number; balanceRefreshCron?: string; logCleanupCron?: string; logCleanupUsageLogsEnabled?: boolean; @@ -44,6 +46,7 @@ interface RuntimeSettingsBody { telegramBotToken?: string; telegramChatId?: string; telegramUseSystemProxy?: boolean; + telegramMessageThreadId?: string; smtpEnabled?: boolean; smtpHost?: string; smtpPort?: number; @@ -172,12 +175,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string { return String(raw || '').trim().replace(/\/+$/, ''); } +function normalizeTelegramMessageThreadId(raw: unknown): string { + return String(raw || '').trim(); +} + +function isValidTelegramMessageThreadId(raw: string): boolean { + return /^[1-9]\d*$/.test(raw); +} + function applyImportedSettingToRuntime(key: string, value: unknown) { switch (key) { case 'checkin_cron': { if (typeof value !== 'string' || !value || !cron.validate(value)) return; config.checkinCron = value; - updateCheckinCron(value); + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); + return; + } + case 'checkin_schedule_mode': { + if (value !== 'cron' && value !== 'interval') return; + const nextMode: 'cron' | 'interval' = value; + config.checkinScheduleMode = nextMode; + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); + return; + } + case 'checkin_interval_hours': { + const intervalHours = Number(value); + if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return; + config.checkinIntervalHours = Math.trunc(intervalHours); + updateCheckinSchedule({ + mode: config.checkinScheduleMode, + cronExpr: config.checkinCron, + intervalHours: config.checkinIntervalHours, + }); return; } case 'balance_refresh_cron': { @@ -293,6 +330,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.telegramUseSystemProxy = !!value; return; } + case 'telegram_message_thread_id': { + if (typeof value !== 'string') return; + config.telegramMessageThreadId = value.trim(); + return; + } case 'smtp_enabled': { config.smtpEnabled = !!value; return; @@ -368,6 +410,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { function getRuntimeSettingsResponse(currentAdminIp = '') { return { checkinCron: config.checkinCron, + checkinScheduleMode: config.checkinScheduleMode, + checkinIntervalHours: config.checkinIntervalHours, balanceRefreshCron: config.balanceRefreshCron, logCleanupCron: config.logCleanupCron, logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, @@ -386,6 +430,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { telegramBotTokenMasked: maskSecret(config.telegramBotToken), telegramChatId: config.telegramChatId, telegramUseSystemProxy: config.telegramUseSystemProxy, + telegramMessageThreadId: config.telegramMessageThreadId, smtpEnabled: config.smtpEnabled, smtpHost: config.smtpHost, smtpPort: config.smtpPort, @@ -519,7 +564,8 @@ export async function settingsRoutes(app: FastifyInstance) { || body.telegramApiBaseUrl !== undefined || body.telegramBotToken !== undefined || body.telegramChatId !== undefined - || body.telegramUseSystemProxy !== undefined; + || body.telegramUseSystemProxy !== undefined + || body.telegramMessageThreadId !== undefined; const nextTelegramEnabled = body.telegramEnabled !== undefined ? !!body.telegramEnabled : config.telegramEnabled; @@ -532,6 +578,9 @@ export async function settingsRoutes(app: FastifyInstance) { const nextTelegramChatId = body.telegramChatId !== undefined ? String(body.telegramChatId || '').trim() : config.telegramChatId; + const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined + ? normalizeTelegramMessageThreadId(body.telegramMessageThreadId) + : config.telegramMessageThreadId; if (telegramTouched && nextTelegramEnabled) { if (!nextTelegramBotToken) { return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' }); @@ -542,13 +591,22 @@ export async function settingsRoutes(app: FastifyInstance) { if (!nextTelegramChatId) { return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' }); } + if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) { + return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' }); + } if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) { return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); } } else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) { return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); + } else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) { + return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' }); } + const checkinScheduleTouched = body.checkinCron !== undefined + || body.checkinScheduleMode !== undefined + || body.checkinIntervalHours !== undefined; + if (body.checkinCron !== undefined) { if (!cron.validate(body.checkinCron)) { return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' }); @@ -556,8 +614,50 @@ export async function settingsRoutes(app: FastifyInstance) { if (body.checkinCron !== config.checkinCron) { changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`); } - updateCheckinCron(body.checkinCron); - upsertSetting('checkin_cron', body.checkinCron); + } + + if (body.checkinScheduleMode !== undefined) { + if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') { + return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' }); + } + if (body.checkinScheduleMode !== config.checkinScheduleMode) { + changedLabels.push('签到方式'); + } + config.checkinScheduleMode = body.checkinScheduleMode; + } + + if (body.checkinIntervalHours !== undefined) { + const intervalHours = Number(body.checkinIntervalHours); + if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) { + return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' }); + } + const nextIntervalHours = Math.trunc(intervalHours); + if (nextIntervalHours !== config.checkinIntervalHours) { + changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`); + } + config.checkinIntervalHours = nextIntervalHours; + } + + if (checkinScheduleTouched) { + const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron; + const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined + ? body.checkinScheduleMode + : config.checkinScheduleMode; + const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined + ? Math.trunc(Number(body.checkinIntervalHours)) + : config.checkinIntervalHours; + + updateCheckinSchedule({ + mode: nextCheckinScheduleMode, + cronExpr: nextCheckinCron, + intervalHours: nextCheckinIntervalHours, + }); + config.checkinCron = nextCheckinCron; + config.checkinScheduleMode = nextCheckinScheduleMode; + config.checkinIntervalHours = nextCheckinIntervalHours; + upsertSetting('checkin_cron', config.checkinCron); + upsertSetting('checkin_schedule_mode', config.checkinScheduleMode); + upsertSetting('checkin_interval_hours', config.checkinIntervalHours); } if (body.balanceRefreshCron !== undefined) { @@ -783,6 +883,15 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy); } + if (body.telegramMessageThreadId !== undefined) { + const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId); + if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) { + changedLabels.push('Telegram Topic ID'); + } + config.telegramMessageThreadId = nextTelegramMessageThreadId; + upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId); + } + if (body.smtpEnabled !== undefined) { if (!!body.smtpEnabled !== config.smtpEnabled) { changedLabels.push('SMTP 开关'); diff --git a/src/server/services/checkinScheduler.test.ts b/src/server/services/checkinScheduler.test.ts new file mode 100644 index 0000000..cce3ccb --- /dev/null +++ b/src/server/services/checkinScheduler.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const cronStopMock = vi.fn(); +const scheduleMock = vi.fn(() => ({ + stop: cronStopMock, +})); +const validateMock = vi.fn(() => true); +const allMock = vi.fn(); + +vi.mock('node-cron', () => ({ + default: { + schedule: (...args: unknown[]) => scheduleMock(...args), + validate: (...args: unknown[]) => validateMock(...args), + }, +})); + +vi.mock('../db/index.js', () => { + const queryChain = { + where: () => queryChain, + get: () => undefined, + all: () => [], + from: () => queryChain, + innerJoin: () => queryChain, + }; + + return { + db: { + select: () => queryChain, + }, + schema: { + settings: { key: 'key' }, + accounts: { checkinEnabled: 'checkinEnabled', status: 'status' }, + sites: { id: 'id' }, + }, + }; +}); + +vi.mock('./checkinService.js', () => ({ + checkinAll: (...args: unknown[]) => allMock(...args), +})); + +describe('checkinScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + cronStopMock.mockReset(); + scheduleMock.mockClear(); + validateMock.mockClear(); + allMock.mockReset(); + }); + + afterEach(async () => { + const scheduler = await import('./checkinScheduler.js'); + scheduler.__resetCheckinSchedulerForTests(); + vi.useRealTimers(); + }); + + it('switches from cron mode to interval mode and back', async () => { + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + const scheduler = await import('./checkinScheduler.js'); + + scheduler.updateCheckinSchedule({ + mode: 'cron', + cronExpr: '0 8 * * *', + intervalHours: 6, + }); + expect(scheduleMock).toHaveBeenCalledTimes(1); + + scheduler.updateCheckinSchedule({ + mode: 'interval', + intervalHours: 6, + }); + expect(cronStopMock).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + + scheduler.updateCheckinSchedule({ + mode: 'cron', + cronExpr: '5 9 * * *', + intervalHours: 6, + }); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + expect(scheduleMock).toHaveBeenCalledTimes(2); + }); + + it('selects due accounts from the last successful checkin time', async () => { + const scheduler = await import('./checkinScheduler.js'); + const now = new Date('2026-03-20T12:00:00.000Z'); + + expect(scheduler.selectDueIntervalCheckinAccountIds([ + { id: 1, lastCheckinAt: null }, + { id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' }, + { id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' }, + ], 6, now)).toEqual([1, 2]); + }); +}); diff --git a/src/server/services/checkinScheduler.ts b/src/server/services/checkinScheduler.ts index 3df0e7d..e758ec1 100644 --- a/src/server/services/checkinScheduler.ts +++ b/src/server/services/checkinScheduler.ts @@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js'; import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js'; import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js'; +export type CheckinScheduleMode = 'cron' | 'interval'; + let checkinTask: cron.ScheduledTask | null = null; +let checkinIntervalTimer: ReturnType | null = null; let balanceTask: cron.ScheduledTask | null = null; let dailySummaryTask: cron.ScheduledTask | null = null; let logCleanupTask: cron.ScheduledTask | null = null; +const intervalAttemptByAccount = new Map(); const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *'; const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *'; +const CHECKIN_INTERVAL_POLL_MS = 60_000; async function resolveJsonSetting( settingKey: string, @@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) { return cron.schedule(cronExpr, async () => { console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`); try { - const results = await checkinAll(); + const results = await checkinAll({ scheduleMode: 'cron' }); const success = results.filter((r) => r.result.success).length; const failed = results.length - success; console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`); @@ -64,6 +69,94 @@ function createCheckinTask(cronExpr: string) { }); } +type IntervalCheckinCandidate = { + id: number; + lastCheckinAt?: string | null; +}; + +export function selectDueIntervalCheckinAccountIds( + rows: IntervalCheckinCandidate[], + intervalHours: number, + now = new Date(), + attemptState = intervalAttemptByAccount, +) { + const nowMs = now.getTime(); + const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000; + + return rows + .filter((row) => { + const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN; + const lastAttemptMs = attemptState.get(row.id); + if (Number.isFinite(lastCheckinMs)) { + if (nowMs - lastCheckinMs < intervalMs) return false; + if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) { + return false; + } + return true; + } + if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false; + return true; + }) + .map((row) => row.id); +} + +async function runIntervalCheckinPass(now = new Date()) { + const rows = await db + .select() + .from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .all(); + + const dueAccountIds = selectDueIntervalCheckinAccountIds( + rows + .filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled') + .map((row: any) => ({ + id: row.accounts.id, + lastCheckinAt: row.accounts.lastCheckinAt, + })), + config.checkinIntervalHours, + now, + ); + + if (dueAccountIds.length === 0) return; + + try { + const results = await checkinAll({ + accountIds: dueAccountIds, + scheduleMode: 'interval', + }); + const nowMs = now.getTime(); + for (const item of results) { + intervalAttemptByAccount.set(item.accountId, nowMs); + } + const success = results.filter((r) => r.result.success).length; + const failed = results.length - success; + console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`); + } catch (err) { + console.error('[Scheduler] Interval check-in error:', err); + } +} + +function stopCheckinSchedule() { + checkinTask?.stop(); + checkinTask = null; + if (checkinIntervalTimer) { + clearInterval(checkinIntervalTimer); + checkinIntervalTimer = null; + } +} + +function startCheckinSchedule() { + stopCheckinSchedule(); + if (config.checkinScheduleMode === 'interval') { + checkinIntervalTimer = setInterval(() => { + void runIntervalCheckinPass(); + }, CHECKIN_INTERVAL_POLL_MS); + return; + } + checkinTask = createCheckinTask(config.checkinCron); +} + function createBalanceTask(cronExpr: string) { return cron.schedule(cronExpr, async () => { console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`); @@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) { export async function startScheduler() { const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron); + const activeCheckinScheduleMode = await resolveJsonSetting( + 'checkin_schedule_mode', + (value): value is CheckinScheduleMode => value === 'cron' || value === 'interval', + config.checkinScheduleMode as CheckinScheduleMode, + ); + const activeCheckinIntervalHours = await resolvePositiveIntegerSetting( + 'checkin_interval_hours', + config.checkinIntervalHours, + ); const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron); const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON); const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON); @@ -135,22 +237,24 @@ export async function startScheduler() { normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays), ); config.checkinCron = activeCheckinCron; + config.checkinScheduleMode = activeCheckinScheduleMode; + config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours)); config.balanceRefreshCron = activeBalanceCron; config.logCleanupCron = activeLogCleanupCron; config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled; config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled; config.logCleanupRetentionDays = activeLogCleanupRetentionDays; - checkinTask?.stop(); + stopCheckinSchedule(); balanceTask?.stop(); dailySummaryTask?.stop(); logCleanupTask?.stop(); - checkinTask = createCheckinTask(activeCheckinCron); + startCheckinSchedule(); balanceTask = createBalanceTask(activeBalanceCron); dailySummaryTask = createDailySummaryTask(activeDailySummaryCron); logCleanupTask = createLogCleanupTask(activeLogCleanupCron); - console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`); + console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`); console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`); console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`); console.log( @@ -159,10 +263,35 @@ export async function startScheduler() { } export function updateCheckinCron(cronExpr: string) { - if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`); - config.checkinCron = cronExpr; - checkinTask?.stop(); - checkinTask = createCheckinTask(cronExpr); + updateCheckinSchedule({ + mode: 'cron', + cronExpr, + intervalHours: config.checkinIntervalHours, + }); +} + +export function updateCheckinSchedule(input: { + mode: CheckinScheduleMode; + cronExpr?: string; + intervalHours?: number; +}) { + const nextMode = input.mode; + if (nextMode !== 'cron' && nextMode !== 'interval') { + throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`); + } + + const nextCronExpr = input.cronExpr ?? config.checkinCron; + if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`); + + const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours; + if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) { + throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`); + } + + config.checkinScheduleMode = nextMode; + config.checkinCron = nextCronExpr; + config.checkinIntervalHours = Math.trunc(nextIntervalHours); + startCheckinSchedule(); } export function updateBalanceRefreshCron(cronExpr: string) { @@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: { logCleanupTask?.stop(); logCleanupTask = createLogCleanupTask(cronExpr); } + +export function __resetCheckinSchedulerForTests() { + stopCheckinSchedule(); + balanceTask?.stop(); + dailySummaryTask?.stop(); + logCleanupTask?.stop(); + balanceTask = null; + dailySummaryTask = null; + logCleanupTask = null; + intervalAttemptByAccount.clear(); +} diff --git a/src/server/services/checkinService.autoRelogin.test.ts b/src/server/services/checkinService.autoRelogin.test.ts index 77bacff..1d5d02c 100644 --- a/src/server/services/checkinService.autoRelogin.test.ts +++ b/src/server/services/checkinService.autoRelogin.test.ts @@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => { expect(notifyMock).not.toHaveBeenCalled(); }); + it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 16, + username: 'interval-user', + accessToken: 'token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 16, + name: 'demo', + url: 'https://example.com', + platform: 'new-api', + }, + }, + ]); + + adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' }); + + const { checkinAccount } = await import('./checkinService.js'); + const result = await checkinAccount(16, { scheduleMode: 'interval' }); + + expect(result.success).toBe(true); + expect(result.status).toBe('success'); + expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) })); + }); + + it('advances lastCheckinAt when interval mode gets a direct success', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 17, + username: 'interval-success', + accessToken: 'token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 17, + name: 'demo', + url: 'https://example.com', + platform: 'new-api', + }, + }, + ]); + + adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' }); + + const { checkinAccount } = await import('./checkinService.js'); + const result = await checkinAccount(17, { scheduleMode: 'interval' }); + + expect(result.success).toBe(true); + expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) })); + }); + it('treats unsupported checkin endpoint responses as skipped', async () => { selectAllMock.mockReturnValue([ { diff --git a/src/server/services/checkinService.ts b/src/server/services/checkinService.ts index fc5c77c..d2f2190 100644 --- a/src/server/services/checkinService.ts +++ b/src/server/services/checkinService.ts @@ -115,7 +115,7 @@ async function tryAutoRelogin(account: any, site: any): Promise { return result.accessToken; } -export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) { +export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean; scheduleMode?: 'cron' | 'interval' }) { const rows = await db .select() .from(schema.accounts) @@ -192,6 +192,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired; const shouldRefreshBalance = result.success || alreadyCheckedIn; const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin; + const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval'); const normalizedStatus: CheckinExecutionStatus = effectiveSuccess ? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success') : 'failed'; @@ -211,9 +212,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: source: 'checkin', }); - const updates: Record = { - lastCheckinAt: new Date().toISOString(), - }; + const updates: Record = {}; + if (shouldAdvanceLastCheckinAt) { + updates.lastCheckinAt = new Date().toISOString(); + } if (!storedPlatformUserId && guessedPlatformUserId) { updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { platformUserId: guessedPlatformUserId, @@ -308,7 +310,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: }; } -export async function checkinAll() { +export async function checkinAll(options?: { accountIds?: number[]; scheduleMode?: 'cron' | 'interval' }) { const rows = await db .select() .from(schema.accounts) @@ -321,10 +323,12 @@ export async function checkinAll() { ) .all(); + const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null; const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = []; const grouped = new Map(); for (const row of rows) { + if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue; const siteId = row.sites.id; if (!grouped.has(siteId)) grouped.set(siteId, []); grouped.get(siteId)!.push(row); @@ -332,7 +336,10 @@ export async function checkinAll() { const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => { for (const row of siteRows) { - const r = await checkinAccount(row.accounts.id, { skipEvent: true }); + const r = await checkinAccount(row.accounts.id, { + skipEvent: true, + scheduleMode: options?.scheduleMode, + }); results.push({ accountId: row.accounts.id, username: row.accounts.username, diff --git a/src/server/services/notifyService.test.ts b/src/server/services/notifyService.test.ts index f96b913..0debcb4 100644 --- a/src/server/services/notifyService.test.ts +++ b/src/server/services/notifyService.test.ts @@ -49,6 +49,7 @@ describe('notifyService', () => { (config as any).telegramChatId = ''; (config as any).telegramUseSystemProxy = false; config.systemProxyUrl = ''; + (config as any).telegramMessageThreadId = ''; config.smtpEnabled = true; config.smtpHost = 'smtp.example.com'; config.smtpPort = 465; @@ -181,11 +182,12 @@ describe('notifyService', () => { expect(payload?.text || '').toContain('UTC Time:'); }); - it('sends telegram message when telegram channel is enabled', async () => { + it('sends telegram message without topic when telegram thread id is empty', async () => { const { config } = await import('../config.js'); (config as any).telegramEnabled = true; (config as any).telegramBotToken = '123456:telegram-token'; (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramMessageThreadId = ''; config.smtpEnabled = false; fetchMock.mockResolvedValue({ @@ -206,13 +208,35 @@ describe('notifyService', () => { ); const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; - const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string }; + const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string; message_thread_id?: number }; expect(payload.chat_id).toBe('-1001234567890'); + expect(payload.message_thread_id).toBeUndefined(); expect(payload.text || '').toContain('Level: warning'); expect(payload.text || '').toContain('Local Time:'); expect(payload.text || '').toContain('UTC Time:'); }); + it('sends telegram topic id when telegram thread id is configured', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + (config as any).telegramMessageThreadId = '77'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true }); + + const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; + const payload = JSON.parse(rawBody?.body || '{}') as { message_thread_id?: number }; + expect(payload.message_thread_id).toBe(77); + }); + it('uses TELEGRAM_API_BASE_URL when configured', async () => { const { config } = await import('../config.js'); (config as any).telegramEnabled = true; diff --git a/src/server/services/notifyService.ts b/src/server/services/notifyService.ts index 7af5201..a6ba336 100644 --- a/src/server/services/notifyService.ts +++ b/src/server/services/notifyService.ts @@ -222,6 +222,7 @@ export async function sendNotification( const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, ''); const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`; const text = buildTelegramText(title, resolvedMessage, level, timeFootnote); + const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10); tasks.push({ channel: 'telegram', run: async () => { @@ -232,6 +233,9 @@ export async function sendNotification( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: config.telegramChatId, + ...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0 + ? { message_thread_id: telegramMessageThreadId } + : {}), text, disable_web_page_preview: true, }), diff --git a/src/web/pages/NotificationSettings.test.tsx b/src/web/pages/NotificationSettings.test.tsx index b12c8e0..bfa8c0b 100644 --- a/src/web/pages/NotificationSettings.test.tsx +++ b/src/web/pages/NotificationSettings.test.tsx @@ -42,6 +42,7 @@ describe('NotificationSettings', () => { telegramEnabled: true, telegramApiBaseUrl: 'https://tg-proxy.example.com', telegramChatId: '-1001234567890', + telegramMessageThreadId: '77', telegramBotTokenMasked: '1234****token', telegramUseSystemProxy: false, smtpEnabled: false, @@ -56,6 +57,7 @@ describe('NotificationSettings', () => { apiMock.updateRuntimeSettings.mockResolvedValue({ success: true, telegramApiBaseUrl: 'https://proxy.example.com/custom', + telegramMessageThreadId: '88', telegramBotTokenMasked: '1234****token', }); apiMock.testNotification.mockResolvedValue({ success: true }); @@ -65,7 +67,7 @@ describe('NotificationSettings', () => { vi.clearAllMocks(); }); - it('loads and saves telegram api base url', async () => { + it('loads and saves telegram api base url and topic id', async () => { let root: ReturnType | null = null; try { await act(async () => { @@ -85,8 +87,15 @@ describe('NotificationSettings', () => { )); expect(proxyInput.props.value).toBe('https://tg-proxy.example.com'); + const topicInput = root.root.find((node) => ( + node.type === 'input' + && node.props.placeholder === '例如: 77' + )); + expect(topicInput.props.value).toBe('77'); + await act(async () => { proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } }); + topicInput.props.onChange({ target: { value: '88' } }); }); const saveButton = root.root.find((node) => ( @@ -101,6 +110,7 @@ describe('NotificationSettings', () => { expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({ telegramApiBaseUrl: 'https://proxy.example.com/custom/', + telegramMessageThreadId: '88', })); } finally { root?.unmount(); diff --git a/src/web/pages/NotificationSettings.tsx b/src/web/pages/NotificationSettings.tsx index c0917fc..1e2cab7 100644 --- a/src/web/pages/NotificationSettings.tsx +++ b/src/web/pages/NotificationSettings.tsx @@ -13,6 +13,7 @@ type RuntimeSettings = { telegramApiBaseUrl: string; telegramChatId: string; telegramUseSystemProxy: boolean; + telegramMessageThreadId: string; smtpEnabled: boolean; smtpHost: string; smtpPort: number; @@ -37,6 +38,7 @@ export default function NotificationSettings() { telegramApiBaseUrl: 'https://api.telegram.org', telegramChatId: '', telegramUseSystemProxy: false, + telegramMessageThreadId: '', smtpEnabled: false, smtpHost: '', smtpPort: 587, @@ -81,6 +83,7 @@ export default function NotificationSettings() { telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org', telegramChatId: runtimeInfo.telegramChatId || '', telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy, + telegramMessageThreadId: runtimeInfo.telegramMessageThreadId || '', smtpEnabled: !!runtimeInfo.smtpEnabled, smtpHost: runtimeInfo.smtpHost || '', smtpPort: Number(runtimeInfo.smtpPort) || 587, @@ -119,6 +122,7 @@ export default function NotificationSettings() { telegramApiBaseUrl: runtime.telegramApiBaseUrl, telegramChatId: runtime.telegramChatId, telegramUseSystemProxy: runtime.telegramUseSystemProxy, + telegramMessageThreadId: runtime.telegramMessageThreadId, smtpEnabled: runtime.smtpEnabled, smtpHost: runtime.smtpHost, smtpPort: runtime.smtpPort, @@ -367,6 +371,16 @@ export default function NotificationSettings() { disabled={!runtime.telegramEnabled} /> +
+
Telegram Topic ID
+ setRuntime((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))} + placeholder="例如: 77" + style={inputStyle} + disabled={!runtime.telegramEnabled} + /> +
Telegram Bot Token diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index e46a6d6..d795734 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -26,6 +26,8 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type RuntimeSettings = { checkinCron: string; + checkinScheduleMode: 'cron' | 'interval'; + checkinIntervalHours: number; balanceRefreshCron: string; logCleanupCron: string; logCleanupUsageLogsEnabled: boolean; @@ -187,6 +189,8 @@ export default function Settings() { const navigate = useNavigate(); const [runtime, setRuntime] = useState({ checkinCron: '0 8 * * *', + checkinScheduleMode: 'cron', + checkinIntervalHours: 6, balanceRefreshCron: '0 * * * *', logCleanupCron: '0 6 * * *', logCleanupUsageLogsEnabled: false, @@ -203,6 +207,7 @@ export default function Settings() { const [maskedToken, setMaskedToken] = useState(''); const [loading, setLoading] = useState(true); const [savingSchedule, setSavingSchedule] = useState(false); + const [testingCheckin, setTestingCheckin] = useState(false); const [savingToken, setSavingToken] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); @@ -397,6 +402,10 @@ export default function Settings() { setMaskedToken(authInfo.masked || '****'); setRuntime({ checkinCron: runtimeInfo.checkinCron || '0 8 * * *', + checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron', + checkinIntervalHours: Number(runtimeInfo.checkinIntervalHours) >= 1 + ? Math.min(24, Math.trunc(Number(runtimeInfo.checkinIntervalHours))) + : 6, balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *', logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *', logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled, @@ -488,6 +497,8 @@ export default function Settings() { try { await api.updateRuntimeSettings({ checkinCron: runtime.checkinCron, + checkinScheduleMode: runtime.checkinScheduleMode, + checkinIntervalHours: runtime.checkinIntervalHours, balanceRefreshCron: runtime.balanceRefreshCron, logCleanupCron: runtime.logCleanupCron, logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled, @@ -502,6 +513,18 @@ export default function Settings() { } }; + const triggerScheduleCheckin = async () => { + setTestingCheckin(true); + try { + await api.triggerCheckinAll(); + toast.success('已开始全部签到,请稍后查看签到日志'); + } catch (err: any) { + toast.error(err?.message || '触发签到失败'); + } finally { + setTestingCheckin(false); + } + }; + const saveProxyToken = async () => { const suffix = proxyTokenSuffix.trim(); if (!suffix) { @@ -953,6 +976,42 @@ export default function Settings() {
定时任务
+
+
+
签到方式
+ +
+
+
签到间隔
+ +
+ +
签到 Cron
@@ -960,6 +1019,7 @@ export default function Settings() { value={runtime.checkinCron} onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))} style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + disabled={runtime.checkinScheduleMode !== 'cron'} />
diff --git a/src/web/pages/settings.schedule-log-cleanup.test.tsx b/src/web/pages/settings.schedule-log-cleanup.test.tsx index d75259b..7f89432 100644 --- a/src/web/pages/settings.schedule-log-cleanup.test.tsx +++ b/src/web/pages/settings.schedule-log-cleanup.test.tsx @@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({ getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), updateRuntimeSettings: vi.fn(), + triggerCheckinAll: vi.fn(), }, })); @@ -46,6 +47,8 @@ describe('Settings log cleanup schedule', () => { apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' }); apiMock.getRuntimeSettings.mockResolvedValue({ checkinCron: '0 8 * * *', + checkinScheduleMode: 'interval', + checkinIntervalHours: 6, balanceRefreshCron: '0 * * * *', logCleanupCron: '15 4 * * *', logCleanupUsageLogsEnabled: true, @@ -70,7 +73,7 @@ describe('Settings log cleanup schedule', () => { vi.clearAllMocks(); }); - it('saves log cleanup fields together with other schedule settings', async () => { + it('saves schedule mode and interval fields together with other schedule settings', async () => { let root: ReturnType | null = null; try { await act(async () => { @@ -97,6 +100,8 @@ describe('Settings log cleanup schedule', () => { expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({ checkinCron: '0 8 * * *', + checkinScheduleMode: 'interval', + checkinIntervalHours: 6, balanceRefreshCron: '0 * * * *', logCleanupCron: '15 4 * * *', logCleanupUsageLogsEnabled: true, @@ -107,4 +112,37 @@ describe('Settings log cleanup schedule', () => { root?.unmount(); } }); + + it('triggers a one-off checkin from the schedule card', async () => { + apiMock.triggerCheckinAll.mockResolvedValue({ success: true }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const triggerButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '测试一次签到' + )); + + await act(async () => { + await triggerButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.triggerCheckinAll).toHaveBeenCalledTimes(1); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/public/desktop-tray-template.png b/src/web/public/desktop-tray-template.png new file mode 100644 index 0000000..9de331f Binary files /dev/null and b/src/web/public/desktop-tray-template.png differ