- 新增 types/claim-identity,统一 ClaimIdentity / Payload / AdminSummary - 导出 KuaishouCloudFlow、KuaishouFeifeiFlow 作为 normalize 出口 - 生产路径 JsonObject 统一从 types/json 导入,去掉 rebind as any - 前后端 claimIdentity 类型字段对齐
195 lines
4.9 KiB
TypeScript
195 lines
4.9 KiB
TypeScript
import http from 'node:http'
|
|
import https from 'node:https'
|
|
|
|
import type { JsonObject } from '../../types/json.js'
|
|
import { createHttpError } from '../../utils/http.js'
|
|
|
|
const DEFAULT_WPUSH_ENDPOINT = 'https://api.wpush.cn/api/v1/send'
|
|
const DEFAULT_TIMEOUT_MS = 10000
|
|
|
|
type NodeHttpResponse = {
|
|
ok: boolean
|
|
status: number
|
|
bodyText: string
|
|
}
|
|
type WpushSendInput = {
|
|
recipient?: {
|
|
id?: string
|
|
name?: string
|
|
apiKey?: string
|
|
}
|
|
title?: string
|
|
body?: string
|
|
endpoint?: string
|
|
}
|
|
|
|
export async function sendWpushNotification(input: WpushSendInput) {
|
|
const apiKey = String(input.recipient?.apiKey || '').trim()
|
|
const title = String(input.title || '').trim()
|
|
const body = String(input.body || '').trim()
|
|
const endpoint = String(input.endpoint || DEFAULT_WPUSH_ENDPOINT).trim() || DEFAULT_WPUSH_ENDPOINT
|
|
|
|
if (!apiKey) {
|
|
throw createHttpError('WPush 接收人缺少 apikey', {
|
|
statusCode: 400,
|
|
errorCode: 'wpush_api_key_required',
|
|
})
|
|
}
|
|
|
|
if (!title && !body) {
|
|
throw createHttpError('WPush 通知标题或内容不能为空', {
|
|
statusCode: 400,
|
|
errorCode: 'wpush_message_required',
|
|
})
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
apikey: apiKey,
|
|
title: title || body,
|
|
content: body || title,
|
|
})
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
|
|
|
|
try {
|
|
const response = await requestViaNodeHttp(new URL(endpoint), {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json, text/plain, */*',
|
|
'content-length': String(Buffer.byteLength(payload)),
|
|
},
|
|
body: payload,
|
|
signal: controller.signal,
|
|
})
|
|
const parsed = parseJsonResponse(response.bodyText)
|
|
|
|
if (!response.ok || isWpushFailure(parsed)) {
|
|
throw createHttpError(resolveWpushErrorMessage(parsed, response.bodyText, response.status), {
|
|
statusCode: response.ok ? 502 : response.status,
|
|
errorCode: 'wpush_send_failed',
|
|
context: {
|
|
status: response.status,
|
|
response: parsed || response.bodyText,
|
|
},
|
|
})
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
status: response.status,
|
|
response: parsed || response.bodyText,
|
|
}
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
throw createHttpError('WPush 通知发送超时', {
|
|
statusCode: 503,
|
|
errorCode: 'wpush_send_timeout',
|
|
cause: error,
|
|
})
|
|
}
|
|
|
|
throw error
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function requestViaNodeHttp(
|
|
url: URL,
|
|
{ method, headers, body, signal }: {
|
|
method: string
|
|
headers: Record<string, string>
|
|
body?: string
|
|
signal?: AbortSignal
|
|
},
|
|
): Promise<NodeHttpResponse> {
|
|
const transport = url.protocol === 'https:' ? https : http
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const request = transport.request(url, {
|
|
method,
|
|
headers,
|
|
rejectUnauthorized: false,
|
|
}, (response) => {
|
|
const chunks: Buffer[] = []
|
|
|
|
response.on('data', (chunk) => {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
})
|
|
|
|
response.on('end', () => {
|
|
resolve({
|
|
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
|
|
status: Number(response.statusCode || 0),
|
|
bodyText: Buffer.concat(chunks).toString('utf8'),
|
|
})
|
|
})
|
|
})
|
|
|
|
request.on('error', reject)
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
const error = new Error('Request aborted')
|
|
error.name = 'AbortError'
|
|
request.destroy(error)
|
|
} else {
|
|
signal.addEventListener('abort', () => {
|
|
const error = new Error('Request aborted')
|
|
error.name = 'AbortError'
|
|
request.destroy(error)
|
|
}, { once: true })
|
|
}
|
|
}
|
|
|
|
if (typeof body !== 'undefined') {
|
|
request.write(body)
|
|
}
|
|
|
|
request.end()
|
|
})
|
|
}
|
|
|
|
function parseJsonResponse(text: string) {
|
|
if (!text) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isWpushFailure(parsed: unknown) {
|
|
if (!isPlainObject(parsed)) {
|
|
return false
|
|
}
|
|
|
|
const success = parsed.success
|
|
if (typeof success === 'boolean') {
|
|
return success === false
|
|
}
|
|
|
|
const code = Number(parsed.code ?? parsed.status ?? 0)
|
|
return Number.isFinite(code) && code !== 0 && code !== 200
|
|
}
|
|
|
|
function resolveWpushErrorMessage(parsed: unknown, responseText: string, status: number) {
|
|
if (isPlainObject(parsed)) {
|
|
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `WPush 通知发送失败,HTTP ${status}`
|
|
}
|
|
|
|
return String(responseText || '').trim() || `WPush 通知发送失败,HTTP ${status}`
|
|
}
|
|
|
|
function isAbortError(error: unknown): error is Error {
|
|
return error instanceof Error && error.name === 'AbortError'
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is JsonObject {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|