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
@@ -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]);
});
});
+148 -8
View File
@@ -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<typeof setInterval> | null = null;
let balanceTask: cron.ScheduledTask | null = null;
let dailySummaryTask: cron.ScheduledTask | null = null;
let logCleanupTask: cron.ScheduledTask | null = null;
const intervalAttemptByAccount = new Map<number, number>();
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
const CHECKIN_INTERVAL_POLL_MS = 60_000;
async function resolveJsonSetting<T>(
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<CheckinScheduleMode>(
'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();
}
@@ -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([
{
+13 -6
View File
@@ -115,7 +115,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
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<string, unknown> = {
lastCheckinAt: new Date().toISOString(),
};
const updates: Record<string, unknown> = {};
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<number, typeof rows>();
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,
+26 -2
View File
@@ -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;
+4
View File
@@ -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,
}),