@qwen-code/channel-dingtalk 0.21.0-preview.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/dist/DingtalkAdapter.d.ts +102 -0
- package/dist/DingtalkAdapter.js +1001 -0
- package/dist/DingtalkAdapter.js.map +1 -0
- package/dist/DingtalkConnectionManager.d.ts +54 -0
- package/dist/DingtalkConnectionManager.js +317 -0
- package/dist/DingtalkConnectionManager.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +12 -0
- package/dist/markdown.js +93 -0
- package/dist/markdown.js.map +1 -0
- package/dist/media.d.ts +20 -0
- package/dist/media.js +93 -0
- package/dist/media.js.map +1 -0
- package/package.json +29 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs';
|
|
7
|
+
import { ChannelBase, isTerminalTaskLifecycleType, sanitizeLogText, sanitizeSenderName, } from '@qwen-code/channel-base';
|
|
8
|
+
import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js';
|
|
9
|
+
import { downloadMedia } from './media.js';
|
|
10
|
+
import { DingtalkConnectionManager, } from './DingtalkConnectionManager.js';
|
|
11
|
+
/** Track seen msgIds to deduplicate retried callbacks. */
|
|
12
|
+
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
|
+
const ACK_REACTION_NAME = '👀';
|
|
14
|
+
const ACK_EMOTION_ID = '2659900';
|
|
15
|
+
const ACK_EMOTION_BG_ID = 'im_bg_1';
|
|
16
|
+
const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion';
|
|
17
|
+
const EMOTION_MAX_ATTEMPTS = 3;
|
|
18
|
+
const EMOTION_RETRY_BASE_DELAY_MS = 250;
|
|
19
|
+
const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
|
|
20
|
+
const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
21
|
+
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
|
|
22
|
+
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
|
|
23
|
+
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
|
|
24
|
+
const TEXT_MESSAGE_LIMIT = 3800;
|
|
25
|
+
const mentionTarget = Symbol('mentionTarget');
|
|
26
|
+
function withNonBotMentionContext(data, text) {
|
|
27
|
+
if (!Array.isArray(data.atUsers) || typeof data.chatbotUserId !== 'string') {
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
const mentions = new Set();
|
|
31
|
+
for (const user of data.atUsers) {
|
|
32
|
+
if (!user)
|
|
33
|
+
continue;
|
|
34
|
+
const dingtalkId = typeof user.dingtalkId === 'string' ? user.dingtalkId : undefined;
|
|
35
|
+
// DingTalk Stream always sets dingtalkId for the bot entry; staffId-only bot entries are not expected.
|
|
36
|
+
if (dingtalkId === data.chatbotUserId)
|
|
37
|
+
continue;
|
|
38
|
+
const staffId = typeof user.staffId === 'string' ? user.staffId : undefined;
|
|
39
|
+
const stableId = dingtalkId || staffId;
|
|
40
|
+
if (stableId)
|
|
41
|
+
mentions.add(stableId);
|
|
42
|
+
}
|
|
43
|
+
if (mentions.size === 0)
|
|
44
|
+
return text;
|
|
45
|
+
const memberLabel = mentions.size === 1 ? 'member' : 'members';
|
|
46
|
+
const context = `[Mentioned ${mentions.size} other group ${memberLabel}]`;
|
|
47
|
+
return text ? `${context}\n${text}` : context;
|
|
48
|
+
}
|
|
49
|
+
function splitTextChunks(text, firstChunkLimit) {
|
|
50
|
+
if (!text)
|
|
51
|
+
return [text];
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let offset = 0;
|
|
54
|
+
let chunkLimit = firstChunkLimit;
|
|
55
|
+
while (offset < text.length) {
|
|
56
|
+
chunks.push(text.slice(offset, offset + chunkLimit));
|
|
57
|
+
offset += chunkLimit;
|
|
58
|
+
chunkLimit = TEXT_MESSAGE_LIMIT;
|
|
59
|
+
}
|
|
60
|
+
return chunks;
|
|
61
|
+
}
|
|
62
|
+
export class DingtalkChannel extends ChannelBase {
|
|
63
|
+
client;
|
|
64
|
+
atSender;
|
|
65
|
+
connectionManager;
|
|
66
|
+
seenMessages = new Map();
|
|
67
|
+
mentionTargets = new Map();
|
|
68
|
+
sessionMentionTargets = new Map();
|
|
69
|
+
textReplySessions = new Set();
|
|
70
|
+
bufferedMentionTargets = new Set();
|
|
71
|
+
bufferedMentionTargetsBySession = new Map();
|
|
72
|
+
dedupTimer;
|
|
73
|
+
/** Map conversationId → latest sessionWebhook URL for sending replies. */
|
|
74
|
+
webhooks = new Map();
|
|
75
|
+
activeReactionKeys = new Set();
|
|
76
|
+
/** sessionId → reaction keys, so a dead session's reactions can be recalled. */
|
|
77
|
+
sessionReactionKeys = new Map();
|
|
78
|
+
/**
|
|
79
|
+
* Real inbound message ids (insertion-ordered, size-capped). Unlike the
|
|
80
|
+
* TTL-swept seenMessages dedup map, entries survive long queue waits, so a
|
|
81
|
+
* turn that starts minutes after its message arrived still gets a reaction.
|
|
82
|
+
*/
|
|
83
|
+
inboundMessageIds = new Set();
|
|
84
|
+
/**
|
|
85
|
+
* Token cache for proactive sends. The stream SDK only refreshes its token
|
|
86
|
+
* on (re)connect, so a long-lived socket serves a stale one after ~2h.
|
|
87
|
+
*/
|
|
88
|
+
proactiveToken;
|
|
89
|
+
constructor(name, config, bridge, options) {
|
|
90
|
+
super(name, config, bridge, options);
|
|
91
|
+
this.atSender =
|
|
92
|
+
config['atSender'] === true;
|
|
93
|
+
if (!config.clientId || !config.clientSecret) {
|
|
94
|
+
throw new Error(`Channel "${name}" requires clientId and clientSecret for DingTalk.`);
|
|
95
|
+
}
|
|
96
|
+
const rawUseConnectionManager = config
|
|
97
|
+
.useConnectionManager;
|
|
98
|
+
if (rawUseConnectionManager !== undefined &&
|
|
99
|
+
typeof rawUseConnectionManager !== 'boolean') {
|
|
100
|
+
throw new Error(`Channel "${name}" useConnectionManager must be a boolean.`);
|
|
101
|
+
}
|
|
102
|
+
const useConnectionManager = rawUseConnectionManager ?? true;
|
|
103
|
+
this.client = this.createClient(useConnectionManager);
|
|
104
|
+
if (useConnectionManager) {
|
|
105
|
+
this.connectionManager = new DingtalkConnectionManager({
|
|
106
|
+
initialClient: this.client,
|
|
107
|
+
createClient: () => this.createClient(true),
|
|
108
|
+
getSocket: (client) => client.socket,
|
|
109
|
+
onClientChanged: (client) => {
|
|
110
|
+
this.client = client;
|
|
111
|
+
},
|
|
112
|
+
log: (message) => {
|
|
113
|
+
process.stderr.write(`[DingTalk:${this.name}] ${sanitizeLogText(message, 200)}\n`);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
createClient(useConnectionManager) {
|
|
119
|
+
const client = new DWClient({
|
|
120
|
+
clientId: this.config.clientId,
|
|
121
|
+
clientSecret: this.config.clientSecret,
|
|
122
|
+
keepAlive: !useConnectionManager,
|
|
123
|
+
});
|
|
124
|
+
client.config.autoReconnect = !useConnectionManager;
|
|
125
|
+
this.installStructuredDownstreamHandler(client);
|
|
126
|
+
this.registerMessageHandler(client);
|
|
127
|
+
return client;
|
|
128
|
+
}
|
|
129
|
+
installStructuredDownstreamHandler(streamClient) {
|
|
130
|
+
const client = streamClient;
|
|
131
|
+
client.debug = false;
|
|
132
|
+
// Keep raw SDK downstream frames off stdout; this switch mirrors the SDK
|
|
133
|
+
// dispatch table and should be checked when upgrading the DingTalk SDK.
|
|
134
|
+
client.onDownStream = (raw) => {
|
|
135
|
+
this.onDownStream(raw, client);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
registerMessageHandler(client) {
|
|
139
|
+
client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
|
|
140
|
+
client.send(msg.headers.messageId, {
|
|
141
|
+
status: EventAck.SUCCESS,
|
|
142
|
+
message: 'ok',
|
|
143
|
+
});
|
|
144
|
+
this.onMessage(msg);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
onDownStream(raw, client) {
|
|
148
|
+
this.connectionManager?.noteActivity(client);
|
|
149
|
+
const decoded = this.decodeDownStream(raw);
|
|
150
|
+
let msg;
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(decoded.text);
|
|
153
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
154
|
+
process.stderr.write(`[DingTalk:${this.name}] downstream parsed to non-object, ignoring.\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
msg = parsed;
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
process.stderr.write(`[DingTalk:${this.name}] Failed to parse downstream: ${sanitizeLogText(String(err), 200)}\n`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const headers = msg.headers && typeof msg.headers === 'object' ? msg.headers : {};
|
|
164
|
+
const type = typeof msg.type === 'string' ? msg.type : '';
|
|
165
|
+
const topic = typeof headers['topic'] === 'string' ? headers['topic'] : '';
|
|
166
|
+
const messageId = typeof headers['messageId'] === 'string' ? headers['messageId'] : '';
|
|
167
|
+
process.stderr.write(`[DingTalk:${this.name}] downstream type=${sanitizeLogText(type, 40)} topic=${sanitizeLogText(topic, 80)} messageId=${sanitizeLogText(messageId, 80)} bytes=${decoded.bytes}\n`);
|
|
168
|
+
if ((type === 'CALLBACK' || type === 'EVENT') && (!topic || !messageId)) {
|
|
169
|
+
process.stderr.write(`[DingTalk:${this.name}] Ignoring downstream with invalid routing headers.\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const normalizedMsg = {
|
|
173
|
+
...msg,
|
|
174
|
+
headers: { ...headers, topic, messageId },
|
|
175
|
+
};
|
|
176
|
+
switch (type) {
|
|
177
|
+
case 'SYSTEM':
|
|
178
|
+
this.callDownStreamHandler(client, 'onSystem', normalizedMsg);
|
|
179
|
+
if (topic === 'disconnect') {
|
|
180
|
+
this.connectionManager?.requestReconnect(client, 'SYSTEM disconnect');
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
case 'EVENT':
|
|
184
|
+
this.callDownStreamHandler(client, 'onEvent', normalizedMsg);
|
|
185
|
+
break;
|
|
186
|
+
case 'CALLBACK':
|
|
187
|
+
this.callDownStreamHandler(client, 'onCallback', normalizedMsg);
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
process.stderr.write(`[DingTalk:${this.name}] Ignoring downstream type ${sanitizeLogText(type || 'unknown', 40)}.\n`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
callDownStreamHandler(client, method, msg) {
|
|
194
|
+
try {
|
|
195
|
+
client[method](msg);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
process.stderr.write(`[DingTalk:${this.name}] ${method} failed: ${sanitizeLogText(String(err), 200)}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
decodeDownStream(raw) {
|
|
202
|
+
if (typeof raw === 'string') {
|
|
203
|
+
return { text: raw, bytes: Buffer.byteLength(raw) };
|
|
204
|
+
}
|
|
205
|
+
if (Buffer.isBuffer(raw)) {
|
|
206
|
+
return { text: raw.toString('utf8'), bytes: raw.length };
|
|
207
|
+
}
|
|
208
|
+
if (raw instanceof Uint8Array) {
|
|
209
|
+
return { text: Buffer.from(raw).toString('utf8'), bytes: raw.byteLength };
|
|
210
|
+
}
|
|
211
|
+
if (raw instanceof ArrayBuffer) {
|
|
212
|
+
return {
|
|
213
|
+
text: Buffer.from(raw).toString('utf8'),
|
|
214
|
+
bytes: raw.byteLength,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { text: String(raw), bytes: Buffer.byteLength(String(raw)) };
|
|
218
|
+
}
|
|
219
|
+
async connect() {
|
|
220
|
+
if (this.connectionManager) {
|
|
221
|
+
await this.connectionManager.start();
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
await this.client.connect();
|
|
225
|
+
}
|
|
226
|
+
// Periodically clean up dedup map
|
|
227
|
+
this.dedupTimer = setInterval(() => {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
for (const [id, ts] of this.seenMessages) {
|
|
230
|
+
if (now - ts > DEDUP_TTL_MS) {
|
|
231
|
+
this.seenMessages.delete(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}, 60_000);
|
|
235
|
+
process.stderr.write(`[DingTalk:${this.name}] Connected via stream.\n`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A group message with no conversationId can't be routed to a stable shared
|
|
239
|
+
* session (chatId would fall back to the expiring sessionWebhook), so it is
|
|
240
|
+
* dropped on ingestion. Exposed for testing the drop rule.
|
|
241
|
+
*/
|
|
242
|
+
static isUnroutableGroupMessage(isGroup, conversationId) {
|
|
243
|
+
return isGroup && !conversationId;
|
|
244
|
+
}
|
|
245
|
+
async sendReply(chatId, text) {
|
|
246
|
+
// chatId is a conversationId — resolve to the latest sessionWebhook
|
|
247
|
+
const webhook = this.webhooks.get(chatId);
|
|
248
|
+
if (!webhook) {
|
|
249
|
+
process.stderr.write(`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const chunks = normalizeDingTalkMarkdown(text);
|
|
253
|
+
const title = extractTitle(text);
|
|
254
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
255
|
+
const chunk = chunks[i];
|
|
256
|
+
const body = {
|
|
257
|
+
msgtype: 'markdown',
|
|
258
|
+
markdown: {
|
|
259
|
+
title: i === 0 ? title : `${title} (cont.)`,
|
|
260
|
+
text: chunk,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const resp = await fetch(webhook, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify(body),
|
|
267
|
+
});
|
|
268
|
+
if (!resp.ok) {
|
|
269
|
+
const detail = await resp.text().catch(() => '');
|
|
270
|
+
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async sendTextReply(chatId, text, atUserId) {
|
|
275
|
+
const webhook = this.webhooks.get(chatId);
|
|
276
|
+
if (!webhook)
|
|
277
|
+
return;
|
|
278
|
+
const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
|
|
279
|
+
const chunks = splitTextChunks(text, TEXT_MESSAGE_LIMIT - mentionPrefix.length);
|
|
280
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
281
|
+
const isMention = i === 0 && atUserId !== undefined;
|
|
282
|
+
const resp = await fetch(webhook, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
msgtype: 'text',
|
|
287
|
+
text: {
|
|
288
|
+
content: isMention ? `${mentionPrefix}${chunks[i]}` : chunks[i],
|
|
289
|
+
},
|
|
290
|
+
...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
if (isMention && process.env['QWEN_CHANNEL_DEBUG_MENTIONS'] === '1') {
|
|
294
|
+
const payload = (await resp
|
|
295
|
+
.clone()
|
|
296
|
+
.json()
|
|
297
|
+
.catch(() => undefined));
|
|
298
|
+
const response = payload && typeof payload === 'object'
|
|
299
|
+
? payload
|
|
300
|
+
: {};
|
|
301
|
+
const value = response['errcode'] ?? response['code'];
|
|
302
|
+
const code = typeof value === 'number' || typeof value === 'string'
|
|
303
|
+
? String(value)
|
|
304
|
+
: 'unknown';
|
|
305
|
+
process.stderr.write(`[DingTalk:${this.name}] mention delivery status=${resp.status} code=${code}\n`);
|
|
306
|
+
}
|
|
307
|
+
if (!resp.ok) {
|
|
308
|
+
const detail = await resp.text().catch(() => '');
|
|
309
|
+
process.stderr.write(`[DingTalk:${this.name}] sendTextReply failed: HTTP ${resp.status} ${detail}\n`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async sendMessage(chatId, text) {
|
|
314
|
+
await this.sendReply(chatId, text);
|
|
315
|
+
}
|
|
316
|
+
supportsProactiveSend() {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
// Regular proactive paths accept only group targets; webhook tasks may use
|
|
320
|
+
// DMs through the one-to-one API.
|
|
321
|
+
supportsProactiveTarget(target) {
|
|
322
|
+
return (target.isGroup === true &&
|
|
323
|
+
target.threadId === undefined &&
|
|
324
|
+
this.isStableTargetId(target.chatId));
|
|
325
|
+
}
|
|
326
|
+
supportsProactiveWebhookTarget(target) {
|
|
327
|
+
return (typeof target.isGroup === 'boolean' &&
|
|
328
|
+
target.threadId === undefined &&
|
|
329
|
+
this.isStableTargetId(target.chatId));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Single-shot cold send: a failed chunk aborts the remainder (already-sent
|
|
333
|
+
* chunks are not recalled) and the error surfaces in the loop's lastError.
|
|
334
|
+
*/
|
|
335
|
+
async pushProactive(target, text) {
|
|
336
|
+
if (!text.trim())
|
|
337
|
+
return;
|
|
338
|
+
const chunks = normalizeDingTalkMarkdown(text);
|
|
339
|
+
const title = extractTitle(text);
|
|
340
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
341
|
+
await this.sendProactiveChunk(target, i === 0 ? title : `${title} (cont.)`, chunks[i], `chunk ${i + 1}/${chunks.length}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async getProactiveToken() {
|
|
345
|
+
const cached = this.proactiveToken;
|
|
346
|
+
if (cached && Date.now() < cached.expiresAt)
|
|
347
|
+
return cached.token;
|
|
348
|
+
const url = `${TOKEN_API}?appkey=${encodeURIComponent(this.config.clientId)}&appsecret=${encodeURIComponent(this.config.clientSecret)}`;
|
|
349
|
+
let data;
|
|
350
|
+
try {
|
|
351
|
+
const resp = await fetch(url, {
|
|
352
|
+
signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
|
|
353
|
+
});
|
|
354
|
+
data = (await resp.json());
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
process.stderr.write(`[DingTalk:${this.name}] access token fetch failed.\n`);
|
|
358
|
+
throw new Error('DingTalk access token fetch failed');
|
|
359
|
+
}
|
|
360
|
+
if (!data.access_token) {
|
|
361
|
+
const errmsg = sanitizeLogText(String(data.errmsg ?? ''), 200);
|
|
362
|
+
process.stderr.write(`[DingTalk:${this.name}] access token request failed: gettoken errcode=${data.errcode} ${errmsg}\n`);
|
|
363
|
+
throw new Error(`DingTalk access token request failed: gettoken errcode=${data.errcode}${errmsg ? ` ${errmsg}` : ''}`);
|
|
364
|
+
}
|
|
365
|
+
this.proactiveToken = {
|
|
366
|
+
token: data.access_token,
|
|
367
|
+
// Refresh a minute early so a fire mid-expiry doesn't race the TTL.
|
|
368
|
+
expiresAt: Date.now() + Math.max(60, (data.expires_in ?? 7200) - 60) * 1000,
|
|
369
|
+
};
|
|
370
|
+
return data.access_token;
|
|
371
|
+
}
|
|
372
|
+
async sendProactiveChunk(target, title, text, chunkLabel) {
|
|
373
|
+
const targetKind = target.isGroup === true ? 'group' : 'dm';
|
|
374
|
+
for (let attempt = 0;; attempt++) {
|
|
375
|
+
const token = await this.getProactiveToken();
|
|
376
|
+
let resp;
|
|
377
|
+
try {
|
|
378
|
+
const targetBody = target.isGroup === true
|
|
379
|
+
? { openConversationId: target.chatId }
|
|
380
|
+
: { userIds: [target.chatId] };
|
|
381
|
+
resp = await fetch(target.isGroup === true ? GROUP_MSG_API : DIRECT_MSG_API, {
|
|
382
|
+
method: 'POST',
|
|
383
|
+
headers: {
|
|
384
|
+
'x-acs-dingtalk-access-token': token,
|
|
385
|
+
'Content-Type': 'application/json',
|
|
386
|
+
},
|
|
387
|
+
body: JSON.stringify({
|
|
388
|
+
robotCode: this.config.clientId,
|
|
389
|
+
...targetBody,
|
|
390
|
+
msgKey: PROACTIVE_MSG_KEY,
|
|
391
|
+
msgParam: JSON.stringify({ title, text }),
|
|
392
|
+
}),
|
|
393
|
+
signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
const cause = err.cause;
|
|
398
|
+
process.stderr.write(`[DingTalk:${this.name}] proactive send error (${targetKind}, ${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\n`);
|
|
399
|
+
throw new Error(`DingTalk proactive send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
400
|
+
}
|
|
401
|
+
if (resp.status === 401 && attempt === 0) {
|
|
402
|
+
// Stale or revoked token — refresh once and retry this chunk.
|
|
403
|
+
this.proactiveToken = undefined;
|
|
404
|
+
await resp.body?.cancel();
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (!resp.ok) {
|
|
408
|
+
const detail = sanitizeLogText(await resp.text().catch(() => ''), 300);
|
|
409
|
+
process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): HTTP ${resp.status} ${detail}\n`);
|
|
410
|
+
throw new Error(`DingTalk proactive send failed: HTTP ${resp.status}${detail ? ` ${detail}` : ''}`);
|
|
411
|
+
}
|
|
412
|
+
if (target.isGroup === false) {
|
|
413
|
+
let data;
|
|
414
|
+
try {
|
|
415
|
+
data = (await resp.json());
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid JSON response\n`);
|
|
419
|
+
throw new Error('DingTalk proactive send failed: invalid JSON response');
|
|
420
|
+
}
|
|
421
|
+
if (data.invalidStaffIdList?.includes(target.chatId)) {
|
|
422
|
+
process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid direct recipient\n`);
|
|
423
|
+
throw new Error('DingTalk proactive send failed: invalid direct recipient');
|
|
424
|
+
}
|
|
425
|
+
if (data.flowControlledStaffIdList?.includes(target.chatId)) {
|
|
426
|
+
process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): direct recipient rate limited\n`);
|
|
427
|
+
throw new Error('DingTalk proactive send failed: direct recipient rate limited');
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
await resp.body?.cancel();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
getAccessToken() {
|
|
436
|
+
return this.client.getConfig().access_token;
|
|
437
|
+
}
|
|
438
|
+
async emotionApi(endpoint, msgId, conversationId) {
|
|
439
|
+
const robotCode = this.config.clientId;
|
|
440
|
+
if (!robotCode || !msgId || !conversationId)
|
|
441
|
+
return;
|
|
442
|
+
try {
|
|
443
|
+
const token = this.config.clientSecret
|
|
444
|
+
? await this.getProactiveToken()
|
|
445
|
+
: this.getAccessToken();
|
|
446
|
+
if (!token)
|
|
447
|
+
return;
|
|
448
|
+
for (let attempt = 0; attempt < EMOTION_MAX_ATTEMPTS; attempt++) {
|
|
449
|
+
const resp = await fetch(`${EMOTION_API}/${endpoint}`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
headers: {
|
|
452
|
+
'x-acs-dingtalk-access-token': token,
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
},
|
|
455
|
+
body: JSON.stringify({
|
|
456
|
+
robotCode,
|
|
457
|
+
openMsgId: msgId,
|
|
458
|
+
openConversationId: conversationId,
|
|
459
|
+
emotionType: 2,
|
|
460
|
+
emotionName: ACK_REACTION_NAME,
|
|
461
|
+
textEmotion: {
|
|
462
|
+
emotionId: ACK_EMOTION_ID,
|
|
463
|
+
emotionName: ACK_REACTION_NAME,
|
|
464
|
+
text: ACK_REACTION_NAME,
|
|
465
|
+
backgroundId: ACK_EMOTION_BG_ID,
|
|
466
|
+
},
|
|
467
|
+
}),
|
|
468
|
+
});
|
|
469
|
+
if (resp.ok)
|
|
470
|
+
return;
|
|
471
|
+
const isTransient = resp.status === 429 || resp.status >= 500;
|
|
472
|
+
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
|
|
473
|
+
await resp.body?.cancel();
|
|
474
|
+
await new Promise((resolve) => setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
|
|
478
|
+
process.stderr.write(`[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// best-effort, don't break message flow
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async attachReaction(msgId, conversationId) {
|
|
487
|
+
await this.emotionApi('reply', msgId, conversationId);
|
|
488
|
+
}
|
|
489
|
+
async recallReaction(msgId, conversationId) {
|
|
490
|
+
await this.emotionApi('recall', msgId, conversationId);
|
|
491
|
+
}
|
|
492
|
+
disconnect() {
|
|
493
|
+
if (this.dedupTimer) {
|
|
494
|
+
clearInterval(this.dedupTimer);
|
|
495
|
+
}
|
|
496
|
+
this.activeReactionKeys.clear();
|
|
497
|
+
this.sessionReactionKeys.clear();
|
|
498
|
+
if (this.connectionManager) {
|
|
499
|
+
this.connectionManager.stop();
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
this.client.disconnect();
|
|
503
|
+
}
|
|
504
|
+
process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`);
|
|
505
|
+
}
|
|
506
|
+
/** Stable API targets are conversation or user IDs, never webhook URLs. */
|
|
507
|
+
isStableTargetId(chatId) {
|
|
508
|
+
return !!chatId && !/^https?:\/\//i.test(chatId);
|
|
509
|
+
}
|
|
510
|
+
reactionKey(messageId, conversationId) {
|
|
511
|
+
return `${conversationId}:${messageId}`;
|
|
512
|
+
}
|
|
513
|
+
rememberInboundMessageId(msgId) {
|
|
514
|
+
this.inboundMessageIds.delete(msgId);
|
|
515
|
+
this.inboundMessageIds.add(msgId);
|
|
516
|
+
if (this.inboundMessageIds.size > 1000) {
|
|
517
|
+
const oldest = this.inboundMessageIds.values().next().value;
|
|
518
|
+
if (oldest !== undefined)
|
|
519
|
+
this.inboundMessageIds.delete(oldest);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
logReactionFailure(action, err) {
|
|
523
|
+
process.stderr.write(`[DingTalk:${this.name}] ${action} failed: ${err instanceof Error ? err.message : err}\n`);
|
|
524
|
+
}
|
|
525
|
+
startReaction(chatId, messageId, sessionId) {
|
|
526
|
+
if (!messageId || !this.isStableTargetId(chatId))
|
|
527
|
+
return;
|
|
528
|
+
// Loop lifecycle events carry the internal job id as messageId; the
|
|
529
|
+
// emotion API only accepts ids of real inbound messages, so skip anything
|
|
530
|
+
// we never saw arrive.
|
|
531
|
+
if (!this.inboundMessageIds.has(messageId))
|
|
532
|
+
return;
|
|
533
|
+
const key = this.reactionKey(messageId, chatId);
|
|
534
|
+
if (this.activeReactionKeys.has(key))
|
|
535
|
+
return;
|
|
536
|
+
this.activeReactionKeys.add(key);
|
|
537
|
+
if (sessionId) {
|
|
538
|
+
let keys = this.sessionReactionKeys.get(sessionId);
|
|
539
|
+
if (!keys) {
|
|
540
|
+
keys = new Map();
|
|
541
|
+
this.sessionReactionKeys.set(sessionId, keys);
|
|
542
|
+
}
|
|
543
|
+
keys.set(key, { messageId, chatId });
|
|
544
|
+
}
|
|
545
|
+
this.attachReaction(messageId, chatId)
|
|
546
|
+
.then(() => {
|
|
547
|
+
if (!this.activeReactionKeys.has(key)) {
|
|
548
|
+
void this.recallReaction(messageId, chatId).catch((err) => {
|
|
549
|
+
this.logReactionFailure('late reaction recall', err);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
})
|
|
553
|
+
.catch((err) => {
|
|
554
|
+
this.activeReactionKeys.delete(key);
|
|
555
|
+
this.logReactionFailure('reaction attach', err);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
stopReaction(chatId, messageId, sessionId) {
|
|
559
|
+
if (!messageId || !this.isStableTargetId(chatId))
|
|
560
|
+
return;
|
|
561
|
+
const key = this.reactionKey(messageId, chatId);
|
|
562
|
+
if (sessionId) {
|
|
563
|
+
const keys = this.sessionReactionKeys.get(sessionId);
|
|
564
|
+
if (keys) {
|
|
565
|
+
keys.delete(key);
|
|
566
|
+
if (keys.size === 0)
|
|
567
|
+
this.sessionReactionKeys.delete(sessionId);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (!this.activeReactionKeys.delete(key))
|
|
571
|
+
return;
|
|
572
|
+
this.recallReaction(messageId, chatId).catch((err) => {
|
|
573
|
+
this.logReactionFailure('reaction recall', err);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/** Recall reactions left behind when a session dies without terminal lifecycle events. */
|
|
577
|
+
onSessionDied(sessionId) {
|
|
578
|
+
const bufferedTargets = this.bufferedMentionTargetsBySession.get(sessionId);
|
|
579
|
+
if (bufferedTargets) {
|
|
580
|
+
this.bufferedMentionTargetsBySession.delete(sessionId);
|
|
581
|
+
for (const messageId of bufferedTargets) {
|
|
582
|
+
this.bufferedMentionTargets.delete(messageId);
|
|
583
|
+
this.mentionTargets.delete(messageId);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
this.sessionMentionTargets.delete(sessionId);
|
|
587
|
+
this.textReplySessions.delete(sessionId);
|
|
588
|
+
const keys = this.sessionReactionKeys.get(sessionId);
|
|
589
|
+
if (keys) {
|
|
590
|
+
this.sessionReactionKeys.delete(sessionId);
|
|
591
|
+
for (const [key, { messageId, chatId }] of keys) {
|
|
592
|
+
if (this.activeReactionKeys.delete(key)) {
|
|
593
|
+
void this.recallReaction(messageId, chatId).catch((err) => {
|
|
594
|
+
this.logReactionFailure('session-death reaction recall', err);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
super.onSessionDied(sessionId);
|
|
600
|
+
}
|
|
601
|
+
onTaskLifecycle(event) {
|
|
602
|
+
if (event.type === 'started') {
|
|
603
|
+
this.startReaction(event.chatId, event.messageId, event.sessionId);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (isTerminalTaskLifecycleType(event.type)) {
|
|
607
|
+
if (event.messageId)
|
|
608
|
+
this.mentionTargets.delete(event.messageId);
|
|
609
|
+
this.stopReaction(event.chatId, event.messageId, event.sessionId);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
onPromptBufferDropped(_chatId, sessionId, messageIds) {
|
|
613
|
+
for (const messageId of messageIds) {
|
|
614
|
+
this.bufferedMentionTargets.delete(messageId);
|
|
615
|
+
this.mentionTargets.delete(messageId);
|
|
616
|
+
this.untrackBufferedMentionTarget(sessionId, messageId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
onPromptBufferDrained(_chatId, sessionId, messageIds) {
|
|
620
|
+
for (const messageId of messageIds) {
|
|
621
|
+
this.bufferedMentionTargets.delete(messageId);
|
|
622
|
+
this.untrackBufferedMentionTarget(sessionId, messageId);
|
|
623
|
+
}
|
|
624
|
+
for (const messageId of messageIds.slice(0, -1)) {
|
|
625
|
+
this.mentionTargets.delete(messageId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
onPromptBuffered(_chatId, sessionId, messageId) {
|
|
629
|
+
if (messageId && this.mentionTargets.has(messageId)) {
|
|
630
|
+
this.bufferedMentionTargets.add(messageId);
|
|
631
|
+
let targets = this.bufferedMentionTargetsBySession.get(sessionId);
|
|
632
|
+
if (!targets) {
|
|
633
|
+
targets = new Set();
|
|
634
|
+
this.bufferedMentionTargetsBySession.set(sessionId, targets);
|
|
635
|
+
}
|
|
636
|
+
targets.add(messageId);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
onPromptStart(chatId, sessionId, messageId) {
|
|
640
|
+
if (messageId) {
|
|
641
|
+
this.bufferedMentionTargets.delete(messageId);
|
|
642
|
+
this.untrackBufferedMentionTarget(sessionId, messageId);
|
|
643
|
+
const atUserId = this.mentionTargets.get(messageId);
|
|
644
|
+
this.mentionTargets.delete(messageId);
|
|
645
|
+
if (this.atSender && atUserId) {
|
|
646
|
+
this.sessionMentionTargets.set(sessionId, atUserId);
|
|
647
|
+
this.textReplySessions.add(sessionId);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
this.startReaction(chatId, messageId, sessionId);
|
|
651
|
+
}
|
|
652
|
+
async handleInbound(envelope) {
|
|
653
|
+
if (!(await this.preflightInbound(envelope)))
|
|
654
|
+
return;
|
|
655
|
+
const messageId = envelope.messageId;
|
|
656
|
+
const atUserId = envelope[mentionTarget];
|
|
657
|
+
if (this.atSender && messageId && atUserId) {
|
|
658
|
+
this.mentionTargets.set(messageId, atUserId);
|
|
659
|
+
}
|
|
660
|
+
await this.processInbound(envelope);
|
|
661
|
+
}
|
|
662
|
+
async processInbound(envelope) {
|
|
663
|
+
const messageId = envelope.messageId;
|
|
664
|
+
try {
|
|
665
|
+
await super.processInbound(envelope);
|
|
666
|
+
}
|
|
667
|
+
finally {
|
|
668
|
+
if (messageId && !this.bufferedMentionTargets.has(messageId)) {
|
|
669
|
+
this.mentionTargets.delete(messageId);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
untrackBufferedMentionTarget(sessionId, messageId) {
|
|
674
|
+
const targets = this.bufferedMentionTargetsBySession.get(sessionId);
|
|
675
|
+
if (!targets)
|
|
676
|
+
return;
|
|
677
|
+
targets.delete(messageId);
|
|
678
|
+
if (targets.size === 0)
|
|
679
|
+
this.bufferedMentionTargetsBySession.delete(sessionId);
|
|
680
|
+
}
|
|
681
|
+
onPromptEnd(chatId, sessionId, messageId) {
|
|
682
|
+
this.sessionMentionTargets.delete(sessionId);
|
|
683
|
+
this.textReplySessions.delete(sessionId);
|
|
684
|
+
this.stopReaction(chatId, messageId, sessionId);
|
|
685
|
+
}
|
|
686
|
+
async sendResponseMessage(chatId, text, sessionId) {
|
|
687
|
+
const atUserId = this.atSender
|
|
688
|
+
? this.sessionMentionTargets.get(sessionId)
|
|
689
|
+
: undefined;
|
|
690
|
+
if (atUserId)
|
|
691
|
+
this.sessionMentionTargets.delete(sessionId);
|
|
692
|
+
if (this.textReplySessions.has(sessionId)) {
|
|
693
|
+
await this.sendTextReply(chatId, text, atUserId);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
await this.sendReply(chatId, text);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Extract quoted/referenced message context from a reply.
|
|
700
|
+
* DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy).
|
|
701
|
+
*/
|
|
702
|
+
extractQuotedContext(data) {
|
|
703
|
+
// Newer format: text.repliedMsg
|
|
704
|
+
if (data.text?.isReplyMsg && data.text.repliedMsg) {
|
|
705
|
+
const replied = data.text.repliedMsg;
|
|
706
|
+
const isReplyToBot = !!data.chatbotUserId && replied.senderId === data.chatbotUserId;
|
|
707
|
+
// Note: DingTalk doesn't include content for interactiveCard replies
|
|
708
|
+
// (bot responses sent via webhook). Only user message quotes have text.
|
|
709
|
+
const text = this.summarizeRepliedContent(replied);
|
|
710
|
+
return { referencedText: text || undefined, isReplyToBot };
|
|
711
|
+
}
|
|
712
|
+
// Legacy format: quoteMessage
|
|
713
|
+
if (data.quoteMessage) {
|
|
714
|
+
const quote = data.quoteMessage;
|
|
715
|
+
const isReplyToBot = !!data.chatbotUserId && quote.senderId === data.chatbotUserId;
|
|
716
|
+
const text = quote.text?.content?.trim();
|
|
717
|
+
return { referencedText: text || undefined, isReplyToBot };
|
|
718
|
+
}
|
|
719
|
+
return { isReplyToBot: false };
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Build a text summary from a repliedMsg, handling text, richText, and
|
|
723
|
+
* media message types with placeholders.
|
|
724
|
+
*/
|
|
725
|
+
summarizeRepliedContent(replied) {
|
|
726
|
+
const msgType = replied.msgType;
|
|
727
|
+
const content = replied.content;
|
|
728
|
+
// Direct text content
|
|
729
|
+
if (content?.text?.trim()) {
|
|
730
|
+
return content.text.trim();
|
|
731
|
+
}
|
|
732
|
+
// RichText: concatenate text parts, placeholder for images
|
|
733
|
+
if (content?.richText && Array.isArray(content.richText)) {
|
|
734
|
+
const parts = [];
|
|
735
|
+
for (const part of content.richText) {
|
|
736
|
+
const partType = part.type || 'text';
|
|
737
|
+
if (partType === 'text' && part.text) {
|
|
738
|
+
parts.push(part.text);
|
|
739
|
+
}
|
|
740
|
+
else if (partType === 'picture') {
|
|
741
|
+
parts.push('[image]');
|
|
742
|
+
}
|
|
743
|
+
else if (partType === 'at' && part.atName) {
|
|
744
|
+
parts.push(`@${part.atName}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const summary = parts.join('').trim();
|
|
748
|
+
if (summary)
|
|
749
|
+
return summary;
|
|
750
|
+
}
|
|
751
|
+
// Media type placeholders
|
|
752
|
+
switch (msgType) {
|
|
753
|
+
case 'picture':
|
|
754
|
+
return '[image]';
|
|
755
|
+
case 'file':
|
|
756
|
+
return `[file: ${content?.fileName || 'file'}]`;
|
|
757
|
+
case 'audio':
|
|
758
|
+
return '[audio]';
|
|
759
|
+
case 'video':
|
|
760
|
+
return '[video]';
|
|
761
|
+
default:
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
return '';
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Extract text and media download codes from an incoming DingTalk message.
|
|
768
|
+
* Handles text, richText, picture, file, audio, and video message types.
|
|
769
|
+
*/
|
|
770
|
+
extractContent(data) {
|
|
771
|
+
const msgtype = data.msgtype || 'text';
|
|
772
|
+
if (msgtype === 'richText') {
|
|
773
|
+
const richText = data.content?.richText;
|
|
774
|
+
if (!Array.isArray(richText)) {
|
|
775
|
+
return { text: '', downloadCodes: [] };
|
|
776
|
+
}
|
|
777
|
+
let text = '';
|
|
778
|
+
const codes = [];
|
|
779
|
+
for (const part of richText) {
|
|
780
|
+
const partType = part.type || 'text';
|
|
781
|
+
if (partType === 'text' && part.text) {
|
|
782
|
+
text += part.text;
|
|
783
|
+
}
|
|
784
|
+
else if (partType === 'picture' && part.downloadCode) {
|
|
785
|
+
codes.push(part.downloadCode);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
text: text.trim() || (codes.length > 0 ? '(image)' : ''),
|
|
790
|
+
downloadCodes: codes,
|
|
791
|
+
mediaType: codes.length > 0 ? 'image' : undefined,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (msgtype === 'picture') {
|
|
795
|
+
const code = data.content?.downloadCode;
|
|
796
|
+
return {
|
|
797
|
+
text: '(image)',
|
|
798
|
+
downloadCodes: code ? [code] : [],
|
|
799
|
+
mediaType: 'image',
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (msgtype === 'file') {
|
|
803
|
+
const code = data.content?.downloadCode;
|
|
804
|
+
const fileName = data.content?.fileName || undefined;
|
|
805
|
+
return {
|
|
806
|
+
text: `(file: ${fileName || 'file'})`,
|
|
807
|
+
downloadCodes: code ? [code] : [],
|
|
808
|
+
mediaType: 'file',
|
|
809
|
+
fileName,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if (msgtype === 'audio') {
|
|
813
|
+
const code = data.content?.downloadCode;
|
|
814
|
+
const recognition = data.content?.recognition;
|
|
815
|
+
return {
|
|
816
|
+
text: recognition || '(audio)',
|
|
817
|
+
downloadCodes: code ? [code] : [],
|
|
818
|
+
mediaType: 'audio',
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (msgtype === 'video') {
|
|
822
|
+
const code = data.content?.downloadCode;
|
|
823
|
+
return {
|
|
824
|
+
text: '(video)',
|
|
825
|
+
downloadCodes: code ? [code] : [],
|
|
826
|
+
mediaType: 'video',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
// Default: text message
|
|
830
|
+
return { text: data.text?.content?.trim() || '', downloadCodes: [] };
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Download a media file and attach it to the envelope.
|
|
834
|
+
* Images → base64 in envelope; files → saved to temp dir with path in text.
|
|
835
|
+
*/
|
|
836
|
+
async attachMedia(envelope, downloadCode, mediaType, fileName) {
|
|
837
|
+
let token;
|
|
838
|
+
try {
|
|
839
|
+
token = await this.getProactiveToken();
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
process.stderr.write(`[DingTalk:${this.name}] Cannot download media: access token refresh failed.\n`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const robotCode = this.config.clientId;
|
|
846
|
+
if (!robotCode) {
|
|
847
|
+
process.stderr.write(`[DingTalk:${this.name}] Cannot download media: missing robotCode.\n`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const media = await downloadMedia(downloadCode, robotCode, token);
|
|
851
|
+
if (!media)
|
|
852
|
+
return;
|
|
853
|
+
if (mediaType === 'image') {
|
|
854
|
+
const mimeType = media.mimeType.startsWith('image/')
|
|
855
|
+
? media.mimeType
|
|
856
|
+
: 'image/jpeg';
|
|
857
|
+
envelope.attachments = [
|
|
858
|
+
...(envelope.attachments || []),
|
|
859
|
+
{
|
|
860
|
+
type: 'image',
|
|
861
|
+
data: media.buffer.toString('base64'),
|
|
862
|
+
mimeType,
|
|
863
|
+
},
|
|
864
|
+
];
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
// Save non-image files to temp dir so the agent can read them
|
|
868
|
+
const dir = join(tmpdir(), 'channel-files', randomUUID());
|
|
869
|
+
mkdirSync(dir, { recursive: true });
|
|
870
|
+
const safeName = basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`;
|
|
871
|
+
const filePath = join(dir, safeName);
|
|
872
|
+
writeFileSync(filePath, media.buffer);
|
|
873
|
+
// Clean up placeholder text like "(audio)", "(video)", "(file: name)"
|
|
874
|
+
if (envelope.text === `(file: ${fileName || 'file'})` ||
|
|
875
|
+
envelope.text === '(audio)' ||
|
|
876
|
+
envelope.text === '(video)') {
|
|
877
|
+
envelope.text = '';
|
|
878
|
+
}
|
|
879
|
+
envelope.attachments = [
|
|
880
|
+
...(envelope.attachments || []),
|
|
881
|
+
{
|
|
882
|
+
type: mediaType,
|
|
883
|
+
filePath,
|
|
884
|
+
mimeType: media.mimeType,
|
|
885
|
+
fileName: safeName,
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
onMessage(downstream) {
|
|
891
|
+
try {
|
|
892
|
+
const data = typeof downstream.data === 'string'
|
|
893
|
+
? JSON.parse(downstream.data)
|
|
894
|
+
: downstream.data;
|
|
895
|
+
this.logDebugPayload('DingTalk', data);
|
|
896
|
+
const dataMsgId = typeof data.msgId === 'string' ? data.msgId : undefined;
|
|
897
|
+
const headerMsgId = typeof downstream.headers.messageId === 'string'
|
|
898
|
+
? downstream.headers.messageId
|
|
899
|
+
: undefined;
|
|
900
|
+
const msgId = dataMsgId || headerMsgId;
|
|
901
|
+
// Dedup: DingTalk retries unACKed messages
|
|
902
|
+
if (msgId && this.seenMessages.has(msgId)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (msgId) {
|
|
906
|
+
this.seenMessages.set(msgId, Date.now());
|
|
907
|
+
this.rememberInboundMessageId(msgId);
|
|
908
|
+
}
|
|
909
|
+
const isGroup = data.conversationType === '2';
|
|
910
|
+
const sessionWebhook = typeof data.sessionWebhook === 'string'
|
|
911
|
+
? data.sessionWebhook
|
|
912
|
+
: undefined;
|
|
913
|
+
const conversationId = typeof data.conversationId === 'string'
|
|
914
|
+
? data.conversationId
|
|
915
|
+
: undefined;
|
|
916
|
+
const conversationTitle = typeof data.conversationTitle === 'string'
|
|
917
|
+
? data.conversationTitle
|
|
918
|
+
: undefined;
|
|
919
|
+
const isMentioned = Boolean(data.isInAtList);
|
|
920
|
+
const senderNick = typeof data.senderNick === 'string' ? data.senderNick : undefined;
|
|
921
|
+
const senderStaffId = typeof data.senderStaffId === 'string' ? data.senderStaffId : undefined;
|
|
922
|
+
const senderIdValue = typeof data.senderId === 'string' ? data.senderId : undefined;
|
|
923
|
+
if (!sessionWebhook) {
|
|
924
|
+
process.stderr.write(`[DingTalk:${this.name}] No sessionWebhook in message, skipping.\n`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
// A group message with no conversationId can't be routed to a stable
|
|
928
|
+
// session — chatId would fall back to the expiring sessionWebhook and the
|
|
929
|
+
// shared-session key would churn. Drop it rather than fragment the group.
|
|
930
|
+
if (DingtalkChannel.isUnroutableGroupMessage(isGroup, conversationId)) {
|
|
931
|
+
// Include identifying context so an operator can tell whether one sender
|
|
932
|
+
// or every group message is affected if DingTalk starts omitting
|
|
933
|
+
// conversationId (API regression / edge-case message type).
|
|
934
|
+
process.stderr.write(`[DingTalk:${this.name}] Group message has no conversationId, skipping (msgId=${msgId || 'unknown'}, sender=${sanitizeSenderName(senderNick || senderStaffId || 'unknown')})\n`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
// Cache webhook by conversationId so sendMessage can look it up
|
|
938
|
+
if (conversationId) {
|
|
939
|
+
this.webhooks.set(conversationId, sessionWebhook);
|
|
940
|
+
}
|
|
941
|
+
process.stderr.write(`[DingTalk:${this.name}] message msgId=${sanitizeLogText(msgId || 'unknown', 80)} conversationId=${sanitizeLogText(conversationId || '', 120)} isGroup=${isGroup} isMentioned=${isMentioned} senderNick=${sanitizeLogText(senderNick || '', 80)} senderStaffId=${sanitizeLogText(senderStaffId || '', 80)} senderId=${sanitizeLogText(senderIdValue || '', 80)}\n`);
|
|
942
|
+
// Extract text and media info from message
|
|
943
|
+
const content = this.extractContent(data);
|
|
944
|
+
let cleanText = content.text;
|
|
945
|
+
// Strip first @mention (the bot) from text, keep other @mentions intact.
|
|
946
|
+
// Anchor to start-of-string so @ symbols inside URLs or emails
|
|
947
|
+
// (e.g. git@host:path) are not accidentally stripped (#7402).
|
|
948
|
+
if (isMentioned) {
|
|
949
|
+
cleanText = cleanText.replace(/^\s*@[^\s\p{Cf}]+/u, '').trim();
|
|
950
|
+
}
|
|
951
|
+
// Extract quoted message context
|
|
952
|
+
const quoted = this.extractQuotedContext(data);
|
|
953
|
+
const chatId = conversationId || sessionWebhook;
|
|
954
|
+
// After stripping the bot @mention, cleanText may legitimately be empty
|
|
955
|
+
// (user pinged the bot with no other text). Don't fall back to the
|
|
956
|
+
// original text in that case — it would re-introduce the @mention.
|
|
957
|
+
const messageText = isMentioned ? cleanText : cleanText || content.text;
|
|
958
|
+
const envelopeText = isGroup
|
|
959
|
+
? withNonBotMentionContext(data, messageText)
|
|
960
|
+
: messageText;
|
|
961
|
+
const senderId = senderStaffId || senderIdValue || '';
|
|
962
|
+
const senderName = senderNick || senderId || 'Unknown';
|
|
963
|
+
const envelope = {
|
|
964
|
+
channelName: this.name,
|
|
965
|
+
senderId,
|
|
966
|
+
senderName,
|
|
967
|
+
chatId,
|
|
968
|
+
...(isGroup && conversationTitle
|
|
969
|
+
? { chatName: conversationTitle }
|
|
970
|
+
: {}),
|
|
971
|
+
text: envelopeText,
|
|
972
|
+
isGroup,
|
|
973
|
+
isMentioned,
|
|
974
|
+
isReplyToBot: quoted.isReplyToBot,
|
|
975
|
+
referencedText: quoted.referencedText,
|
|
976
|
+
};
|
|
977
|
+
// Reactions are resolved later via the chatId passed to
|
|
978
|
+
// onPromptStart/onPromptEnd — no extra bookkeeping needed.
|
|
979
|
+
envelope.messageId = msgId;
|
|
980
|
+
if (this.atSender && isGroup && senderStaffId) {
|
|
981
|
+
envelope[mentionTarget] = senderStaffId;
|
|
982
|
+
}
|
|
983
|
+
const processMessage = async () => {
|
|
984
|
+
// Download media if present (first downloadCode only for images)
|
|
985
|
+
if (content.downloadCodes.length > 0 && content.mediaType) {
|
|
986
|
+
await this.attachMedia(envelope, content.downloadCodes[0], content.mediaType, content.fileName);
|
|
987
|
+
}
|
|
988
|
+
await this.handleInbound(envelope);
|
|
989
|
+
};
|
|
990
|
+
// Don't await — stream callback should return quickly
|
|
991
|
+
processMessage().catch((err) => {
|
|
992
|
+
process.stderr.write(`[DingTalk:${this.name}] Error handling message: ${err}\n`);
|
|
993
|
+
this.sendMessage(chatId, 'Sorry, something went wrong processing your message.').catch(() => { });
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
process.stderr.write(`[DingTalk:${this.name}] Failed to parse message: ${err}\n`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
//# sourceMappingURL=DingtalkAdapter.js.map
|