@soimy/dingtalk 3.5.2 → 3.6.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/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
package/src/media-utils.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { lookup as dnsLookup } from "node:dns/promises";
|
|
|
13
13
|
import { BlockList, isIP } from "node:net";
|
|
14
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";
|
|
@@ -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";
|
|
@@ -397,6 +534,12 @@ export function resolveOutboundMediaType(params: {
|
|
|
397
534
|
return explicitType;
|
|
398
535
|
}
|
|
399
536
|
|
|
537
|
+
// Audio files default to "file" (attachment) unless asVoice is explicitly set.
|
|
538
|
+
// This prevents mp3/wav/ogg/amr from being sent as voice messages unexpectedly.
|
|
539
|
+
if (detectedType === "voice") {
|
|
540
|
+
return "file";
|
|
541
|
+
}
|
|
542
|
+
|
|
400
543
|
return detectedType;
|
|
401
544
|
}
|
|
402
545
|
|
|
@@ -687,6 +830,12 @@ export interface UploadMediaResult {
|
|
|
687
830
|
mediaId: string;
|
|
688
831
|
/** The file buffer read during upload, reusable for voice duration parsing etc. */
|
|
689
832
|
buffer: Buffer;
|
|
833
|
+
/**
|
|
834
|
+
* Voice duration captured before any temporary transcoded file is cleaned up.
|
|
835
|
+
* This is the stable field callers should use instead of depending on any
|
|
836
|
+
* upload-time temp path lifecycle.
|
|
837
|
+
*/
|
|
838
|
+
durationMs?: number;
|
|
690
839
|
}
|
|
691
840
|
|
|
692
841
|
export async function uploadMedia(
|
|
@@ -697,11 +846,22 @@ export async function uploadMedia(
|
|
|
697
846
|
log?: Logger,
|
|
698
847
|
options?: { mediaLocalRoots?: string[] },
|
|
699
848
|
): Promise<UploadMediaResult | null> {
|
|
849
|
+
let voicePreparedCleanup: (() => Promise<void>) | undefined;
|
|
700
850
|
try {
|
|
701
851
|
const token = await getAccessToken(config, log);
|
|
852
|
+
let resolvedMediaPath = mediaPath;
|
|
853
|
+
|
|
854
|
+
if (mediaType === "voice") {
|
|
855
|
+
const prepared = await prepareVoiceUploadPath(mediaPath, log);
|
|
856
|
+
resolvedMediaPath = prepared.path;
|
|
857
|
+
voicePreparedCleanup = prepared.cleanup;
|
|
858
|
+
}
|
|
702
859
|
|
|
703
860
|
// Read file via sandbox-aware bridge (falls back to direct fs for host paths)
|
|
704
|
-
const { buffer, size } = await readMediaBuffer(
|
|
861
|
+
const { buffer, size } = await readMediaBuffer(resolvedMediaPath, options, log);
|
|
862
|
+
const durationMs = mediaType === "voice"
|
|
863
|
+
? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, { ...options, preReadBuffer: buffer })
|
|
864
|
+
: undefined;
|
|
705
865
|
|
|
706
866
|
// Check file size
|
|
707
867
|
const sizeLimit = FILE_SIZE_LIMITS[mediaType];
|
|
@@ -714,7 +874,7 @@ export async function uploadMedia(
|
|
|
714
874
|
return null;
|
|
715
875
|
}
|
|
716
876
|
|
|
717
|
-
const filename = path.basename(
|
|
877
|
+
const filename = path.basename(resolvedMediaPath);
|
|
718
878
|
|
|
719
879
|
// Upload to DingTalk's media server using form-data
|
|
720
880
|
const form = new FormData();
|
|
@@ -735,7 +895,7 @@ export async function uploadMedia(
|
|
|
735
895
|
log?.debug?.(
|
|
736
896
|
`[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
|
|
737
897
|
);
|
|
738
|
-
return { mediaId: response.data.media_id, buffer };
|
|
898
|
+
return { mediaId: response.data.media_id, buffer, durationMs };
|
|
739
899
|
} else {
|
|
740
900
|
log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
|
|
741
901
|
return null;
|
|
@@ -758,5 +918,7 @@ export async function uploadMedia(
|
|
|
758
918
|
}
|
|
759
919
|
}
|
|
760
920
|
return null;
|
|
921
|
+
} finally {
|
|
922
|
+
await voicePreparedCleanup?.();
|
|
761
923
|
}
|
|
762
924
|
}
|
package/src/message-utils.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AtMention,
|
|
3
|
+
DingTalkInboundMessage,
|
|
4
|
+
MessageContent,
|
|
5
|
+
QuotedInfo,
|
|
6
|
+
SendMessageOptions,
|
|
7
|
+
} from "./types";
|
|
3
8
|
|
|
4
9
|
interface DingTalkDocMeta {
|
|
5
10
|
spaceId: string;
|
|
6
11
|
fileId: string;
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
const UNKNOWN_PERSON_LABEL = "某人";
|
|
15
|
+
|
|
9
16
|
function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
|
|
10
17
|
if (!url || typeof url !== "string") {
|
|
11
18
|
return null;
|
|
@@ -76,7 +83,7 @@ function extractRichTextQuoteParts(
|
|
|
76
83
|
? part.atName
|
|
77
84
|
: typeof textValue === "string"
|
|
78
85
|
? textValue
|
|
79
|
-
:
|
|
86
|
+
: UNKNOWN_PERSON_LABEL;
|
|
80
87
|
textParts.push(`@${atName}`);
|
|
81
88
|
continue;
|
|
82
89
|
}
|
|
@@ -94,10 +101,20 @@ function extractRichTextQuoteParts(
|
|
|
94
101
|
return {
|
|
95
102
|
summary,
|
|
96
103
|
pictureDownloadCode,
|
|
97
|
-
pictureDownloadCodes:
|
|
104
|
+
pictureDownloadCodes:
|
|
105
|
+
uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
|
|
98
106
|
};
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
function extractAtMentionsFromText(text: string): AtMention[] {
|
|
110
|
+
const mentions: AtMention[] = [];
|
|
111
|
+
const matches = text.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
|
|
112
|
+
for (const match of matches) {
|
|
113
|
+
mentions.push({ name: match[1].trim() });
|
|
114
|
+
}
|
|
115
|
+
return mentions;
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
function trimString(value: string | undefined): string | undefined {
|
|
102
119
|
if (typeof value !== "string") {
|
|
103
120
|
return undefined;
|
|
@@ -106,7 +123,101 @@ function trimString(value: string | undefined): string | undefined {
|
|
|
106
123
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
|
|
126
|
+
const MAX_CHAT_RECORD_ENTRIES = 30;
|
|
127
|
+
|
|
128
|
+
function stringifyChatRecordContentValue(value: unknown): string | undefined {
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
return trimString(value);
|
|
131
|
+
}
|
|
132
|
+
if (!value || typeof value !== "object") {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const record = value as Record<string, unknown>;
|
|
136
|
+
const content = record.content;
|
|
137
|
+
const contentText =
|
|
138
|
+
typeof content === "string"
|
|
139
|
+
? trimString(content)
|
|
140
|
+
: content && typeof content === "object"
|
|
141
|
+
? trimString((content as Record<string, unknown>).text as string | undefined)
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
// Preserve DingTalk's observed chatRecord text priority: explicit text first,
|
|
145
|
+
// then nested content text, then the legacy message fallback.
|
|
146
|
+
return (
|
|
147
|
+
trimString(record.text as string | undefined) ||
|
|
148
|
+
contentText ||
|
|
149
|
+
trimString(record.message as string | undefined)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getChatRecordEntriesSource(content: Record<string, unknown> | undefined): unknown {
|
|
154
|
+
return content?.chatRecord ?? content?.records ?? content?.messages;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatChatRecordEntries(rawRecord: unknown): string[] {
|
|
158
|
+
let entries = rawRecord;
|
|
159
|
+
if (typeof rawRecord === "string") {
|
|
160
|
+
const trimmed = rawRecord.trim();
|
|
161
|
+
if (!trimmed || trimmed === "[]") {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
entries = JSON.parse(trimmed);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(entries)) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return entries
|
|
174
|
+
.map((entry) => {
|
|
175
|
+
if (!entry || typeof entry !== "object") {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const record = entry as Record<string, unknown>;
|
|
179
|
+
const sender =
|
|
180
|
+
trimString(record.senderName as string | undefined) ||
|
|
181
|
+
trimString(record.senderNick as string | undefined) ||
|
|
182
|
+
trimString(record.sender as string | undefined) ||
|
|
183
|
+
trimString(record.senderId as string | undefined) ||
|
|
184
|
+
UNKNOWN_PERSON_LABEL;
|
|
185
|
+
const body = stringifyChatRecordContentValue(
|
|
186
|
+
record.content ?? record.text ?? record.message ?? record.body,
|
|
187
|
+
);
|
|
188
|
+
return body ? `${sender}: ${body}` : undefined;
|
|
189
|
+
})
|
|
190
|
+
.filter((line): line is string => Boolean(line))
|
|
191
|
+
.slice(0, MAX_CHAT_RECORD_ENTRIES);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatChatRecordPreview(
|
|
195
|
+
content: Record<string, unknown> | undefined,
|
|
196
|
+
options: { useTitleAsLabel?: boolean } = {},
|
|
197
|
+
): string | undefined {
|
|
198
|
+
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
199
|
+
const title = typeof content?.title === "string" ? content.title.trim() : "";
|
|
200
|
+
const rawRecord = getChatRecordEntriesSource(content);
|
|
201
|
+
const recordLines = formatChatRecordEntries(rawRecord);
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
if (summary && summary !== "[]") {
|
|
204
|
+
const label = options.useTitleAsLabel
|
|
205
|
+
? title
|
|
206
|
+
? `[${title}] `
|
|
207
|
+
: "[聊天记录] "
|
|
208
|
+
: "[聊天记录摘要] ";
|
|
209
|
+
parts.push(`${label}${summary}`);
|
|
210
|
+
}
|
|
211
|
+
if (recordLines.length > 0) {
|
|
212
|
+
parts.push(`[聊天记录内容]\n${recordLines.join("\n")}`);
|
|
213
|
+
}
|
|
214
|
+
return parts.join("\n\n") || undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildQuotedMessageTypePlaceholder(
|
|
218
|
+
messageType: string | undefined,
|
|
219
|
+
fileName?: string,
|
|
220
|
+
): string | undefined {
|
|
110
221
|
switch (messageType) {
|
|
111
222
|
case "text":
|
|
112
223
|
return undefined;
|
|
@@ -138,8 +249,7 @@ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMe
|
|
|
138
249
|
const previewMessageType = trimString(message?.msgtype);
|
|
139
250
|
return {
|
|
140
251
|
previewText:
|
|
141
|
-
trimString(message?.text?.content) ||
|
|
142
|
-
buildQuotedMessageTypePlaceholder(previewMessageType),
|
|
252
|
+
trimString(message?.text?.content) || buildQuotedMessageTypePlaceholder(previewMessageType),
|
|
143
253
|
previewMessageType,
|
|
144
254
|
previewSenderId: trimString(message?.senderId),
|
|
145
255
|
};
|
|
@@ -193,7 +303,10 @@ function buildRepliedMessagePreview(params: {
|
|
|
193
303
|
return {
|
|
194
304
|
isQuotedFile: true,
|
|
195
305
|
fileCreatedAt: repliedMsg.createdAt,
|
|
196
|
-
previewText: buildQuotedMessageTypePlaceholder(
|
|
306
|
+
previewText: buildQuotedMessageTypePlaceholder(
|
|
307
|
+
repliedMsgType,
|
|
308
|
+
hasFileName ? fileName : undefined,
|
|
309
|
+
),
|
|
197
310
|
previewMessageType: repliedMsgType,
|
|
198
311
|
...(hasFileName ? { previewFileName: fileName } : {}),
|
|
199
312
|
previewSenderId: trimString(repliedMsg.senderId),
|
|
@@ -225,6 +338,17 @@ function buildRepliedMessagePreview(params: {
|
|
|
225
338
|
};
|
|
226
339
|
}
|
|
227
340
|
|
|
341
|
+
if (repliedMsgType === "chatRecord") {
|
|
342
|
+
return {
|
|
343
|
+
previewText:
|
|
344
|
+
formatChatRecordPreview(content as Record<string, unknown> | undefined, {
|
|
345
|
+
useTitleAsLabel: true,
|
|
346
|
+
}) || buildQuotedMessageTypePlaceholder("chatRecord"),
|
|
347
|
+
previewMessageType: "chatRecord",
|
|
348
|
+
previewSenderId: trimString(repliedMsg.senderId),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
228
352
|
const textPreview =
|
|
229
353
|
trimString(content?.text) ||
|
|
230
354
|
trimString(richTextQuote?.summary) ||
|
|
@@ -458,11 +582,7 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
458
582
|
|
|
459
583
|
// Strip quoted prefix before extracting @mentions to avoid matching @names inside quotes.
|
|
460
584
|
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
|
-
}
|
|
585
|
+
atMentions.push(...extractAtMentionsFromText(textForAtExtraction));
|
|
466
586
|
|
|
467
587
|
return {
|
|
468
588
|
text: textContent || quoted?.previewText || "",
|
|
@@ -501,7 +621,10 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
501
621
|
mediaPath: pictureDownloadCode,
|
|
502
622
|
mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
|
|
503
623
|
mediaType: pictureDownloadCode ? "image" : undefined,
|
|
504
|
-
mediaTypes:
|
|
624
|
+
mediaTypes:
|
|
625
|
+
uniquePictureDownloadCodes.length > 0
|
|
626
|
+
? uniquePictureDownloadCodes.map(() => "image")
|
|
627
|
+
: undefined,
|
|
505
628
|
messageType: "richText",
|
|
506
629
|
quoted: quoted ?? undefined,
|
|
507
630
|
atMentions,
|
|
@@ -553,6 +676,18 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
553
676
|
};
|
|
554
677
|
}
|
|
555
678
|
|
|
679
|
+
if (msgtype === "markdown") {
|
|
680
|
+
const mdText = typeof data.content?.text === "string" ? data.content.text.trim() : "";
|
|
681
|
+
atMentions.push(...extractAtMentionsFromText(mdText));
|
|
682
|
+
return {
|
|
683
|
+
text: mdText || "[markdown消息]",
|
|
684
|
+
messageType: "markdown",
|
|
685
|
+
quoted: quoted ?? undefined,
|
|
686
|
+
atMentions,
|
|
687
|
+
atUserDingtalkIds,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
556
691
|
if (msgtype === "interactiveCard") {
|
|
557
692
|
const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
|
|
558
693
|
if (docMeta) {
|
|
@@ -577,7 +712,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
577
712
|
if (msgtype === "chatRecord") {
|
|
578
713
|
const content = data.content as Record<string, unknown> | undefined;
|
|
579
714
|
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
580
|
-
const rawRecord = content
|
|
715
|
+
const rawRecord = getChatRecordEntriesSource(content);
|
|
716
|
+
const chatRecordText = formatChatRecordPreview(content);
|
|
581
717
|
if (
|
|
582
718
|
summary === "[]" ||
|
|
583
719
|
(typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
|
|
@@ -591,9 +727,9 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
591
727
|
atUserDingtalkIds,
|
|
592
728
|
};
|
|
593
729
|
}
|
|
594
|
-
if (
|
|
730
|
+
if (chatRecordText) {
|
|
595
731
|
return {
|
|
596
|
-
text:
|
|
732
|
+
text: chatRecordText,
|
|
597
733
|
messageType: "chatRecord",
|
|
598
734
|
quoted: quoted ?? undefined,
|
|
599
735
|
atMentions,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { sendMessage } from "../send-service";
|
|
2
|
+
import type { DingTalkConfig, Logger } from "../types";
|
|
3
|
+
|
|
4
|
+
const MAX_QUESTION_LENGTH = 80;
|
|
5
|
+
const LEADING_MENTIONS_RE = /^(?:@\S+\s+)*/u;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Strip leading `@mention` tokens from inbound text. Used by both the abort and
|
|
9
|
+
* BTW bypass branches in `inbound-handler.ts` so that command detection works
|
|
10
|
+
* uniformly in DM and group chats.
|
|
11
|
+
*/
|
|
12
|
+
export function stripLeadingMentions(text: string): string {
|
|
13
|
+
return text.replace(LEADING_MENTIONS_RE, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildBtwBlockquote(senderName: string, rawQuestion: string): string {
|
|
17
|
+
const stripped = stripLeadingMentions(rawQuestion);
|
|
18
|
+
// Iterate by Unicode code points (not UTF-16 code units) so emoji /
|
|
19
|
+
// surrogate pairs aren't sliced in half at the truncation boundary.
|
|
20
|
+
const codePoints = Array.from(stripped);
|
|
21
|
+
const truncated =
|
|
22
|
+
codePoints.length > MAX_QUESTION_LENGTH
|
|
23
|
+
? `${codePoints.slice(0, MAX_QUESTION_LENGTH).join("")}…`
|
|
24
|
+
: stripped;
|
|
25
|
+
const senderPrefix = senderName ? `${senderName}: ` : "";
|
|
26
|
+
return `> ${senderPrefix}${truncated}\n\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DeliverBtwReplyArgs {
|
|
30
|
+
config: DingTalkConfig;
|
|
31
|
+
sessionWebhook: string | undefined;
|
|
32
|
+
conversationId: string;
|
|
33
|
+
to: string;
|
|
34
|
+
senderName: string;
|
|
35
|
+
rawQuestion: string;
|
|
36
|
+
replyText: string;
|
|
37
|
+
log: Logger | undefined;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
storePath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deliver a BTW reply through the unified `sendMessage` entry point.
|
|
44
|
+
*
|
|
45
|
+
* BTW is a special inbound trigger, but the *outbound* reply is still a regular
|
|
46
|
+
* markdown/text message and must inherit the standard send-service semantics:
|
|
47
|
+
* persistence into the message context store, delivery metadata tracking, and
|
|
48
|
+
* the single `{ ok, error, ... }` contract. We pass `forceMarkdown: true` so
|
|
49
|
+
* that `sendMessage` skips the card branch even when the channel is configured
|
|
50
|
+
* for card mode — BTW must never create or touch an AI Card (see CLAUDE.md
|
|
51
|
+
* anti-pattern: "Do not create multiple active AI Cards for the same
|
|
52
|
+
* `accountId:conversationId`").
|
|
53
|
+
*
|
|
54
|
+
* When `sessionWebhook` is present `sendMessage` internally dispatches via
|
|
55
|
+
* `sendBySession`; otherwise it falls back to the proactive text/markdown API.
|
|
56
|
+
* Either way the caller sees the same return shape, and failures propagate as
|
|
57
|
+
* `{ ok: false }` instead of being silently swallowed.
|
|
58
|
+
*/
|
|
59
|
+
export async function deliverBtwReply(
|
|
60
|
+
args: DeliverBtwReplyArgs,
|
|
61
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
62
|
+
const blockquote = buildBtwBlockquote(args.senderName, args.rawQuestion);
|
|
63
|
+
const fullText = `${blockquote}${args.replyText}`;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await sendMessage(args.config, args.to, fullText, {
|
|
67
|
+
log: args.log,
|
|
68
|
+
accountId: args.accountId,
|
|
69
|
+
storePath: args.storePath,
|
|
70
|
+
conversationId: args.conversationId,
|
|
71
|
+
sessionWebhook: args.sessionWebhook,
|
|
72
|
+
forceMarkdown: true,
|
|
73
|
+
});
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
args.log?.warn?.(
|
|
76
|
+
`[DingTalk] BTW reply delivery returned not-ok: ${result.error ?? "unknown"}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { ok: result.ok, error: result.error };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
82
|
+
args.log?.warn?.(`[DingTalk] BTW reply delivery threw: ${error}`);
|
|
83
|
+
return { ok: false, error };
|
|
84
|
+
}
|
|
85
|
+
}
|