@perk-net/perk-pushplus-sdk 1.0.0 → 1.0.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perk-net/perk-pushplus-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "pushplus(推送加) 官方接口 JavaScript/TypeScript SDK,可在 Node.js 与浏览器中使用",
5
5
  "keywords": [
6
6
  "pushplus",
@@ -0,0 +1,241 @@
1
+ import { AccessKeyManager } from '../access-key-manager';
2
+ import { ResolvedPushPlusConfig } from '../config';
3
+ import { PushPlusError } from '../exception';
4
+ import { HttpRequester, callExecuteRaw, isSuccessfulHttpStatus } from '../http';
5
+ import {
6
+ ImageItem,
7
+ ImageUploadResult,
8
+ ImageUploadToken,
9
+ PageQuery,
10
+ PageResult,
11
+ } from '../models';
12
+ import { OpenAbstractApi } from './open-base';
13
+
14
+ /**
15
+ * 二进制图片输入。
16
+ *
17
+ * - `Uint8Array`:Node 与浏览器通用(Buffer 是 Uint8Array 的子类)
18
+ * - `ArrayBuffer`:原始字节缓冲
19
+ * - `Blob`/`File`:浏览器与 Node 18+ 都支持
20
+ */
21
+ export type ImageFileInput = Uint8Array | ArrayBuffer | Blob;
22
+
23
+ /** 通用上传选项。 */
24
+ export interface ImageUploadOptions {
25
+ /** 文件名(建议带扩展名,如 `logo.png`)。 */
26
+ fileName: string;
27
+ /** 文件 MIME 类型;未指定时按文件名后缀猜测。 */
28
+ contentType?: string;
29
+ }
30
+
31
+ /**
32
+ * 开放接口 - 图片服务(文档「十二. 图片服务接口」)。
33
+ *
34
+ * 包含 4 个接口:
35
+ *
36
+ * 1. {@link ImageApi.getUploadToken} 获取上传凭证
37
+ * 2. {@link ImageApi.upload} 上传图片到七牛云(multipart/form-data,**不**带 access-key)
38
+ * 3. {@link ImageApi.list} 已上传图片列表
39
+ * 4. {@link ImageApi.delete} 主动删除图片
40
+ *
41
+ * 另外提供 {@link ImageApi.uploadBytes} 等便捷方法,
42
+ * 内部自动「先取凭证 → 再上传」。仅支持图片类型,30 天有效期。
43
+ */
44
+ export class ImageApi extends OpenAbstractApi {
45
+ constructor(config: ResolvedPushPlusConfig, http: HttpRequester, mgr: AccessKeyManager) {
46
+ super(config, http, mgr);
47
+ }
48
+
49
+ /** 1. 获取上传凭证。 */
50
+ getUploadToken(): Promise<ImageUploadToken> {
51
+ return this.executeOpen<ImageUploadToken>('GET', '/api/open/userImage/uploadToken');
52
+ }
53
+
54
+ /**
55
+ * 2. 上传图片到七牛云。
56
+ *
57
+ * 使用「获取上传凭证」返回的 `uploadUrl` 与 `uploadToken`,
58
+ * 按七牛云表单上传规范以 `multipart/form-data` 提交。该请求
59
+ * **不会** 携带 PushPlus 的 `access-key` 头。
60
+ */
61
+ async upload(
62
+ token: ImageUploadToken,
63
+ file: ImageFileInput,
64
+ options: ImageUploadOptions,
65
+ ): Promise<ImageUploadResult> {
66
+ if (token == null) {
67
+ throw new PushPlusError('上传凭证 token 不能为 null');
68
+ }
69
+ if (!token.uploadToken) {
70
+ throw new PushPlusError('上传凭证 uploadToken 不能为空');
71
+ }
72
+ const uploadUrl = token.uploadUrl || token.uploadHost;
73
+ if (!uploadUrl) {
74
+ throw new PushPlusError('上传凭证未返回 uploadUrl/uploadHost');
75
+ }
76
+ return this.uploadToQiniu(uploadUrl, token.uploadToken, file, options);
77
+ }
78
+
79
+ /**
80
+ * 2. 上传图片到七牛云(低层方法)。直接指定上传地址与 token。
81
+ */
82
+ async uploadToQiniu(
83
+ uploadUrl: string,
84
+ uploadToken: string,
85
+ file: ImageFileInput,
86
+ options: ImageUploadOptions,
87
+ ): Promise<ImageUploadResult> {
88
+ if (!uploadUrl) {
89
+ throw new PushPlusError('uploadUrl 不能为空');
90
+ }
91
+ if (!uploadToken) {
92
+ throw new PushPlusError('uploadToken 不能为空');
93
+ }
94
+ const bytes = await toUint8Array(file);
95
+ if (bytes.byteLength === 0) {
96
+ throw new PushPlusError('上传文件内容不能为空');
97
+ }
98
+
99
+ const fileName = options.fileName || 'file';
100
+ const contentType =
101
+ options.contentType || guessContentTypeByName(fileName) || 'application/octet-stream';
102
+
103
+ const boundary = '----PushPlusBoundary' + randomBoundarySuffix();
104
+ const body = buildMultipartBody(boundary, uploadToken, fileName, contentType, bytes);
105
+
106
+ const resp = await callExecuteRaw(this.http, {
107
+ method: 'POST',
108
+ url: uploadUrl,
109
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
110
+ body,
111
+ });
112
+ if (!isSuccessfulHttpStatus(resp.statusCode)) {
113
+ throw new PushPlusError(
114
+ `上传图片到七牛云失败: status=${resp.statusCode}, body=${resp.body}`,
115
+ resp.statusCode,
116
+ );
117
+ }
118
+ let result: ImageUploadResult;
119
+ try {
120
+ result = JSON.parse(resp.body) as ImageUploadResult;
121
+ } catch (e) {
122
+ throw new PushPlusError(
123
+ `解析七牛云响应失败: ${(e as Error).message}, payload=${resp.body}`,
124
+ -1,
125
+ { cause: e },
126
+ );
127
+ }
128
+ if (result == null || typeof result !== 'object') {
129
+ throw new PushPlusError(`七牛云返回非 JSON 对象: ${resp.body}`);
130
+ }
131
+ if (result.errno !== 0) {
132
+ throw new PushPlusError(
133
+ `七牛云上传失败: errno=${result.errno}, msg=${result.msg ?? ''}`,
134
+ result.errno ?? -1,
135
+ );
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * 便捷方法:自动获取上传凭证后上传字节数组 / Blob / ArrayBuffer。
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * await client.image.uploadBytes(buffer, { fileName: 'a.png' });
146
+ * await client.image.uploadBytes(blob, { fileName: 'b.jpg', contentType: 'image/jpeg' });
147
+ * ```
148
+ */
149
+ async uploadBytes(file: ImageFileInput, options: ImageUploadOptions): Promise<ImageUploadResult> {
150
+ const token = await this.getUploadToken();
151
+ return this.upload(token, file, options);
152
+ }
153
+
154
+ /** 3. 图片列表。 */
155
+ list(query?: PageQuery): Promise<PageResult<ImageItem>> {
156
+ return this.executeOpen<PageResult<ImageItem>>(
157
+ 'POST',
158
+ '/api/open/userImage/list',
159
+ query ?? {},
160
+ );
161
+ }
162
+
163
+ /**
164
+ * 4. 主动删除图片;未删除的图片默认 30 天后由系统自动清理。
165
+ */
166
+ async delete(id: number): Promise<void> {
167
+ await this.executeOpen<unknown>(
168
+ 'DELETE',
169
+ this.appendQuery('/api/open/userImage/delete', { id }),
170
+ );
171
+ }
172
+ }
173
+
174
+ /* ============================== 内部辅助 ============================== */
175
+
176
+ async function toUint8Array(file: ImageFileInput): Promise<Uint8Array> {
177
+ if (file == null) {
178
+ throw new PushPlusError('上传文件不能为 null');
179
+ }
180
+ if (file instanceof Uint8Array) {
181
+ return file;
182
+ }
183
+ if (file instanceof ArrayBuffer) {
184
+ return new Uint8Array(file);
185
+ }
186
+ if (typeof Blob !== 'undefined' && file instanceof Blob) {
187
+ const ab = await file.arrayBuffer();
188
+ return new Uint8Array(ab);
189
+ }
190
+ throw new PushPlusError(`不支持的上传文件类型: ${Object.prototype.toString.call(file)}`);
191
+ }
192
+
193
+ function buildMultipartBody(
194
+ boundary: string,
195
+ uploadToken: string,
196
+ fileName: string,
197
+ contentType: string,
198
+ fileBytes: Uint8Array,
199
+ ): Uint8Array {
200
+ const crlf = '\r\n';
201
+ const enc = new TextEncoder();
202
+ const head = enc.encode(
203
+ `--${boundary}${crlf}` +
204
+ `Content-Disposition: form-data; name="token"${crlf}${crlf}` +
205
+ `${uploadToken}${crlf}` +
206
+ `--${boundary}${crlf}` +
207
+ `Content-Disposition: form-data; name="file"; filename="${escapeFileName(fileName)}"${crlf}` +
208
+ `Content-Type: ${contentType}${crlf}${crlf}`,
209
+ );
210
+ const tail = enc.encode(`${crlf}--${boundary}--${crlf}`);
211
+ const out = new Uint8Array(head.byteLength + fileBytes.byteLength + tail.byteLength);
212
+ out.set(head, 0);
213
+ out.set(fileBytes, head.byteLength);
214
+ out.set(tail, head.byteLength + fileBytes.byteLength);
215
+ return out;
216
+ }
217
+
218
+ function escapeFileName(name: string): string {
219
+ return name.replace(/"/g, '_').replace(/\r/g, ' ').replace(/\n/g, ' ');
220
+ }
221
+
222
+ function guessContentTypeByName(name: string | undefined): string | null {
223
+ if (!name) return null;
224
+ const lower = name.toLowerCase();
225
+ if (lower.endsWith('.png')) return 'image/png';
226
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
227
+ if (lower.endsWith('.gif')) return 'image/gif';
228
+ if (lower.endsWith('.webp')) return 'image/webp';
229
+ if (lower.endsWith('.bmp')) return 'image/bmp';
230
+ if (lower.endsWith('.svg')) return 'image/svg+xml';
231
+ return null;
232
+ }
233
+
234
+ function randomBoundarySuffix(): string {
235
+ // 32 个 hex 字符;不依赖 Node-only 的 crypto,浏览器/Node 都能跑。
236
+ let s = '';
237
+ for (let i = 0; i < 32; i++) {
238
+ s += Math.floor(Math.random() * 16).toString(16);
239
+ }
240
+ return s;
241
+ }
package/src/client.ts CHANGED
@@ -3,6 +3,7 @@ import { AccessKeyApi } from './api/access-key-api';
3
3
  import { ChannelApi } from './api/channel-api';
4
4
  import { ClawBotApi } from './api/clawbot-api';
5
5
  import { FriendApi } from './api/friend-api';
6
+ import { ImageApi } from './api/image-api';
6
7
  import { MessageApi } from './api/message-api';
7
8
  import { MessageTokenApi } from './api/message-token-api';
8
9
  import { OpenMessageApi } from './api/open-message-api';
@@ -65,6 +66,7 @@ export class PushPlusClient {
65
66
  readonly clawBot: ClawBotApi;
66
67
  readonly setting: SettingApi;
67
68
  readonly pre: PreApi;
69
+ readonly image: ImageApi;
68
70
 
69
71
  constructor(options: PushPlusClientOptions = {}) {
70
72
  this.config = resolveConfig(options);
@@ -86,6 +88,7 @@ export class PushPlusClient {
86
88
  this.clawBot = new ClawBotApi(this.config, this.httpRequester, this.accessKeyManager);
87
89
  this.setting = new SettingApi(this.config, this.httpRequester, this.accessKeyManager);
88
90
  this.pre = new PreApi(this.config, this.httpRequester, this.accessKeyManager);
91
+ this.image = new ImageApi(this.config, this.httpRequester, this.accessKeyManager);
89
92
  }
90
93
 
91
94
  /** 与 Java SDK 风格一致的 Builder 入口。 */
package/src/config.ts CHANGED
@@ -87,6 +87,6 @@ export function resolveConfig(input: PushPlusConfig | undefined | null): Resolve
87
87
  logRequest: cfg.logRequest ?? false,
88
88
  rateLimitGuardEnabled: cfg.rateLimitGuardEnabled ?? true,
89
89
  rateLimitCooldownMs: cfg.rateLimitCooldownMs ?? 0,
90
- userAgent: cfg.userAgent ?? `@perk-net/perk-pushplus-sdk/1.0.0`,
90
+ userAgent: cfg.userAgent ?? `@perk-net/perk-pushplus-sdk/1.0.1`,
91
91
  };
92
92
  }
package/src/http.ts CHANGED
@@ -8,6 +8,20 @@ export interface HttpRequestOptions {
8
8
  body?: string | null;
9
9
  }
10
10
 
11
+ /**
12
+ * 二进制请求体(multipart 上传等场景使用)。
13
+ *
14
+ * 接受 fetch `BodyInit` 中的常见二进制形态。
15
+ */
16
+ export type HttpRawBody = Uint8Array | ArrayBuffer | Blob | null;
17
+
18
+ export interface HttpRawRequestOptions {
19
+ method: string;
20
+ url: string;
21
+ headers?: Record<string, string>;
22
+ body?: HttpRawBody;
23
+ }
24
+
11
25
  export interface HttpResponse {
12
26
  statusCode: number;
13
27
  body: string;
@@ -18,9 +32,45 @@ export interface HttpResponse {
18
32
  *
19
33
  * SDK 默认提供基于 `fetch` 的实现(Node 18+ 内置 / 浏览器原生)。
20
34
  * 调用方也可以自行实现并通过 `PushPlusClient` 注入以使用其它客户端(如 axios/undici/got)。
35
+ *
36
+ * `executeRaw` 用于二进制请求体场景(如图片 multipart 上传)。
37
+ * 自定义实现可选择覆写以正确处理二进制;未覆写时调用方应通过
38
+ * {@link callExecuteRaw} 适配回退到 `execute`。
21
39
  */
22
40
  export interface HttpRequester {
23
41
  execute(options: HttpRequestOptions): Promise<HttpResponse>;
42
+ /** 可选:执行带二进制 body 的请求。 */
43
+ executeRaw?(options: HttpRawRequestOptions): Promise<HttpResponse>;
44
+ }
45
+
46
+ /**
47
+ * 调用 {@link HttpRequester} 的二进制通道。
48
+ *
49
+ * - 若实现类提供了 `executeRaw`(推荐对二进制场景覆写),则直接使用;
50
+ * - 否则按 UTF-8 把字节解码成字符串后回退到 {@link HttpRequester.execute},
51
+ * 适用于 body 本身是文本的场景。
52
+ */
53
+ export async function callExecuteRaw(
54
+ requester: HttpRequester,
55
+ options: HttpRawRequestOptions,
56
+ ): Promise<HttpResponse> {
57
+ if (typeof requester.executeRaw === 'function') {
58
+ return requester.executeRaw(options);
59
+ }
60
+ const { method, url, headers, body } = options;
61
+ let text: string | null = null;
62
+ if (body != null) {
63
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
64
+ text = await body.text();
65
+ } else if (body instanceof Uint8Array) {
66
+ text = new TextDecoder('utf-8').decode(body);
67
+ } else if (body instanceof ArrayBuffer) {
68
+ text = new TextDecoder('utf-8').decode(new Uint8Array(body));
69
+ } else {
70
+ text = String(body);
71
+ }
72
+ }
73
+ return requester.execute({ method, url, headers, body: text });
24
74
  }
25
75
 
26
76
  /**
@@ -52,7 +102,36 @@ export class FetchHttpRequester implements HttpRequester {
52
102
  }
53
103
 
54
104
  async execute(options: HttpRequestOptions): Promise<HttpResponse> {
55
- const { method, url, headers, body } = options;
105
+ return this.doExecute({
106
+ method: options.method,
107
+ url: options.url,
108
+ headers: options.headers,
109
+ body: options.body ?? null,
110
+ bodyForLog: options.body ?? null,
111
+ defaultContentType: 'application/json;charset=UTF-8',
112
+ });
113
+ }
114
+
115
+ async executeRaw(options: HttpRawRequestOptions): Promise<HttpResponse> {
116
+ return this.doExecute({
117
+ method: options.method,
118
+ url: options.url,
119
+ headers: options.headers,
120
+ body: options.body ?? null,
121
+ bodyForLog: null,
122
+ defaultContentType: 'application/octet-stream',
123
+ });
124
+ }
125
+
126
+ private async doExecute(args: {
127
+ method: string;
128
+ url: string;
129
+ headers?: Record<string, string>;
130
+ body: string | HttpRawBody;
131
+ bodyForLog: string | null;
132
+ defaultContentType: string;
133
+ }): Promise<HttpResponse> {
134
+ const { method, url, headers, body, bodyForLog, defaultContentType } = args;
56
135
  const finalHeaders: Record<string, string> = {};
57
136
 
58
137
  let hasContentType = false;
@@ -63,8 +142,8 @@ export class FetchHttpRequester implements HttpRequester {
63
142
  if (k.toLowerCase() === 'content-type') hasContentType = true;
64
143
  }
65
144
  }
66
- if (body != null && !hasContentType) {
67
- finalHeaders['Content-Type'] = 'application/json;charset=UTF-8';
145
+ if (body != null && !hasContentType && defaultContentType) {
146
+ finalHeaders['Content-Type'] = defaultContentType;
68
147
  }
69
148
  // 浏览器中不允许设置 User-Agent,仅在非浏览器环境下添加
70
149
  if (typeof window === 'undefined' && !finalHeaders['User-Agent'] && !finalHeaders['user-agent']) {
@@ -72,8 +151,14 @@ export class FetchHttpRequester implements HttpRequester {
72
151
  }
73
152
 
74
153
  if (this.logRequest) {
75
- // eslint-disable-next-line no-console
76
- console.debug('[pushplus] >>>', method, url, 'body=', body);
154
+ if (bodyForLog != null) {
155
+ // eslint-disable-next-line no-console
156
+ console.debug('[pushplus] >>>', method, url, 'body=', bodyForLog);
157
+ } else {
158
+ const len = bodyLength(body);
159
+ // eslint-disable-next-line no-console
160
+ console.debug('[pushplus] >>>', method, url, 'bodyBytes=', len);
161
+ }
77
162
  }
78
163
 
79
164
  const controller = new AbortController();
@@ -86,7 +171,8 @@ export class FetchHttpRequester implements HttpRequester {
86
171
  signal: controller.signal,
87
172
  };
88
173
  if (body != null) {
89
- init.body = body;
174
+ // fetch BodyInit 兼容 string / Uint8Array / ArrayBuffer / Blob 等。
175
+ init.body = body as BodyInit;
90
176
  }
91
177
  const resp = await this.fetchImpl(url, init);
92
178
  const respBody = await resp.text();
@@ -109,6 +195,15 @@ export class FetchHttpRequester implements HttpRequester {
109
195
  }
110
196
  }
111
197
 
198
+ function bodyLength(body: unknown): number {
199
+ if (body == null) return 0;
200
+ if (typeof body === 'string') return body.length;
201
+ if (body instanceof Uint8Array) return body.byteLength;
202
+ if (body instanceof ArrayBuffer) return body.byteLength;
203
+ if (typeof Blob !== 'undefined' && body instanceof Blob) return body.size;
204
+ return -1;
205
+ }
206
+
112
207
  /**
113
208
  * 是否处于成功的 HTTP 状态码区间(2xx)。
114
209
  */
package/src/index.ts CHANGED
@@ -33,9 +33,12 @@ export { PushPlusError, PushPlusException } from './exception';
33
33
  export {
34
34
  type HttpRequester,
35
35
  type HttpRequestOptions,
36
+ type HttpRawBody,
37
+ type HttpRawRequestOptions,
36
38
  type HttpResponse,
37
39
  FetchHttpRequester,
38
40
  isSuccessfulHttpStatus,
41
+ callExecuteRaw,
39
42
  } from './http';
40
43
  export { RateLimitGuard } from './rate-limit';
41
44
  export { AccessKeyManager } from './access-key-manager';
@@ -89,6 +92,9 @@ export type {
89
92
  PreDetail,
90
93
  PreSaveRequest,
91
94
  PreTestRequest,
95
+ ImageUploadToken,
96
+ ImageUploadResult,
97
+ ImageItem,
92
98
  } from './models';
93
99
 
94
100
  export {
@@ -114,3 +120,8 @@ export { ChannelApi } from './api/channel-api';
114
120
  export { ClawBotApi } from './api/clawbot-api';
115
121
  export { SettingApi } from './api/setting-api';
116
122
  export { PreApi } from './api/pre-api';
123
+ export {
124
+ ImageApi,
125
+ type ImageFileInput,
126
+ type ImageUploadOptions,
127
+ } from './api/image-api';
package/src/models.ts CHANGED
@@ -578,3 +578,65 @@ export interface PreTestRequest {
578
578
  contentType?: number;
579
579
  message?: string;
580
580
  }
581
+
582
+ /* ============================== 开放接口 - image ============================== */
583
+
584
+ /**
585
+ * 图片服务 - 获取上传凭证响应。
586
+ *
587
+ * 对应文档「十二. 图片服务接口 / 1. 获取上传凭证」。
588
+ * 返回七牛云表单上传所需的 token 及上传域名、存储桶等信息。
589
+ */
590
+ export interface ImageUploadToken {
591
+ /** 七牛云上传凭证。 */
592
+ uploadToken?: string;
593
+ /** 七牛云上传域名,例如 `https://upload.qiniup.com`。 */
594
+ uploadHost?: string;
595
+ /** 七牛云上传地址,一般等同于 `uploadHost + "/"`。 */
596
+ uploadUrl?: string;
597
+ /** 七牛云存储桶名称。 */
598
+ bucket?: string;
599
+ /** 凭证有效时间(秒)。 */
600
+ expiresIn?: number;
601
+ }
602
+
603
+ /**
604
+ * 图片服务 - 上传图片响应(由七牛云直接返回)。
605
+ *
606
+ * 注意:该响应不是 PushPlus 统一的 `{code, msg, data}` 结构,
607
+ * 判断成功使用 `errno === 0`。
608
+ */
609
+ export interface ImageUploadResult {
610
+ /** 错误码;0 表示成功。 */
611
+ errno?: number;
612
+ /** 文件扩展名,例如 `.png`。 */
613
+ ext?: string;
614
+ /** 文件名。 */
615
+ fname?: string;
616
+ /** 文件大小(字节)。 */
617
+ fsize?: number;
618
+ /** 七牛云文件 hash。 */
619
+ hash?: string;
620
+ /** 对象存储中的路径 key。 */
621
+ key?: string;
622
+ /** MIME 类型,例如 `image/png`。 */
623
+ mimeType?: string;
624
+ /** 响应说明。 */
625
+ msg?: string;
626
+ /** 缩略图地址。 */
627
+ thumbnail?: string;
628
+ /** 图片访问地址。 */
629
+ url?: string;
630
+ }
631
+
632
+ /** 图片服务 - 图片列表项。 */
633
+ export interface ImageItem {
634
+ /** 图片 id。 */
635
+ id?: number;
636
+ /** 图片地址。 */
637
+ imgUrl?: string;
638
+ /** 缩略图地址。 */
639
+ thumbnail?: string;
640
+ /** 创建时间。 */
641
+ createTime?: string;
642
+ }