feat(contracts): share notification, organization and device capability models

Move the Hub channel table, organization tags and device capability definitions
into the workspace packages so the control plane and the console validate the
same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
chick
2026-09-05 18:52:54 +08:00
parent 03fa2e6c7b
commit f11877f13e
19 changed files with 15535 additions and 11768 deletions
+186 -21
View File
@@ -1,7 +1,13 @@
import type { PageEnvelope, PageQuery } from './instances.js';
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
export const SCHEDULED_OPERATION_TYPES = ['restart-service', 'reboot-system', 'send-sms'] as const;
export const SCHEDULED_OPERATION_TYPES = [
'restart-service',
'reboot-system',
'restart-baseband',
'send-sms',
'backup-data',
] as const;
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
export const SCHEDULED_RUN_OUTCOMES = [
@@ -13,16 +19,35 @@ export const SCHEDULED_RUN_OUTCOMES = [
'needs-attention',
] as const;
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
export const SCHEDULE_TRIGGER_KINDS = ['cron', 'fixed', 'interval'] as const;
export const SCHEDULE_INTERVAL_UNITS = ['mins', 'hours', 'days'] as const;
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
export type ScheduleTriggerKind = (typeof SCHEDULE_TRIGGER_KINDS)[number];
export type ScheduleIntervalUnit = (typeof SCHEDULE_INTERVAL_UNITS)[number];
export type ScheduleTargetSelector =
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] };
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
| { readonly mode: 'group'; readonly groupId: string }
| { readonly mode: 'all' };
/**
* Hub-style scheduling vocabulary. `cron` keeps the existing five-field engine, `fixed` is a set of
* weekday/time pairs, and `interval` is a plain wall-clock period.
*/
export type ScheduleTrigger =
| { readonly kind: 'cron'; readonly expression: string }
| {
readonly kind: 'fixed';
readonly weekdays: readonly number[];
readonly times: readonly string[];
}
| { readonly kind: 'interval'; readonly value: number; readonly unit: ScheduleIntervalUnit };
export interface ScheduleRetryPolicy {
readonly maxRetries: number;
@@ -32,6 +57,8 @@ export interface ScheduleRetryPolicy {
export interface ScheduledSmsInput {
readonly recipients: readonly string[];
readonly content: string;
/** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */
readonly randomDelaySeconds?: number;
}
export interface ScheduledSmsSummary {
@@ -42,10 +69,13 @@ export interface ScheduledSmsSummary {
export interface CreateScheduledTaskRequest {
readonly name: string;
readonly operationType: ScheduledOperationType;
/** Optional so legacy cron-only callers keep working; parsers always fill it in. */
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput;
readonly delaySeconds?: number;
readonly effectiveStartAt?: string;
readonly effectiveEndAt?: string;
readonly misfirePolicy: ScheduleMisfirePolicy;
@@ -58,10 +88,12 @@ export interface UpdateScheduledTaskRequest {
readonly version: number;
readonly name?: string;
readonly operationType?: ScheduledOperationType;
readonly trigger?: ScheduleTrigger;
readonly cronExpression?: string;
readonly timezone?: typeof AUTOMATION_TIMEZONE;
readonly targetSelector?: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput | null;
readonly delaySeconds?: number | null;
readonly effectiveStartAt?: string | null;
readonly effectiveEndAt?: string | null;
readonly misfirePolicy?: ScheduleMisfirePolicy;
@@ -74,10 +106,12 @@ export interface ScheduledTask {
readonly id: string;
readonly name: string;
readonly operationType: ScheduledOperationType;
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsSummary;
readonly delaySeconds?: number;
readonly effectiveStartAt?: string;
readonly effectiveEndAt?: string;
readonly misfirePolicy: ScheduleMisfirePolicy;
@@ -130,10 +164,12 @@ export interface CronPreview {
const CREATE_KEYS = new Set([
'name',
'operationType',
'trigger',
'cronExpression',
'timezone',
'targetSelector',
'sms',
'delaySeconds',
'effectiveStartAt',
'effectiveEndAt',
'misfirePolicy',
@@ -204,42 +240,147 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
tags: strings(source.tags, 'tags', 200),
};
}
if (source.mode === 'group') {
exactKeys(source, new Set(['mode', 'groupId']));
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
}
if (source.mode === 'all') {
exactKeys(source, new Set(['mode']));
return { mode: 'all' };
}
throw new TypeError('targetSelector mode is invalid');
}
const INTERVAL_UNIT_MS: Record<ScheduleIntervalUnit, number> = {
mins: 60_000,
hours: 3_600_000,
days: 86_400_000,
};
export function intervalMilliseconds(
trigger: Extract<ScheduleTrigger, { kind: 'interval' }>,
): number {
return trigger.value * INTERVAL_UNIT_MS[trigger.unit];
}
function timeOfDay(value: unknown): string {
const raw = string(value, 'time', 5);
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(raw);
const hour = match?.[1];
const minute = match?.[2];
if (!hour || !minute) throw new TypeError('time must use HH:MM syntax');
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
}
function weekdays(value: unknown): number[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 7)
throw new TypeError('weekdays must contain 1-7 values');
const result = value.map((item) => {
if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 7)
throw new TypeError('weekdays must be between Monday (1) and Sunday (7)');
return item as number;
});
if (new Set(result).size !== result.length) throw new TypeError('weekdays must be unique');
return result.sort((left, right) => left - right);
}
function times(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 48)
throw new TypeError('times must contain 1-48 values');
const result = value.map(timeOfDay);
if (new Set(result).size !== result.length) throw new TypeError('times must be unique');
return result.sort((left, right) => left.localeCompare(right));
}
export function parseScheduleTrigger(value: unknown): ScheduleTrigger {
const source = object(value, 'trigger');
if (source.kind === 'cron') {
exactKeys(source, new Set(['kind', 'expression']));
return { kind: 'cron', expression: cronExpression(source.expression) };
}
if (source.kind === 'fixed') {
exactKeys(source, new Set(['kind', 'weekdays', 'times']));
return { kind: 'fixed', weekdays: weekdays(source.weekdays), times: times(source.times) };
}
if (source.kind === 'interval') {
exactKeys(source, new Set(['kind', 'value', 'unit']));
if (!Number.isSafeInteger(source.value) || (source.value as number) < 1)
throw new TypeError('interval value must be a positive integer');
if ((source.value as number) > 10_000)
throw new TypeError('interval value must be at most 10000');
return {
kind: 'interval',
value: source.value as number,
unit: member(source.unit, SCHEDULE_INTERVAL_UNITS, 'interval unit'),
};
}
throw new TypeError('trigger kind is invalid');
}
/**
* Canonical five-field cron kept for the legacy `cron_expression` column and for display. Only the
* `cron` kind is exact; `fixed` collapses to its earliest pair and `interval` to its step form.
*/
export function deriveCronExpression(trigger: ScheduleTrigger): string {
if (trigger.kind === 'cron') return trigger.expression;
if (trigger.kind === 'interval') {
if (trigger.unit === 'mins')
return trigger.value === 1 ? '* * * * *' : `*/${trigger.value} * * * *`;
if (trigger.unit === 'hours')
return trigger.value === 1 ? '0 * * * *' : `0 */${trigger.value} * * *`;
return trigger.value === 1 ? '0 0 * * *' : `0 0 */${trigger.value} * *`;
}
const earliest = trigger.times[0] ?? '00:00';
const [hour, minute] = earliest.split(':');
const days = trigger.weekdays.length === 7 ? '*' : trigger.weekdays.join(',');
return `${Number(minute)} ${Number(hour)} * * ${days}`;
}
/** Resolve the effective trigger for a stored or legacy task. */
export function scheduleTriggerOf(task: {
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
}): ScheduleTrigger {
return task.trigger ?? { kind: 'cron', expression: task.cronExpression };
}
function sms(value: unknown): ScheduledSmsInput {
const source = object(value, 'sms');
exactKeys(source, new Set(['recipients', 'content']));
exactKeys(source, new Set(['recipients', 'content', 'randomDelaySeconds']));
const randomDelaySeconds =
source.randomDelaySeconds === undefined
? undefined
: numberInRange(source.randomDelaySeconds, 'sms.randomDelaySeconds', 0, 600);
return {
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
return recipient;
}),
content: string(source.content, 'SMS content', 2_000),
...(randomDelaySeconds === undefined ? {} : { randomDelaySeconds }),
};
}
function numberInRange(value: unknown, label: string, minimum: number, maximum: number): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum)
throw new TypeError(`${label} must be between ${minimum} and ${maximum}`);
return value as number;
}
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
const source = object(value, 'retryPolicy');
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
const maxRetries = source.maxRetries;
const intervalSeconds = source.intervalSeconds;
if (
!Number.isSafeInteger(maxRetries) ||
(maxRetries as number) < 0 ||
(maxRetries as number) > 10
)
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
if (
!Number.isSafeInteger(intervalSeconds) ||
(intervalSeconds as number) < 1 ||
(intervalSeconds as number) > 86_400
)
throw new TypeError('retryPolicy.intervalSeconds must be between 1 and 86400');
if (operationType === 'reboot-system' && (maxRetries as number) > 0)
const maxRetries = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10);
const intervalSeconds = numberInRange(
source.intervalSeconds,
'retryPolicy.intervalSeconds',
1,
86_400,
);
if (operationType === 'reboot-system' && maxRetries > 0)
throw new TypeError('System reboot automatic retries are not supported');
return { maxRetries: maxRetries as number, intervalSeconds: intervalSeconds as number };
return { maxRetries, intervalSeconds };
}
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
@@ -248,6 +389,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
if (source.trigger === undefined && source.cronExpression === undefined)
throw new TypeError('trigger or cronExpression is required');
const trigger =
source.trigger === undefined
? ({ kind: 'cron', expression: cronExpression(source.cronExpression) } as const)
: parseScheduleTrigger(source.trigger);
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
if (operationType === 'send-sms' && !smsValue)
throw new TypeError('SMS configuration is required');
@@ -265,13 +412,21 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
throw new TypeError('enabled must be boolean');
const delaySeconds =
source.delaySeconds === undefined
? undefined
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
if (delaySeconds !== undefined && operationType !== 'reboot-system')
throw new TypeError('delaySeconds is only valid for reboot-system');
return {
name: string(source.name, 'name', 120),
operationType,
cronExpression: cronExpression(source.cronExpression),
trigger,
cronExpression: deriveCronExpression(trigger),
timezone: AUTOMATION_TIMEZONE,
targetSelector: targetSelector(source.targetSelector),
...(smsValue ? { sms: smsValue } : {}),
...(delaySeconds === undefined ? {} : { delaySeconds }),
...(effectiveStartAt ? { effectiveStartAt } : {}),
...(effectiveEndAt ? { effectiveEndAt } : {}),
misfirePolicy:
@@ -299,8 +454,13 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
if (source.operationType !== undefined)
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
if (source.cronExpression !== undefined)
if (source.trigger !== undefined) {
result.trigger = parseScheduleTrigger(source.trigger);
result.cronExpression = deriveCronExpression(result.trigger as ScheduleTrigger);
} else if (source.cronExpression !== undefined) {
result.cronExpression = cronExpression(source.cronExpression);
result.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger;
}
if (source.timezone !== undefined) {
if (source.timezone !== AUTOMATION_TIMEZONE)
throw new TypeError('timezone must be Asia/Shanghai');
@@ -309,6 +469,11 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
if (source.targetSelector !== undefined)
result.targetSelector = targetSelector(source.targetSelector);
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
if (source.delaySeconds !== undefined)
result.delaySeconds =
source.delaySeconds === null
? null
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
if (source.effectiveStartAt !== undefined)
result.effectiveStartAt =
source.effectiveStartAt === null
+2
View File
@@ -4,4 +4,6 @@ export * from './errors.js';
export * from './instances.js';
export * from './jobs.js';
export * from './operations.js';
export * from './organization.js';
export * from './notifications.js';
export const contractsWorkspaceReady = true;
+4
View File
@@ -41,12 +41,14 @@ export interface InstanceInput {
readonly name: string;
readonly origin: string;
readonly tags?: readonly string[];
readonly groupId?: string | null;
readonly password?: PasswordUpdate;
}
export interface InstancePatch {
readonly name?: string;
readonly origin?: string;
readonly tags?: readonly string[];
readonly groupId?: string | null;
readonly password?: PasswordUpdate;
}
export interface LoginInput {
@@ -58,6 +60,7 @@ export interface Instance {
readonly name: string;
readonly origin: string;
readonly tags: readonly string[];
readonly groupId: string | null;
readonly revision: Revision;
readonly capabilityStatus: CapabilityStatus;
readonly freshness: SnapshotFreshness;
@@ -68,6 +71,7 @@ export interface InstanceFilters {
readonly capabilityStatus?: CapabilityStatus;
readonly freshness?: SnapshotFreshness;
readonly tag?: string;
readonly groupId?: string;
readonly credentialConfigured?: boolean;
}
export type InstancePageQuery = PageQuery<'name' | 'status' | 'freshness' | 'updatedAt'> &
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import {
NOTIFICATION_CHANNEL_SPECS,
NotificationChannelConfigError,
isNotificationChannelType,
notificationChannelDefaults,
notificationChannelFields,
notificationChannelLabel,
notificationChannelSecretKeys,
notificationChannelSpec,
parseKeyValueField,
splitNotificationChannelConfig,
} from './notifications.js';
describe('notification channel specs', () => {
it('describes every Hub channel type exactly once', () => {
const types = NOTIFICATION_CHANNEL_SPECS.map((spec) => spec.type);
expect(new Set(types).size).toBe(types.length);
expect(types).toEqual([
'webhook',
'bark',
'pushplus',
'wecom_app',
'wecom_robot',
'dingtalk_robot',
'dingtalk_app',
'feishu_robot',
'telegram',
'email',
'serverchan',
]);
expect(NOTIFICATION_CHANNEL_SPECS.every((spec) => spec.summary.length > 0)).toBe(true);
});
it('keeps field keys unique and every field kind renderable', () => {
for (const spec of NOTIFICATION_CHANNEL_SPECS) {
const keys = spec.fields.map((field) => field.key);
expect(new Set(keys).size).toBe(keys.length);
for (const field of spec.fields) {
expect([
'text',
'url',
'secret',
'number',
'select',
'toggle',
'key-values',
] as const).toContain(field.kind);
expect(field.label.length).toBeGreaterThan(0);
if (field.kind === 'select') expect((field.options ?? []).length).toBeGreaterThan(0);
}
}
});
it('looks specs up by type and tolerates unknown input', () => {
expect(notificationChannelSpec('telegram')?.label).toBe('Telegram');
expect(notificationChannelSpec('nope')).toBeUndefined();
expect(isNotificationChannelType('bark')).toBe(true);
expect(isNotificationChannelType('Bark')).toBe(false);
expect(notificationChannelLabel('nope')).toBe('nope');
expect(notificationChannelFields('nope')).toEqual([]);
});
});
describe('splitNotificationChannelConfig', () => {
it('fills defaults and separates secrets from plain config', () => {
const { config, secrets } = splitNotificationChannelConfig('bark', {
server_url: 'https://bark.example.test',
device_key: ' key-1 ',
group: 'Island',
});
expect(config).toEqual({
server_url: 'https://bark.example.test',
group: 'Island',
sound: '',
level: '',
icon: '',
auto_copy: true,
save_history: true,
});
expect(secrets).toEqual({ device_key: 'key-1' });
expect(notificationChannelSecretKeys('bark')).toEqual(['device_key']);
});
it('carries a stored secret forward when the editor leaves it blank', () => {
const { config, secrets } = splitNotificationChannelConfig(
'telegram',
{ bot_token: '', chat_id: '42' },
{ bot_token: 'stored-token' },
);
expect(secrets).toEqual({ bot_token: 'stored-token' });
expect(config['chat_id']).toBe('42');
});
it('names the missing required fields in the local language', () => {
expect(() => splitNotificationChannelConfig('serverchan', {})).toThrowError(
NotificationChannelConfigError,
);
try {
splitNotificationChannelConfig('serverchan', {});
} catch (error) {
const failure = error as NotificationChannelConfigError;
expect(failure.fields).toEqual(['send_key']);
expect(failure.message).toContain('SendKey');
}
});
it('validates numeric ranges and select membership', () => {
const relay = {
smtp_host: 'smtp.example.test',
sender_address: 'a@example.test',
receiver_addresses: 'b@example.test',
};
expect(() => splitNotificationChannelConfig('email', { ...relay, smtp_port: 99_999 })).toThrow(
/SMTP /u,
);
expect(() =>
splitNotificationChannelConfig('bark', { device_key: 'k', level: 'loud' }),
).toThrow(//u);
expect(
splitNotificationChannelConfig('email', {
...relay,
smtp_port: '587',
smtp_security: 'starttls',
}).config['smtp_port'],
).toBe(587);
});
it('rejects unknown channel types instead of guessing a schema', () => {
expect(() => splitNotificationChannelConfig('sms' as never, {} as never)).toThrowError(
NotificationChannelConfigError,
);
});
it('keeps declared defaults for untouched fields', () => {
expect(notificationChannelDefaults('webhook')).toEqual({
url: '',
http_method: 'POST',
headers: '',
});
expect(notificationChannelFields('webhook').map((field) => field.kind)).toEqual([
'url',
'select',
'secret',
'key-values',
]);
});
});
describe('parseKeyValueField', () => {
it('reads colon lines and ignores anything malformed', () => {
expect(parseKeyValueField('X-A: 1\n\nno-separator\nX-B: a/b \n:c-empty')).toEqual({
'X-A': '1',
'X-B': 'a/b',
});
});
it('returns nothing for non-text input', () => {
expect(parseKeyValueField(undefined)).toEqual({});
expect(parseKeyValueField({})).toEqual({});
});
});
+484
View File
@@ -0,0 +1,484 @@
/**
* Notification channel contract shared by the control plane and the console.
*
* The field tables mirror the official SimAdminHub channel configuration so a
* channel created here speaks the same wire format as one created there.
*/
export const NOTIFICATION_CHANNEL_TYPES = [
'webhook',
'bark',
'pushplus',
'wecom_app',
'wecom_robot',
'dingtalk_robot',
'dingtalk_app',
'feishu_robot',
'telegram',
'email',
'serverchan',
] as const;
export type NotificationChannelType = (typeof NOTIFICATION_CHANNEL_TYPES)[number];
export const NOTIFICATION_EVENT_TYPES = [
'sms',
'ddns',
'version',
'system',
'device',
'automation',
] as const;
export type NotificationChannelFieldKind =
| 'text'
| 'url'
| 'secret'
| 'number'
| 'select'
| 'toggle'
| 'key-values';
export interface NotificationChannelFieldOption {
readonly value: string;
readonly label: string;
}
export interface NotificationChannelFieldSpec {
readonly key: string;
readonly label: string;
readonly kind: NotificationChannelFieldKind;
readonly required?: boolean;
readonly default?: string | number | boolean;
readonly options?: readonly NotificationChannelFieldOption[];
readonly placeholder?: string;
readonly help?: string;
readonly maxLength?: number;
readonly minimum?: number;
readonly maximum?: number;
/** Only meaningful for `url` fields: the value is joined with a path segment. */
readonly trailingPath?: boolean;
}
export interface NotificationChannelTypeSpec {
readonly type: NotificationChannelType;
readonly label: string;
readonly summary: string;
readonly fields: readonly NotificationChannelFieldSpec[];
}
const SELECT_LEVEL = [
{ value: '', label: '默认' },
{ value: 'passive', label: 'passive' },
{ value: 'active', label: 'active' },
{ value: 'timeSensitive', label: 'timeSensitive' },
] as const;
export const NOTIFICATION_CHANNEL_SPECS: readonly NotificationChannelTypeSpec[] = [
{
type: 'webhook',
label: 'Webhook',
summary: '向自定义地址投递 JSON,可选 HMAC 签名校验。',
fields: [
{ key: 'url', label: '回调地址', kind: 'url', required: true, maxLength: 2000 },
{
key: 'http_method',
label: '请求方法',
kind: 'select',
default: 'POST',
options: [
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'PATCH', label: 'PATCH' },
],
},
{
key: 'secret',
label: '签名密钥',
kind: 'secret',
maxLength: 4096,
help: '填写后随请求附带 X-Hub-Timestamp 与 X-Hub-Signature。',
},
{
key: 'headers',
label: '附加请求头',
kind: 'key-values',
maxLength: 2000,
placeholder: 'Authorization: Bearer ...',
},
],
},
{
type: 'bark',
label: 'Bark',
summary: '投递到 Bark 服务端,支持分组、铃声与推送级别。',
fields: [
{ key: 'server_url', label: '服务器地址', kind: 'url', default: 'https://api.day.app' },
{ key: 'device_key', label: '设备 Key', kind: 'secret', required: true, maxLength: 512 },
{ key: 'group', label: '分组', kind: 'text', maxLength: 160 },
{ key: 'sound', label: '铃声', kind: 'text', maxLength: 80 },
{ key: 'level', label: '推送级别', kind: 'select', default: '', options: SELECT_LEVEL },
{ key: 'icon', label: '图标地址', kind: 'url', maxLength: 2000 },
{ key: 'auto_copy', label: '自动复制', kind: 'toggle', default: true },
{ key: 'save_history', label: '保存历史', kind: 'toggle', default: true },
],
},
{
type: 'pushplus',
label: 'PushPlus',
summary: '通过 pushplus.plus 发送微信推送,支持 HTML 与话题分组。',
fields: [
{ key: 'token', label: 'Token', kind: 'secret', required: true, maxLength: 512 },
{ key: 'topic', label: '话题', kind: 'text', maxLength: 160 },
{
key: 'template',
label: '消息模板',
kind: 'select',
default: 'txt',
options: [
{ value: 'txt', label: 'txt' },
{ value: 'html', label: 'html' },
{ value: 'json', label: 'json' },
{ value: 'cloudPrint', label: '云打印' },
],
},
{ key: 'channel', label: '发送渠道', kind: 'text', maxLength: 80 },
{ key: 'callback_url', label: '回调地址', kind: 'url', maxLength: 2000 },
],
},
{
type: 'wecom_app',
label: '企业微信应用',
summary: '使用企业微信自建应用接口,按成员、部门或标签投递。',
fields: [
{
key: 'api_base_url',
label: '接口地址',
kind: 'url',
default: 'https://qyapi.weixin.qq.com',
},
{ key: 'corp_id', label: '企业 ID', kind: 'text', required: true, maxLength: 160 },
{ key: 'agent_id', label: '应用 AgentId', kind: 'text', required: true, maxLength: 80 },
{ key: 'secret', label: '应用 Secret', kind: 'secret', required: true, maxLength: 512 },
{ key: 'to_user', label: '接收成员', kind: 'text', default: '@all', maxLength: 1000 },
{ key: 'to_party', label: '接收部门', kind: 'text', maxLength: 500 },
{ key: 'to_tag', label: '接收标签', kind: 'text', maxLength: 500 },
{ key: 'safe', label: '仅接收方可读', kind: 'toggle', default: false },
],
},
{
type: 'wecom_robot',
label: '企业微信群机器人',
summary: '直接向群机器人 Webhook 投递 Markdown 消息。',
fields: [
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
{ key: 'key', label: '密钥', kind: 'secret', maxLength: 512 },
],
},
{
type: 'dingtalk_robot',
label: '钉钉群机器人',
summary: '支持加签校验的钉钉自定义机器人,可 @ 指定手机号。',
fields: [
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
{ key: 'access_token', label: 'Access Token', kind: 'secret', maxLength: 512 },
{ key: 'secret', label: '加签密钥', kind: 'secret', maxLength: 512 },
{ key: 'at_mobiles', label: '@ 手机号', kind: 'text', maxLength: 1000 },
{ key: 'at_all', label: '@ 所有人', kind: 'toggle', default: false },
],
},
{
type: 'dingtalk_app',
label: '钉钉应用',
summary: '使用钉钉企业内部机器人向指定会话发送消息。',
fields: [
{ key: 'app_key', label: 'App Key', kind: 'secret', required: true, maxLength: 512 },
{ key: 'app_secret', label: 'App Secret', kind: 'secret', required: true, maxLength: 512 },
{ key: 'robot_code', label: 'Robot Code', kind: 'text', required: true, maxLength: 160 },
{
key: 'open_conversation_id',
label: '会话 ID',
kind: 'text',
required: true,
maxLength: 160,
},
{
key: 'msg_key',
label: '消息类型',
kind: 'select',
default: 'sampleText',
options: [
{ value: 'sampleText', label: 'sampleText' },
{ value: 'sampleMarkdown', label: 'sampleMarkdown' },
],
},
],
},
{
type: 'feishu_robot',
label: '飞书群机器人',
summary: '飞书自定义机器人,支持签名校验与关键词。',
fields: [
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
{ key: 'token', label: 'Token', kind: 'secret', maxLength: 512 },
{ key: 'secret', label: '加签密钥', kind: 'secret', maxLength: 512 },
],
},
{
type: 'telegram',
label: 'Telegram',
summary: '通过 Bot API 向指定聊天发送消息,可指向自建中转地址。',
fields: [
{ key: 'api_base_url', label: '接口地址', kind: 'url', default: 'https://api.telegram.org' },
{ key: 'bot_token', label: 'Bot Token', kind: 'secret', required: true, maxLength: 512 },
{ key: 'chat_id', label: 'Chat ID', kind: 'text', required: true, maxLength: 160 },
{
key: 'parse_mode',
label: '解析模式',
kind: 'select',
default: '',
options: [
{ value: '', label: '纯文本' },
{ value: 'Markdown', label: 'Markdown' },
{ value: 'MarkdownV2', label: 'MarkdownV2' },
{ value: 'HTML', label: 'HTML' },
],
},
{ key: 'disable_web_page_preview', label: '关闭网页预览', kind: 'toggle', default: true },
],
},
{
type: 'email',
label: '邮件',
summary: '通过 SMTP 投递,支持隐式 TLS、STARTTLS 与明文端口。',
fields: [
{ key: 'smtp_host', label: 'SMTP 地址', kind: 'text', required: true, maxLength: 255 },
{
key: 'smtp_port',
label: 'SMTP 端口',
kind: 'number',
default: 465,
minimum: 1,
maximum: 65_535,
},
{
key: 'smtp_security',
label: '传输加密',
kind: 'select',
default: 'implicit_tls',
options: [
{ value: 'implicit_tls', label: '隐式 TLS' },
{ value: 'starttls', label: 'STARTTLS' },
{ value: 'none', label: '不加密' },
],
},
{ key: 'allow_insecure_tls', label: '允许自签证书', kind: 'toggle', default: false },
{ key: 'username', label: '登录账号', kind: 'text', maxLength: 255 },
{ key: 'password', label: '登录密码', kind: 'secret', maxLength: 512 },
{ key: 'sender_address', label: '发件地址', kind: 'text', required: true, maxLength: 255 },
{ key: 'sender_name', label: '发件人名称', kind: 'text', maxLength: 160 },
{
key: 'receiver_addresses',
label: '收件地址',
kind: 'text',
required: true,
maxLength: 2000,
placeholder: 'a@example.com, b@example.com',
},
{
key: 'message_format',
label: '正文格式',
kind: 'select',
default: 'plain',
options: [
{ value: 'plain', label: '纯文本' },
{ value: 'html', label: 'HTML' },
],
},
],
},
{
type: 'serverchan',
label: 'Server 酱',
summary: '兼容 Server 酱³ 与经典 SendKey 两种推送地址。',
fields: [
{ key: 'send_key', label: 'SendKey', kind: 'secret', required: true, maxLength: 512 },
{ key: 'uid', label: 'UID', kind: 'text', maxLength: 160 },
{ key: 'channel', label: '渠道', kind: 'text', maxLength: 80 },
{ key: 'openid', label: 'OpenID', kind: 'text', maxLength: 160 },
],
},
] as const;
export type NotificationChannelConfigValue = string | number | boolean;
export type NotificationChannelConfigMap = Readonly<Record<string, NotificationChannelConfigValue>>;
const SPEC_INDEX = new Map<string, NotificationChannelTypeSpec>(
NOTIFICATION_CHANNEL_SPECS.map((spec) => [spec.type, spec]),
);
export function isNotificationChannelType(value: unknown): value is NotificationChannelType {
return typeof value === 'string' && SPEC_INDEX.has(value);
}
export function notificationChannelSpec(
type: NotificationChannelType | string,
): NotificationChannelTypeSpec | undefined {
return SPEC_INDEX.get(type);
}
export function notificationChannelLabel(type: string): string {
return notificationChannelSpec(type)?.label ?? type;
}
export function notificationChannelFields(
type: NotificationChannelType | string,
): readonly NotificationChannelFieldSpec[] {
return notificationChannelSpec(type)?.fields ?? [];
}
export function notificationChannelSecretKeys(
type: NotificationChannelType | string,
): readonly string[] {
return notificationChannelFields(type)
.filter((field) => field.kind === 'secret')
.map((field) => field.key);
}
/** Defaults for every field of a type, used to prefill the editor. */
export function notificationChannelDefaults(
type: NotificationChannelType | string,
): Record<string, NotificationChannelConfigValue> {
const config: Record<string, NotificationChannelConfigValue> = {};
for (const field of notificationChannelFields(type)) {
if (field.kind === 'secret') continue;
if (field.default !== undefined) config[field.key] = field.default;
else if (field.kind === 'toggle') config[field.key] = false;
else if (field.kind === 'number') config[field.key] = 0;
else config[field.key] = '';
}
return config;
}
export class NotificationChannelConfigError extends Error {
readonly fields: readonly string[];
constructor(message: string, fields: readonly string[] = []) {
super(message);
this.name = 'NotificationChannelConfigError';
this.fields = fields;
}
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return undefined;
}
/**
* Validates a raw editor payload against the type table and splits it into the
* values that may be persisted in clear text and the values that must go to the
* secret store.
*/
export function splitNotificationChannelConfig(
type: NotificationChannelType | string,
input: Readonly<Record<string, unknown>>,
existingSecrets: Readonly<Record<string, string>> = {},
): {
readonly config: Record<string, NotificationChannelConfigValue>;
readonly secrets: Record<string, string>;
} {
const spec = notificationChannelSpec(type);
if (!spec) throw new NotificationChannelConfigError(`未知的通知通道类型:${String(type)}`);
const config: Record<string, NotificationChannelConfigValue> = {};
const secrets: Record<string, string> = {};
const missing: string[] = [];
for (const field of spec.fields) {
const raw = input[field.key];
if (field.kind === 'toggle') {
config[field.key] = raw === undefined ? field.default === true : raw === true;
continue;
}
if (field.kind === 'number') {
if (raw === undefined || raw === '') {
const fallback = typeof field.default === 'number' ? field.default : 0;
if (field.required && fallback === 0) missing.push(field.key);
config[field.key] = fallback;
continue;
}
const parsed = typeof raw === 'number' ? raw : Number(asTrimmedString(raw));
if (!Number.isFinite(parsed))
throw new NotificationChannelConfigError(`${field.label} 必须是数字`, [field.key]);
const minimum = field.minimum ?? Number.MIN_SAFE_INTEGER;
const maximum = field.maximum ?? Number.MAX_SAFE_INTEGER;
if (parsed < minimum || parsed > maximum)
throw new NotificationChannelConfigError(
`${field.label} 必须在 ${minimum}${maximum} 之间`,
[field.key],
);
config[field.key] = Math.trunc(parsed);
continue;
}
const textValue = asTrimmedString(raw) ?? '';
const maxLength = field.maxLength ?? 2000;
if (textValue.length > maxLength)
throw new NotificationChannelConfigError(`${field.label} 长度超出限制`, [field.key]);
if (field.kind === 'secret') {
const carried = existingSecrets[field.key];
if (textValue === '') {
if (carried) secrets[field.key] = carried;
else if (field.required) missing.push(field.key);
continue;
}
secrets[field.key] = textValue;
continue;
}
if (field.kind === 'select' && field.options) {
const allowed = new Set(field.options.map((option) => option.value));
if (textValue !== '' && !allowed.has(textValue))
throw new NotificationChannelConfigError(`${field.label} 不是允许的取值`, [field.key]);
}
if (textValue === '') {
if (field.required) missing.push(field.key);
config[field.key] = typeof field.default === 'string' ? field.default : '';
continue;
}
config[field.key] = textValue;
}
if (missing.length > 0)
throw new NotificationChannelConfigError(
`缺少必填项:${missing
.map(
(key) => notificationChannelFields(type).find((field) => field.key === key)?.label ?? key,
)
.join('、')}`,
missing,
);
return { config, secrets };
}
/** Parses the `key-values` editor control ("a: 1\nb: 2") into an ordered map. */
export function parseKeyValueField(value: unknown): Record<string, string> {
const result: Record<string, string> = {};
if (typeof value !== 'string') return result;
for (const line of value.split(/\r?\n/u)) {
const separator = line.indexOf(':');
if (separator <= 0) continue;
const key = line.slice(0, separator).trim().slice(0, 128);
const item = line
.slice(separator + 1)
.trim()
.slice(0, 1024);
if (key) result[key] = item;
}
return result;
}
+93
View File
@@ -0,0 +1,93 @@
export interface DeviceGroup {
readonly id: string;
readonly name: string;
readonly description: string;
readonly deviceCount: number;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface DeviceGroupInput {
readonly name: string;
readonly description?: string;
}
export interface DeviceGroupPatch {
readonly name?: string;
readonly description?: string;
}
export interface DeviceTag {
readonly tag: string;
readonly color: string;
readonly deviceCount: number;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface DeviceTagInput {
readonly tag: string;
readonly color?: string;
}
export interface DeviceTagPatch {
readonly color?: string;
}
export const TAG_COLORS = [
'',
'coral',
'turquoise',
'lime',
'yellow',
'pink',
'purple',
'blue',
] as const;
export type TagColor = (typeof TAG_COLORS)[number];
function text(value: unknown, field: string, maximum: number): string {
if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
const result = value.trim();
if (result.length > maximum)
throw new TypeError(`${field} must be at most ${maximum} characters`);
return result;
}
export function parseDeviceGroupInput(value: unknown): DeviceGroupInput {
const record = value as Record<string, unknown>;
const name = text(record?.name, 'name', 100);
if (!name) throw new TypeError('name must not be empty');
const description =
record?.description === undefined ? '' : text(record.description, 'description', 500);
return { name, description };
}
export function parseDeviceGroupPatch(value: unknown): DeviceGroupPatch {
const record = value as Record<string, unknown>;
const patch: { name?: string; description?: string } = {};
if (record?.name !== undefined) {
const name = text(record.name, 'name', 100);
if (!name) throw new TypeError('name must not be empty');
patch.name = name;
}
if (record?.description !== undefined)
patch.description = text(record.description, 'description', 500);
if (Object.keys(patch).length === 0) throw new TypeError('no group fields to update');
return patch;
}
export function parseDeviceTagInput(value: unknown): DeviceTagInput {
const record = value as Record<string, unknown>;
const tag = text(record?.tag, 'tag', 100);
if (!tag) throw new TypeError('tag must not be empty');
const color = record?.color === undefined ? '' : text(record.color, 'color', 32);
return { tag, color };
}
export function parseDeviceTagPatch(value: unknown): DeviceTagPatch {
const record = value as Record<string, unknown>;
if (record?.color === undefined) throw new TypeError('no tag fields to update');
return { color: text(record.color, 'color', 32) };
}