@clowder-ai/connector-dingtalk 0.0.0-stage → 0.1.0-alpha.7
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.en.md +54 -0
- package/README.md +24 -2
- package/assets/icon.svg +4 -0
- package/dist/DingTalkAdapter.d.ts +251 -0
- package/dist/DingTalkAdapter.d.ts.map +1 -0
- package/dist/DingTalkAdapter.js +821 -0
- package/dist/DingTalkAdapter.js.map +1 -0
- package/dist/command-ingress.d.ts +10 -0
- package/dist/command-ingress.d.ts.map +1 -0
- package/dist/command-ingress.js +15 -0
- package/dist/command-ingress.js.map +1 -0
- package/dist/execution-status.d.ts +27 -0
- package/dist/execution-status.d.ts.map +1 -0
- package/dist/execution-status.js +48 -0
- package/dist/execution-status.js.map +1 -0
- package/dist/inbound-download.d.ts +13 -0
- package/dist/inbound-download.d.ts.map +1 -0
- package/dist/inbound-download.js +70 -0
- package/dist/inbound-download.js.map +1 -0
- package/dist/inbound-media-source.d.ts +25 -0
- package/dist/inbound-media-source.d.ts.map +1 -0
- package/dist/inbound-media-source.js +115 -0
- package/dist/inbound-media-source.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-action.d.ts +30 -0
- package/dist/lifecycle-action.d.ts.map +1 -0
- package/dist/lifecycle-action.js +457 -0
- package/dist/lifecycle-action.js.map +1 -0
- package/dist/materialize-media.d.ts +7 -0
- package/dist/materialize-media.d.ts.map +1 -0
- package/dist/materialize-media.js +35 -0
- package/dist/materialize-media.js.map +1 -0
- package/dist/media-notice.d.ts +6 -0
- package/dist/media-notice.d.ts.map +1 -0
- package/dist/media-notice.js +50 -0
- package/dist/media-notice.js.map +1 -0
- package/dist/plugin-entrypoint.d.ts +7 -0
- package/dist/plugin-entrypoint.d.ts.map +1 -0
- package/dist/plugin-entrypoint.js +282 -0
- package/dist/plugin-entrypoint.js.map +1 -0
- package/dist/reply-sender-map.d.ts +10 -0
- package/dist/reply-sender-map.d.ts.map +1 -0
- package/dist/reply-sender-map.js +123 -0
- package/dist/reply-sender-map.js.map +1 -0
- package/dist/runtime.d.ts +68 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +167 -0
- package/dist/runtime.js.map +1 -0
- package/dist/thread-mappings.d.ts +46 -0
- package/dist/thread-mappings.d.ts.map +1 -0
- package/dist/thread-mappings.js +62 -0
- package/dist/thread-mappings.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/npm-shrinkwrap.json +485 -0
- package/package.json +55 -4
- package/plugin.yaml +63 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DingTalk (钉钉) Enterprise Bot Adapter
|
|
3
|
+
* Inbound: Parse Stream events → extract DM messages
|
|
4
|
+
* Outbound: Send reply via DingTalk OpenAPI + AI Card streaming
|
|
5
|
+
*
|
|
6
|
+
* Uses dingtalk-stream for Stream mode (no public URL needed).
|
|
7
|
+
* AI Card for rich/streaming replies (create → streaming update → finish).
|
|
8
|
+
*
|
|
9
|
+
* F132 DingTalk + WeCom Chat Gateway — Phase A
|
|
10
|
+
*/
|
|
11
|
+
import { openAsBlob } from 'node:fs';
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import { fetchBoundedInboundMedia, INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, } from './inbound-download.js';
|
|
14
|
+
import { materializeMedia } from './materialize-media.js';
|
|
15
|
+
// DingTalk robot media upload limits: image/file 20 MB, voice 2 MB.
|
|
16
|
+
// Source: https://open.dingtalk.com/document/orgapp/upload-media-files
|
|
17
|
+
export const DINGTALK_MEDIA_MAX_BYTES = {
|
|
18
|
+
image: 20_000_000,
|
|
19
|
+
file: 20_000_000,
|
|
20
|
+
audio: 2_000_000,
|
|
21
|
+
};
|
|
22
|
+
// ── AI Card Throttle Config ──
|
|
23
|
+
const AI_CARD_THROTTLE_MS = 300;
|
|
24
|
+
const AI_CARD_TEMPLATE_ID = 'StandardCard';
|
|
25
|
+
const STREAM_CONNECT_TIMEOUT_MS = 30_000;
|
|
26
|
+
// ── Adapter ──
|
|
27
|
+
export class DingTalkAdapter {
|
|
28
|
+
connectorId = 'dingtalk';
|
|
29
|
+
log;
|
|
30
|
+
appKey;
|
|
31
|
+
appSecret;
|
|
32
|
+
robotCode;
|
|
33
|
+
// Stream client (dingtalk-stream SDK)
|
|
34
|
+
streamClient = null;
|
|
35
|
+
stopFn = null;
|
|
36
|
+
streamGeneration = 0;
|
|
37
|
+
// Active AI Card sessions (keyed by outTrackId)
|
|
38
|
+
activeCards = new Map();
|
|
39
|
+
// staffId → conversationId mapping (populated from inbound parseEvent)
|
|
40
|
+
// AI Card delivery needs the real conversationId, but the public layer
|
|
41
|
+
// passes externalChatId (= staffId) for outbound routing.
|
|
42
|
+
staffToConversation = new Map();
|
|
43
|
+
groupConversationIds = new Set();
|
|
44
|
+
senderNickCache = new Map();
|
|
45
|
+
conversationTitleCache = new Map();
|
|
46
|
+
// DI injection points (for testing + runtime override)
|
|
47
|
+
streamModuleOverride = null;
|
|
48
|
+
sendMessageFn = null;
|
|
49
|
+
createCardFn = null;
|
|
50
|
+
streamingCardFn = null;
|
|
51
|
+
accessTokenFn = null;
|
|
52
|
+
downloadMediaFn = null;
|
|
53
|
+
uploadMediaFn = null;
|
|
54
|
+
inboundFetchFn = globalThis.fetch;
|
|
55
|
+
inboundMediaTimeoutMs = INBOUND_MEDIA_TIMEOUT_MS;
|
|
56
|
+
streamConnectTimeoutMs;
|
|
57
|
+
constructor(log, options) {
|
|
58
|
+
this.log = log;
|
|
59
|
+
this.appKey = options.appKey;
|
|
60
|
+
this.appSecret = options.appSecret;
|
|
61
|
+
this.robotCode = options.robotCode ?? options.appKey;
|
|
62
|
+
this.streamConnectTimeoutMs = options.streamConnectTimeoutMs ?? STREAM_CONNECT_TIMEOUT_MS;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* G1/N1: the SDK retries a revoked-credential or dropped stream silently,
|
|
66
|
+
* so 'startStream resolved once' is not proof of a live stream. This asks
|
|
67
|
+
* the client itself and requires BOTH flags: `connected` is the WebSocket
|
|
68
|
+
* session, `registered` is the robot registration frame — the SDK sets it
|
|
69
|
+
* only on the REGISTERED system topic and clears both on close
|
|
70
|
+
* (dist/client.mjs). A connect-only socket that never registered is dead
|
|
71
|
+
* for ingress purposes.
|
|
72
|
+
*/
|
|
73
|
+
isStreamLive() {
|
|
74
|
+
const client = this.streamClient;
|
|
75
|
+
return client !== null && client.connected === true && client.registered === true;
|
|
76
|
+
}
|
|
77
|
+
async loadStreamModule() {
|
|
78
|
+
if (this.streamModuleOverride !== null)
|
|
79
|
+
return this.streamModuleOverride;
|
|
80
|
+
return await import('dingtalk-stream');
|
|
81
|
+
}
|
|
82
|
+
// ── Inbound: Parse Stream Event ──
|
|
83
|
+
/**
|
|
84
|
+
* Parse a DingTalk Stream bot message callback into an inbound message.
|
|
85
|
+
* Supports text, richText, picture, audio, file message types.
|
|
86
|
+
* Returns null for group or unsupported events.
|
|
87
|
+
*
|
|
88
|
+
* AC-A1: DM text + richText parsing
|
|
89
|
+
*/
|
|
90
|
+
parseEvent(eventBody) {
|
|
91
|
+
if (!eventBody || typeof eventBody !== 'object')
|
|
92
|
+
return null;
|
|
93
|
+
const body = eventBody;
|
|
94
|
+
const msgType = body.msgtype;
|
|
95
|
+
if (!msgType)
|
|
96
|
+
return null;
|
|
97
|
+
const conversationType = body.conversationType;
|
|
98
|
+
if (conversationType !== '1' && conversationType !== '2')
|
|
99
|
+
return null;
|
|
100
|
+
const isGroup = conversationType === '2';
|
|
101
|
+
const chatType = isGroup ? 'group' : 'p2p';
|
|
102
|
+
const conversationId = body.conversationId ?? '';
|
|
103
|
+
const openConversationId = body.openConversationId ?? '';
|
|
104
|
+
const conversationTitle = body.conversationTitle ?? undefined;
|
|
105
|
+
const messageId = body.msgId ?? '';
|
|
106
|
+
const senderStaffId = body.senderStaffId ?? body.senderId ?? 'unknown';
|
|
107
|
+
const senderNick = body.senderNick ?? undefined;
|
|
108
|
+
const chatId = isGroup ? openConversationId : senderStaffId;
|
|
109
|
+
const base = {
|
|
110
|
+
chatId,
|
|
111
|
+
conversationId,
|
|
112
|
+
messageId,
|
|
113
|
+
senderId: senderStaffId,
|
|
114
|
+
chatType,
|
|
115
|
+
senderNick,
|
|
116
|
+
conversationTitle,
|
|
117
|
+
};
|
|
118
|
+
if (conversationId)
|
|
119
|
+
this.staffToConversation.set(senderStaffId, conversationId);
|
|
120
|
+
if (isGroup && openConversationId) {
|
|
121
|
+
this.groupConversationIds.add(openConversationId);
|
|
122
|
+
if (senderNick)
|
|
123
|
+
this.senderNickCache.set(senderStaffId, senderNick);
|
|
124
|
+
if (conversationTitle)
|
|
125
|
+
this.conversationTitleCache.set(openConversationId, conversationTitle);
|
|
126
|
+
}
|
|
127
|
+
switch (msgType) {
|
|
128
|
+
case 'text': {
|
|
129
|
+
const textObj = body.text;
|
|
130
|
+
const text = textObj?.content;
|
|
131
|
+
if (!text)
|
|
132
|
+
return null;
|
|
133
|
+
return { ...base, text: text.trim() };
|
|
134
|
+
}
|
|
135
|
+
case 'richText': {
|
|
136
|
+
// DingTalk Stream FAQ confirms: body.richText is a flat array
|
|
137
|
+
const richContent = Array.isArray(body.richText) ? body.richText : null;
|
|
138
|
+
if (!richContent)
|
|
139
|
+
return null;
|
|
140
|
+
const textParts = [];
|
|
141
|
+
const attachments = [];
|
|
142
|
+
for (const node of richContent) {
|
|
143
|
+
const n = node;
|
|
144
|
+
if (n.text)
|
|
145
|
+
textParts.push(String(n.text));
|
|
146
|
+
if (n.type === 'picture' && n.downloadCode) {
|
|
147
|
+
attachments.push({ type: 'image', downloadCode: String(n.downloadCode) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const text = textParts.join('') || '[富文本]';
|
|
151
|
+
return { ...base, text, ...(attachments.length > 0 ? { attachments } : {}) };
|
|
152
|
+
}
|
|
153
|
+
case 'picture': {
|
|
154
|
+
const downloadCode = body.content?.downloadCode;
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
text: '[图片]',
|
|
158
|
+
attachments: downloadCode ? [{ type: 'image', downloadCode }] : undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
case 'audio': {
|
|
162
|
+
const audioBody = body.content;
|
|
163
|
+
const downloadCode = audioBody?.downloadCode;
|
|
164
|
+
const duration = audioBody?.duration;
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
text: '[语音]',
|
|
168
|
+
attachments: downloadCode
|
|
169
|
+
? [{ type: 'audio', downloadCode, ...(duration != null ? { duration } : {}) }]
|
|
170
|
+
: undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
case 'file': {
|
|
174
|
+
const fileBody = body.content;
|
|
175
|
+
const downloadCode = fileBody?.downloadCode;
|
|
176
|
+
const fileName = fileBody?.fileName;
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
text: fileName ? `[文件] ${fileName}` : '[文件]',
|
|
180
|
+
attachments: downloadCode
|
|
181
|
+
? [{ type: 'file', downloadCode, ...(fileName ? { fileName } : {}) }]
|
|
182
|
+
: undefined,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// ── Name Resolution (AC-A2.5) ──
|
|
190
|
+
/**
|
|
191
|
+
* Resolve sender display name from cached inbound event data.
|
|
192
|
+
* DingTalk group webhooks include senderNick, so no API call needed.
|
|
193
|
+
*/
|
|
194
|
+
resolveSenderName(staffId) {
|
|
195
|
+
return this.senderNickCache.get(staffId);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve group conversation title from cached inbound event data.
|
|
199
|
+
* DingTalk group webhooks include conversationTitle, so no API call needed.
|
|
200
|
+
*/
|
|
201
|
+
resolveConversationTitle(openConversationId) {
|
|
202
|
+
return this.conversationTitleCache.get(openConversationId);
|
|
203
|
+
}
|
|
204
|
+
// ── Outbound: Send Messages ──
|
|
205
|
+
/**
|
|
206
|
+
* Prepend @sender mention for group chat replies (AC-A2.4).
|
|
207
|
+
* Only used for group chats — DM replies must NOT @mention (Feishu AC-C2 pattern).
|
|
208
|
+
*/
|
|
209
|
+
prependAtSender(content, sender) {
|
|
210
|
+
const name = sender.name ?? this.senderNickCache.get(sender.id) ?? '用户';
|
|
211
|
+
return `@${name} ${content}`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Send a plain text reply via DingTalk Robot API.
|
|
215
|
+
* AC-A2: Basic text + markdown sending
|
|
216
|
+
*/
|
|
217
|
+
async sendReply(externalChatId, content, metadata) {
|
|
218
|
+
const metaChatType = metadata?.chatType;
|
|
219
|
+
const isGroup = metaChatType === 'group' || this.groupConversationIds.has(externalChatId);
|
|
220
|
+
const sender = metadata?.replyToSender;
|
|
221
|
+
const text = isGroup && sender ? this.prependAtSender(content, sender) : content;
|
|
222
|
+
await this.sendDingTalkMessage(externalChatId, 'text', { content: text }, isGroup ? 'group' : undefined);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Send a markdown reply.
|
|
226
|
+
*/
|
|
227
|
+
async sendMarkdown(externalChatId, title, text, chatTypeOverride) {
|
|
228
|
+
await this.sendDingTalkMessage(externalChatId, 'markdown', { title, text }, chatTypeOverride);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Send a rich block message (convert blocks to markdown text).
|
|
232
|
+
*/
|
|
233
|
+
async sendRichMessage(externalChatId, textContent, _blocks, catDisplayName, metadata) {
|
|
234
|
+
const chatTypeOverride = metadata?.chatType;
|
|
235
|
+
const title = `🐱 ${catDisplayName}`;
|
|
236
|
+
await this.sendMarkdown(externalChatId, title, textContent, chatTypeOverride);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Send a formatted reply as AI Card.
|
|
240
|
+
* AC-A3: AI Card with cat name header + body + deep link
|
|
241
|
+
*/
|
|
242
|
+
async sendFormattedReply(externalChatId, envelope, metadata) {
|
|
243
|
+
const metaChatType = metadata?.chatType;
|
|
244
|
+
const chatTypeOverride = metaChatType === 'group' ? 'group' : undefined;
|
|
245
|
+
const isCallback = envelope.origin === 'callback';
|
|
246
|
+
const headerTitle = isCallback ? `📨 ${envelope.header} · 传话` : envelope.header;
|
|
247
|
+
// Build markdown body for AI Card
|
|
248
|
+
let body = '';
|
|
249
|
+
if (envelope.subtitle) {
|
|
250
|
+
body += `**${envelope.subtitle}**\n\n`;
|
|
251
|
+
}
|
|
252
|
+
body += envelope.body;
|
|
253
|
+
if (envelope.footer) {
|
|
254
|
+
body += `\n\n---\n${envelope.footer}`;
|
|
255
|
+
}
|
|
256
|
+
// Try AI Card first, fall back to markdown
|
|
257
|
+
try {
|
|
258
|
+
await this.sendAICard(externalChatId, headerTitle, body, chatTypeOverride);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
this.log.warn({ err }, '[DingTalkAdapter] AI Card sendFormattedReply failed, falling back to markdown');
|
|
262
|
+
await this.sendMarkdown(externalChatId, headerTitle, body, chatTypeOverride);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ── Streaming: AI Card ──
|
|
266
|
+
/**
|
|
267
|
+
* Send a placeholder AI Card and return its outTrackId.
|
|
268
|
+
* AC-A4: AI Card streaming (create phase)
|
|
269
|
+
*/
|
|
270
|
+
async sendPlaceholder(externalChatId, text) {
|
|
271
|
+
const outTrackId = `cc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
272
|
+
try {
|
|
273
|
+
await this.createAICardInstance(externalChatId, outTrackId, text);
|
|
274
|
+
this.activeCards.set(outTrackId, {
|
|
275
|
+
outTrackId,
|
|
276
|
+
state: 'PROCESSING',
|
|
277
|
+
lastUpdateAt: 0,
|
|
278
|
+
lastContentLength: 0,
|
|
279
|
+
});
|
|
280
|
+
return outTrackId;
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
this.log.warn({ err }, '[DingTalkAdapter] sendPlaceholder failed');
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Edit an AI Card via streaming update.
|
|
289
|
+
* AC-A4: AI Card streaming (update phase, 300ms throttle)
|
|
290
|
+
*/
|
|
291
|
+
async editMessage(_externalChatId, platformMessageId, text, options = {}) {
|
|
292
|
+
const card = this.activeCards.get(platformMessageId);
|
|
293
|
+
if (!card) {
|
|
294
|
+
this.log.warn({ platformMessageId }, '[DingTalkAdapter] editMessage: no active card found');
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
// 300ms throttle (AC-A4)
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
if (!options.bypassThrottle && now - card.lastUpdateAt < AI_CARD_THROTTLE_MS)
|
|
300
|
+
return false;
|
|
301
|
+
try {
|
|
302
|
+
// Transition to INPUTING if still PROCESSING
|
|
303
|
+
const newState = card.state === 'PROCESSING' ? 'INPUTING' : card.state;
|
|
304
|
+
await this.updateAICardStreaming(platformMessageId, text, newState);
|
|
305
|
+
card.state = newState;
|
|
306
|
+
card.lastUpdateAt = now;
|
|
307
|
+
card.lastContentLength = text.length;
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
this.log.warn({ err, platformMessageId }, '[DingTalkAdapter] editMessage streaming update failed');
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Delete/finish an AI Card (transition to FINISHED state).
|
|
317
|
+
* StreamingOutboundHook calls this for cleanup.
|
|
318
|
+
*/
|
|
319
|
+
async deleteMessage(platformMessageId, finalContent = '') {
|
|
320
|
+
const card = this.activeCards.get(platformMessageId);
|
|
321
|
+
if (!card)
|
|
322
|
+
return;
|
|
323
|
+
try {
|
|
324
|
+
await this.updateAICardStreaming(platformMessageId, finalContent, 'FINISHED');
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
this.log.warn({ err, platformMessageId }, '[DingTalkAdapter] deleteMessage (finish card) failed');
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
this.activeCards.delete(platformMessageId);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ── Media ──
|
|
334
|
+
/**
|
|
335
|
+
* Send a media message (image, file, audio).
|
|
336
|
+
* AC-A5: Media upload + send
|
|
337
|
+
*/
|
|
338
|
+
async sendMedia(externalChatId, payload) {
|
|
339
|
+
const materialized = await materializeMedia(payload.content, payload.fileName, DINGTALK_MEDIA_MAX_BYTES[payload.type]);
|
|
340
|
+
try {
|
|
341
|
+
const mediaId = await this.uploadToDingTalk(materialized.path, payload.type);
|
|
342
|
+
if (!mediaId)
|
|
343
|
+
throw new Error('DingTalk media upload returned no media id');
|
|
344
|
+
await this.sendDingTalkMediaMessage(externalChatId, payload.type, mediaId, {
|
|
345
|
+
fileName: payload.fileName ?? basename(materialized.path),
|
|
346
|
+
duration: payload.duration,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await materialized.cleanup().catch(() => undefined);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Download media file from an inbound message using its downloadCode.
|
|
355
|
+
* Returns a temporary download URL.
|
|
356
|
+
* AC-A5: Inbound media download via POST /v1.0/robot/messageFiles/download
|
|
357
|
+
*/
|
|
358
|
+
async downloadMedia(downloadCode, signal) {
|
|
359
|
+
if (this.downloadMediaFn)
|
|
360
|
+
return this.downloadMediaFn(downloadCode);
|
|
361
|
+
const accessToken = await this.getAccessToken(signal);
|
|
362
|
+
const url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download';
|
|
363
|
+
const res = await this.inboundFetchFn(url, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: {
|
|
366
|
+
'Content-Type': 'application/json',
|
|
367
|
+
'x-acs-dingtalk-access-token': accessToken,
|
|
368
|
+
},
|
|
369
|
+
body: JSON.stringify({ downloadCode, robotCode: this.robotCode }),
|
|
370
|
+
signal,
|
|
371
|
+
});
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
374
|
+
throw new Error(`DingTalk media download error ${res.status}: ${body}`);
|
|
375
|
+
}
|
|
376
|
+
const data = (await res.json());
|
|
377
|
+
if (!data.downloadUrl)
|
|
378
|
+
throw new Error('DingTalk media download: missing downloadUrl in response');
|
|
379
|
+
return data.downloadUrl;
|
|
380
|
+
}
|
|
381
|
+
async downloadInboundMedia(locator) {
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
const timer = setTimeout(() => controller.abort(new Error('DingTalk inbound media download timed out')), this.inboundMediaTimeoutMs);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
try {
|
|
386
|
+
const downloadUrl = await this.downloadMedia(locator.platformKey, controller.signal);
|
|
387
|
+
const parsed = new URL(downloadUrl);
|
|
388
|
+
if (parsed.protocol !== 'https:')
|
|
389
|
+
throw new Error('DingTalk media download URL must use HTTPS');
|
|
390
|
+
const { response, bytes } = await fetchBoundedInboundMedia(this.inboundFetchFn, parsed, { signal: controller.signal }, INBOUND_MEDIA_MAX_BYTES, this.inboundMediaTimeoutMs);
|
|
391
|
+
if (!response.ok)
|
|
392
|
+
throw new Error(`DingTalk media download HTTP ${response.status}`);
|
|
393
|
+
return bytes;
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// ── Stream Connection ──
|
|
400
|
+
/**
|
|
401
|
+
* Start the DingTalk Stream connection (long-lived, like Telegram polling).
|
|
402
|
+
* Calls onMessage for each inbound bot message.
|
|
403
|
+
* AC-A7: Stream connection + reconnect + dedup
|
|
404
|
+
*/
|
|
405
|
+
async startStream(onMessage) {
|
|
406
|
+
const generation = ++this.streamGeneration;
|
|
407
|
+
try {
|
|
408
|
+
const { DWClient, EventAck, TOPIC_ROBOT } = await this.loadStreamModule();
|
|
409
|
+
if (generation !== this.streamGeneration)
|
|
410
|
+
return;
|
|
411
|
+
const client = new DWClient({
|
|
412
|
+
clientId: this.appKey,
|
|
413
|
+
clientSecret: this.appSecret,
|
|
414
|
+
debug: false,
|
|
415
|
+
// The SDK default is keepAlive:false (dist/client.mjs defaultConfig),
|
|
416
|
+
// which means its ping/pong watchdog is never installed and a
|
|
417
|
+
// half-dead socket is never terminated from the client side.
|
|
418
|
+
keepAlive: true,
|
|
419
|
+
});
|
|
420
|
+
client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
|
|
421
|
+
const downstream = res;
|
|
422
|
+
const messageId = downstream.headers?.messageId ?? '';
|
|
423
|
+
try {
|
|
424
|
+
const data = downstream.data ? JSON.parse(downstream.data) : null;
|
|
425
|
+
if (!data)
|
|
426
|
+
return;
|
|
427
|
+
const parsed = this.parseEvent(data);
|
|
428
|
+
if (parsed) {
|
|
429
|
+
await onMessage(parsed);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
this.log.error({ err }, '[DingTalkAdapter] Stream message handler error');
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
// Always ACK to prevent 60s retry (guard: SDK throws if messageId is empty)
|
|
437
|
+
if (messageId)
|
|
438
|
+
client.socketCallBackResponse(messageId, EventAck.SUCCESS);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
this.streamClient = client;
|
|
442
|
+
this.stopFn = async () => {
|
|
443
|
+
try {
|
|
444
|
+
client.disconnect();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// ignore disconnect errors
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
// One connection budget shared by the connect() race and the liveness
|
|
451
|
+
// poll — previously the timer and the poll each had their own 30s, so a
|
|
452
|
+
// slow failure could spin for a full minute.
|
|
453
|
+
const deadline = Date.now() + this.streamConnectTimeoutMs;
|
|
454
|
+
let connectTimer;
|
|
455
|
+
try {
|
|
456
|
+
await new Promise((resolve, reject) => {
|
|
457
|
+
connectTimer = setTimeout(() => {
|
|
458
|
+
reject(new Error(`DingTalk stream connect timed out after ${this.streamConnectTimeoutMs}ms`));
|
|
459
|
+
}, Math.max(deadline - Date.now(), 0));
|
|
460
|
+
// Handlers are attached to client.connect(), so a late settlement
|
|
461
|
+
// after the timeout rejects is never an unhandled rejection.
|
|
462
|
+
client.connect().then(() => resolve(), reject);
|
|
463
|
+
});
|
|
464
|
+
// DWClient.connect() never rejects on connection failure — the SDK
|
|
465
|
+
// catches, schedules a backoff reconnect, and returns. Wait until the
|
|
466
|
+
// socket is open AND registered so a resolved startStream means a live
|
|
467
|
+
// stream instead of reporting 'running' over a dead connection.
|
|
468
|
+
while (!(client.connected === true && client.registered === true)) {
|
|
469
|
+
if (generation !== this.streamGeneration) {
|
|
470
|
+
try {
|
|
471
|
+
client.disconnect();
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// ignore disconnect errors
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (Date.now() >= deadline) {
|
|
479
|
+
try {
|
|
480
|
+
client.disconnect();
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// ignore disconnect errors
|
|
484
|
+
}
|
|
485
|
+
throw new Error(`DingTalk stream connect timed out after ${this.streamConnectTimeoutMs}ms`);
|
|
486
|
+
}
|
|
487
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
// stop() racing this connect() disconnects the client first, so the
|
|
492
|
+
// connect rejection is expected — swallow it instead of rethrowing into
|
|
493
|
+
// a startPromise that stop() no longer awaits (unhandled rejection).
|
|
494
|
+
if (generation !== this.streamGeneration)
|
|
495
|
+
return;
|
|
496
|
+
try {
|
|
497
|
+
client.disconnect();
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// ignore disconnect errors
|
|
501
|
+
}
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
if (connectTimer)
|
|
506
|
+
clearTimeout(connectTimer);
|
|
507
|
+
}
|
|
508
|
+
if (generation !== this.streamGeneration) {
|
|
509
|
+
try {
|
|
510
|
+
client.disconnect();
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// ignore disconnect errors
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.log.info('[DingTalkAdapter] Stream connection established');
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
this.log.error({ err }, '[DingTalkAdapter] Failed to start Stream connection');
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Stop the Stream connection.
|
|
526
|
+
*/
|
|
527
|
+
async stopStream() {
|
|
528
|
+
this.streamGeneration += 1;
|
|
529
|
+
const stop = this.stopFn;
|
|
530
|
+
this.stopFn = null;
|
|
531
|
+
this.streamClient = null;
|
|
532
|
+
if (stop) {
|
|
533
|
+
await stop();
|
|
534
|
+
this.log.info('[DingTalkAdapter] Stream connection stopped');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// ── Private: DingTalk OpenAPI Calls ──
|
|
538
|
+
async postRobotMessage(chatId, msgKey, msgParam, diMsgType, errorLabel, chatTypeOverride) {
|
|
539
|
+
const isGroup = chatTypeOverride === 'group' || this.groupConversationIds.has(chatId);
|
|
540
|
+
if (this.sendMessageFn) {
|
|
541
|
+
return this.sendMessageFn({
|
|
542
|
+
chatId,
|
|
543
|
+
content: JSON.stringify(msgParam),
|
|
544
|
+
msgType: diMsgType,
|
|
545
|
+
chatType: isGroup ? 'group' : 'p2p',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const accessToken = await this.getAccessToken();
|
|
549
|
+
const url = isGroup
|
|
550
|
+
? 'https://api.dingtalk.com/v1.0/robot/orgGroupSend'
|
|
551
|
+
: 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
552
|
+
const payload = isGroup
|
|
553
|
+
? { robotCode: this.robotCode, openConversationId: chatId, msgKey, msgParam: JSON.stringify(msgParam) }
|
|
554
|
+
: { robotCode: this.robotCode, userIds: [chatId], msgKey, msgParam: JSON.stringify(msgParam) };
|
|
555
|
+
const res = await fetch(url, {
|
|
556
|
+
method: 'POST',
|
|
557
|
+
headers: {
|
|
558
|
+
'Content-Type': 'application/json',
|
|
559
|
+
'x-acs-dingtalk-access-token': accessToken,
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify(payload),
|
|
562
|
+
});
|
|
563
|
+
if (!res.ok) {
|
|
564
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
565
|
+
throw new Error(`DingTalk ${errorLabel} error ${res.status}: ${body}`);
|
|
566
|
+
}
|
|
567
|
+
return res.json();
|
|
568
|
+
}
|
|
569
|
+
async sendDingTalkMessage(chatId, msgType, msgContent, chatTypeOverride) {
|
|
570
|
+
const msgKey = msgType === 'text' ? 'sampleText' : 'sampleMarkdown';
|
|
571
|
+
return this.postRobotMessage(chatId, msgKey, msgContent, msgType, 'send', chatTypeOverride);
|
|
572
|
+
}
|
|
573
|
+
async uploadToDingTalk(filePath, type) {
|
|
574
|
+
if (this.uploadMediaFn) {
|
|
575
|
+
return this.uploadMediaFn({ filePath, type });
|
|
576
|
+
}
|
|
577
|
+
const accessToken = await this.getAccessToken();
|
|
578
|
+
const uploadUrl = 'https://api.dingtalk.com/v1.0/robot/messageFiles/upload';
|
|
579
|
+
const fileName = basename(filePath);
|
|
580
|
+
const formData = new FormData();
|
|
581
|
+
formData.append('robotCode', this.robotCode);
|
|
582
|
+
formData.append('mediaType', type === 'image' ? 'image' : 'file');
|
|
583
|
+
formData.append('file', await openAsBlob(filePath), fileName);
|
|
584
|
+
const res = await fetch(uploadUrl, {
|
|
585
|
+
method: 'POST',
|
|
586
|
+
headers: { 'x-acs-dingtalk-access-token': accessToken },
|
|
587
|
+
body: formData,
|
|
588
|
+
});
|
|
589
|
+
if (!res.ok) {
|
|
590
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
591
|
+
this.log.warn({ status: res.status, body }, '[DingTalkAdapter] uploadToDingTalk failed');
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
const data = (await res.json());
|
|
595
|
+
return data.mediaId ?? null;
|
|
596
|
+
}
|
|
597
|
+
async sendDingTalkMediaMessage(chatId, type, mediaId, meta, chatTypeOverride) {
|
|
598
|
+
const msgKeyMap = {
|
|
599
|
+
audio: {
|
|
600
|
+
msgKey: 'sampleAudio',
|
|
601
|
+
buildParam: () => ({ mediaId, duration: String(meta.duration ?? 0) }),
|
|
602
|
+
},
|
|
603
|
+
file: {
|
|
604
|
+
msgKey: 'sampleFile',
|
|
605
|
+
buildParam: () => ({
|
|
606
|
+
mediaId,
|
|
607
|
+
fileName: meta.fileName ?? 'file',
|
|
608
|
+
fileType: (meta.fileName ?? 'file').split('.').pop() ?? '',
|
|
609
|
+
}),
|
|
610
|
+
},
|
|
611
|
+
image: {
|
|
612
|
+
msgKey: 'sampleImageMsg',
|
|
613
|
+
buildParam: () => ({ photoURL: mediaId }),
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
const entry = msgKeyMap[type];
|
|
617
|
+
if (!entry)
|
|
618
|
+
throw new Error(`Unsupported media type for DingTalk: ${type}`);
|
|
619
|
+
return this.postRobotMessage(chatId, entry.msgKey, entry.buildParam(), entry.msgKey, 'media send', chatTypeOverride);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Create an AI Card instance and deliver it.
|
|
623
|
+
* POST /v1.0/card/instances/createAndDeliver
|
|
624
|
+
*/
|
|
625
|
+
async createAICardInstance(chatId, outTrackId, headerText, chatTypeOverride) {
|
|
626
|
+
const isGroup = chatTypeOverride === 'group' || this.groupConversationIds.has(chatId);
|
|
627
|
+
if (!isGroup) {
|
|
628
|
+
const conversationId = this.staffToConversation.get(chatId);
|
|
629
|
+
if (!conversationId) {
|
|
630
|
+
throw new Error(`No conversationId mapped for staffId=${chatId}; AI Card requires a prior inbound message`);
|
|
631
|
+
}
|
|
632
|
+
if (this.createCardFn) {
|
|
633
|
+
await this.createCardFn({ outTrackId, cardData: { headerText, conversationId, chatType: 'p2p' } });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const accessToken = await this.getAccessToken();
|
|
637
|
+
const url = 'https://api.dingtalk.com/v1.0/card/instances/createAndDeliver';
|
|
638
|
+
const cardData = {
|
|
639
|
+
outTrackId,
|
|
640
|
+
cardTemplateId: AI_CARD_TEMPLATE_ID,
|
|
641
|
+
cardData: {
|
|
642
|
+
cardParamMap: {
|
|
643
|
+
title: headerText,
|
|
644
|
+
content: '...',
|
|
645
|
+
status: 'PROCESSING',
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
imRobotOpenDeliverModel: {
|
|
649
|
+
spaceType: 'IM_ROBOT',
|
|
650
|
+
robotCode: this.robotCode,
|
|
651
|
+
extension: JSON.stringify({
|
|
652
|
+
conversationType: '1',
|
|
653
|
+
conversationId,
|
|
654
|
+
}),
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
const res = await fetch(url, {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
headers: {
|
|
660
|
+
'Content-Type': 'application/json',
|
|
661
|
+
'x-acs-dingtalk-access-token': accessToken,
|
|
662
|
+
},
|
|
663
|
+
body: JSON.stringify(cardData),
|
|
664
|
+
});
|
|
665
|
+
if (!res.ok) {
|
|
666
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
667
|
+
throw new Error(`DingTalk AI Card create error ${res.status}: ${body}`);
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (this.createCardFn) {
|
|
672
|
+
await this.createCardFn({
|
|
673
|
+
outTrackId,
|
|
674
|
+
cardData: { headerText, chatType: 'group', openConversationId: chatId },
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const accessToken = await this.getAccessToken();
|
|
679
|
+
const url = 'https://api.dingtalk.com/v1.0/card/instances/createAndDeliver';
|
|
680
|
+
const cardData = {
|
|
681
|
+
outTrackId,
|
|
682
|
+
cardTemplateId: AI_CARD_TEMPLATE_ID,
|
|
683
|
+
cardData: {
|
|
684
|
+
cardParamMap: {
|
|
685
|
+
title: headerText,
|
|
686
|
+
content: '...',
|
|
687
|
+
status: 'PROCESSING',
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
imGroupOpenSpaceModel: {
|
|
691
|
+
supportForward: true,
|
|
692
|
+
},
|
|
693
|
+
imGroupOpenDeliverModel: {
|
|
694
|
+
robotCode: this.robotCode,
|
|
695
|
+
openConversationId: chatId,
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
const res = await fetch(url, {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: {
|
|
701
|
+
'Content-Type': 'application/json',
|
|
702
|
+
'x-acs-dingtalk-access-token': accessToken,
|
|
703
|
+
},
|
|
704
|
+
body: JSON.stringify(cardData),
|
|
705
|
+
});
|
|
706
|
+
if (!res.ok) {
|
|
707
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
708
|
+
throw new Error(`DingTalk AI Card group create error ${res.status}: ${body}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Update an AI Card streaming content.
|
|
713
|
+
* PUT /v1.0/card/streaming
|
|
714
|
+
* State machine: PROCESSING → INPUTING → FINISHED
|
|
715
|
+
*/
|
|
716
|
+
async updateAICardStreaming(outTrackId, content, state) {
|
|
717
|
+
if (this.streamingCardFn) {
|
|
718
|
+
await this.streamingCardFn({ outTrackId, content, state });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const accessToken = await this.getAccessToken();
|
|
722
|
+
const url = 'https://api.dingtalk.com/v1.0/card/streaming';
|
|
723
|
+
const payload = {
|
|
724
|
+
outTrackId,
|
|
725
|
+
key: 'content',
|
|
726
|
+
content,
|
|
727
|
+
isFull: true,
|
|
728
|
+
isFinalize: state === 'FINISHED',
|
|
729
|
+
guid: `${outTrackId}-${Date.now()}`,
|
|
730
|
+
};
|
|
731
|
+
const res = await fetch(url, {
|
|
732
|
+
method: 'PUT',
|
|
733
|
+
headers: {
|
|
734
|
+
'Content-Type': 'application/json',
|
|
735
|
+
'x-acs-dingtalk-access-token': accessToken,
|
|
736
|
+
},
|
|
737
|
+
body: JSON.stringify(payload),
|
|
738
|
+
});
|
|
739
|
+
if (!res.ok) {
|
|
740
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
741
|
+
throw new Error(`DingTalk AI Card streaming error ${res.status}: ${body}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Send an AI Card with title and markdown body (non-streaming, single shot).
|
|
746
|
+
*/
|
|
747
|
+
async sendAICard(staffId, title, body, chatTypeOverride) {
|
|
748
|
+
const outTrackId = `cc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
749
|
+
await this.createAICardInstance(staffId, outTrackId, title, chatTypeOverride);
|
|
750
|
+
await this.updateAICardStreaming(outTrackId, body, 'FINISHED');
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Get DingTalk access token (cached, 2h TTL).
|
|
754
|
+
*/
|
|
755
|
+
cachedToken = null;
|
|
756
|
+
async getAccessToken(signal) {
|
|
757
|
+
if (this.accessTokenFn)
|
|
758
|
+
return this.accessTokenFn();
|
|
759
|
+
const now = Date.now();
|
|
760
|
+
if (this.cachedToken && this.cachedToken.expiresAt > now + 60_000) {
|
|
761
|
+
return this.cachedToken.token;
|
|
762
|
+
}
|
|
763
|
+
const url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
|
|
764
|
+
const res = await this.inboundFetchFn(url, {
|
|
765
|
+
method: 'POST',
|
|
766
|
+
headers: { 'Content-Type': 'application/json' },
|
|
767
|
+
body: JSON.stringify({ appKey: this.appKey, appSecret: this.appSecret }),
|
|
768
|
+
signal,
|
|
769
|
+
});
|
|
770
|
+
if (!res.ok) {
|
|
771
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
772
|
+
throw new Error(`DingTalk accessToken error ${res.status}: ${body}`);
|
|
773
|
+
}
|
|
774
|
+
const data = (await res.json());
|
|
775
|
+
const token = data.accessToken;
|
|
776
|
+
if (!token)
|
|
777
|
+
throw new Error('DingTalk accessToken response missing token');
|
|
778
|
+
// expireIn is in seconds, cache with 5-minute safety margin
|
|
779
|
+
const expiresAt = now + (data.expireIn ?? 7200) * 1000;
|
|
780
|
+
this.cachedToken = { token, expiresAt };
|
|
781
|
+
return token;
|
|
782
|
+
}
|
|
783
|
+
// ── Test Helpers ──
|
|
784
|
+
/** @internal */
|
|
785
|
+
_injectSendMessage(fn) {
|
|
786
|
+
this.sendMessageFn = fn;
|
|
787
|
+
}
|
|
788
|
+
/** @internal */
|
|
789
|
+
_injectCreateCard(fn) {
|
|
790
|
+
this.createCardFn = fn;
|
|
791
|
+
}
|
|
792
|
+
/** @internal */
|
|
793
|
+
_injectStreamingCard(fn) {
|
|
794
|
+
this.streamingCardFn = fn;
|
|
795
|
+
}
|
|
796
|
+
/** @internal */
|
|
797
|
+
_injectAccessToken(fn) {
|
|
798
|
+
this.accessTokenFn = fn;
|
|
799
|
+
}
|
|
800
|
+
/** @internal */
|
|
801
|
+
_injectDownloadMedia(fn) {
|
|
802
|
+
this.downloadMediaFn = fn;
|
|
803
|
+
}
|
|
804
|
+
/** @internal */
|
|
805
|
+
_injectInboundFetch(fn) {
|
|
806
|
+
this.inboundFetchFn = fn;
|
|
807
|
+
}
|
|
808
|
+
/** @internal */
|
|
809
|
+
_injectInboundMediaTimeout(timeoutMs) {
|
|
810
|
+
this.inboundMediaTimeoutMs = timeoutMs;
|
|
811
|
+
}
|
|
812
|
+
/** @internal */
|
|
813
|
+
_injectUploadMedia(fn) {
|
|
814
|
+
this.uploadMediaFn = fn;
|
|
815
|
+
}
|
|
816
|
+
/** @internal Test seam: replace the dynamic `dingtalk-stream` import. */
|
|
817
|
+
_injectStreamModule(module_) {
|
|
818
|
+
this.streamModuleOverride = module_;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
//# sourceMappingURL=DingTalkAdapter.js.map
|