diff --git a/src/index.ts b/src/index.ts index 239f299..bff9252 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,4 @@ export * from './push/xi-zhi' export * from './one' export * from './interfaces/response' export * from './interfaces/send' +export * from './interfaces/schema' diff --git a/src/interfaces/schema.ts b/src/interfaces/schema.ts new file mode 100644 index 0000000..60b12cf --- /dev/null +++ b/src/interfaces/schema.ts @@ -0,0 +1,151 @@ +// 是否是联合类型 +type IsUnion = T extends U ? ([U] extends [T] ? false : true) : never +/** + * 判断类型是否相同 + */ +type Equal = + (() => U extends Left ? 1 : 0) extends (() => U extends Right ? 1 : 0) ? true : false + +/** + * 判断字段是否必填 + */ +type IsRequired = Equal, T> + +export type Config = { + [key: string]: any +} + +/** + * 配置 Schema + * 如果字段的类型是 string,则生成的 Schema 类型为 string + * 如果字段的类型是 number,则生成的 Schema 类型为 number + * 如果字段的类型是 boolean,则生成的 Schema 类型为 boolean + * 如果字段的类型是 object,则生成的 Schema 类型为 object + * 如果字段的类型是 array,则生成的 Schema 类型为 array + * 如果字段的类型是 联合 number 类型(1 | 2 | 3),则生成的 Schema 类型为 select + * 如果字段的类型是 联合 string 类型('text' | 'html'),则生成的 Schema 类型为 select + * (IsUnion extends true ? 'select' : never) + */ +export type ConfigSchema = { + [K in keyof T]: { + // 字段类型 + type: IsUnion extends true ? 'select' : ( + T[K] extends string ? 'string' : ( + T[K] extends number ? 'number' : ( + T[K] extends boolean ? 'boolean' : ( + T[K] extends any[] ? 'array' : ( + T[K] extends object ? 'object' : ( + 'select' + ) + ) + ) + ) + ) + ) + + // 字段名称 + title?: string + // 字段描述 + description?: string + // 字段是否必填 + required: IsRequired> + // 字段默认值 + default?: T[K] + // 字段选项,仅当字段类型为 select 时有效 + options?: IsUnion extends true ? { + // 选项名称 + label: string + // 选项值 + value: T[K] // 选项值的类型跟字段的类型一致 + }[] : never + } +} + +// type ConfigA = { +// name: string +// age?: number +// isActive: boolean +// content?: 'text' | 'html' +// status: 1 | 2 | 3 +// } + +// type ConfigSchemaA = ConfigSchema + +// const a: ConfigSchemaA = { +// name: { +// type: 'string', +// title: '', +// description: '', +// required: true, +// default: '', +// }, +// age: { +// type: 'number', +// title: '', +// description: '', +// required: false, +// default: 0, +// }, +// isActive: { +// type: 'boolean', +// title: '', +// description: '', +// required: true, +// default: false, +// options: [ +// { +// label: '是', +// value: true, +// }, +// { +// label: '否', +// value: false, +// }, +// ], +// }, +// content: { +// type: 'string', +// title: '', +// description: '', +// required: false, +// default: 'text', +// options: [ +// { +// label: '文本', +// value: 'text', +// }, +// { +// label: 'HTML', +// value: 'html', +// }, +// ], +// }, +// status: { +// type: 'number', +// title: '', +// description: '', +// required: true, +// default: 1, +// options: [ +// { +// label: '1', +// value: 1, +// }, +// { +// label: '2', +// value: 2, +// }, +// { +// label: '3', +// value: 3, +// }, +// ], +// }, +// } + +export type Option = { + [key: string]: any +} + +export type OptionSchema = ConfigSchema + diff --git a/src/push/custom-email.ts b/src/push/custom-email.ts index 19974f2..0813f35 100644 --- a/src/push/custom-email.ts +++ b/src/push/custom-email.ts @@ -4,6 +4,8 @@ import SMTPTransport from 'nodemailer/lib/smtp-transport' import Mail from 'nodemailer/lib/mailer' import { Send } from '@/interfaces/send' import { SendResponse } from '@/interfaces/response' +import { ConfigSchema, OptionSchema } from '@/interfaces/schema' +import { validate } from '@/utils/validate' const Debugger = debug('push:custom-email') @@ -35,8 +37,113 @@ export interface CustomEmailConfig { EMAIL_PORT: number } +export type CustomEmailConfigSchema = ConfigSchema + +export const customEmailConfigSchema: CustomEmailConfigSchema = { + EMAIL_TYPE: { + type: 'select', + title: '邮件类型', + description: '邮件类型', + required: true, + default: 'text', + options: [ + { + label: '文本', + value: 'text', + }, + { + label: 'HTML', + value: 'html', + }, + ], + }, + EMAIL_TO_ADDRESS: { + type: 'string', + title: '收件邮箱', + description: '收件邮箱', + required: true, + default: '', + }, + EMAIL_AUTH_USER: { + type: 'string', + title: '发件邮箱', + description: '发件邮箱', + required: true, + default: '', + }, + EMAIL_AUTH_PASS: { + type: 'string', + title: '发件授权码(或密码)', + description: '发件授权码(或密码)', + required: true, + default: '', + }, + EMAIL_HOST: { + type: 'string', + title: '发件域名', + description: '发件域名', + required: true, + default: '', + }, + EMAIL_PORT: { + type: 'number', + title: '发件端口', + description: '发件端口', + required: true, + default: 465, + }, +} as const + export type CustomEmailOption = Mail.Options +type OptionalCustomEmailOption = Pick + +/** + * 由于 CustomEmailOption 的配置太多,所以不提供完整的 Schema,只提供部分配置 schema。 + * 如需使用完整的配置,请查看官方文档 + */ +export type CustomEmailOptionSchema = OptionSchema<{ + [K in keyof OptionalCustomEmailOption]: string +}> + +export const customEmailOptionSchema: CustomEmailOptionSchema = { + to: { + type: 'string', + title: '收件邮箱', + description: '收件邮箱', + required: false, + default: '', + }, + from: { + type: 'string', + title: '发件邮箱', + description: '发件邮箱', + required: false, + default: '', + }, + subject: { + type: 'string', + title: '邮件主题', + description: '邮件主题', + required: false, + default: '', + }, + text: { + type: 'string', + title: '邮件内容', + description: '邮件内容', + required: false, + default: '', + }, + html: { + type: 'string', + title: '邮件内容', + description: '邮件内容', + required: false, + default: '', + }, +} as const + /** * 自定义邮件。官方文档: https://github.com/nodemailer/nodemailer * @@ -47,6 +154,10 @@ export type CustomEmailOption = Mail.Options */ export class CustomEmail implements Send { + static configSchema = customEmailConfigSchema + + static optionSchema = customEmailOptionSchema + private config: CustomEmailConfig private transporter: nodemailer.Transporter @@ -54,11 +165,8 @@ export class CustomEmail implements Send { constructor(config: CustomEmailConfig) { this.config = config Debugger('CustomEmailConfig: %o', config) - Object.entries(config).forEach(([key, value]) => { - if (!value) { - throw new Error(`CustomEmailConfig 的 "${key}" 字段是必须的!`) - } - }) + // 根据 configSchema 验证 config + validate(config, CustomEmail.configSchema) const { EMAIL_AUTH_USER, EMAIL_AUTH_PASS, EMAIL_HOST, EMAIL_PORT } = this.config this.transporter = nodemailer.createTransport({ host: EMAIL_HOST, diff --git a/src/push/dingtalk.ts b/src/push/dingtalk.ts index d473b8d..4565433 100644 --- a/src/push/dingtalk.ts +++ b/src/push/dingtalk.ts @@ -4,12 +4,14 @@ import { Markdown } from './dingtalk/markdown' import { Text } from './dingtalk/text' import { Link } from './dingtalk/link' import { FeedCard } from './dingtalk/feed-card' -import { ActionCard } from './dingtalk/action-card' +import { ActionCard, IndependentJump, OverallJump } from './dingtalk/action-card' import { Send } from '@/interfaces/send' import { warn } from '@/utils/helper' import { ajax } from '@/utils/ajax' import { generateSignature } from '@/utils/crypto' import { SendResponse } from '@/interfaces/response' +import { ConfigSchema, OptionSchema } from '@/interfaces/schema' +import { validate } from '@/utils/validate' const Debugger = debug('push:dingtalk') @@ -26,9 +28,126 @@ export interface DingtalkConfig { DINGTALK_SECRET?: string } -export type DingtalkOption = { - [key: string]: any -} & Partial<(Text | Markdown | Link | FeedCard | ActionCard)> +export type DingtalkConfigSchema = ConfigSchema + +export const dingtalkConfigSchema: DingtalkConfigSchema = { + DINGTALK_ACCESS_TOKEN: { + type: 'string', + title: '钉钉机器人 access_token', + description: '钉钉机器人 access_token', + required: true, + default: '', + }, + DINGTALK_SECRET: { + type: 'string', + title: '加签安全秘钥(HmacSHA256)', + required: false, + default: '', + }, +} + +export type DingtalkOption = Partial<(Text | Markdown | Link | FeedCard | ActionCard)> + +type TempDingtalkOption = { + msgtype?: DingtalkOption['msgtype'] + text?: Text['text'] + markdown?: Markdown['markdown'] + link?: Link['link'] + actionCard?: { + // 首屏会话透出的展示内容 + title: string + // markdown 格式的消息内容 + text: string + // 0:按钮竖直排列;1:按钮横向排列 + btnOrientation?: '0' | '1' + } & Partial & Partial + feedCard?: FeedCard['feedCard'] + + at?: Text['at'] +} + +export type DingtalkOptionSchema = OptionSchema + +export const dingtalkOptionSchema: DingtalkOptionSchema = { + msgtype: { + type: 'select', + title: '消息类型', + description: '消息类型', + required: false, + default: 'text', + options: [ + { + label: '文本', + value: 'text', + }, + { + label: 'Markdown', + value: 'markdown', + }, + { + label: '链接', + value: 'link', + }, + { + label: '按钮', + value: 'actionCard', + }, + { + label: 'FeedCard', + value: 'feedCard', + }, + ], + }, + text: { + type: 'object', + title: '文本', + description: '文本', + required: false, + default: { + content: '', + }, + }, + markdown: { + type: 'object', + title: 'Markdown', + description: 'Markdown', + required: false, + default: { + title: '', + text: '', + }, + }, + link: { + type: 'object', + title: '链接', + description: '链接', + required: false, + default: { + text: '', + title: '', + messageUrl: '', + }, + }, + actionCard: { + type: 'object', + title: '动作卡片', + description: '动作卡片', + required: false, + default: { + title: '', + text: '', + }, + }, + feedCard: { + type: 'object', + title: '订阅卡片', + description: '订阅卡片', + required: false, + default: { + links: [], + }, + }, +} export interface DingtalkResponse { errcode: number @@ -44,6 +163,11 @@ export interface DingtalkResponse { * @class Dingtalk */ export class Dingtalk implements Send { + + static configSchema = dingtalkConfigSchema + + static optionSchema = dingtalkOptionSchema + private ACCESS_TOKEN: string /** * 加签安全秘钥(HmacSHA256) @@ -64,9 +188,8 @@ export class Dingtalk implements Send { this.ACCESS_TOKEN = DINGTALK_ACCESS_TOKEN this.SECRET = DINGTALK_SECRET Debugger('DINGTALK_ACCESS_TOKEN: %s , DINGTALK_SECRET: %s', this.ACCESS_TOKEN, this.SECRET) - if (!this.ACCESS_TOKEN) { - throw new Error('DINGTALK_ACCESS_TOKEN 是必须的!') - } + // 根据 configSchema 验证 config + validate(config, Dingtalk.configSchema) if (!this.SECRET) { warn('未提供 DINGTALK_SECRET !') } diff --git a/src/utils/helper.ts b/src/utils/helper.ts index e216856..e728bf5 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -38,3 +38,21 @@ export const isHttpURL = (url: string): boolean => /^(https?:\/\/)/.test(url) * @returns */ export const isSocksUrl = (url: string): boolean => /^(socks5?:\/\/)/.test(url) + +/** + * 判断是否为 null 或 undefined + * @param value + * @returns + */ +export function isNil(value: unknown): boolean { + return value === null || value === undefined +} + +/** + * 判断是否不为 null 且不为 undefined + * @param value + * @returns + */ +export function isNotNil(value: unknown): boolean { + return !isNil(value) +} diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000..0d56910 --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,45 @@ +import { isNil } from './helper' +import { Config, ConfigSchema } from '@/interfaces/schema' + +export function validate(config: T, schema: ConfigSchema): void { + Object.keys(schema).forEach((key) => { + const item = schema[key] + const value = config[key] + if (!item.required && isNil(value)) { + return + } + if (item.required && isNil(value)) { + throw new Error(`"${key}" 字段是必须的!`) + } + if (item.type === 'select') { + const { options } = item as any + if (!options.map((e) => e.value).includes(value)) { + throw new Error(`"${key}" 字段必须是以下选项之一:${options.map((e) => e.value).join(',')}`) + } + } else if (item.type === 'string') { + if (typeof value !== 'string') { + throw new Error(`"${key}" 字段必须是字符串!`) + } + + } else if (item.type === 'number') { + if (typeof value !== 'number') { + throw new Error(`"${key}" 字段必须是数字!`) + } + + } else if (item.type === 'boolean') { + if (typeof value !== 'boolean') { + throw new Error(`"${key}" 字段必须是布尔值!`) + } + + } else if (item.type === 'array') { + if (!Array.isArray(value)) { + throw new Error(`"${key}" 字段必须是数组!`) + } + + } else if (item.type === 'object') { + if (typeof value !== 'object') { + throw new Error(`"${key}" 字段必须是对象!`) + } + } + }) +}