fix desktop tray icon telegram topics and checkin intervals

This commit is contained in:
cita
2026-03-20 12:16:00 +08:00
parent 2de5eebf29
commit 220f786204
23 changed files with 808 additions and 42 deletions
+22 -1
View File
@@ -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();
});
});
+30 -7
View File
@@ -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 };
}
@@ -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',
+114 -5
View File
@@ -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 开关');