@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 +97 -64
- package/dist/client.d.ts +8 -32
- package/dist/client.js +126 -124
- package/dist/errors.d.ts +7 -22
- package/dist/errors.js +0 -8
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +20 -38
- package/dist/types.js +0 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# @frankie0736/dingtalk-notify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@frankie0736/dingtalk-notify)
|
|
4
|
+
[](https://github.com/frankie0736/dingtalk-notify-client/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
-
|
|
8
|
-
- Runs on
|
|
9
|
-
-
|
|
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
|
-
|
|
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
|
|
24
|
+
## Quick Start
|
|
22
25
|
|
|
23
26
|
```ts
|
|
24
27
|
import { DingTalk } from '@frankie0736/dingtalk-notify';
|
|
25
28
|
|
|
26
|
-
const dt = new DingTalk({
|
|
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
|
-
|
|
29
|
-
await dt.text('🔔 Build #123 failed', { atMobiles: ['13800138000'] });
|
|
34
|
+
await dt.text('Build #123 failed', { atMobiles: ['13800138000'] });
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
await dt.markdown(
|
|
37
|
+
'Build #123',
|
|
38
|
+
'### main failed\n- env: prod\n- [logs](https://example.com)',
|
|
39
|
+
);
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
##
|
|
42
|
+
## Mention Behavior
|
|
36
43
|
|
|
37
44
|
| Mode | Rich formatting | `atMobiles` triggers push? |
|
|
38
45
|
| --- | --- | --- |
|
|
39
|
-
| `text` | No |
|
|
40
|
-
| `markdown` | Yes
|
|
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
|
|
49
|
+
This is a DingTalk platform behavior. The package preserves it instead of hiding it.
|
|
43
50
|
|
|
44
|
-
### `combo()`
|
|
51
|
+
### `combo()`
|
|
45
52
|
|
|
46
|
-
When you need both a
|
|
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: '
|
|
57
|
+
alert: 'Build #123 failed. See detail.',
|
|
51
58
|
title: 'Build #123',
|
|
52
|
-
detail: '### main
|
|
59
|
+
detail: '### main failed\n- env: prod\n- [logs](https://example.com)',
|
|
53
60
|
atMobiles: ['13800138000'],
|
|
54
61
|
});
|
|
55
62
|
```
|
|
56
63
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
## Result
|
|
97
|
+
|
|
98
|
+
`text` / `markdown` / `notify` resolve to DingTalk's direct verdict:
|
|
62
99
|
|
|
63
100
|
```ts
|
|
64
|
-
|
|
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':
|
|
72
|
-
|
|
73
|
-
case '
|
|
74
|
-
|
|
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` |
|
|
83
|
-
| `network` | `fetch` threw or
|
|
84
|
-
| `http` |
|
|
85
|
-
| `rejected` |
|
|
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
|
-
|
|
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
|
-
##
|
|
145
|
+
## DingTalk Limits
|
|
103
146
|
|
|
104
|
-
|
|
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
|
|
123
|
-
bun run test
|
|
124
|
-
bun run build
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
26
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
#
|
|
24
|
-
#
|
|
9
|
+
#webhook;
|
|
10
|
+
#secret;
|
|
25
11
|
#timeoutMs;
|
|
26
12
|
#retries;
|
|
27
13
|
#fetch;
|
|
28
|
-
#
|
|
14
|
+
#now;
|
|
29
15
|
constructor(options) {
|
|
30
|
-
if (!options || typeof options.
|
|
31
|
-
throw new DingTalkError({ kind: 'validation', message: '
|
|
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
|
-
|
|
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
|
-
/**
|
|
36
|
+
/** Send a fully-formed `text` or `markdown` message. */
|
|
50
37
|
async notify(body) {
|
|
51
|
-
const wire =
|
|
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.
|
|
65
|
-
*
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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(
|
|
87
|
+
async #sendWithRetry(body) {
|
|
107
88
|
let attempt = 0;
|
|
108
89
|
for (;;) {
|
|
109
90
|
try {
|
|
110
|
-
return await this.#sendOnce(
|
|
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(
|
|
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.#
|
|
111
|
+
res = await this.#fetch(await this.#requestUrl(), {
|
|
131
112
|
method: 'POST',
|
|
132
|
-
headers: {
|
|
133
|
-
|
|
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
|
|
153
|
-
|
|
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: `
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
if (parsed.ok === true) {
|
|
140
|
+
if (parsed.errcode === 0) {
|
|
181
141
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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: ${
|
|
195
|
-
errcode:
|
|
196
|
-
errmsg:
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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 1
|
|
189
|
+
message: 'content must be a string of 1-20000 chars',
|
|
238
190
|
});
|
|
239
191
|
}
|
|
240
|
-
const
|
|
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 1
|
|
197
|
+
message: 'markdown title must be a string of 1-200 chars',
|
|
246
198
|
});
|
|
247
199
|
}
|
|
248
|
-
|
|
200
|
+
normalized.title = body.title;
|
|
249
201
|
}
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
217
|
+
if (body.atMobiles.length > 0)
|
|
218
|
+
normalized.atMobiles = body.atMobiles;
|
|
268
219
|
}
|
|
269
220
|
if (body.atAll === true) {
|
|
270
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
6
|
-
* - `network`
|
|
7
|
-
* - `http`
|
|
8
|
-
* - `rejected`
|
|
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
|
-
/**
|
|
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
|
|
18
|
+
/** DingTalk `errmsg`, when DingTalk returned one. */
|
|
26
19
|
errmsg?: string | null;
|
|
27
|
-
/** DingTalk's raw response body
|
|
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,
|
|
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
|
|
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
|
-
/** 1
|
|
13
|
+
/** 1-20000 chars. */
|
|
19
14
|
content: string;
|
|
20
|
-
atMobiles?: AtMobiles;
|
|
21
|
-
atAll?: boolean;
|
|
22
15
|
}
|
|
23
|
-
/** A `markdown` message: rich formatting
|
|
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
|
-
/** 1
|
|
19
|
+
/** 1-200 chars. Shown as the card title / notification preview. */
|
|
27
20
|
title: string;
|
|
28
|
-
/** 1
|
|
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
|
|
36
|
-
export interface
|
|
37
|
-
httpStatus: number
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankie0736/dingtalk-notify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|