@frankie0736/dingtalk-notify 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # @frankie0736/dingtalk-notify
2
2
 
3
- Thin, zero-dependency client for the [DingTalk Notification Server](https://github.com/frankie0736/dingtalk-notify-client). Send DingTalk group-chat notifications over a single authenticated endpoint — no HMAC signing, no `@mobile` injection, no per-service boilerplate.
3
+ [![npm version](https://img.shields.io/npm/v/@frankie0736/dingtalk-notify.svg)](https://www.npmjs.com/package/@frankie0736/dingtalk-notify)
4
+ [![CI](https://github.com/frankie0736/dingtalk-notify-client/actions/workflows/ci.yml/badge.svg)](https://github.com/frankie0736/dingtalk-notify-client/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/npm/l/@frankie0736/dingtalk-notify.svg)](./LICENSE)
4
6
 
5
- The server handles signing, encryption, and auditing. This package handles the one thing every caller would otherwise re-implement: a correct `fetch` with the right headers, body shape, and crucially the "HTTP 200 but DingTalk rejected it" failure that a naive wrapper silently treats as success.
7
+ Zero-dependency DingTalk custom robot client. It signs the robot webhook locally with HMAC-SHA256, builds DingTalk's `text` / `markdown` payloads, injects `@` mentions, and posts directly to DingTalk.
6
8
 
7
- - **Pure ESM**, zero runtime dependencies.
8
- - Runs on **Node.js 18+, Bun, and Cloudflare Workers / Edge** (anything with global `fetch`).
9
- - **Throws on failure**, with a single `DingTalkError` whose `kind` tells you exactly what went wrong.
9
+ - Pure ESM.
10
+ - Runs on Node.js 18+, Bun, and Cloudflare Workers / Edge.
11
+ - No runtime dependency on any notification server.
12
+ - Throws one `DingTalkError` type for validation, network, HTTP, and DingTalk business rejection failures.
10
13
 
11
- > Not for browsers: the bearer token must never ship to a frontend.
14
+ Do not use this package in browsers. The DingTalk webhook and secret are credentials.
12
15
 
13
16
  ## Install
14
17
 
@@ -18,60 +21,113 @@ npm install @frankie0736/dingtalk-notify
18
21
  bun add @frankie0736/dingtalk-notify
19
22
  ```
20
23
 
21
- ## Quick start
24
+ ## Quick Start
22
25
 
23
26
  ```ts
24
27
  import { DingTalk } from '@frankie0736/dingtalk-notify';
25
28
 
26
- const dt = new DingTalk({ token: process.env.DINGTALK_TOKEN! }); // 'dnk_...'
29
+ const dt = new DingTalk({
30
+ webhook: process.env.DINGTALK_WEBHOOK!,
31
+ secret: process.env.DINGTALK_SECRET, // optional for keyword/IP-whitelist robots
32
+ });
27
33
 
28
- // Plain text atMobiles here fires a real @-push + device notification.
29
- await dt.text('🔔 Build #123 failed', { atMobiles: ['13800138000'] });
34
+ await dt.text('Build #123 failed', { atMobiles: ['13800138000'] });
30
35
 
31
- // Markdown card — rich formatting (no push; see below).
32
- await dt.markdown('Build #123', '### main 失败\n- env: prod\n- [logs](https://example.com)');
36
+ await dt.markdown(
37
+ 'Build #123',
38
+ '### main failed\n- env: prod\n- [logs](https://example.com)',
39
+ );
33
40
  ```
34
41
 
35
- ## `@`-mention behavior (a DingTalk platform quirk)
42
+ ## Mention Behavior
36
43
 
37
44
  | Mode | Rich formatting | `atMobiles` triggers push? |
38
45
  | --- | --- | --- |
39
- | `text` | No | **Yes** real blue-badge @ + device notification |
40
- | `markdown` | Yes (lists, links, bold, code) | **No** name renders in the card, but no push fires |
46
+ | `text` | No | Yes. Real blue-badge `@` and device notification. |
47
+ | `markdown` | Yes | No. DingTalk can render the name in the card, but it does not push. |
41
48
 
42
- This asymmetry is a DingTalk limitation, not a choice of this library.
49
+ This is a DingTalk platform behavior. The package preserves it instead of hiding it.
43
50
 
44
- ### `combo()` — push + rich content in one call
51
+ ### `combo()`
45
52
 
46
- When you need both a reliable @-push **and** a rich card, send a short `text` (the actual notification) followed by a `markdown` card with the detail:
53
+ When you need both a real push and rich detail, send a short `text` first and a `markdown` card second:
47
54
 
48
55
  ```ts
49
56
  await dt.combo({
50
- alert: '🔔 Build #123 failed see detail', // short text, carries the @
57
+ alert: 'Build #123 failed. See detail.',
51
58
  title: 'Build #123',
52
- detail: '### main 失败\n- env: prod\n- [logs](https://example.com)',
59
+ detail: '### main failed\n- env: prod\n- [logs](https://example.com)',
53
60
  atMobiles: ['13800138000'],
54
61
  });
55
62
  ```
56
63
 
57
- The `text` leg is sent first (it's the real push). If the `markdown` leg then fails, the thrown error carries `comboLeg: 'markdown'` and the already-sent text result on `comboPartial.text`.
64
+ If the markdown leg fails after the text leg succeeds, the thrown `DingTalkError` has `comboLeg: 'markdown'` and `comboPartial.text`.
65
+
66
+ ## API
67
+
68
+ ```ts
69
+ new DingTalk({
70
+ webhook: 'https://oapi.dingtalk.com/robot/send?access_token=...',
71
+ secret: 'SEC...', // optional
72
+ timeoutMs: 10_000, // default
73
+ retries: 0, // default; retries network failures and HTTP 5xx only
74
+ fetch: customFetch, // optional
75
+ now: () => Date.now(), // optional deterministic signing hook
76
+ });
77
+ ```
78
+
79
+ Methods:
80
+
81
+ ```ts
82
+ await dt.text(content, { atMobiles, atAll });
83
+ await dt.markdown(title, content, { atMobiles, atAll });
84
+ await dt.notify({ type: 'text', content, atMobiles, atAll });
85
+ await dt.notify({ type: 'markdown', title, content, atMobiles, atAll });
86
+ await dt.combo({ alert, title, detail, atMobiles, atAll });
87
+ ```
88
+
89
+ Validation runs before any request:
58
90
 
59
- ## Error handling
91
+ - `content`: 1-20000 chars.
92
+ - markdown `title`: 1-200 chars.
93
+ - `atMobiles`: max 50, each matching `/^\+?\d{6,20}$/`.
94
+ - `atAll` and non-empty `atMobiles` are mutually exclusive.
60
95
 
61
- Every method throws [`DingTalkError`](./src/errors.ts) on failure. Branch on `kind`:
96
+ ## Result
97
+
98
+ `text` / `markdown` / `notify` resolve to DingTalk's direct verdict:
62
99
 
63
100
  ```ts
64
- import { DingTalk, DingTalkError } from '@frankie0736/dingtalk-notify';
101
+ {
102
+ httpStatus: 200,
103
+ errcode: 0,
104
+ errmsg: 'ok',
105
+ rawBody: '{"errcode":0,"errmsg":"ok"}',
106
+ }
107
+ ```
108
+
109
+ `combo()` resolves to `{ text: NotifyResult, markdown: NotifyResult }`.
110
+
111
+ ## Error Handling
112
+
113
+ Every method throws `DingTalkError` on failure:
114
+
115
+ ```ts
116
+ import { DingTalkError } from '@frankie0736/dingtalk-notify';
65
117
 
66
118
  try {
67
119
  await dt.text('hi', { atMobiles: ['13800138000'] });
68
120
  } catch (err) {
69
121
  if (err instanceof DingTalkError) {
70
122
  switch (err.kind) {
71
- case 'validation': break; // bad input — no request was sent
72
- case 'network': break; // fetch failed or timed out
73
- case 'http': break; // non-2xx; err.status, err.serverError
74
- case 'rejected': break; // HTTP 200 but DingTalk said no; err.errcode, err.errmsg, err.logId
123
+ case 'validation':
124
+ break; // bad local input; no request sent
125
+ case 'network':
126
+ break; // fetch failed or timed out
127
+ case 'http':
128
+ break; // non-2xx; err.status and err.rawBody are available
129
+ case 'rejected':
130
+ break; // HTTP 2xx, but DingTalk returned errcode !== 0
75
131
  }
76
132
  }
77
133
  }
@@ -79,51 +135,28 @@ try {
79
135
 
80
136
  | `kind` | Meaning | Useful fields |
81
137
  | --- | --- | --- |
82
- | `validation` | Input rejected client-side; **no request sent** | `message` |
83
- | `network` | `fetch` threw or the request timed out | `cause` |
84
- | `http` | Server replied non-2xx | `status`, `serverError`, `details`, `requestId` |
85
- | `rejected` | HTTP 200 but `ok:false` (DingTalk rejected) | `errcode`, `errmsg`, `rawBody`, `logId`, `requestId` |
86
-
87
- `err.retryable` is `true` for `network` failures and HTTP 5xx.
88
-
89
- ## Options
138
+ | `validation` | Local input rejected before sending | `message` |
139
+ | `network` | `fetch` threw or timed out | `cause` |
140
+ | `http` | DingTalk replied non-2xx | `status`, `errcode`, `errmsg`, `rawBody` |
141
+ | `rejected` | DingTalk replied 2xx with `errcode !== 0` | `errcode`, `errmsg`, `rawBody` |
90
142
 
91
- ```ts
92
- new DingTalk({
93
- token: 'dnk_...', // required, per-robot bearer token
94
- baseUrl: 'https://dingtalk-notify.210k.cc', // default
95
- timeoutMs: 10_000, // per-request, via AbortController
96
- retries: 0, // retries network/timeout/5xx only; never 4xx or rejected
97
- fetch: customFetch, // optional; defaults to global fetch
98
- userAgent: '@frankie0736/dingtalk-notify', // optional override
99
- });
100
- ```
143
+ `err.retryable` is true for `network` and HTTP 5xx failures only. DingTalk business rejections are not retried.
101
144
 
102
- ## Result shape
145
+ ## DingTalk Limits
103
146
 
104
- `text` / `markdown` / `notify` resolve to:
105
-
106
- ```ts
107
- {
108
- logId: string; // server audit-log id (lg_...); cross-reference in /admin/logs
109
- requestId: string; // trace id (rq_...); also in server logs
110
- dingtalk: { httpStatus: number | null; errcode: number | null; errmsg: string | null };
111
- }
112
- ```
113
-
114
- `combo` resolves to `{ text: NotifyResult, markdown: NotifyResult }`.
115
-
116
- > Field names are camelCase here; the server's wire format uses snake_case (`log_id`, `request_id`, `at_mobiles`). The SDK converts both ways for you.
147
+ DingTalk custom robots are limited to 20 messages per minute per robot. This package does not add client-side throttling; keep rate control at your job queue, worker, or application boundary.
117
148
 
118
149
  ## Development
119
150
 
120
151
  ```bash
121
152
  bun install
122
- bun run check # typecheck
123
- bun run test # build + node:test
124
- bun run build # emit dist/
153
+ bun run check
154
+ bun run test
155
+ bun run build
125
156
  ```
126
157
 
158
+ Tests import `../dist/index.js`, so use `bun run test` after changing `src/`; it builds first.
159
+
127
160
  ## License
128
161
 
129
- MIT © Frankie
162
+ MIT (c) Frankie
package/dist/client.d.ts CHANGED
@@ -1,43 +1,19 @@
1
- import type { ComboInput, ComboResult, DingTalkOptions, NotifyBody, NotifyResult } from './types.js';
2
- /**
3
- * Client for the DingTalk Notification Server.
4
- *
5
- * ```ts
6
- * const dt = new DingTalk({ token: process.env.DINGTALK_TOKEN! });
7
- * await dt.text('🔔 Build #123 failed', { atMobiles: ['13800138000'] });
8
- * ```
9
- *
10
- * Every method throws {@link DingTalkError} on failure — including the
11
- * easy-to-miss case where the transport succeeds but DingTalk rejects the
12
- * message (HTTP 200, `ok:false`).
13
- */
1
+ import type { AtOptions, ComboInput, ComboResult, DingTalkOptions, NotifyBody, NotifyResult } from './types.js';
14
2
  export declare class DingTalk {
15
3
  #private;
16
4
  constructor(options: DingTalkOptions);
17
- /** Thin core: send a fully-formed message body. */
5
+ /** Send a fully-formed `text` or `markdown` message. */
18
6
  notify(body: NotifyBody): Promise<NotifyResult>;
19
7
  /** Send a plain `text` message. `atMobiles` here triggers a real @-push. */
20
- text(content: string, opts?: {
21
- atMobiles?: string[];
22
- atAll?: boolean;
23
- }): Promise<NotifyResult>;
8
+ text(content: string, opts?: AtOptions): Promise<NotifyResult>;
24
9
  /**
25
- * Send a `markdown` card. Note: per the DingTalk platform, `@` mentions
26
- * render in the card but do **not** fire a device push use {@link combo}
27
- * (or a separate {@link text} call) when you need the push.
10
+ * Send a `markdown` card. Per DingTalk, `@` mentions render in the card but
11
+ * do not fire a device push; use {@link combo} when you need both.
28
12
  */
29
- markdown(title: string, content: string, opts?: {
30
- atMobiles?: string[];
31
- atAll?: boolean;
32
- }): Promise<NotifyResult>;
13
+ markdown(title: string, content: string, opts?: AtOptions): Promise<NotifyResult>;
33
14
  /**
34
- * Recommended pattern when you want both a reliable @-push and rich content:
35
- * send a short `text` (the actual notification, carrying the @) followed by a
36
- * `markdown` card with the full detail.
37
- *
38
- * The `text` leg is sent first because it is the real push. If the `markdown`
39
- * leg then fails, the thrown {@link DingTalkError} carries `comboLeg:'markdown'`
40
- * and the already-succeeded `text` result on `comboPartial.text`.
15
+ * Send a short `text` push first, then a rich `markdown` card. If the second
16
+ * leg fails, the thrown error carries `comboPartial.text`.
41
17
  */
42
18
  combo(input: ComboInput): Promise<ComboResult>;
43
19
  }
package/dist/client.js CHANGED
@@ -1,41 +1,24 @@
1
1
  import { DingTalkError } from './errors.js';
2
- const DEFAULT_BASE_URL = 'https://dingtalk-notify.210k.cc';
3
- const NOTIFY_PATH = '/api/v1/notify';
4
2
  const DEFAULT_TIMEOUT_MS = 10_000;
5
- const DEFAULT_USER_AGENT = '@frankie0736/dingtalk-notify';
6
3
  const MOBILE_RE = /^\+?\d{6,20}$/;
4
+ const enc = new TextEncoder();
7
5
  const sleep = (ms) => new Promise((resolve) => {
8
6
  setTimeout(resolve, ms);
9
7
  });
10
- /**
11
- * Client for the DingTalk Notification Server.
12
- *
13
- * ```ts
14
- * const dt = new DingTalk({ token: process.env.DINGTALK_TOKEN! });
15
- * await dt.text('🔔 Build #123 failed', { atMobiles: ['13800138000'] });
16
- * ```
17
- *
18
- * Every method throws {@link DingTalkError} on failure — including the
19
- * easy-to-miss case where the transport succeeds but DingTalk rejects the
20
- * message (HTTP 200, `ok:false`).
21
- */
22
8
  export class DingTalk {
23
- #token;
24
- #url;
9
+ #webhook;
10
+ #secret;
25
11
  #timeoutMs;
26
12
  #retries;
27
13
  #fetch;
28
- #userAgent;
14
+ #now;
29
15
  constructor(options) {
30
- if (!options || typeof options.token !== 'string' || options.token.length === 0) {
31
- throw new DingTalkError({ kind: 'validation', message: 'token is required' });
16
+ if (!options || typeof options.webhook !== 'string' || options.webhook.length === 0) {
17
+ throw new DingTalkError({ kind: 'validation', message: 'webhook is required' });
18
+ }
19
+ if (options.secret !== undefined && (typeof options.secret !== 'string' || options.secret.length === 0)) {
20
+ throw new DingTalkError({ kind: 'validation', message: 'secret must be a non-empty string when provided' });
32
21
  }
33
- const base = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
34
- this.#token = options.token;
35
- this.#url = base + NOTIFY_PATH;
36
- this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
- this.#retries = Math.max(0, options.retries ?? 0);
38
- this.#userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
39
22
  const f = options.fetch ?? globalThis.fetch;
40
23
  if (typeof f !== 'function') {
41
24
  throw new DingTalkError({
@@ -43,12 +26,16 @@ export class DingTalk {
43
26
  message: 'global fetch is unavailable; pass options.fetch',
44
27
  });
45
28
  }
46
- // Preserve `this` binding for environments where fetch is a bound global.
29
+ this.#webhook = options.webhook;
30
+ this.#secret = options.secret;
31
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
32
+ this.#retries = Math.max(0, options.retries ?? 0);
47
33
  this.#fetch = f.bind(globalThis);
34
+ this.#now = options.now ?? Date.now;
48
35
  }
49
- /** Thin core: send a fully-formed message body. */
36
+ /** Send a fully-formed `text` or `markdown` message. */
50
37
  async notify(body) {
51
- const wire = buildWire(body);
38
+ const wire = buildDingTalkBody(validateBody(body));
52
39
  return this.#sendWithRetry(wire);
53
40
  }
54
41
  /** Send a plain `text` message. `atMobiles` here triggers a real @-push. */
@@ -61,9 +48,8 @@ export class DingTalk {
61
48
  return this.notify(body);
62
49
  }
63
50
  /**
64
- * Send a `markdown` card. Note: per the DingTalk platform, `@` mentions
65
- * render in the card but do **not** fire a device push use {@link combo}
66
- * (or a separate {@link text} call) when you need the push.
51
+ * Send a `markdown` card. Per DingTalk, `@` mentions render in the card but
52
+ * do not fire a device push; use {@link combo} when you need both.
67
53
  */
68
54
  async markdown(title, content, opts = {}) {
69
55
  const body = { type: 'markdown', title, content };
@@ -74,13 +60,8 @@ export class DingTalk {
74
60
  return this.notify(body);
75
61
  }
76
62
  /**
77
- * Recommended pattern when you want both a reliable @-push and rich content:
78
- * send a short `text` (the actual notification, carrying the @) followed by a
79
- * `markdown` card with the full detail.
80
- *
81
- * The `text` leg is sent first because it is the real push. If the `markdown`
82
- * leg then fails, the thrown {@link DingTalkError} carries `comboLeg:'markdown'`
83
- * and the already-succeeded `text` result on `comboPartial.text`.
63
+ * Send a short `text` push first, then a rich `markdown` card. If the second
64
+ * leg fails, the thrown error carries `comboPartial.text`.
84
65
  */
85
66
  async combo(input) {
86
67
  const at = {};
@@ -103,11 +84,11 @@ export class DingTalk {
103
84
  throw tagComboLeg(err, 'markdown', { text: textResult });
104
85
  }
105
86
  }
106
- async #sendWithRetry(wire) {
87
+ async #sendWithRetry(body) {
107
88
  let attempt = 0;
108
89
  for (;;) {
109
90
  try {
110
- return await this.#sendOnce(wire);
91
+ return await this.#sendOnce(body);
111
92
  }
112
93
  catch (err) {
113
94
  const isRetryable = err instanceof DingTalkError && err.retryable;
@@ -118,7 +99,7 @@ export class DingTalk {
118
99
  }
119
100
  }
120
101
  }
121
- async #sendOnce(wire) {
102
+ async #sendOnce(body) {
122
103
  const controller = new AbortController();
123
104
  let timedOut = false;
124
105
  const timer = setTimeout(() => {
@@ -127,15 +108,10 @@ export class DingTalk {
127
108
  }, this.#timeoutMs);
128
109
  let res;
129
110
  try {
130
- res = await this.#fetch(this.#url, {
111
+ res = await this.#fetch(await this.#requestUrl(), {
131
112
  method: 'POST',
132
- headers: {
133
- 'Content-Type': 'application/json',
134
- Accept: 'application/json',
135
- Authorization: `Bearer ${this.#token}`,
136
- 'User-Agent': this.#userAgent,
137
- },
138
- body: JSON.stringify(wire),
113
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
114
+ body: JSON.stringify(body),
139
115
  signal: controller.signal,
140
116
  });
141
117
  }
@@ -149,82 +125,58 @@ export class DingTalk {
149
125
  finally {
150
126
  clearTimeout(timer);
151
127
  }
152
- const rawText = await res.text().catch(() => '');
153
- let parsed;
154
- if (rawText.length > 0) {
155
- try {
156
- parsed = JSON.parse(rawText);
157
- }
158
- catch {
159
- parsed = undefined;
160
- }
161
- }
128
+ const rawBody = await res.text().catch(() => '');
129
+ const parsed = parseDingTalkResponse(rawBody);
162
130
  if (!res.ok) {
163
131
  throw new DingTalkError({
164
132
  kind: 'http',
165
133
  status: res.status,
166
- message: `server returned HTTP ${res.status}${parsed?.error ? ` (${parsed.error})` : ''}`,
167
- ...(parsed?.error !== undefined ? { serverError: parsed.error } : {}),
168
- ...(parsed?.details !== undefined ? { details: parsed.details } : {}),
169
- ...(parsed?.request_id !== undefined ? { requestId: parsed.request_id } : {}),
170
- });
171
- }
172
- if (!parsed) {
173
- throw new DingTalkError({
174
- kind: 'http',
175
- status: res.status,
176
- message: `server returned HTTP ${res.status} with an unparseable body`,
134
+ message: `DingTalk returned HTTP ${res.status}`,
135
+ errcode: parsed.errcode,
136
+ errmsg: parsed.errmsg,
137
+ rawBody,
177
138
  });
178
139
  }
179
- const dt = parsed.dingtalk ?? {};
180
- if (parsed.ok === true) {
140
+ if (parsed.errcode === 0) {
181
141
  return {
182
- logId: parsed.log_id ?? '',
183
- requestId: parsed.request_id ?? '',
184
- dingtalk: {
185
- httpStatus: dt.http_status ?? null,
186
- errcode: dt.errcode ?? null,
187
- errmsg: dt.errmsg ?? null,
188
- },
142
+ httpStatus: res.status,
143
+ errcode: parsed.errcode,
144
+ errmsg: parsed.errmsg,
145
+ rawBody,
189
146
  };
190
147
  }
191
- // HTTP 200 but DingTalk rejected the message.
192
148
  throw new DingTalkError({
193
149
  kind: 'rejected',
194
- message: `DingTalk rejected the message: ${dt.errmsg ?? 'unknown'} (errcode ${dt.errcode ?? 'unknown'})`,
195
- errcode: dt.errcode ?? null,
196
- errmsg: dt.errmsg ?? null,
197
- ...(dt.raw_body !== undefined ? { rawBody: dt.raw_body } : {}),
198
- ...(parsed.log_id !== undefined ? { logId: parsed.log_id } : {}),
199
- ...(parsed.request_id !== undefined ? { requestId: parsed.request_id } : {}),
150
+ message: `DingTalk rejected the message: ${parsed.errmsg ?? 'unknown'} (errcode ${parsed.errcode ?? 'unknown'})`,
151
+ errcode: parsed.errcode,
152
+ errmsg: parsed.errmsg,
153
+ rawBody,
200
154
  });
201
155
  }
156
+ async #requestUrl() {
157
+ if (this.#secret === undefined)
158
+ return this.#webhook;
159
+ return buildSignedUrl(this.#webhook, this.#secret, this.#now().toString());
160
+ }
202
161
  }
203
- /**
204
- * Re-wrap a leg failure with combo context, preserving every field. Non-SDK
205
- * errors (which shouldn't normally occur) are returned untouched.
206
- */
207
- function tagComboLeg(err, leg, partial) {
208
- if (!(err instanceof DingTalkError))
209
- return err;
210
- return new DingTalkError({
211
- kind: err.kind,
212
- message: `combo ${leg} leg failed: ${err.message}`,
213
- ...(err.status !== undefined ? { status: err.status } : {}),
214
- ...(err.serverError !== undefined ? { serverError: err.serverError } : {}),
215
- ...(err.details !== undefined ? { details: err.details } : {}),
216
- ...(err.errcode !== undefined ? { errcode: err.errcode } : {}),
217
- ...(err.errmsg !== undefined ? { errmsg: err.errmsg } : {}),
218
- ...(err.rawBody !== undefined ? { rawBody: err.rawBody } : {}),
219
- ...(err.logId !== undefined ? { logId: err.logId } : {}),
220
- ...(err.requestId !== undefined ? { requestId: err.requestId } : {}),
221
- comboLeg: leg,
222
- ...(partial !== undefined ? { comboPartial: partial } : {}),
223
- cause: err,
224
- });
162
+ function b64encode(bytes) {
163
+ let s = '';
164
+ for (const b of bytes)
165
+ s += String.fromCharCode(b);
166
+ return btoa(s);
167
+ }
168
+ async function sign(secret, timestamp) {
169
+ const stringToSign = `${timestamp}\n${secret}`;
170
+ const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
171
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(stringToSign)));
172
+ return encodeURIComponent(b64encode(sig));
225
173
  }
226
- /** Validate input at the boundary and convert to the snake_case wire shape. */
227
- function buildWire(body) {
174
+ async function buildSignedUrl(webhook, secret, timestamp) {
175
+ const s = await sign(secret, timestamp);
176
+ const sep = webhook.includes('?') ? '&' : '?';
177
+ return `${webhook}${sep}timestamp=${timestamp}&sign=${s}`;
178
+ }
179
+ function validateBody(body) {
228
180
  if (!body || (body.type !== 'text' && body.type !== 'markdown')) {
229
181
  throw new DingTalkError({
230
182
  kind: 'validation',
@@ -234,28 +186,27 @@ function buildWire(body) {
234
186
  if (typeof body.content !== 'string' || body.content.length < 1 || body.content.length > 20_000) {
235
187
  throw new DingTalkError({
236
188
  kind: 'validation',
237
- message: 'content must be a string of 120000 chars',
189
+ message: 'content must be a string of 1-20000 chars',
238
190
  });
239
191
  }
240
- const wire = { type: body.type, content: body.content };
192
+ const normalized = { type: body.type, content: body.content };
241
193
  if (body.type === 'markdown') {
242
194
  if (typeof body.title !== 'string' || body.title.length < 1 || body.title.length > 200) {
243
195
  throw new DingTalkError({
244
196
  kind: 'validation',
245
- message: 'markdown title must be a string of 1200 chars',
197
+ message: 'markdown title must be a string of 1-200 chars',
246
198
  });
247
199
  }
248
- wire.title = body.title;
200
+ normalized.title = body.title;
249
201
  }
250
- const atMobiles = body.atMobiles;
251
- if (atMobiles !== undefined) {
252
- if (!Array.isArray(atMobiles) || atMobiles.length > 50) {
202
+ if (body.atMobiles !== undefined) {
203
+ if (!Array.isArray(body.atMobiles) || body.atMobiles.length > 50) {
253
204
  throw new DingTalkError({
254
205
  kind: 'validation',
255
206
  message: 'atMobiles must be an array of at most 50 numbers',
256
207
  });
257
208
  }
258
- for (const m of atMobiles) {
209
+ for (const m of body.atMobiles) {
259
210
  if (typeof m !== 'string' || !MOBILE_RE.test(m)) {
260
211
  throw new DingTalkError({
261
212
  kind: 'validation',
@@ -263,17 +214,68 @@ function buildWire(body) {
263
214
  });
264
215
  }
265
216
  }
266
- if (atMobiles.length > 0)
267
- wire.at_mobiles = atMobiles;
217
+ if (body.atMobiles.length > 0)
218
+ normalized.atMobiles = body.atMobiles;
268
219
  }
269
220
  if (body.atAll === true) {
270
- if (wire.at_mobiles && wire.at_mobiles.length > 0) {
221
+ if (normalized.atMobiles && normalized.atMobiles.length > 0) {
271
222
  throw new DingTalkError({
272
223
  kind: 'validation',
273
224
  message: 'atAll and atMobiles are mutually exclusive',
274
225
  });
275
226
  }
276
- wire.at_all = true;
227
+ normalized.atAll = true;
228
+ }
229
+ return normalized;
230
+ }
231
+ function buildDingTalkBody(input) {
232
+ const at = {
233
+ atMobiles: input.atMobiles ?? [],
234
+ isAtAll: input.atAll === true,
235
+ };
236
+ if (input.type === 'text') {
237
+ return {
238
+ msgtype: 'text',
239
+ text: { content: input.content },
240
+ at,
241
+ };
277
242
  }
278
- return wire;
243
+ const trailingMentions = input.atMobiles && input.atMobiles.length > 0
244
+ ? '\n\n' + input.atMobiles.map((m) => `@${m} `).join('')
245
+ : '';
246
+ return {
247
+ msgtype: 'markdown',
248
+ markdown: {
249
+ title: input.title ?? 'Notification',
250
+ text: input.content + trailingMentions,
251
+ },
252
+ at,
253
+ };
254
+ }
255
+ function parseDingTalkResponse(rawBody) {
256
+ try {
257
+ const parsed = JSON.parse(rawBody);
258
+ return {
259
+ errcode: typeof parsed.errcode === 'number' ? parsed.errcode : null,
260
+ errmsg: typeof parsed.errmsg === 'string' ? parsed.errmsg : null,
261
+ };
262
+ }
263
+ catch {
264
+ return { errcode: null, errmsg: null };
265
+ }
266
+ }
267
+ function tagComboLeg(err, leg, partial) {
268
+ if (!(err instanceof DingTalkError))
269
+ return err;
270
+ return new DingTalkError({
271
+ kind: err.kind,
272
+ message: `combo ${leg} leg failed: ${err.message}`,
273
+ ...(err.status !== undefined ? { status: err.status } : {}),
274
+ ...(err.errcode !== undefined ? { errcode: err.errcode } : {}),
275
+ ...(err.errmsg !== undefined ? { errmsg: err.errmsg } : {}),
276
+ ...(err.rawBody !== undefined ? { rawBody: err.rawBody } : {}),
277
+ comboLeg: leg,
278
+ ...(partial !== undefined ? { comboPartial: partial } : {}),
279
+ cause: err,
280
+ });
279
281
  }
package/dist/errors.d.ts CHANGED
@@ -2,13 +2,10 @@ import type { NotifyResult } from './types.js';
2
2
  /**
3
3
  * How a {@link DingTalkError} arose. Branch on this in a `catch`:
4
4
  *
5
- * - `validation` input rejected client-side; **no request was sent**.
6
- * - `network` fetch threw, or the request timed out.
7
- * - `http` server replied with a non-2xx status.
8
- * - `rejected` HTTP 200 but DingTalk rejected it (`ok:false`, `errcode !== 0`).
9
- *
10
- * The `rejected` case is the easy-to-miss one: the transport succeeded, so a
11
- * bare fetch wrapper would treat it as success. The SDK surfaces it as an error.
5
+ * - `validation` - input rejected client-side; no request was sent.
6
+ * - `network` - fetch threw, or the request timed out.
7
+ * - `http` - DingTalk replied with a non-2xx status.
8
+ * - `rejected` - HTTP 2xx but DingTalk returned `errcode !== 0`.
12
9
  */
13
10
  export type DingTalkErrorKind = 'validation' | 'network' | 'http' | 'rejected';
14
11
  export interface DingTalkErrorInit {
@@ -16,20 +13,12 @@ export interface DingTalkErrorInit {
16
13
  message: string;
17
14
  /** HTTP status, when one was received. */
18
15
  status?: number;
19
- /** Server error code string, e.g. `invalid_token` / `validation_failed`. */
20
- serverError?: string;
21
- /** Server-provided validation details (zod issues or a message string). */
22
- details?: unknown;
23
- /** DingTalk `errcode` (for `rejected`). */
16
+ /** DingTalk `errcode`, when DingTalk returned one. */
24
17
  errcode?: number | null;
25
- /** DingTalk `errmsg` (for `rejected`). */
18
+ /** DingTalk `errmsg`, when DingTalk returned one. */
26
19
  errmsg?: string | null;
27
- /** DingTalk's raw response body (for `rejected`), useful for debugging. */
20
+ /** DingTalk's raw response body, useful for debugging. */
28
21
  rawBody?: string;
29
- /** Server audit-log id, when known. */
30
- logId?: string;
31
- /** Trace id, when known. */
32
- requestId?: string;
33
22
  /** Which `combo` leg failed, when thrown from {@link DingTalk.combo}. */
34
23
  comboLeg?: 'text' | 'markdown';
35
24
  /** A `combo` leg that already succeeded before the failure. */
@@ -43,13 +32,9 @@ export interface DingTalkErrorInit {
43
32
  export declare class DingTalkError extends Error {
44
33
  readonly kind: DingTalkErrorKind;
45
34
  readonly status?: number;
46
- readonly serverError?: string;
47
- readonly details?: unknown;
48
35
  readonly errcode?: number | null;
49
36
  readonly errmsg?: string | null;
50
37
  readonly rawBody?: string;
51
- readonly logId?: string;
52
- readonly requestId?: string;
53
38
  readonly comboLeg?: 'text' | 'markdown';
54
39
  readonly comboPartial?: {
55
40
  text?: NotifyResult;
package/dist/errors.js CHANGED
@@ -2,13 +2,9 @@
2
2
  export class DingTalkError extends Error {
3
3
  kind;
4
4
  status;
5
- serverError;
6
- details;
7
5
  errcode;
8
6
  errmsg;
9
7
  rawBody;
10
- logId;
11
- requestId;
12
8
  comboLeg;
13
9
  comboPartial;
14
10
  constructor(init) {
@@ -16,13 +12,9 @@ export class DingTalkError extends Error {
16
12
  this.name = 'DingTalkError';
17
13
  this.kind = init.kind;
18
14
  this.status = init.status;
19
- this.serverError = init.serverError;
20
- this.details = init.details;
21
15
  this.errcode = init.errcode;
22
16
  this.errmsg = init.errmsg;
23
17
  this.rawBody = init.rawBody;
24
- this.logId = init.logId;
25
- this.requestId = init.requestId;
26
18
  this.comboLeg = init.comboLeg;
27
19
  this.comboPartial = init.comboPartial;
28
20
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { DingTalk } from './client.js';
2
2
  export { DingTalkError } from './errors.js';
3
3
  export type { DingTalkErrorKind, DingTalkErrorInit } from './errors.js';
4
- export type { AtMobiles, AtOptions, TextBody, MarkdownBody, NotifyBody, DingTalkVerdict, NotifyResult, DingTalkOptions, ComboInput, ComboResult, } from './types.js';
4
+ export type { AtMobiles, AtOptions, TextBody, MarkdownBody, NotifyBody, NotifyResult, DingTalkOptions, ComboInput, ComboResult, } from './types.js';
package/dist/types.d.ts CHANGED
@@ -1,57 +1,41 @@
1
- /**
2
- * Wire types — a faithful replica of the server contract in
3
- * `dingtalk_notification_server/src/routes/notify.ts`. Keep these in sync
4
- * with that file when the server's request/response shape changes.
5
- */
6
- /** Mobile numbers to @-mention. Server rule: `/^\+?\d{6,20}$/`, max 50. */
1
+ /** Mobile numbers to @-mention. Rule: `/^\+?\d{6,20}$/`, max 50. */
7
2
  export type AtMobiles = string[];
8
3
  /** Common `@` options shared by both message types. */
9
4
  export interface AtOptions {
10
- /** Real blue-badge @ + device push (only effective for `text` messages). */
5
+ /** Real blue-badge @ + device push only for `text` messages. */
11
6
  atMobiles?: AtMobiles;
12
7
  /** @-everyone. Mutually exclusive with a non-empty `atMobiles`. */
13
8
  atAll?: boolean;
14
9
  }
15
- /** A `text` message: no rich formatting, but `atMobiles` triggers a real push. */
16
- export interface TextBody {
10
+ /** A DingTalk `text` message: no rich formatting, but `atMobiles` triggers a real push. */
11
+ export interface TextBody extends AtOptions {
17
12
  type: 'text';
18
- /** 120000 chars. */
13
+ /** 1-20000 chars. */
19
14
  content: string;
20
- atMobiles?: AtMobiles;
21
- atAll?: boolean;
22
15
  }
23
- /** A `markdown` message: rich formatting, but `@` renders without a push. */
24
- export interface MarkdownBody {
16
+ /** A DingTalk `markdown` message: rich formatting; `@` renders without a push. */
17
+ export interface MarkdownBody extends AtOptions {
25
18
  type: 'markdown';
26
- /** 1200 chars. Shown as the card title / notification preview. */
19
+ /** 1-200 chars. Shown as the card title / notification preview. */
27
20
  title: string;
28
- /** 120000 chars of DingTalk-flavored markdown. */
21
+ /** 1-20000 chars of DingTalk-flavored markdown. */
29
22
  content: string;
30
- atMobiles?: AtMobiles;
31
- atAll?: boolean;
32
23
  }
33
24
  /** Discriminated union accepted by {@link DingTalk.notify}. */
34
25
  export type NotifyBody = TextBody | MarkdownBody;
35
- /** DingTalk's verbatim verdict, normalized to camelCase. */
36
- export interface DingTalkVerdict {
37
- httpStatus: number | null;
26
+ /** DingTalk's direct response, normalized to camelCase and preserving raw text. */
27
+ export interface NotifyResult {
28
+ httpStatus: number;
38
29
  errcode: number | null;
39
30
  errmsg: string | null;
40
- }
41
- /** Successful result of a notify call (`errcode === 0`). */
42
- export interface NotifyResult {
43
- /** Server audit-log id (`lg_...`). */
44
- logId: string;
45
- /** End-to-end trace id (`rq_...`); also surfaced in server logs. */
46
- requestId: string;
47
- dingtalk: DingTalkVerdict;
31
+ rawBody: string;
48
32
  }
49
33
  /** Options for {@link DingTalk}. */
50
34
  export interface DingTalkOptions {
51
- /** Per-robot bearer token (`dnk_...`). Required. */
52
- token: string;
53
- /** Server origin. Defaults to `https://dingtalk-notify.210k.cc`. */
54
- baseUrl?: string;
35
+ /** DingTalk custom robot webhook URL, including `access_token`. */
36
+ webhook: string;
37
+ /** DingTalk custom robot signing secret. Omit for keyword/IP-whitelist robots. */
38
+ secret?: string;
55
39
  /** Per-request timeout in ms (via AbortController). Defaults to 10000. */
56
40
  timeoutMs?: number;
57
41
  /**
@@ -61,19 +45,17 @@ export interface DingTalkOptions {
61
45
  retries?: number;
62
46
  /** Custom fetch (for tests or non-standard runtimes). Defaults to global fetch. */
63
47
  fetch?: typeof fetch;
64
- /** Overrides the default `User-Agent` header. */
65
- userAgent?: string;
48
+ /** Timestamp provider for deterministic signing tests. Defaults to `Date.now`. */
49
+ now?: () => number;
66
50
  }
67
51
  /** Argument for {@link DingTalk.combo}. */
68
- export interface ComboInput {
52
+ export interface ComboInput extends AtOptions {
69
53
  /** Short `text` line carrying the actual @-push. */
70
54
  alert: string;
71
55
  /** Markdown card title. */
72
56
  title: string;
73
57
  /** Markdown card body. */
74
58
  detail: string;
75
- atMobiles?: AtMobiles;
76
- atAll?: boolean;
77
59
  }
78
60
  /** Result of {@link DingTalk.combo}: both legs that were sent. */
79
61
  export interface ComboResult {
package/dist/types.js CHANGED
@@ -1,6 +1 @@
1
- /**
2
- * Wire types — a faithful replica of the server contract in
3
- * `dingtalk_notification_server/src/routes/notify.ts`. Keep these in sync
4
- * with that file when the server's request/response shape changes.
5
- */
6
1
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frankie0736/dingtalk-notify",
3
- "version": "0.1.0",
4
- "description": "Thin client for the DingTalk Notification Server send DingTalk group notifications over a single authenticated endpoint.",
3
+ "version": "0.2.0",
4
+ "description": "Zero-dependency DingTalk custom robot client with local HMAC signing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Frankie",