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 body?: string signal?: AbortSignal }, ): Promise { 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) }