@xmanrui/dsh-im 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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,282 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
6
+ const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']);
7
+
8
+ function cleanString(value) {
9
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
10
+ }
11
+
12
+ function digest(value) {
13
+ return createHash('sha256').update(value).digest('hex');
14
+ }
15
+
16
+ function safeBotId(value) {
17
+ const id = cleanString(value);
18
+ return id && /^dt_[a-f0-9]{24}$/.test(id) ? id : null;
19
+ }
20
+
21
+ function safeSecretRef(value) {
22
+ const ref = cleanString(value);
23
+ return ref && /^DSH_DINGTALK_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null;
24
+ }
25
+
26
+ function safeSenderKey(value) {
27
+ const key = cleanString(value);
28
+ return key && /^dt_sender_[a-f0-9]{32}$/.test(key) ? key : null;
29
+ }
30
+
31
+ function normalizeApprovedSender(value) {
32
+ const record = typeof value === 'string' ? { staffId: value } : value;
33
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
34
+ const senderKey = safeSenderKey(record.senderKey);
35
+ const staffId = cleanString(record.staffId);
36
+ if (!senderKey || !staffId) return null;
37
+ return Object.freeze({
38
+ senderKey,
39
+ staffId,
40
+ displayName: cleanString(record.displayName),
41
+ approvedAt: cleanString(record.approvedAt),
42
+ });
43
+ }
44
+
45
+ function normalizeApprovedSenders(value) {
46
+ if (!Array.isArray(value)) return null;
47
+ const senders = value.map(normalizeApprovedSender);
48
+ if (senders.some((sender) => sender === null)) return null;
49
+ const ids = new Set();
50
+ const keys = new Set();
51
+ for (const sender of senders) {
52
+ if (ids.has(sender.staffId) || keys.has(sender.senderKey)) return null;
53
+ ids.add(sender.staffId);
54
+ keys.add(sender.senderKey);
55
+ }
56
+ return Object.freeze(senders);
57
+ }
58
+
59
+ /**
60
+ * Derives stable non-secret identifiers for a DingTalk bot credential.
61
+ * @param {string} clientId DingTalk application client ID.
62
+ * @returns {{botId: string, secretRef: string}} Derived identifiers.
63
+ */
64
+ export function deriveDingtalkBotIdentity(clientId) {
65
+ const value = cleanString(clientId);
66
+ if (!value) throw new TypeError('clientId is required');
67
+ const valueDigest = digest(value).slice(0, 24);
68
+ return Object.freeze({
69
+ botId: `dt_${valueDigest}`,
70
+ secretRef: `DSH_DINGTALK_BOT_SECRET_${valueDigest.toUpperCase()}`,
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Creates a random browser-safe key for an approved DingTalk sender.
76
+ * @returns {string} Opaque sender key.
77
+ */
78
+ export function deriveDingtalkSenderKey() {
79
+ return `dt_sender_${randomUUID().replaceAll('-', '')}`;
80
+ }
81
+
82
+ /**
83
+ * Redacts a DingTalk sender ID for display.
84
+ * @param {string} staffId DingTalk staff ID.
85
+ * @returns {string} Partially redacted identifier.
86
+ */
87
+ export function maskDingtalkSenderId(staffId) {
88
+ const value = cleanString(staffId);
89
+ if (!value) return '钉钉用户';
90
+ return '身份已隐藏';
91
+ }
92
+
93
+ /**
94
+ * Redacts a DingTalk client ID for display.
95
+ * @param {string} clientId DingTalk application client ID.
96
+ * @returns {string} Partially redacted client ID.
97
+ */
98
+ export function maskDingtalkClientId(clientId) {
99
+ const value = cleanString(clientId);
100
+ if (!value) return '钉钉机器人';
101
+ if (value.length <= 8) return `${value.slice(0, 2)}••••`;
102
+ return `${value.slice(0, 4)}••••${value.slice(-4)}`;
103
+ }
104
+
105
+ function normalizeBot(value, { stored = false } = {}) {
106
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
107
+ if ('clientSecret' in value || 'client_secret' in value || 'deviceCode' in value) return null;
108
+ if (stored && Object.keys(value).some((key) => !STORED_BOT_KEYS.has(key))) return null;
109
+ const clientId = cleanString(value.clientId);
110
+ const secretRef = safeSecretRef(value.secretRef);
111
+ const approvedSenders = normalizeApprovedSenders(value.approvedSenders ?? []);
112
+ if (!clientId || !secretRef || !approvedSenders) return null;
113
+ const identity = deriveDingtalkBotIdentity(clientId);
114
+ if (identity.secretRef !== secretRef) return null;
115
+ const suppliedBotId = value.botId === undefined ? identity.botId : safeBotId(value.botId);
116
+ if (suppliedBotId !== identity.botId) return null;
117
+ return Object.freeze({
118
+ botId: identity.botId,
119
+ clientId,
120
+ secretRef,
121
+ approvedSenders,
122
+ });
123
+ }
124
+
125
+ function normalizeDocument(value) {
126
+ if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
127
+ const bots = value.bots.map((bot) => normalizeBot(bot, { stored: true }));
128
+ if (bots.some((bot) => bot === null)) return null;
129
+ const botIds = new Set();
130
+ const clientIds = new Set();
131
+ const secretRefs = new Set();
132
+ for (const bot of bots) {
133
+ if (botIds.has(bot.botId) || clientIds.has(bot.clientId) || secretRefs.has(bot.secretRef)) {
134
+ return null;
135
+ }
136
+ botIds.add(bot.botId);
137
+ clientIds.add(bot.clientId);
138
+ secretRefs.add(bot.secretRef);
139
+ }
140
+ return Object.freeze({ version: 1, bots: Object.freeze(bots) });
141
+ }
142
+
143
+ function storedDocument(document) {
144
+ return {
145
+ version: 1,
146
+ bots: document.bots.map((bot) => ({
147
+ clientId: bot.clientId,
148
+ secretRef: bot.secretRef,
149
+ approvedSenders: bot.approvedSenders.map((sender) => ({
150
+ senderKey: sender.senderKey,
151
+ staffId: sender.staffId,
152
+ displayName: sender.displayName,
153
+ approvedAt: sender.approvedAt,
154
+ })),
155
+ })),
156
+ };
157
+ }
158
+
159
+ /** Atomic non-secret DingTalk bot configuration store. */
160
+ export class DingtalkConfigStore {
161
+ #path;
162
+ #value = EMPTY_DOCUMENT;
163
+ #writeQueue = Promise.resolve();
164
+
165
+ /** @param {string} path Absolute or process-relative configuration file path. */
166
+ constructor(path) {
167
+ if (!cleanString(path)) throw new TypeError('config path is required');
168
+ this.#path = path;
169
+ }
170
+
171
+ /** @returns {Promise<DingtalkConfigStore>} Loaded store. */
172
+ async load() {
173
+ try {
174
+ const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
175
+ if (!normalized) throw new Error('dsh-dingtalk config contains invalid bot data');
176
+ this.#value = normalized;
177
+ } catch (error) {
178
+ if (error?.code !== 'ENOENT') throw error;
179
+ this.#value = EMPTY_DOCUMENT;
180
+ }
181
+ return this;
182
+ }
183
+
184
+ /** @returns {Array<object>} Cloned bot configurations with derived bot IDs. */
185
+ list() {
186
+ return structuredClone(this.#value.bots);
187
+ }
188
+
189
+ /** @param {string} botId Derived bot ID. @returns {object|null} Bot configuration. */
190
+ get(botId) {
191
+ const found = this.#value.bots.find((bot) => bot.botId === botId);
192
+ return found ? structuredClone(found) : null;
193
+ }
194
+
195
+ /** @param {string} clientId DingTalk client ID. @returns {object|null} Bot configuration. */
196
+ getByClientId(clientId) {
197
+ const found = this.#value.bots.find((bot) => bot.clientId === clientId);
198
+ return found ? structuredClone(found) : null;
199
+ }
200
+
201
+ /** @param {object} value Bot configuration without a client secret. @returns {Promise<object>} Saved config. */
202
+ async save(value) {
203
+ const normalized = normalizeBot(value);
204
+ if (!normalized) throw new Error('Refusing to persist invalid dsh-dingtalk bot data');
205
+ return this.#mutate((bots) => {
206
+ const collision = bots.find(
207
+ (bot) => (bot.clientId === normalized.clientId || bot.secretRef === normalized.secretRef)
208
+ && bot.botId !== normalized.botId,
209
+ );
210
+ if (collision) throw new Error('Duplicate DingTalk bot identity');
211
+ const index = bots.findIndex((bot) => bot.botId === normalized.botId);
212
+ if (index === -1) bots.push(normalized);
213
+ else bots[index] = normalized;
214
+ return structuredClone(normalized);
215
+ });
216
+ }
217
+
218
+ /** @param {string} botId Derived bot ID. @returns {Promise<object|null>} Removed config. */
219
+ async remove(botId) {
220
+ if (!safeBotId(botId)) throw new TypeError('Invalid DingTalk bot id');
221
+ return this.#mutate((bots) => {
222
+ const index = bots.findIndex((bot) => bot.botId === botId);
223
+ if (index === -1) return null;
224
+ const [removed] = bots.splice(index, 1);
225
+ return structuredClone(removed);
226
+ });
227
+ }
228
+
229
+ /** Removes the configuration file and resets the in-memory store. */
230
+ async clear() {
231
+ const operation = this.#writeQueue.then(async () => {
232
+ try {
233
+ await unlink(this.#path);
234
+ } catch (error) {
235
+ if (error?.code !== 'ENOENT') throw error;
236
+ }
237
+ this.#value = EMPTY_DOCUMENT;
238
+ });
239
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
240
+ await operation;
241
+ }
242
+
243
+ async #mutate(mutator) {
244
+ let result;
245
+ const operation = this.#writeQueue.then(async () => {
246
+ const bots = [...this.#value.bots];
247
+ result = mutator(bots);
248
+ const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
249
+ await this.#write(document);
250
+ this.#value = document;
251
+ });
252
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
253
+ await operation;
254
+ return result;
255
+ }
256
+
257
+ async #write(document) {
258
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
259
+ const temporary = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
260
+ try {
261
+ await writeFile(temporary, `${JSON.stringify(storedDocument(document), null, 2)}\n`, {
262
+ encoding: 'utf8',
263
+ flag: 'wx',
264
+ mode: 0o600,
265
+ });
266
+ await rename(temporary, this.#path);
267
+ } catch (error) {
268
+ try {
269
+ await unlink(temporary);
270
+ } catch (cleanupError) {
271
+ if (cleanupError?.code !== 'ENOENT') throw new AggregateError([error, cleanupError]);
272
+ }
273
+ throw error;
274
+ }
275
+ }
276
+ }
277
+
278
+ export { deriveDingtalkBotIdentity as deriveDingTalkBotIdentity };
279
+ export { deriveDingtalkSenderKey as deriveDingTalkSenderKey };
280
+ export { maskDingtalkClientId as maskDingTalkClientId };
281
+ export { maskDingtalkSenderId as maskDingTalkSenderId };
282
+ export { DingtalkConfigStore as DingTalkConfigStore };
@@ -0,0 +1,237 @@
1
+ const DEFAULT_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com';
2
+ const REGISTRATION_SOURCE = 'DING_DWS_CLAW';
3
+
4
+ function cleanString(value) {
5
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
6
+ }
7
+
8
+ function positiveNumber(value, fallback) {
9
+ const number = Number(value);
10
+ return Number.isFinite(number) && number > 0 ? number : fallback;
11
+ }
12
+
13
+ function normalizeBaseUrl(value) {
14
+ let url;
15
+ try {
16
+ url = new URL(cleanString(value) ?? DEFAULT_REGISTRATION_BASE_URL);
17
+ } catch {
18
+ throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL');
19
+ }
20
+ const isDingtalkHost = url.hostname === 'dingtalk.com' || url.hostname.endsWith('.dingtalk.com');
21
+ if (url.protocol !== 'https:'
22
+ || url.port
23
+ || !isDingtalkHost
24
+ || url.username
25
+ || url.password
26
+ || url.search
27
+ || url.hash) {
28
+ throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL');
29
+ }
30
+ url.pathname = url.pathname.replace(/\/+$/, '');
31
+ return url.href.replace(/\/$/, '');
32
+ }
33
+
34
+ function readNow(clock) {
35
+ const value = typeof clock?.now === 'function' ? clock.now() : clock();
36
+ if (!Number.isFinite(value)) throw new TypeError('clock must return a finite timestamp');
37
+ return value;
38
+ }
39
+
40
+ function assertRecord(value, action) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
42
+ throw new DingtalkDeviceAuthError(
43
+ 'invalid-response',
44
+ `DingTalk ${action} returned an invalid response`,
45
+ action,
46
+ );
47
+ }
48
+ if (Number(value.errcode) !== 0) {
49
+ throw new DingtalkDeviceAuthError(
50
+ 'api-error',
51
+ `DingTalk ${action} request was rejected`,
52
+ action,
53
+ );
54
+ }
55
+ return value;
56
+ }
57
+
58
+ /** A sanitized DingTalk device-registration failure. */
59
+ export class DingtalkDeviceAuthError extends Error {
60
+ /**
61
+ * @param {string} code Stable failure code.
62
+ * @param {string} message Safe diagnostic that does not include response credentials.
63
+ * @param {string} action Registration stage that failed.
64
+ * @param {{cause?: unknown}} [options] Optional underlying error.
65
+ */
66
+ constructor(code, message, action, options = {}) {
67
+ super(message, options);
68
+ this.name = 'DingtalkDeviceAuthError';
69
+ this.code = code;
70
+ this.action = action;
71
+ }
72
+ }
73
+
74
+ /** Host-only client for DingTalk's QR device-registration flow. */
75
+ export class DingtalkDeviceAuth {
76
+ #fetch;
77
+ #clock;
78
+ #baseUrl;
79
+ #timeoutMs;
80
+
81
+ /**
82
+ * @param {{fetch?: typeof globalThis.fetch, clock?: {now(): number}|(()=>number), baseUrl?: string, timeoutMs?: number}} [options]
83
+ * Device-registration dependencies.
84
+ */
85
+ constructor({
86
+ fetch = globalThis.fetch,
87
+ clock = Date,
88
+ baseUrl = DEFAULT_REGISTRATION_BASE_URL,
89
+ timeoutMs = 15_000,
90
+ } = {}) {
91
+ if (typeof fetch !== 'function') throw new TypeError('fetch is required');
92
+ if (typeof clock !== 'function' && typeof clock?.now !== 'function') {
93
+ throw new TypeError('clock must be a function or expose now()');
94
+ }
95
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
96
+ throw new TypeError('timeoutMs must be a positive number');
97
+ }
98
+ this.#fetch = fetch;
99
+ this.#clock = clock;
100
+ this.#baseUrl = normalizeBaseUrl(baseUrl);
101
+ this.#timeoutMs = timeoutMs;
102
+ }
103
+
104
+ /**
105
+ * Starts a QR registration and returns the host-only device code with QR metadata.
106
+ * @param {{signal?: AbortSignal}} [options] Optional cancellation signal.
107
+ * @returns {Promise<object>} Device registration details.
108
+ */
109
+ async start({ signal } = {}) {
110
+ const initialized = await this.#post(
111
+ '/app/registration/init',
112
+ { source: REGISTRATION_SOURCE },
113
+ 'initialization',
114
+ signal,
115
+ );
116
+ const nonce = cleanString(initialized.nonce);
117
+ if (!nonce) {
118
+ throw new DingtalkDeviceAuthError(
119
+ 'missing-nonce',
120
+ 'DingTalk registration initialization did not return a nonce',
121
+ 'initialization',
122
+ );
123
+ }
124
+
125
+ const begun = await this.#post(
126
+ '/app/registration/begin',
127
+ { nonce },
128
+ 'begin',
129
+ signal,
130
+ );
131
+ const deviceCode = cleanString(begun.device_code);
132
+ const verificationUrl = cleanString(begun.verification_uri_complete);
133
+ if (!deviceCode || !verificationUrl) {
134
+ throw new DingtalkDeviceAuthError(
135
+ 'incomplete-registration',
136
+ 'DingTalk registration did not return complete QR metadata',
137
+ 'begin',
138
+ );
139
+ }
140
+
141
+ const expiresInSeconds = positiveNumber(begun.expires_in, 7_200);
142
+ const pollIntervalMs = positiveNumber(begun.interval, 5) * 1_000;
143
+ return Object.freeze({
144
+ deviceCode,
145
+ verificationUrl,
146
+ verificationUri: cleanString(begun.verification_uri),
147
+ userCode: cleanString(begun.user_code),
148
+ expiresAt: readNow(this.#clock) + expiresInSeconds * 1_000,
149
+ pollIntervalMs,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Polls one registration attempt.
155
+ * @param {{deviceCode: string, signal?: AbortSignal}|string} request Host-only device code.
156
+ * @returns {Promise<object>} Normalized registration state and credentials on success.
157
+ */
158
+ async poll(request) {
159
+ const deviceCode = cleanString(typeof request === 'string' ? request : request?.deviceCode);
160
+ const signal = typeof request === 'object' ? request?.signal : undefined;
161
+ if (!deviceCode) throw new TypeError('deviceCode is required');
162
+ const response = await this.#post(
163
+ '/app/registration/poll',
164
+ { device_code: deviceCode },
165
+ 'poll',
166
+ signal,
167
+ );
168
+ const rawStatus = cleanString(response.status)?.toUpperCase();
169
+ const status = ['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED'].includes(rawStatus)
170
+ ? rawStatus
171
+ : 'UNKNOWN';
172
+ return Object.freeze({
173
+ status,
174
+ clientId: cleanString(response.client_id),
175
+ clientSecret: cleanString(response.client_secret),
176
+ failReason: cleanString(response.fail_reason),
177
+ });
178
+ }
179
+
180
+ async #post(path, body, action, signal) {
181
+ let response;
182
+ const timeoutSignal = AbortSignal.timeout(this.#timeoutMs);
183
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
184
+ try {
185
+ response = await this.#fetch(`${this.#baseUrl}${path}`, {
186
+ method: 'POST',
187
+ headers: {
188
+ accept: 'application/json',
189
+ 'content-type': 'application/json',
190
+ },
191
+ body: JSON.stringify(body),
192
+ redirect: 'error',
193
+ signal: requestSignal,
194
+ });
195
+ } catch (error) {
196
+ if (signal?.aborted) throw signal.reason ?? error;
197
+ if (timeoutSignal.aborted) {
198
+ throw new DingtalkDeviceAuthError(
199
+ 'timeout',
200
+ `DingTalk ${action} request timed out`,
201
+ action,
202
+ { cause: error },
203
+ );
204
+ }
205
+ if (error?.name === 'AbortError') throw error;
206
+ throw new DingtalkDeviceAuthError(
207
+ 'network-error',
208
+ `DingTalk ${action} request could not be completed`,
209
+ action,
210
+ { cause: error },
211
+ );
212
+ }
213
+ if (!response || response.ok === false || typeof response.json !== 'function') {
214
+ throw new DingtalkDeviceAuthError(
215
+ 'http-error',
216
+ `DingTalk ${action} request failed`,
217
+ action,
218
+ );
219
+ }
220
+ let value;
221
+ try {
222
+ value = await response.json();
223
+ } catch (error) {
224
+ throw new DingtalkDeviceAuthError(
225
+ 'invalid-json',
226
+ `DingTalk ${action} returned invalid JSON`,
227
+ action,
228
+ { cause: error },
229
+ );
230
+ }
231
+ return assertRecord(value, action);
232
+ }
233
+ }
234
+
235
+ export { DEFAULT_REGISTRATION_BASE_URL, REGISTRATION_SOURCE };
236
+ export { DingtalkDeviceAuth as DingTalkDeviceAuth };
237
+ export { DingtalkDeviceAuthError as DingTalkDeviceAuthError };