forked from boos/BoosAPI
add site announcements
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||
|
||||
@@ -44,6 +45,19 @@ export interface ApiTokenInfo {
|
||||
tokenGroup?: string | null;
|
||||
}
|
||||
|
||||
export interface SiteAnnouncement {
|
||||
sourceKey: string;
|
||||
title: string;
|
||||
content: string;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
sourceUrl?: string | null;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
upstreamCreatedAt?: string | null;
|
||||
upstreamUpdatedAt?: string | null;
|
||||
rawPayload?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateApiTokenOptions {
|
||||
name?: string;
|
||||
group?: string;
|
||||
@@ -66,6 +80,7 @@ export interface PlatformAdapter {
|
||||
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
||||
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
|
||||
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
|
||||
getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise<SiteAnnouncement[]>;
|
||||
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
|
||||
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
|
||||
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
|
||||
@@ -149,6 +164,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
||||
}
|
||||
|
||||
async getSiteAnnouncements(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
_platformUserId?: number,
|
||||
): Promise<SiteAnnouncement[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
@@ -192,4 +215,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
protected buildNoticeSourceKey(content: string): string {
|
||||
const normalized = (content || '').trim();
|
||||
return `notice:${createHash('sha1').update(normalized).digest('hex')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DoneHubAdapter } from './doneHub.js';
|
||||
|
||||
describe('DoneHubAdapter', () => {
|
||||
@@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/notice') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: 'Scheduled maintenance tonight',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
@@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => {
|
||||
expect(balance.used).toBe(60);
|
||||
expect(balance.quota).toBe(100);
|
||||
});
|
||||
|
||||
it('normalizes the global site notice from /api/notice', async () => {
|
||||
const adapter = new DoneHubAdapter();
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`,
|
||||
title: 'Site notice',
|
||||
content: 'Scheduled maintenance tonight',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { success: true, data: 'Scheduled maintenance tonight' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OneHubAdapter } from './oneHub.js';
|
||||
import type { BalanceInfo, CheckinResult } from './base.js';
|
||||
import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js';
|
||||
|
||||
export class DoneHubAdapter extends OneHubAdapter {
|
||||
readonly platformName: string = 'done-hub';
|
||||
@@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter {
|
||||
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
|
||||
const content = typeof payload?.data === 'string'
|
||||
? payload.data.trim()
|
||||
: (typeof payload === 'string' ? payload.trim() : '');
|
||||
if (!content) return [];
|
||||
return [{
|
||||
sourceKey: this.buildNoticeSourceKey(content),
|
||||
title: 'Site notice',
|
||||
content,
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: payload,
|
||||
}];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
||||
// No need to override here — OneHub's implementation handles this correctly.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { NewApiAdapter } from './newApi.js';
|
||||
|
||||
@@ -142,6 +143,15 @@ describe('NewApiAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/notice') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: 'Welcome to the site',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/token/')) {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
@@ -616,4 +626,20 @@ describe('NewApiAdapter', () => {
|
||||
expect(receivedHeaders['rix-api-user']).toBe('42');
|
||||
expect(receivedHeaders['neo-api-user']).toBe('42');
|
||||
});
|
||||
|
||||
it('normalizes the global site notice from /api/notice', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`,
|
||||
title: 'Site notice',
|
||||
content: 'Welcome to the site',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { success: true, data: 'Welcome to the site' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
|
||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js';
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { createContext, runInContext } from 'node:vm';
|
||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||
@@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
|
||||
const content = typeof payload?.data === 'string'
|
||||
? payload.data.trim()
|
||||
: (typeof payload === 'string' ? payload.trim() : '');
|
||||
if (!content) return [];
|
||||
return [{
|
||||
sourceKey: this.buildNoticeSourceKey(content),
|
||||
title: 'Site notice',
|
||||
content,
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: payload,
|
||||
}];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private tryDecodeUserId(token: string): number | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BasePlatformAdapter,
|
||||
type BalanceInfo,
|
||||
type CheckinResult,
|
||||
type PlatformAdapter,
|
||||
type SiteAnnouncement,
|
||||
} from './base.js';
|
||||
|
||||
class UnsupportedAnnouncementAdapter extends BasePlatformAdapter {
|
||||
override readonly platformName = 'unsupported';
|
||||
|
||||
override async detect(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
override async checkin(): Promise<CheckinResult> {
|
||||
return { success: false, message: 'unsupported' };
|
||||
}
|
||||
|
||||
override async getBalance(): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
override async getModels(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter {
|
||||
override readonly platformName = 'single-notice';
|
||||
|
||||
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
|
||||
return [{
|
||||
sourceKey: 'notice:welcome',
|
||||
title: 'Site notice',
|
||||
content: 'Welcome to the site',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { notice: 'Welcome to the site' },
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
class ListAnnouncementAdapter extends UnsupportedAnnouncementAdapter {
|
||||
override readonly platformName = 'list-notice';
|
||||
|
||||
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
|
||||
return [
|
||||
{
|
||||
sourceKey: 'announcement:1',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'warning',
|
||||
startsAt: '2026-03-20T10:00:00Z',
|
||||
upstreamCreatedAt: '2026-03-20T09:00:00Z',
|
||||
rawPayload: { id: 1 },
|
||||
},
|
||||
{
|
||||
sourceKey: 'announcement:2',
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
level: 'info',
|
||||
endsAt: '2026-03-21T00:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T12:00:00Z',
|
||||
rawPayload: { id: 2 },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function expectAnnouncementShape(row: SiteAnnouncement) {
|
||||
expect(row.sourceKey).toBeTruthy();
|
||||
expect(row.title).toBeTruthy();
|
||||
expect(row.content).toBeTruthy();
|
||||
expect(['info', 'warning', 'error']).toContain(row.level);
|
||||
}
|
||||
|
||||
describe('site announcement platform contract', () => {
|
||||
it('allows unsupported adapters to return an empty list', async () => {
|
||||
const adapter: PlatformAdapter = new UnsupportedAnnouncementAdapter();
|
||||
|
||||
await expect(adapter.getSiteAnnouncements('https://example.com', 'token')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes single-notice platforms into the shared announcement shape', async () => {
|
||||
const adapter: PlatformAdapter = new SingleNoticeAdapter();
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expectAnnouncementShape(rows[0]);
|
||||
});
|
||||
|
||||
it('normalizes list-style platforms into the shared announcement shape', async () => {
|
||||
const adapter: PlatformAdapter = new ListAnnouncementAdapter();
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
rows.forEach(expectAnnouncementShape);
|
||||
});
|
||||
});
|
||||
@@ -438,4 +438,79 @@ describe('Sub2ApiAdapter', () => {
|
||||
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
||||
expect(deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes announcements from /api/v1/announcements', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/announcements?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
starts_at: '2026-03-20T10:00:00Z',
|
||||
ends_at: '2026-03-20T12:00:00Z',
|
||||
created_at: '2026-03-20T09:00:00Z',
|
||||
updated_at: '2026-03-20T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
read_at: '2026-03-20T12:05:00Z',
|
||||
created_at: '2026-03-20T12:00:00Z',
|
||||
updated_at: '2026-03-20T12:01:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'jwt-token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
startsAt: '2026-03-20T10:00:00Z',
|
||||
endsAt: '2026-03-20T12:00:00Z',
|
||||
upstreamCreatedAt: '2026-03-20T09:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T09:30:00Z',
|
||||
rawPayload: {
|
||||
id: 11,
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
starts_at: '2026-03-20T10:00:00Z',
|
||||
ends_at: '2026-03-20T12:00:00Z',
|
||||
created_at: '2026-03-20T09:00:00Z',
|
||||
updated_at: '2026-03-20T09:30:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceKey: 'announcement:12',
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
level: 'info',
|
||||
upstreamCreatedAt: '2026-03-20T12:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T12:01:00Z',
|
||||
rawPayload: {
|
||||
id: 12,
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
read_at: '2026-03-20T12:05:00Z',
|
||||
created_at: '2026-03-20T12:00:00Z',
|
||||
updated_at: '2026-03-20T12:01:00Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckinResult,
|
||||
BalanceInfo,
|
||||
CreateApiTokenOptions,
|
||||
type SiteAnnouncement,
|
||||
UserInfo,
|
||||
} from './base.js';
|
||||
|
||||
@@ -486,6 +487,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const endpoint = '/api/v1/announcements?page=1&page_size=100';
|
||||
const res = await this.fetchJson<any>(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
|
||||
headers: this.buildAuthHeader(accessToken),
|
||||
});
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
const rawItems = Array.isArray(data)
|
||||
? data
|
||||
: (Array.isArray(data?.items) ? data.items : []);
|
||||
const rows: SiteAnnouncement[] = [];
|
||||
for (const item of rawItems) {
|
||||
const id = Number.parseInt(String(item?.id), 10);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const title = typeof item?.title === 'string' ? item.title.trim() : '';
|
||||
const content = typeof item?.content === 'string' ? item.content.trim() : '';
|
||||
if (!title && !content) continue;
|
||||
rows.push({
|
||||
sourceKey: `announcement:${id}`,
|
||||
title: title || `Announcement ${id}`,
|
||||
content: content || title,
|
||||
level: 'info',
|
||||
startsAt: typeof item?.starts_at === 'string' ? item.starts_at : undefined,
|
||||
endsAt: typeof item?.ends_at === 'string' ? item.ends_at : undefined,
|
||||
upstreamCreatedAt: typeof item?.created_at === 'string' ? item.created_at : undefined,
|
||||
upstreamUpdatedAt: typeof item?.updated_at === 'string' ? item.updated_at : undefined,
|
||||
rawPayload: item,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||
try {
|
||||
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const syncSiteAnnouncementsMock = vi.fn();
|
||||
|
||||
vi.mock('./siteAnnouncementService.js', () => ({
|
||||
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
|
||||
}));
|
||||
|
||||
describe('siteAnnouncementPollingService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
syncSiteAnnouncementsMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const module = await import('./siteAnnouncementPollingService.js');
|
||||
module.stopSiteAnnouncementPolling();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs one immediate sync and then continues on the configured interval', async () => {
|
||||
const module = await import('./siteAnnouncementPollingService.js');
|
||||
syncSiteAnnouncementsMock.mockResolvedValue(undefined);
|
||||
|
||||
module.startSiteAnnouncementPolling(60_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { syncSiteAnnouncements } from './siteAnnouncementService.js';
|
||||
|
||||
const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
let pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let syncRunning = false;
|
||||
|
||||
async function runSyncOnce() {
|
||||
if (syncRunning) return;
|
||||
syncRunning = true;
|
||||
try {
|
||||
await syncSiteAnnouncements();
|
||||
} catch (error) {
|
||||
console.error('[SiteAnnouncementPolling] Sync failed:', error);
|
||||
} finally {
|
||||
syncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startSiteAnnouncementPolling(intervalMs = DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS) {
|
||||
stopSiteAnnouncementPolling();
|
||||
pollingTimer = setInterval(() => {
|
||||
void runSyncOnce();
|
||||
}, Math.max(10_000, intervalMs));
|
||||
pollingTimer.unref?.();
|
||||
void runSyncOnce();
|
||||
return { intervalMs: Math.max(10_000, intervalMs) };
|
||||
}
|
||||
|
||||
export function stopSiteAnnouncementPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const getAdapterMock = vi.fn();
|
||||
const sendNotificationMock = vi.fn();
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: (...args: unknown[]) => getAdapterMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./notifyService.js', () => ({
|
||||
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ServiceModule = typeof import('./siteAnnouncementService.js');
|
||||
|
||||
describe('siteAnnouncementService', () => {
|
||||
let dataDir = '';
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let closeDbConnections: DbModule['closeDbConnections'];
|
||||
let syncSiteAnnouncements: ServiceModule['syncSiteAnnouncements'];
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-service-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const serviceModule = await import('./siteAnnouncementService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
closeDbConnections = dbModule.closeDbConnections;
|
||||
syncSiteAnnouncements = serviceModule.syncSiteAnnouncements;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
getAdapterMock.mockReset();
|
||||
sendNotificationMock.mockReset();
|
||||
|
||||
await db.delete(schema.siteAnnouncements).run();
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
if (typeof closeDbConnections === 'function') {
|
||||
await closeDbConnections();
|
||||
}
|
||||
if (dataDir) {
|
||||
try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('stores first-seen announcements, creates one event, and sends one notification', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'demo-user',
|
||||
accessToken: 'jwt-token',
|
||||
status: 'active',
|
||||
}).run();
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'Unsupported Site',
|
||||
url: 'https://unsupported.example.com',
|
||||
platform: 'openai',
|
||||
status: 'active',
|
||||
}).run();
|
||||
|
||||
getAdapterMock.mockImplementation((platform: string) => {
|
||||
if (platform === 'sub2api') {
|
||||
return {
|
||||
getSiteAnnouncements: vi.fn(async (_baseUrl: string, accessToken: string) => {
|
||||
expect(accessToken).toBe('jwt-token');
|
||||
return [{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
rawPayload: { id: 11, title: 'Maintenance' },
|
||||
}];
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
getSiteAnnouncements: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await syncSiteAnnouncements();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedSites: 2,
|
||||
inserted: 1,
|
||||
updated: 0,
|
||||
unsupported: 1,
|
||||
notifications: 1,
|
||||
events: 1,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
|
||||
expect(announcementRows).toHaveLength(1);
|
||||
expect(announcementRows[0]).toMatchObject({
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
});
|
||||
|
||||
const eventRows = await db.select().from(schema.events).all();
|
||||
expect(eventRows).toHaveLength(1);
|
||||
expect(eventRows[0]).toMatchObject({
|
||||
type: 'site_notice',
|
||||
relatedType: 'site_announcement',
|
||||
});
|
||||
expect(Number(eventRows[0]?.relatedId)).toBe(Number(announcementRows[0]?.id));
|
||||
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('Sub Site');
|
||||
expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('Window starts at 10:00');
|
||||
expect(sendNotificationMock.mock.calls[0]?.[2]).toBe('info');
|
||||
});
|
||||
|
||||
it('updates existing announcements without duplicating events or notifications', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'demo-user',
|
||||
accessToken: 'jwt-token',
|
||||
status: 'active',
|
||||
}).run();
|
||||
|
||||
getAdapterMock.mockReturnValue({
|
||||
getSiteAnnouncements: vi.fn(async () => [{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
rawPayload: { id: 11, title: 'Maintenance' },
|
||||
}]),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
|
||||
await syncSiteAnnouncements({ siteId: site.id });
|
||||
const firstRow = await db.select().from(schema.siteAnnouncements).get();
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-20T11:00:00Z'));
|
||||
const result = await syncSiteAnnouncements({ siteId: site.id });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedSites: 1,
|
||||
inserted: 0,
|
||||
updated: 1,
|
||||
notifications: 0,
|
||||
events: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
|
||||
expect(announcementRows).toHaveLength(1);
|
||||
expect(announcementRows[0]?.firstSeenAt).toBe(firstRow?.firstSeenAt);
|
||||
expect(announcementRows[0]?.lastSeenAt).not.toBe(firstRow?.lastSeenAt);
|
||||
|
||||
const eventRows = await db.select().from(schema.events).all();
|
||||
expect(eventRows).toHaveLength(1);
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||
import type { SiteAnnouncement } from './platforms/base.js';
|
||||
|
||||
export type SiteAnnouncementSyncResult = {
|
||||
scannedSites: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
unsupported: number;
|
||||
notifications: number;
|
||||
events: number;
|
||||
failed: number;
|
||||
failedSites: Array<{ siteId: number; siteName: string; message: string }>;
|
||||
};
|
||||
|
||||
function toStoredPayload(rawPayload: unknown): string | null {
|
||||
if (rawPayload === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(rawPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnouncementMessage(row: SiteAnnouncement): string {
|
||||
const title = String(row.title || '').trim();
|
||||
const content = String(row.content || '').trim();
|
||||
if (title && content && title !== content && title.toLowerCase() !== 'site notice') {
|
||||
return `${title}\n${content}`;
|
||||
}
|
||||
return content || title;
|
||||
}
|
||||
|
||||
async function resolveSiteAccessToken(siteId: number, siteApiKey?: string | null): Promise<string> {
|
||||
const direct = String(siteApiKey || '').trim();
|
||||
if (direct) return direct;
|
||||
|
||||
const account = await db.select()
|
||||
.from(schema.accounts)
|
||||
.where(and(
|
||||
eq(schema.accounts.siteId, siteId),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
))
|
||||
.orderBy(asc(schema.accounts.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return String(account?.accessToken || '').trim();
|
||||
}
|
||||
|
||||
async function listTargetSites(siteId?: number | null) {
|
||||
if (siteId && Number.isFinite(siteId) && siteId > 0) {
|
||||
return await db.select()
|
||||
.from(schema.sites)
|
||||
.where(eq(schema.sites.id, siteId))
|
||||
.all();
|
||||
}
|
||||
|
||||
return await db.select()
|
||||
.from(schema.sites)
|
||||
.where(eq(schema.sites.status, 'active'))
|
||||
.all();
|
||||
}
|
||||
|
||||
export async function syncSiteAnnouncements(options?: { siteId?: number | null }): Promise<SiteAnnouncementSyncResult> {
|
||||
const result: SiteAnnouncementSyncResult = {
|
||||
scannedSites: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
unsupported: 0,
|
||||
notifications: 0,
|
||||
events: 0,
|
||||
failed: 0,
|
||||
failedSites: [],
|
||||
};
|
||||
|
||||
const sites = await listTargetSites(options?.siteId ?? null);
|
||||
|
||||
for (const site of sites) {
|
||||
result.scannedSites += 1;
|
||||
const adapter = getAdapter(String(site.platform || ''));
|
||||
if (!adapter || typeof adapter.getSiteAnnouncements !== 'function') {
|
||||
result.unsupported += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveSiteAccessToken(site.id, site.apiKey);
|
||||
const announcements = await adapter.getSiteAnnouncements(site.url, accessToken);
|
||||
const seenAt = formatUtcSqlDateTime(new Date());
|
||||
|
||||
for (const announcement of announcements) {
|
||||
const existing = await db.select()
|
||||
.from(schema.siteAnnouncements)
|
||||
.where(and(
|
||||
eq(schema.siteAnnouncements.siteId, site.id),
|
||||
eq(schema.siteAnnouncements.sourceKey, announcement.sourceKey),
|
||||
))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const patch = {
|
||||
platform: String(site.platform || '').trim(),
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
level: announcement.level,
|
||||
sourceUrl: announcement.sourceUrl ?? null,
|
||||
startsAt: announcement.startsAt ?? null,
|
||||
endsAt: announcement.endsAt ?? null,
|
||||
upstreamCreatedAt: announcement.upstreamCreatedAt ?? null,
|
||||
upstreamUpdatedAt: announcement.upstreamUpdatedAt ?? null,
|
||||
lastSeenAt: seenAt,
|
||||
rawPayload: toStoredPayload(announcement.rawPayload),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db.update(schema.siteAnnouncements)
|
||||
.set(patch)
|
||||
.where(eq(schema.siteAnnouncements.id, existing.id))
|
||||
.run();
|
||||
result.updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(schema.siteAnnouncements).values({
|
||||
siteId: site.id,
|
||||
sourceKey: announcement.sourceKey,
|
||||
firstSeenAt: seenAt,
|
||||
...patch,
|
||||
}).returning().get();
|
||||
result.inserted += 1;
|
||||
|
||||
const title = `站点公告:${site.name}`;
|
||||
const message = buildAnnouncementMessage(announcement);
|
||||
await db.insert(schema.events).values({
|
||||
type: 'site_notice',
|
||||
title,
|
||||
message,
|
||||
level: announcement.level,
|
||||
relatedId: inserted.id,
|
||||
relatedType: 'site_announcement',
|
||||
createdAt: seenAt,
|
||||
}).run();
|
||||
result.events += 1;
|
||||
|
||||
await sendNotification(title, message, announcement.level);
|
||||
result.notifications += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
result.failedSites.push({
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
message: error instanceof Error && error.message ? error.message : 'unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user