@soimy/dingtalk 3.5.1 → 3.5.3
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.md +13 -24
- package/openclaw.plugin.json +695 -0
- package/package.json +12 -7
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -1
- package/src/card/card-action-handler.ts +1 -1
- package/src/card/card-stop-handler.ts +1 -1
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +117 -6
- package/src/card-service.ts +112 -1
- package/src/channel.ts +131 -96
- package/src/command/card-stop-command.ts +4 -22
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/config-schema.ts +62 -38
- package/src/config.ts +25 -3
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +475 -501
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +166 -10
- package/src/message-utils.ts +33 -5
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
- package/src/onboarding.ts +29 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +294 -28
- package/src/reply-strategy-markdown.ts +124 -19
- package/src/reply-strategy.ts +22 -2
- package/src/send-service.ts +178 -7
- package/src/targeting/agent-routing.ts +55 -32
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/types.ts +60 -4
- package/src/utils.ts +190 -0
package/src/logger-context.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import type { Logger } from "./types";
|
|
2
2
|
|
|
3
3
|
let currentLogger: Logger | undefined;
|
|
4
|
+
const loggerByAccountId = new Map<string, Logger>();
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Persist current request logger for shared services invoked outside handler scope.
|
|
7
8
|
*/
|
|
8
|
-
export function setCurrentLogger(log?: Logger): void {
|
|
9
|
+
export function setCurrentLogger(log?: Logger, accountId?: string | null): void {
|
|
9
10
|
currentLogger = log;
|
|
11
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
12
|
+
if (!normalizedAccountId) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (log) {
|
|
16
|
+
loggerByAccountId.set(normalizedAccountId, log);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
loggerByAccountId.delete(normalizedAccountId);
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Read current logger bound by inbound handler.
|
|
14
24
|
*/
|
|
15
|
-
export function getLogger(): Logger | undefined {
|
|
25
|
+
export function getLogger(accountId?: string | null): Logger | undefined {
|
|
26
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
27
|
+
if (normalizedAccountId) {
|
|
28
|
+
return loggerByAccountId.get(normalizedAccountId);
|
|
29
|
+
}
|
|
16
30
|
return currentLogger;
|
|
17
31
|
}
|
package/src/media-utils.ts
CHANGED
|
@@ -11,8 +11,9 @@ import * as path from "node:path";
|
|
|
11
11
|
import { promises as fsPromises } from "node:fs";
|
|
12
12
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
13
13
|
import { BlockList, isIP } from "node:net";
|
|
14
|
-
import axios from "
|
|
14
|
+
import axios from "./http-client";
|
|
15
15
|
import FormData from "form-data";
|
|
16
|
+
import { runFfmpeg, runFfprobe } from "openclaw/plugin-sdk/media-runtime";
|
|
16
17
|
import type { DingTalkConfig, Logger } from "./types";
|
|
17
18
|
import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
|
|
18
19
|
import { getDingTalkRuntime } from "./runtime";
|
|
@@ -26,7 +27,7 @@ interface PluginRuntimeWithMedia {
|
|
|
26
27
|
media?: {
|
|
27
28
|
loadWebMedia(
|
|
28
29
|
mediaPath: string,
|
|
29
|
-
options?: {
|
|
30
|
+
options?: { localRoots?: readonly string[] | "any" },
|
|
30
31
|
): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
|
|
31
32
|
};
|
|
32
33
|
[key: string]: unknown;
|
|
@@ -207,6 +208,122 @@ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, l
|
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
const DEFAULT_VOICE_DURATION_MS = 1000;
|
|
211
|
+
const DINGTALK_VOICE_UPLOAD_EXTENSIONS = new Set([".ogg", ".amr"]);
|
|
212
|
+
const DINGTALK_VOICE_INPUT_EXTENSIONS = new Set([".ogg", ".amr", ".mp3", ".wav"]);
|
|
213
|
+
|
|
214
|
+
async function getDurationMsWithFfprobe(filePath: string, log?: Logger): Promise<number> {
|
|
215
|
+
try {
|
|
216
|
+
const stdout = await runFfprobe([
|
|
217
|
+
"-v",
|
|
218
|
+
"error",
|
|
219
|
+
"-show_entries",
|
|
220
|
+
"format=duration",
|
|
221
|
+
"-of",
|
|
222
|
+
"csv=p=0",
|
|
223
|
+
filePath,
|
|
224
|
+
]);
|
|
225
|
+
const duration = Number.parseFloat(stdout.trim());
|
|
226
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
return Math.max(1, Math.round(duration * 1000));
|
|
230
|
+
} catch (err: unknown) {
|
|
231
|
+
log?.warn?.(`[DingTalk] Failed to probe voice duration: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function prepareVoiceUploadPath(
|
|
237
|
+
mediaPath: string,
|
|
238
|
+
log?: Logger,
|
|
239
|
+
): Promise<{ path: string; cleanup?: () => Promise<void> }> {
|
|
240
|
+
const ext = path.extname(mediaPath).toLowerCase();
|
|
241
|
+
if (DINGTALK_VOICE_UPLOAD_EXTENSIONS.has(ext)) {
|
|
242
|
+
return { path: mediaPath };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const outputPath = path.join(os.tmpdir(), `dingtalk_voice_${randomUUID()}.ogg`);
|
|
246
|
+
await runFfmpeg([
|
|
247
|
+
"-y",
|
|
248
|
+
"-i",
|
|
249
|
+
mediaPath,
|
|
250
|
+
"-vn",
|
|
251
|
+
"-sn",
|
|
252
|
+
"-dn",
|
|
253
|
+
"-ar",
|
|
254
|
+
"16000",
|
|
255
|
+
"-ac",
|
|
256
|
+
"1",
|
|
257
|
+
"-c:a",
|
|
258
|
+
"libopus",
|
|
259
|
+
"-b:a",
|
|
260
|
+
"24k",
|
|
261
|
+
outputPath,
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
log?.debug?.(`[DingTalk] Transcoded voice upload to OGG: ${mediaPath} -> ${outputPath}`);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
path: outputPath,
|
|
268
|
+
cleanup: async () => {
|
|
269
|
+
await fsPromises.rm(outputPath, { force: true });
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getWavDurationMsFromBuffer(buffer: Buffer, log?: Logger): number {
|
|
275
|
+
try {
|
|
276
|
+
if (buffer.length < 44 || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let offset = 12;
|
|
281
|
+
let byteRate = 0;
|
|
282
|
+
let sampleRate = 0;
|
|
283
|
+
let channels = 0;
|
|
284
|
+
let bitsPerSample = 0;
|
|
285
|
+
let dataSize = 0;
|
|
286
|
+
|
|
287
|
+
while (offset + 8 <= buffer.length) {
|
|
288
|
+
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
|
289
|
+
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
290
|
+
const chunkDataStart = offset + 8;
|
|
291
|
+
const paddedChunkSize = chunkSize + (chunkSize % 2);
|
|
292
|
+
|
|
293
|
+
if (chunkDataStart + chunkSize > buffer.length) {
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (chunkId === "fmt " && chunkSize >= 16) {
|
|
298
|
+
channels = buffer.readUInt16LE(chunkDataStart + 2);
|
|
299
|
+
sampleRate = buffer.readUInt32LE(chunkDataStart + 4);
|
|
300
|
+
byteRate = buffer.readUInt32LE(chunkDataStart + 8);
|
|
301
|
+
bitsPerSample = buffer.readUInt16LE(chunkDataStart + 14);
|
|
302
|
+
} else if (chunkId === "data") {
|
|
303
|
+
dataSize = chunkSize;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (dataSize > 0 && (byteRate > 0 || (sampleRate > 0 && channels > 0 && bitsPerSample > 0))) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
offset = chunkDataStart + paddedChunkSize;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const effectiveByteRate = byteRate || (sampleRate > 0 && channels > 0 && bitsPerSample > 0
|
|
314
|
+
? sampleRate * channels * (bitsPerSample / 8)
|
|
315
|
+
: 0);
|
|
316
|
+
|
|
317
|
+
if (effectiveByteRate <= 0 || dataSize <= 0) {
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return Math.max(1, Math.round((dataSize / effectiveByteRate) * 1000));
|
|
322
|
+
} catch (err: unknown) {
|
|
323
|
+
log?.warn?.(`[DingTalk] Failed to parse WAV duration: ${err instanceof Error ? err.message : String(err)}`);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
210
327
|
|
|
211
328
|
export async function getVoiceDurationMs(
|
|
212
329
|
filePath: string,
|
|
@@ -240,6 +357,26 @@ export async function getVoiceDurationMs(
|
|
|
240
357
|
return DEFAULT_VOICE_DURATION_MS;
|
|
241
358
|
}
|
|
242
359
|
|
|
360
|
+
if (ext === ".wav") {
|
|
361
|
+
try {
|
|
362
|
+
const buffer = options?.preReadBuffer
|
|
363
|
+
?? (await readMediaBuffer(filePath, options, log)).buffer;
|
|
364
|
+
const durationMs = getWavDurationMsFromBuffer(buffer, log);
|
|
365
|
+
if (durationMs > 0) {
|
|
366
|
+
return durationMs;
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Fall through to the safe default below.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (ext === ".ogg" || ext === ".amr") {
|
|
374
|
+
const durationMs = await getDurationMsWithFfprobe(filePath, log);
|
|
375
|
+
if (durationMs > 0) {
|
|
376
|
+
return durationMs;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
243
380
|
return DEFAULT_VOICE_DURATION_MS;
|
|
244
381
|
}
|
|
245
382
|
|
|
@@ -339,7 +476,7 @@ function isAllowedByMediaUrlAllowlist(url: URL, mediaUrlAllowlist: string[]): bo
|
|
|
339
476
|
* Detect media type from file extension
|
|
340
477
|
* Matches DingTalk's supported media types:
|
|
341
478
|
* - image: jpg, gif, png, bmp (max 20MB)
|
|
342
|
-
* - voice: amr, mp3, wav (max 2MB)
|
|
479
|
+
* - voice: ogg, amr, mp3, wav (max 2MB)
|
|
343
480
|
* - video: mp4 (max 20MB)
|
|
344
481
|
* - file: doc, docx, xls, xlsx, ppt, pptx, zip, pdf, rar (max 20MB)
|
|
345
482
|
*
|
|
@@ -351,7 +488,7 @@ export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaTyp
|
|
|
351
488
|
|
|
352
489
|
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp"].includes(ext)) {
|
|
353
490
|
return "image";
|
|
354
|
-
} else if ([".mp3", ".amr", ".wav"].includes(ext)) {
|
|
491
|
+
} else if ([".ogg", ".mp3", ".amr", ".wav"].includes(ext)) {
|
|
355
492
|
return "voice";
|
|
356
493
|
} else if ([".mp4", ".avi", ".mov"].includes(ext)) {
|
|
357
494
|
return "video";
|
|
@@ -386,8 +523,8 @@ export function resolveOutboundMediaType(params: {
|
|
|
386
523
|
throw new Error('asVoice requires mediaType="voice" when mediaType is provided.');
|
|
387
524
|
}
|
|
388
525
|
|
|
389
|
-
if (detectedType !== "voice") {
|
|
390
|
-
throw new Error("asVoice requires an audio file (
|
|
526
|
+
if (detectedType !== "voice" || !DINGTALK_VOICE_INPUT_EXTENSIONS.has(path.extname(params.mediaPath).toLowerCase())) {
|
|
527
|
+
throw new Error("asVoice requires an audio file (ogg, amr, mp3, wav).");
|
|
391
528
|
}
|
|
392
529
|
|
|
393
530
|
return "voice";
|
|
@@ -667,7 +804,7 @@ async function readMediaBuffer(
|
|
|
667
804
|
}
|
|
668
805
|
|
|
669
806
|
const media = await rt.media.loadWebMedia(mediaPath, {
|
|
670
|
-
|
|
807
|
+
localRoots: options?.mediaLocalRoots,
|
|
671
808
|
});
|
|
672
809
|
|
|
673
810
|
if (!media || !media.buffer) {
|
|
@@ -687,6 +824,12 @@ export interface UploadMediaResult {
|
|
|
687
824
|
mediaId: string;
|
|
688
825
|
/** The file buffer read during upload, reusable for voice duration parsing etc. */
|
|
689
826
|
buffer: Buffer;
|
|
827
|
+
/**
|
|
828
|
+
* Voice duration captured before any temporary transcoded file is cleaned up.
|
|
829
|
+
* This is the stable field callers should use instead of depending on any
|
|
830
|
+
* upload-time temp path lifecycle.
|
|
831
|
+
*/
|
|
832
|
+
durationMs?: number;
|
|
690
833
|
}
|
|
691
834
|
|
|
692
835
|
export async function uploadMedia(
|
|
@@ -697,11 +840,22 @@ export async function uploadMedia(
|
|
|
697
840
|
log?: Logger,
|
|
698
841
|
options?: { mediaLocalRoots?: string[] },
|
|
699
842
|
): Promise<UploadMediaResult | null> {
|
|
843
|
+
let voicePreparedCleanup: (() => Promise<void>) | undefined;
|
|
700
844
|
try {
|
|
701
845
|
const token = await getAccessToken(config, log);
|
|
846
|
+
let resolvedMediaPath = mediaPath;
|
|
847
|
+
|
|
848
|
+
if (mediaType === "voice") {
|
|
849
|
+
const prepared = await prepareVoiceUploadPath(mediaPath, log);
|
|
850
|
+
resolvedMediaPath = prepared.path;
|
|
851
|
+
voicePreparedCleanup = prepared.cleanup;
|
|
852
|
+
}
|
|
702
853
|
|
|
703
854
|
// Read file via sandbox-aware bridge (falls back to direct fs for host paths)
|
|
704
|
-
const { buffer, size } = await readMediaBuffer(
|
|
855
|
+
const { buffer, size } = await readMediaBuffer(resolvedMediaPath, options, log);
|
|
856
|
+
const durationMs = mediaType === "voice"
|
|
857
|
+
? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, { ...options, preReadBuffer: buffer })
|
|
858
|
+
: undefined;
|
|
705
859
|
|
|
706
860
|
// Check file size
|
|
707
861
|
const sizeLimit = FILE_SIZE_LIMITS[mediaType];
|
|
@@ -714,7 +868,7 @@ export async function uploadMedia(
|
|
|
714
868
|
return null;
|
|
715
869
|
}
|
|
716
870
|
|
|
717
|
-
const filename = path.basename(
|
|
871
|
+
const filename = path.basename(resolvedMediaPath);
|
|
718
872
|
|
|
719
873
|
// Upload to DingTalk's media server using form-data
|
|
720
874
|
const form = new FormData();
|
|
@@ -735,7 +889,7 @@ export async function uploadMedia(
|
|
|
735
889
|
log?.debug?.(
|
|
736
890
|
`[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
|
|
737
891
|
);
|
|
738
|
-
return { mediaId: response.data.media_id, buffer };
|
|
892
|
+
return { mediaId: response.data.media_id, buffer, durationMs };
|
|
739
893
|
} else {
|
|
740
894
|
log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
|
|
741
895
|
return null;
|
|
@@ -758,5 +912,7 @@ export async function uploadMedia(
|
|
|
758
912
|
}
|
|
759
913
|
}
|
|
760
914
|
return null;
|
|
915
|
+
} finally {
|
|
916
|
+
await voicePreparedCleanup?.();
|
|
761
917
|
}
|
|
762
918
|
}
|
package/src/message-utils.ts
CHANGED
|
@@ -98,6 +98,15 @@ function extractRichTextQuoteParts(
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function extractAtMentionsFromText(text: string): AtMention[] {
|
|
102
|
+
const mentions: AtMention[] = [];
|
|
103
|
+
const matches = text.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
|
|
104
|
+
for (const match of matches) {
|
|
105
|
+
mentions.push({ name: match[1].trim() });
|
|
106
|
+
}
|
|
107
|
+
return mentions;
|
|
108
|
+
}
|
|
109
|
+
|
|
101
110
|
function trimString(value: string | undefined): string | undefined {
|
|
102
111
|
if (typeof value !== "string") {
|
|
103
112
|
return undefined;
|
|
@@ -225,6 +234,17 @@ function buildRepliedMessagePreview(params: {
|
|
|
225
234
|
};
|
|
226
235
|
}
|
|
227
236
|
|
|
237
|
+
if (repliedMsgType === "chatRecord") {
|
|
238
|
+
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
239
|
+
const title = typeof content?.title === "string" ? content.title.trim() : "";
|
|
240
|
+
const chatRecordLabel = title ? `[${title}] ` : "[聊天记录] ";
|
|
241
|
+
return {
|
|
242
|
+
previewText: summary ? `${chatRecordLabel}${summary}` : buildQuotedMessageTypePlaceholder("chatRecord"),
|
|
243
|
+
previewMessageType: "chatRecord",
|
|
244
|
+
previewSenderId: trimString(repliedMsg.senderId),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
228
248
|
const textPreview =
|
|
229
249
|
trimString(content?.text) ||
|
|
230
250
|
trimString(richTextQuote?.summary) ||
|
|
@@ -458,11 +478,7 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
458
478
|
|
|
459
479
|
// Strip quoted prefix before extracting @mentions to avoid matching @names inside quotes.
|
|
460
480
|
const textForAtExtraction = textContent.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
461
|
-
|
|
462
|
-
const atMatches = textForAtExtraction.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
|
|
463
|
-
for (const match of atMatches) {
|
|
464
|
-
atMentions.push({ name: match[1].trim() });
|
|
465
|
-
}
|
|
481
|
+
atMentions.push(...extractAtMentionsFromText(textForAtExtraction));
|
|
466
482
|
|
|
467
483
|
return {
|
|
468
484
|
text: textContent || quoted?.previewText || "",
|
|
@@ -553,6 +569,18 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
553
569
|
};
|
|
554
570
|
}
|
|
555
571
|
|
|
572
|
+
if (msgtype === "markdown") {
|
|
573
|
+
const mdText = typeof data.content?.text === "string" ? data.content.text.trim() : "";
|
|
574
|
+
atMentions.push(...extractAtMentionsFromText(mdText));
|
|
575
|
+
return {
|
|
576
|
+
text: mdText || "[markdown消息]",
|
|
577
|
+
messageType: "markdown",
|
|
578
|
+
quoted: quoted ?? undefined,
|
|
579
|
+
atMentions,
|
|
580
|
+
atUserDingtalkIds,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
556
584
|
if (msgtype === "interactiveCard") {
|
|
557
585
|
const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
|
|
558
586
|
if (docMeta) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { AttachmentTextSource } from "
|
|
3
|
+
import type { AttachmentTextSource } from "../types";
|
|
4
4
|
|
|
5
5
|
const MAX_EXTRACTED_TEXT_CHARS = 6000;
|
|
6
6
|
const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
-
import axios from "
|
|
4
|
-
import { getAccessToken } from "
|
|
5
|
-
import { getDingTalkRuntime } from "
|
|
6
|
-
import type { DingTalkConfig, Logger, MediaFile } from "
|
|
7
|
-
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "
|
|
3
|
+
import axios from "../http-client";
|
|
4
|
+
import { getAccessToken } from "../auth";
|
|
5
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
6
|
+
import type { DingTalkConfig, Logger, MediaFile } from "../types";
|
|
7
|
+
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../utils";
|
|
8
8
|
|
|
9
9
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
10
10
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -229,6 +229,7 @@ export async function downloadGroupFile(
|
|
|
229
229
|
dentryId: string,
|
|
230
230
|
unionId: string,
|
|
231
231
|
log?: Logger,
|
|
232
|
+
originalFilename?: string,
|
|
232
233
|
): Promise<MediaFile | null> {
|
|
233
234
|
const rt = getDingTalkRuntime();
|
|
234
235
|
const token = await getAccessToken(config, log);
|
|
@@ -294,9 +295,13 @@ export async function downloadGroupFile(
|
|
|
294
295
|
const maxBytes =
|
|
295
296
|
config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
|
|
296
297
|
try {
|
|
297
|
-
const saved =
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
const saved = await rt.channel.media.saveMediaBuffer(
|
|
299
|
+
buffer,
|
|
300
|
+
contentType,
|
|
301
|
+
"inbound",
|
|
302
|
+
maxBytes,
|
|
303
|
+
originalFilename,
|
|
304
|
+
);
|
|
300
305
|
|
|
301
306
|
return { path: saved.path, mimeType: saved.contentType ?? contentType };
|
|
302
307
|
} catch (err: unknown) {
|
|
@@ -353,7 +358,7 @@ export async function resolveQuotedFile(
|
|
|
353
358
|
}
|
|
354
359
|
|
|
355
360
|
stage = "download-file";
|
|
356
|
-
const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log);
|
|
361
|
+
const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log, match.name);
|
|
357
362
|
if (!media) {
|
|
358
363
|
return null;
|
|
359
364
|
}
|
package/src/onboarding.ts
CHANGED
|
@@ -122,10 +122,12 @@ function applyAccountConfig(params: {
|
|
|
122
122
|
? { groupAllowFrom: input.groupAllowFrom }
|
|
123
123
|
: {}),
|
|
124
124
|
...(input.displayNameResolution ? { displayNameResolution: input.displayNameResolution } : {}),
|
|
125
|
+
...(input.contextVisibility ? { contextVisibility: input.contextVisibility } : {}),
|
|
125
126
|
...(input.mediaUrlAllowlist && input.mediaUrlAllowlist.length > 0
|
|
126
127
|
? { mediaUrlAllowlist: input.mediaUrlAllowlist }
|
|
127
128
|
: {}),
|
|
128
129
|
...(input.messageType ? { messageType: input.messageType } : {}),
|
|
130
|
+
...(input.cardStreamingMode ? { cardStreamingMode: input.cardStreamingMode } : {}),
|
|
129
131
|
...(typeof input.maxReconnectCycles === "number"
|
|
130
132
|
? { maxReconnectCycles: input.maxReconnectCycles }
|
|
131
133
|
: {}),
|
|
@@ -233,6 +235,7 @@ async function configureDingTalkAccount(params: {
|
|
|
233
235
|
});
|
|
234
236
|
|
|
235
237
|
let messageType: "markdown" | "card" = "markdown";
|
|
238
|
+
let cardStreamingMode: DingTalkConfig["cardStreamingMode"];
|
|
236
239
|
|
|
237
240
|
if (wantsCardMode) {
|
|
238
241
|
await prompter.note(
|
|
@@ -244,6 +247,15 @@ async function configureDingTalkAccount(params: {
|
|
|
244
247
|
"Built-in AI Card Template",
|
|
245
248
|
);
|
|
246
249
|
messageType = "card";
|
|
250
|
+
cardStreamingMode = (await prompter.select({
|
|
251
|
+
message: "Card streaming mode",
|
|
252
|
+
options: [
|
|
253
|
+
{ label: "Off - answer does not stream incrementally", value: "off" },
|
|
254
|
+
{ label: "Answer - only answer streams incrementally", value: "answer" },
|
|
255
|
+
{ label: "All - answer and thinking stream incrementally", value: "all" },
|
|
256
|
+
],
|
|
257
|
+
initialValue: resolved.cardStreamingMode ?? (resolved.cardRealTimeStream ? "all" : "off"),
|
|
258
|
+
})) as DingTalkConfig["cardStreamingMode"];
|
|
247
259
|
}
|
|
248
260
|
|
|
249
261
|
const dmPolicyValue = await prompter.select({
|
|
@@ -333,6 +345,22 @@ async function configureDingTalkAccount(params: {
|
|
|
333
345
|
initialValue: resolved.displayNameResolution ?? "disabled",
|
|
334
346
|
});
|
|
335
347
|
|
|
348
|
+
await prompter.note(
|
|
349
|
+
[
|
|
350
|
+
"Advanced host context visibility is available as channels.dingtalk.contextVisibility.",
|
|
351
|
+
"Recommended advanced mode: allowlist_quote.",
|
|
352
|
+
"Modes:",
|
|
353
|
+
"- all: preserve the current host supplemental-context behavior",
|
|
354
|
+
"- allowlist: keep only host allowlisted supplemental context",
|
|
355
|
+
"- allowlist_quote: keep explicit quote/reply context while filtering extra context",
|
|
356
|
+
"This is separate from displayNameResolution and should be set manually if needed.",
|
|
357
|
+
resolved.contextVisibility
|
|
358
|
+
? `Current resolved value: ${resolved.contextVisibility}`
|
|
359
|
+
: "Current resolved value: host default",
|
|
360
|
+
].join("\n"),
|
|
361
|
+
"Advanced context visibility",
|
|
362
|
+
);
|
|
363
|
+
|
|
336
364
|
let maxReconnectCycles: number | undefined;
|
|
337
365
|
const wantsReconnectLimits = await prompter.confirm({
|
|
338
366
|
message: "Configure runtime reconnect cycle limit? (recommended)",
|
|
@@ -410,6 +438,7 @@ async function configureDingTalkAccount(params: {
|
|
|
410
438
|
displayNameResolution: displayNameResolutionValue as "disabled" | "all",
|
|
411
439
|
mediaUrlAllowlist,
|
|
412
440
|
messageType,
|
|
441
|
+
cardStreamingMode,
|
|
413
442
|
maxReconnectCycles,
|
|
414
443
|
mediaMaxMb,
|
|
415
444
|
journalTTLDays,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
|
|
2
|
+
|
|
3
|
+
type JsonResultReturn = NonNullable<ChannelMessageActionAdapter["handleAction"]> extends (
|
|
4
|
+
...args: unknown[]
|
|
5
|
+
) => Promise<infer TResult>
|
|
6
|
+
? TResult
|
|
7
|
+
: never;
|
|
8
|
+
|
|
9
|
+
declare module "openclaw/plugin-sdk/channel-actions" {
|
|
10
|
+
export function jsonResult(payload: unknown): JsonResultReturn;
|
|
11
|
+
}
|