@perk-net/perk-pushplus-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { PushPlusError } from './exception';
2
+ import { CallbackPayload } from './models';
3
+
4
+ /**
5
+ * PushPlus 回调请求体解析工具。
6
+ *
7
+ * 用法:在你的回调接口中拿到原始 JSON body,直接传入 `parseCallback(body)` 即可。
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { parseCallback, CallbackEvent } from '@perk-net/perk-pushplus-sdk';
12
+ *
13
+ * function onPushPlusCallback(rawBody: string) {
14
+ * const payload = parseCallback(rawBody);
15
+ * switch (payload.event) {
16
+ * case CallbackEvent.MESSAGE_COMPLETE:
17
+ * // payload.messageInfo
18
+ * break;
19
+ * case CallbackEvent.ADD_TOPIC_USER:
20
+ * // payload.topicUserInfo
21
+ * break;
22
+ * case CallbackEvent.ADD_FRIEND:
23
+ * // payload.friendInfo, payload.qrCode
24
+ * break;
25
+ * }
26
+ * return 'ok';
27
+ * }
28
+ * ```
29
+ */
30
+ export function parseCallback(json: string | object): CallbackPayload {
31
+ if (json == null) {
32
+ throw new PushPlusError('回调请求体不能为空');
33
+ }
34
+ if (typeof json === 'string') {
35
+ try {
36
+ return JSON.parse(json) as CallbackPayload;
37
+ } catch (e) {
38
+ throw new PushPlusError(`解析 PushPlus 回调失败: ${(e as Error).message}`, -1, { cause: e });
39
+ }
40
+ }
41
+ return json as CallbackPayload;
42
+ }
43
+
44
+ export const CallbackParser = {
45
+ parse: parseCallback,
46
+ };
package/src/client.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { AccessKeyManager } from './access-key-manager';
2
+ import { AccessKeyApi } from './api/access-key-api';
3
+ import { ChannelApi } from './api/channel-api';
4
+ import { ClawBotApi } from './api/clawbot-api';
5
+ import { FriendApi } from './api/friend-api';
6
+ import { MessageApi } from './api/message-api';
7
+ import { MessageTokenApi } from './api/message-token-api';
8
+ import { OpenMessageApi } from './api/open-message-api';
9
+ import { PreApi } from './api/pre-api';
10
+ import { SettingApi } from './api/setting-api';
11
+ import { TopicApi } from './api/topic-api';
12
+ import { TopicUserApi } from './api/topic-user-api';
13
+ import { UserApi } from './api/user-api';
14
+ import { WebhookApi } from './api/webhook-api';
15
+ import { PushPlusConfig, ResolvedPushPlusConfig, resolveConfig } from './config';
16
+ import { PushPlusError } from './exception';
17
+ import { FetchHttpRequester, HttpRequester } from './http';
18
+ import { BatchSendRequest, BatchSendResult, SendRequest } from './models';
19
+ import { RateLimitGuard } from './rate-limit';
20
+
21
+ export interface PushPlusClientOptions extends PushPlusConfig {
22
+ /** 自定义 HTTP 客户端实现。 */
23
+ httpRequester?: HttpRequester;
24
+ }
25
+
26
+ /**
27
+ * PushPlus SDK 统一入口。
28
+ *
29
+ * 兼容 Node.js(>=18 内置 fetch)与浏览器环境。
30
+ *
31
+ * @example 快速开始
32
+ * ```ts
33
+ * import { PushPlusClient } from '@perk-net/perk-pushplus-sdk';
34
+ *
35
+ * const client = new PushPlusClient({
36
+ * token: 'your_user_token',
37
+ * secretKey: 'your_secret_key', // 调用开放接口才需要
38
+ * });
39
+ *
40
+ * // 发送消息
41
+ * const shortCode = await client.sendSimple('标题', 'Hello PushPlus');
42
+ *
43
+ * // 调用开放接口(无需手动管理 AccessKey)
44
+ * const info = await client.user.myInfo();
45
+ * ```
46
+ */
47
+ export class PushPlusClient {
48
+ /** 已解析的不可变配置。 */
49
+ readonly config: ResolvedPushPlusConfig;
50
+ readonly httpRequester: HttpRequester;
51
+ readonly accessKeyManager: AccessKeyManager;
52
+ readonly rateLimitGuard: RateLimitGuard;
53
+
54
+ /* ---------------- 各 API ---------------- */
55
+ readonly message: MessageApi;
56
+ readonly accessKey: AccessKeyApi;
57
+ readonly openMessage: OpenMessageApi;
58
+ readonly user: UserApi;
59
+ readonly messageToken: MessageTokenApi;
60
+ readonly topic: TopicApi;
61
+ readonly topicUser: TopicUserApi;
62
+ readonly friend: FriendApi;
63
+ readonly webhook: WebhookApi;
64
+ readonly channel: ChannelApi;
65
+ readonly clawBot: ClawBotApi;
66
+ readonly setting: SettingApi;
67
+ readonly pre: PreApi;
68
+
69
+ constructor(options: PushPlusClientOptions = {}) {
70
+ this.config = resolveConfig(options);
71
+ this.httpRequester = options.httpRequester ?? new FetchHttpRequester(this.config);
72
+ this.rateLimitGuard = new RateLimitGuard(this.config);
73
+
74
+ this.message = new MessageApi(this.config, this.httpRequester, this.rateLimitGuard);
75
+ this.accessKey = new AccessKeyApi(this.config, this.httpRequester);
76
+ this.accessKeyManager = new AccessKeyManager(this.config, this.accessKey);
77
+
78
+ this.openMessage = new OpenMessageApi(this.config, this.httpRequester, this.accessKeyManager);
79
+ this.user = new UserApi(this.config, this.httpRequester, this.accessKeyManager);
80
+ this.messageToken = new MessageTokenApi(this.config, this.httpRequester, this.accessKeyManager);
81
+ this.topic = new TopicApi(this.config, this.httpRequester, this.accessKeyManager);
82
+ this.topicUser = new TopicUserApi(this.config, this.httpRequester, this.accessKeyManager);
83
+ this.friend = new FriendApi(this.config, this.httpRequester, this.accessKeyManager);
84
+ this.webhook = new WebhookApi(this.config, this.httpRequester, this.accessKeyManager);
85
+ this.channel = new ChannelApi(this.config, this.httpRequester, this.accessKeyManager);
86
+ this.clawBot = new ClawBotApi(this.config, this.httpRequester, this.accessKeyManager);
87
+ this.setting = new SettingApi(this.config, this.httpRequester, this.accessKeyManager);
88
+ this.pre = new PreApi(this.config, this.httpRequester, this.accessKeyManager);
89
+ }
90
+
91
+ /** 与 Java SDK 风格一致的 Builder 入口。 */
92
+ static builder(): PushPlusClientBuilder {
93
+ return new PushPlusClientBuilder();
94
+ }
95
+
96
+ /** 工厂方法。 */
97
+ static of(options: PushPlusClientOptions): PushPlusClient {
98
+ return new PushPlusClient(options);
99
+ }
100
+
101
+ /* ============================== 便捷转发方法 ============================== */
102
+
103
+ /** 发送一条简单消息(默认 wechat / html)。 */
104
+ sendSimple(title: string | undefined, content: string): Promise<string> {
105
+ return this.message.sendSimple(title, content);
106
+ }
107
+
108
+ /** 发送消息。 */
109
+ send(req: SendRequest): Promise<string> {
110
+ return this.message.send(req);
111
+ }
112
+
113
+ /** 多渠道发送消息。 */
114
+ batchSend(req: BatchSendRequest): Promise<BatchSendResult[]> {
115
+ return this.message.batchSend(req);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 链式 Builder,与 Java/Python SDK 风格保持一致。
121
+ */
122
+ export class PushPlusClientBuilder {
123
+ private readonly opt: PushPlusClientOptions = {};
124
+
125
+ token(v?: string): this { this.opt.token = v; return this; }
126
+ secretKey(v?: string): this { this.opt.secretKey = v; return this; }
127
+ baseUrl(v?: string): this { this.opt.baseUrl = v; return this; }
128
+ connectTimeoutMs(v: number): this { this.opt.connectTimeoutMs = v; return this; }
129
+ readTimeoutMs(v: number): this { this.opt.readTimeoutMs = v; return this; }
130
+ accessKeyRefreshAheadSeconds(v: number): this { this.opt.accessKeyRefreshAheadSeconds = v; return this; }
131
+ logRequest(v: boolean): this { this.opt.logRequest = v; return this; }
132
+ rateLimitGuardEnabled(v: boolean): this { this.opt.rateLimitGuardEnabled = v; return this; }
133
+ rateLimitCooldownMs(v: number): this { this.opt.rateLimitCooldownMs = v; return this; }
134
+ userAgent(v: string): this { this.opt.userAgent = v; return this; }
135
+ httpRequester(req: HttpRequester): this { this.opt.httpRequester = req; return this; }
136
+
137
+ build(): PushPlusClient {
138
+ if (this.opt.token != null && this.opt.token.trim().length === 0) {
139
+ throw new PushPlusError('token 不能为空字符串');
140
+ }
141
+ return new PushPlusClient(this.opt);
142
+ }
143
+ }
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * PushPlus SDK 全局配置。
3
+ *
4
+ * 通过 `PushPlusClient.builder()` 或直接 `new PushPlusClient(config)` 构建实例。
5
+ * 所有字段都有合理默认值,仅 `token` 是发送消息接口必填、`secretKey` 是开放接口必填。
6
+ */
7
+ export interface PushPlusConfig {
8
+ /**
9
+ * 用户 token 或消息 token,发送消息接口默认使用。
10
+ * 注意:获取 AccessKey 必须使用用户 token。
11
+ */
12
+ token?: string;
13
+
14
+ /**
15
+ * 用户 secretKey,调用开放接口(获取 AccessKey)必填。
16
+ * 在 pushplus 个人中心 -> 开发设置 中配置。
17
+ */
18
+ secretKey?: string;
19
+
20
+ /**
21
+ * 服务器基础地址。默认:https://www.pushplus.plus
22
+ */
23
+ baseUrl?: string;
24
+
25
+ /** 连接超时(毫秒)。默认 10000。 */
26
+ connectTimeoutMs?: number;
27
+
28
+ /** 请求/读超时(毫秒)。默认 30000。 */
29
+ readTimeoutMs?: number;
30
+
31
+ /**
32
+ * 在 AccessKey 过期前提前多少秒刷新。默认提前 5 分钟(300 秒),
33
+ * 文档中提到老 AccessKey 在新 AccessKey 生成后 5 分钟内仍可用。
34
+ */
35
+ accessKeyRefreshAheadSeconds?: number;
36
+
37
+ /** 是否启用请求/响应详细日志。默认关闭。 */
38
+ logRequest?: boolean;
39
+
40
+ /**
41
+ * 是否启用本地限流守卫。默认开启。
42
+ *
43
+ * 开启后,当任意一次发送消息接口返回 code=900(请求次数过多)时,
44
+ * 后续对同一 token 的发送调用会被 SDK 直接短路,不再发起 HTTP,
45
+ * 直到 `rateLimitCooldownMs`(默认次日 0 点)到期。
46
+ */
47
+ rateLimitGuardEnabled?: boolean;
48
+
49
+ /**
50
+ * 命中 code=900 后的本地禁推时长(毫秒)。
51
+ * 不传或 <=0 表示使用默认策略:到「次日 0 点」。
52
+ *
53
+ * 注意:服务端实际禁推时长可能更长(文档示例为 2 天)。
54
+ */
55
+ rateLimitCooldownMs?: number;
56
+
57
+ /** 自定义 User-Agent。 */
58
+ userAgent?: string;
59
+ }
60
+
61
+ /** PushPlus 默认服务地址。 */
62
+ export const DEFAULT_BASE_URL = 'https://www.pushplus.plus';
63
+
64
+ export interface ResolvedPushPlusConfig {
65
+ token: string;
66
+ secretKey: string;
67
+ baseUrl: string;
68
+ connectTimeoutMs: number;
69
+ readTimeoutMs: number;
70
+ accessKeyRefreshAheadSeconds: number;
71
+ logRequest: boolean;
72
+ rateLimitGuardEnabled: boolean;
73
+ rateLimitCooldownMs: number;
74
+ userAgent: string;
75
+ }
76
+
77
+ export function resolveConfig(input: PushPlusConfig | undefined | null): ResolvedPushPlusConfig {
78
+ const cfg = input ?? {};
79
+ const baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
80
+ return {
81
+ token: cfg.token ?? '',
82
+ secretKey: cfg.secretKey ?? '',
83
+ baseUrl: baseUrl || DEFAULT_BASE_URL,
84
+ connectTimeoutMs: cfg.connectTimeoutMs ?? 10000,
85
+ readTimeoutMs: cfg.readTimeoutMs ?? 30000,
86
+ accessKeyRefreshAheadSeconds: cfg.accessKeyRefreshAheadSeconds ?? 300,
87
+ logRequest: cfg.logRequest ?? false,
88
+ rateLimitGuardEnabled: cfg.rateLimitGuardEnabled ?? true,
89
+ rateLimitCooldownMs: cfg.rateLimitCooldownMs ?? 0,
90
+ userAgent: cfg.userAgent ?? `@perk-net/perk-pushplus-sdk/1.0.0`,
91
+ };
92
+ }
package/src/enums.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * PushPlus 发送渠道枚举。
3
+ *
4
+ * 对应官方文档「发送渠道(channel)枚举」。
5
+ */
6
+ export enum Channel {
7
+ /** 微信公众号(默认)。 */
8
+ WECHAT = 'wechat',
9
+ /** 第三方 webhook(企业微信/钉钉/飞书/bark/Gotify/Server酱/IFTTT/WxPusher 等)。 */
10
+ WEBHOOK = 'webhook',
11
+ /** 企业微信应用。 */
12
+ CP = 'cp',
13
+ /** 邮箱。 */
14
+ MAIL = 'mail',
15
+ /** 短信(收费)。 */
16
+ SMS = 'sms',
17
+ /** 语音(收费)。 */
18
+ VOICE = 'voice',
19
+ /** 浏览器扩展插件 / 桌面应用程序。 */
20
+ EXTENSION = 'extension',
21
+ /** App 渠道(安卓/鸿蒙/iOS)。 */
22
+ APP = 'app',
23
+ /** 微信 ClawBot。 */
24
+ CLAWBOT = 'clawbot',
25
+ }
26
+
27
+ /**
28
+ * PushPlus 消息模板枚举。
29
+ */
30
+ export enum Template {
31
+ /** 默认模板,支持 HTML 文本。 */
32
+ HTML = 'html',
33
+ /** 纯文本,不转义 HTML。 */
34
+ TXT = 'txt',
35
+ /** 基于 JSON 格式展示。 */
36
+ JSON = 'json',
37
+ /** Markdown 格式。 */
38
+ MARKDOWN = 'markdown',
39
+ /** 阿里云监控报警定制模板。 */
40
+ CLOUD_MONITOR = 'cloudMonitor',
41
+ /** Jenkins 插件定制模板。 */
42
+ JENKINS = 'jenkins',
43
+ /** 路由器插件定制模板。 */
44
+ ROUTE = 'route',
45
+ /** 支付成功通知模板。 */
46
+ PAY = 'pay',
47
+ }
48
+
49
+ /**
50
+ * 消息投递状态。
51
+ *
52
+ * 0-未发送/未投递,1-发送中,2-发送成功,3-发送失败。
53
+ */
54
+ export enum SendStatus {
55
+ NOT_SENT = 0,
56
+ SENDING = 1,
57
+ SUCCESS = 2,
58
+ FAILED = 3,
59
+ }
60
+
61
+ export const SendStatusDescription: Record<SendStatus, string> = {
62
+ [SendStatus.NOT_SENT]: '未发送',
63
+ [SendStatus.SENDING]: '发送中',
64
+ [SendStatus.SUCCESS]: '发送成功',
65
+ [SendStatus.FAILED]: '发送失败',
66
+ };
67
+
68
+ /**
69
+ * 回调事件类型。
70
+ */
71
+ export enum CallbackEvent {
72
+ /** 消息发送完成。 */
73
+ MESSAGE_COMPLETE = 'message_complate',
74
+ /** 群组新增用户。 */
75
+ ADD_TOPIC_USER = 'add_topic_user',
76
+ /** 新增好友。 */
77
+ ADD_FRIEND = 'add_friend',
78
+ }
79
+
80
+ /**
81
+ * Webhook 渠道类型。
82
+ *
83
+ * 对应开放接口文档「webhook 列表」中的 webhookType 枚举值。
84
+ */
85
+ export enum WebhookType {
86
+ WORK_WECHAT_BOT = 1,
87
+ DING_TALK_BOT = 2,
88
+ FEISHU_BOT = 3,
89
+ SERVER_CHAN = 4,
90
+ BARK = 50,
91
+ WORK_WECHAT_APP = 6,
92
+ TENCENT_LIGHT_LINK = 7,
93
+ IFTTT = 8,
94
+ JI_JIAN_YUN = 9,
95
+ GOTIFY = 10,
96
+ WX_PUSHER = 11,
97
+ CUSTOM = 12,
98
+ }
99
+
100
+ export const WebhookTypeDescription: Record<number, string> = {
101
+ [WebhookType.WORK_WECHAT_BOT]: '企业微信机器人',
102
+ [WebhookType.DING_TALK_BOT]: '钉钉机器人',
103
+ [WebhookType.FEISHU_BOT]: '飞书机器人',
104
+ [WebhookType.SERVER_CHAN]: 'Server酱',
105
+ [WebhookType.BARK]: 'bark',
106
+ [WebhookType.WORK_WECHAT_APP]: '企业微信应用',
107
+ [WebhookType.TENCENT_LIGHT_LINK]: '腾讯轻联',
108
+ [WebhookType.IFTTT]: 'IFTTT',
109
+ [WebhookType.JI_JIAN_YUN]: '集简云',
110
+ [WebhookType.GOTIFY]: 'Gotify',
111
+ [WebhookType.WX_PUSHER]: 'WxPusher',
112
+ [WebhookType.CUSTOM]: '自定义',
113
+ };
114
+
115
+ /**
116
+ * PushPlus 接口业务返回码语义。
117
+ *
118
+ * 对应官方文档「接口返回码说明」:https://www.pushplus.plus/doc/guide/code.html
119
+ */
120
+ export enum ErrorCode {
121
+ /** 200 执行成功。 */
122
+ OK = 200,
123
+ /** 302 未登录。 */
124
+ NOT_LOGIN = 302,
125
+ /** 401 请求未授权(开放接口未启用)。 */
126
+ UNAUTHORIZED = 401,
127
+ /** 403 请求 IP 未授权(白名单未配置)。 */
128
+ IP_FORBIDDEN = 403,
129
+ /** 500 系统异常,请稍后再试。 */
130
+ SERVER_ERROR = 500,
131
+ /** 600 数据异常,操作失败。 */
132
+ DATA_ERROR = 600,
133
+ /** 805 无权查看。 */
134
+ FORBIDDEN_VIEW = 805,
135
+ /** 888 积分不足,需要充值。 */
136
+ INSUFFICIENT_POINTS = 888,
137
+ /** 900 用户账号使用受限(请求次数过多)。 */
138
+ RATE_LIMITED = 900,
139
+ /** 905 账户未进行实名认证。 */
140
+ NOT_VERIFIED = 905,
141
+ /** 903 无效的用户令牌。 */
142
+ INVALID_TOKEN = 903,
143
+ /** 999 服务端验证错误。 */
144
+ VALIDATION_ERROR = 999,
145
+ /** 其它未在文档中列出的 code。 */
146
+ UNKNOWN = -1,
147
+ }
148
+
149
+ export function errorCodeFromValue(code: number | null | undefined): ErrorCode {
150
+ if (code == null) return ErrorCode.UNKNOWN;
151
+ const known = [
152
+ ErrorCode.OK,
153
+ ErrorCode.NOT_LOGIN,
154
+ ErrorCode.UNAUTHORIZED,
155
+ ErrorCode.IP_FORBIDDEN,
156
+ ErrorCode.SERVER_ERROR,
157
+ ErrorCode.DATA_ERROR,
158
+ ErrorCode.FORBIDDEN_VIEW,
159
+ ErrorCode.INSUFFICIENT_POINTS,
160
+ ErrorCode.RATE_LIMITED,
161
+ ErrorCode.NOT_VERIFIED,
162
+ ErrorCode.INVALID_TOKEN,
163
+ ErrorCode.VALIDATION_ERROR,
164
+ ];
165
+ return known.includes(code as ErrorCode) ? (code as ErrorCode) : ErrorCode.UNKNOWN;
166
+ }
167
+
168
+ export function isRateLimitedCode(code: number | null | undefined): boolean {
169
+ return code != null && code === ErrorCode.RATE_LIMITED;
170
+ }
@@ -0,0 +1,48 @@
1
+ import { ErrorCode, errorCodeFromValue, isRateLimitedCode } from './enums';
2
+
3
+ /**
4
+ * PushPlus SDK 统一运行时异常。
5
+ *
6
+ * 该异常会在以下场景抛出:
7
+ * - HTTP 请求失败(网络异常、非 2xx 状态码)
8
+ * - PushPlus 业务接口返回 code != 200
9
+ * - JSON 序列化/反序列化异常
10
+ * - SDK 参数校验失败
11
+ * - 本地限流守卫命中(code=900 后被短路),不会真正发起 HTTP 请求
12
+ */
13
+ export class PushPlusError extends Error {
14
+ /** PushPlus 接口返回的业务 code。HTTP 错误时为对应的 HTTP 状态码;其他为 -1。 */
15
+ public readonly code: number;
16
+
17
+ /** 同 message。便于和其它语言 SDK 风格一致。 */
18
+ public get businessMessage(): string {
19
+ return this.message;
20
+ }
21
+
22
+ constructor(message: string, code: number = -1, options?: { cause?: unknown }) {
23
+ super(message);
24
+ this.name = 'PushPlusError';
25
+ this.code = code;
26
+ if (options?.cause !== undefined) {
27
+ // Node 16+ 支持 Error cause
28
+ (this as any).cause = options.cause;
29
+ }
30
+ // 修复部分环境下 instanceof 失效问题
31
+ Object.setPrototypeOf(this, PushPlusError.prototype);
32
+ }
33
+
34
+ /** 把数值 code 映射为 ErrorCode 枚举(未知为 UNKNOWN)。 */
35
+ get errorCode(): ErrorCode {
36
+ return errorCodeFromValue(this.code);
37
+ }
38
+
39
+ /** 是否为 PushPlus 限流(code=900)。命中后建议当天停止继续调用发送消息接口。 */
40
+ isRateLimited(): boolean {
41
+ return isRateLimitedCode(this.code);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 兼容 Java SDK 命名(PushPlusException)的别名导出。
47
+ */
48
+ export const PushPlusException = PushPlusError;
package/src/http.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { ResolvedPushPlusConfig } from './config';
2
+ import { PushPlusError } from './exception';
3
+
4
+ export interface HttpRequestOptions {
5
+ method: string;
6
+ url: string;
7
+ headers?: Record<string, string>;
8
+ body?: string | null;
9
+ }
10
+
11
+ export interface HttpResponse {
12
+ statusCode: number;
13
+ body: string;
14
+ }
15
+
16
+ /**
17
+ * HTTP 请求执行器抽象。
18
+ *
19
+ * SDK 默认提供基于 `fetch` 的实现(Node 18+ 内置 / 浏览器原生)。
20
+ * 调用方也可以自行实现并通过 `PushPlusClient` 注入以使用其它客户端(如 axios/undici/got)。
21
+ */
22
+ export interface HttpRequester {
23
+ execute(options: HttpRequestOptions): Promise<HttpResponse>;
24
+ }
25
+
26
+ /**
27
+ * 基于 fetch 的请求执行器。
28
+ *
29
+ * - Node.js:18+ 自带全局 fetch;< 18 需要使用 polyfill 或自定义 HttpRequester。
30
+ * - 浏览器:所有现代浏览器原生支持。
31
+ *
32
+ * 实现是无状态的,可作为 SDK 单例长期复用。
33
+ */
34
+ export class FetchHttpRequester implements HttpRequester {
35
+ private readonly readTimeoutMs: number;
36
+ private readonly logRequest: boolean;
37
+ private readonly userAgent: string;
38
+ private readonly fetchImpl: typeof fetch;
39
+
40
+ constructor(config: ResolvedPushPlusConfig, fetchImpl?: typeof fetch) {
41
+ this.readTimeoutMs = config.readTimeoutMs;
42
+ this.logRequest = config.logRequest;
43
+ this.userAgent = config.userAgent;
44
+ const resolved = fetchImpl ?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefined);
45
+ if (!resolved) {
46
+ throw new PushPlusError(
47
+ '当前运行环境没有可用的 fetch 实现。请在 Node.js 18+ 中运行,' +
48
+ '或自行注入 HttpRequester 实例。',
49
+ );
50
+ }
51
+ this.fetchImpl = resolved;
52
+ }
53
+
54
+ async execute(options: HttpRequestOptions): Promise<HttpResponse> {
55
+ const { method, url, headers, body } = options;
56
+ const finalHeaders: Record<string, string> = {};
57
+
58
+ let hasContentType = false;
59
+ if (headers) {
60
+ for (const [k, v] of Object.entries(headers)) {
61
+ if (k == null || v == null) continue;
62
+ finalHeaders[k] = v;
63
+ if (k.toLowerCase() === 'content-type') hasContentType = true;
64
+ }
65
+ }
66
+ if (body != null && !hasContentType) {
67
+ finalHeaders['Content-Type'] = 'application/json;charset=UTF-8';
68
+ }
69
+ // 浏览器中不允许设置 User-Agent,仅在非浏览器环境下添加
70
+ if (typeof window === 'undefined' && !finalHeaders['User-Agent'] && !finalHeaders['user-agent']) {
71
+ finalHeaders['User-Agent'] = this.userAgent;
72
+ }
73
+
74
+ if (this.logRequest) {
75
+ // eslint-disable-next-line no-console
76
+ console.debug('[pushplus] >>>', method, url, 'body=', body);
77
+ }
78
+
79
+ const controller = new AbortController();
80
+ const timer = this.readTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.readTimeoutMs) : null;
81
+
82
+ try {
83
+ const init: RequestInit = {
84
+ method: method.toUpperCase(),
85
+ headers: finalHeaders,
86
+ signal: controller.signal,
87
+ };
88
+ if (body != null) {
89
+ init.body = body;
90
+ }
91
+ const resp = await this.fetchImpl(url, init);
92
+ const respBody = await resp.text();
93
+ if (this.logRequest) {
94
+ // eslint-disable-next-line no-console
95
+ console.debug('[pushplus] <<< status=', resp.status, 'body=', respBody);
96
+ }
97
+ return { statusCode: resp.status, body: respBody };
98
+ } catch (e: unknown) {
99
+ const err = e as { name?: string; message?: string };
100
+ if (err && err.name === 'AbortError') {
101
+ throw new PushPlusError(`调用 PushPlus 接口超时(${this.readTimeoutMs}ms): ${err.message ?? ''}`, -1, {
102
+ cause: e,
103
+ });
104
+ }
105
+ throw new PushPlusError(`调用 PushPlus 接口失败: ${err?.message ?? String(e)}`, -1, { cause: e });
106
+ } finally {
107
+ if (timer) clearTimeout(timer);
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 是否处于成功的 HTTP 状态码区间(2xx)。
114
+ */
115
+ export function isSuccessfulHttpStatus(status: number): boolean {
116
+ return status >= 200 && status < 300;
117
+ }