@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
|
|
4
|
+
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
|
|
5
|
+
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
|
6
|
+
export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
9
|
+
const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
|
|
10
|
+
|
|
11
|
+
export class DingtalkApiError extends Error {
|
|
12
|
+
constructor(code, message, options = {}) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = 'DingtalkApiError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = options.status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nonEmptyString(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isDingtalkHost(hostname) {
|
|
25
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
26
|
+
return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeTrustedUrl(value, { label, requireSubdomain = true } = {}) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(value);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new DingtalkApiError('invalid-url', `${label ?? '钉钉服务'}返回了无效地址。`);
|
|
35
|
+
}
|
|
36
|
+
const normalizedHost = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
37
|
+
const trustedHost = requireSubdomain
|
|
38
|
+
? normalizedHost !== 'dingtalk.com' && isDingtalkHost(normalizedHost)
|
|
39
|
+
: isDingtalkHost(normalizedHost);
|
|
40
|
+
if (url.protocol !== 'https:' || !trustedHost || (url.port && url.port !== '443')) {
|
|
41
|
+
throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`);
|
|
42
|
+
}
|
|
43
|
+
if (url.username || url.password) {
|
|
44
|
+
throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`);
|
|
45
|
+
}
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeDingtalkSessionWebhook(value) {
|
|
50
|
+
const text = nonEmptyString(value);
|
|
51
|
+
if (!text) throw new DingtalkApiError('invalid-session-webhook', '钉钉消息没有可用的回复地址。');
|
|
52
|
+
const url = normalizeTrustedUrl(text, { label: '钉钉回复', requireSubdomain: false });
|
|
53
|
+
url.hash = '';
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function splitDingtalkText(value, maxChars = 4_000) {
|
|
58
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
59
|
+
if (!text) return [];
|
|
60
|
+
if (!Number.isInteger(maxChars) || maxChars < 1) throw new TypeError('maxChars must be a positive integer');
|
|
61
|
+
if (text.length <= maxChars) return [text];
|
|
62
|
+
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let remaining = text;
|
|
65
|
+
while (remaining.length > maxChars) {
|
|
66
|
+
let splitAt = remaining.lastIndexOf('\n', maxChars);
|
|
67
|
+
if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
|
|
68
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
69
|
+
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
|
|
70
|
+
}
|
|
71
|
+
if (remaining) chunks.push(remaining);
|
|
72
|
+
return chunks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function abortError(signal) {
|
|
76
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
77
|
+
return new DOMException('The operation was aborted', 'AbortError');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function abortableDelay(ms, signal) {
|
|
81
|
+
if (ms <= 0) return Promise.resolve();
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
if (signal?.aborted) {
|
|
84
|
+
reject(abortError(signal));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
signal?.removeEventListener('abort', onAbort);
|
|
89
|
+
resolve();
|
|
90
|
+
}, ms);
|
|
91
|
+
const onAbort = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
reject(abortError(signal));
|
|
94
|
+
};
|
|
95
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function requestJson(fetchImpl, url, {
|
|
100
|
+
body,
|
|
101
|
+
signal,
|
|
102
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
103
|
+
headers = {},
|
|
104
|
+
method = 'POST',
|
|
105
|
+
action = 'request',
|
|
106
|
+
} = {}) {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
let timedOut = false;
|
|
109
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
110
|
+
if (signal?.aborted) throw abortError(signal);
|
|
111
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
112
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
controller.abort();
|
|
115
|
+
}, timeoutMs) : null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetchImpl(url, {
|
|
119
|
+
method,
|
|
120
|
+
redirect: 'error',
|
|
121
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
122
|
+
body: JSON.stringify(body ?? {}),
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
});
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new DingtalkApiError(
|
|
127
|
+
'http-error',
|
|
128
|
+
`钉钉服务请求失败(HTTP ${response.status})。`,
|
|
129
|
+
{ status: response.status },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
return await response.json();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new DingtalkApiError('invalid-response', '钉钉服务返回了无法解析的响应。', { cause: error });
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (signal?.aborted) throw abortError(signal);
|
|
139
|
+
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
|
|
140
|
+
if (error instanceof DingtalkApiError) throw error;
|
|
141
|
+
throw new DingtalkApiError('network-error', `暂时无法完成钉钉${action}请求。`, { cause: error });
|
|
142
|
+
} finally {
|
|
143
|
+
if (timer) clearTimeout(timer);
|
|
144
|
+
signal?.removeEventListener('abort', onAbort);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeCardTarget(target) {
|
|
149
|
+
if (target?.type === 'user') {
|
|
150
|
+
const userId = nonEmptyString(target.userId);
|
|
151
|
+
if (userId) return { type: 'user', userId };
|
|
152
|
+
}
|
|
153
|
+
if (target?.type === 'group') {
|
|
154
|
+
const openConversationId = nonEmptyString(target.openConversationId);
|
|
155
|
+
if (openConversationId) return { type: 'group', openConversationId };
|
|
156
|
+
}
|
|
157
|
+
throw new TypeError('DingTalk AI Card target is invalid');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function cardData(text, flowStatus) {
|
|
161
|
+
return {
|
|
162
|
+
cardParamMap: {
|
|
163
|
+
flowStatus,
|
|
164
|
+
msgContent: normalizeDingtalkCardMarkdown(text),
|
|
165
|
+
staticMsgContent: '',
|
|
166
|
+
sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
|
|
167
|
+
config: JSON.stringify({ autoLayout: true }),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cardDeliverBody(cardInstanceId, target, robotCode) {
|
|
173
|
+
const base = { outTrackId: cardInstanceId, userIdType: 1 };
|
|
174
|
+
if (target.type === 'group') {
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
|
|
178
|
+
imGroupOpenDeliverModel: { robotCode },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
|
|
184
|
+
imRobotOpenDeliverModel: {
|
|
185
|
+
spaceType: 'IM_ROBOT',
|
|
186
|
+
robotCode,
|
|
187
|
+
extension: { dynamicSummary: 'true' },
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function normalizeDingtalkCardMarkdown(value) {
|
|
193
|
+
const text = typeof value === 'string' ? value.replace(/\r\n?/g, '\n') : '';
|
|
194
|
+
const lines = text.split('\n');
|
|
195
|
+
let inCodeBlock = false;
|
|
196
|
+
return lines.map((line, index) => {
|
|
197
|
+
const fenced = /^\s{0,3}```/.test(line);
|
|
198
|
+
const currentInCodeBlock = inCodeBlock;
|
|
199
|
+
if (fenced) inCodeBlock = !inCodeBlock;
|
|
200
|
+
if (index === lines.length - 1) return line;
|
|
201
|
+
if (currentInCodeBlock || fenced || inCodeBlock || !line || !lines[index + 1]) return `${line}\n`;
|
|
202
|
+
if (/^\s{0,3}(?:[-*+] |\d+[.)] |#{1,6} |\||> )/.test(lines[index + 1])) return `${line}\n`;
|
|
203
|
+
return `${line}<br>`;
|
|
204
|
+
}).join('');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function assertRegistrationOk(value, action) {
|
|
208
|
+
if (!value || typeof value !== 'object' || value.errcode !== 0) {
|
|
209
|
+
throw new DingtalkApiError(
|
|
210
|
+
'registration-rejected',
|
|
211
|
+
`钉钉扫码${action}失败。`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function positiveNumber(value, fallback) {
|
|
218
|
+
const number = Number(value);
|
|
219
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createDingtalkApi({
|
|
223
|
+
fetchImpl = fetch,
|
|
224
|
+
registrationBaseUrl = process.env.DINGTALK_REGISTRATION_BASE_URL
|
|
225
|
+
|| DINGTALK_REGISTRATION_BASE_URL,
|
|
226
|
+
registrationSource = process.env.DINGTALK_REGISTRATION_SOURCE
|
|
227
|
+
|| DINGTALK_REGISTRATION_SOURCE,
|
|
228
|
+
now = () => Date.now(),
|
|
229
|
+
cardMinIntervalMs = 50,
|
|
230
|
+
cardBackoffMs = 1_000,
|
|
231
|
+
delay = abortableDelay,
|
|
232
|
+
} = {}) {
|
|
233
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
234
|
+
if (typeof now !== 'function') throw new TypeError('now must be a function');
|
|
235
|
+
if (!Number.isFinite(cardMinIntervalMs) || cardMinIntervalMs < 0) {
|
|
236
|
+
throw new TypeError('cardMinIntervalMs must be a non-negative number');
|
|
237
|
+
}
|
|
238
|
+
if (!Number.isFinite(cardBackoffMs) || cardBackoffMs < 0) {
|
|
239
|
+
throw new TypeError('cardBackoffMs must be a non-negative number');
|
|
240
|
+
}
|
|
241
|
+
if (typeof delay !== 'function') throw new TypeError('delay must be a function');
|
|
242
|
+
const registrationBase = normalizeTrustedUrl(registrationBaseUrl, {
|
|
243
|
+
label: '钉钉注册服务',
|
|
244
|
+
requireSubdomain: false,
|
|
245
|
+
});
|
|
246
|
+
const apiBase = new URL(DINGTALK_API_BASE_URL);
|
|
247
|
+
const source = nonEmptyString(registrationSource);
|
|
248
|
+
if (!source) throw new TypeError('registrationSource is required');
|
|
249
|
+
const tokenCache = new Map();
|
|
250
|
+
const tokenRequests = new Map();
|
|
251
|
+
let cardSlotTail = Promise.resolve();
|
|
252
|
+
let nextCardRequestAt = 0;
|
|
253
|
+
|
|
254
|
+
const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
|
|
255
|
+
|
|
256
|
+
async function accessToken({ clientId, clientSecret, signal }) {
|
|
257
|
+
const appKey = nonEmptyString(clientId);
|
|
258
|
+
const appSecret = nonEmptyString(clientSecret);
|
|
259
|
+
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
|
260
|
+
const cached = tokenCache.get(appKey);
|
|
261
|
+
if (cached && cached.expiresAt > now()) return cached.token;
|
|
262
|
+
if (tokenRequests.has(appKey)) return tokenRequests.get(appKey);
|
|
263
|
+
|
|
264
|
+
const request = (async () => {
|
|
265
|
+
const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
|
|
266
|
+
body: { appKey, appSecret },
|
|
267
|
+
signal,
|
|
268
|
+
action: '鉴权',
|
|
269
|
+
});
|
|
270
|
+
const token = nonEmptyString(value?.accessToken);
|
|
271
|
+
if (!token) throw new DingtalkApiError('invalid-access-token', '钉钉服务没有返回访问令牌。');
|
|
272
|
+
const expiresInSeconds = positiveNumber(value?.expireIn ?? value?.expiresIn, 7_200);
|
|
273
|
+
const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
|
|
274
|
+
tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
|
|
275
|
+
return token;
|
|
276
|
+
})().finally(() => tokenRequests.delete(appKey));
|
|
277
|
+
tokenRequests.set(appKey, request);
|
|
278
|
+
return request;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function acquireCardRequestSlot(signal) {
|
|
282
|
+
const acquire = async () => {
|
|
283
|
+
const waitMs = Math.max(0, nextCardRequestAt - now());
|
|
284
|
+
if (waitMs > 0) await delay(waitMs, signal);
|
|
285
|
+
nextCardRequestAt = Math.max(nextCardRequestAt, now()) + cardMinIntervalMs;
|
|
286
|
+
};
|
|
287
|
+
const slot = cardSlotTail.then(acquire, acquire);
|
|
288
|
+
cardSlotTail = slot.catch(() => undefined);
|
|
289
|
+
return slot;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function cardRequest(pathname, options) {
|
|
293
|
+
await acquireCardRequestSlot(options.signal);
|
|
294
|
+
try {
|
|
295
|
+
return await requestJson(fetchImpl, endpoint(apiBase, pathname), options);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof DingtalkApiError) || error.status !== 403) throw error;
|
|
298
|
+
await delay(cardBackoffMs, options.signal);
|
|
299
|
+
await acquireCardRequestSlot(options.signal);
|
|
300
|
+
return requestJson(fetchImpl, endpoint(apiBase, pathname), options);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function failCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
|
|
305
|
+
const instanceId = nonEmptyString(cardInstanceId);
|
|
306
|
+
const content = nonEmptyString(text);
|
|
307
|
+
if (!instanceId) throw new TypeError('cardInstanceId is required');
|
|
308
|
+
if (!content) throw new TypeError('text is required');
|
|
309
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
310
|
+
const headers = { 'x-acs-dingtalk-access-token': token };
|
|
311
|
+
const requests = [
|
|
312
|
+
cardRequest('v1.0/card/streaming', {
|
|
313
|
+
method: 'PUT',
|
|
314
|
+
body: {
|
|
315
|
+
outTrackId: instanceId,
|
|
316
|
+
guid: randomUUID(),
|
|
317
|
+
key: 'msgContent',
|
|
318
|
+
content: normalizeDingtalkCardMarkdown(content),
|
|
319
|
+
isFull: true,
|
|
320
|
+
isFinalize: false,
|
|
321
|
+
isError: true,
|
|
322
|
+
},
|
|
323
|
+
headers,
|
|
324
|
+
signal,
|
|
325
|
+
action: 'AI Card 失败收口',
|
|
326
|
+
}),
|
|
327
|
+
cardRequest('v1.0/card/instances', {
|
|
328
|
+
method: 'PUT',
|
|
329
|
+
body: {
|
|
330
|
+
outTrackId: instanceId,
|
|
331
|
+
cardData: cardData(content, '5'),
|
|
332
|
+
cardUpdateOptions: { updateCardDataByKey: true },
|
|
333
|
+
},
|
|
334
|
+
headers,
|
|
335
|
+
signal,
|
|
336
|
+
action: 'AI Card 失败状态',
|
|
337
|
+
}),
|
|
338
|
+
];
|
|
339
|
+
const results = await Promise.allSettled(requests);
|
|
340
|
+
if (results.every(({ status }) => status === 'rejected')) throw results[0].reason;
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return Object.freeze({
|
|
345
|
+
async beginRegistration({ signal } = {}) {
|
|
346
|
+
const initialized = assertRegistrationOk(await requestJson(
|
|
347
|
+
fetchImpl,
|
|
348
|
+
endpoint(registrationBase, 'app/registration/init'),
|
|
349
|
+
{ body: { source }, signal, action: '初始化' },
|
|
350
|
+
), '初始化');
|
|
351
|
+
const nonce = nonEmptyString(initialized.nonce);
|
|
352
|
+
if (!nonce) throw new DingtalkApiError('invalid-registration', '钉钉扫码初始化缺少 nonce。');
|
|
353
|
+
|
|
354
|
+
const begun = assertRegistrationOk(await requestJson(
|
|
355
|
+
fetchImpl,
|
|
356
|
+
endpoint(registrationBase, 'app/registration/begin'),
|
|
357
|
+
{ body: { nonce }, signal, action: '创建' },
|
|
358
|
+
), '创建');
|
|
359
|
+
const deviceCode = nonEmptyString(begun.device_code);
|
|
360
|
+
const verificationUriComplete = nonEmptyString(begun.verification_uri_complete);
|
|
361
|
+
if (!deviceCode || !verificationUriComplete) {
|
|
362
|
+
throw new DingtalkApiError('invalid-registration', '钉钉扫码服务返回的信息不完整。');
|
|
363
|
+
}
|
|
364
|
+
const verificationUrl = normalizeTrustedUrl(verificationUriComplete, {
|
|
365
|
+
label: '钉钉扫码',
|
|
366
|
+
requireSubdomain: false,
|
|
367
|
+
}).toString();
|
|
368
|
+
return {
|
|
369
|
+
deviceCode,
|
|
370
|
+
userCode: nonEmptyString(begun.user_code) ?? undefined,
|
|
371
|
+
verificationUri: nonEmptyString(begun.verification_uri) ?? undefined,
|
|
372
|
+
verificationUriComplete: verificationUrl,
|
|
373
|
+
expiresInSeconds: positiveNumber(begun.expires_in, 7_200),
|
|
374
|
+
intervalSeconds: positiveNumber(begun.interval, 5),
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async pollRegistration({ deviceCode, signal } = {}) {
|
|
379
|
+
const code = nonEmptyString(deviceCode);
|
|
380
|
+
if (!code) throw new TypeError('deviceCode is required');
|
|
381
|
+
const polled = assertRegistrationOk(await requestJson(
|
|
382
|
+
fetchImpl,
|
|
383
|
+
endpoint(registrationBase, 'app/registration/poll'),
|
|
384
|
+
{ body: { device_code: code }, signal, action: '状态查询' },
|
|
385
|
+
), '状态查询');
|
|
386
|
+
const status = nonEmptyString(polled.status)?.toUpperCase();
|
|
387
|
+
if (!status || !REGISTRATION_STATUSES.has(status)) {
|
|
388
|
+
throw new DingtalkApiError('invalid-registration-status', '钉钉扫码服务返回了无法识别的状态。');
|
|
389
|
+
}
|
|
390
|
+
const result = {
|
|
391
|
+
status,
|
|
392
|
+
failReason: nonEmptyString(polled.fail_reason) ?? undefined,
|
|
393
|
+
};
|
|
394
|
+
if (status === 'SUCCESS') {
|
|
395
|
+
result.clientId = nonEmptyString(polled.client_id) ?? undefined;
|
|
396
|
+
result.clientSecret = nonEmptyString(polled.client_secret) ?? undefined;
|
|
397
|
+
if (!result.clientId || !result.clientSecret) {
|
|
398
|
+
throw new DingtalkApiError('missing-credentials', '钉钉扫码已确认,但没有返回机器人凭据。');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
accessToken,
|
|
405
|
+
|
|
406
|
+
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
|
|
407
|
+
const appKey = nonEmptyString(clientId);
|
|
408
|
+
const appSecret = nonEmptyString(clientSecret);
|
|
409
|
+
const content = nonEmptyString(initialText);
|
|
410
|
+
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
|
411
|
+
if (!content) throw new TypeError('initialText is required');
|
|
412
|
+
const normalizedTarget = normalizeCardTarget(target);
|
|
413
|
+
const token = await accessToken({ clientId: appKey, clientSecret: appSecret, signal });
|
|
414
|
+
const cardInstanceId = `dsh_${randomUUID()}`;
|
|
415
|
+
const headers = { 'x-acs-dingtalk-access-token': token };
|
|
416
|
+
|
|
417
|
+
let delivered = false;
|
|
418
|
+
try {
|
|
419
|
+
await cardRequest('v1.0/card/instances', {
|
|
420
|
+
body: {
|
|
421
|
+
cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID,
|
|
422
|
+
outTrackId: cardInstanceId,
|
|
423
|
+
cardData: {
|
|
424
|
+
cardParamMap: { config: JSON.stringify({ autoLayout: true }) },
|
|
425
|
+
},
|
|
426
|
+
callbackType: 'STREAM',
|
|
427
|
+
imGroupOpenSpaceModel: { supportForward: true },
|
|
428
|
+
imRobotOpenSpaceModel: { supportForward: true },
|
|
429
|
+
},
|
|
430
|
+
headers,
|
|
431
|
+
signal,
|
|
432
|
+
action: 'AI Card 创建',
|
|
433
|
+
});
|
|
434
|
+
await cardRequest('v1.0/card/instances/deliver', {
|
|
435
|
+
body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
|
|
436
|
+
headers,
|
|
437
|
+
signal,
|
|
438
|
+
action: 'AI Card 投放',
|
|
439
|
+
});
|
|
440
|
+
delivered = true;
|
|
441
|
+
await cardRequest('v1.0/card/instances', {
|
|
442
|
+
method: 'PUT',
|
|
443
|
+
body: { outTrackId: cardInstanceId, cardData: cardData(content, '2') },
|
|
444
|
+
headers,
|
|
445
|
+
signal,
|
|
446
|
+
action: 'AI Card 启动',
|
|
447
|
+
});
|
|
448
|
+
await cardRequest('v1.0/card/streaming', {
|
|
449
|
+
method: 'PUT',
|
|
450
|
+
body: {
|
|
451
|
+
outTrackId: cardInstanceId,
|
|
452
|
+
guid: randomUUID(),
|
|
453
|
+
key: 'msgContent',
|
|
454
|
+
content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''),
|
|
455
|
+
isFull: true,
|
|
456
|
+
isFinalize: false,
|
|
457
|
+
isError: false,
|
|
458
|
+
},
|
|
459
|
+
headers,
|
|
460
|
+
signal,
|
|
461
|
+
action: 'AI Card 启动',
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (delivered) {
|
|
465
|
+
const cleanupSignal = AbortSignal.timeout(5_000);
|
|
466
|
+
await failCard({
|
|
467
|
+
clientId: appKey,
|
|
468
|
+
clientSecret: appSecret,
|
|
469
|
+
cardInstanceId,
|
|
470
|
+
text: '消息处理失败,请稍后重试。',
|
|
471
|
+
signal: cleanupSignal,
|
|
472
|
+
}).catch(() => undefined);
|
|
473
|
+
}
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
return { cardInstanceId };
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
async updateAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
|
|
480
|
+
const instanceId = nonEmptyString(cardInstanceId);
|
|
481
|
+
const content = nonEmptyString(text);
|
|
482
|
+
if (!instanceId) throw new TypeError('cardInstanceId is required');
|
|
483
|
+
if (!content) throw new TypeError('text is required');
|
|
484
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
485
|
+
await cardRequest('v1.0/card/streaming', {
|
|
486
|
+
method: 'PUT',
|
|
487
|
+
body: {
|
|
488
|
+
outTrackId: instanceId,
|
|
489
|
+
guid: randomUUID(),
|
|
490
|
+
key: 'msgContent',
|
|
491
|
+
content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''),
|
|
492
|
+
isFull: true,
|
|
493
|
+
isFinalize: false,
|
|
494
|
+
isError: false,
|
|
495
|
+
},
|
|
496
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
497
|
+
signal,
|
|
498
|
+
action: 'AI Card 更新',
|
|
499
|
+
});
|
|
500
|
+
return true;
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
async finishAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
|
|
504
|
+
const instanceId = nonEmptyString(cardInstanceId);
|
|
505
|
+
const content = nonEmptyString(text);
|
|
506
|
+
if (!instanceId) throw new TypeError('cardInstanceId is required');
|
|
507
|
+
if (!content) throw new TypeError('text is required');
|
|
508
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
509
|
+
const headers = { 'x-acs-dingtalk-access-token': token };
|
|
510
|
+
const normalizedContent = normalizeDingtalkCardMarkdown(content);
|
|
511
|
+
await cardRequest('v1.0/card/streaming', {
|
|
512
|
+
method: 'PUT',
|
|
513
|
+
body: {
|
|
514
|
+
outTrackId: instanceId,
|
|
515
|
+
guid: randomUUID(),
|
|
516
|
+
key: 'msgContent',
|
|
517
|
+
content: normalizedContent,
|
|
518
|
+
isFull: true,
|
|
519
|
+
isFinalize: true,
|
|
520
|
+
isError: false,
|
|
521
|
+
},
|
|
522
|
+
headers,
|
|
523
|
+
signal,
|
|
524
|
+
action: 'AI Card 完成',
|
|
525
|
+
});
|
|
526
|
+
let completed = true;
|
|
527
|
+
const completionRequest = {
|
|
528
|
+
method: 'PUT',
|
|
529
|
+
body: {
|
|
530
|
+
outTrackId: instanceId,
|
|
531
|
+
cardData: cardData(content, '3'),
|
|
532
|
+
cardUpdateOptions: { updateCardDataByKey: true },
|
|
533
|
+
},
|
|
534
|
+
headers,
|
|
535
|
+
signal,
|
|
536
|
+
action: 'AI Card 收口',
|
|
537
|
+
};
|
|
538
|
+
try {
|
|
539
|
+
await cardRequest('v1.0/card/instances', completionRequest);
|
|
540
|
+
} catch {
|
|
541
|
+
try {
|
|
542
|
+
await cardRequest('v1.0/card/instances', completionRequest);
|
|
543
|
+
} catch {
|
|
544
|
+
completed = false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { delivered: true, completed };
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
failAiCard: failCard,
|
|
551
|
+
|
|
552
|
+
async sendText({ clientId, clientSecret, sessionWebhook, text, signal }) {
|
|
553
|
+
const content = nonEmptyString(text);
|
|
554
|
+
if (!content) throw new TypeError('text is required');
|
|
555
|
+
const webhook = normalizeDingtalkSessionWebhook(sessionWebhook);
|
|
556
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
557
|
+
const response = await requestJson(fetchImpl, webhook, {
|
|
558
|
+
body: { msgtype: 'text', text: { content } },
|
|
559
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
560
|
+
signal,
|
|
561
|
+
action: '消息回复',
|
|
562
|
+
});
|
|
563
|
+
if ((response?.errcode !== undefined && response.errcode !== 0)
|
|
564
|
+
|| (response?.code !== undefined && response.code !== 0)) {
|
|
565
|
+
throw new DingtalkApiError('send-rejected', '钉钉服务拒绝了回复消息。');
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
clearAccessToken(clientId) {
|
|
571
|
+
const appKey = nonEmptyString(clientId);
|
|
572
|
+
if (appKey) tokenCache.delete(appKey);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export const createDingTalkApi = createDingtalkApi;
|
|
578
|
+
export const normalizeDingTalkSessionWebhook = normalizeDingtalkSessionWebhook;
|
|
579
|
+
export const splitDingTalkText = splitDingtalkText;
|