add site announcements

This commit is contained in:
cita
2026-03-20 11:24:29 +08:00
parent 5a9f007a75
commit 940bc1d71c
35 changed files with 1831 additions and 15 deletions
+28
View File
@@ -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' },
},
]);
});
});
+21 -1
View File
@@ -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' },
},
]);
});
});
+21 -1
View File
@@ -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',
},
},
]);
});
});
+36
View File
@@ -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);