@frankie0736/dingtalk-notify 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frankie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @frankie0736/dingtalk-notify
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.
4
+
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.
6
+
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.
10
+
11
+ > Not for browsers: the bearer token must never ship to a frontend.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @frankie0736/dingtalk-notify
17
+ # or
18
+ bun add @frankie0736/dingtalk-notify
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { DingTalk } from '@frankie0736/dingtalk-notify';
25
+
26
+ const dt = new DingTalk({ token: process.env.DINGTALK_TOKEN! }); // 'dnk_...'
27
+
28
+ // Plain text — atMobiles here fires a real @-push + device notification.
29
+ await dt.text('🔔 Build #123 failed', { atMobiles: ['13800138000'] });
30
+
31
+ // Markdown card — rich formatting (no push; see below).
32
+ await dt.markdown('Build #123', '### main 失败\n- env: prod\n- [logs](https://example.com)');
33
+ ```
34
+
35
+ ## `@`-mention behavior (a DingTalk platform quirk)
36
+
37
+ | Mode | Rich formatting | `atMobiles` triggers push? |
38
+ | --- | --- | --- |
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 |
41
+
42
+ This asymmetry is a DingTalk limitation, not a choice of this library.
43
+
44
+ ### `combo()` — push + rich content in one call
45
+
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:
47
+
48
+ ```ts
49
+ await dt.combo({
50
+ alert: '🔔 Build #123 failed — see detail', // short text, carries the @
51
+ title: 'Build #123',
52
+ detail: '### main 失败\n- env: prod\n- [logs](https://example.com)',
53
+ atMobiles: ['13800138000'],
54
+ });
55
+ ```
56
+
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`.
58
+
59
+ ## Error handling
60
+
61
+ Every method throws [`DingTalkError`](./src/errors.ts) on failure. Branch on `kind`:
62
+
63
+ ```ts
64
+ import { DingTalk, DingTalkError } from '@frankie0736/dingtalk-notify';
65
+
66
+ try {
67
+ await dt.text('hi', { atMobiles: ['13800138000'] });
68
+ } catch (err) {
69
+ if (err instanceof DingTalkError) {
70
+ 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
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ | `kind` | Meaning | Useful fields |
81
+ | --- | --- | --- |
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
90
+
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
+ ```
101
+
102
+ ## Result shape
103
+
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.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ bun install
122
+ bun run check # typecheck
123
+ bun run test # build + node:test
124
+ bun run build # emit dist/
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT © Frankie
@@ -0,0 +1,43 @@
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
+ */
14
+ export declare class DingTalk {
15
+ #private;
16
+ constructor(options: DingTalkOptions);
17
+ /** Thin core: send a fully-formed message body. */
18
+ notify(body: NotifyBody): Promise<NotifyResult>;
19
+ /** 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>;
24
+ /**
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.
28
+ */
29
+ markdown(title: string, content: string, opts?: {
30
+ atMobiles?: string[];
31
+ atAll?: boolean;
32
+ }): Promise<NotifyResult>;
33
+ /**
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`.
41
+ */
42
+ combo(input: ComboInput): Promise<ComboResult>;
43
+ }
package/dist/client.js ADDED
@@ -0,0 +1,279 @@
1
+ import { DingTalkError } from './errors.js';
2
+ const DEFAULT_BASE_URL = 'https://dingtalk-notify.210k.cc';
3
+ const NOTIFY_PATH = '/api/v1/notify';
4
+ const DEFAULT_TIMEOUT_MS = 10_000;
5
+ const DEFAULT_USER_AGENT = '@frankie0736/dingtalk-notify';
6
+ const MOBILE_RE = /^\+?\d{6,20}$/;
7
+ const sleep = (ms) => new Promise((resolve) => {
8
+ setTimeout(resolve, ms);
9
+ });
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
+ export class DingTalk {
23
+ #token;
24
+ #url;
25
+ #timeoutMs;
26
+ #retries;
27
+ #fetch;
28
+ #userAgent;
29
+ constructor(options) {
30
+ if (!options || typeof options.token !== 'string' || options.token.length === 0) {
31
+ throw new DingTalkError({ kind: 'validation', message: 'token is required' });
32
+ }
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
+ const f = options.fetch ?? globalThis.fetch;
40
+ if (typeof f !== 'function') {
41
+ throw new DingTalkError({
42
+ kind: 'validation',
43
+ message: 'global fetch is unavailable; pass options.fetch',
44
+ });
45
+ }
46
+ // Preserve `this` binding for environments where fetch is a bound global.
47
+ this.#fetch = f.bind(globalThis);
48
+ }
49
+ /** Thin core: send a fully-formed message body. */
50
+ async notify(body) {
51
+ const wire = buildWire(body);
52
+ return this.#sendWithRetry(wire);
53
+ }
54
+ /** Send a plain `text` message. `atMobiles` here triggers a real @-push. */
55
+ async text(content, opts = {}) {
56
+ const body = { type: 'text', content };
57
+ if (opts.atMobiles !== undefined)
58
+ body.atMobiles = opts.atMobiles;
59
+ if (opts.atAll !== undefined)
60
+ body.atAll = opts.atAll;
61
+ return this.notify(body);
62
+ }
63
+ /**
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.
67
+ */
68
+ async markdown(title, content, opts = {}) {
69
+ const body = { type: 'markdown', title, content };
70
+ if (opts.atMobiles !== undefined)
71
+ body.atMobiles = opts.atMobiles;
72
+ if (opts.atAll !== undefined)
73
+ body.atAll = opts.atAll;
74
+ return this.notify(body);
75
+ }
76
+ /**
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`.
84
+ */
85
+ async combo(input) {
86
+ const at = {};
87
+ if (input.atMobiles !== undefined)
88
+ at.atMobiles = input.atMobiles;
89
+ if (input.atAll !== undefined)
90
+ at.atAll = input.atAll;
91
+ let textResult;
92
+ try {
93
+ textResult = await this.text(input.alert, at);
94
+ }
95
+ catch (err) {
96
+ throw tagComboLeg(err, 'text');
97
+ }
98
+ try {
99
+ const markdownResult = await this.markdown(input.title, input.detail);
100
+ return { text: textResult, markdown: markdownResult };
101
+ }
102
+ catch (err) {
103
+ throw tagComboLeg(err, 'markdown', { text: textResult });
104
+ }
105
+ }
106
+ async #sendWithRetry(wire) {
107
+ let attempt = 0;
108
+ for (;;) {
109
+ try {
110
+ return await this.#sendOnce(wire);
111
+ }
112
+ catch (err) {
113
+ const isRetryable = err instanceof DingTalkError && err.retryable;
114
+ if (!isRetryable || attempt >= this.#retries)
115
+ throw err;
116
+ await sleep(250 * 2 ** attempt);
117
+ attempt += 1;
118
+ }
119
+ }
120
+ }
121
+ async #sendOnce(wire) {
122
+ const controller = new AbortController();
123
+ let timedOut = false;
124
+ const timer = setTimeout(() => {
125
+ timedOut = true;
126
+ controller.abort();
127
+ }, this.#timeoutMs);
128
+ let res;
129
+ try {
130
+ res = await this.#fetch(this.#url, {
131
+ 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),
139
+ signal: controller.signal,
140
+ });
141
+ }
142
+ catch (err) {
143
+ throw new DingTalkError({
144
+ kind: 'network',
145
+ message: timedOut ? `request timed out after ${this.#timeoutMs}ms` : 'network request failed',
146
+ cause: err,
147
+ });
148
+ }
149
+ finally {
150
+ clearTimeout(timer);
151
+ }
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
+ }
162
+ if (!res.ok) {
163
+ throw new DingTalkError({
164
+ kind: 'http',
165
+ 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`,
177
+ });
178
+ }
179
+ const dt = parsed.dingtalk ?? {};
180
+ if (parsed.ok === true) {
181
+ 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
+ },
189
+ };
190
+ }
191
+ // HTTP 200 but DingTalk rejected the message.
192
+ throw new DingTalkError({
193
+ 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 } : {}),
200
+ });
201
+ }
202
+ }
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
+ });
225
+ }
226
+ /** Validate input at the boundary and convert to the snake_case wire shape. */
227
+ function buildWire(body) {
228
+ if (!body || (body.type !== 'text' && body.type !== 'markdown')) {
229
+ throw new DingTalkError({
230
+ kind: 'validation',
231
+ message: "type must be 'text' or 'markdown'",
232
+ });
233
+ }
234
+ if (typeof body.content !== 'string' || body.content.length < 1 || body.content.length > 20_000) {
235
+ throw new DingTalkError({
236
+ kind: 'validation',
237
+ message: 'content must be a string of 1–20000 chars',
238
+ });
239
+ }
240
+ const wire = { type: body.type, content: body.content };
241
+ if (body.type === 'markdown') {
242
+ if (typeof body.title !== 'string' || body.title.length < 1 || body.title.length > 200) {
243
+ throw new DingTalkError({
244
+ kind: 'validation',
245
+ message: 'markdown title must be a string of 1–200 chars',
246
+ });
247
+ }
248
+ wire.title = body.title;
249
+ }
250
+ const atMobiles = body.atMobiles;
251
+ if (atMobiles !== undefined) {
252
+ if (!Array.isArray(atMobiles) || atMobiles.length > 50) {
253
+ throw new DingTalkError({
254
+ kind: 'validation',
255
+ message: 'atMobiles must be an array of at most 50 numbers',
256
+ });
257
+ }
258
+ for (const m of atMobiles) {
259
+ if (typeof m !== 'string' || !MOBILE_RE.test(m)) {
260
+ throw new DingTalkError({
261
+ kind: 'validation',
262
+ message: `invalid mobile number: ${String(m)} (expected /^\\+?\\d{6,20}$/)`,
263
+ });
264
+ }
265
+ }
266
+ if (atMobiles.length > 0)
267
+ wire.at_mobiles = atMobiles;
268
+ }
269
+ if (body.atAll === true) {
270
+ if (wire.at_mobiles && wire.at_mobiles.length > 0) {
271
+ throw new DingTalkError({
272
+ kind: 'validation',
273
+ message: 'atAll and atMobiles are mutually exclusive',
274
+ });
275
+ }
276
+ wire.at_all = true;
277
+ }
278
+ return wire;
279
+ }
@@ -0,0 +1,60 @@
1
+ import type { NotifyResult } from './types.js';
2
+ /**
3
+ * How a {@link DingTalkError} arose. Branch on this in a `catch`:
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.
12
+ */
13
+ export type DingTalkErrorKind = 'validation' | 'network' | 'http' | 'rejected';
14
+ export interface DingTalkErrorInit {
15
+ kind: DingTalkErrorKind;
16
+ message: string;
17
+ /** HTTP status, when one was received. */
18
+ 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`). */
24
+ errcode?: number | null;
25
+ /** DingTalk `errmsg` (for `rejected`). */
26
+ errmsg?: string | null;
27
+ /** DingTalk's raw response body (for `rejected`), useful for debugging. */
28
+ rawBody?: string;
29
+ /** Server audit-log id, when known. */
30
+ logId?: string;
31
+ /** Trace id, when known. */
32
+ requestId?: string;
33
+ /** Which `combo` leg failed, when thrown from {@link DingTalk.combo}. */
34
+ comboLeg?: 'text' | 'markdown';
35
+ /** A `combo` leg that already succeeded before the failure. */
36
+ comboPartial?: {
37
+ text?: NotifyResult;
38
+ };
39
+ /** Underlying cause (e.g. the original fetch error). */
40
+ cause?: unknown;
41
+ }
42
+ /** The single error type thrown by every {@link DingTalk} method on failure. */
43
+ export declare class DingTalkError extends Error {
44
+ readonly kind: DingTalkErrorKind;
45
+ readonly status?: number;
46
+ readonly serverError?: string;
47
+ readonly details?: unknown;
48
+ readonly errcode?: number | null;
49
+ readonly errmsg?: string | null;
50
+ readonly rawBody?: string;
51
+ readonly logId?: string;
52
+ readonly requestId?: string;
53
+ readonly comboLeg?: 'text' | 'markdown';
54
+ readonly comboPartial?: {
55
+ text?: NotifyResult;
56
+ };
57
+ constructor(init: DingTalkErrorInit);
58
+ /** True for transient failures worth retrying (network/timeout, HTTP 5xx). */
59
+ get retryable(): boolean;
60
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,37 @@
1
+ /** The single error type thrown by every {@link DingTalk} method on failure. */
2
+ export class DingTalkError extends Error {
3
+ kind;
4
+ status;
5
+ serverError;
6
+ details;
7
+ errcode;
8
+ errmsg;
9
+ rawBody;
10
+ logId;
11
+ requestId;
12
+ comboLeg;
13
+ comboPartial;
14
+ constructor(init) {
15
+ super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
16
+ this.name = 'DingTalkError';
17
+ this.kind = init.kind;
18
+ this.status = init.status;
19
+ this.serverError = init.serverError;
20
+ this.details = init.details;
21
+ this.errcode = init.errcode;
22
+ this.errmsg = init.errmsg;
23
+ this.rawBody = init.rawBody;
24
+ this.logId = init.logId;
25
+ this.requestId = init.requestId;
26
+ this.comboLeg = init.comboLeg;
27
+ this.comboPartial = init.comboPartial;
28
+ }
29
+ /** True for transient failures worth retrying (network/timeout, HTTP 5xx). */
30
+ get retryable() {
31
+ if (this.kind === 'network')
32
+ return true;
33
+ if (this.kind === 'http' && this.status !== undefined)
34
+ return this.status >= 500;
35
+ return false;
36
+ }
37
+ }
@@ -0,0 +1,4 @@
1
+ export { DingTalk } from './client.js';
2
+ export { DingTalkError } from './errors.js';
3
+ export type { DingTalkErrorKind, DingTalkErrorInit } from './errors.js';
4
+ export type { AtMobiles, AtOptions, TextBody, MarkdownBody, NotifyBody, DingTalkVerdict, NotifyResult, DingTalkOptions, ComboInput, ComboResult, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { DingTalk } from './client.js';
2
+ export { DingTalkError } from './errors.js';
@@ -0,0 +1,82 @@
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. */
7
+ export type AtMobiles = string[];
8
+ /** Common `@` options shared by both message types. */
9
+ export interface AtOptions {
10
+ /** Real blue-badge @ + device push (only effective for `text` messages). */
11
+ atMobiles?: AtMobiles;
12
+ /** @-everyone. Mutually exclusive with a non-empty `atMobiles`. */
13
+ atAll?: boolean;
14
+ }
15
+ /** A `text` message: no rich formatting, but `atMobiles` triggers a real push. */
16
+ export interface TextBody {
17
+ type: 'text';
18
+ /** 1–20000 chars. */
19
+ content: string;
20
+ atMobiles?: AtMobiles;
21
+ atAll?: boolean;
22
+ }
23
+ /** A `markdown` message: rich formatting, but `@` renders without a push. */
24
+ export interface MarkdownBody {
25
+ type: 'markdown';
26
+ /** 1–200 chars. Shown as the card title / notification preview. */
27
+ title: string;
28
+ /** 1–20000 chars of DingTalk-flavored markdown. */
29
+ content: string;
30
+ atMobiles?: AtMobiles;
31
+ atAll?: boolean;
32
+ }
33
+ /** Discriminated union accepted by {@link DingTalk.notify}. */
34
+ export type NotifyBody = TextBody | MarkdownBody;
35
+ /** DingTalk's verbatim verdict, normalized to camelCase. */
36
+ export interface DingTalkVerdict {
37
+ httpStatus: number | null;
38
+ errcode: number | null;
39
+ 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;
48
+ }
49
+ /** Options for {@link DingTalk}. */
50
+ 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;
55
+ /** Per-request timeout in ms (via AbortController). Defaults to 10000. */
56
+ timeoutMs?: number;
57
+ /**
58
+ * Retry attempts for transient failures (network error, timeout, HTTP 5xx).
59
+ * Defaults to 0. DingTalk business rejections and HTTP 4xx are never retried.
60
+ */
61
+ retries?: number;
62
+ /** Custom fetch (for tests or non-standard runtimes). Defaults to global fetch. */
63
+ fetch?: typeof fetch;
64
+ /** Overrides the default `User-Agent` header. */
65
+ userAgent?: string;
66
+ }
67
+ /** Argument for {@link DingTalk.combo}. */
68
+ export interface ComboInput {
69
+ /** Short `text` line carrying the actual @-push. */
70
+ alert: string;
71
+ /** Markdown card title. */
72
+ title: string;
73
+ /** Markdown card body. */
74
+ detail: string;
75
+ atMobiles?: AtMobiles;
76
+ atAll?: boolean;
77
+ }
78
+ /** Result of {@link DingTalk.combo}: both legs that were sent. */
79
+ export interface ComboResult {
80
+ text: NotifyResult;
81
+ markdown: NotifyResult;
82
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
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
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
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.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Frankie",
8
+ "homepage": "https://github.com/frankie0736/dingtalk-notify-client#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/frankie0736/dingtalk-notify-client.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/frankie0736/dingtalk-notify-client/issues"
15
+ },
16
+ "keywords": [
17
+ "dingtalk",
18
+ "钉钉",
19
+ "webhook",
20
+ "notification",
21
+ "robot",
22
+ "cloudflare-workers"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "sideEffects": false,
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "main": "./dist/index.js",
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "check": "tsc --noEmit",
45
+ "test": "tsc -p tsconfig.build.json && node --test --experimental-strip-types",
46
+ "prepublishOnly": "tsc --noEmit && tsc -p tsconfig.build.json && node --test --experimental-strip-types"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^26.0.0",
50
+ "typescript": "^5.9.2"
51
+ }
52
+ }